From 2099fa65930a1915cb6eff991c347bbdba221d71 Mon Sep 17 00:00:00 2001 From: Ashley Date: Wed, 21 Oct 2026 19:01:17 +1030 Subject: [PATCH] Keep a populated home feed when a forced warm publishes nothing A forced warm that came up with no publishable candidates deleted the live feed key. For a per-user feed that is destructive in exactly the wrong direction: the warm can come up empty because the viewer's cached following list is stale or because the statuses behind the candidates could not be resolved, and the user who was reading their timeline a moment ago is left with nothing. The scratch key is dropped instead and the live feed is untouched. The instance-wide feeds keep their clear-on-empty behaviour, which now has a comment saying why they differ. warmCache also returns the number of members it published instead of a constant 1, which is what lets a caller tell "rebuilt and ready" from "nothing to show". The unused $returnIds parameter is gone; all four call sites discarded it. Verified standalone off staging: HomeWarmEmptyResultTest 2 passed; HomeChronologicalOrderTest green; tests/Feature/Timeline + HomeFeed 73 passed. --- .../Commands/Admin/RebuildHomeFeeds.php | 2 +- .../FeedWarmCachePipeline.php | 2 +- app/Services/HomeTimelineService.php | 23 +++-- .../Timeline/HomeChronologicalOrderTest.php | 2 +- .../Timeline/HomeWarmEmptyResultTest.php | 89 +++++++++++++++++++ 5 files changed, 110 insertions(+), 8 deletions(-) create mode 100644 tests/Feature/Timeline/HomeWarmEmptyResultTest.php diff --git a/app/Console/Commands/Admin/RebuildHomeFeeds.php b/app/Console/Commands/Admin/RebuildHomeFeeds.php index 0a0a4cf6e..b00869bd0 100644 --- a/app/Console/Commands/Admin/RebuildHomeFeeds.php +++ b/app/Console/Commands/Admin/RebuildHomeFeeds.php @@ -122,7 +122,7 @@ class RebuildHomeFeeds extends Command } if ($sync) { - HomeTimelineService::warmCache($pid, true, 400, true); + HomeTimelineService::warmCache($pid, true, 400); return; } diff --git a/app/Jobs/HomeFeedPipeline/FeedWarmCachePipeline.php b/app/Jobs/HomeFeedPipeline/FeedWarmCachePipeline.php index 6be72de55..06c855c91 100644 --- a/app/Jobs/HomeFeedPipeline/FeedWarmCachePipeline.php +++ b/app/Jobs/HomeFeedPipeline/FeedWarmCachePipeline.php @@ -73,6 +73,6 @@ class FeedWarmCachePipeline implements ShouldBeUniqueUntilProcessing, ShouldQueu return; } - HomeTimelineService::warmCache($pid, true, 400, true); + HomeTimelineService::warmCache($pid, true, 400); } } diff --git a/app/Services/HomeTimelineService.php b/app/Services/HomeTimelineService.php index d86cd4be4..81bbf6fbe 100644 --- a/app/Services/HomeTimelineService.php +++ b/app/Services/HomeTimelineService.php @@ -80,7 +80,14 @@ class HomeTimelineService } } - public static function warmCache($id, $force = false, $limit = 100, $returnIds = false) + /** + * Rebuild the cached feed, returning the number of members published. + * + * Returns 0 both when nothing was published and when the guard declined + * to run; the read path only ever forces, so the two cannot collide + * there, and every other caller treats them the same way. + */ + public static function warmCache($id, bool $force = false, int $limit = 100): int { if (self::count($id) == 0 || $force == true) { $following = Cache::remember('profile:following:'.$id, 1209600, function () use ($id) { @@ -153,9 +160,15 @@ class HomeTimelineService } if ($added === 0) { - Redis::del(self::CACHE_KEY.$id); - - return $returnIds ? $ids : 1; + // The instance-wide feeds clear themselves when a warm finds + // nothing, because their content is the instance's content. A + // per-user feed is different: a warm can come up empty because + // this user's relationships or the statuses behind their feed + // could not be resolved, and throwing away a feed that was + // serving content is how a user ends up with a blank timeline. + Redis::del($tmpKey); + + return 0; } if ($added > self::FEED_LIMIT) { @@ -165,7 +178,7 @@ class HomeTimelineService Redis::rename($tmpKey, self::CACHE_KEY.$id); Redis::expire(self::CACHE_KEY.$id, self::CACHE_TTL); - return $returnIds ? $ids : 1; + return $added; } return 0; diff --git a/tests/Feature/Timeline/HomeChronologicalOrderTest.php b/tests/Feature/Timeline/HomeChronologicalOrderTest.php index 629fde567..c12ef146d 100644 --- a/tests/Feature/Timeline/HomeChronologicalOrderTest.php +++ b/tests/Feature/Timeline/HomeChronologicalOrderTest.php @@ -52,7 +52,7 @@ it('ranks home posts by created_at even when a remote post has a newer id but ol StatusService::get($recent->id, false); StatusService::get($staleRemote->id, false); - HomeTimelineService::warmCache($pid, true, 400, true); + HomeTimelineService::warmCache($pid, true, 400); $ids = collect(HomeTimelineService::get($pid, 0, -1))->map(fn ($v) => (int) $v)->all(); diff --git a/tests/Feature/Timeline/HomeWarmEmptyResultTest.php b/tests/Feature/Timeline/HomeWarmEmptyResultTest.php new file mode 100644 index 000000000..9c6c79de9 --- /dev/null +++ b/tests/Feature/Timeline/HomeWarmEmptyResultTest.php @@ -0,0 +1,89 @@ +create(array_merge([ + 'profile_id' => $profileId, + 'type' => 'photo', + 'scope' => 'public', + 'visibility' => 'public', + 'local' => true, + 'in_reply_to_id' => null, + 'reblog_of_id' => null, + 'uri' => null, + 'created_at' => now()->subMinutes(30), + ], $overrides)); + StatusService::get($status->id, false); + StatusService::getMastodon($status->id, false); + + return $status; +} + +function warmEmptyViewerFollowing(): array +{ + $viewer = User::factory()->create(); + $viewer->refresh(); + $author = User::factory()->create(); + $author->refresh(); + Follower::create([ + 'profile_id' => $viewer->profile_id, + 'following_id' => $author->profile_id, + ]); + + return [$viewer, $author]; +} + +it('keeps a populated feed when a forced warm publishes nothing', function () { + $viewer = User::factory()->create(); + $viewer->refresh(); + $pid = $viewer->profile_id; + // A real status by an author the viewer does not follow: the warm's + // candidate query cannot find it, so the warm publishes nothing. + $stranger = User::factory()->create(); + $stranger->refresh(); + $status = warmEmptyPhoto($stranger->profile_id); + HomeTimelineService::add($pid, $status->id); + expect(HomeTimelineService::count($pid))->toBe(1); + HomeTimelineService::warmCache($pid, true, 400); + expect(HomeTimelineService::count($pid))->toBe(1) + ->and(collect(HomeTimelineService::get($pid, 0, -1))->map(fn ($v) => (int) $v)->all()) + ->toContain((int) $status->id); +}); + +it('reports how many members the warm published', function () { + [$viewer, $author] = warmEmptyViewerFollowing(); + $pid = $viewer->profile_id; + collect(range(1, 3))->each(fn () => warmEmptyPhoto($author->profile_id)); + expect(HomeTimelineService::warmCache($pid, true, 100))->toBe(3); + $barren = User::factory()->create(); + $barren->refresh(); + expect(HomeTimelineService::warmCache($barren->profile_id, true, 100))->toBe(0); +});