feat(brain): add DeleteFromIndex job

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 <noreply@openai.com>

Closes tasks.lthn.sh/view.php?id=57
This commit is contained in:
Snider 2026-04-23 12:55:45 +01:00
parent 4dc5ed8d14
commit 8d520adb5e
3 changed files with 124 additions and 2 deletions

View file

@ -0,0 +1,36 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
namespace Core\Mod\Agentic\Jobs;
use Core\Mod\Agentic\Services\BrainService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class DeleteFromIndex implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
/**
* @var array<int, int>
*/
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);
}
}

View file

@ -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<string> $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()}");
}
}
}

View file

@ -0,0 +1,75 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
use Core\Mod\Agentic\Jobs\DeleteFromIndex;
use Core\Mod\Agentic\Models\BrainMemory;
use Core\Mod\Agentic\Services\BrainService;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
function deleteFromIndexService(): BrainService
{
return new BrainService(
ollamaUrl: 'https://ollama.test',
qdrantUrl: 'https://qdrant.test',
collection: 'openbrain',
embeddingModel: 'embeddinggemma',
verifySsl: false,
);
}
test('DeleteFromIndex_handle_Good_deletes_memory_from_qdrant', function (): void {
$memoryId = Str::uuid()->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');
}
});