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',