Skip to content

Cache architecture

How CepatEdge caches at the edge: Durable Objects (not KV), registry-backed shards, and multi-shard reads for time-series data.

Code: apps/worker/src/lib/services/cache/ · lifecycle contracts: ../lifecycle/cache/ · engineering map: ../../maintainer/systems/cache.md.


DO vs KV

FeatureDurable ObjectsKV
ConsistencyStrongEventual
TransactionsYesNo
LatencyHot <10ms, warm <50msGlobal <10ms
QueriesFiltering / aggregation in-objectKey-value only
CostIncluded with WorkersPer-read pricing
FitCache + logs that need consistency and range readsSimple eventual KV

Maintenance cache → DO. Updates must be visible immediately across Workers; invalidation often touches multiple keys atomically. Eventual KV would risk stale workflow state (approve / assign / complete).

Logs → DO. Timestamp-indexed keys and storage.list({ start, end, limit }) support range queries and pagination. KV cannot range-query by time without a separate index (or expensive scans), and read-heavy dashboards would cost more.

Recommendation: Keep DO for maintenance cache and logs. Use per-key timestamp storage and paginate (500–1K entries per read).


Which caches need multi-shard

Cache typeMulti-shard?StrategyNotes
Internal logsYesMonth-basedAccumulates; admin/user queries across months
MaintenanceMaybeRegistry / IDOnly if list results are cached or a shard hits size limits; today: single-request lookups
Auth rate limitsNoSingle shardPer-identifier + TTL
AnalyticsMaybeMonth-basedOnly if storing raw events; today: query-result cache

Multi-shard reader (shipped)

Central reader: apps/worker/src/lib/services/cache/multi-shard-reader/index.ts.

  • Resolves shards from the registry (getRegistryShards)
  • Timestamp filters (fromTimestamp / toTimestamp)
  • Cursor pagination (cursor: timestamp:id)
  • Custom filters (level, category, service, …)
  • Merges results newest-first; returns nextCursor + hasMore
typescript
const reader = createMultiShardReader(env);
const result = await reader.read<InternalLogEntry>({
  cacheType: 'internal',
  monthly: true,
  indexKeyPrefix: 'internal:index',
  entryKeyPrefix: 'internal:log',
  filters: { fromTimestamp, toTimestamp },
  cursor: '1737000000000:log-id',
  limit: 100,
  customFilter: (entry) => entry.level === 'error',
});

Index keys (same shard as data)

  • Key: {cacheType}:index:&#36;{shardId}Array<{ id, timestamp }> sorted DESC
  • Updated on each write
  • Avoids relying on Worker-side storage.list() for ID discovery

Write / read sketch (internal logs)

  1. Registry returns active monthly shard
  2. Store internal:log:&#36;{id}
  3. Append { id, timestamp } to internal:index:&#36;{shardId}
  4. On read: multi-shard reader walks indexes → fetches entries → filters → paginates

Maintenance cache

  • Keys: maintenance:&#36;{requestId} (and related patterns)
  • Strong consistency for workflow correctness
  • List queries typically hit the DB; cache is for targeted lookups unless list caching is added later

Decisions to keep

  1. Index keys live in the same shard as entries
  2. Cursor format is timestamp:id (handles duplicate timestamps)
  3. One central reader, not per-cache forks
  4. Time-series reads are timestamp-based, not ID-based