From 8d520adb5ef7fa820420acfa11c83b27e2ebe671 Mon Sep 17 00:00:00 2001 From: Snider Date: Thu, 23 Apr 2026 12:55:45 +0100 Subject: [PATCH] feat(brain): add DeleteFromIndex job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inverse of the EmbedMemory job (#56): removes a memory from Qdrant (and the future Elasticsearch index) when brain_forget fires or a memory is soft-deleted. - php/Jobs/DeleteFromIndex.php — Laravel Job, 3 retries with backoff - BrainService: qdrantDelete() private→public and now throws on HTTP failure (was silent Log::warning — wouldn't trigger Job retry) - elasticDelete() stub added (fills in with the ES integration ticket) - php/tests/Feature/Jobs/DeleteFromIndexTest.php — success + HTTP-failure paths via mocked Http Co-authored-by: Codex Closes tasks.lthn.sh/view.php?id=57 --- php/Jobs/DeleteFromIndex.php | 36 +++++++++ php/Services/BrainService.php | 15 +++- .../Feature/Jobs/DeleteFromIndexTest.php | 75 +++++++++++++++++++ 3 files changed, 124 insertions(+), 2 deletions(-) create mode 100644 php/Jobs/DeleteFromIndex.php create mode 100644 php/tests/Feature/Jobs/DeleteFromIndexTest.php diff --git a/php/Jobs/DeleteFromIndex.php b/php/Jobs/DeleteFromIndex.php new file mode 100644 index 0000000..b249d68 --- /dev/null +++ b/php/Jobs/DeleteFromIndex.php @@ -0,0 +1,36 @@ + + */ + public array $backoff = [10, 60, 300]; + + public function __construct( + public string $memoryId, + ) {} + + public function handle(BrainService $brain): void + { + $brain->qdrantDelete([$this->memoryId]); + $brain->elasticDelete($this->memoryId); + } +} diff --git a/php/Services/BrainService.php b/php/Services/BrainService.php index bd61002..58706c5 100644 --- a/php/Services/BrainService.php +++ b/php/Services/BrainService.php @@ -214,6 +214,14 @@ class BrainService // Implemented by the Elasticsearch integration ticket. } + /** + * Delete a memory from Elasticsearch. + */ + public function elasticDelete(string $id): void + { + // Implemented by the Elasticsearch integration ticket. + } + /** * Build a Qdrant filter from criteria. * @@ -279,8 +287,10 @@ class BrainService * Delete points from Qdrant by ID. * * @param array $ids + * + * @throws \RuntimeException */ - private function qdrantDelete(array $ids): void + public function qdrantDelete(array $ids): void { $response = $this->http(10) ->post("{$this->qdrantUrl}/collections/{$this->collection}/points/delete", [ @@ -288,7 +298,8 @@ class BrainService ]); if (! $response->successful()) { - Log::warning("Qdrant delete failed: {$response->status()}", ['ids' => $ids, 'body' => $response->body()]); + Log::error("Qdrant delete failed: {$response->status()}", ['ids' => $ids, 'body' => $response->body()]); + throw new \RuntimeException("Qdrant delete failed: {$response->status()}"); } } } diff --git a/php/tests/Feature/Jobs/DeleteFromIndexTest.php b/php/tests/Feature/Jobs/DeleteFromIndexTest.php new file mode 100644 index 0000000..177bd34 --- /dev/null +++ b/php/tests/Feature/Jobs/DeleteFromIndexTest.php @@ -0,0 +1,75 @@ +toString(); + + Http::fake([ + 'https://qdrant.test/collections/openbrain/points/delete' => Http::response(['result' => ['status' => 'ok']]), + ]); + + (new DeleteFromIndex($memoryId))->handle(deleteFromIndexService()); + + Http::assertSent(fn (Request $request): bool => $request->url() === 'https://qdrant.test/collections/openbrain/points/delete' + && $request->method() === 'POST' + && $request['points'] === [$memoryId]); +}); + +test('DeleteFromIndex_handle_Bad_deletes_soft_deleted_memory_from_qdrant', function (): void { + $workspace = createWorkspace(); + + Http::fake([ + 'https://qdrant.test/collections/openbrain/points/delete' => Http::response(['result' => ['status' => 'ok']]), + ]); + + $memory = BrainMemory::create([ + 'workspace_id' => $workspace->id, + 'agent_id' => 'virgil', + 'type' => 'observation', + 'content' => 'Soft-deleted memories should still be removed from the index.', + 'confidence' => 0.8, + ]); + $memory->delete(); + + (new DeleteFromIndex($memory->id))->handle(deleteFromIndexService()); + + Http::assertSent(fn (Request $request): bool => $request->url() === 'https://qdrant.test/collections/openbrain/points/delete' + && $request->method() === 'POST' + && $request['points'] === [$memory->id]); +}); + +test('DeleteFromIndex_handle_Ugly_throws_when_qdrant_delete_fails', function (): void { + $memoryId = Str::uuid()->toString(); + + Http::fake([ + 'https://qdrant.test/collections/openbrain/points/delete' => Http::response(['error' => 'unavailable'], 500), + ]); + + try { + (new DeleteFromIndex($memoryId))->handle(deleteFromIndexService()); + $this->fail('Expected Qdrant failure to throw.'); + } catch (RuntimeException $exception) { + expect($exception->getMessage())->toBe('Qdrant delete failed: 500'); + } +});