Skip to content

obs(api): add expiration index size and evictor sweep duration metrics - #3606

Open
AdaAibaby wants to merge 1 commit into
e2b-dev:mainfrom
AdaAibaby:obs/redis-expiration-index-metrics
Open

AdaAibaby wants to merge 1 commit into
e2b-dev:mainfrom
AdaAibaby:obs/redis-expiration-index-metrics

Conversation

@AdaAibaby

@AdaAibaby AdaAibaby commented Aug 24, 2026 •

Copy link
Copy Markdown
Contributor

Relationship to #3608 (please read before merging). #3606 and #3608 are an intentionally disjoint, complementary split of the Redis-storage observability series — not duplicates. They can merge in either order.

Summary

Closes #3605.

Adds two histograms to make the global expiration ZSET observable in production:

Metric Unit Collection point
api.redis_storage.expiration_index.size {entry} Once per heal pass (healExpirationIndex, every 5 min)
api.redis_storage.expiration_index.sweep_duration ms Per ExpiredItems call (every evictor tick)

Why these two signals

sandbox:storage:global:expiration is a singleton ZSET shared by every sandbox.
With 22,458 members (~3 MB) observed in production on 2026-08-14 and the evictor
calling ExpiredItems at pollInterval = 50 ms, this key is read 20 × N times
per second
(N = API allocation count) — yet neither its size trend nor the
per-sweep cost was visible in any metric.

  • expiration_index.size — use max/p99 to detect unbounded growth before
    it causes latency spikes; use as a before/after signal when fix(api): prune stale team index entries when ZSET orphans are swept #3567 (orphan SREM
    fix) or future sharding work lands.
  • expiration_index.sweep_duration — ZRANGEBYSCORE is O(log N + K);
    rising p99 alongside rising size confirms the cost is growing and justifies
    remediation.

Implementation

heal.go — one ZCARD call appended after healExpirationIndex completes.
Cost: one Redis round-trip per 5 min, negligible next to the forEachSandboxBatch
SSCAN scan that precedes it. Sampling here avoids adding any load to the 50 ms
evictor hot path.

items.go — defer-based timer wrapping the full ExpiredItems body.
Records on every evictor tick, capturing the end-to-end cost of
ZRANGEBYSCORE + MGET pipeline + orphan cleanup.

Related

/cc @jakubno @dobrac @ValentaTomas

Closes e2b-dev#3605.

Two new histograms:

  api.redis_storage.expiration_index.size ({entry})
    ZCARD of sandbox:storage:global:expiration sampled once per heal
    pass (every 5 min). Use max/p99 to detect unbounded growth caused
    by stale ZSET entries that are not being swept.

  api.redis_storage.expiration_index.sweep_duration (ms)
    Wall-clock duration of each ExpiredItems call (ZRANGEBYSCORE +
    MGET pipeline). Rising p99 alongside rising index size confirms
    the O(log N + K) cost growth described in e2b-dev#3605.

Collection points:
- heal.go: ZCARD after each healExpirationIndex pass — one extra
  Redis round-trip per 5 min, negligible next to forEachSandboxBatch.
- items.go: defer-based timer wrapping the full ExpiredItems body,
  recorded on every evictor tick.
@AdaAibaby
AdaAibaby force-pushed the obs/redis-expiration-index-metrics branch from 5ee0e63 to 18158d1 Compare November 12, 2026 03:52
AdaAibaby pushed a commit to AdaAibaby/infra that referenced this pull request Nov 12, 2026
… and index

Closes e2b-dev#3607. Rebased onto current main and reworked to stay disjoint from
e2b-dev#3606 (which owns expiration_index.size + sweep_duration): this PR deliberately
does NOT redeclare those two, so the two PRs can merge in either order.

Lock (lock.go)
- api.redis_storage.lock.wait_duration ms histogram — total wait per successful
  Obtain, via a new obtainLock wrapper on Storage.
- api.redis_storage.lock.timeout_total counter — Obtain calls whose context
  expired before the lock was held.

Transition (state_change.go)
- api.redis_storage.transition.duration ms histogram — StartRemoving → callback
  result write (transitionStart captured before the lock, threaded into the
  callback).
- api.redis_storage.transition.execution_mismatch_total — both ErrExecutionMismatch
  paths in StartRemoving.
- api.redis_storage.transition.callback_failures_total — rehomed onto main's
  current shape: the finish_transition Lua script failing (main replaced the old
  callback lock with an atomic script, so the original "callback lock not
  obtained" path no longer exists).
- api.redis_storage.transition.joined_total — joined transitions.
- api.redis_storage.transition.wait_fallback_polls_total — waitForTransition
  fallback ticker iterations.

Scan — reuses main's existing plumbing instead of adding parallel counting:
- api.redis_storage.scan.teams_skipped_total — recorded from forEachSandboxBatch's
  own skipped-team return value at the heal pass, not a new per-continue counter.
- api.redis_storage.scan.corrupt_records_total — incremented in decodeRecord
  (now a Storage method) on the json.Unmarshal failure branch only.

Index size (operations.go)
- api.redis_storage.team_index.size {entry} histogram — recorded where
  TeamsWithSandboxCount already pipelines SCARD over every team, avoiding a
  second full SCARD pass.

All metric names registered in packages/shared/pkg/telemetry/meters.go with
descriptions and units.

Adaptations vs the original PR base: healExpirationIndex is now
(int, teamWalk, error); StartRemoving returns StateTransition; forEachSandboxBatch
returns a skipped count; the corrupt-record unmarshal moved to readers.go
decodeRecord — all hooks were moved to match.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

obs(api): no metrics on global expiration ZSET size or evictor sweep latency

2 participants