fix(cubeproxy): invalidate deleted sandbox route cache - #1252
Conversation
| utils:respond_unavailable() | ||
| end | ||
| if not metadata then | ||
| ngx.log(ngx.ERR, "LEVEL_WARN||", |
There was a problem hiding this comment.
A confirmed-miss 404 is an expected client outcome (request for a non-existent or already-deleted sandbox ID), but this path logs at ngx.ERR. On a busy dataplane, requests for arbitrary/unknown sandbox IDs will fill the error log and can trip error-log alerting, which is normally reserved for actual failures. Also, the "LEVEL_WARN||" prefix contradicts the ngx.ERR level used here — the two should agree. Suggest ngx.WARN (matching the prefix) or a rate-limited/deduped debug log for this case.
Review: fix(cubeproxy): invalidate deleted sandbox route cache (#1252)AI-generated review — reviewed against the SummaryThe PR replaces the shared-cache scan in Overall the change is well-scoped and internally consistent. I traced the lookup/invalidation paths against the base tree and verified:
Findings below are ordered by importance. Three are posted as inline comments on the PR. Findings
VerdictNo blocking correctness bug found in the changed code; the diff is internally consistent and the tests match the implementation. The main items for the author are the status-code contract decision (404 vs E2B-502), the WARN-log/Redis amplification for repeated misses, and explicitly accepting the concurrent re-arm race. Worth a maintainer pass before merge. |
2bb94bd to
b4eb6aa
Compare
|
Thanks, we do have to invalidate route cache for use-case like cross-node pause/resume. |
I am curious about the motivation here.
|
For example, an existing sandbox may be removed asynchronously when an agent operation outlives the configured auto-kill timeout. The client may still hold the sandbox’s data-plane endpoint and continue sending command, file, or service requests without checking the control plane before each request. The problem today is that CubeProxy may return 504 while a stale local route still exists, and 503 after the route-cache TTL expires. Both look like retryable infrastructure failures, even though the sandbox no longer exists. Once CubeProxy receives the lifecycle delete notification, invalidating the stale route and returning 404 makes that terminal state explicit.
On the security concern: nonexistent sandbox requests already query the Redis route keys today and return 503. This change only changes the response for a confirmed miss from 503 to 404, negative caching or rate limiting for arbitrary IDs would be a separate concern. |
| -- Outlive the longest route-cache TTL so dynamic per-port entries can age | ||
| -- out without a shared-dict scan. The grace period also covers a request | ||
| -- that entered the cache-hit path just before this delete notification. | ||
| local tombstone_ttl = timeout_max + CACHE_TOMBSTONE_GRACE_SECONDS |
There was a problem hiding this comment.
I don't understand why a tombstone is needed. Deleting the HostIP key already invalidates cache.
There was a problem hiding this comment.
Cache hits use the per-port backend_ip / backend_port entries plus meta_cached, so I think the key invalidation is probably meta_cached rather than HostIP, and deleting meta_cached does handle the normal case.
The issue is that deleting meta_cached only invalidates the cache at that moment. A concurrent cache-hit request may have already read the stale route and can refresh meta_cached and the per-port entries after deletion.
The tombstone is used to prevent that stale route from becoming valid again. It keeps those dynamic entries unusable until their TTL expires, without scanning the shared dict to find and delete every per-port key, which would be expensive.
There was a problem hiding this comment.
I consider this is kind of overdesigned.
As you said, A concurrent cache-hit request may have already read the stale route will cause a 504 instead of 404. I think the tombstone does not handle this well. That is to say, there is always a race window.
There was a problem hiding this comment.
The race I’m trying to cover is: a request reads the cached route, the delete handler clears the cache, and that request then writes the stale route back. The tombstone cannot change that in-flight request, but it prevents subsequent requests from using the refreshed stale entry.
The chance of this race is very low. If you consider it not worth guarding against, I can remove the tombstone and keep the simpler fixed-key invalidation.
There was a problem hiding this comment.
Up to you. I tend to keep the diff minimal.
There was a problem hiding this comment.
Makes sense. I’ll keep the change minimal and remove the tombstone. The delete handler will clear meta_cached together with the known fixed route-cache keys.
Separately, for a confirmed route miss / sandbox-not-found case, would you prefer aligning with E2B’s 502, or using 404 for explicit not-found semantics?
There was a problem hiding this comment.
For status code, I have no preference myself. But if we reset stale connections from CubeVS, the status code would be 502. See #992.
b4eb6aa to
4604827
Compare
| -- Invalidate the cache-hit sentinel first. Per-port entries are dynamic and | ||
| -- cannot be enumerated without scanning the shared dict, but they are not | ||
| -- usable without meta_cached and will expire under their existing TTL. | ||
| CACHE:delete(sid .. ":meta_cached") |
There was a problem hiding this comment.
The PR body describes a fixed-TTL per-sandbox tombstone ("POST /admin/meta/delete now writes a fixed-TTL per-sandbox tombstone before clearing known route-cache keys"; "while the tombstone exists, route resolution bypasses local cache and queries Redis"; "requests neither extend nor clear the tombstone"), but this diff implements only cache-key deletion — no tombstone is written anywhere, and nothing in resolve_backend consults one.
That leaves a re-arm window: if a request lands after this delete but while Redis still holds the sandbox's route, resolve_backend (sandbox_backend.lua:179–195) re-populates sid:meta_cached and the fixed keys. The surviving per-port entries (sid:<port>:backend_ip / sid:<port>:backend_port, left to TTL here) then become valid cache hits again for other ports until their TTLs expire — reproducing the exact stale-route → 504 symptom the PR motivation says it eliminates. The tombstone design described in the body (outliving the max route-cache TTL, not cleared by a successful lookup) would prevent this; the implemented code does not.
Either the tombstone logic is missing from this diff, or the PR body needs to be rewritten to describe the "delete fixed keys + meta_cached sentinel" approach actually implemented (including its one-request re-arm caveat).
| utils:respond_unavailable() | ||
| end | ||
| if not metadata then | ||
| ngx.log(ngx.WARN, "LEVEL_WARN||", |
There was a problem hiding this comment.
Two things worth confirming before merge:
-
gRPC ingress behavior change not mentioned in the PR. On the 9090 plaintext gRPC server (
$cube_ingress_protocol=grpc),utils:respond_not_found()maps 404 → grpc-status 5 (NOT_FOUND), where a missing sandbox previously returned 503 → grpc-status 14 (UNAVAILABLE). That is a client-visible protocol change for native gRPC clients (nginx.conf:428 maps only 502/503/504 to UNAVAILABLE, so the new 404 lands on@grpc_lua_errorwith status 5). Probably the more correct semantic, but it should be called out. -
Log noise / clarity. This WARN fires on every confirmed miss, including the sandbox-creation race the PR deliberately treats as authoritative — under ID-scanning traffic this is a per-request WARN. Also, since the
errbranch above callsrespond_unavailable()(which exits), this could beelseif not metadata thenfor clarity. No functional issue — the pattern matches the existingrespond_not_found()calls below.
| test: | ||
| bash tests/test_start.sh | ||
| bash tests/test_admin_access_log.sh | ||
| $(Q)set -e; \ |
There was a problem hiding this comment.
make test contract change: this block makes the target hard-fail (exit 1) when neither docker (with a pullable CUBE_PROXY_BASE_IMAGE) nor a host luajit is available — previously make test only ran the two bash scripts. Environments that ran make test without docker/luajit will now break. Also note the pre-existing standalone tests/sandbox_backend_cache_test.lua exercises the same resolve_backend cache/Redis paths but is not added to LUA_TESTS, so its coverage still isn't wired into make test while the two new tests are.
|
@zyl1121 Sorry for the delay. Please rebase and resolve conflict. |
4604827 to
b1a49e0
Compare
b1a49e0 to
6f43173
Compare
Rebased and resolved the conflict. I also removed the delete-side cache invalidation from this PR, since #1322 now covers it through the shared backend-cache helper. The PR currently returns a 404 when no sandbox route exists, while Redis lookup failures remain 503. I’m considering changing a confirmed route miss to a 502, which would align with E2B and with the expected behavior when CubeVS resets a stale upstream connection, as discussed in #992. Which behavior would you prefer? |
No, it wasn’t an automatic update. While rebasing, I saw that the delete-side invalidation was already in the merged code, so I checked the discussion in #1322. That discussion initially pointed to reusing the scoped invalidation from this PR, but #1322 ultimately merged with Looks like I misread the intent of that merge. Since the helper is now also shared with the cross-node pause/resume path, should I update the shared helper itself to use scoped invalidation, or only restore it for the delete path here? |
|
I think we can have a single |
Avoid scanning the shared route cache when a sandbox route must be invalidated. Invalidate the cache-hit sentinel and fixed Redis metadata keys through a shared bounded helper, while leaving dynamic per-port entries to their existing TTL. Return HTTP 404 only for confirmed Redis route misses and preserve HTTP 503 for lookup failures. Add regression coverage for bounded cache invalidation, confirmed misses, Redis failures, and mixed lookup outcomes. Signed-off-by: zhengyilei <zheng_yilei@qq.com>
6f43173 to
dfc0dab
Compare
| ngx.log(ngx.ERR, "LEVEL_ERROR||", err) | ||
| utils:respond_unavailable() | ||
| end | ||
| if not metadata then |
There was a problem hiding this comment.
This new branch changes the data-plane contract for a confirmed route miss from HTTP 503 to HTTP 404, and it's worth confirming two consequences before merge:
-
Contract change / E2B 502 question. Previously an authoritative miss fell through with
errset ("get redis nil") →respond_unavailable()→ 503, indistinguishable from a Redis failure. Now it's 404. Any caller (SDK / CubeAPI / CLM) that matched on 503 for "sandbox gone" or that retries on 503 will behave differently. The PR body raises the E2B-502 compatibility question — this code path now bakes 404 in as the definitive contract, so the maintainer decision should land before merge. -
WARN log + Redis amplification on every miss. Because negative caching is intentionally skipped, every request to a deleted/unknown sandbox re-runs the 2-key Redis lookup and emits a WARN-level log line with the request ID. Under retry loops or ID scanning that is both Redis load amplification and log noise. An expected miss is arguably not a WARN condition — consider DEBUG/INFO, or a short-TTL negative cache to bound the repeated lookups.
| if cache:get(key) ~= nil then | ||
| deleted = deleted + 1 | ||
| end | ||
| cache:delete(key) |
There was a problem hiding this comment.
The "bounded invalidation makes stale per-port entries unable to form an immediate cache hit" guarantee (and the scope note in the PR) holds only in the sequential case. A request that already observed meta_cached + the per-port backend_ip/backend_port entries just before this loop runs will, on the cache-hit path, re-write meta_cached, the per-port keys, and the optional fields (sandbox_backend.lua:167-172) after this loop deletes them — re-arming the stale route for a fresh TTL.
This race is pre-existing (the old get_keys scan had the same window), so it's not a regression, but it's worth documenting that invalidate_sandbox is best-effort under concurrency, or accepting the residual stale window explicitly.
| -- Invalidate fixed route metadata so the next data-plane request reloads | ||
| -- the sandbox route from Redis. | ||
| local deleted = backend_cache.invalidate_sandbox(sid) | ||
| META:delete(sid) |
There was a problem hiding this comment.
Semantics of the response count changed silently. delete_sandbox previously returned the number of every per-sandbox entry removed (fixed fields + dynamic per-port backend_* keys); invalidate_sandbox returns only the count of fixed keys that existed. For a sandbox whose only remaining cache entries are dynamic per-port keys, both /admin/meta/delete (backend_cache_deleted) and /admin/backend_cache/delete (deleted) will now report 0 even though stale entries remain until TTL. If any caller uses this field to confirm cleanup completeness, the meaning has changed — worth noting in the API docs or renaming the field to reflect what it now counts.
Yes, that makes sense. I’ve updated it that way: both request paths now dispatch to the same |
Motivation
CubeProxy should stop using stale local routes after a sandbox is deleted and expose a clear response when Redis confirms that no route metadata exists.
Scanning the entire shared cache during invalidation makes lifecycle operations proportional to the global cache size and blocks the Nginx worker performing the scan. After invalidation, a confirmed route miss and a Redis lookup failure also both return HTTP 503, so clients cannot distinguish missing metadata from a temporary metadata-store failure.
What Changed
meta_cachedand the fixed route metadata mirrored from Redis.meta_cachedfirst so new data-plane requests reload route metadata instead of immediately using stale per-port entries.Validation
make -C CubeProxy testpassed with the target OpenResty LuaJIT runtime.get_keys(), removes the cache-hit sentinel before the fixed route keys, leaves dynamic per-port entries to TTL cleanup, and does not affect other sandboxes.Scope
This PR keeps cache invalidation bounded to known per-sandbox keys. It intentionally does not scan or eagerly delete dynamic per-port entries; those entries cannot form an immediate cache hit after
meta_cachedis removed and remain subject to their existing TTL.This PR does not add negative caching, change the lifecycle event protocol, or modify Redis route publication ordering. Requests for nonexistent sandbox IDs continue to perform the same Redis lookups as before.
Note: E2B Compatibility
Official E2B's data-plane proxy returns HTTP 502 when the requested sandbox cannot be found, while this PR currently returns HTTP 404 for a confirmed missing sandbox route.
Maintainer confirmation is needed on whether CubeProxy should keep HTTP 404 or use HTTP 502 for closer E2B data-plane compatibility. Redis lookup failures remain HTTP 503 in either case.