Dispute Jira resolution sweep
Moves the hourly Disputes::ResolveDoneJiraIssuesJob sweep from Rails/GoodJob
to API2/BullMQ. The GoodJob dashboard showed the job succeeding every hour
(15 * * * *) ahead of the September 9 cutover.
Class coverage
| Rails class | Producer(s) | API2 owner | State |
|---|---|---|---|
Disputes::ResolveDoneJiraIssuesJob | GoodJob cron resolve_done_jira_issues (15 * * * *) | dispute-jira-resolution queue, tick job, scheduler dispute-jira-resolution-tick | Migrated; Rails cron disabled by default |
The Rails job class remains deployed so already-queued rows can drain and the rollback path works without a deploy.
Contract
- Scheduler
dispute-jira-resolution-tick, cron15 * * * *UTC — identical to the GoodJob entry — emits onetickjob. - The tick re-checks the kill switch, then lists candidates with the same
predicate the job used:
disputes.status = 'open'joined tojira_issues.status_category = 'done', ordered by dispute id, capped at 5,000 per tick (truncatedwhen capped). It fans out onedisputejob per row withscannedAtstamped once; any enqueue failure fails the tick (AggregateError) so BullMQ retries it. - Job id
dispute-jira-resolution-<dispute_id>, 3 attempts, exponential backoff from 10 s. A finished job under the id is removed and re-added, a waiting/active job absorbs the new tick. - The
disputeworker re-checks the kill switch at execution time (a job queued before the switch flips must not race a Rails rollback), then runs the resolution natively in api2 —Dispute#resolve_from_jira_issue!ported to pure SQL insrc/lib/dispute-jira-resolution/resolve.ts: re-check the Jira issue status category, mark the dispute resolved, resolve linked customer complaints, and recompute dispute risk — all org-scoped, matching theActsAsTenantbehavior of the retired loop. - A missing dispute (deleted between tick and run) completes as
skipped, matchingdiscard_on ActiveRecord::RecordNotFound. Genuine failures raise and BullMQ retries (3 attempts, exponential backoff) — better than the retired job’s log-only in-loop rescue — and the next hourly tick re-drives any still-open dispute.
Rollout controls
| Control | Store | Safe state | Effect |
|---|---|---|---|
pause_api2_dispute_jira_resolutions | Flipper (API2 reads flipper_features) | off | Tick completes as paused without fan-out; queued dispute jobs complete as skipped. Owner: disputes engineering; created 2026-09-09; review/remove by 2026-12-08. |
resolve_done_jira_issues cron | good_job.rb, enabled_by_default: false | disabled | Enable in the GoodJob dashboard (or revert the line) only after pausing API2. |
Rollback order
- Enable
pause_api2_dispute_jira_resolutions, confirm the next tick logspaused. - Enable the
resolve_done_jira_issuescron in the GoodJob dashboard (or revert the line) to restore the hourly Rails sweep. Dispute jobs already queued in BullMQ are idempotent —resolve_from_jira_issue!returns early on already-resolved disputes.
Bake gates
Ownership is asserted only after, in both regions:
- Two consecutive 15-past-the-hour ticks complete with every candidate enqueued and zero failed dispute jobs.
- Zero new normal-path GoodJob rows for
Disputes::ResolveDoneJiraIssuesJobfor 24 hours. - Open disputes continue to resolve at the Jira
status_category_change_datetimestamp with matching complaint and risk-level side effects.
After 24 clean hours, delete Disputes::ResolveDoneJiraIssuesJob, the cron
entry and the flag.
Observation queries
SELECT count(*)
FROM good_jobs
WHERE serialized_params->>'job_class' = 'Disputes::ResolveDoneJiraIssuesJob'
AND created_at > now() - interval '24 hours';Open disputes still linked to done Jira issues (must stay near zero, allowed only within the one-hour sweep lag):
SELECT count(*)
FROM disputes d
JOIN jira_issues ji ON ji.id = d.jira_issue_id
WHERE d.status = 'open' AND ji.status_category = 'done';BullMQ (redis-cli): ZCARD bull:dispute-jira-resolution:completed,
ZCARD bull:dispute-jira-resolution:failed.
Verification
API2 unit tests cover the candidate SQL (PGlite), pause gate, fan-out,
truncation, enqueue-failure retry, deterministic job options and
dedupe/replace behavior, plus the native per-dispute resolution — complaint
resolution, risk recalculation, org scoping, not-done/already-resolved
no-ops — against PGlite fixtures. pnpm typecheck and scoped RuboCop are
required before push.