feat(intake): run the drafting worker after a submission and on a sweep (#223)

Two drivers. The call after a successful submission means a draft is usually waiting by the time anybody looks; the five-minute sweep means a restart mid-draft is recoverable rather than a permanently stalled row, and picks up whatever the first call missed.

Neither is awaited. A slow or failing model must not become a failed upload for someone who did nothing wrong, which is the whole reason drafting does not happen inline — the cost of a dropped call is a few minutes' delay, not a lost submission.

Both catch for themselves. The comment beside the existing schedulers points out that `void` is only safe because those functions handle their own errors, and draftQueued does not: its first query can reject, and an escaping rejection would take the container down.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-31 19:49:14 -05:00
co-authored by Claude Opus 5
parent 9336bb7a5e
commit 6a1466680d
2 changed files with 20 additions and 0 deletions
+9
View File
@@ -4,6 +4,7 @@ import { asyncRoute } from '../asyncRoute';
import { hashToken } from '../uploadLinks';
import { uploadImages, verifyUploadedImages, insertItemImages } from '../imageUpload';
import { intakeViewLimiter, intakeSubmitLimiter } from '../rateLimit';
import { draftQueued } from '../intake/draftingWorker';
const router = Router();
@@ -148,6 +149,14 @@ router.post(
}
await client.query('COMMIT');
// Deliberately not awaited, and catching for itself. A slow or failing
// model must not become a failed upload for someone who did nothing
// wrong, which is the whole reason drafting does not happen inline. The
// sweeper picks up anything this misses, so the cost of it failing here
// is a few minutes' delay rather than a lost submission.
void draftQueued(1).catch((err) => console.error('[drafting] after submission:', err));
// No item id in the response: the sender has no business knowing about
// the catalogue, and nothing they could do with it.
res.status(201).json({ ok: true });
+11
View File
@@ -6,6 +6,7 @@ import { renderTemplate, greeting, formatDuration } from './emailTemplates';
import { getSettings } from './adminSettings';
import { loadStoredTemplate } from './routes/adminEmailTemplates';
import { validateEnv } from './envValidation';
import { draftQueued } from './intake/draftingWorker';
// Release cart holds whose expiry has passed.
async function sweepExpiredCarts(): Promise<void> {
@@ -96,6 +97,16 @@ interface ReminderRow {
setInterval(() => void sweepExpiredCarts(), 5 * 60 * 1000);
cron.schedule('0 9 * * *', () => void sendCartReminders());
// Every five minutes, in the same shape as the cart sweep. This is what makes a
// restart mid-draft recoverable rather than a permanently stalled row, and what
// picks up anything the post-submission call missed. Unlike the two above,
// draftQueued does not catch at its own top level — the initial query can
// reject — so it catches here instead, for the reason the comment above gives.
setInterval(
() => void draftQueued().catch((err) => console.error('[drafting] sweep:', err)),
5 * 60 * 1000
);
const PORT = parseInt(process.env.PORT || '3000', 10);
// Checked at boot rather than left to be discovered by the first request that