From 6914ef80337d9cd546365e3958d3aa3d95ccd2d9 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 12 Oct 2026 21:58:22 +0800 Subject: [PATCH 1/4] feat(api-cache): version caches by the tables they embed A cached list stores the Eloquent result, including every relation the cached query eager loads, but its key only carried the parent table's version, so writes to embedded tables (e.g. catalogs embedded in the food truck list) were served stale until the TTL. Keys now carry the versions of the model's own table and of every table its cached result embeds, read in one Cache::many round trip: - tables a model declares in $apiCacheDependencies (relation paths, which include pivot and through tables, or raw table names) - the relations a list request loads or counts (with, expand, count, with_count) - the $with of a model lookup and the relation of a relationship lookup List keys read a table wide version next to the company one, so writes that know no company (pivots, manual invalidation, CLI) invalidate every company. Relation methods are resolved like Eloquent eager loading, on a fresh instance without constraints; client supplied paths never call framework methods and the memo is capped. An unresolvable declaration throws in debug and is logged and skipped otherwise. --- src/Support/ApiModelCache.php | 243 ++++++++++++++- src/Traits/HasApiModelCache.php | 19 ++ tests/Unit/Support/ApiModelCacheTest.php | 283 +++++++++++++++++- tests/Unit/Traits/HasApiModelBehaviorTest.php | 5 + 4 files changed, 533 insertions(+), 17 deletions(-) diff --git a/src/Support/ApiModelCache.php b/src/Support/ApiModelCache.php index 9ce16762..7083b3d5 100644 --- a/src/Support/ApiModelCache.php +++ b/src/Support/ApiModelCache.php @@ -3,9 +3,14 @@ namespace Fleetbase\Support; 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 +24,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 @@ -59,6 +70,18 @@ class ApiModelCache */ public const RELATIONSHIP_TTL = 1800; + /** + * 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; + /** * Generate a cache key for a query. */ @@ -96,17 +119,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 { @@ -121,6 +154,192 @@ 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 throws in debug and is logged and skipped + * otherwise); `$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); + // Like Eloquent's eager loading: a fresh instance, without the relation's constraints + $relation = $method ? Relation::noConstraints(fn () => $current->newInstance()->{$method}()) : 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 fails + // loudly in debug (tests, development) but only logs in production + $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('app.debug')) { + 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). + * + * Request paths are client supplied, so only methods the model's own classes + * declare, with no required parameters, are considered. + */ + 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); + $declaredBy = $reflection->getDeclaringClass()->getName(); + if (!$reflection->isPublic() || $reflection->isStatic() || $reflection->getNumberOfRequiredParameters() > 0) { + continue; + } + if (!$strict && str_starts_with($declaredBy, 'Illuminate\\')) { + continue; + } + + return $method; + } + + return null; + } + + /** + * 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_values(array_unique(array_map('trim', $paths))); + } + + /** + * 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; + } + /** * Advance a cache version so keys built from it change. * @@ -144,11 +363,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}"; } /** @@ -158,11 +378,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}"; } /** 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 047067fb..90a4cbc5 100644 --- a/tests/Unit/Support/ApiModelCacheTest.php +++ b/tests/Unit/Support/ApiModelCacheTest.php @@ -5,6 +5,7 @@ use Illuminate\Database\Capsule\Manager as Capsule; use Illuminate\Database\Eloquent\Builder as EloquentBuilder; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\Pivot; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Events\Dispatcher; use Illuminate\Http\Request; @@ -80,6 +81,95 @@ 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 function payload() + { + return $this->belongsTo(ApiModelCacheDependencyPayload::class, 'payload_uuid', 'uuid'); + } + + public function tags() + { + return $this->belongsToMany(ApiModelCacheDependencyTag::class, 'order_tags', 'order_uuid', 'tag_uuid') + ->using(ApiModelCacheDependencyOrderTag::class); + } + + public function subject() + { + return $this->morphTo(); + } + + public function tagPayloads() + { + return $this->hasManyThrough(ApiModelCacheDependencyPayload::class, ApiModelCacheDependencyTag::class, 'order_uuid', 'tag_uuid', 'uuid', 'uuid'); + } + + public function reconnectingPayload() + { + $this->setConnection('mysql'); + + return $this->belongsTo(ApiModelCacheDependencyPayload::class, 'payload_uuid', 'uuid'); + } + + public function summary(): string + { + return 'not a relation'; + } +} + +class ApiModelCacheDependencyOrder extends ApiModelCacheRelationsOrder +{ + protected $apiCacheDependencies = ['payload', 'tags', 'audit_logs']; +} + +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) @@ -154,6 +244,16 @@ public function get(string $key, mixed $default = null): mixed return $this->values[$key] ?? $default; } + public function many(array $keys): array + { + $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; @@ -315,6 +415,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(); @@ -424,7 +549,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')) @@ -448,7 +573,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++; @@ -595,7 +720,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'); @@ -653,6 +778,152 @@ 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(['app.debug' => 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 outside debug', function () { + api_model_cache_dependency_database(); + config(['app.debug' => 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 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; @@ -686,9 +957,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 () { @@ -778,7 +1049,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; From 9fd8875e3d04acade0096663c5e5e573433505be Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 13 Oct 2026 12:32:03 +0800 Subject: [PATCH 2/4] fix(api-cache): harden dependency resolution and invalidate again on commit A release review found four ways #301 could hurt production: - Client supplied with/expand names were resolved by calling the model method of that name. A public method without required arguments could be anything: ?with=dispatch would call Order::dispatch() on a blank order, which saves a row and fires OrderDispatched. Request paths now only call methods that declare an Eloquent Relation return type (58% of relation methods do; the rest add no versions, as before #301), and are capped at 50 per request. - A relation method throwing on a blank model escaped key generation and failed the request. Resolution now contains it, and key generation runs inside cacheQueryResult's fallback, so a cache outage while reading versions falls back to the query instead of a 500. - An unresolvable declaration threw whenever app.debug was on, which some self-hosted production installs run with. It now only throws when api.cache.strict_dependencies is set (for test suites); otherwise it is logged once per process and skipped. - Model events fire before the surrounding transaction commits, so a read in that window cached the old rows under the new version until the TTL. Versions are bumped again on commit, through the transactions manager all connections share, since the transaction is often on another connection than the written model's. --- config/api.php | 7 ++ src/Support/ApiModelCache.php | 120 +++++++++++++++---- tests/Unit/Support/ApiModelCacheTest.php | 141 +++++++++++++++++++++-- 3 files changed, 239 insertions(+), 29 deletions(-) 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 b6a1d576..5427c1e7 100644 --- a/src/Support/ApiModelCache.php +++ b/src/Support/ApiModelCache.php @@ -2,6 +2,8 @@ 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; @@ -91,6 +93,11 @@ class ApiModelCache */ protected const DEPENDENCY_MEMO_LIMIT = 1000; + /** + * Most relation paths read from one request; the rest add no versions. + */ + protected const REQUEST_PATH_LIMIT = 50; + /** * Generate a cache key for a query. * @@ -190,8 +197,8 @@ protected static function versionSegment(array $versionKeys): string * 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 throws in debug and is logged and skipped - * otherwise); `$paths` are relations a request or lookup loads (`with`, `expand`, + * 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. * @@ -252,9 +259,17 @@ protected static function resolvePath(Model $model, string $path, bool $strict): $segments = explode('.', $path); foreach ($segments as $segment) { - $method = static::relationMethod($current, trim($segment), $strict); - // Like Eloquent's eager loading: a fresh instance, without the relation's constraints - $relation = $method ? Relation::noConstraints(fn () => $current->newInstance()->{$method}()) : null; + $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) { @@ -266,10 +281,11 @@ protected static function resolvePath(Model $model, string $path, bool $strict): return [$segment]; } - // Extensions deploy independently of core, so a bad declaration fails - // loudly in debug (tests, development) but only logs in production + // 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('app.debug')) { + if (config('api.cache.strict_dependencies', false)) { throw new \LogicException($message); } Log::error($message); @@ -294,8 +310,12 @@ protected static function resolvePath(Model $model, string $path, bool $strict): /** * Find the method a relation segment names, as requests spell it (camel or snake case). * - * Request paths are client supplied, so only methods the model's own classes - * declare, with no required parameters, are considered. + * 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 { @@ -305,11 +325,10 @@ protected static function relationMethod(Model $model, string $segment, bool $st } $reflection = new \ReflectionMethod($model, $method); - $declaredBy = $reflection->getDeclaringClass()->getName(); if (!$reflection->isPublic() || $reflection->isStatic() || $reflection->getNumberOfRequiredParameters() > 0) { continue; } - if (!$strict && str_starts_with($declaredBy, 'Illuminate\\')) { + if (!$strict && !static::declaresRelationReturn($reflection)) { continue; } @@ -319,6 +338,18 @@ protected static function relationMethod(Model $model, string $segment, bool $st 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. * @@ -337,7 +368,7 @@ protected static function requestRelationPaths(Request $request): array } } - return array_values(array_unique(array_map('trim', $paths))); + return array_slice(array_values(array_unique(array_map('trim', $paths))), 0, static::REQUEST_PATH_LIMIT); } /** @@ -360,6 +391,47 @@ protected static function relationPaths(array $with): array 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 = function () use ($versionKeys) { + foreach ($versionKeys as $versionKey) { + static::bumpVersion($versionKey); + } + }; + + 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 a cache version so keys built from it change. * @@ -443,16 +515,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; @@ -607,9 +682,12 @@ 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)]; + foreach ($versionKeys as $versionKey) { + static::bumpVersion($versionKey); + } + static::bumpVersionsAfterCommit($model, $versionKeys); // Generate tags for BOTH model and query caches // Model caches: single-record lookups diff --git a/tests/Unit/Support/ApiModelCacheTest.php b/tests/Unit/Support/ApiModelCacheTest.php index 8a50c482..09229d8f 100644 --- a/tests/Unit/Support/ApiModelCacheTest.php +++ b/tests/Unit/Support/ApiModelCacheTest.php @@ -3,8 +3,13 @@ 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; @@ -111,28 +116,30 @@ class ApiModelCacheDependencyOrderTag extends Pivot class ApiModelCacheRelationsOrder extends ApiModelCacheTraitTestModel { - public function payload() + public static int $sideEffects = 0; + + public function payload(): BelongsTo { return $this->belongsTo(ApiModelCacheDependencyPayload::class, 'payload_uuid', 'uuid'); } - public function tags() + public function tags(): BelongsToMany { return $this->belongsToMany(ApiModelCacheDependencyTag::class, 'order_tags', 'order_uuid', 'tag_uuid') ->using(ApiModelCacheDependencyOrderTag::class); } - public function subject() + public function subject(): MorphTo { return $this->morphTo(); } - public function tagPayloads() + public function tagPayloads(): HasManyThrough { return $this->hasManyThrough(ApiModelCacheDependencyPayload::class, ApiModelCacheDependencyTag::class, 'order_uuid', 'tag_uuid', 'uuid', 'uuid'); } - public function reconnectingPayload() + public function reconnectingPayload(): BelongsTo { $this->setConnection('mysql'); @@ -143,6 +150,25 @@ 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 @@ -150,6 +176,16 @@ 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']; @@ -235,6 +271,7 @@ class ApiModelCacheTestStore public array $flushedTags = []; public array $forgotten = []; public bool $throwOnTags = false; + public bool $throwOnMany = false; public bool $lockReturnsNull = false; public bool $flushRemovesValues = true; public bool $incrementCreatesMissing = true; @@ -246,6 +283,10 @@ public function get(string $key, mixed $default = null): mixed 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; @@ -791,7 +832,7 @@ public function gc(int $max_lifetime): int|false test('api model cache resolves declared dependencies to related pivot and raw table names', function () { api_model_cache_dependency_database(); - config(['app.debug' => true]); + config(['api.cache.strict_dependencies' => true]); expect(ApiModelCache::dependencyTables(new ApiModelCacheDependencyOrder()))->toBe(['audit_logs', 'order_tags', 'payloads', 'tags']) ->and(ApiModelCache::dependencyTables(new ApiModelCacheRelationsOrder()))->toBe([]) @@ -806,9 +847,9 @@ public function gc(int $max_lifetime): int|false ->toThrow(LogicException::class, 'entry "summary" cannot be resolved at "summary"'); }); -test('api model cache logs and skips an unresolvable declaration outside debug', function () { +test('api model cache logs and skips an unresolvable declaration unless strict', function () { api_model_cache_dependency_database(); - config(['app.debug' => false]); + config(['api.cache.strict_dependencies' => false]); $logger = new ApiModelCacheTestLogger(); app()->instance('log', $logger); Facade::clearResolvedInstance('log'); @@ -818,6 +859,90 @@ public function gc(int $max_lifetime): int|false ->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 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(); From b9093321d38d922c003488d96c70c8364ce3bc8c Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 13 Oct 2026 12:44:43 +0800 Subject: [PATCH 3/4] fix(api-cache): invalidation never fails the write that triggered it Version bumps ran outside invalidateModelCache's try block, so a cache outage failed every write that invalidates: evented model writes, and with the extension PRs also quiet write paths such as location pings. A bump that fails inside an after-commit callback would also surface from commit() after the rows were written. Both now go through bumpVersions(), which logs the failure; the entries it would have retired expire within their TTL. --- src/Support/ApiModelCache.php | 34 ++++++++++++++++++------ tests/Unit/Support/ApiModelCacheTest.php | 21 +++++++++++++++ 2 files changed, 47 insertions(+), 8 deletions(-) diff --git a/src/Support/ApiModelCache.php b/src/Support/ApiModelCache.php index 5427c1e7..f4d50bcb 100644 --- a/src/Support/ApiModelCache.php +++ b/src/Support/ApiModelCache.php @@ -406,11 +406,7 @@ protected static function relationPaths(array $with): array */ protected static function bumpVersionsAfterCommit(Model $model, array $versionKeys): void { - $bump = function () use ($versionKeys) { - foreach ($versionKeys as $versionKey) { - static::bumpVersion($versionKey); - } - }; + $bump = fn () => static::bumpVersions($versionKeys, $model); try { $container = Container::getInstance(); @@ -432,6 +428,30 @@ protected static function bumpVersionsAfterCommit(Model $model, array $versionKe } } + /** + * 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. * @@ -684,9 +704,7 @@ public static function invalidateModelCache(Model $model, ?string $companyUuid = // actually hides stale caches (see the class docblock) $table = $model->getTable(); $versionKeys = [static::queryVersionKey($table, $companyUuid), static::modelVersionKey($table)]; - foreach ($versionKeys as $versionKey) { - static::bumpVersion($versionKey); - } + static::bumpVersions($versionKeys, $model); static::bumpVersionsAfterCommit($model, $versionKeys); // Generate tags for BOTH model and query caches diff --git a/tests/Unit/Support/ApiModelCacheTest.php b/tests/Unit/Support/ApiModelCacheTest.php index 09229d8f..4b18eb44 100644 --- a/tests/Unit/Support/ApiModelCacheTest.php +++ b/tests/Unit/Support/ApiModelCacheTest.php @@ -272,6 +272,7 @@ class ApiModelCacheTestStore 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; @@ -315,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; } @@ -889,6 +894,22 @@ public function gc(int $max_lifetime): int|false ->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 falls back to the query when reading versions fails', function () { $store = api_model_cache_fixture(); $store->throwOnMany = true; From 96059960e086020739176c9a05275147867b7373 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 13 Oct 2026 13:01:35 +0800 Subject: [PATCH 4/4] feat(api-cache): withoutInvalidating() scope for coalesced high frequency writes Telemetry saves vehicles and trailers with model events on every device event. With dependency versioning, every list embedding vehicles (orders, storefront food trucks) would then be invalidated on every ping in any company with streaming devices, effectively uncached. withoutInvalidating() keeps the save's model events but skips the cache invalidation they trigger, so the caller can coalesce it (fleetops throttles it to once per window per table and company). --- src/Support/ApiModelCache.php | 32 +++++++++++++++++++++++- tests/Unit/Support/ApiModelCacheTest.php | 23 +++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/src/Support/ApiModelCache.php b/src/Support/ApiModelCache.php index f4d50bcb..1c272fe7 100644 --- a/src/Support/ApiModelCache.php +++ b/src/Support/ApiModelCache.php @@ -98,6 +98,11 @@ class ApiModelCache */ protected const REQUEST_PATH_LIMIT = 50; + /** + * Depth of withoutInvalidating() scopes currently running. + */ + protected static int $invalidationSuppressed = 0; + /** * Generate a cache key for a query. * @@ -690,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; } @@ -730,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/tests/Unit/Support/ApiModelCacheTest.php b/tests/Unit/Support/ApiModelCacheTest.php index 4b18eb44..9d064e84 100644 --- a/tests/Unit/Support/ApiModelCacheTest.php +++ b/tests/Unit/Support/ApiModelCacheTest.php @@ -910,6 +910,29 @@ public function gc(int $max_lifetime): int|false ->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;