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
| Feature | Durable Objects | KV |
|---|---|---|
| Consistency | Strong | Eventual |
| Transactions | Yes | No |
| Latency | Hot <10ms, warm <50ms | Global <10ms |
| Queries | Filtering / aggregation in-object | Key-value only |
| Cost | Included with Workers | Per-read pricing |
| Fit | Cache + logs that need consistency and range reads | Simple 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 type | Multi-shard? | Strategy | Notes |
|---|---|---|---|
| Internal logs | Yes | Month-based | Accumulates; admin/user queries across months |
| Maintenance | Maybe | Registry / ID | Only if list results are cached or a shard hits size limits; today: single-request lookups |
| Auth rate limits | No | Single shard | Per-identifier + TTL |
| Analytics | Maybe | Month-based | Only 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
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:${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)
- Registry returns active monthly shard
- Store
internal:log:${id} - Append
{ id, timestamp }tointernal:index:${shardId} - On read: multi-shard reader walks indexes → fetches entries → filters → paginates
Maintenance cache
- Keys:
maintenance:${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
- Index keys live in the same shard as entries
- Cursor format is
timestamp:id(handles duplicate timestamps) - One central reader, not per-cache forks
- Time-series reads are timestamp-based, not ID-based
Related
- Logs architecture — monthly sharding registry + write/read paths
- Security logs —
/me/security/logsand user-scoped reads - ADR-005 Durable Objects
- Lifecycle cache contracts