From 1fb431e8da2748ae96685ba45ef2be1a96e3c203 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Mon, 15 Jun 2026 07:43:01 -0400 Subject: [PATCH] refactor(18): IN-03 memoize household timezone per outbox drain cycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The UPDATE and CREATE all-day branches each called getHouseholdTimezone(db) independently, so a drain processing both an all-day create row and an all-day update row issued two identical app_config SELECTs. Add a lazy per-cycle TimezoneResolver (mirroring the existing clientCache thread-through) created in runOutboxDrain and passed into dispatchRow. The read stays lazy — cycles with no all-day work never touch the DB — but is shared across all all-day rows in a cycle. Behavior unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/api/src/broker/outboxWorker.ts | 42 +++++++++++++++++++++++------ 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/apps/api/src/broker/outboxWorker.ts b/apps/api/src/broker/outboxWorker.ts index 2666f43..53c3abd 100644 --- a/apps/api/src/broker/outboxWorker.ts +++ b/apps/api/src/broker/outboxWorker.ts @@ -373,7 +373,31 @@ interface DispatchResult { error?: string; } -async function dispatchRow(row: OutboxRow): Promise { +/** + * Lazily resolves the household timezone at most once, caching the promise. + * Threaded through a drain cycle (mirroring clientCache, IN-01) so an all-day + * create row and an all-day update row in the same cycle share a single + * app_config read instead of issuing two identical SELECTs (IN-03). The read is + * still lazy: cycles with no all-day work never touch the DB. + */ +type TimezoneResolver = () => Promise; + +function makeTimezoneResolver(): TimezoneResolver { + let cached: Promise | undefined; + return () => { + if (cached === undefined) { + // D-05: route through the single stored-TZ accessor (no inline fallback duplicated here). + // D-06: getHouseholdTimezone falls back to process.env.TZ → Intl when no row is stored. + cached = getHouseholdTimezone(db); + } + return cached; + }; +} + +async function dispatchRow( + row: OutboxRow, + resolveTimezone: TimezoneResolver, +): Promise { // CR-03: fail closed on credential errors — let loadClientForUser throw. // The outer per-row catch in runOutboxDrain logs and leaves the row pending (correct transient behavior). // Do NOT add an empty-credential fallback — that would silently PUT with no authentication. @@ -499,9 +523,8 @@ async function dispatchRow(row: OutboxRow): Promise { fields.allDay && fields.start ) { - // D-05: route through the single stored-TZ accessor (no inline fallback duplicated here). - // D-06: getHouseholdTimezone falls back to process.env.TZ → Intl when no row is stored. - const tz = await getHouseholdTimezone(db); + // IN-03: shared per-cycle resolver — one app_config read across all-day rows. + const tz = await resolveTimezone(); const leadDays = fields.reminderLeadMinutes / 1440; allDayAlertInstantUtcUpdate = computeAlertInstantUtc(fields.start, leadDays, tz); } @@ -607,9 +630,8 @@ async function dispatchRow(row: OutboxRow): Promise { // carries an explicit picker value or no reminder at all; no preserve path needed). let allDayAlertInstantUtcCreate: Date | undefined; if (fields.reminderLeadMinutes != null && fields.allDay && fields.start) { - // D-05: route through the single stored-TZ accessor (no inline fallback duplicated here). - // D-06: getHouseholdTimezone falls back to process.env.TZ → Intl when no row is stored. - const tz = await getHouseholdTimezone(db); + // IN-03: shared per-cycle resolver — one app_config read across all-day rows. + const tz = await resolveTimezone(); const leadDays = fields.reminderLeadMinutes / 1440; allDayAlertInstantUtcCreate = computeAlertInstantUtc(fields.start, leadDays, tz); } @@ -747,6 +769,10 @@ export async function runOutboxDrain(): Promise { // credential at most once per cycle. Discarded when the drain returns — never persisted. const clientCache = new Map(); + // IN-03: per-drain-cycle timezone resolver so multiple all-day rows in the same cycle + // share one app_config read. Lazy: cycles with no all-day work never hit the DB. + const resolveTimezone = makeTimezoneResolver(); + for (const row of sorted) { // D-04 fast path: if the create for this group already failed in this batch, skip the delete if (row.operation === 'delete' && row.groupId && failedCreateGroups.has(row.groupId)) { @@ -793,7 +819,7 @@ export async function runOutboxDrain(): Promise { } try { - const result = await dispatchRow(row); + const result = await dispatchRow(row, resolveTimezone); if (result.conflict) { // WR-06: distinguish an edit-as-move create-412 from a same-calendar conflict.