Appearance
PriceDataManager
Read-through cache between PriceAlert and PriceCollector. Fetches, caches, and serves price data with retry, deduplication, and geographic routing.
High-Level Design
Role: Read Replica for the System
PriceDataManager is the sole price data gateway for the PriceAlert DO. Every price query — whether from the internal alarm loop or external RPCs — flows through this manager. It exists to reduce RPC pressure on PriceCollector by absorbing repeated reads into in-memory caches.
┌─────────────────────────────────────┐
│ PriceCollector │ (source of truth, geo-located)
│ Polls Hyperliquid 1 req/sec │
│ Stores in binary shards │
└──────────────┬──────────────────────┘
│ RPC (on cache miss only)
▼
┌─────────────────────────────────────┐
│ PriceDataManager │ (in-memory read replica)
│ │
│ ┌─────────┐ ┌─────────┐ ┌────────┐ │
│ │ Tick │ │ 1m │ │ 1h │ │
│ │ Cache │ │ Candle │ │ Candle │ │
│ │ 300/5m │ │ 120/2h │ │ 48/2d │ │
│ └─────────┘ └─────────┘ └────────┘ │
│ │
│ Inflight dedup ─ Retry ─ Geo route │
└──────────┬──────────┬───────────────┘
│ │
┌─────┘ └──────┐
▼ ▼
Alarm Loop Read Replica RPC
(internal) (external callers)
│ │
▼ ▼
AlertEvaluation UserPortfolio
Manager AccountValueTrackerThree Cache Tiers
| Cache | Key Granularity | Max Entries | TTL | Use Case |
|---|---|---|---|---|
priceCache | 1-second ticks | 300 (5 min) | 5 min | Alarm loop, price arrival alerts |
candle1mCache | Minute-aligned | 120 (2 hrs) | 2 hrs | Candle queries |
candle1hCache | Hour-aligned | 48 (2 days) | 2 days | AccountHealthManager hourly filtering |
All caches are purely in-memory — no SQLite persistence. On DO eviction, caches rebuild organically from cache misses.
Geographic Routing
Each PriceAlert DO name encodes its location (e.g., wnam-alert-3). PriceDataManager extracts the first segment (wnam) and routes all RPC calls to the co-located PriceCollector. This keeps fetches within the same data center, minimizing latency.
Inflight Request Deduplication
When multiple callers request the same timestamp concurrently (common during the alarm loop), only one RPC is made. Subsequent callers await the same promise:
Caller A requests T=100 ──→ creates inflight promise ──→ RPC to PriceCollector
Caller B requests T=100 ──→ sees inflight promise ──→ awaits same promise
│
Both receive result ◄────────────────────────────────────┘The inflight map is cleaned in a finally block to prevent memory leaks regardless of success or failure.
Empty Results Are Never Cached
When PriceCollector returns empty data for a timestamp (e.g., data not yet finalized), PriceDataManager does not cache it but still includes it in the returned result map. This ensures callers see the timestamp was attempted, while the next request retries the fetch rather than serving a stale miss. The check uses prices.size > 0 — BTCUSD always exists in valid data, so size 0 reliably indicates absence.
Retry with Exponential Backoff
All fetch methods retry up to 3 times (configurable) with exponential backoff: 200ms → 400ms → 800ms. After exhausting retries, the last error is thrown with context about what was being fetched.
Range Fetch Optimization
getPriceDataRangeWithRetry() (used by the alarm loop) scans the full [startTime, endTime] range against cache, identifies the bounding uncached sub-range, and fetches only that sub-range. It also fetches finalizedUpTo in parallel to inform cursor advancement. When fully cached, no RPC is made and finalizedUpTo returns 0 (signaling the caller that no fresh finalization info is available).
Special Handling
getPriceDataWithRetry(timestamps: number[], maxRetries?: number): Promise<Map<number, PriceData>>
Primary price fetch method. Runs in two phases per retry attempt:
Phase 1 — Fetch: For timestamps not in cache or inflight, issues a single RPC to PriceCollector. Registers inflight promises so concurrent callers deduplicate.
Phase 2 — Wait: For timestamps that were already inflight when the call started, awaits their promises. If an inflight promise resolved and was cleaned up before this phase, falls back to cache lookup. Throws if neither inflight nor cache yields data.
Only caches non-empty results. Throws after all retries exhausted.
getPriceDataRangeWithRetry(startTime: number, endTime: number, maxRetries?: number): Promise<{ prices: Map<number, PriceData>; finalizedUpTo: number }>
Fetches a contiguous range. Scans cache for the full range and only fetches the uncached sub-range. Also fetches finalizedUpTo in parallel. Returns { finalizedUpTo: 0 } when fully cached.
get1mCandlesWithRetry(timestamps: number[], maxRetries?: number): Promise<Map<number, Map<string, CandleDataPoint>>>
Fetches 1-minute candle data. Aligns timestamps to minute boundaries (Math.floor(ts / 60) * 60). Cache-first with retry.
get1hCandlesWithRetry(timestamps: number[], maxRetries?: number): Promise<Map<number, Map<string, CandleDataPoint>>>
Fetches 1-hour candle data. Aligns timestamps to hour boundaries (Math.floor(ts / 3600) * 3600). Cache-first with retry.
State & Storage
In-Memory State
| Property | Type | Max Size | TTL |
|---|---|---|---|
priceCache | Map<number, PriceData> | 300 entries | 5 minutes |
candle1mCache | Map<number, Map<string, CandleDataPoint>> | 120 entries | 2 hours |
candle1hCache | Map<number, Map<string, CandleDataPoint>> | 48 entries | 2 days |
inflightPriceDataFetches | { [timestamp]: Promise<PriceData> } | unbounded | Cleared after promise settles |
SQLite Tables
None. This manager is purely in-memory.
Interactions
Depends On
- PriceCollector DO (via
getPriceCollectorDO(locationHint)):getPriceData(),getPriceDataRange(),getFinalizedUpTo(),get1mCandles(),get1hCandles().
Depended By
- PriceAlert alarm loop: fetches price data for each cursor tick via
getPriceDataRangeWithRetry(). - PriceAlert
getPriceData()RPC (read replica): serves cached price data to UserPaperTradePortfolio and AccountValueAggregationTracker. - AlertEvaluationManager: indirectly, via prices passed from the alarm loop.
Edge Cases
- Empty price data is never cached — PriceCollector may have data on a subsequent attempt.
- Inflight deduplication prevents duplicate RPCs. Cleaned up in
finallyto avoid leaks. - Cache eviction removes oldest entries first (sorted by timestamp).
- TTL cleanup runs on
initialize()to purge stale entries from a previous activation. findUncachedRangereturns the bounding range of uncached timestamps, which may include some cached timestamps in the middle; these are overwritten on fetch.- All retry methods throw after exhausting retries, propagating the last error.
See Also
- AlertCursorManager -- determines the timestamp range to fetch prices for
- AlertEvaluationManager -- evaluates alerts using fetched prices
- AlertStorageManager -- provides alerts that drive which symbols need prices
- Parent DO:
PriceAlert