From fd4b1693f81fec5934ee6d4623974689cf437d14 Mon Sep 17 00:00:00 2001 From: el-schneider Date: Tue, 14 Jul 2026 12:27:47 +0200 Subject: [PATCH 1/2] Surface document failures from bulk imports --- config/statamic-typesense.php | 6 ++++++ src/Exceptions/ImportFailedException.php | 23 +++++++++++++++++++++++ src/Typesense/Index.php | 24 +++++++++++++++++++++++- 3 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 src/Exceptions/ImportFailedException.php diff --git a/config/statamic-typesense.php b/config/statamic-typesense.php index 9ac9414..75e3cf5 100644 --- a/config/statamic-typesense.php +++ b/config/statamic-typesense.php @@ -6,4 +6,10 @@ // but be quicker to update large numbers of documents. 'insert_chunk_size' => 100, + // Typesense may reject individual documents during a bulk import + // (e.g. schema type mismatches) while the import itself succeeds. + // By default these failures are logged as a warning. Set this to + // true to throw an exception instead. + 'throw_on_import_failure' => false, + ]; diff --git a/src/Exceptions/ImportFailedException.php b/src/Exceptions/ImportFailedException.php new file mode 100644 index 0000000..a41b11f --- /dev/null +++ b/src/Exceptions/ImportFailedException.php @@ -0,0 +1,23 @@ +index; + } + + public function failures(): array + { + return $this->failures; + } +} diff --git a/src/Typesense/Index.php b/src/Typesense/Index.php index 4adcb18..945a12e 100644 --- a/src/Typesense/Index.php +++ b/src/Typesense/Index.php @@ -3,11 +3,13 @@ namespace StatamicRadPack\Typesense\Typesense; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Log; use Statamic\Contracts\Search\Searchable; use Statamic\Facades\Blink; use Statamic\Search\Documents; use Statamic\Search\Index as BaseIndex; use Statamic\Support\Arr; +use StatamicRadPack\Typesense\Exceptions\ImportFailedException; use Typesense\Client; use Typesense\Exceptions\ObjectNotFound; use Typesense\Exceptions\TypesenseClientError; @@ -75,7 +77,27 @@ public function exists() protected function insertDocuments(Documents $documents) { - $this->getOrCreateIndex()->documents->import($documents->all(), ['action' => 'upsert']); + $results = $this->getOrCreateIndex()->documents->import($documents->all(), ['action' => 'upsert', 'return_id' => true]); + + $failures = collect($results)->filter(fn ($result) => ! ($result['success'] ?? false))->values(); + + if ($failures->isEmpty()) { + return; + } + + if (config('statamic-typesense.throw_on_import_failure', false)) { + throw new ImportFailedException($this->name, $failures->all()); + } + + Log::warning(sprintf('Typesense rejected %d document(s) during import into the [%s] index.', $failures->count(), $this->name), [ + 'failures' => $failures + ->take(10) + ->map(fn ($failure) => [ + 'id' => $failure['id'] ?? null, + 'error' => $failure['error'] ?? null, + ]) + ->all(), + ]); } protected function deleteIndex() From 14a1d0e4f4a7109208c73ea48a91a4e5535276b6 Mon Sep 17 00:00:00 2001 From: el-schneider Date: Tue, 14 Jul 2026 12:27:47 +0200 Subject: [PATCH 2/2] Add tests for import failure handling --- tests/Unit/IndexTest.php | 102 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) diff --git a/tests/Unit/IndexTest.php b/tests/Unit/IndexTest.php index bce387e..63ba697 100644 --- a/tests/Unit/IndexTest.php +++ b/tests/Unit/IndexTest.php @@ -2,8 +2,10 @@ namespace StatamicRadPack\Typesense\Tests\Unit; +use Illuminate\Support\Facades\Log; use PHPUnit\Framework\Attributes\Test; use Statamic\Facades; +use StatamicRadPack\Typesense\Exceptions\ImportFailedException; use StatamicRadPack\Typesense\Tests\TestCase; use Typesense\Client; @@ -132,4 +134,104 @@ public function it_sorts_by_specified_order() $this->assertSame(['Entry 2', 'Entry 1'], collect($results['results'])->pluck('title')->all()); } + + #[Test] + public function it_logs_a_warning_when_documents_are_rejected_during_import() + { + $this->configureFailingIndex(); + + Log::spy(); + + Facades\Collection::make() + ->handle('pages') + ->title('Pages') + ->save(); + + Facades\Entry::make() + ->id('test-1') + ->collection('pages') + ->data(['title' => 'Entry 1']) + ->save(); + + Log::shouldHaveReceived('warning') + ->once() + ->withArgs(function ($message, $context) { + return str_contains($message, 'typesense_failing_index') + && str_contains($message, '1 document(s)') + && $context['failures'][0]['id'] === 'entry::test-1' + && ! empty($context['failures'][0]['error']); + }); + } + + #[Test] + public function it_throws_when_documents_are_rejected_during_import_and_strict_mode_is_enabled() + { + config()->set('statamic-typesense.throw_on_import_failure', true); + + $this->configureFailingIndex(); + + Facades\Collection::make() + ->handle('pages') + ->title('Pages') + ->save(); + + try { + Facades\Entry::make() + ->id('test-1') + ->collection('pages') + ->data(['title' => 'Entry 1']) + ->save(); + + $this->fail('ImportFailedException was not thrown.'); + } catch (ImportFailedException $e) { + $this->assertSame('typesense_failing_index', $e->index()); + $this->assertCount(1, $e->failures()); + $this->assertNotEmpty($e->failures()[0]['error']); + } + } + + #[Test] + public function it_does_not_log_or_throw_when_all_documents_import_successfully() + { + config()->set('statamic-typesense.throw_on_import_failure', true); + + Log::spy(); + + Facades\Collection::make() + ->handle('pages') + ->title('Pages') + ->save(); + + Facades\Entry::make() + ->id('test-1') + ->collection('pages') + ->data(['title' => 'Entry 1']) + ->save(); + + Log::shouldNotHaveReceived('warning'); + + $export = collect(json_decode('['.str_replace("\n", ',', Facades\Search::index('typesense_index')->getOrCreateIndex()->documents->export()).']'))->pluck('id'); + + $this->assertContains('entry::test-1', $export); + } + + private function configureFailingIndex() + { + // Typesense will reject documents whose title is a string, + // since the schema declares it as an int32. + config()->set('statamic.search.indexes.typesense_failing_index', [ + 'driver' => 'typesense', + 'searchables' => ['collection:pages'], + 'settings' => [ + 'schema' => [ + 'fields' => [ + [ + 'type' => 'int32', + 'name' => 'title', + ], + ], + ], + ], + ]); + } }