From 1df59f09b46038afe9424b665cb44bb67c2f5ec6 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Thu, 7 May 2026 10:41:37 -0300 Subject: [PATCH] perf: cache account post count for 5 minutes in HasUsage Posts have no plan-quota gating, so a brief staleness on the count is acceptable. Avoids the heavy aggregate query on every authenticated request. Empty-account case skips cache writes entirely. --- app/Models/Traits/HasUsage.php | 30 ++++++++++++++++-- tests/Feature/Models/HasUsageTraitTest.php | 36 ++++++++++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/app/Models/Traits/HasUsage.php b/app/Models/Traits/HasUsage.php index b974e855..e0c3a07e 100644 --- a/app/Models/Traits/HasUsage.php +++ b/app/Models/Traits/HasUsage.php @@ -10,6 +10,8 @@ use App\Features\WorkspaceLimit; use App\Models\AiUsageLog; use App\Models\Invite; +use App\Models\Post; +use Illuminate\Support\Facades\Cache; use Laravel\Pennant\Feature; /** @@ -21,13 +23,21 @@ */ trait HasUsage { + /** + * Cache TTL for the per-account post count. Posts are unbounded by plan + * limits and not used for any quota gating, so a few minutes of staleness + * is acceptable in exchange for skipping a potentially heavy aggregate + * query on every authenticated request. + */ + private const POST_COUNT_CACHE_TTL = 300; + /** * @return array{workspaceCount: int, socialAccountCount: int, memberCount: int, pendingInviteCount: int, postCount: int, creditsUsed: int} */ public function usage(): array { $workspaces = $this->workspaces() - ->withCount(['socialAccounts', 'posts']) + ->withCount('socialAccounts') ->get(); return [ @@ -37,7 +47,7 @@ public function usage(): array 'pendingInviteCount' => Invite::where('account_id', $this->id) ->whereNull('accepted_at') ->count(), - 'postCount' => (int) $workspaces->sum('posts_count'), + 'postCount' => $this->cachedPostCount($workspaces->pluck('id')->all()), 'creditsUsed' => AiUsageLog::monthlyCredits($this->id), ]; } @@ -54,4 +64,20 @@ public function featureLimits(): array 'monthlyCreditsLimit' => Feature::for($this)->value(MonthlyCreditsLimit::class), ]; } + + /** + * @param array $workspaceIds + */ + private function cachedPostCount(array $workspaceIds): int + { + if (empty($workspaceIds)) { + return 0; + } + + return Cache::remember( + "account:{$this->id}:posts_count", + self::POST_COUNT_CACHE_TTL, + fn () => Post::whereIn('workspace_id', $workspaceIds)->count(), + ); + } } diff --git a/tests/Feature/Models/HasUsageTraitTest.php b/tests/Feature/Models/HasUsageTraitTest.php index e26d0d57..e19eea15 100644 --- a/tests/Feature/Models/HasUsageTraitTest.php +++ b/tests/Feature/Models/HasUsageTraitTest.php @@ -5,9 +5,11 @@ use App\Models\Account; use App\Models\Invite; use App\Models\Plan; +use App\Models\Post; use App\Models\SocialAccount; use App\Models\User; use App\Models\Workspace; +use Illuminate\Support\Facades\Cache; beforeEach(function () { $this->account = Account::factory()->create(); @@ -68,3 +70,37 @@ expect($this->account->usage()['pendingInviteCount'])->toBe(1); }); + +test('postCount is cached and survives new posts within the TTL', function () { + $workspace = Workspace::factory()->create([ + 'account_id' => $this->account->id, + 'user_id' => $this->owner->id, + ]); + + Post::factory()->count(2)->create([ + 'workspace_id' => $workspace->id, + 'user_id' => $this->owner->id, + ]); + + // First call primes the cache. + expect($this->account->usage()['postCount'])->toBe(2); + + // A new post is created mid-window. The cached value should win. + Post::factory()->create([ + 'workspace_id' => $workspace->id, + 'user_id' => $this->owner->id, + ]); + + expect($this->account->usage()['postCount'])->toBe(2); + + // Forgetting the cache key returns the fresh count. + Cache::forget("account:{$this->account->id}:posts_count"); + expect($this->account->usage()['postCount'])->toBe(3); +}); + +test('postCount returns zero without querying when account has no workspaces', function () { + expect($this->account->usage()['postCount'])->toBe(0); + + // No cache entry should be written for the empty case. + expect(Cache::has("account:{$this->account->id}:posts_count"))->toBeFalse(); +});