Repository navigation
fix: isolate API model cache keys between live and sandbox - #312
Merged
Merged
Conversation
…never share entries Sandbox requests switch models to the sandbox database without any credential, company or path that tells them apart, so list, model and relationship cache keys collided: test mode served cached live data and live could be served sandbox data. Every key now carries the connection the model reads. Versions stay shared, since a list key carries versions of tables other models write and a cross-connection write only over-invalidates. Co-Authored-By: Claude <noreply@anthropic.com>
Merged
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.
Incident
Fleetbase Cloud sandbox issues: test mode shows live data, and live can be served cached test data.
Root cause
Auth::setSandboxSession()switchesdatabase.defaultandfleetbase.connection.dbtosandboxfor console requests withAccess-Console-Sandbox: trueand forflb_test_keys. Models then read the separatefleetbase_sandboxdatabase.ApiModelCachebuilt its keys without any environment or connection part:{api_query}:{table}:company_{uuid}:{versions}:{md5(params, path, session scope)}{api_model}:{table}:{versions}:{id}{withHash}{api_relation}:{table}:{versions}:{id}:{relation}The session scope (
api_credential,storefront_store,storefront_network) separates public API keys, but console (Sanctum) requests carry no credential. A live and a sandbox console request for the same company, path and query string got the same key. Model and relationship keys only use the id or uuid. Integer ids collide between the two databases, and synced uuids (users, companies) exist in both.Reproduction
Local stack, console token:
GET /int/v1/orders?limit=1&_probe=Nreturned live orders withX-Cache-Status: MISS.Access-Console-Sandbox: truereturned the same live orders with the sameX-Cache-KeyandX-Cache-Status: HIT.[].Fix
ApiModelCachegenerates (query, model, relationship) now carries the connection its model reads, right after the Redis hash tag. The connection comes from the newApiModelCache::connectionName():$model->getConnectionName() ?? config('database.default').{api_query}:{connection}:{table}:...{api_model}:{connection}:{table}:...{api_relation}:{connection}:{table}:...fleetbase.connection.dbat call time, and models without a connection fall back todatabase.default.setSandboxSession()switches both, so sandbox keys readsandboxand live keys read the live connection (e.g.mysql). A model pinned to an explicit connection keeps one key in both modes, because it reads the same database in both.invalidateQueryCache()builds its key throughgenerateQueryCacheKey(), so it is covered too. TheHasApiModelCache/HasApiModelBehaviortraits only call these generators and needed no change.Version keys stay shared across connections (deliberate)
api_query_version:*/api_model_version:*are still keyed by table (and company), not by connection:$apiCacheDependencies, requestwith/expand/count). Other models write those tables, possibly on another connection (e.g. a storefront model under a core list).dependencyTables()only knows table names, not which connection the writer uses. If versions were per connection, a list would read the version under its own connection. A write on the dependency's connection would bump a different version, and that list would serve stale data until its TTL ran out. That is a correctness regression.api_cache,api_model:{table},company:{uuid}) also stay shared for the same reason. On Redis they only drop tag sets, and the versions do the real invalidation (see the class docblock).Other caches checked
None of these needed a change in this PR:
ValidateETagmiddleware: compares the client'sIf-None-Matchwith the ETag of the response this request produced. Nothing is stored server side.AttachCacheHeaders: only reportsApiModelCache's status and key. It now shows the connection inX-Cache-Key.ResponseCache: core-api registers noCacheResponsemiddleware. It only callsResponseCache::clear()(HttpCacheObserver,ClearCacheAfterDelete), so nothing is served from it.AuthController::session(session_validation_{token}),bootstrap(auth_bootstrap_{user}_{token}),getUserOrganizations(user_organizations_v2_{user}) andUserCacheService(user:current:{user}:{company}:{updated_at}): these cache identity and organization data from the users/companies tables, which are synced into the sandbox. The keys have no connection part. If those tables ever drift between the two databases, these could show the other side's copy for up to 5 to 30 minutes. They don't hold tenant operational data, so they are noted here, not changed. Scoping them would also need everyCache::forgetcall site to clear both connections.LookupControllerblog posts,Settingsystem settings,ApiRateLimits,Telemetry, SMS ledger/policy, OAuth/Apple key caches: global or credential-keyed, with no environment-specific data.Tests
php -d memory_limit=512M vendor/bin/pest(PHP 8.4.0): 2185 passed (13691 assertions), 0 failed (47 deprecated and 8 warnings come from vendor packages and are pre-existing).php-cs-fixer fix -v --dry-run(=composer test:lint): 0 of 874 files to fix.composer test:date-drift: passed.New tests in
tests/Unit/Support/ApiModelCacheTest.php. They switch to sandbox through the realAuth::setSandboxSession()with anAccess-Console-Sandbox: truerequest:database.default) and aFleetbase\Models\Model(fleetbase.connection.db). A connection-pinned model keeps one key.[], not the live orders), and each side then HITs its own entry. This is the incident reproduction.Existing expected key strings were updated to the new
{hash}:{connection}:{table}format. The first three new tests fail against the oldApiModelCache.Deploy note
Existing cache entries keep their old keys and are no longer read. They expire within their TTL (
LIST_TTL300s,MODEL_TTL3600s,RELATIONSHIP_TTL1800s). To purge them right away, runphp artisan cache:clearon Cloud after deploy;deploy.shalready runs it.🤖 Generated with Claude Code