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:
co-authored by
Claude Opus 4.8
parent
93217b58fe
commit
1fb431e8da
@@ -373,7 +373,31 @@ interface DispatchResult {
|
||||
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.
|
||||
// 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<DispatchResult> {
|
||||
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<DispatchResult> {
|
||||
// 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<void> {
|
||||
// credential at most once per cycle. Discarded when the drain returns — never persisted.
|
||||
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) {
|
||||
// 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<void> {
|
||||
}
|
||||
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user