perf(api): add per-allocation sandbox cache to eliminate TeamItems MGET bandwidth - #3594
Open
AdaAibaby wants to merge 1 commit into
Open
perf(api): add per-allocation sandbox cache to eliminate TeamItems MGET bandwidth#3594AdaAibaby wants to merge 1 commit into
AdaAibaby wants to merge 1 commit into
Conversation
…ET bandwidth TeamItems issues SMEMBERS + MGET on every call. With teams of 9 000+ sandboxes and multiple API allocations the read bandwidth scales as O(allocations × team_size), saturating the API allocation NIC. This PR introduces a per-allocation in-process cache for sandbox state backed by the existing pub/sub infrastructure (publisher + subscriptionManager, introduced in e2b-dev#2099 / e2b-dev#2668). The cache eliminates the O(allocations × team_size) multiplier: each allocation maintains a local snapshot and TeamItems reads from memory after the first cold-fetch. Design - sandbox_event.go: sandboxEvent JSON type published alongside existing plain routing-key strings on globalStorageNotifyChannel. JSON prefix '{' is an unambiguous discriminator from routing keys ('sandbox:...', 'lock:...'). - sandbox_cache.go: sandboxCache keyed by sandbox ID, indexed by team, with warm/cold state per team. Thread-safe via sync.RWMutex. - publisher.go: publishSandboxEvent marshals and enqueues events on the existing 32-worker publish pool. - subscription_manager.go: dispatch detects JSON events and applies them to the embedded sandboxCache before routing-key fan-out. - operations.go: Add/Update/Remove broadcast events after each Redis write; TeamItems checks the cache (warm-path) or falls back to SMEMBERS+MGET and warms the team on cold-start. Gated by SandboxTeamItemsCacheFlag (default false). - featureflags/flags.go: SandboxTeamItemsCacheFlag for safe rollout. Tests - sandbox_cache_test.go: unit tests for apply/evict/warmTeam/getTeam, state filtering, team isolation, stale-entry eviction, event marshal/unmarshal, routing-key disambiguation. - team_items_test.go: extended with 9 new integration tests (real Redis via testcontainers) covering cold-start warming, empty-team warming, event-driven add/remove reflection, end-to-end Add/Remove/Update broadcast, team isolation, and flag-off behaviour. Closes e2b-dev#3593
AdaAibaby
requested review from
ValentaTomas,
dobrac and
jakubno
as code owners
August 22, 2026 09:58
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Closes #3593
TeamItemsissuesSMEMBERS+ batchedMGETon every call. With teams of 9 000+ sandbox IDs per team index (observed, per #3571) and multiple API allocations (count_instancesinvariables.tf), the read bandwidth scales as:At N=10 allocations this saturates the API allocation NIC. #3571 (batched MGET) mitigated Redis event-loop stalls but left total bytes transferred unchanged.
Approach
Extend the existing
publisher+subscriptionManagerpub/sub infrastructure (introduced in #2099 / #2668) to also broadcast sandbox state-change events onglobalStorageNotifyChannel. Each allocation maintains a local in-process cache populated by these events.TeamItemsreads from the cache after the first cold-fetch, eliminating theO(allocations × team_size)multiplier.This directly answers the
TODO: this should be removed once we have a better way to handle node synccomment left in the deleted memory backend (sync.go, removed in #2750): the pub/sub channel is that better way. Unlike the oldmemory.Storage(a standalone source of truth per allocation), this is a cache layer — Redis remains the source of truth.Changes
New files
sandbox_event.go—sandboxEventJSON type published alongside existing plain routing-key strings. JSON prefix{is an unambiguous discriminator (sandbox:storage:/lock:routing keys never start with{).sandbox_cache.go—sandboxCachewithsync.RWMutex, keyed by sandbox ID, indexed by team, with per-team warm/cold tracking.sandbox_cache_test.go— unit tests for the cache in isolation (no Redis).Modified files
publisher.go—publishSandboxEvent: marshals event to JSON, enqueues on the existing 32-worker pool.subscription_manager.go—dispatchnow detects JSON event payloads and applies them to the embeddedsandboxCachebefore routing-key fan-out. Fully backward compatible: non-JSON payloads follow the existing path unchanged.operations.go—Add/Update/Removebroadcast events after each successful Redis write.TeamItemschecks the cache (warm path, zero Redis reads) or falls back toSMEMBERS+MGETand warms the team (cold path, once per allocation lifetime per team). Gated bySandboxTeamItemsCacheFlag(defaultfalse).main.go—cacheForced boolfield for test-time override.featureflags/flags.go—SandboxTeamItemsCacheFlag = NewBoolFlag("sandbox-team-items-cache", false).team_items_test.go— 9 new integration tests (real Redis via testcontainers).Consistency model
The cache is eventually consistent with Redis. Dropped pub/sub events (backpressure, pod restart) cause temporary staleness until the next cold-fetch for that team. Callers that require strong consistency (
ExpiredItems,Reconcile) are unchanged and bypass the cache entirely.Expected impact
At 100 sandbox mutations/second, 10 allocations:
At steady state,
TeamItemsrequires zero Redis reads per call.Rollout
The cache is off by default (
sandbox-team-items-cache = false). Flip the LaunchDarkly flag totruein staging first, then production. No config changes required.Test plan
sandbox_cache_test.go— apply/evict/warmTeam/getTeam, state filter, team isolation, stale-entry eviction, event marshal/unmarshal, routing-key disambiguation (10 tests, no Docker required)team_items_test.go— cold-start warming, empty-team warm, event-driven add/remove reflection, end-to-end Add/Remove/Update broadcast via real pub/sub, team isolation, flag-off behaviour (15 tests, testcontainers Redis)./internal/sandbox/storage/redis/...suite passes (go test -count=1 -timeout 300s)