From 777c68d9b3af8058e01972a7e32254e54e75e6e1 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 15 Oct 2026 13:38:04 +0800 Subject: [PATCH] fix: key API model caches by database connection so live and sandbox 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 --- src/Support/ApiModelCache.php | 55 +++++-- tests/Unit/Support/ApiModelCacheTest.php | 178 ++++++++++++++++++++--- 2 files changed, 203 insertions(+), 30 deletions(-) diff --git a/src/Support/ApiModelCache.php b/src/Support/ApiModelCache.php index 7e598aba..5842b8e6 100644 --- a/src/Support/ApiModelCache.php +++ b/src/Support/ApiModelCache.php @@ -39,6 +39,16 @@ * an already finished scan, so RedisTaggedCache::flush() deletes only the tag * sets and leaves every tagged value in Redis until its TTL expires. Bumping the * version moves readers to new keys and leaves the orphaned entries to expire. + * + * Every query, model and relationship key also carries the connection its model + * reads (see connectionName()). Sandbox requests (`Access-Console-Sandbox`, + * `flb_test_` keys) switch models to the separate sandbox database without any + * credential, company or path that would tell the two apart, and integer ids and + * synced uuids exist in both databases, so without it live and sandbox requests + * shared entries. Versions stay shared across connections: a list key also + * carries the versions of the tables it embeds, which other models (possibly on + * other connections) write, and a version is only known by table name. A write on + * either connection retiring the other's entries only over-invalidates. */ class ApiModelCache { @@ -111,11 +121,13 @@ class ApiModelCache * endpoints (which paginate and eager load differently) and endpoints whose * callbacks read route parameters; the session scope separates API keys, stores * and networks of one company. Both are hashed, so credentials never appear in - * the key or in the X-Cache-Key debug header. + * the key or in the X-Cache-Key debug header. The model's connection separates + * live from sandbox data. */ public static function generateQueryCacheKey(Model $model, Request $request, array $additionalParams = []): string { $table = $model->getTable(); + $connection = static::connectionName($model); $companyUuid = static::getCompanyUuid($request); // Get ALL query parameters from the request @@ -164,14 +176,29 @@ public static function generateQueryCacheKey(Model $model, Request $request, arr // Add Redis hash tag {api_query} for Redis Cluster shard routing // Include version to ensure cache keys change after writes - return "{api_query}:{$table}:{$companyPart}:{$version}:{$paramsHash}"; + return "{api_query}:{$connection}:{$table}:{$companyPart}:{$version}:{$paramsHash}"; + } + + /** + * Get the database connection a model reads, as cache keys carry it. + * + * Live and sandbox models share tables, ids, synced uuids, companies and + * request paths; the connection is the only thing that tells them apart. + * Fleetbase models resolve it from `fleetbase.connection.db` at call time, + * models without a connection fall back to the default connection, both of + * which Auth::setSandboxSession() switches to the sandbox. + */ + public static function connectionName(Model $model): string + { + return $model->getConnectionName() ?? config('database.default') ?? 'default'; } /** * Cache key holding the query version for a table/company. * * A null company is the table wide version, which every company's list keys - * include, so writes that know no company invalidate all of them. + * include, so writes that know no company invalidate all of them. Versions are + * shared by all connections (see the class docblock). */ protected static function queryVersionKey(string $table, ?string $companyUuid): string { @@ -478,14 +505,15 @@ protected static function bumpVersion(string $versionKey): void */ public static function generateModelCacheKey(Model $model, $id, array $with = []): string { - $table = $model->getTable(); - $withHash = !empty($with) ? ':' . md5(json_encode($with)) : ''; - $tables = [$table, ...static::dependencyTables($model, static::relationPaths($with))]; - $version = static::versionSegment(array_map(fn ($versionTable) => static::modelVersionKey($versionTable), $tables)); + $table = $model->getTable(); + $connection = static::connectionName($model); + $withHash = !empty($with) ? ':' . md5(json_encode($with)) : ''; + $tables = [$table, ...static::dependencyTables($model, static::relationPaths($with))]; + $version = static::versionSegment(array_map(fn ($versionTable) => static::modelVersionKey($versionTable), $tables)); // Add Redis hash tag {api_model} for Redis Cluster shard routing // Include version to ensure cache keys change after writes - return "{api_model}:{$table}:{$version}:{$id}{$withHash}"; + return "{api_model}:{$connection}:{$table}:{$version}:{$id}{$withHash}"; } /** @@ -493,14 +521,15 @@ public static function generateModelCacheKey(Model $model, $id, array $with = [] */ public static function generateRelationshipCacheKey(Model $model, string $relationshipName): string { - $table = $model->getTable(); - $id = $model->getKey(); - $tables = [$table, ...static::dependencyTables($model, [$relationshipName])]; - $version = static::versionSegment(array_map(fn ($versionTable) => static::modelVersionKey($versionTable), $tables)); + $table = $model->getTable(); + $connection = static::connectionName($model); + $id = $model->getKey(); + $tables = [$table, ...static::dependencyTables($model, [$relationshipName])]; + $version = static::versionSegment(array_map(fn ($versionTable) => static::modelVersionKey($versionTable), $tables)); // Add Redis hash tag {api_relation} for Redis Cluster shard routing // Carries the parent's versions and the related tables', so a write to either side changes it - return "{api_relation}:{$table}:{$version}:{$id}:{$relationshipName}"; + return "{api_relation}:{$connection}:{$table}:{$version}:{$id}:{$relationshipName}"; } /** diff --git a/tests/Unit/Support/ApiModelCacheTest.php b/tests/Unit/Support/ApiModelCacheTest.php index d302cab1..8b880bf5 100644 --- a/tests/Unit/Support/ApiModelCacheTest.php +++ b/tests/Unit/Support/ApiModelCacheTest.php @@ -1,6 +1,8 @@ 333, 'api.cache.scope.session' => null, 'cache.default' => 'array', + 'database.default' => 'mysql', ], $config)); app()->instance('cache', $store); @@ -453,6 +476,23 @@ function api_model_cache_model(string $uuid = 'order-1', ?string $companyUuid = return $model; } +/** + * Run a callback inside a sandbox session as Auth::setSandboxSession() opens one + * for a console request with `Access-Console-Sandbox: true`, then restore live. + */ +function api_model_cache_in_sandbox(Closure $callback): mixed +{ + $request = Request::create('/int/v1/orders', 'GET', [], [], [], ['HTTP_ACCESS_CONSOLE_SANDBOX' => 'true']); + Auth::setSandboxSession($request); + + try { + return $callback(); + } finally { + config(['database.default' => 'mysql', 'fleetbase.connection.db' => 'mysql']); + session(['is_sandbox' => false]); + } +} + function api_model_cache_request(array $query = [], mixed $company = 'company-1'): Request { $request = Request::create('/int/v1/orders', 'GET', $query); @@ -624,15 +664,119 @@ public function gc(int $max_lifetime): int|false ]), ['callback' => true]); expect($first)->toBe($second) - ->and($first)->toStartWith('{api_query}:orders:company_company-1:v0.7:') + ->and($first)->toStartWith('{api_query}:mysql:orders:company_company-1:v0.7:') ->and(ApiModelCache::generateModelCacheKey($model, 'order-1', ['customer', 'payload'])) - ->toBe('{api_model}:orders:v0:order-1:' . md5(json_encode(['customer', 'payload']))) + ->toBe('{api_model}:mysql:orders:v0:order-1:' . md5(json_encode(['customer', 'payload']))) ->and(ApiModelCache::generateRelationshipCacheKey($model, 'payload')) - ->toBe('{api_relation}:orders:v0:order-1:payload') + ->toBe('{api_relation}:mysql:orders:v0:order-1:payload') ->and(ApiModelCache::generateCacheTags($model, 'company-1', true)) ->toBe(['api_cache', 'api_model:orders', 'api_query:orders', 'company:company-1']); }); +test('api model cache keys the same request apart under live and sandbox sessions', function () { + api_model_cache_fixture(['fleetbase.connection.db' => 'mysql']); + $request = fn () => api_model_cache_request(['status' => 'active', 'limit' => '1']); + + $keys = function () use ($request) { + $model = api_model_cache_model(); + $fleetbase = new ApiModelCacheFleetbaseTestModel(); + $fleetbase->setAttribute('uuid', 'order-1'); + + return [ + 'query' => ApiModelCache::generateQueryCacheKey($model, $request()), + 'model' => ApiModelCache::generateModelCacheKey($model, 'order-1', ['payload']), + 'relationship' => ApiModelCache::generateRelationshipCacheKey($model, 'payload'), + 'fleetbase_query' => ApiModelCache::generateQueryCacheKey($fleetbase, $request()), + 'fleetbase_model' => ApiModelCache::generateModelCacheKey($fleetbase, 1), + 'fleetbase_relation' => ApiModelCache::generateRelationshipCacheKey($fleetbase, 'payload'), + 'pinned_query' => ApiModelCache::generateQueryCacheKey(new ApiModelCachePinnedTestModel(), $request()), + ]; + }; + + $live = $keys(); + $sandbox = api_model_cache_in_sandbox($keys); + + foreach (['query', 'model', 'relationship', 'fleetbase_query', 'fleetbase_model', 'fleetbase_relation'] as $kind) { + expect($sandbox[$kind])->not->toBe($live[$kind]); + } + + expect($live['query'])->toStartWith('{api_query}:mysql:orders:company_company-1:v0.0:') + ->and($sandbox['query'])->toStartWith('{api_query}:sandbox:orders:company_company-1:v0.0:') + ->and($live['model'])->toBe('{api_model}:mysql:orders:v0:order-1:' . md5(json_encode(['payload']))) + ->and($sandbox['model'])->toBe('{api_model}:sandbox:orders:v0:order-1:' . md5(json_encode(['payload']))) + ->and($live['relationship'])->toBe('{api_relation}:mysql:orders:v0:order-1:payload') + ->and($sandbox['relationship'])->toBe('{api_relation}:sandbox:orders:v0:order-1:payload') + ->and($live['fleetbase_model'])->toBe('{api_model}:mysql:orders:v0:1') + ->and($sandbox['fleetbase_model'])->toBe('{api_model}:sandbox:orders:v0:1') + ->and($sandbox['fleetbase_query'])->toStartWith('{api_query}:sandbox:orders:') + ->and($sandbox['fleetbase_relation'])->toBe('{api_relation}:sandbox:orders:v0:order-1:payload') + // A model pinned to a connection reads the same data in both, so it keeps one key + ->and($live['pinned_query'])->toStartWith('{api_query}:storefront:orders:') + ->and($sandbox['pinned_query'])->toBe($live['pinned_query']); +}); + +test('api model cache never serves a live list to a sandbox request or a sandbox list to a live one', function () { + api_model_cache_fixture(['fleetbase.connection.db' => 'mysql']); + $request = fn () => api_model_cache_request(['limit' => '1', '_probe' => '1']); + + $live = ApiModelCache::cacheQueryResult(api_model_cache_model(), $request(), fn () => collect(['live-order'])); + expect($live->all())->toBe(['live-order']) + ->and(ApiModelCache::getCacheStatus())->toBe('MISS'); + $liveKey = ApiModelCache::getCacheKey(); + + [$sandbox, $sandboxStatus, $sandboxKey] = api_model_cache_in_sandbox(function () use ($request) { + ApiModelCache::resetCacheStatus(); + $result = ApiModelCache::cacheQueryResult(api_model_cache_model(), $request(), fn () => collect([])); + + return [$result, ApiModelCache::getCacheStatus(), ApiModelCache::getCacheKey()]; + }); + + expect($sandbox->all())->toBe([]) + ->and($sandboxStatus)->toBe('MISS') + ->and($sandboxKey)->not->toBe($liveKey); + + // Each environment then hits its own entry + ApiModelCache::resetCacheStatus(); + expect(ApiModelCache::cacheQueryResult(api_model_cache_model(), $request(), fn () => collect(['unexpected']))->all())->toBe(['live-order']) + ->and(ApiModelCache::getCacheStatus())->toBe('HIT'); + + $sandboxAgain = api_model_cache_in_sandbox(fn () => ApiModelCache::cacheQueryResult(api_model_cache_model(), $request(), fn () => collect(['unexpected']))); + expect($sandboxAgain->all())->toBe([]); +}); + +test('api model cache never serves a live model or relationship lookup to a sandbox request', function () { + api_model_cache_fixture(['fleetbase.connection.db' => 'mysql']); + $model = new ApiModelCacheFleetbaseTestModel(); + $model->setAttribute('uuid', 'user-1'); + + expect(ApiModelCache::cacheModel($model, 1, fn () => 'live-record'))->toBe('live-record') + ->and(ApiModelCache::cacheRelationship($model, 'payload', fn () => 'live-relation'))->toBe('live-relation'); + + [$record, $relation] = api_model_cache_in_sandbox(fn () => [ + ApiModelCache::cacheModel($model, 1, fn () => 'sandbox-record'), + ApiModelCache::cacheRelationship($model, 'payload', fn () => 'sandbox-relation'), + ]); + + expect($record)->toBe('sandbox-record') + ->and($relation)->toBe('sandbox-relation') + ->and(ApiModelCache::cacheModel($model, 1, fn () => 'unexpected'))->toBe('live-record') + ->and(ApiModelCache::cacheRelationship($model, 'payload', fn () => 'unexpected'))->toBe('live-relation'); +}); + +test('api model cache shares versions across connections so a sandbox write only over invalidates live lists', function () { + $store = api_model_cache_fixture(['fleetbase.connection.db' => 'mysql']); + $request = fn () => api_model_cache_request(['status' => 'active']); + + $liveBefore = ApiModelCache::generateQueryCacheKey(api_model_cache_model(), $request()); + api_model_cache_in_sandbox(fn () => ApiModelCache::invalidateModelCache(api_model_cache_model(), 'company-1')); + + expect($store->get('api_query_version:orders:company-1'))->toBe(1) + ->and($store->get('api_model_version:orders'))->toBe(1) + ->and(ApiModelCache::generateQueryCacheKey(api_model_cache_model(), $request())) + ->not->toBe($liveBefore) + ->toStartWith('{api_query}:mysql:orders:company_company-1:v0.1:'); +}); + test('api model cache stores query results with miss hit status and disabled bypass behavior', function () { api_model_cache_fixture(); $calls = 0; @@ -648,7 +792,7 @@ public function gc(int $max_lifetime): int|false expect($first->all())->toBe(['fresh-result']) ->and($calls)->toBe(1) ->and(ApiModelCache::getCacheStatus())->toBe('MISS') - ->and(ApiModelCache::getCacheKey())->toStartWith('{api_query}:orders:company_company-1:v0.0:'); + ->and(ApiModelCache::getCacheKey())->toStartWith('{api_query}:mysql:orders:company_company-1:v0.0:'); $second = ApiModelCache::cacheQueryResult($model, $request, function () use (&$calls) { $calls++; @@ -720,7 +864,7 @@ public function gc(int $max_lifetime): int|false ->and($secondModel)->toBe(['uuid' => 'order-1']) ->and($modelCalls)->toBe(1) ->and(ApiModelCache::getCacheStatus())->toBe('HIT') - ->and(ApiModelCache::getCacheKey())->toStartWith('{api_model}:orders:v0:order-1:'); + ->and(ApiModelCache::getCacheKey())->toStartWith('{api_model}:mysql:orders:v0:order-1:'); ApiModelCache::resetCacheStatus(); $relationshipCalls = 0; @@ -739,7 +883,7 @@ public function gc(int $max_lifetime): int|false ->and($secondRelation)->toBe(['uuid' => 'payload-1']) ->and($relationshipCalls)->toBe(1) ->and(ApiModelCache::getCacheStatus())->toBe('HIT') - ->and(ApiModelCache::getCacheKey())->toBe('{api_relation}:orders:v0:order-1:payload'); + ->and(ApiModelCache::getCacheKey())->toBe('{api_relation}:mysql:orders:v0:order-1:payload'); }); test('api model cache bypasses model relationship and invalidation work when disabled', function () { @@ -795,7 +939,7 @@ public function gc(int $max_lifetime): int|false ApiModelCache::invalidateQueryCache($model, $request); expect($store->forgotten[0]['tags'])->toBe(['api_cache', 'api_model:orders', 'api_query:orders', 'company:company-1']) - ->and($store->forgotten[0]['key'])->toStartWith('{api_query}:orders:company_company-1:v0.1:'); + ->and($store->forgotten[0]['key'])->toStartWith('{api_query}:mysql:orders:company_company-1:v0.1:'); ApiModelCache::invalidateCompanyCache('company-1'); @@ -1102,7 +1246,7 @@ public function gc(int $max_lifetime): int|false expect(ApiModelCache::cacheQueryResult($order, $request, fn () => collect(['fresher'])))->toEqual(collect(['fresher'])) ->and(ApiModelCache::getCacheStatus())->toBe('MISS') // orders, then audit_logs, order_tags, payloads, tags; each table wide then company-1 - ->and(ApiModelCache::getCacheKey())->toStartWith('{api_query}:orders:company_company-1:v0.0.0.0.1.0.0.1.0.0:'); + ->and(ApiModelCache::getCacheKey())->toStartWith('{api_query}:mysql:orders:company_company-1:v0.0.0.0.1.0.0.1.0.0:'); }); test('has api model cache invalidates a parent list when a dependency or custom pivot is written through eloquent', function () { @@ -1151,7 +1295,7 @@ public function gc(int $max_lifetime): int|false ApiModelCache::invalidateModelCache(new ApiModelCacheDependencyPayload(), 'company-1'); expect(ApiModelCache::cacheRelationship($order, 'payload', fn () => ['name' => 'fresh']))->toBe(['name' => 'fresh']) - ->and(ApiModelCache::getCacheKey())->toBe('{api_relation}:orders:v0.1:order-1:payload') + ->and(ApiModelCache::getCacheKey())->toBe('{api_relation}:mysql:orders:v0.1:order-1:payload') ->and(ApiModelCache::cacheModel($order, 'order-1', fn () => ['payload' => 'fresh'], ['payload:uuid,name']))->toBe(['payload' => 'fresh']) // A lookup that does not load the payload keeps its cache ->and(ApiModelCache::cacheModel($order, 'order-1', fn () => ['status' => 'unexpected']))->toBe(['status' => 'cached']); @@ -1195,7 +1339,7 @@ public function gc(int $max_lifetime): int|false ->and(ApiModelCache::getCacheStatus())->toBe('MISS') ->and(ApiModelCache::cacheQueryResult($model, $storeA, fn () => collect(['not reached']), $callback))->toEqual(collect(['store a product'])) ->and(ApiModelCache::getCacheStatus())->toBe('HIT') - ->and($storeAKey)->toStartWith('{api_query}:orders:company_company-1:v0.0:') + ->and($storeAKey)->toStartWith('{api_query}:mysql:orders:company_company-1:v0.0:') ->and($storeAKey)->not->toContain('store_key_a'); }); @@ -1248,7 +1392,7 @@ public function gc(int $max_lifetime): int|false 'company_uuid' => 'company-1', ])); - expect($sessionless)->toStartWith('{api_query}:orders:company_company-1:v0.0:') + expect($sessionless)->toStartWith('{api_query}:mysql:orders:company_company-1:v0.0:') ->and($sessionless)->toBe($unscoped); }); @@ -1265,9 +1409,9 @@ public function gc(int $max_lifetime): int|false ], null); expect(ApiModelCache::generateQueryCacheKey($model, $userRequest)) - ->toStartWith('{api_query}:orders:company_company-from-user:v0.0:') + ->toStartWith('{api_query}:mysql:orders:company_company-from-user:v0.0:') ->and(ApiModelCache::generateQueryCacheKey($model, $inputRequest)) - ->toStartWith('{api_query}:orders:company_company-from-input:v0.0:'); + ->toStartWith('{api_query}:mysql:orders:company_company-from-input:v0.0:'); }); test('api model cache invalidation methods swallow tagged cache backend failures', function () { @@ -1303,13 +1447,13 @@ public function gc(int $max_lifetime): int|false expect($modelResult)->toBe(['fallback' => true]) ->and(ApiModelCache::getCacheStatus())->toBe('ERROR') - ->and(ApiModelCache::getCacheKey())->toBe('{api_model}:orders:v0:order-1'); + ->and(ApiModelCache::getCacheKey())->toBe('{api_model}:mysql:orders:v0:order-1'); $relationshipResult = ApiModelCache::cacheRelationship($model, 'payload', fn () => ['relationship' => true]); expect($relationshipResult)->toBe(['relationship' => true]) ->and(ApiModelCache::getCacheStatus())->toBe('ERROR') - ->and(ApiModelCache::getCacheKey())->toBe('{api_relation}:orders:v0:order-1:payload'); + ->and(ApiModelCache::getCacheKey())->toBe('{api_relation}:mysql:orders:v0:order-1:payload'); }); test('api model cache warmup logs failures without leaking exceptions', function () { @@ -1357,7 +1501,7 @@ public function gc(int $max_lifetime): int|false ->and($second->pluck('uuid')->all())->toBe(['order-2']) ->and($callbackCalls)->toBe(1) ->and(ApiModelCache::getCacheStatus())->toBe('HIT') - ->and(ApiModelCache::getCacheKey())->toStartWith('{api_query}:orders:company_company-1:v0.0:') + ->and(ApiModelCache::getCacheKey())->toStartWith('{api_query}:mysql:orders:company_company-1:v0.0:') ->and(ApiModelCacheTraitTestModel::$mutatedRequestQueries)->toHaveCount(1) ->and(ApiModelCacheTraitTestModel::$mutatedRequestQueries[0])->toMatchArray([ 'status' => 'active', @@ -1522,7 +1666,7 @@ public function perPage(): int ->and($model->getRelation('cached_payload'))->toBe($firstRelation) ->and($store->flushedTags)->toContain(['api_cache', 'api_model:orders', 'company:company-1']) ->and($store->flushedTags)->toContain(['api_cache', 'api_model:orders', 'api_query:orders', 'company:company-1']) - ->and($store->forgotten[0]['key'])->toStartWith('{api_query}:orders:company_company-1:') + ->and($store->forgotten[0]['key'])->toStartWith('{api_query}:mysql:orders:company_company-1:') ->and(ApiModelCacheTraitTestModel::getCacheStats())->toMatchArray([ 'enabled' => true, 'driver' => 'array',