Skip to content

AlertCursorManager

Manages cursor-based iteration state for the PriceAlert alarm loop. Tracks which second of price data has been processed, enabling reliable catch-up after DO evictions.

High-Level Design

Core Concept: The Cursor

The PriceAlert alarm fires every 1 second. Each second of price data has a unix timestamp. The cursor is the last timestamp that was successfully processed. On each alarm:

cursor = 1705000010 (last processed)
now    = 1705000013

→ Process: 1705000011, 1705000012, 1705000013
→ Advance cursor to 1705000013

This gives the alarm loop exactly-once semantics — it always knows where to resume, even after eviction.

Data Flow

alarm() fires


cursorManager.getCursorRange(now)
    │  returns { startCursor: cursor+1, endCursor: now }

alarm() caps endCursor to startCursor + 3599  ◄── Unbounded catch-up protection


Fetch prices for [startCursor..endCursor] from PriceDataManager


For each timestamp in range:
    ├─ Has price data? → evaluate alerts, advance lastProcessedTs
    ├─ Empty but finalized? → skip, notify arrival alerts, advance
    └─ Not finalized? → STOP (don't advance cursor past this point)


cursorManager.updateCursor(lastProcessedTs, triggeredCount)
    │  persists to SQLite + updates in-memory state

Next alarm picks up from lastProcessedTs + 1

Key Design Decisions

Safe cursor advancement. The cursor only advances to the last timestamp where data was successfully processed. If timestamp T has no data and isn't finalized (backfill still in progress), the loop breaks and the cursor stays at T-1. The next alarm retries from T.

Catch-up window cap. After a long eviction (e.g., 2 hours), processing all missed seconds in one alarm would spike CPU. The MAX_CATCHUP_WINDOW = 3600 (1 hour) caps the range per alarm cycle. Remaining seconds are caught up in subsequent alarms.

Finalized-empty gap handling. PriceCollector reports a finalizedUpTo watermark. If a timestamp is before this watermark (ts < finalizedUpTo) but has no data, it means data will never arrive (interpolation exhausted). The cursor skips it but still fires price arrival alerts with empty data so pending market orders fail gracefully instead of staying orphaned.

Always fetch prices. The alarm fetches prices even when no alerts exist. This populates the PriceDataManager's in-memory cache, which serves as a read replica for UserPortfolio price queries.

State & Storage

In-Memory State

PropertyTypeLifecycle
lastCheckedTimestampnumberLoaded from SQLite on initialize(), updated on each updateCursor() call.

SQLite Tables

alert_metrics (singleton row, id = 1)

ColumnTypePurpose
total_alertsintegerCumulative alerts registered
alerts_triggered_todayintegerRolling triggered count
last_checked_timestampintegerCursor position (unix seconds)

On initialize(), the in-memory value is restored from SQLite. On first-ever run, cursor seeds to now - 1 to avoid processing unbounded history.

Interactions

  • PriceAlert alarm loop — sole consumer. Calls getCursorRange() → processes data → calls updateCursor().
  • PriceAlert health endpoint — calls getHealthStatus() for monitoring dashboard metrics (flushed to Analytics Engine every 60s).

Edge Cases

  • First-ever initialization seeds cursor to now - 1 to avoid processing unbounded history.
  • No timestamps processed — if the first timestamp in the range has no data and isn't finalized, lastProcessedTs remains at startCursor - 1 (the current cursor), so updateCursor() is called but does not advance.
  • getCursorRange returns timestampsToCheck <= 0 when called faster than once per second; callers must handle this.
  • Staleness threshold of 5 seconds means brief DO evictions are tolerated without marking unhealthy.

See Also