Class: AdSpendSyncWorker
- Inherits:
-
Object
- Object
- AdSpendSyncWorker
- Includes:
- Sidekiq::Job
- Defined in:
- app/workers/ad_spend_sync_worker.rb
Overview
Nightly per-campaign ad spend into SourceDataPoint, so ROI/ROAS is
answerable from the database across every paid channel.
Assistant conversation 4167 exposed the gap this closes: asked for marketing
ROI, Sunny could only reach Google (its only spend tool) and silently omitted
the Microsoft and OpenAI money. Spend lands on the campaign's Source — the
same key revenue attributes to (doc/architecture/SOURCE_ATTRIBUTION.md) — so
return-on-ad-spend is a join, not a reconciliation.
This worker is an orchestrator: it fans the providers out into a Sidekiq Pro
batch, one job each (AdSpendProviderSyncWorker), and AdSpendDayFinalizer
decides what the day's outcome means once they all land. The providers used
to run sequentially inside this job, which meant an Amazon throttle failed
the whole day and a re-run re-fetched Google, Microsoft and OpenAI as well —
spending their quota to recover someone else's. Fanning out keeps a retry
scoped to the provider that actually failed. Amazon report waiting is now a
chain of short polling jobs, so no worker thread is pinned between polls.
Idempotent: metrics upsert on (source, metric_type, period), so re-running a
date overwrites rather than doubling. Runs after every campaign sync (Google
01:00 → Amazon 02:15 CT) so a campaign created today already has its Source
row before its spend arrives.
Constant Summary collapse
- ADAPTERS =
Provider key → adapter. Keys are what gets passed across the job boundary,
so they must stay stable; the adapter class itself can't be serialized. { 'google' => Marketing::AdSpend::GoogleAdapter, 'microsoft_ads' => Marketing::AdSpend::MicrosoftAdapter, 'openai_ads' => Marketing::AdSpend::OpenaiAdapter, 'amazon_ads' => Marketing::AdSpend::AmazonAdapter }.freeze
- DEEP_HISTORY_PROVIDERS =
Providers whose history reaches further back than Amazon's, which retains
60 days for Sponsored Brands and gates the whole provider-day on it. A
historical backfill scopes itself to these, or every pre-retention day
fires an Amazon request that is certain to be rejected — and each rejection
opens the cohort-wide cooldown, stalling the chain for everyone. %w[google microsoft_ads openai_ads].freeze
Class Method Summary collapse
-
.providers_for(providers) ⇒ Array<String>
Only
nilmeans "every provider". -
.scope_for(providers) ⇒ String?
Run-state keyspace for a provider selection.
-
.throttleable?(providers) ⇒ Boolean
Only Amazon opens the cohort-wide cooldown, so a run without it has no reason to stand down for one — which is the whole point of scoping a historical backfill to the other three.
Instance Method Summary collapse
-
#perform(date_string = nil, backfill_end = nil, providers = nil) ⇒ String
The batch id, useful for tracing a day in the Sidekiq UI.
Class Method Details
.providers_for(providers) ⇒ Array<String>
Only nil means "every provider". An explicitly empty array is refused
rather than widened: perform(date, nil, []) reading as a full run would
fan Amazon out across a historical range — the exact doomed request this
scoping exists to avoid — and it would do it silently.
54 55 56 57 58 59 60 61 62 63 64 |
# File 'app/workers/ad_spend_sync_worker.rb', line 54 def self.providers_for(providers) return ADAPTERS.keys if providers.nil? requested = Array(providers).map(&:to_s).uniq raise ArgumentError, 'ad-spend provider list is empty; pass nil to run every provider' if requested.empty? unknown = requested - ADAPTERS.keys raise ArgumentError, "unknown ad-spend provider(s): #{unknown.join(', ')}" if unknown.any? requested end |
.scope_for(providers) ⇒ String?
Run-state keyspace for a provider selection. A full run gets nil so its
markers stay on the shared key. See Marketing::AdSpend::RunState.
71 72 73 74 75 76 |
# File 'app/workers/ad_spend_sync_worker.rb', line 71 def self.scope_for(providers) selected = providers_for(providers).sort return nil if selected == ADAPTERS.keys.sort selected.join('+') end |
.throttleable?(providers) ⇒ Boolean
Only Amazon opens the cohort-wide cooldown, so a run without it has no
reason to stand down for one — which is the whole point of scoping a
historical backfill to the other three.
84 85 86 |
# File 'app/workers/ad_spend_sync_worker.rb', line 84 def self.throttleable?(providers) providers_for(providers).include?(Marketing::AdSpend::AmazonAdapter::PROVIDER) end |
Instance Method Details
#perform(date_string = nil, backfill_end = nil, providers = nil) ⇒ String
Returns the batch id, useful for tracing a day in the Sidekiq UI.
95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 |
# File 'app/workers/ad_spend_sync_worker.rb', line 95 def perform(date_string = nil, backfill_end = nil, providers = nil) date = date_string.present? ? Date.parse(date_string) : Date.current - 1 selected = self.class.providers_for(providers) scope = self.class.scope_for(providers) batch_id = SecureRandom.uuid # Claim the day HERE, not in the callers, because every dispatcher routes # through this method — the nightly cron, the backfill chain, the self-heal # sweep, a console re-run. While the claim lived in AdSpendBackfillWorker # only the chain recorded it, so `in_flight?` was blind to nightly runs and # the finalizer's release cleared a key nobody had set. # # The claim has to come FIRST: the batch can complete — and its finalizer # clear this very key — before a claim written afterwards lands, leaving a # stale marker that blocks the date for its whole three-hour TTL. But a claim # written first must be released if the enqueue raises, or nothing will ever # clear it: no batch exists, so no :complete callback fires. Marketing::AdSpend::RunState.mark_in_flight!(date, scope:) claimed = true batch = Sidekiq::Batch.new batch.description = "Ad spend #{date}#{" (#{scope})" if scope}" batch.on(:complete, AdSpendDayFinalizer, 'date' => date.to_s, 'backfill_end' => backfill_end, 'providers' => providers) batch.jobs do selected.each { |provider| AdSpendProviderSyncWorker.perform_async(provider, date.to_s, batch_id) } end batch.bid rescue StandardError # Only release a claim we actually took. An invalid provider list raises # before the claim, and clearing then would drop a concurrent full run's # in-flight marker for this date. Marketing::AdSpend::RunState.clear_in_flight!(date, scope: scope) if claimed raise end |