From fe382bdb86750c20e1f10e37e7d91a5b0b2b9418 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 13 Sep 2026 21:34:41 +0930 Subject: [PATCH] Extend story author-key TTL instead of overwriting so it survives to the longest-lived story --- app/Services/StoryIndexService.php | 16 +++- tests/Feature/StoryIndexAuthorTtlTest.php | 89 +++++++++++++++++++++++ 2 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 tests/Feature/StoryIndexAuthorTtlTest.php diff --git a/app/Services/StoryIndexService.php b/app/Services/StoryIndexService.php index db7863eed..a67f5fc9d 100644 --- a/app/Services/StoryIndexService.php +++ b/app/Services/StoryIndexService.php @@ -175,11 +175,25 @@ class StoryIndexService $type = $story->type; $path = $story->path; + // The author key must live as long as the LONGEST-lived active story, + // not whichever story is indexed last. rebuildIndex() reindexes an + // author's stories newest-first, so the oldest (shortest-lived) story + // is indexed last; a plain expire() would shorten the key's TTL and + // drop the author from the index while newer stories are still live. + // Read the current TTL up front (outside the pipeline) and only ever + // extend, mirroring markSeen(). + $authorKeyTtl = (int) ($ttl + 3600); + $currentAuthorTtl = $this->redisInt(fn () => Redis::ttl($this->authorKey($author))); + if ($currentAuthorTtl > $authorKeyTtl) { + $authorKeyTtl = $currentAuthorTtl; + } + Redis::pipeline(function ($pipe) use ( $author, $sid, $score, $ttl, + $authorKeyTtl, $duration, $overlays, $viewCount, @@ -206,7 +220,7 @@ class StoryIndexService $pipe->zadd($keyAuth, $score, $sid); } $pipe->sadd('story:active_authors', $author); - $pipe->expire($keyAuth, (int) ($ttl + 3600)); + $pipe->expire($keyAuth, $authorKeyTtl); }); } diff --git a/tests/Feature/StoryIndexAuthorTtlTest.php b/tests/Feature/StoryIndexAuthorTtlTest.php new file mode 100644 index 000000000..1f860b0b4 --- /dev/null +++ b/tests/Feature/StoryIndexAuthorTtlTest.php @@ -0,0 +1,89 @@ +profile_id = $author->id; + $story->type = 'photo'; + $story->duration = 3; + $story->path = 'public/story/'.uniqid().'.jpg'; + $story->local = true; + $story->active = true; + $story->public = false; + $story->view_count = 0; + $story->created_at = $createdAt; + $story->expires_at = $expiresAt; + $story->save(); + + return $story; +} + +it('keeps the author-key TTL at the longest-lived story when the oldest is indexed last', function () { + $author = Profile::factory()->create(['user_id' => null, 'domain' => 'remote.example']); + + // Start from a clean author key so a prior test cannot leak state. + Redis::del("story:by_author:{$author->id}"); + + // Story B: newer, ~25h remaining. Story A: older, ~4h remaining. + $newer = makeStory($author, now()->subHour(), now()->addHours(24)); + $older = makeStory($author, now()->subHours(20), now()->addHours(4)); + + $index = app(StoryIndexService::class); + + // Mirror rebuildIndex() ordering: newest-first, so the OLDER (shorter TTL) + // story is indexed LAST -- the exact sequence that used to shorten the key. + $index->indexStory($newer); + $index->indexStory($older); + + $ttl = (int) Redis::ttl("story:by_author:{$author->id}"); + + // Must reflect the newer story (~24h + 3600s buffer), not the older (~4h). + // Comfortably above the older story's window, below the newer story's cap. + expect($ttl)->toBeGreaterThan(4 * 3600 + 3600 + 60) + ->and($ttl)->toBeLessThanOrEqual(24 * 3600 + 3600); + + // The author still has an active story regardless of index order. + expect($index->hasActiveStory($author->id))->toBeTrue(); +}); + +it('extends the author-key TTL when a newer story is indexed after an older one', function () { + $author = Profile::factory()->create(['user_id' => null, 'domain' => 'remote.example']); + Redis::del("story:by_author:{$author->id}"); + + $index = app(StoryIndexService::class); + + // Index a short-lived story first, then a longer-lived one. + $short = makeStory($author, now()->subHours(20), now()->addHours(4)); + $index->indexStory($short); + expect((int) Redis::ttl("story:by_author:{$author->id}")) + ->toBeLessThanOrEqual(4 * 3600 + 3600); + + $long = makeStory($author, now(), now()->addHours(24)); + $index->indexStory($long); + + // TTL extended up to the longer story, never shortened back. + expect((int) Redis::ttl("story:by_author:{$author->id}")) + ->toBeGreaterThan(4 * 3600 + 3600 + 60); +});