diff --git a/src/Search/Comb/Index.php b/src/Search/Comb/Index.php index 9126b35fccb..baeeeb1d4ea 100644 --- a/src/Search/Comb/Index.php +++ b/src/Search/Comb/Index.php @@ -2,6 +2,8 @@ namespace Statamic\Search\Comb; +use Closure; +use Illuminate\Support\Facades\Cache; use Statamic\Facades\File; use Statamic\Search\Comb\Exceptions\NoQuery; use Statamic\Search\Comb\Exceptions\NoResultsFound; @@ -88,32 +90,36 @@ public function path() public function delete($document) { - try { - $data = $this->data(); - } catch (IndexNotFoundException $e) { - return; - } + $this->locked(function () use ($document) { + try { + $data = $this->data(); + } catch (IndexNotFoundException $e) { + return; + } - $ref = $document->getSearchReference(); + $ref = $document->getSearchReference(); - if (! $data->has($ref)) { - return; - } + if (! $data->has($ref)) { + return; + } - $data->forget($ref); + $data->forget($ref); - $this->save($data); + $this->save($data); + }); } public function insertDocuments(Documents $documents) { - try { - $data = $this->data(); - } catch (IndexNotFoundException $e) { - $data = collect(); - } - - $this->save($documents->union($data)); + $this->locked(function () use ($documents) { + try { + $data = $this->data(); + } catch (IndexNotFoundException $e) { + $data = collect(); + } + + $this->save($documents->union($data)); + }); } public function deleteIndex() @@ -128,6 +134,11 @@ protected function save($documents) app('files')->put($this->path(), $documents->toJson(), lock: true); } + private function locked(Closure $callback) + { + return Cache::lock('search-index-'.$this->name, 60)->block(30, $callback); + } + public function extraAugmentedResultData(Result $result) { return [ diff --git a/tests/Search/CombIndexTest.php b/tests/Search/CombIndexTest.php index fa12fb837ac..e5c23cc8365 100644 --- a/tests/Search/CombIndexTest.php +++ b/tests/Search/CombIndexTest.php @@ -2,11 +2,14 @@ namespace Tests\Search; +use Illuminate\Contracts\Cache\Lock; use Illuminate\Contracts\Filesystem\Filesystem; +use Illuminate\Support\Facades\Cache; use Mockery; use PHPUnit\Framework\Attributes\Test; use Statamic\Contracts\Search\Searchable; use Statamic\Search\Comb\Index; +use Statamic\Search\Documents; use Tests\TestCase; class CombIndexTest extends TestCase @@ -114,6 +117,59 @@ public function delete_rewrites_the_index_when_the_reference_is_present() ); } + #[Test] + public function inserting_holds_the_index_lock_across_the_read_and_the_write() + { + $path = $this->createIndexFile(['entry::a' => ['title' => 'Foo']]); + $this->expectIndexLock( + acquired: fn () => $this->assertSame(['entry::a' => ['title' => 'Foo']], json_decode(file_get_contents($path), true)), + released: fn () => $this->assertSame(['entry::b' => ['title' => 'Bar'], 'entry::a' => ['title' => 'Foo']], json_decode(file_get_contents($path), true)), + ); + + $index = $this->getIndex('test', ['path' => $this->tmpDir], null); + + $index->insertDocuments(new Documents(['entry::b' => ['title' => 'Bar']])); + } + + #[Test] + public function deleting_holds_the_index_lock_across_the_read_and_the_write() + { + $path = $this->createIndexFile(['entry::a' => ['title' => 'Foo'], 'entry::b' => ['title' => 'Bar']]); + $this->expectIndexLock( + acquired: fn () => $this->assertCount(2, json_decode(file_get_contents($path), true)), + released: fn () => $this->assertSame(['entry::b' => ['title' => 'Bar']], json_decode(file_get_contents($path), true)), + ); + + $index = $this->getIndex('test', ['path' => $this->tmpDir], null); + + $document = Mockery::mock(Searchable::class); + $document->shouldReceive('getSearchReference')->andReturn('entry::a'); + + $index->delete($document); + } + + private function expectIndexLock(?\Closure $acquired = null, ?\Closure $released = null) + { + $lock = Mockery::mock(Lock::class); + $lock->shouldReceive('block')->withArgs(fn ($seconds, $callback) => $callback instanceof \Closure)->once()->andReturnUsing(function ($seconds, $callback) use ($acquired, $released) { + if ($acquired) { + $acquired(); + } + + $result = $callback(); + + if ($released) { + $released(); + } + + return $result; + }); + + Cache::shouldReceive('lock')->with('search-index-test', 60)->once()->andReturn($lock); + + return $lock; + } + private function createIndexFile(array $data): string { $this->tmpDir = sys_get_temp_dir().'/statamic_comb_test_'.uniqid();