fix(email): read naive SQLite timestamps as UTC, and page the candidates

Codex review round 2 on #1273. Both findings restore the false all-clear that
round 1 set out to remove, by different routes.

Both timestamp columns default to CURRENT_TIMESTAMP, which SQLite renders as a
zone-less 'YYYY-MM-DD HH:MM:SS' in UTC -- and Date.parse reads that shape as
LOCAL time. On a TZ=America/New_York deployment a row due now looked four hours
away and never reached the waiting list; nine hours the other way, fresh mail
read as long overdue. The parser now stamps the zone the value actually
carries.

That parser moved to utils/queueTimestamps so it can be tested honestly. This
suite runs in UTC, where reading a zone-less value as local and as UTC give the
same answer, and process.env.TZ does not reliably re-bind mid-process -- my
first attempt at these tests passed against the broken code for exactly that
reason. They now force TZ in a child process, so they fail on any host.

The candidate rows are paged rather than cut off with one LIMIT. The time
filter runs in JS, so a queue holding more than a page of future-scheduled rows
-- split-payment invoices are exactly that shape -- filled the window with rows
that all got filtered out and hid the due row behind them, reporting nothing
waiting. Paging also drops the dependency on ORDER BY created_at meaning
anything, which it does not on SQLite once numeric and text timestamps mix.
Bounded at 10k scanned; past that the response is a sample, which the 200-row
cap already made it.

12 more tests. The paging one fails before this commit, and all four
naive-timestamp ones fail against the old parsing on any host.
This commit is contained in:
Paul Nothaft
2026-09-02 14:57:00 +02:00
parent 89db469f06
commit 98aa06aeff
4 changed files with 203 additions and 43 deletions
@@ -222,6 +222,40 @@ describe('GET /admin/system-health/failures — waiting emails (#1262)', () => {
});
});
// --- Codex review round 2 -------------------------------------------------
//
// The zone-less CURRENT_TIMESTAMP shape is covered in
// __tests__/utils/queueTimestamps.test.js instead of here: this suite runs
// in UTC, where reading such a value as local and as UTC give the same
// answer, and process.env.TZ does not reliably re-bind mid-process. Those
// tests force the zone in a child process, so they fail on any host.
it('finds a due row behind a page full of future-scheduled ones', async () => {
// Codex review round 2. The candidates used to be cut off with a single
// LIMIT before the time filter ran, so a queue holding a page of
// split-payment invoices scheduled for later hid the due row behind them
// and reported an empty waiting list — the false all-clear, again.
// Over the 1000-row cut-off the first attempt used, so the due row really
// does sit behind a full page rather than merely late in one.
const rows = [];
for (let i = 0; i < 1200; i += 1) {
rows.push({
recipient_email: `bulk${i}@example.com`,
email_type: 'invoice_due',
email_data: '{}',
status: 'pending',
retry_count: 0,
created_at: ago(90 * MINUTE),
scheduled_at: ahead(30 * 24 * 60 * MINUTE),
});
}
await db.batchInsert('email_queue', rows, 100);
await queue({ email_type: 'gallery_created', created_at: ago(52 * MINUTE) });
const body = await failures();
expect(typesOf(body.waitingEmails)).toEqual(['gallery_created']);
});
it('reports what the queue processor last did', async () => {
const body = await failures();
// Never started in this process — which is the condition that makes a