Zendesk daily ticket reconciliation
ENG-2931
moves the 00:00 UTC SyncZendeskTicketsJob sweep and its
ImportZendeskConversationJob fan-out from Rails/GoodJob to the API2
zendesk-ticket-reconciliation BullMQ queue. Normal Zendesk webhook ingestion
already belongs to API2 (ENG-2692, ENG-2917); the daily sweep stays as the
safety net for tickets whose webhook never arrived or failed terminally, and it
is not narrowed until webhook coverage has a measured miss rate.
Why
Rolling 12h EU+US GoodJob evidence ending 2026-09-09 10:13 UTC showed
ImportZendeskConversationJob at 5,029 executions for 2,738 unique jobs with
2,291 rate-limit attempts, all produced by the midnight sweep and with no real
backlog. The Rails job retried ImportRateLimitedError up to 60 times with a
random wait, so one throttled tenant amplified into thousands of executions.
Contract
- One scheduler,
zendesk-ticket-reconciliation, fires ascanjob at0 0 * * *(the existing cadence). The scan lists non-discarded Rails Zendesk sources joined to an API2 organization, skips unmapped tenants with a log line, and enqueues onesourcejob per mapped source. An enqueue failure for one source is recorded and does not stop the others. - A
sourcejob re-checks the kill switch, then walks/api/v2/incremental/tickets.jsonfromlast_synced_at(24h fallback on the first run, matching Rails) with a 10-second gap between pages and a 200-page cap. Each page’s ticket jobs are enqueued before its checkpoint is written;last_synced_atonly moves forward (update_last_synced_at_checkpointsemantics), and the final watermark is set to the scan start only afterend_of_stream. A capped or failed source keeps its window so the next sweep resumes from the last delivered page. - A
ticketjob fetches the ticket, upserts the Rails-compatible conversation through the sharedzendesk/ticket-import.tsmodule (same code as webhook ingestion), and enqueues the source-owned provider sync with a deterministic run ID. A 404 marks the ticket obsolete and completes. - Deduplication IDs are
zendesk_reconciliation_source_<org>_<ods>andzendesk_reconciliation_ticket_<org>_<ods>_<ticketId>, so a webhook and a sweep for the same ticket, or two overlapping sweeps, produce one live job. - Ticket jobs have 3 attempts, source/scan jobs 5, exponential backoff from
60s. A Zendesk 429 does not consume an attempt: the worker calls
moveToDelayedforRetry-After(default 60s) and rethrowsDelayedError. Worker throughput is capped at 30 jobs / 10s on top of the shared per-connection limiter. - Failed ticket jobs are retained (
removeOnFail: false) for manual redrive; scan/source failures are removed after logging because the next sweep retries them from the retained window. - The Rails
sync_zendesk_ticketscron stays ingood_job.rbwithenabled_by_default: false.SyncZendeskTicketsJobandImportZendeskConversationJobare a documented recovery-only shim.
Kill switch and rollback
Flipper pause_api2_zendesk_ticket_reconciliation is checked at scan, source,
and ticket execution time; paused jobs complete as paused without touching
Zendesk or last_synced_at. Rollback order:
- Enable
pause_api2_zendesk_ticket_reconciliationin the affected region. - Only then re-enable the
sync_zendesk_ticketscron in the GoodJob dashboard (or revertenabled_by_default: false). Never run both owners: both advancelast_synced_aton the same row. - Leave the API2 worker and queued BullMQ jobs alone; they drain as
paused. Do not delete the scheduler unless the migration is being abandoned.
Work skipped while paused is covered by the next Rails sweep because the watermark did not move.
Bake criteria
Deploy the API2 worker/scheduler and the Rails revision together (overlap is safe: conversations are unique per external identity and provider sync deduplicates). Then observe two midnight ticks in US and EU and require:
- every mapped source’s
last_synced_atadvanced past the tick, and each unmapped source is explicitly listed as retained Rails-only coverage; - zero new
SyncZendeskTicketsJob/ImportZendeskConversationJobGoodJob rows created after the deploy; - no
cappedsource results, no growing waiting/delayed backlog onzendesk-ticket-reconciliation, delayed count returning to zero within the hour after the tick; - no new duplicate Zendesk external identities and healthy downstream provider syncs;
- ticket failed-set count is zero or each entry is explained.
Observation queries
Rails (each region):
SELECT ods.id, ods.organization_id, ods.last_synced_at
FROM organization_data_sources ods
JOIN data_sources ds ON ds.id = ods.data_source_id
WHERE ds.key = 'zendesk' AND ods.discarded_at IS NULL
ORDER BY ods.last_synced_at NULLS FIRST;
SELECT job_class, count(*) AS jobs, sum(executions_count) AS executions
FROM good_jobs
WHERE job_class IN ('SyncZendeskTicketsJob', 'ImportZendeskConversationJob')
AND created_at > now() - interval '24 hours'
GROUP BY job_class;The second query must return no rows after the deploy. API2: open
/admin/queues, select zendesk-ticket-reconciliation, and read waiting,
delayed, failed counts and the scheduler’s next time. Worker logs are tagged
zendesk-ticket-reconciliation with status, pageCount, ticketCount,
cursorBefore, and cursorAfter per source.
Recover a failed ticket
The watermark records queue delivery, not import success. A terminally failed
ticket keeps organizationId, organizationDataSourceId, and ticketId in the
failed set. With production authorization: match the job to its final-attempt
log/Sentry event, fix the credential/provider/persistence cause, and retry that
individual job from /admin/queues. Do not reset last_synced_at, edit the
job identity, or purge the failed set to clear an alert. For a source whose
outage predates the retained window, run the Rails shim manually for that
source only while the API2 flag is paused, then unpause.
Verification
src/lib/zendesk/ticket-reconciliation.test.ts covers source mapping and
skips, per-source enqueue isolation, 24h fallback, enqueue-before-checkpoint,
pagination spacing, page cap retention, non-advancing cursors, kill switch at
every kind, idempotent upsert plus provider-sync enqueue, 404 handling, and
Retry-After deferral. fetch.test.ts covers the incremental export URL and 429
mapping; reconciliation-worker-wiring.test.ts asserts the dedupe/retention
options, scheduler registration, and the paused Rails cron. Rails GoodJob
configuration specs assert the disabled cron entry.