Rebuild a missing home feed from the read that found it missing

The read path had three ways of answering a request whose window came
back empty, and all three could leave a user staring at a blank timeline.
It returned early whenever the set had any members, so a feed full of
unresolvable ids never got looked at. It only attempted a rebuild for a
request that sent no cursor, so a client holding a cursor got nothing on
every retry. And it marked the rebuild as done before knowing whether it
had worked, in a key nothing ever evicted, so a warm that threw
suppressed the next five minutes of attempts, and the throw itself
escaped the synchronous dispatch and turned the response into a 500.

A missing cached set is now rebuilt in-request behind a non-blocking
30-second lock: a reader that loses the lock serves nothing rather than
running a second full warm, one that wins releases it in a finally, and a
warm that throws is logged and leaves the feed eligible for the next
request. The page is then re-read through the same window helper, so the
cursor the client sent survives the rebuild. The rebuild goes 100 members
deep and queues the deepening job only when it filled that depth.

Three existing cases asserted the old bookkeeping rather than the
behaviour it was meant to protect. They keep their 200s and their empty
bodies and now assert the single-warm guarantee directly.
pull/7869/head
Ashley 6 days ago
parent 8b4d2cc7d9
commit be59d31de9

@ -106,6 +106,14 @@ class ApiV1Controller extends Controller
const PF_API_ENTITY_KEY = '_pe';
/**
* How deep a home feed rebuild goes while a request is waiting for it.
* Several screens worth, rather than the full feed cap: the queued job
* takes it the rest of the way. Raising this means raising the warm
* lock's ttl with it.
*/
const HOME_COLD_BOOT_DEPTH = 100;
public function __construct()
{
$this->fractal = new Fractal\Manager;
@ -2776,6 +2784,57 @@ class ApiV1Controller extends Controller
];
}
/**
* Rebuild a home feed that has no cached set and serve the window the
* request asked for out of it.
*
* The settled-empty guard comes first, so a feed a completed warm has
* already found empty costs nothing to read. The lock is non-blocking and
* short-lived: a request that loses it serves nothing rather than running
* a second full warm, which is the concurrency protection the old
* check-then-set marker could not give. A warm that throws releases the
* lock and writes no record of emptiness, so the next request retries.
*/
protected function homeColdBoot($pid, array $cursor, int $paddedLimit): array
{
if (! HomeTimelineService::shouldWarmEmpty($pid)) {
return [];
}
$lock = Cache::lock('pf:timeline:home:warm:lock:'.$pid, 30);
if (! $lock->get()) {
return [];
}
try {
$added = HomeTimelineService::warmCache($pid, true, self::HOME_COLD_BOOT_DEPTH);
} catch (\Throwable $e) {
Log::warning('timelineHome: home feed warm failed', [
'pid' => $pid,
'error' => $e->getMessage(),
]);
return [];
} finally {
$lock->release();
}
if ($added === 0) {
return [];
}
if ($added >= self::HOME_COLD_BOOT_DEPTH) {
// The warm filled the depth a request should wait for, so there
// is more to collect than this response needs. A shallower result
// means the candidate set was exhausted and a deeper pass over
// the same query cannot find more.
FeedWarmCachePipeline::dispatch($pid)->onQueue('feed');
}
return $this->fetchHomeWindow($pid, $cursor, $paddedLimit);
}
protected function fetchHomeWindow($pid, array $cursor, int $paddedLimit): array
{
if ($cursor['min']) {
@ -2846,25 +2905,12 @@ class ApiV1Controller extends Controller
$ids = $this->fetchHomeWindow($pid, $cursor, $paddedLimit);
if (! $ids) {
if (HomeTimelineService::count($pid) > 0) {
return $this->json([]);
}
$coldBootKey = 'pf:services:apiv1:home:cached:coldbootcheck:'.$pid;
if (! Cache::has($coldBootKey)) {
Cache::set($coldBootKey, 1, 300);
FeedWarmCachePipeline::dispatchSync($pid);
$ids = HomeTimelineService::get($pid, 0, $paddedLimit);
if (! $ids) {
return $this->json([]);
}
} else {
return $this->json([]);
}
// No cached set at all. This fires for a cursor-ful request too: a
// client holding a cursor from a feed that has since gone used to get
// an empty page on every retry, because the rebuild only ran for a
// request that sent no cursor.
if (! HomeTimelineService::cacheKeyExists($pid)) {
$ids = $this->homeColdBoot($pid, $cursor, $paddedLimit);
}
$applyPhotosReblogsOnly = $photosReblogsOnly;

@ -0,0 +1,185 @@
<?php
use App\Models\Follower;
use App\Models\Status;
use App\Models\User;
use App\Services\HomeTimelineService;
use App\Services\StatusService;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Facades\Schema;
use Laravel\Passport\Passport;
uses(LazilyRefreshDatabase::class);
/*
|--------------------------------------------------------------------------
| Rebuilding a home feed while serving the read that found it missing
|--------------------------------------------------------------------------
|
| A feed with no Redis key is rebuilt by the request that discovered it,
| under a lock so concurrent readers do not all rebuild, and the page that
| comes back has to answer the cursor the client actually sent. A rebuild
| that fails must leave the feed eligible for the next attempt instead of
| wedging it behind bookkeeping that says the work is already done.
|
*/
afterEach(function () {
Redis::flushdb();
});
function coldBootLockKey(int $pid): string
{
return 'pf:timeline:home:warm:lock:'.$pid;
}
function coldBootPhoto(int $profileId, int $minutesAgo): Status
{
$status = Status::factory()->create([
'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($minutesAgo),
]);
StatusService::get($status->id, false);
StatusService::getMastodon($status->id, false);
return $status;
}
/** A viewer with a cold feed and $count eligible posts from one follow. */
function coldBootViewerWithPosts(int $count): array
{
$viewer = User::factory()->create();
$viewer->refresh();
$author = User::factory()->create();
$author->refresh();
Follower::withoutEvents(fn () => Follower::create([
'profile_id' => $viewer->profile_id,
'following_id' => $author->profile_id,
]));
$statuses = collect(range(0, $count - 1))->map(
fn ($i) => coldBootPhoto($author->profile_id, $count - $i)
);
Redis::del(HomeTimelineService::CACHE_KEY.$viewer->profile_id);
return [$viewer, $author, $statuses->sortByDesc(fn ($s) => (int) $s->id)->values()];
}
function coldBootStatusQueries(callable $fn): int
{
$count = 0;
DB::listen(function ($query) use (&$count) {
if (preg_match('/from\s+"?statuses"?/i', $query->sql)) {
$count++;
}
});
$fn();
return $count;
}
it('honours the cursor on the page served by a cold boot', function () {
[$viewer, , $ordered] = coldBootViewerWithPosts(12);
expect(HomeTimelineService::cacheKeyExists($viewer->profile_id))->toBeFalse();
$cursor = (int) $ordered->get(8)->id;
Passport::actingAs($viewer, ['read']);
$res = $this->getJson('/api/v1/timelines/home?limit=4&max_id='.$cursor)
->assertOk()
->json();
$ids = collect($res)->pluck('id')->map(fn ($id) => (int) $id)->all();
expect($ids)->not->toBeEmpty();
foreach ($ids as $id) {
expect($id)->toBeLessThan($cursor);
}
});
it('does not rebuild while another request holds the warm lock', function () {
[$viewer] = coldBootViewerWithPosts(4);
$pid = $viewer->profile_id;
$held = Cache::lock(coldBootLockKey($pid), 30);
expect($held->get())->toBeTrue();
Passport::actingAs($viewer, ['read']);
$queries = coldBootStatusQueries(function () {
$this->getJson('/api/v1/timelines/home?limit=4')->assertOk();
});
expect($queries)->toBe(0);
$held->release();
});
it('releases the warm lock it took', function () {
[$viewer] = coldBootViewerWithPosts(4);
$pid = $viewer->profile_id;
Passport::actingAs($viewer, ['read']);
$this->getJson('/api/v1/timelines/home?limit=4')->assertOk();
$lock = Cache::lock(coldBootLockKey($pid), 30);
expect($lock->get())->toBeTrue();
$lock->release();
});
it('leaves the feed rebuildable when the rebuild fails', function () {
[$viewer, , $ordered] = coldBootViewerWithPosts(3);
$pid = $viewer->profile_id;
Passport::actingAs($viewer, ['read']);
Schema::rename('statuses', 'statuses_unavailable');
try {
$first = $this->getJson('/api/v1/timelines/home?limit=4');
} finally {
Schema::rename('statuses_unavailable', 'statuses');
}
$first->assertOk();
expect($first->json())->toBe([])
->and(HomeTimelineService::shouldWarmEmpty($pid))->toBeTrue();
$ids = collect($this->getJson('/api/v1/timelines/home?limit=4')->assertOk()->json())
->pluck('id')->map(fn ($id) => (int) $id)->all();
expect($ids)->toContain((int) $ordered->first()->id);
});
it('completes a cold boot over a large candidate set promptly', function () {
[$viewer] = coldBootViewerWithPosts(120);
Passport::actingAs($viewer, ['read']);
$start = microtime(true);
$this->getJson('/api/v1/timelines/home?limit=20')->assertOk();
$elapsed = (microtime(true) - $start) * 1000;
expect($elapsed)->toBeLessThan(3000);
});

@ -60,4 +60,8 @@ it('returns 200 empty when a cold feed warms to nothing', function () {
$res = $this->getJson('/api/v1/timelines/home')->assertOk();
expect($res->json())->toBe([]);
// The warm completed and found nothing, so it is recorded as empty
// rather than left looking like a feed that needs building.
expect(HomeTimelineService::shouldWarmEmpty($pid))->toBeFalse();
});

@ -47,7 +47,7 @@ it('returns 200 with no cold-boot when a populated feed has nothing newer than m
// A populated-but-empty-window read must not trip cold-boot warming.
Bus::assertNotDispatched(FeedWarmCachePipeline::class);
expect(Cache::has('pf:services:apiv1:home:cached:coldbootcheck:'.$pid))->toBeFalse();
expect(HomeTimelineService::shouldWarmEmpty($pid))->toBeTrue();
});
it('warms once on a genuinely cold feed and guards re-warming', function () {
@ -61,8 +61,13 @@ it('warms once on a genuinely cold feed and guards re-warming', function () {
$this->getJson('/api/v1/timelines/home')->assertOk();
Bus::assertDispatchedSync(FeedWarmCachePipeline::class, 1);
// The warm ran in-request and found nothing, so it is recorded and no
// deepening job was queued.
expect(HomeTimelineService::shouldWarmEmpty($pid))->toBeFalse();
Bus::assertNotDispatched(FeedWarmCachePipeline::class);
$ttl = Cache::has('pf:services:apiv1:home:cached:coldbootcheck:'.$pid);
expect($ttl)->toBeTrue();
// And the warm lock was released rather than left held for its ttl.
$lock = Cache::lock('pf:timeline:home:warm:lock:'.$pid, 30);
expect($lock->get())->toBeTrue();
$lock->release();
});

@ -8,7 +8,7 @@ use App\Services\HomeTimelineService;
use App\Services\StatusService;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Redis;
use Laravel\Passport\Passport;
@ -26,15 +26,9 @@ uses(LazilyRefreshDatabase::class);
|
*/
function homeColdBootKey(int $pid): string
{
return 'pf:services:apiv1:home:cached:coldbootcheck:'.$pid;
}
function flushHome(int $pid): void
{
Redis::del(HomeTimelineService::CACHE_KEY.$pid);
Cache::forget(homeColdBootKey($pid));
}
/**
@ -121,19 +115,30 @@ it('warms once on cold-start and does not warm again', function () {
Passport::actingAs($viewer, ['read']);
$candidateQueries = 0;
DB::listen(function ($query) use (&$candidateQueries) {
if (preg_match('/from\s+"?statuses"?/i', $query->sql)) {
$candidateQueries++;
}
});
$first = $this->getJson('/api/v1/timelines/home');
$first->assertOk();
expect($first->json())->toBe([]);
expect($candidateQueries)->toBeGreaterThan(0);
Bus::assertDispatchedSync(FeedWarmCachePipeline::class, 1);
// Second immediate request: still empty, and the record of the completed
// empty warm must stop it querying for candidates again.
$candidateQueries = 0;
// Second immediate request: still empty, and the coldBootKey guard must
// stop a second warm from dispatching.
$second = $this->getJson('/api/v1/timelines/home');
$second->assertOk();
expect($second->json())->toBe([]);
expect($candidateQueries)->toBe(0);
Bus::assertDispatchedSync(FeedWarmCachePipeline::class, 1);
// The queued deepening job only runs when the warm filled its depth.
Bus::assertNotDispatched(FeedWarmCachePipeline::class);
expect(HomeTimelineService::shouldWarmEmpty($pid))->toBeFalse();
flushHome($pid);
});

@ -9,7 +9,6 @@ use App\Services\PublicTimelineService;
use App\Services\StatusService;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Redis;
use Laravel\Passport\Passport;
@ -90,7 +89,6 @@ it('paginates the home feed without duplicates when id order differs from date o
}
Redis::del(HomeTimelineService::CACHE_KEY.$pid);
Cache::forget('pf:services:apiv1:home:cached:coldbootcheck:'.$pid);
HomeTimelineService::warmCache($pid, true, 400);
$cached = collect(Redis::zrevrange(HomeTimelineService::CACHE_KEY.$pid, 0, -1))
@ -206,7 +204,6 @@ it('advances the home feed when the client rounds the snowflake cursor', functio
}
Redis::del(HomeTimelineService::CACHE_KEY.$pid);
Cache::forget('pf:services:apiv1:home:cached:coldbootcheck:'.$pid);
HomeTimelineService::warmCache($pid, true, 400);
$ordered = collect(Redis::zrevrange(HomeTimelineService::CACHE_KEY.$pid, 0, -1))

Loading…
Cancel
Save