Appearance
CallbackManager
Delivers hourly and daily account value extremes to UserPaperTradePortfolio DOs with reliable retry on failure.
Purpose
CallbackManager is a stateless emission layer — it does not compute extremes (that's ExtremesTrackingManager) or decide when to emit (that's the parent DO's alarm pipeline). Its sole job is: given a payload and a target user, deliver it via RPC and handle failures.
High-Level Design
Data Flow
Parent DO alarm pipeline
│
├─ Step 0: Resume pending callbacks (from previous alarm's subrequest limit)
├─ Step 1: handleDayBoundary() ──► callbackManager.emitDailyCallbacksBatch()
├─ Step 2: handleHourBoundary() ──► callbackManager.emitHourlyCallbacksBatch()
├─ Step 3: batchProcessPrices() (extremes calculation, no callbacks)
├─ Step 4: retryFailedCallbacks()
└─ Step 5: Periodic cleanup of old hourly snapshots
│
▼
UserPaperTradePortfolio DO (via RPC)Batch Emission
Both hourly and daily callbacks use Promise.allSettled for parallel delivery, capped at 500 per alarm (Cloudflare subrequest limit). When the limit is reached, unprocessed callbacks are held in memory for the next alarm cycle — intentionally not persisted to SQLite because:
- Daily: Progress is tracked per-row via
daily_dateon each tracking record, so the next alarm safely resumes via keyset pagination cursor - Hourly: The receiving side uses UPSERT, making re-delivery idempotent
Daily vs Hourly Differences
| Aspect | Hourly | Daily |
|---|---|---|
| Trigger | Hour boundary crossed | UTC midnight crossed |
| Precondition | None | All trackings must have caught up to end of previous day |
| Pagination | In-memory index over tracking array | Keyset pagination (WHERE user_id > cursor) to handle row mutation |
| Closing value | Last price tick in the hour | Midnight price via coefficientManager.calculateValueAtTimestamp() |
Why keyset pagination for daily? After a successful daily callback, resetTrackingForNewDay mutates the tracking's daily_date. OFFSET-based pagination would skip or duplicate rows. Keyset pagination is stable under mutation.
Why deferred daily emission? Daily callbacks are only emitted after ALL trackings have been processed up to end-of-day. This guarantees the daily peak/bottom values are complete — no partial data.
Daily closing value fallback: If midnight price or coefficients are unavailable, the daily peak value is used as the closing value.
Failed Callback Retry
Failed callbacks are persisted to failed_callbacks table on first failure and retried with exponential backoff:
Attempt: 1 2 3 4 5
Delay: 1m 5m 15m 1h 2h → dropped- Retries are batched (50 per alarm, lower than the 500 emission cap to leave headroom)
- On successful daily retry: advances
tracking.daily_dateto prevent duplicate daily emissions - Duplicate prevention: users with pending daily retries in
failed_callbacksare excluded from new daily emission batches
Edge Cases & Error Handling
- Individual callback failures do not affect other callbacks in a batch (
Promise.allSettled) - After 5 retries (total window ~3.5h), the callback is dropped and an error is logged
- In-memory pending state is safe to lose on DO eviction: daily progress is per-row, hourly is idempotent via UPSERT
- The parent DO's alarm adapts interval: 100ms when pending callbacks remain, 60s waiting for price data, 5 min steady state
See Also
- ExtremesTrackingManager — computes the extremes that callbacks deliver
- AccountValueTrackingManager — manages the tracking records referenced by callbacks
- AccountValueAggregationTracker — parent DO