refactor(18): IN-03 memoize household timezone per outbox drain cycle

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) <noreply@anthropic.com>
This commit is contained in:
Lucas Berger
2026-06-15 07:43:01 -04:00
co-authored by Claude Opus 4.8
parent 93217b58fe
commit 1fb431e8da
+34 -8
View File
@@ -373,7 +373,31 @@ interface DispatchResult {
error?: string; error?: string;
} }
async function dispatchRow(row: OutboxRow): Promise<DispatchResult> { /**
* 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<string>;
function makeTimezoneResolver(): TimezoneResolver {
let cached: Promise<string> | 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<DispatchResult> {
// CR-03: fail closed on credential errors — let loadClientForUser throw. // 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). // 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. // Do NOT add an empty-credential fallback — that would silently PUT with no authentication.
@@ -499,9 +523,8 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
fields.allDay && fields.allDay &&
fields.start fields.start
) { ) {
// D-05: route through the single stored-TZ accessor (no inline fallback duplicated here). // IN-03: shared per-cycle resolver — one app_config read across all-day rows.
// D-06: getHouseholdTimezone falls back to process.env.TZ → Intl when no row is stored. const tz = await resolveTimezone();
const tz = await getHouseholdTimezone(db);
const leadDays = fields.reminderLeadMinutes / 1440; const leadDays = fields.reminderLeadMinutes / 1440;
allDayAlertInstantUtcUpdate = computeAlertInstantUtc(fields.start, leadDays, tz); allDayAlertInstantUtcUpdate = computeAlertInstantUtc(fields.start, leadDays, tz);
} }
@@ -607,9 +630,8 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
// carries an explicit picker value or no reminder at all; no preserve path needed). // carries an explicit picker value or no reminder at all; no preserve path needed).
let allDayAlertInstantUtcCreate: Date | undefined; let allDayAlertInstantUtcCreate: Date | undefined;
if (fields.reminderLeadMinutes != null && fields.allDay && fields.start) { if (fields.reminderLeadMinutes != null && fields.allDay && fields.start) {
// D-05: route through the single stored-TZ accessor (no inline fallback duplicated here). // IN-03: shared per-cycle resolver — one app_config read across all-day rows.
// D-06: getHouseholdTimezone falls back to process.env.TZ → Intl when no row is stored. const tz = await resolveTimezone();
const tz = await getHouseholdTimezone(db);
const leadDays = fields.reminderLeadMinutes / 1440; const leadDays = fields.reminderLeadMinutes / 1440;
allDayAlertInstantUtcCreate = computeAlertInstantUtc(fields.start, leadDays, tz); allDayAlertInstantUtcCreate = computeAlertInstantUtc(fields.start, leadDays, tz);
} }
@@ -747,6 +769,10 @@ export async function runOutboxDrain(): Promise<void> {
// credential at most once per cycle. Discarded when the drain returns — never persisted. // credential at most once per cycle. Discarded when the drain returns — never persisted.
const clientCache = new Map<number, FastmailClient>(); const clientCache = new Map<number, FastmailClient>();
// 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) { for (const row of sorted) {
// D-04 fast path: if the create for this group already failed in this batch, skip the delete // 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)) { if (row.operation === 'delete' && row.groupId && failedCreateGroups.has(row.groupId)) {
@@ -793,7 +819,7 @@ export async function runOutboxDrain(): Promise<void> {
} }
try { try {
const result = await dispatchRow(row); const result = await dispatchRow(row, resolveTimezone);
if (result.conflict) { if (result.conflict) {
// WR-06: distinguish an edit-as-move create-412 from a same-calendar conflict. // WR-06: distinguish an edit-as-move create-412 from a same-calendar conflict.