diff --git a/config/api.php b/config/api.php index 16ca6cf0..2f962b39 100644 --- a/config/api.php +++ b/config/api.php @@ -69,6 +69,13 @@ 'session' => ['api_credential', 'storefront_store', 'storefront_network'], ], + // Throw when a model's $apiCacheDependencies entry cannot be resolved, instead + // of logging it and skipping the entry. Meant for test suites; keep it off in + // production so an extension's bad declaration never fails a request. + // Default: false + // Example: API_CACHE_STRICT_DEPENDENCIES=true + 'strict_dependencies' => env('API_CACHE_STRICT_DEPENDENCIES', false), + // Cache driver (uses Laravel's cache configuration) // Options: redis, memcached, database, file // Default: uses config('cache.default') diff --git a/src/Support/ApiModelCache.php b/src/Support/ApiModelCache.php index 2e1b99c9..1c272fe7 100644 --- a/src/Support/ApiModelCache.php +++ b/src/Support/ApiModelCache.php @@ -2,10 +2,17 @@ namespace Fleetbase\Support; +use Illuminate\Container\Container; +use Illuminate\Database\DatabaseTransactionsManager; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Eloquent\Relations\HasManyThrough; +use Illuminate\Database\Eloquent\Relations\MorphTo; +use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Http\Request; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Str; /** * API Model Cache Manager. @@ -19,9 +26,15 @@ * - Cache tagging for efficient bulk invalidation * * Caches are invalidated by versions embedded in their keys, not by the tag - * flush: list caches carry a per table/company query version, single model and - * relationship caches a per table model version (their lookups are keyed by id - * and know no company). On Laravel 10 with phpredis >= 6.1, + * flush: list caches carry per table query versions (table wide and per company), + * single model and relationship caches per table model versions (their lookups + * are keyed by id and know no company). A key carries the versions of the + * model's own table and of every table its cached result embeds: the tables a + * model declares in `$apiCacheDependencies` (relation paths or table names), plus + * the relations a request (`with`, `expand`, `count`) or a model or relationship + * lookup loads. A write to any of those tables changes the key. Only what is + * loaded inside the cached callback goes stale; relations a resource lazy loads + * at render time are queried fresh on every hit. On Laravel 10 with phpredis >= 6.1, * RedisTagSet::entries() starts ZSCAN with cursor '0', which phpredis treats as * 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 @@ -68,6 +81,28 @@ class ApiModelCache */ public const SESSION_SCOPE = ['api_credential', 'storefront_store', 'storefront_network']; + /** + * Resolved dependency tables, keyed by model class, mode and relation path. + * + * @var array + */ + protected static array $dependencyTables = []; + + /** + * Most resolved paths kept per process; request paths are client supplied. + */ + protected const DEPENDENCY_MEMO_LIMIT = 1000; + + /** + * Most relation paths read from one request; the rest add no versions. + */ + protected const REQUEST_PATH_LIMIT = 50; + + /** + * Depth of withoutInvalidating() scopes currently running. + */ + protected static int $invalidationSuppressed = 0; + /** * Generate a cache key for a query. * @@ -116,17 +151,27 @@ public static function generateQueryCacheKey(Model $model, Request $request, arr // Include company UUID for multi-tenancy $companyPart = $companyUuid ? "company_{$companyUuid}" : 'no_company'; - // Get query version for this table/company + // Get query versions for this table and the tables its responses embed // This ensures cache keys change after invalidation - $version = Cache::get(static::queryVersionKey($table, $companyUuid), 0); + $versionKeys = []; + foreach ([$table, ...static::dependencyTables($model, static::requestRelationPaths($request))] as $versionTable) { + $versionKeys[] = static::queryVersionKey($versionTable, null); + if ($companyUuid) { + $versionKeys[] = static::queryVersionKey($versionTable, $companyUuid); + } + } + $version = static::versionSegment($versionKeys); // 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}:v{$version}:{$paramsHash}"; + return "{api_query}:{$table}:{$companyPart}:{$version}:{$paramsHash}"; } /** * 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. */ protected static function queryVersionKey(string $table, ?string $companyUuid): string { @@ -141,6 +186,277 @@ protected static function modelVersionKey(string $table): string return "api_model_version:{$table}"; } + /** + * Read versions in one round trip and join them into a key segment. + * + * @param string[] $versionKeys + */ + protected static function versionSegment(array $versionKeys): string + { + $versions = Cache::many($versionKeys); + + return 'v' . implode('.', array_map(fn ($version) => (int) $version, $versions)); + } + + /** + * Get the other tables whose data a model's cached results embed. + * + * Declared dependencies come from the model's `$apiCacheDependencies` and must + * resolve (an entry that does not is logged and skipped, or throws when + * api.cache.strict_dependencies is on); `$paths` are relations a request or lookup loads (`with`, `expand`, + * `$with`, a relationship name), and segments that cannot be resolved there + * end that path. + * + * @param string[] $paths + * + * @return string[] sorted table names, excluding the model's own table + */ + public static function dependencyTables(Model $model, array $paths = []): array + { + $declared = method_exists($model, 'getApiCacheDependencies') ? $model->getApiCacheDependencies() : []; + $tables = []; + + foreach ($declared as $path) { + array_push($tables, ...static::pathTables($model, $path, true)); + } + foreach ($paths as $path) { + array_push($tables, ...static::pathTables($model, $path, false)); + } + + $tables = array_values(array_unique(array_diff($tables, [$model->getTable()]))); + sort($tables); + + return $tables; + } + + /** + * Resolve one relation path, memoized per process. + * + * @return string[] + */ + protected static function pathTables(Model $model, string $path, bool $strict): array + { + $memoKey = get_class($model) . '|' . ($strict ? 'declared' : 'lookup') . '|' . $path; + if (array_key_exists($memoKey, static::$dependencyTables)) { + return static::$dependencyTables[$memoKey]; + } + + if (count(static::$dependencyTables) >= static::DEPENDENCY_MEMO_LIMIT) { + static::$dependencyTables = []; + } + + return static::$dependencyTables[$memoKey] = static::resolvePath($model, $path, $strict); + } + + /** + * Resolve a relation path (or, when declared, a table name) to tables. + * + * A relation contributes its related table, the pivot table of a many to many + * relation and the intermediate table of a has-many-through. A morphTo has no + * single related table, so declarations must name its tables instead. + * + * @return string[] + */ + protected static function resolvePath(Model $model, string $path, bool $strict): array + { + $tables = []; + $current = $model->newInstance(); + $segments = explode('.', $path); + + foreach ($segments as $segment) { + $method = static::relationMethod($current, trim($segment), $strict); + $relation = null; + if ($method) { + try { + // Like Eloquent's eager loading: a fresh instance, without the relation's constraints + $relation = Relation::noConstraints(fn () => $current->newInstance()->{$method}()); + } catch (\Throwable $e) { + // A relation that cannot be built on a blank model adds no versions + $relation = null; + } + } + + if (!$relation instanceof Relation || $relation instanceof MorphTo) { + if (!$strict) { + return $tables; + } + + // A declared entry that is not a relation name is a table name + if ($method === null && count($segments) === 1) { + return [$segment]; + } + + // Extensions deploy independently of core, so a bad declaration never + // fails a request; it is logged once per process, and throws only when + // api.cache.strict_dependencies is on (tests) + $message = sprintf('%s::$apiCacheDependencies entry "%s" cannot be resolved at "%s"; declare a relation path or the table names it reads.', get_class($model), $path, $segment); + if (config('api.cache.strict_dependencies', false)) { + throw new \LogicException($message); + } + Log::error($message); + + return $tables; + } + + if ($relation instanceof BelongsToMany) { + $tables[] = $relation->getTable(); + } + if ($relation instanceof HasManyThrough) { + $tables[] = $relation->getParent()->getTable(); + } + + $current = $relation->getRelated(); + $tables[] = $current->getTable(); + } + + return $tables; + } + + /** + * Find the method a relation segment names, as requests spell it (camel or snake case). + * + * Only public, non-static methods without required parameters are considered. + * Request paths are client supplied, so for them a method is only called when it + * declares a Relation return type: an untyped method could be anything (e.g. + * Order::dispatch() saves and fires events), and calling it is never worth a + * cache version. Declared paths come from the model's own code and may name + * untyped relation methods. + */ + protected static function relationMethod(Model $model, string $segment, bool $strict): ?string + { + foreach (array_unique([$segment, Str::camel($segment), Str::snake($segment)]) as $method) { + if ($method === '' || !method_exists($model, $method)) { + continue; + } + + $reflection = new \ReflectionMethod($model, $method); + if (!$reflection->isPublic() || $reflection->isStatic() || $reflection->getNumberOfRequiredParameters() > 0) { + continue; + } + if (!$strict && !static::declaresRelationReturn($reflection)) { + continue; + } + + return $method; + } + + return null; + } + + /** + * Whether a method's declared return type is an Eloquent relation. + */ + protected static function declaresRelationReturn(\ReflectionMethod $method): bool + { + $type = $method->getReturnType(); + + return $type instanceof \ReflectionNamedType + && !$type->isBuiltin() + && is_a($type->getName(), Relation::class, true); + } + + /** + * Get the relation paths a list request eager loads or counts. + * + * @return string[] + */ + protected static function requestRelationPaths(Request $request): array + { + $paths = []; + foreach (['with', 'expand', 'count', 'with_count'] as $param) { + $value = $request->input($param); + if (is_string($value)) { + $value = explode(',', $value); + } + if (is_array($value)) { + array_push($paths, ...array_filter($value, 'is_string')); + } + } + + return array_slice(array_values(array_unique(array_map('trim', $paths))), 0, static::REQUEST_PATH_LIMIT); + } + + /** + * Turn eager load definitions into relation paths. + * + * @param array $with + * + * @return string[] + */ + protected static function relationPaths(array $with): array + { + $paths = []; + foreach ($with as $key => $value) { + $path = is_string($key) ? $key : $value; + if (is_string($path)) { + $paths[] = explode(':', $path, 2)[0]; + } + } + + return $paths; + } + + /** + * Bump versions again when the write's transaction commits. + * + * Model events fire before the surrounding transaction commits, so a read in + * between sees the old rows under the new version and caches them. The second + * bump on commit retires anything cached in that window. The transaction may be + * on another connection than the model's (e.g. a storefront model written + * inside a transaction on the default connection), so this defers through the + * transactions manager all connections share. Outside a transaction the + * immediate bump stands alone. + * + * @param string[] $versionKeys + */ + protected static function bumpVersionsAfterCommit(Model $model, array $versionKeys): void + { + $bump = fn () => static::bumpVersions($versionKeys, $model); + + try { + $container = Container::getInstance(); + if ($container->bound('db.transactions')) { + $transactions = $container->make('db.transactions'); + if ($transactions instanceof DatabaseTransactionsManager && $transactions->callbackApplicableTransactions()->isNotEmpty()) { + $transactions->addCallback($bump); + } + + return; + } + + // No shared manager (e.g. a bare capsule): only the model's connection is known + if (Model::getConnectionResolver() !== null && $model->getConnection()->transactionLevel() > 0) { + $model->getConnection()->afterCommit($bump); + } + } catch (\Throwable $e) { + // Nothing to defer to; the immediate bump stands alone + } + } + + /** + * Advance versions without ever failing the write that triggered them. + * + * Invalidation runs inside model events, after-commit callbacks and write paths + * such as location pings. A cache outage there must not fail or roll back the + * write; the cached entries it would have retired expire within their TTL. + * + * @param string[] $versionKeys + */ + protected static function bumpVersions(array $versionKeys, Model $model): void + { + try { + foreach ($versionKeys as $versionKey) { + static::bumpVersion($versionKey); + } + } catch (\Throwable $e) { + Log::error('Failed to advance API cache versions', [ + 'model' => get_class($model), + 'keys' => $versionKeys, + 'error' => $e->getMessage(), + ]); + } + } + /** * Advance a cache version so keys built from it change. * @@ -164,11 +480,12 @@ public static function generateModelCacheKey(Model $model, $id, array $with = [] { $table = $model->getTable(); $withHash = !empty($with) ? ':' . md5(json_encode($with)) : ''; - $version = Cache::get(static::modelVersionKey($table), 0); + $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}:v{$version}:{$id}{$withHash}"; + return "{api_model}:{$table}:{$version}:{$id}{$withHash}"; } /** @@ -178,11 +495,12 @@ public static function generateRelationshipCacheKey(Model $model, string $relati { $table = $model->getTable(); $id = $model->getKey(); - $version = Cache::get(static::modelVersionKey($table), 0); + $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 - // Shares the model version, since relationships are invalidated with their parent - return "{api_relation}:{$table}:v{$version}:{$id}:{$relationshipName}"; + // Carries the parent's versions and the related tables', so a write to either side changes it + return "{api_relation}:{$table}:{$version}:{$id}:{$relationshipName}"; } /** @@ -222,16 +540,19 @@ public static function cacheQueryResult(Model $model, Request $request, \Closure return $result ?? collect([]); // Guard against null } - $cacheKey = static::generateQueryCacheKey($model, $request, $additionalParams); - $companyUuid = static::getCompanyUuid($request); - $tags = static::generateCacheTags($model, $companyUuid, true); // Include query tag - $ttl = $ttl ?? static::LIST_TTL; + $cacheKey = null; + $ttl = $ttl ?? static::LIST_TTL; // FIX #3: Guard against read-after-invalidate in same request // After invalidation, resetCacheStatus() sets $cacheStatus to null and the - // query version has moved, so the key generated above is already the new one + // query version has moved, so the key generated below is already the new one try { + // Inside the try, so a cache outage while reading versions falls back to the query + $cacheKey = static::generateQueryCacheKey($model, $request, $additionalParams); + $companyUuid = static::getCompanyUuid($request); + $tags = static::generateCacheTags($model, $companyUuid, true); // Include query tag + // Initialize cache status and key static::$cacheStatus = null; static::$cacheKey = $cacheKey; @@ -374,7 +695,7 @@ public static function cacheRelationship(Model $model, string $relationshipName, */ public static function invalidateModelCache(Model $model, ?string $companyUuid = null): void { - if (!static::isCachingEnabled()) { + if (!static::isCachingEnabled() || static::$invalidationSuppressed > 0) { return; } @@ -386,9 +707,10 @@ public static function invalidateModelCache(Model $model, ?string $companyUuid = // FIX #4: Increment the query and model version counters // This ensures cache keys change after invalidation, and it is what // actually hides stale caches (see the class docblock) - $table = $model->getTable(); - static::bumpVersion(static::queryVersionKey($table, $companyUuid)); - static::bumpVersion(static::modelVersionKey($table)); + $table = $model->getTable(); + $versionKeys = [static::queryVersionKey($table, $companyUuid), static::modelVersionKey($table)]; + static::bumpVersions($versionKeys, $model); + static::bumpVersionsAfterCommit($model, $versionKeys); // Generate tags for BOTH model and query caches // Model caches: single-record lookups @@ -413,6 +735,31 @@ public static function invalidateModelCache(Model $model, ?string $companyUuid = } } + /** + * Run a callback without invalidating API caches. + * + * For high frequency writes (telemetry pings) whose model events should still + * fire, but whose cache invalidation the caller coalesces itself, e.g. once per + * window per table. Everything else the callback writes is not invalidated + * either, so keep the callback to the write itself. + * + * @template T + * + * @param \Closure(): T $callback + * + * @return T + */ + public static function withoutInvalidating(\Closure $callback): mixed + { + static::$invalidationSuppressed++; + + try { + return $callback(); + } finally { + static::$invalidationSuppressed--; + } + } + /** * Invalidate cache for a specific query. */ diff --git a/src/Traits/HasApiModelCache.php b/src/Traits/HasApiModelCache.php index 195b89cf..5416696f 100644 --- a/src/Traits/HasApiModelCache.php +++ b/src/Traits/HasApiModelCache.php @@ -250,6 +250,25 @@ public function loadMultipleCached($relationships) return $this; } + /** + * Get the relation paths or table names this model's cached responses embed. + * + * Declare them on the model as `protected $apiCacheDependencies = ['catalogs', 'catalogs.products', 'some_table'];` + * so a write to any of those tables invalidates this model's caches. List only + * what the cached query loads (`$with`, filter or controller eager loads, counts); + * request `with`/`expand` paths are added automatically. Writes only invalidate + * when the model writing that table uses HasApiModelBehavior or this trait (for + * pivots, a `->using()` pivot class with one), goes through Eloquent events (not + * query builder updates, inserts or quiet saves), or is followed by + * invalidateApiCacheManually(). + * + * @return string[] + */ + public function getApiCacheDependencies(): array + { + return property_exists($this, 'apiCacheDependencies') ? (array) $this->apiCacheDependencies : []; + } + /** * Invalidate all caches for this model. */ diff --git a/tests/Unit/Support/ApiModelCacheTest.php b/tests/Unit/Support/ApiModelCacheTest.php index 98978d5b..9d064e84 100644 --- a/tests/Unit/Support/ApiModelCacheTest.php +++ b/tests/Unit/Support/ApiModelCacheTest.php @@ -3,8 +3,14 @@ use Fleetbase\Support\ApiModelCache; use Fleetbase\Traits\HasApiModelCache; use Illuminate\Database\Capsule\Manager as Capsule; +use Illuminate\Database\DatabaseTransactionsManager; use Illuminate\Database\Eloquent\Builder as EloquentBuilder; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Eloquent\Relations\HasManyThrough; +use Illuminate\Database\Eloquent\Relations\MorphTo; +use Illuminate\Database\Eloquent\Relations\Pivot; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Events\Dispatcher; use Illuminate\Http\Request; @@ -80,6 +86,126 @@ class ApiModelCacheSoftDeletingTraitTestModel extends ApiModelCacheTraitTestMode use SoftDeletes; } +class ApiModelCacheDependencyPayload extends Model +{ + use HasApiModelCache; + + protected $table = 'payloads'; + protected $primaryKey = 'uuid'; + public $incrementing = false; + protected $keyType = 'string'; + protected $guarded = []; +} + +class ApiModelCacheDependencyTag extends Model +{ + protected $table = 'tags'; + protected $primaryKey = 'uuid'; + public $incrementing = false; + protected $keyType = 'string'; + protected $guarded = []; +} + +class ApiModelCacheDependencyOrderTag extends Pivot +{ + use HasApiModelCache; + + protected $table = 'order_tags'; + public $timestamps = false; +} + +class ApiModelCacheRelationsOrder extends ApiModelCacheTraitTestModel +{ + public static int $sideEffects = 0; + + public function payload(): BelongsTo + { + return $this->belongsTo(ApiModelCacheDependencyPayload::class, 'payload_uuid', 'uuid'); + } + + public function tags(): BelongsToMany + { + return $this->belongsToMany(ApiModelCacheDependencyTag::class, 'order_tags', 'order_uuid', 'tag_uuid') + ->using(ApiModelCacheDependencyOrderTag::class); + } + + public function subject(): MorphTo + { + return $this->morphTo(); + } + + public function tagPayloads(): HasManyThrough + { + return $this->hasManyThrough(ApiModelCacheDependencyPayload::class, ApiModelCacheDependencyTag::class, 'order_uuid', 'tag_uuid', 'uuid', 'uuid'); + } + + public function reconnectingPayload(): BelongsTo + { + $this->setConnection('mysql'); + + return $this->belongsTo(ApiModelCacheDependencyPayload::class, 'payload_uuid', 'uuid'); + } + + public function summary(): string + { + return 'not a relation'; + } + + // An untyped relation: declarations may name it, client paths may not + public function untypedPayload() + { + return $this->belongsTo(ApiModelCacheDependencyPayload::class, 'payload_uuid', 'uuid'); + } + + // Shaped like Order::dispatch(): public, no required arguments, side effects + public function dispatch(bool $save = true) + { + static::$sideEffects++; + + return $this; + } + + public function exploding(): BelongsTo + { + throw new RuntimeException('needs a loaded model'); + } +} + +class ApiModelCacheDependencyOrder extends ApiModelCacheRelationsOrder +{ + protected $apiCacheDependencies = ['payload', 'tags', 'audit_logs']; +} + +class ApiModelCacheUntypedDependencyOrder extends ApiModelCacheRelationsOrder +{ + protected $apiCacheDependencies = ['untypedPayload']; +} + +class ApiModelCacheExplodingDependencyOrder extends ApiModelCacheRelationsOrder +{ + protected $apiCacheDependencies = ['tags', 'exploding']; +} + +class ApiModelCacheMorphDependencyOrder extends ApiModelCacheRelationsOrder +{ + protected $apiCacheDependencies = ['subject']; +} + +class ApiModelCacheUnresolvableDependencyOrder extends ApiModelCacheRelationsOrder +{ + protected $apiCacheDependencies = ['payload.customer']; +} + +class ApiModelCacheNotRelationDependencyOrder extends ApiModelCacheRelationsOrder +{ + protected $apiCacheDependencies = ['summary']; +} + +class ApiModelCachePartlyResolvableDependencyOrder extends ApiModelCacheRelationsOrder +{ + protected $apiCacheDependencies = ['tags', 'payload.customer']; +} + class ApiModelCacheTestLock { public function __construct(private ApiModelCacheTestStore $store) @@ -145,6 +271,8 @@ class ApiModelCacheTestStore public array $flushedTags = []; public array $forgotten = []; public bool $throwOnTags = false; + public bool $throwOnMany = false; + public bool $throwOnIncrement = false; public bool $lockReturnsNull = false; public bool $flushRemovesValues = true; public bool $incrementCreatesMissing = true; @@ -154,6 +282,20 @@ public function get(string $key, mixed $default = null): mixed return $this->values[$key] ?? $default; } + public function many(array $keys): array + { + if ($this->throwOnMany) { + throw new RuntimeException('cache unavailable'); + } + + $values = []; + foreach ($keys as $key) { + $values[$key] = $this->values[$key] ?? null; + } + + return $values; + } + public function put(string $key, mixed $value): bool { $this->values[$key] = $value; @@ -174,6 +316,10 @@ public function add(string $key, mixed $value): bool public function increment(string $key): int|false { + if ($this->throwOnIncrement) { + throw new RuntimeException('cache unavailable'); + } + if (!$this->incrementCreatesMissing && !array_key_exists($key, $this->values)) { return false; } @@ -326,6 +472,31 @@ public function uri(): string return $request; } +function api_model_cache_dependency_database(): Capsule +{ + $capsule = api_model_cache_trait_database(); + $schema = $capsule->getConnection('mysql')->getSchemaBuilder(); + + $schema->table('orders', fn ($table) => $table->string('payload_uuid')->nullable()); + $schema->create('payloads', function ($table) { + $table->string('uuid')->primary(); + $table->string('company_uuid')->nullable(); + $table->string('name')->nullable(); + $table->timestamp('created_at')->nullable(); + $table->timestamp('updated_at')->nullable(); + }); + $schema->create('tags', function ($table) { + $table->string('uuid')->primary(); + $table->string('name')->nullable(); + }); + $schema->create('order_tags', function ($table) { + $table->string('order_uuid'); + $table->string('tag_uuid'); + }); + + return $capsule; +} + function api_model_cache_trait_database(): Capsule { Model::clearBootedModels(); @@ -435,7 +606,7 @@ public function gc(int $max_lifetime): int|false ]), ['callback' => true]); expect($first)->toBe($second) - ->and($first)->toStartWith('{api_query}:orders:company_company-1:v7:') + ->and($first)->toStartWith('{api_query}: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']))) ->and(ApiModelCache::generateRelationshipCacheKey($model, 'payload')) @@ -459,7 +630,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:'); + ->and(ApiModelCache::getCacheKey())->toStartWith('{api_query}:orders:company_company-1:v0.0:'); $second = ApiModelCache::cacheQueryResult($model, $request, function () use (&$calls) { $calls++; @@ -606,7 +777,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:v1:'); + ->and($store->forgotten[0]['key'])->toStartWith('{api_query}:orders:company_company-1:v0.1:'); ApiModelCache::invalidateCompanyCache('company-1'); @@ -664,6 +835,275 @@ public function gc(int $max_lifetime): int|false ->and($store->get('api_model_version:orders'))->toBe(2); }); +test('api model cache resolves declared dependencies to related pivot and raw table names', function () { + api_model_cache_dependency_database(); + config(['api.cache.strict_dependencies' => true]); + + expect(ApiModelCache::dependencyTables(new ApiModelCacheDependencyOrder()))->toBe(['audit_logs', 'order_tags', 'payloads', 'tags']) + ->and(ApiModelCache::dependencyTables(new ApiModelCacheRelationsOrder()))->toBe([]) + // Lookup paths skip what they cannot resolve instead of failing the lookup + ->and(ApiModelCache::dependencyTables(new ApiModelCacheRelationsOrder(), ['payload', 'subject', 'missing', 'tags.missing'])) + ->toBe(['order_tags', 'payloads', 'tags']) + ->and(fn () => ApiModelCache::dependencyTables(new ApiModelCacheMorphDependencyOrder())) + ->toThrow(LogicException::class, 'entry "subject" cannot be resolved at "subject"') + ->and(fn () => ApiModelCache::dependencyTables(new ApiModelCacheUnresolvableDependencyOrder())) + ->toThrow(LogicException::class, 'entry "payload.customer" cannot be resolved at "customer"') + ->and(fn () => ApiModelCache::dependencyTables(new ApiModelCacheNotRelationDependencyOrder())) + ->toThrow(LogicException::class, 'entry "summary" cannot be resolved at "summary"'); +}); + +test('api model cache logs and skips an unresolvable declaration unless strict', function () { + api_model_cache_dependency_database(); + config(['api.cache.strict_dependencies' => false]); + $logger = new ApiModelCacheTestLogger(); + app()->instance('log', $logger); + Facade::clearResolvedInstance('log'); + + expect(ApiModelCache::dependencyTables(new ApiModelCachePartlyResolvableDependencyOrder()))->toBe(['order_tags', 'payloads', 'tags']) + ->and($logger->errors)->toHaveCount(1) + ->and($logger->errors[0]['message'])->toContain('entry "payload.customer" cannot be resolved at "customer"'); +}); + +test('api model cache never calls a method a client names unless it is declared to return a relation', function () { + api_model_cache_dependency_database(); + config(['api.cache.strict_dependencies' => false]); + ApiModelCacheRelationsOrder::$sideEffects = 0; + $order = new ApiModelCacheRelationsOrder(); + + expect(ApiModelCache::dependencyTables($order, ['dispatch', 'untypedPayload', 'untyped_payload', 'exploding', 'payload']))->toBe(['payloads']) + ->and(ApiModelCacheRelationsOrder::$sideEffects)->toBe(0) + // A declaration comes from the model's own code and may name an untyped relation + ->and(ApiModelCache::dependencyTables(new ApiModelCacheUntypedDependencyOrder()))->toBe(['payloads']); + + // A list request naming them still caches and versions what it can + $request = api_model_cache_request(['with' => ['dispatch', 'exploding', 'payload']]); + expect(ApiModelCache::cacheQueryResult($order, $request, fn () => collect(['listed'])))->toEqual(collect(['listed'])) + ->and(ApiModelCache::getCacheStatus())->toBe('MISS') + ->and(ApiModelCacheRelationsOrder::$sideEffects)->toBe(0); +}); + +test('api model cache contains a declared relation that throws on a blank model', function () { + api_model_cache_dependency_database(); + config(['api.cache.strict_dependencies' => false]); + $logger = new ApiModelCacheTestLogger(); + app()->instance('log', $logger); + Facade::clearResolvedInstance('log'); + + expect(ApiModelCache::dependencyTables(new ApiModelCacheExplodingDependencyOrder()))->toBe(['order_tags', 'tags']) + ->and($logger->errors)->toHaveCount(1) + ->and($logger->errors[0]['message'])->toContain('entry "exploding" cannot be resolved'); +}); + +test('api model cache invalidation never fails the write when the cache is unavailable', function () { + $store = api_model_cache_fixture(); + $store->throwOnIncrement = true; + $store->throwOnTags = true; + $logger = new ApiModelCacheTestLogger(); + app()->instance('log', $logger); + Facade::clearResolvedInstance('log'); + + ApiModelCache::invalidateModelCache(api_model_cache_model(), 'company-1'); + + expect($logger->errors)->toHaveCount(2) + ->and($logger->errors[0]['message'])->toBe('Failed to advance API cache versions') + ->and($logger->errors[0]['context']['keys'])->toBe(['api_query_version:orders:company-1', 'api_model_version:orders']) + ->and($logger->errors[1]['message'])->toBe('Failed to invalidate cache'); +}); + +test('api model cache skips invalidation inside a withoutInvalidating scope only', function () { + $store = api_model_cache_fixture(); + $model = api_model_cache_model(); + + $result = ApiModelCache::withoutInvalidating(function () use ($model) { + ApiModelCache::invalidateModelCache($model, 'company-1'); + // Nested scopes stay suppressed until the outermost one ends + ApiModelCache::withoutInvalidating(fn () => ApiModelCache::invalidateModelCache($model, 'company-1')); + ApiModelCache::invalidateModelCache($model, 'company-1'); + + return 'written'; + }); + + expect($result)->toBe('written') + ->and($store->get('api_query_version:orders:company-1'))->toBeNull(); + + // A callback that throws still ends the scope + expect(fn () => ApiModelCache::withoutInvalidating(fn () => throw new RuntimeException('write failed')))->toThrow(RuntimeException::class); + + ApiModelCache::invalidateModelCache($model, 'company-1'); + expect($store->get('api_query_version:orders:company-1'))->toBe(1); +}); + +test('api model cache falls back to the query when reading versions fails', function () { + $store = api_model_cache_fixture(); + $store->throwOnMany = true; + $model = api_model_cache_model(); + + expect(ApiModelCache::cacheQueryResult($model, api_model_cache_request(['status' => 'active']), fn () => collect(['direct'])))->toEqual(collect(['direct'])) + ->and(ApiModelCache::getCacheStatus())->toBe('MISS') + ->and($store->taggedValues)->toBe([]); +}); + +test('api model cache retires a list cached while the invalidating write was uncommitted', function () { + $capsule = api_model_cache_dependency_database(); + $store = app('cache'); + $store->flushRemovesValues = false; + $connection = $capsule->getConnection('mysql'); + $transactions = new DatabaseTransactionsManager(); + $connection->setTransactionManager($transactions); + app()->instance('db.transactions', $transactions); + $order = new ApiModelCacheDependencyOrder(); + $request = api_model_cache_request(['status' => 'active']); + + $connection->beginTransaction(); + ApiModelCache::invalidateModelCache(new ApiModelCacheDependencyPayload(), 'company-1'); + + // Another request reads before the commit and caches the old rows under the new version + ApiModelCache::cacheQueryResult($order, $request, fn () => collect(['uncommitted view'])); + expect($store->get('api_query_version:payloads:company-1'))->toBe(1); + + $connection->commit(); + + expect($store->get('api_query_version:payloads:company-1'))->toBe(2) + ->and(ApiModelCache::cacheQueryResult($order, $request, fn () => collect(['committed'])))->toEqual(collect(['committed'])); + + // The transaction may be on another connection than the written model's + $capsule->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => ''], 'other'); + $other = $capsule->getConnection('other'); + $other->setTransactionManager($transactions); + $other->beginTransaction(); + ApiModelCache::invalidateModelCache(new ApiModelCacheDependencyPayload(), 'company-1'); + ApiModelCache::cacheQueryResult($order, $request, fn () => collect(['uncommitted elsewhere'])); + $other->commit(); + + expect(ApiModelCache::cacheQueryResult($order, $request, fn () => collect(['committed elsewhere'])))->toEqual(collect(['committed elsewhere'])); + + // A rolled back write leaves only its immediate bump + $connection->beginTransaction(); + ApiModelCache::invalidateModelCache(new ApiModelCacheDependencyPayload(), 'company-1'); + $connection->rollBack(); + + expect($store->get('api_query_version:payloads:company-1'))->toBe(5); + + app()->forgetInstance('db.transactions'); +}); + +test('api model cache resolves request style relation paths without side effects on the model', function () { + api_model_cache_dependency_database(); + $order = new ApiModelCacheRelationsOrder(); + + expect(ApiModelCache::dependencyTables($order, ['tag_payloads']))->toBe(['payloads', 'tags']) + // Framework methods named by a client are never called + ->and(ApiModelCache::dependencyTables($order, ['delete', 'push', 'touch', 'getTable']))->toBe([]) + ->and(ApiModelCache::dependencyTables($order, ['reconnecting_payload']))->toBe(['payloads']) + ->and($order->getConnectionName())->toBeNull(); +}); + +test('api model cache versions a list by the relations its request eager loads', function () { + api_model_cache_dependency_database(); + $store = app('cache'); + $store->flushRemovesValues = false; + $order = new ApiModelCacheRelationsOrder(); + $withPayload = api_model_cache_request(['with' => ['payload']]); + $expandTags = api_model_cache_request(['expand' => 'tags']); + $plain = api_model_cache_request(); + + ApiModelCache::cacheQueryResult($order, $withPayload, fn () => collect(['stale payload'])); + ApiModelCache::cacheQueryResult($order, $expandTags, fn () => collect(['stale tags'])); + ApiModelCache::cacheQueryResult($order, $plain, fn () => collect(['plain'])); + + ApiModelCache::invalidateModelCache(new ApiModelCacheDependencyPayload(), 'company-1'); + + expect(ApiModelCache::cacheQueryResult($order, $withPayload, fn () => collect(['fresh payload'])))->toEqual(collect(['fresh payload'])) + ->and(ApiModelCache::cacheQueryResult($order, $expandTags, fn () => collect(['unexpected'])))->toEqual(collect(['stale tags'])) + ->and(ApiModelCache::cacheQueryResult($order, $plain, fn () => collect(['unexpected'])))->toEqual(collect(['plain'])); + + ApiModelCache::invalidateModelCache(new ApiModelCacheDependencyOrderTag(), null); + + expect(ApiModelCache::cacheQueryResult($order, $expandTags, fn () => collect(['fresh tags'])))->toEqual(collect(['fresh tags'])) + ->and(ApiModelCache::cacheQueryResult($order, $plain, fn () => collect(['unexpected'])))->toEqual(collect(['plain'])); +}); + +test('api model cache does not serve a list after a write to a declared dependency of the same company or of no company', function () { + api_model_cache_dependency_database(); + $store = app('cache'); + $store->flushRemovesValues = false; + $order = new ApiModelCacheDependencyOrder(); + $request = api_model_cache_request(['status' => 'active']); + + ApiModelCache::cacheQueryResult($order, $request, fn () => collect(['stale'])); + + // Another company's write leaves this company's list alone + ApiModelCache::invalidateModelCache(new ApiModelCacheDependencyPayload(), 'company-2'); + + expect(ApiModelCache::cacheQueryResult($order, $request, fn () => collect(['unexpected'])))->toEqual(collect(['stale'])) + ->and(ApiModelCache::getCacheStatus())->toBe('HIT'); + + ApiModelCache::invalidateModelCache(new ApiModelCacheDependencyPayload(), 'company-1'); + + expect(ApiModelCache::cacheQueryResult($order, $request, fn () => collect(['fresh'])))->toEqual(collect(['fresh'])) + ->and(ApiModelCache::getCacheStatus())->toBe('MISS'); + + // Pivots carry no company, so their writes move the table wide version every company reads + ApiModelCache::invalidateModelCache(new ApiModelCacheDependencyOrderTag(), null); + + 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:'); +}); + +test('has api model cache invalidates a parent list when a dependency or custom pivot is written through eloquent', function () { + api_model_cache_dependency_database(); + $store = app('cache'); + $store->flushRemovesValues = false; + + Model::setEventDispatcher(new Dispatcher(app())); + Model::clearBootedModels(); + + $request = api_model_cache_request(['status' => 'active']); + $read = fn (string $value) => ApiModelCache::cacheQueryResult(new ApiModelCacheDependencyOrder(), $request, fn () => collect([$value])); + + $read('before payload write'); + $payload = ApiModelCacheDependencyPayload::create(['uuid' => 'payload-1', 'company_uuid' => 'company-1', 'name' => 'Payload']); + + expect($read('after payload create'))->toEqual(collect(['after payload create'])); + + $payload->update(['name' => 'Renamed']); + + expect($read('after payload update'))->toEqual(collect(['after payload update'])); + + $order = ApiModelCacheDependencyOrder::find('order-1'); + $order->tags()->attach('tag-1'); + + expect($read('after attach'))->toEqual(collect(['after attach'])); + + $order->tags()->detach('tag-1'); + + expect($read('after detach'))->toEqual(collect(['after detach'])) + ->and($read('unexpected'))->toEqual(collect(['after detach'])); + + Model::unsetEventDispatcher(); +}); + +test('api model cache does not serve relationship or eager loaded lookups after a write to the related table', function () { + api_model_cache_dependency_database(); + $store = app('cache'); + $store->flushRemovesValues = false; + $order = new ApiModelCacheRelationsOrder(['uuid' => 'order-1']); + + ApiModelCache::cacheRelationship($order, 'payload', fn () => ['name' => 'stale']); + ApiModelCache::cacheModel($order, 'order-1', fn () => ['payload' => 'stale'], ['payload:uuid,name']); + ApiModelCache::cacheModel($order, 'order-1', fn () => ['status' => 'cached']); + + 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::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']); +}); + test('api model cache seeds versions on stores whose increment does not create missing keys', function () { $store = api_model_cache_fixture(); $store->flushRemovesValues = false; @@ -702,7 +1142,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:') + ->and($storeAKey)->toStartWith('{api_query}:orders:company_company-1:v0.0:') ->and($storeAKey)->not->toContain('store_key_a'); }); @@ -755,9 +1195,9 @@ public function gc(int $max_lifetime): int|false ], null); expect(ApiModelCache::generateQueryCacheKey($model, $userRequest)) - ->toStartWith('{api_query}:orders:company_company-from-user:v0:') + ->toStartWith('{api_query}:orders:company_company-from-user:v0.0:') ->and(ApiModelCache::generateQueryCacheKey($model, $inputRequest)) - ->toStartWith('{api_query}:orders:company_company-from-input:v0:'); + ->toStartWith('{api_query}:orders:company_company-from-input:v0.0:'); }); test('api model cache invalidation methods swallow tagged cache backend failures', function () { @@ -847,7 +1287,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:') + ->and(ApiModelCache::getCacheKey())->toStartWith('{api_query}:orders:company_company-1:v0.0:') ->and(ApiModelCacheTraitTestModel::$mutatedRequestQueries)->toHaveCount(1) ->and(ApiModelCacheTraitTestModel::$mutatedRequestQueries[0])->toMatchArray([ 'status' => 'active', diff --git a/tests/Unit/Traits/HasApiModelBehaviorTest.php b/tests/Unit/Traits/HasApiModelBehaviorTest.php index 96f1f75b..ba9fb5d8 100644 --- a/tests/Unit/Traits/HasApiModelBehaviorTest.php +++ b/tests/Unit/Traits/HasApiModelBehaviorTest.php @@ -58,6 +58,11 @@ public function get(string $key, mixed $default = null): mixed return $this->store[$key] ?? $default; } + public function many(array $keys): array + { + return array_combine($keys, array_map(fn ($key) => $this->store[$key] ?? null, $keys)); + } + public function put(string $key, mixed $value, mixed $ttl = null): bool { $this->store[$key] = $value;