Background
`sandbox:storage:global:expiration` is a single global ZSET shared by every
sandbox across all teams. The evictor calls `ExpiredItems` every 50 ms
(`pollInterval`), which issues a `ZRANGEBYSCORE` against this key on every tick.
Problem
There are currently no metrics to observe:
- ZSET cardinality over time — no way to know whether
`sandbox:storage:global:expiration` is growing unboundedly, shrinking after
orphan sweeps (fix(api): prune stale team index entries when ZSET orphans are swept #3567), or stable at steady state.
- Evictor sweep duration — no way to know how long each
`ZRANGEBYSCORE + MGET pipeline` takes, or whether sweep latency is
increasing as the ZSET grows.
Without these two signals it is impossible to:
Production evidence
Observed 2026-08-14 09:00 – 09:30 (Redis big-key scan):
| Key |
Type |
Memory |
Elements |
| `sandbox:storage:global:expiration` |
zset |
3,137,528 B (~3 MB) |
22,458 |
| `sandbox:storage:{65380065...}:index` |
set |
588,824 B |
9,535 |
| `template:info:{fnqz33l5lhnpqiephgbl}:default` |
string |
12,304 B |
1 |
At `pollInterval = 50 ms` and N API allocations, the evictor issues
20 × N `ZRANGEBYSCORE` calls per second against the 22 k-member ZSET.
This load is currently invisible — there is no metric showing either the
key size trend or the per-sweep cost.
Observability gap
| Signal |
Metric name (proposed) |
Collection point |
| ZSET cardinality |
`api.redis_storage.expiration_index.size` (gauge) |
Once per heal pass (`healExpirationIndex`, every 5 min) |
| Evictor sweep duration |
`api.redis_storage.expiration_index.sweep_duration` (histogram, ms) |
Per `ExpiredItems` call |
Why a gauge for size, not sampled on every evictor tick?
`global:expiration` is already the key being diagnosed as a high-frequency
hot key. Sampling `ZCARD` on every 50 ms evictor tick would add 20 × N
extra Redis calls per second — worsening the problem while observing it.
A single `ZCARD` piggybacked on the existing heal pass (every 5 min,
same cadence as #3604) adds negligible overhead and is sufficient for
capacity planning and alerting.
Why sweep duration matters
`ZRANGEBYSCORE` is O(log N + K) where N = 22,458 and K = batch size (256).
As N grows the per-sweep cost grows. Correlating sweep duration with ZSET
size over time makes the relationship visible before it becomes a latency
incident.
Related
/cc @jakubno @dobrac @ValentaTomas
Background
`sandbox:storage:global:expiration` is a single global ZSET shared by every
sandbox across all teams. The evictor calls `ExpiredItems` every 50 ms
(`pollInterval`), which issues a `ZRANGEBYSCORE` against this key on every tick.
Problem
There are currently no metrics to observe:
`sandbox:storage:global:expiration` is growing unboundedly, shrinking after
orphan sweeps (fix(api): prune stale team index entries when ZSET orphans are swept #3567), or stable at steady state.
`ZRANGEBYSCORE + MGET pipeline` takes, or whether sweep latency is
increasing as the ZSET grows.
Without these two signals it is impossible to:
reducing cardinality
Production evidence
Observed 2026-08-14 09:00 – 09:30 (Redis big-key scan):
At `pollInterval = 50 ms` and N API allocations, the evictor issues
20 × N `ZRANGEBYSCORE` calls per second against the 22 k-member ZSET.
This load is currently invisible — there is no metric showing either the
key size trend or the per-sweep cost.
Observability gap
Why a gauge for size, not sampled on every evictor tick?
`global:expiration` is already the key being diagnosed as a high-frequency
hot key. Sampling `ZCARD` on every 50 ms evictor tick would add 20 × N
extra Redis calls per second — worsening the problem while observing it.
A single `ZCARD` piggybacked on the existing heal pass (every 5 min,
same cadence as #3604) adds negligible overhead and is sufficient for
capacity planning and alerting.
Why sweep duration matters
`ZRANGEBYSCORE` is O(log N + K) where N = 22,458 and K = batch size (256).
As N grows the per-sweep cost grows. Correlating sweep duration with ZSET
size over time makes the relationship visible before it becomes a latency
incident.
Related
`global:expiration` via orphaned ZSET members
`startTransitionScript` (separate hot path)
sweep latency metrics would provide the baseline needed to evaluate
whether a backend switch is warranted
/cc @jakubno @dobrac @ValentaTomas