Authenticating...
Skip to main content

0057: Real-time Offer API Syncing

STATUS

Accepted

CONTEXT

AdGem currently synchronizes offers from the internal database to the Offer API using a scheduled batch script (SyncOfferApiV2). This script runs on a cron schedule:

  • At the 20th minute of each hour for some apps
  • At the 50th minute of each hour for some apps
  • At the 10th and 40th minutes for strategic apps (twice hourly)

While functional, this approach introduces latency between when data changes occur in the dashboard and when those changes are reflected in the Offer API.

Current Architecture

┌─────────────────────────────────────────────────────────────────────┐
│ Current Batch Sync Flow │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ Dashboard Changes Scheduled Task Offer API │
│ ┌──────────────┐ ┌─────────────┐ ┌──────────┐ │
│ │ Campaign │ │ │ │ │ │
│ │ Goal │ ──X──> │ SyncOffer │ ────> │ Offers │ │
│ │ Offerwall │ (wait) │ ApiV2 │ │ │ │
│ │ Margin Ovr │ │ │ │ │ │
│ └──────────────┘ └─────────────┘ └──────────┘ │
│ │ │
│ Runs at :20, :50, or :10/:40 │
│ depending on app tier │
└─────────────────────────────────────────────────────────────────────┘

Problem Statement

The batch synchronization approach has several limitations:

  1. Latency: Changes to campaigns, goals, payouts, or targeting can take 10-40 minutes to propagate to the Offer API
  2. Inefficiency: Full app syncs process all offers even when only a single field changed
  3. Stale Data: Publishers and end-users may see outdated offer information
  4. Operational Overhead: Increased API calls during batch windows can strain the Offer API

Business Drivers

  • Payout Accuracy: When a goal's margin or revenue changes, the corresponding offers should reflect updated payouts promptly to avoid revenue leakage or over-payment
  • Offer Integrity: Contract changes (targeting, rewards) should trigger offer supersession to maintain offer integrity
  • Publisher Experience: Publishers expect changes made in the dashboard to be reflected quickly
  • Operational Efficiency: Processing only changed data reduces system load on the Offer API. During batch sync windows, increased API traffic can degrade response latency for publisher-facing HTTP requests. Event-driven syncing distributes load more evenly and targets only affected offers, promoting more focused use of Offer API resources

Historical Context: Previous Real-time Implementation

A previous attempt at real-time syncing left behind event infrastructure that still exists in the codebase:

  • MarginOverrideCreated, MarginOverrideUpdated, MarginOverrideDeleted events
  • OfferwallCreated, OfferwallUpdated events
  • UpdateOffersForCampaign listener

This implementation was problematic because it superseded offers for any observed change, regardless of whether the change affected contract fields. The approach was abandoned in favor of the batch sync, but the event definitions remain.

Dashboard Access Patterns Bypassing Model Events

A contributing factor to the previous implementation's failure — and an ongoing limitation for any observer-based approach (Options 1 and 2) — is that the dashboard codebase has many code paths that bypass Laravel's Eloquent model events. Direct queries, bulk updates, raw SQL, and other access patterns that don't go through Eloquent's save() / update() methods will not trigger observers or fire model events.

This means an observer-based real-time sync can silently miss changes, leading to drift between the dashboard database and the Offer API. Addressing this requires either:

  • Auditing and refactoring all code paths that modify synced fields to ensure they go through Eloquent (significant effort in a legacy codebase)
  • Choosing an architecture where the source of events is explicit (Options 4/5/6), where services intentionally publish domain events rather than relying on model lifecycle hooks

This limitation strengthens the case for Options 4/5/6, where event publishing is an explicit, intentional action rather than a side effect of the ORM.

Current Real-time Capabilities

Some event-driven syncing currently works via Eloquent Observers:

TriggerCurrent ActionImplementation
Campaign status → active/pausedEnable/Disable offersCampaignObserver
Campaign capped → trueDisable offersCampaignObserver
App status → active/disabledEnable/Disable offersAppObserver

Contract Fields

The Offer API defines "contract fields" that represent the core identity of an offer. Changes to these fields require supersession (delete + recreate with new ID):

  • total_amount - Total reward amount for the user
  • total_payout_usd - Total payout to publisher
  • goals - Goal structure including individual payouts
  • is_multi_reward - Whether offer has multiple goals
  • location_targets - Geographic targeting
  • device_targets - Device type targeting
  • os_targets - Operating system targeting

Non-contract field changes can be applied via PATCH (update in place).

Entities Requiring Event-driven Sync

EntityField(s)ImpactAction
Goalmargin, revenuetotal_payout_usd, goalsSupersede
Goaldescription, order, completion timesMetadataUpdate
MarginOverridemargin_value, apps, campaigns, publishers, activetotal_payout_usdSupersede
Offerwallmultipliertotal_amountSupersede
Offerwallcurrency_name_singular, currency_name_pluralMetadataUpdate
CampaignOfferwallCreativeAll text/image fieldsMetadataUpdate
TargetingProfileandroid, ios, OS versionsdevice_targets, os_targetsSupersede
TargetLocation/TargetDeviceCreated/deletedlocation_targets, device_targetsSupersede
Campaignstart_datetime, end_datetime, etc.MetadataUpdate

Scale Considerations

  • Some apps have 1500+ offers
  • Targeting profiles are designed for one-to-many relationships (though rarely used this way in practice)
  • MarginOverrides can match broadly (by publisher, app, or campaign arrays where null means "all")

Considered Options

Option 1: Extend Eloquent Observer Pattern

Leverage the existing Observer infrastructure. Each model gets an observer that dispatches specialized jobs based on which fields changed.

┌─────────────────────────────────────────────────────────────────────┐
│ Option 1: Observer Pattern │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ Model Change Observer Job Queue │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────┐ │
│ │ Goal.margin │ ────> │ GoalObserver │ ────> │ Supersede│ │
│ │ changed │ │ wasChanged() │ │ Campaign │ │
│ └──────────────┘ └──────────────┘ │ Offers │ │
│ └──────────┘ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────┐ │
│ │ Creative.name│ ────> │ Creative │ ────> │ Update │ │
│ │ changed │ │ Observer │ │ Campaign │ │
│ └──────────────┘ └──────────────┘ │ Offers │ │
│ └──────────┘ │
└─────────────────────────────────────────────────────────────────────┘

Pros:

  • Uses Laravel's built-in wasChanged() for field-level detection
  • Simple to understand for developers familiar with Laravel
  • No new infrastructure required
  • Gradual rollout via feature flags

Cons:

  • Routing logic scattered across multiple observer classes
  • Each observer must independently determine supersede vs update
  • Harder to see the big picture of what triggers what
  • Deduplication and batching must be handled per-observer or in jobs
  • Adding new field handlers requires modifying observer classes
  • Previous real-time implementation failed using this pattern

Option 2: Centralized Event Bus with Field-Level Routing

Create an OfferSyncEventBus service that receives model change events and routes them to appropriate handlers based on configurable field→action mappings.

┌─────────────────────────────────────────────────────────────────────┐
│ Option 2: Event Bus Pattern │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ Model Change Lightweight Event Bus │
│ ┌──────────────┐ Observer ┌──────────────────────┐ │
│ │ Goal.margin │ ┌─────────┐ │ OfferSyncEventBus │ │
│ │ changed │──>│ Emits │─────────>│ │ │
│ └──────────────┘ │ Event │ │ ┌────────────────┐ │ │
│ └─────────┘ │ │ Field Router │ │ │
│ ┌──────────────┐ ┌─────────┐ │ │ │ │ │
│ │ Creative.name│──>│ Emits │─────────>│ │ goal.margin │ │ │
│ │ changed │ │ Event │ │ │ → Supersede │ │ │
│ └──────────────┘ └─────────┘ │ │ │ │ │
│ │ │ creative.* │ │ │
│ │ │ → Update │ │ │
│ │ └────────────────┘ │ │
│ │ │ │
│ │ ┌────────────────┐ │ │
│ │ │ Deduplication │ │ │
│ │ │ & Batching │ │ │
│ │ └────────────────┘ │ │
│ │ │ │ │
│ └──────────┼───────────┘ │
│ ▼ │
│ ┌──────────────┐ │
│ │ Job Queue │ │
│ └──────────────┘ │
└─────────────────────────────────────────────────────────────────────┘

Pros:

  • Centralized routing logic: All field→action mappings in one configurable location
  • Single point for batching/deduplication: Prevents duplicate jobs when multiple fields change
  • Better visibility: One place to understand all sync triggers
  • Cleaner separation of concerns: Observers emit events; bus decides actions
  • Easier configuration: Add new handlers without modifying observer code
  • Testability: Test routing logic once in the bus, not across multiple observers
  • Addresses previous failure: Previous implementation lacked proper field→action routing

Cons:

  • More upfront infrastructure to build
  • New pattern to learn for the team
  • Slightly more complex architecture

Option 3: Database Triggers / Change Data Capture

Use database-level triggers or CDC tools (e.g., Debezium) to capture changes and push to a message queue.

Pros:

  • Captures all changes regardless of application code path
  • Decoupled from application logic
  • Works for changes made outside the dashboard

Cons:

  • Significant infrastructure complexity
  • Harder to debug and test
  • Overkill for current requirements (changes originate from dashboard)
  • Database-specific implementation
  • Requires additional services (Kafka, Debezium, etc.)

Option 4: Domain Events to Offer API (Hybrid)

Rather than building the Event Bus in the dashboard, the dashboard emits lightweight domain events describing what changed, and the Offer API owns the Event Bus, routing logic, and transformation.

┌─────────────────────────────────────────────────────────────────────────────┐
│ Option 4: Domain Events to Offer API │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Dashboard Message Queue Offer API │
│ ┌──────────────┐ ┌─────────────┐ ┌────────────────┐ │
│ │ Goal.margin │ │ │ │ Event Bus │ │
│ │ changed │──(event)─>│ SQS/Redis │ ────────> │ ├─ Routing │ │
│ │ │ │ │ │ ├─ Transform │ │
│ │ Emits domain │ │ │ │ ├─ Supersede │ │
│ │ event only │ └─────────────┘ │ └─ Persist │ │
│ └──────────────┘ └────────────────┘ │
│ │
│ Dashboard knows: Offer API knows: │
│ - Campaigns, Goals - What changes mean for offers │
│ - Offerwalls, Margins - Field → action mappings │
│ - "What changed" - Supersession vs update logic │
│ - Batching, deduplication │
└─────────────────────────────────────────────────────────────────────────────┘

What are domain events?

Domain events describe what changed in the dashboard's domain without encoding what it means for offers:

// Dashboard emits these events (knows nothing about offers)
GoalMarginChanged { goal_id, campaign_id, old_margin, new_margin }
OfferwallMultiplierUpdated { offerwall_id, app_id, old_multiplier, new_multiplier }
CampaignCreativeUpdated { campaign_id, changed_fields[] }
MarginOverrideCreated { margin_override_id, apps[], campaigns[], publishers[] }

// Offer API translates these to offer operations
"GoalMarginChanged" → recalculate payouts → supersede affected offers
"OfferwallMultiplierUpdated" → recalculate rewards → supersede all app offers

This inverts responsibility: the dashboard knows about campaigns/goals/offerwalls; the Offer API knows about offers.

Pros:

  • Future-proofing: Logic lives in the target system (Offer API), not the legacy system (dashboard)
  • New clients: Future systems replacing the dashboard use the same event pathway
  • Clean separation: Dashboard describes "what changed"; Offer API decides "what to do"
  • Single source of truth: Offer API owns all offer-related business logic
  • Testability: Offer API can be tested independently with synthetic events

Cons:

  • More upfront work: Requires building infrastructure in Offer API
  • Domain coupling: Offer API must understand dashboard entities (Goal, Offerwall, etc.)
  • Cross-team coordination: Changes may require updates to both systems
  • Message queue dependency: Introduces SQS/Redis as critical infrastructure

Trade-offs: Option 2 vs Option 4

ConsiderationOption 2 (Dashboard Bus)Option 4 (Offer API Bus)
Time to implementFasterMore upfront work
Future-proofingLogic in legacy systemLogic in target system
New clientsMust replicate or call dashboardUse same event pathway
Domain couplingOffer API stays "dumb"Offer API understands source entities
Team ownershipDashboard team owns sync logicOffer API team owns sync logic
TestingTest in dashboard contextTest with synthetic events
DebuggingLogs in one systemLogs span two systems + queue

Future State: Service-Oriented Architecture

The current dashboard is being decomposed into separate services:

  • AdSuite: Campaign and goal management, including margin overrides (admin-facing)
  • Publisher Portal: App/property management (publisher-facing)

This evolution affects our architecture choices. Options 5 and 6 below extend Option 4 to accommodate multiple source services.

Why Not AWS Glue?

AWS Glue was considered as a centralized ETL layer for transforming source data into offers. However, Glue is not a good fit for this use case:

  • Batch-oriented: Glue is optimized for large batch ETL jobs, not real-time event processing
  • Cold start latency: Glue jobs have 30-60 second minimum startup time, incompatible with near-real-time requirements
  • Cost model: Glue pricing favors large, infrequent batch operations; frequent small changes would be expensive
  • Coupling concern: If source services calculate payouts before sending to Glue, we couple them to offer structure, defeating the separation of concerns

Glue could complement real-time syncing as a periodic reconciliation layer (e.g., nightly verification), but should not be the primary sync mechanism.

Option 5: Hybrid SQS with Service-Specific Queues

Extending Option 4, each source service publishes domain events to its own SQS queue. The Offer API consumes from all queues and owns the transformation logic.

┌───────────────────────────────────────────────────────────────────────────────┐
│ Option 5: Service-Specific SQS Queues │
├───────────────────────────────────────────────────────────────────────────────┤
│ │
│ AdSuite Publisher Portal │
│ (campaigns, goals, (apps, offerwalls) │
│ targeting profiles, │
│ margin overrides) │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ SQS: │ │ SQS: │ │
│ │ campaign- │ │ publisher- │ │
│ │ events │ │ events │ │
│ └──────┬──────┘ └──────┬──────┘ │
│ │ │ │
│ └─────────────────┬───────────────────┘ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ Offer API │ │
│ │ ┌───────────────┐ │ │
│ │ │ Event Bus │ │ │
│ │ │ - Routing │ │ │
│ │ │ - Transform │ │ │
│ │ │ - Dedup │ │ │
│ │ │ - Supersede │ │ │
│ │ └───────────────┘ │ │
│ └─────────────────────┘ │
└───────────────────────────────────────────────────────────────────────────────┘

Domain events by service:

// AdSuite emits campaign-domain events (including margin overrides)
GoalMarginChanged { goal_id, campaign_id, old_margin, new_margin }
GoalRevenueChanged { goal_id, campaign_id, old_revenue, new_revenue }
CampaignCreativeUpdated { campaign_id, changed_fields[] }
TargetingProfileUpdated { profile_id, campaigns[], changed_fields[] }
MarginOverrideCreated { margin_override_id, apps[], campaigns[], publishers[], margin_value }
MarginOverrideUpdated { margin_override_id, changed_fields[], apps[], campaigns[], publishers[] }
MarginOverrideDeleted { margin_override_id, apps[], campaigns[], publishers[] }

// Publisher Portal emits app-domain events
OfferwallMultiplierUpdated { offerwall_id, app_id, old_multiplier, new_multiplier }
OfferwallCurrencyUpdated { offerwall_id, app_id, currency_name, currency_name_plural }
AppStatusChanged { app_id, old_status, new_status }

Pros:

  • Service isolation: Each service owns its event schema and queue
  • Independent scaling: High-volume services can scale independently
  • Failure isolation: One queue's issues don't affect others
  • Clear ownership: Each team owns their event contract
  • Gradual migration: Services can migrate to new queues independently

Cons:

  • Multiple queues to manage: Operational overhead of multiple queues
  • Cross-service effects: MarginOverrides (in AdSuite) can affect offers for apps managed in Publisher Portal
  • Consumer complexity: Offer API must consume from multiple queues
  • Event ordering: No guaranteed ordering across queues (may matter for some scenarios)

Option 6: AWS EventBridge as Central Event Router

Use AWS EventBridge as a central event bus with pattern-based routing. All services publish to EventBridge; the Offer API subscribes to relevant event patterns.

┌───────────────────────────────────────────────────────────────────────────────┐
│ Option 6: AWS EventBridge Central Router │
├───────────────────────────────────────────────────────────────────────────────┤
│ │
│ AdSuite Publisher Portal │
│ (campaigns, goals, (apps, offerwalls) │
│ margin overrides) │
│ │ │ │
│ │ │ │
│ └──────────────────┬───────────────────┘ │
│ ▼ │
│ ┌──────────────────────────────┐ │
│ │ AWS EventBridge │ │
│ │ │ │
│ │ Rules: │ │
│ │ ┌────────────────────────┐ │ │
│ │ │ source: "adsuite" │──┼──┐ │
│ │ │ detail-type: "Goal*" │ │ │ │
│ │ │ | "Margin*" │ │ │ │
│ │ └────────────────────────┘ │ │ │
│ │ ┌────────────────────────┐ │ │ │
│ │ │ source: "publisher" │──┼──┤ │
│ │ │ detail-type: "App*" │ │ │ │
│ │ │ | "Offer*" │ │ │ │
│ │ └────────────────────────┘ │ │ │
│ │ │ │ │
│ │ Features: │ │ │
│ │ - Dead-letter queues │ │ │
│ │ - Event archive & replay │ │ │
│ │ - Schema registry │ │ │
│ └──────────────────────────────┘ │ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ Offer API │ │
│ │ ┌───────────────┐ │ │
│ │ │ Event Handler │ │ │
│ │ │ - Transform │ │ │
│ │ │ - Supersede │ │ │
│ │ │ - Persist │ │ │
│ │ └───────────────┘ │ │
│ └─────────────────────┘ │
└───────────────────────────────────────────────────────────────────────────────┘

Pros:

  • Single event bus: All services publish to one place
  • Pattern-based routing: EventBridge rules filter events to appropriate targets
  • Built-in features: Dead-letter queues, event archive, replay, schema registry
  • Decoupled producers/consumers: Services don't need to know about Offer API
  • Multiple consumers: Other services can subscribe to same events if needed
  • AWS managed: Serverless, auto-scaling, high availability

Cons:

  • Event size limit: 256KB per event (may matter for large payloads)
  • AWS lock-in: Tight coupling to AWS EventBridge
  • Cost: EventBridge charges per event published
  • Latency: Adds a hop compared to direct SQS
  • Learning curve: Team needs to learn EventBridge patterns and rules

Trade-offs: Option 5 vs Option 6

ConsiderationOption 5 (Service SQS)Option 6 (EventBridge)
InfrastructureMultiple SQS queuesSingle EventBridge bus
Routing logicIn Offer API consumerIn EventBridge rules
Event filteringApplication-levelEventBridge patterns
Archive/replayManual implementationBuilt-in
Schema validationManual implementationSchema registry available
Multi-consumerRequires SNS fan-outNative support
AWS dependencySQS onlyEventBridge + rules
Event orderingPer-queue FIFO availableBest-effort (no FIFO)

DECISION

RECOMMENDED APPROACH: Phased Investment — Minimal Dashboard Fixes + Offer API Event Infrastructure

Rather than building the full event bus in the dashboard (Option 2), we recommend a phased approach that balances immediate business impact with long-term architectural investment. This avoids over-investing in the legacy dashboard while building the real-time infrastructure in the right place.

Phase A: Minimal Dashboard Fixes (Short-term)

Apply targeted, low-cost fixes to the dashboard for the highest-impact pain points:

  1. Goal payout changes (margin/revenue) — these are the most time-sensitive and directly affect publisher earnings. A focused observer or hook for goal payout changes provides immediate business value with minimal code
  2. Campaign status/capping — already handled by existing observers (no work needed)
  3. Retain SyncOfferApiV2 as the safety net for all other changes

This phase keeps dashboard investment minimal and focused only on changes where the 10-40 minute latency causes real business problems. The Option 2 implementation details in this document provide useful reference for the observer and job patterns needed in this phase.

Phase B: Build Event-Driven Infrastructure in Offer API (Medium-term)

Invest primarily in the Offer API's ability to consume domain events (per Options 4/5/6):

  1. Build the Event Bus / event handler in the Offer API
  2. Define the domain event schema and contracts
  3. Stand up the message queue infrastructure (SQS, EventBridge, etc.)
  4. Have the dashboard emit domain events for the limited Phase A triggers as a proof-of-concept

This positions the Offer API to receive events from any source service — dashboard today, AdSuite and Publisher Portal tomorrow.

Phase C: AdSuite / Publisher Portal Integration (Long-term)

As the new services come online, they publish domain events natively:

  1. AdSuite publishes campaign, goal, and targeting events
  2. Publisher Portal publishes app, offerwall, and property events
  3. Dashboard event publishing is decommissioned as its functionality is absorbed
  4. SyncOfferApiV2 is fully retired

Why not build the full Option 2 Event Bus in the dashboard?

While Option 2 provides a well-designed architecture for dashboard-based syncing, the dashboard is being actively decomposed into AdSuite and Publisher Portal. Building the full event bus, routing logic, and deduplication infrastructure in the dashboard risks creating throwaway work. Additionally, the dashboard has known access patterns that bypass Eloquent model events (see Context section), which limits the reliability of any observer-based approach.

Options 4/5/6 — where the Offer API owns the event bus and routing logic — are better long-term investments. The phased approach lets us capture the most urgent business value (Phase A) while directing the real infrastructure investment toward the target architecture (Phase B/C).

Why Centralized Event Bus Over Scattered Observers?

Regardless of where the event bus lives (dashboard in Phase A, Offer API in Phase B), the centralized bus pattern is preferred over scattered observers:

  1. Learned from history: The previous observer-based implementation failed because it lacked proper field→action discrimination. A centralized bus makes this routing explicit and configurable.

  2. Maintainability: As the number of tracked fields grows, having routing logic in one place prevents the "scattered observers" problem.

  3. Deduplication: When a single save touches multiple fields (e.g., campaign edit updates description AND instructions), the bus can deduplicate into a single sync action.

  4. Auditability: The bus can log all routing decisions, making it easier to debug why an offer was superseded vs updated.

Phase A Reference: Option 2 Implementation Details

The following implementation details from Option 2 serve as reference material for Phase A's targeted dashboard work (goal payout observer, sync jobs) and as a blueprint for the eventual Offer API event bus in Phase B. Not all of this needs to be built in Phase A — only the components needed for goal payout syncing.

1. OfferSyncEventBus Service

// app/Services/OfferApi/OfferSyncEventBus.php
class OfferSyncEventBus
{
// Configuration-driven field → action mapping
private array $supersessionTriggers = [
Goal::class => ['margin', 'revenue'],
Offerwall::class => ['multiplier'],
TargetingProfile::class => ['android', 'ios', 'min_ios', 'max_ios', 'min_android', 'max_android'],
];

private array $updateTriggers = [
Goal::class => ['description', 'order', 'minimum_completion_time', ...],
Offerwall::class => ['currency_name_singular', 'currency_name_plural'],
CampaignOfferwallCreative::class => ['*'], // All fields trigger update
Campaign::class => ['start_datetime', 'end_datetime', ...],
];

public function handle(ModelChangedEvent $event): void
{
// 1. Check feature flag
// 2. Determine action based on changed fields
// 3. Deduplicate if multiple changes to same entity
// 4. Dispatch appropriate job (or skip if no action needed)
}
}

Scenarios where no job is dispatched:

ScenarioExampleReason
Field not synced to Offer APIOfferwall currency_image, hex, button_accent_color, tab labelsThese fields are UI/display settings not included in offer data
Feature flag disabledApp not in useRealTimeOfferSync listReal-time sync not enabled for this app
No active offers existCampaign is paused or app is disabledNo offers in Offer API to update; changes will apply when campaign/app reactivates
Internal operational fieldsGoal is_capping_goal, conversion_limit, spend_limitUsed for internal tracking, not part of offer contract
Higher-scope job pendingCampaign update while app supersede is queuedDeduplication: higher-scope job will include this change
Non-payable goal changedProgress goal (zero revenue) updatedProgress goals don't affect offer payout calculations

Fields NOT synced to Offer API (no job needed):

Offerwall:

  • currency_image - icon asset, not synced
  • hex, button_accent_color, button_font_color - color theming
  • header_image, banner_image_path - offerwall UI assets
  • offer_filter_setting, category_filter - filtering logic
  • randomization_chance - display algorithm setting
  • decimal_setting, use_thousands_separator - number formatting
  • my_offers_tab_label, all_offers_tab_label, custom_item_label, custom_item_label_plural - UI labels
  • title - offerwall title, not per-offer

Goal:

  • is_attribution, is_purchase_goal, is_capping_goal - internal classification flags
  • conversion_limit, spend_limit - capping thresholds (handled separately)
  • external_goal_id - third-party reference

Campaign:

  • Internal tracking fields, advertiser relationships, and other fields not passed to OfferSyncFormatter
// Example: Bus determines no action needed
class OfferSyncEventBus
{
private array $ignoredFields = [
Offerwall::class => [
'currency_image', 'hex', 'header_image', 'banner_image_path',
'button_accent_color', 'button_font_color', 'offer_filter_setting',
'category_filter', 'randomization_chance', 'decimal_setting',
'use_thousands_separator', 'my_offers_tab_label', 'all_offers_tab_label',
'custom_item_label', 'custom_item_label_plural', 'title',
],
Goal::class => [
'is_attribution', 'is_purchase_goal', 'is_capping_goal',
'conversion_limit', 'spend_limit', 'external_goal_id',
],
];

public function handle(ModelChangedEvent $event): void
{
$changedFields = array_keys($event->changedFields);
$ignoredForModel = $this->ignoredFields[get_class($event->model)] ?? [];

// Filter out ignored fields
$relevantChanges = array_diff($changedFields, $ignoredForModel);

if (empty($relevantChanges)) {
Log::debug("No relevant field changes for offer sync", [
'model' => get_class($event->model),
'changed_fields' => $changedFields,
]);
return; // No job dispatched
}

// Continue with routing logic...
}
}

2. Deduplication Logic

Deduplication prevents redundant or conflicting API calls when multiple changes occur that affect the same offers. There are two levels of deduplication to consider:

Within-Request Deduplication

When a single form save touches multiple fields, the bus coalesces events before dispatching:

ScenarioWithout DeduplicationWith Deduplication
Campaign edit updates description AND instructions2 UpdateOffersForCampaignJob dispatched1 UpdateOffersForCampaignJob dispatched
Goal margin AND revenue both change2 SupersedeOffersForCampaignJob dispatched1 SupersedeOffersForCampaignJob dispatched
Multiple goals on same campaign editedN jobs (one per goal)1 job (per campaign)

Implementation: The bus buffers events during the request lifecycle and flushes once at the end, grouping by sync scope (campaign ID or app ID).

Cross-Request Deduplication

When changes happen in different parts of the system (different requests, users, or processes), we need job-level coordination:

Scenario 1: Offerwall multiplier updated + MarginOverride created for same app (both supersessions)

Request A: Offerwall.multiplier changed → SupersedeOffersForAppJob(app:123)
Request B: MarginOverride created for app:123 → SupersedeOffersForMarginOverrideJob

Timeline Option 1: App job runs first
→ App job fetches current DB state (includes new MarginOverride)
→ All offers superseded with correct payout (both changes reflected)
→ MarginOverride job runs but produces identical offers (redundant work)

Timeline Option 2: MarginOverride job runs first
→ Subset of offers superseded with new payout
→ App job runs, supersedes ALL offers again (redundant for the subset)

Resolution: Use Laravel's ShouldBeUnique with scope-aware unique IDs. The app-level supersede takes precedence:

class SupersedeOffersForAppJob implements ShouldBeUnique
{
public function uniqueId(): string
{
return "supersede:app:{$this->appId}";
}
}

class SupersedeOffersForMarginOverrideJob implements ShouldBeUnique
{
public function handle(): void
{
// Check if app-level supersede is queued/running
foreach ($this->affectedAppIds as $appId) {
if (Cache::has("supersede:app:{$appId}:pending")) {
// Skip this app - will be handled by app-level job
continue;
}
// Process offers for this app
}
}
}

Scenario 2: Offerwall multiplier updated (supersede) + Campaign instructions updated (update)

Request A: Offerwall.multiplier changed → SupersedeOffersForAppJob(app:123)
Request B: CampaignOfferwallCreative.instructions changed → UpdateOffersForCampaignJob(campaign:456)

Resolution: Supersede encompasses update. The supersede job recreates offers with all current data (including updated instructions). The update job should check for pending/recent supersessions:

class UpdateOffersForCampaignJob implements ShouldBeUnique
{
public function handle(): void
{
$appId = $this->campaign->apps->first()->id; // or iterate all apps

// If app-level supersede is pending, skip - it will include our changes
if (Cache::has("supersede:app:{$appId}:pending")) {
Log::info("Skipping update for campaign {$this->campaignId} - app supersede pending");
return;
}

// Proceed with update
}
}

Scenario 3: Offerwall currency names updated (update) + Campaign instructions updated (update)

Request A: Offerwall.currency_name changed → UpdateOffersForAppJob(app:123)
Request B: CampaignOfferwallCreative.instructions changed → UpdateOffersForCampaignJob(campaign:456)

Resolution: Both are updates but at different scopes. Since they patch different fields, both can proceed independently. However, if they race to the Offer API, we should ensure idempotency:

class UpdateOffersForAppJob
{
public function handle(): void
{
// Only patch the fields relevant to this change
$patchData = [
'currency_name' => $this->offerwall->currency_name_singular,
'currency_name_plural' => $this->offerwall->currency_name_plural,
];

foreach ($this->getOffersForApp() as $offer) {
$this->offerApiClient->patch($offer['id'], $patchData);
}
}
}

class UpdateOffersForCampaignJob
{
public function handle(): void
{
// Only patch the fields relevant to this change
$patchData = [
'instructions' => $this->creative->offer_instructions,
];

foreach ($this->getOffersForCampaign() as $offer) {
$this->offerApiClient->patch($offer['id'], $patchData);
}
}
}
Deduplication Summary
Scope ConflictResolution
App supersede + Campaign supersedeApp supersede wins (encompasses campaign)
App supersede + Campaign updateApp supersede wins (includes fresh data)
App supersede + MarginOverride supersedeApp supersede wins; MarginOverride skips affected apps
App update + Campaign updateBoth proceed (patch different fields)
Campaign supersede + Campaign update (same campaign)Supersede wins

Key mechanisms:

  1. ShouldBeUnique: Prevents duplicate jobs for the same scope
  2. Pending flags in cache: Allow jobs to detect and defer to higher-scope operations
  3. Field-specific patches: Update jobs only patch their relevant fields, allowing concurrent updates at different scopes

3. Lightweight Observers

Observers become simple event emitters:

// app/Observers/GoalObserver.php
class GoalObserver
{
public function saved(Goal $goal)
{
if ($goal->wasChanged()) {
ModelChangedEvent::dispatch(
model: $goal,
changedFields: $goal->getChanges(),
originalValues: $goal->getOriginal()
);
}
}
}

4. Core Jobs

Create foundational jobs that the bus will dispatch:

  • SupersedeOffersForCampaignJob - Deletes and recreates offers for a campaign across all apps
  • SupersedeOffersForAppJob - Deletes and recreates all offers for an app (with batching)
  • UpdateOffersForCampaignJob - Patches metadata for all offers of a campaign
  • UpdateOffersForAppJob - Patches metadata for all offers of an app

5. Feature Flag Control

Add DevCycle feature flag useRealTimeOfferSync to control rollout:

// DevCycleService.php
public function usesRealTimeOfferSync(int $appId): bool
{
$enabledApps = $this->getVariable('useRealTimeOfferSync', []);
return in_array($appId, $enabledApps);
}

6. MarginOverride Handling

Due to the complex matching logic (publishers, apps, campaigns arrays), MarginOverride changes will use a two-phase approach:

┌─────────────────────────────────────────────────────────────────────┐
│ MarginOverride Discovery Phase │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ MarginOverrideUpdated Discovery Batched Jobs │
│ ┌──────────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Event Fired │ ───> │ Identify all │ ───> │ Supersede │ │
│ │ │ │ affected │ │ in batches │ │
│ │ │ │ offers │ │ │ │
│ └──────────────────┘ │ │ │ Log if >100 │ │
│ │ Log count │ │ offers │ │
│ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────────────────────┘

7. Batching & Rate Limiting

For apps with large offer counts (1500+):

// config/offer-api.php
'realtime_sync' => [
'batch_size' => 50, // Offers per batch
'batch_delay_ms' => 500, // Delay between batches
'large_impact_threshold' => 100, // Log warning if exceeds
],

8. Scheduled Sync Retention

The SyncOfferApiV2 command will be retained but unscheduled:

  • Available for manual execution via --force flag
  • Add --dry-run flag to preview sync without executing
  • Can be re-enabled if real-time sync encounters issues

Phased Rollout Plan

This rollout plan aligns with the three-phase approach in the DECISION section:

Phase A: Minimal Dashboard Fixes (Short-term)
├── GoalObserver for payout changes (margin/revenue)
├── Core sync jobs (SupersedeOffersForCampaignJob)
├── DevCycle feature flag (useRealTimeOfferSync)
├── Retain SyncOfferApiV2 as safety net
└── Gradual rollout: test apps → 10% → 25% → 50% → 100%

Phase B: Offer API Event Infrastructure (Medium-term)
├── Event Bus / event handler in Offer API
├── Domain event schema and contracts
├── Message queue infrastructure (SQS or EventBridge)
├── Dashboard emits domain events for Phase A triggers (proof-of-concept)
├── Offer API consumes events and owns routing/transformation
└── Monitoring & alerting

Phase C: Service Integration (Long-term)
├── AdSuite publishes campaign, goal, targeting events
├── Publisher Portal publishes app, offerwall events
├── Dashboard event publishing decommissioned
└── SyncOfferApiV2 fully retired

The Option 2 implementation details above (OfferSyncEventBus, deduplication, batching, etc.) can inform both Phase A's targeted observer work and Phase B's Offer API event bus design.

CONSEQUENCES

Positive Outcomes

  1. Reduced Latency: Changes propagate to Offer API within seconds/minutes instead of 10-40 minutes
  2. Improved Accuracy: Payout and contract changes reflect immediately
  3. Operational Efficiency: Only changed data is processed, reducing API load
  4. Better Publisher Experience: Dashboard changes visible quickly
  5. Audit Trail: Centralized bus enables comprehensive logging of sync decisions
  6. Learned Architecture: Addresses root cause of previous implementation failure

Negative Outcomes

  1. Initial Complexity: Event bus requires upfront infrastructure investment
  2. New Pattern: Team needs to learn the event bus approach
  3. Testing Overhead: Bus routing logic and each observer require testing
  4. Potential for Cascading Updates: One change could trigger multiple offer updates

Risks

RiskLikelihoodImpactMitigation
Offer API overload from burst of changesMediumHighBatching, rate limiting, circuit breaker in bus
Missed updates due to routing bugsLowHighComprehensive testing, retain scheduled sync as fallback
Large blast radius from MarginOverride changesMediumMediumDiscovery phase with logging, configurable thresholds
Queue backlog during bulk operationsMediumMediumUnique job constraints, batch processing
Feature flag misconfigurationLowMediumDefault to disabled, gradual rollout
Team unfamiliarity with event busLowLowDocumentation, code review, pairing
Dashboard deprecation investment riskMediumHighPhased approach limits dashboard investment (see DECISION)
Access patterns bypassing model eventsHighHighPhase A is limited in scope; Phase B uses explicit event publishing

Dashboard Deprecation Risk

The dashboard is being actively decomposed into AdSuite and Publisher Portal. The phased approach in the DECISION section directly addresses this risk by limiting dashboard investment (Phase A) and directing the real infrastructure work toward the Offer API (Phase B) and the new services (Phase C).

Migration Strategy

Phase A Rollout:

  1. Deploy goal payout observer and sync jobs to all environments
  2. Enable feature flag for 1-2 test apps
  3. Monitor for 1 week, verify offer consistency
  4. Gradually enable for production apps (10% → 25% → 50% → 100%)
  5. SyncOfferApiV2 remains scheduled as safety net throughout Phase A

Phase B Transition:

  1. Build event infrastructure in Offer API
  2. Dashboard begins emitting domain events alongside existing observer-based sync
  3. Validate Offer API event processing produces identical results
  4. Cut over from dashboard observer sync to Offer API event-driven sync
  5. Unschedule SyncOfferApiV2 once event-driven sync covers all entity types

Phase C Decommission:

  1. New services publish events directly; dashboard event publishing removed
  2. SyncOfferApiV2 fully retired
  3. Retain manual sync command capability for emergency use

NOTES

References

  • AGPI-1517: Real-time Offer API Syncing (Epic)
  • AGPI-1516: Discovery & ADR (this document)

Original Author

Micah Wierenga

Approval date

Approved by

APPENDIX

Proposed Story Breakdown

Phase A: Minimal Dashboard Fixes

StoryTitlePriorityEst. Points
A1GoalObserver for payout changes (margin/revenue) with field-level routingP05
A2SupersedeOffersForCampaignJob (core sync job)P05
A3DevCycle feature flag for real-time syncP02
A4Gradual rollout and validation (test apps → production)P03

Phase A Estimated Points: 15

Phase B: Offer API Event Infrastructure

StoryTitlePriorityEst. Points
B1Domain event schema and contracts definitionP05
B2Message queue infrastructure (SQS/EventBridge)P05
B3Offer API Event Bus: event handler and routing logicP08
B4Offer API: field→action mapping and supersede/update logicP08
B5Dashboard emits domain events for goal payout changes (proof-of-concept)P15
B6Offer API: deduplication and batchingP15
B7Offer API: MarginOverride event handlingP18
B8Monitoring & alertingP25

Phase B Estimated Points: 49

Phase C: Service Integration

StoryTitlePriorityEst. Points
C1AdSuite publishes campaign, goal, and targeting domain eventsP08
C2Publisher Portal publishes app, offerwall domain eventsP08
C3Decommission dashboard event publishingP13
C4Retire SyncOfferApiV2P22

Phase C Estimated Points: 21

Total Estimated Points: 85 (across all phases)

Note: Phase A is designed to be small and deliver immediate business value. Phase B represents the bulk of the architectural investment but is built in the right system (Offer API). Phase C depends on the AdSuite/Publisher Portal timeline and can be scoped as those services come online.

Model Reference

The CampaignOfferwallCreative model contains offer display content. For brevity in code examples, we refer to its observer as CreativeObserver.

Current SyncOfferApiV2 Flow

// app/Console/Commands/SyncOfferApiV2.php
// 1. Query DevCycle for apps at specified frequency
// 2. For each app, dispatch UpdateOfferApiForAppJob

// app/Jobs/OfferApi/UpdateOfferApiForAppJob.php
// 1. Fetch AppCampaigns with all relationships
// 2. Format offers using OfferSyncFormatter
// 3. Pass to OfferApiSynchronizationService->sync()

// app/Libraries/OfferApi/OfferApiSynchronizationService.php
// 1. Fetch existing offers from Offer API
// 2. Compare using OfferSyncHelper->determinePatches()
// 3. CREATE missing, UPDATE changed, DISABLE removed
// 4. Contract field changes trigger DELETE + CREATE (supersession)

Configuration Reference

// config/offer-api.php
return [
'url' => env('OFFER_API_URL'),
'internal_api_key' => env('OFFER_API_INTERNAL_API_KEY'),
'contract_fields' => [
'total_amount',
'total_payout_usd',
'goals',
'is_multi_reward',
'location_targets',
'device_targets',
'os_targets',
],
'jobs' => [
'retry_on_error' => true,
'retry_limit' => 10,
'retry_delay' => 2,
'retry_http_statuses' => [500, 502, 503, 504, 429],
],
// Proposed additions for real-time sync
'realtime_sync' => [
'enabled' => false, // Controlled by DevCycle per-app
'batch_size' => 50,
'batch_delay_ms' => 500,
'large_impact_threshold' => 100,
],
];

Questions for Team Discussion

  1. Service-Specific Queues vs EventBridge (Option 5 vs 6) for Phase B:

    • Do we need the advanced features of EventBridge (archive, replay, schema registry)?
    • Is event ordering across services important for our use case?
    • What is our team's familiarity with EventBridge vs SQS?
  2. Cross-Service Effects: MarginOverrides (managed in AdSuite) can affect offers for apps managed in Publisher Portal. How should the Offer API handle events that have cross-service implications?

  3. Existing Event Cleanup: Should we remove the unused MarginOverrideCreated/Updated/Deleted and OfferwallCreated/Updated events, or repurpose them as the foundation for domain events in Phase B?

  4. Targeting Profile Scope: When a targeting profile changes, should we supersede offers for ALL campaigns using it (could be large), or add a threshold notification and process in batches?

  5. Phase B Timeline: What is the realistic timeline for building the Offer API event infrastructure? Should Phase B begin in parallel with Phase A rollout, or sequentially after Phase A is validated?