ruby sql 72 lines · 3 tabs

Time Zone Safe Scheduling

Shared by codesnips Jan 2026
3 tabs
class Reminder < ApplicationRecord
  belongs_to :user

  validates :time_zone, inclusion: { in: ActiveSupport::TimeZone::MAPPING.values }
  validates :local_time, presence: true

  scope :active, -> { where(active: true) }
  scope :due, -> { active.where("next_run_at <= ?", Time.current) }

  before_validation :ensure_next_run_at, on: :create

  def zone
    ActiveSupport::TimeZone[time_zone]
  end

  def reschedule!
    update!(next_run_at: compute_next_run_at(after: zone.now))
  end

  def compute_next_run_at(after: nil)
    reference = after || zone.now
    candidate = reference.change(hour: local_time.hour, min: local_time.min, sec: 0)
    candidate += 1.day if candidate <= reference
    candidate.utc
  end

  private

  def ensure_next_run_at
    self.next_run_at ||= compute_next_run_at
  end
end
3 files · ruby, sql Explain with highlit

This snippet shows how to schedule recurring reminders that fire at the right wall-clock time for each user, regardless of the server's zone or daylight-saving shifts. The core mistake in naive scheduling is storing a user's "9:00 AM" as an absolute UTC instant computed once; when DST changes, that instant drifts to 8:00 or 10:00 local. The fix is to persist the intent (a local time-of-day plus an IANA zone) and recompute the next absolute instant in that zone every time.

In Reminder model, the record stores local_time (a time column read as hour/minute), time_zone (an IANA identifier like America/Sao_Paulo), and next_run_at (a UTC timestamp). The zone helper wraps ActiveSupport::TimeZone, and compute_next_run_at does the real work: it takes "now" in the user's zone, builds a candidate at the requested hour and minute using change, and rolls forward a day if that moment already passed. Crucially it calls .utc only at the very end, so all arithmetic happens in local time where DST is handled correctly by ActiveSupport. The due scope selects rows whose next_run_at has arrived.

Because next_run_at is always derived, a spring-forward day that skips 2:00–3:00 AM won't produce a duplicated or missing fire — the zone object resolves the nearest valid local time. Storing the zone per row rather than globally means each user keeps their own schedule even as they travel or the app serves multiple regions.

In ReminderScheduler job, a periodic worker loads Reminder.due, and for each one uses with_advisory_lock plus a stale re-check to guarantee only one process delivers a given reminder — important when multiple Sidekiq workers poll the same table. After enqueueing delivery it calls reschedule!, which recomputes next_run_at from the same intent so the next occurrence lands on tomorrow's local local_time.

The schema.sql tab shows the backing table: a partial index on next_run_at limited to active rows keeps the due polling query fast even with millions of reminders. The trade-off of this recompute-on-demand approach is a small polling query cost, but it buys correctness across DST, leap scheduling, and per-user zones — far safer than precomputing a long horizon of UTC instants that silently rot.


Related snips

Share this code

Here's the card — post it anywhere.

Time Zone Safe Scheduling — share card
Link copied