Preserve concurrent article mappings during static sync

This commit is contained in:
Codex 2026-10-30 12:03:17 +08:00
parent 25e0f87d97
commit 9d2cd9a895
3 changed files with 37 additions and 3 deletions

View File

@ -20,6 +20,7 @@ public function handle(LocalIllustrations $local): int
return self::FAILURE; return self::FAILURE;
} }
try { try {
$baseline = $local->selectionBaseline();
$snapshots = []; $snapshots = [];
$expected = []; $expected = [];
foreach (config('illustrations.categories') as $category => $pool) { foreach (config('illustrations.categories') as $category => $pool) {
@ -33,7 +34,7 @@ public function handle(LocalIllustrations $local): int
$snapshots[$pool] = $snapshot; $snapshots[$pool] = $snapshot;
$expected[$pool] = $keys; $expected[$pool] = $keys;
} }
$result = $local->synchronize($snapshots); $result = $local->synchronize($snapshots, $baseline);
foreach ($expected as $pool => $keys) { foreach ($expected as $pool => $keys) {
foreach ($keys as $key) { foreach ($keys as $key) {
if (! $local->thumbnail($pool, $key)) $result['pending'][$pool.':'.$key] ??= 'local_copy_unavailable'; if (! $local->thumbnail($pool, $key)) $result['pending'][$pool.':'.$key] ??= 'local_copy_unavailable';

View File

@ -128,9 +128,16 @@ public function storeSelection(string $pool, string $slug, array $image): string
} }
/** One atomic manifest switch after all downloadable files pass verification. */ /** One atomic manifest switch after all downloadable files pass verification. */
public function synchronize(array $snapshots): array public function selectionBaseline(): array
{ {
return $this->locked(function () use ($snapshots) { $this->manifest = null;
return $this->read()['selections'];
}
public function synchronize(array $snapshots, ?array $baseline = null): array
{
$baseline ??= $this->selectionBaseline();
return $this->locked(function () use ($snapshots, $baseline) {
$state = $this->read(); $state = $this->read();
$pending = []; $pending = [];
foreach ($snapshots as $pool => $snapshot) { foreach ($snapshots as $pool => $snapshot) {
@ -155,6 +162,18 @@ public function synchronize(array $snapshots): array
$pending[$pool.':'.$slug] = $reason; $pending[$pool.':'.$slug] = $reason;
} }
} }
// Receivers may publish new mappings while the remote snapshot is in flight.
// Never replace a local change made after this sync's baseline was captured.
$current = $state['selections'][$pool] ?? [];
$before = $baseline[$pool] ?? [];
foreach (array_unique(array_merge(array_keys($before), array_keys($current))) as $slug) {
$exists = array_key_exists($slug, $current);
if ($exists !== array_key_exists($slug, $before) || ($current[$slug] ?? null) !== ($before[$slug] ?? null)) {
if ($exists) $selections[$slug] = $current[$slug];
else unset($selections[$slug]);
unset($pending[$pool.':'.$slug]);
}
}
$state['selections'][$pool] = $selections; $state['selections'][$pool] = $selections;
} }
$this->save($state); $this->save($state);

View File

@ -125,4 +125,18 @@ public function test_missing_manifest_never_fetches_resource_or_returns_remote_f
$this->assertSame('/tutorial.png', IllustrationPool::thumbnail(['iCategoryId' => 3, 'sSlug' => 'guide'], '/tutorial.png')); $this->assertSame('/tutorial.png', IllustrationPool::thumbnail(['iCategoryId' => 3, 'sSlug' => 'guide'], '/tutorial.png'));
Http::assertNothingSent(); Http::assertNothingSent();
} }
public function test_sync_does_not_overwrite_concurrent_article_assignments(): void
{
$this->fakeImages();$local = app(LocalIllustrations::class);$old = $this->image();
$local->storeSelection('freenode', 'fn1', $old);
$baseline = $local->selectionBaseline();
$new = $this->image('33333333-3333-4333-8333-333333333333');
$local->storeSelection('freenode', 'fn1', $new);
$local->storeSelection('freenode', 'fn2', $new);
$local->synchronize(['freenode' => ['items' => [$old], 'selections' => ['fn1' => $old], 'unavailable' => []]], $baseline);
$state = json_decode(file_get_contents($this->dir.'/private/manifest.json'), true);
$this->assertSame($new['asset_id'], $state['selections']['freenode']['fn1']);
$this->assertSame($new['asset_id'], $state['selections']['freenode']['fn2']);
}
} }