The notification lease and retry+backoff engineering
How traverse's 120-second lease works in every failure mode, and the retry ladder you own.
Know your own system
The deep version of your best lease implementation — src/queues/notification.queue.ts.
The claim SQL (be able to write it)
UPDATE notification_delivery nd SET attempts = attempts + 1, next_attempt_at = NOW() + INTERVAL '120 seconds' -- the lease FROM ( SELECT id FROM notification_delivery WHERE status='pending' AND next_attempt_at <= NOW() ORDER BY next_attempt_at LIMIT 50 -- DRAIN_BATCH_SIZE FOR UPDATE SKIP LOCKED -- competing drains claim disjoint rows ) claimed WHERE nd.id = claimed.id RETURNING nd.*;
Every design element has a reason: SKIP LOCKED = parallel drains; the lease is forward-dated, not a status = crashed workers need no reaper (Day 32); ORDER BY next_attempt_at = oldest-first fairness; batch 50 = bounded send burst per 30 s drain tick.
Failure-mode walkthrough (the interview drill)
- Worker dies before sending: lease expires in ≤120 s; next drain re-claims; attempts incremented; eventually
failedafter MAX_ATTEMPTS=5. Cost: 2 minutes delay. - Worker alive but slow (APNs takes >120 s): lease expires while send is in flight; another drain claims and double-sends; both record
sent. Accepted: push is collapse-coalesced; and the design doc says quiet-hours defer, never drop — same philosophy: err toward delivering. - APNs 5xx:
failed(retryable)→ backoffmin(60s·2^(attempts-1), 1h)written as nextnext_attempt_at→ status stayspending. Exponential with cap — no infinite growth. - APNs 410/Unregistered/BadDeviceToken:
invalid_token→ terminal; the PushToken is invalidated; no retry — error classification again (Day 19). - No token registered:
skipped(no_push_token)— terminal, and not an error: "a decision, not a failure." The drain must never re-add skipped rows to the retry set — the code documents this. - APNs not configured (
isApnsConfigured()==false): every push →skipped(unconfigured)— deliberate, terminal. Consequence stated in the design: configuring APNs later pushes only new notifications, never a backlog — because the rows already went terminal. That's a real tradeoff decision documented in the code header; recite it.
The retry ladder inventory (both projects)
| Layer | Policy | File |
|---|---|---|
| zlink connector | 3 attempts, 500 ms base, exp+jitter, 30 s cap | zlink/internal/retry |
| zoth consumer | 5 attempts via republish + x-retry-count, then DLQ | internal/queue/consumer |
| BullMQ submission | 3 attempts, exp 2 s | src/queues/submission.queue.ts |
| BullMQ AI | 2 attempts, exp 5 s | src/queues/aiAnalysis.queue.ts |
| BullMQ notification | 3 attempts, exp 2 s (job-level) + 5 delivery attempts with 60s·2^n→1h (row-level) | src/queues/notification.queue.ts |
| Relay publish | no retry — abort batch, retry next tick (outbox holds) | internal/queue/outbox |
Note the two-tier retry in notifications: the job can fail (BullMQ retries 3×), and separately the delivery row accumulates its own attempts with its own backoff — the row is the durable retry state; the job is just the trigger. That's the outbox-style separation of state from execution, and it's why the system is resumable from any crash point.
The jitter rule (everywhere or nowhere): zlink's delay/2 + rand(delay/2) — without jitter, synchronized failures (e.g., APNs blip at a drain tick) produce synchronized retries; with it, the herd spreads. BullMQ's exponential backoff includes jitter by default. Your consumer republish path has no jitter — an improvement to name (sleep-before-republish or randomized delay header).
Bulletproof your resume
Say these answers aloud before moving on
These are the follow-ups an interviewer will actually ask about your projects.
Why is the lease 120 seconds and not a 'sending' status?
A status needs a cleanup job: a crashed worker leaves rows stuck 'sending' forever. A forward-dated lease is self-cleaning — the row becomes claimable exactly when the lease lapses, and a retry uses the same code path as a first attempt. The 120 s value covers a normal APNs/FCM send with margin; the cost of a wrong-side choice is bounded (double-send, coalesced) versus the cost of a stuck status (silent non-delivery). Time-based claims trade exactness for liveness, and for push notifications liveness is the right trade.
What happens if APNs is configured a month after launch?
Nothing retroactive: deliveries that ran while unconfigured went to 'skipped' — terminal by design — so only notifications emitted after configuration get pushed. That's documented as a decision: treating 'no provider' as a permanent condition rather than a backlog, because an old backlog pushed months later would be noise, not information. If I wanted catch-up, the fix is a distinct 'deferred' status the drain re-evaluates — but the product answer was 'never deliver stale pushes.'
Your retries have jitter in zlink but not in the consumer republish path. Why fix that?
Because the republish path's failure mode is synchronized: a batch of events failing on the same downstream outage will all retry at the same cadence, multiplying load exactly when the dependency is recovering. Jitter (randomizing the republish delay) converts the herd into a spread. It's a one-line improvement I'd make — and mentioning it shows the principle is applied by rule, not by accident.