ADR-0024: Per-org local-time scheduling via Go-side time.LoadLocation
Status
AcceptedTags
cron, scheduling, timezones, fieldforce, briefingDecision
When a cron pipeline needs to fire at a per-org local time (e.g., “send daily briefing at 08:00 in the org’s timezone”), the eligibility check MUST be split:- SQL layer — apply only cheap, tz-independent filters: module enabled, feature flag on, today’s row exists (with a
±1 dayUTC window to cover all offsets). Returns a candidate set. - Go layer — for each candidate, compute the org’s local time using
time.LoadLocation(orgTimezone)and compare against the configured schedule. Decide eligibility in pure Go.
AT TIME ZONE arithmetic in SQL is forbidden for scheduling decisions. JSONB-extraction-with-timezone-cast inside a WHERE clause is forbidden.
This applies to any future per-org local-time cron. The first consumer is Phase 4 fieldforce briefings.
Why
Per-org timezones live insideorg_module_configs.config_json (per ADR-0019) — they are JSONB fields, not first-class columns. Expressing “is now() ≥ schedule_time in org’s tz?” purely in SQL would require:
time.LoadLocation — the same IANA database, but with three benefits the SQL form lacks:
- Testable as a pure function. No database fixture needed.
- Failure-isolated per org. A malformed tz string for one org returns an error for that org; the rest of the tick proceeds.
- One implementation site. Every future “daily at X local time” feature uses the same helper, so the DST and IANA-correctness story is told once.
AT TIME ZONEin SQL. Distributes timezone math across every scheduling query and every feature’s repo layer. Rejected.- Per-org tick goroutine. One goroutine per org, each with its own ticker keyed to local schedule. Avoids the candidate scan but explodes with org count. Rejected.
- First-class
timezonecolumn onorganizations. Would simplify the SQL slightly but breaks the ADR-0019 decision to keep per-module config insideorg_module_configs. Rejected for consistency.
How it works
The repo returns a candidate set with cheap filters applied:BETWEEN current_date - 1 AND current_date + 1 window covers every realistic UTC-offset boundary so the LEFT JOIN finds the relevant row regardless of which side of UTC midnight the org sits on.
Go layer (in the job, not the repo):
briefing_date semantics: the value stored is whatever the org’s tz says at the moment the row is created. No retroactive remapping if the org changes timezone later — existing rows keep their original date.
Known limitations
- The candidate scan over
org_module_configsis a sequential scan with JSONB extraction (cheap fields). At realistic org counts (≤ low thousands) this is invisible; at much larger counts a derived index or denormalized column may be warranted. time.LoadLocationrequires the Go binary’s tzdata or the OS tzdata to be present. The Dockerfile already copies tzdata; new build pipelines must preserve this.- An org with an invalid tz string is silently skipped (with a warning log). There is no UI gate today preventing admins from saving a bad value — Phase 4 admin UI should validate against
time.LoadLocationclient-side, but the worker is the safety net.
Rules for agents
- New per-org local-time cron features MUST place tz arithmetic in Go using
time.LoadLocation, not in SQL. - The repo MUST return a candidate set; eligibility decisions belong to the use case / job layer.
- The candidate scan MUST use a
±1 dayUTC window when joining “today’s” row to cover offset boundaries. - A snapshot date column (e.g.,
briefing_date) stores the org-local date at creation time and is never remapped.