Class: SourceReconciliationWorker
- Inherits:
-
Object
- Object
- SourceReconciliationWorker
- Includes:
- Sidekiq::Job
- Defined in:
- app/workers/source_reconciliation_worker.rb
Overview
Restores the operational source of records that nurturing campaigns
overwrote, back to the acquisition source stamped once at creation.
auto_assign_source let 26 campaigns rewrite source_id on the
opportunities, orders and invoices of every customer they touched. Measured
2026-07-31: 92,284 records across the three types hold an influence-only
campaign as their operational source while their write-once
original_source_id records that they were acquired elsewhere — Costco,
professional referrals, blog posts, cold calls.
ORDER OF OPERATIONS MATTERS. StopAutoAssigningInfluenceOnlyCampaigns must be
deployed first. Reconciling while those campaigns still auto-assign means
Campaign#synchronize_source simply re-overwrites everything this fixes on the
customer's next touch, and the sweep quietly undoes itself.
Resumable by construction: a corrected record no longer matches the scope, so
a re-run picks up exactly where a killed worker stopped. That matters because
a kamal deploy cycles every worker mid-run.
Constant Summary collapse
- BATCH_SIZE =
Records per job. Small enough that a deploy loses little work, large enough
that 92k records don't need thousands of jobs. 500- RECORDS =
Where each record type's acquisition source lives, and which table it is
corrected on.Invoicecarries nooriginal_source_idof its own — it
inherits the acquisition source of the order it bills.Table names are spelled out rather than derived with
tableize: these three
happen to inflect correctly today, but a mapping that silently produces the
wrong table name would build valid SQL against the wrong rows. { 'Order' => { table: 'orders', acquisition: 'orders.original_source_id' }, 'Opportunity' => { table: 'opportunities', acquisition: 'opportunities.original_source_id' }, 'Invoice' => { table: 'invoices', acquisition: 'orders.original_source_id' } }.freeze
- LOCK_NAME =
Serializes runs so two jobs for the same record type cannot read the same
batch and write it twice. Held across select-and-correct, not just the
write, because the select is what decides the work. 'source_reconciliation'- LOCK_TIMEOUT =
Short: a batch is a bounded select plus one insert and a few updates.
10- LOCK_RETRY_DELAY =
How long to stand off when another run holds the lock. Long enough that a
busy holder finishes, short enough that the sweep doesn't crawl. 30.seconds
Instance Method Summary collapse
-
#perform(record_type, batch_id, dry_run = false) ⇒ Integer
Records corrected by this job.
Instance Method Details
#perform(record_type, batch_id, dry_run = false) ⇒ Integer
Returns records corrected by this job.
57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 |
# File 'app/workers/source_reconciliation_worker.rb', line 57 def perform(record_type, batch_id, dry_run = false) # Selection and correction under one lock: two concurrent jobs would # otherwise select the same batch and both write it, producing duplicate # manifest entries whose `from_source_id` disagree — and a manifest that # disagrees with itself is not an undo log. outcome = Source.with_advisory_lock(LOCK_NAME, timeout_seconds: LOCK_TIMEOUT) do run_batch(record_type, batch_id, dry_run) end return outcome unless outcome == false # `with_advisory_lock` returns false WITHOUT running the block when it # cannot acquire in time. Returning here would end the chain silently: the # re-enqueue lives inside the block, so a job that loses the lock takes its # whole record type down with it and the sweep just stops. That is exactly # what stalled the 2026-07-31 run at 1,000 of 30,645 orders, with nothing # queued, nothing retrying, and no error recorded anywhere. Rails.logger.info("[SourceReconciliationWorker] #{record_type} deferred — another run holds the lock") self.class.perform_in(LOCK_RETRY_DELAY, record_type, batch_id, dry_run) unless dry_run 0 end |