Appearance
Empty Queue โ Design โ
Date: 2026-07-22 Status: Approved, pending implementation
Problem โ
A merchant who queues many sync jobs (e.g. selects a large batch of sets, or accumulates a backlog behind a variant-daily-limit throttle) has no way to back out. Today the only per-job control is DELETE /api/sync/:jobId/terminate (server/routes/api.js:2859), one job at a time, and it marks jobs failed โ which misrepresents jobs that never started. There is no "clear everything that hasn't run yet" operation.
Decision โ
Add a one-click "Empty Queue" action that cancels every not-yet-started sync job for the shop, leaves in-flight and finished jobs untouched, and marks the cleared records cancelled (not failed).
Scope of "empty" โ
SyncJob records for req.shop with status โ {queued, pending}.
queuedis the status every enqueue site writes (server/routes/api.js:2329,:2609,:3469;server/queues/processors/syncProcessor.js:361).pendingis in the status enum (server/models/SyncJob.js:38) but no code path ever writes it as a top-level status (the'pending'literals elsewhere are pipeline-phase statuses and other models' fields). Included for robustness; tests should not assume it occurs in the wild.throttledis explicitly excluded. It is a terminal state, not the backlog: when the variant daily cap hits, the processor marks the original jobthrottledwithcompletedAtset and its partial success counts preserved, and creates a fresh SyncJob withstatus: 'queued'plus a delayed BullMQ job keyed by the new record's id (server/queues/processors/syncProcessor.js:356-388). The backlog the merchant wants gone is that queued resume record โ already covered by the{queued, pending}scope, with BullMQ statedelayedโ removable. Flipping thethrottledparent tocancelledwould rewrite a completed, partially-successful history record and remove nothing from any queue.- In-flight states (
running,validating) and terminal states (completed,failed,cancelled,throttled) are untouched. This honors "leave the running job alone." sealed_addrecords are written already-completed (server/models/SyncJob.js:11-14) and are naturally excluded by the status filter.
Known cosmetic trade-off: after clearing, a throttled parent record still carries resumeScheduledFor โ a resume promise the merchant just cancelled. We deliberately leave that record untouched; the adjacent cancelled resume row in sync history tells the story. Revisit only if merchants report confusion.
Why cancelled, not failed โ
cancelled is already load-bearing in the processor, which gives the clear operation a free safety net if BullMQ removal fails or races:
- A job found already-
cancelledat pickup is skipped without doing work (server/queues/processors/syncProcessor.js:51). - The failure path preserves
cancelledrather than overwriting withfailed(server/queues/processors/syncProcessor.js:490).
The existing terminate endpoint's use of failed conflates "kill a running job" with cancellation; for jobs that never started, cancelled is the honest status.
Server โ
syncService.clearQueuedJobs(shop) โ
- Find SyncJobs for
shopwithstatus โ {queued, pending}. - Per job, resolve its BullMQ job โ jobs are keyed by the SyncJob's Mongo
_id(getJob(syncJobId), same resolution the terminate endpoint uses atserver/routes/api.js:2869-2871) โ in the queue matching itsjobType:syncQueueforproduct_sync,priceUpdateQueueforprice_update. - Order of operations (race safety): check BullMQ state first.
activeโ skip entirely: no removal, no Mongo write. An unconditionalcancelledwrite would tripcheckIfCancelledmid-run (server/queues/processors/syncProcessor.js:27-32) and abort a running job, violating the design's own constraint.waiting/delayed/pausedโremove(), wrapped in try/catch (best-effort, matching the terminate endpoint's pattern).
- Mark the record with a status-guarded atomic update so a job the worker picked up between the find and the write is never flipped. The filter also carries
shopโ redundant with the shop-scoped find, but every write to a per-store collection stays tenant-guarded on its own:jsThe residual window (BullMQSyncJob.updateOne( { _id: job._id, shop, status: { $in: ['queued', 'pending'] } }, { $set: { status: 'cancelled', completedAt: new Date(), lastError: 'Cleared by merchant (queue emptied)' } } )activecheck passed, worker grabs the job before the update lands, Mongo status stillqueued) is benign: the processor's already-cancelled check at pickup usually discards it before any work happens. In the narrowest double-race โ the guarded update lands after that pickup check but before the processor savesrunningโ the processor's own status save wins and the job runs to completion; the only artifact is aclearedcount off by one and a stalelastErroron a record that endscompleted. No handling warranted. - Return
{ cleared, skippedRunning }(counts).
No per-job notifications. The terminate endpoint sends a failure notification per job (server/routes/api.js:2902); a bulk clear must not โ one merchant click would otherwise spam a Slack message per cleared job. This deviation is deliberate.
Repeatable price-update schedule is safe by construction โ
Queued price_update SyncJobs only come from the manual run-now endpoint (server/routes/api.js:3466-3477), which pre-creates the record and keys the BullMQ job by its id. Scheduled repeatable price jobs have no SyncJob until the processor creates one at run time, so per-record resolution can never touch โ and never accidentally deregister โ the repeatable schedule. Do not "simplify" this into draining priceUpdateQueue wholesale; that would break repeatable scheduling.
POST /api/sync/empty-queue โ
- Calls
syncService.clearQueuedJobs(req.shop), returns{ ok: true, cleared, skippedRunning }. - No request body โ shop comes from auth. No Zod body schema, consistent with
DELETE /api/products(bodyless, shop-scoped). Route still sits behind the standard auth middleware chain.
Client โ
EmptyQueueButton.jsx (new) โ
- retroui
Button+ confirmDialog: "Clear all queued syncs that haven't started yet? Running jobs finish normally." - On confirm โ
api.post('/api/sync/empty-queue')viautils/api.js. - Shows result: "Cleared N queued jobs" / "Queue is already empty" (
cleared === 0). - Fires
onClearedcallback on success.
Queue.jsx โ
- Holds a
refreshKey; renders<EmptyQueueButton onCleared={bump} />in the header and passeskey={refreshKey}toSyncHistoryso the list refreshes after clearing.
The confirm dialog is UX only โ the server is the sole enforcement point (CLAUDE.md rule 7); there is no client-side eligibility filtering to drift out of sync.
Game parity (ยง5.1) โ
Game-agnostic: keys on shop + status, touches no plugin, per-game model, or importer. mtg / pokemon / riftbound are covered identically; nothing to mirror. game is never read, defaulted, or branched on (ยง5.5 clean).
Edge cases โ
- Empty result: no matching jobs โ
{ ok: true, cleared: 0, skippedRunning: 0 }; client shows "Queue is already empty." - BullMQ job missing (already consumed, or Redis flushed): Mongo record is still marked
cancelledโ same "not found in queue, still mark in DB" behavior as terminate (server/routes/api.js:2889-2891). - Job goes active mid-clear: skipped, counted in
skippedRunning. remove()throws: swallowed per-job; the status-guarded update still runs, and the processor's cancelled-at-pickup check discards the orphaned BullMQ job.- Throttled backlog: the queued resume record is cleared like any other queued job; its
throttledparent is untouched (see Scope).
Testing โ
- Service (
syncService.test.js, via_setDeps()injection):- queued job with
waitingBullMQ state โ removed and markedcancelled, counted incleared. - queued resume job with
delayedBullMQ state โ removed and cancelled (the throttle-backlog case). - job whose BullMQ state is
activeโ not removed, no status write, counted inskippedRunning. running/completed/throttled/failedrecords โ never selected, never written.price_updatequeued record โ resolved againstpriceUpdateQueue, notsyncQueue.remove()throwing โ record still markedcancelled.- Jobs belonging to a different shop โ untouched.
- queued job with
- Route: no dedicated route test โ
server/routes/api.jshas no test harness in this repo (only the small standalone routerswebhooks.jsandpublicBuylist.jsare route-tested), and the handler is a three-line wrapper over the fully-tested service, mounted afterdualModeAuthlike every other authenticated route. Deliberate deviation, consistent with every other endpoint inapi.js. - Client (
EmptyQueueButton.test.jsx): dialog opens on click; confirm posts to the endpoint;onClearedfires on success; zero-cleared response renders "Queue is already empty."
