From e10762854c281f411d2f0b6098ae844b2bb9bb94 Mon Sep 17 00:00:00 2001 From: Yiorgis Gozadinos Date: Fri, 17 Jul 2026 12:33:24 +0300 Subject: [PATCH] Post-commit hooks are best-effort observers --- CHANGELOG.md | 2 +- docs/hooks.md | 2 + haiku_rag_slim/haiku/rag/client/__init__.py | 5 +- haiku_rag_slim/haiku/rag/client/documents.py | 14 +--- haiku_rag_slim/haiku/rag/hooks.py | 59 +++++++++++++- ...ingest_hook_failure_does_not_fail_job.yaml | 42 ++++++++++ tests/ingester/test_pipeline.py | 38 ++++++++++ tests/test_hooks.py | 76 +++++++++++++++++++ 8 files changed, 221 insertions(+), 17 deletions(-) create mode 100644 tests/cassettes/test_pipeline/test_after_ingest_hook_failure_does_not_fail_job.yaml diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c2bbfc6..3682c6ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ ### Added -- Client lifecycle hooks (`after_ingest`, `after_delete`, `before_search`, `after_search`) registered under the `haiku.rag.hooks` entry-point group and activated via the `hooks:` config list. +- Client lifecycle hooks (`after_ingest`, `after_delete`, `before_search`, `after_search`) registered under the `haiku.rag.hooks` entry-point group and activated via the `hooks:` config list. Post-commit observer hooks are best-effort; failures are logged and do not change the completed operation's result. - `SearchResult.annotations` carries notes attached by `after_search` hooks, preserved through context expansion and rendered in agent-facing output. ## [0.78.0] - 2026-08-24 diff --git a/docs/hooks.md b/docs/hooks.md index a757d956..6779ce09 100644 --- a/docs/hooks.md +++ b/docs/hooks.md @@ -60,6 +60,8 @@ An unknown name in `hooks:` raises `ValueError` when the client is constructed, ## Semantics +- **Post-commit hooks are best-effort observers.** By the time `after_ingest` or `after_delete` runs, the operation has committed. A hook failure is logged, subsequent hooks still run, and the operation still returns success (the ingester proceeds through its normal success path). Correctness-critical derived state therefore needs its own retry or reconciliation, such as the backfill loop below. `before_search` and `after_search` failures propagate: nothing has committed and failing the search is visible to the caller. +- **Post-commit hooks are not a supported transformation point.** Mutating event models does not alter the committed record; explicit client writes are separate operations and are not atomic with the original write. - **Update equals ingest.** `after_ingest` fires for both creation and content updates, with `event.operation` set to `"create"` or `"update"` (creation against an already-stored URI reports `"update"`). Treat both as "replace any state you derived from these documents". The operation is informational, for notification or sync hooks. Metadata-only and title-only updates do not fire. - **Batch your writes.** A batch import delivers all its documents in one event. A hook keeping LanceDB state should write once per event, not once per document, to avoid creating a table version per document. - **Hooks run after the write commits.** They execute outside the store's write lock, so a hook may itself write to the database, and a hook failure never rolls back the document write. diff --git a/haiku_rag_slim/haiku/rag/client/__init__.py b/haiku_rag_slim/haiku/rag/client/__init__.py index 0b42e974..209c55f4 100644 --- a/haiku_rag_slim/haiku/rag/client/__init__.py +++ b/haiku_rag_slim/haiku/rag/client/__init__.py @@ -17,7 +17,7 @@ import httpx from haiku.rag.client.documents import DocumentImport from haiku.rag.config import AppConfig, get_config from haiku.rag.converters import get_converter -from haiku.rag.hooks import DeleteEvent, build_hooks, load_hooks +from haiku.rag.hooks import DeleteEvent, build_hooks, load_hooks, notify from haiku.rag.reranking import get_reranker from haiku.rag.store.engine import Store from haiku.rag.store.models.chunk import Chunk, SearchResult, SearchType @@ -424,8 +424,7 @@ class HaikuRAG: if self._config.storage.auto_vacuum: self._schedule_vacuum() event = DeleteEvent(documents=docs_to_delete) - for hook in self._hooks: - await hook.after_delete(self, event) + await notify(self._hooks, "after_delete", self, event) return True async def list_documents( diff --git a/haiku_rag_slim/haiku/rag/client/documents.py b/haiku_rag_slim/haiku/rag/client/documents.py index b3fa1db6..ccae42cd 100644 --- a/haiku_rag_slim/haiku/rag/client/documents.py +++ b/haiku_rag_slim/haiku/rag/client/documents.py @@ -16,7 +16,7 @@ from haiku.rag.client.processing import ( ) from haiku.rag.client.titles import resolve_title from haiku.rag.converters import get_converter -from haiku.rag.hooks import IngestEvent +from haiku.rag.hooks import IngestEvent, notify from haiku.rag.store.models.chunk import Chunk from haiku.rag.store.models.document import Document from haiku.rag.store.models.document_item import DocumentItem, extract_items @@ -162,13 +162,11 @@ async def _store_document_with_chunks( if client._config.storage.auto_vacuum: client._schedule_vacuum() - event = IngestEvent( documents=[stored_doc], operation="create" if existing is None else "update", ) - for hook in client._hooks: - await hook.after_ingest(client, event) + await notify(client._hooks, "after_ingest", client, event) return stored_doc @@ -220,10 +218,8 @@ async def _update_document_with_chunks( if client._config.storage.auto_vacuum: client._schedule_vacuum() - event = IngestEvent(documents=[updated_doc], operation="update") - for hook in client._hooks: - await hook.after_ingest(client, event) + await notify(client._hooks, "after_ingest", client, event) return updated_doc @@ -326,10 +322,8 @@ async def _store_documents_with_chunks( if client._config.storage.auto_vacuum: client._schedule_vacuum() - event = IngestEvent(documents=created, operation="create") - for hook in client._hooks: - await hook.after_ingest(client, event) + await notify(client._hooks, "after_ingest", client, event) return created diff --git a/haiku_rag_slim/haiku/rag/hooks.py b/haiku_rag_slim/haiku/rag/hooks.py index 7268494a..28dc6ee3 100644 --- a/haiku_rag_slim/haiku/rag/hooks.py +++ b/haiku_rag_slim/haiku/rag/hooks.py @@ -1,7 +1,8 @@ +import logging from collections.abc import Callable, Mapping, Sequence from dataclasses import dataclass from importlib.metadata import entry_points -from typing import TYPE_CHECKING, Literal, Protocol, runtime_checkable +from typing import TYPE_CHECKING, Literal, Protocol, overload, runtime_checkable from haiku.rag.store.models.chunk import SearchResult, SearchType from haiku.rag.store.models.document import Document @@ -13,6 +14,8 @@ if TYPE_CHECKING: ENTRY_POINT_GROUP = "haiku.rag.hooks" +logger = logging.getLogger(__name__) + IngestOperation = Literal["create", "update"] @@ -62,10 +65,19 @@ class Hook: document's content was rewritten (including creation against an already-stored URI). Replace any state derived from the documents regardless of the operation: even a creation may be a retry. - Metadata/title-only updates do not fire.""" + Metadata/title-only updates do not fire. + + Best-effort observer: the operation has already committed, so + exceptions are logged and never raised, and subsequent hooks still + run. Correctness-critical derived state needs its own + reconciliation. Post-commit hooks are not a supported transformation + point: mutating event models does not alter the committed record, + and explicit client writes are separate operations, not atomic with + the original write.""" async def after_delete(self, client: "HaikuRAG", event: DeleteEvent) -> None: - """``event.documents`` were deleted; cascades arrive as one event.""" + """``event.documents`` were deleted; cascades arrive as one event. + Best-effort observer with the same contract as ``after_ingest``.""" async def before_search( self, client: "HaikuRAG", request: SearchRequest @@ -86,6 +98,47 @@ class Hook: return results +@overload +async def notify( + hooks: Sequence[Hook], + method: Literal["after_ingest"], + client: "HaikuRAG", + event: IngestEvent, +) -> None: ... + + +@overload +async def notify( + hooks: Sequence[Hook], + method: Literal["after_delete"], + client: "HaikuRAG", + event: DeleteEvent, +) -> None: ... + + +async def notify( + hooks: Sequence[Hook], + method: Literal["after_ingest", "after_delete"], + client: "HaikuRAG", + event: IngestEvent | DeleteEvent, +) -> None: + """Fire post-commit observer hooks best-effort: a hook failure is logged + and never raised (the operation already committed), and subsequent hooks + still run.""" + for hook in hooks: + try: + await getattr(hook, method)(client, event) + except Exception: + cls = type(hook) + logger.exception( + "%s.%s.%s failed for documents %s", + cls.__module__, + cls.__qualname__, + method, + [d.id for d in event.documents], + ) + + HookFactory = Callable[[], Hook] diff --git a/tests/cassettes/test_pipeline/test_after_ingest_hook_failure_does_not_fail_job.yaml b/tests/cassettes/test_pipeline/test_after_ingest_hook_failure_does_not_fail_job.yaml new file mode 100644 index 00000000..030d3227 --- /dev/null +++ b/tests/cassettes/test_pipeline/test_after_ingest_hook_failure_does_not_fail_job.yaml @@ -0,0 +1,42 @@ +interactions: +- request: + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate, zstd + connection: + - keep-alive + content-length: + - '75' + content-type: + - application/json + host: + - localhost:11434 + method: POST + parsed_body: + encoding_format: base64 + input: + - hello + model: qwen3-embedding:4b + uri: http://localhost:11434/v1/embeddings + response: + headers: + content-type: + - application/json + transfer-encoding: + - chunked + parsed_body: + data: + - embedding: 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 + index: 0 + object: embedding + model: qwen3-embedding:4b + object: list + usage: + prompt_tokens: 2 + total_tokens: 2 + status: + code: 200 + message: OK +version: 1 diff --git a/tests/ingester/test_pipeline.py b/tests/ingester/test_pipeline.py index d756e903..a5af050e 100644 --- a/tests/ingester/test_pipeline.py +++ b/tests/ingester/test_pipeline.py @@ -1,4 +1,6 @@ +import logging from datetime import UTC, datetime +from pathlib import Path from unittest.mock import AsyncMock import httpx @@ -13,10 +15,12 @@ from obstore.exceptions import ( ) from haiku.rag.client import HaikuRAG +from haiku.rag.hooks import Hook from haiku.rag.ingester.exceptions import PermanentError, TransientError from haiku.rag.ingester.queue.models import Job, JobOp, JobStatus from haiku.rag.ingester.workers.pipeline import run_job from haiku.rag.sources.base import FetchResult, FileTooLargeError, Source +from haiku.rag.sources.fs import FSSource from haiku.rag.store.models.document import Document @@ -580,3 +584,37 @@ async def test_other_obstore_errors_classified_transient(exc_class): client.create_document_from_source.side_effect = exc_class("upstream hiccup") with pytest.raises(TransientError): await run_job(client, _job()) + + +@pytest.fixture(scope="module") +def vcr_cassette_dir(): + return str(Path(__file__).parent.parent / "cassettes" / "test_pipeline") + + +class _ThrowingIngestHook(Hook): + async def after_ingest(self, client, event): + raise RuntimeError("ingest hook boom") + + +@pytest.mark.asyncio +@pytest.mark.vcr() +async def test_after_ingest_hook_failure_does_not_fail_job( + temp_db_path, tmp_path, caplog +): + """A throwing post-commit hook must not turn a committed ingest into a + failed job: run_job returns a successful JobResult instead of raising + into _classify.""" + file_path = tmp_path / "doc.md" + file_path.write_text("hello") + + fs = FSSource(root=tmp_path, source_id="src") + + async with HaikuRAG(temp_db_path, create=True) as client: + client._hooks = [_ThrowingIngestHook()] + + with caplog.at_level(logging.ERROR, logger="haiku.rag.hooks"): + result = await run_job(client, _job(uri=str(file_path)), sources=[fs]) + + assert result.document_id is not None + assert result.deleted is False + assert any("after_ingest" in r.message for r in caplog.records) diff --git a/tests/test_hooks.py b/tests/test_hooks.py index c79e1b5a..d01a3679 100644 --- a/tests/test_hooks.py +++ b/tests/test_hooks.py @@ -1,3 +1,5 @@ +import logging + import pytest from haiku.rag.client import HaikuRAG @@ -372,6 +374,80 @@ async def test_annotations_survive_context_expansion(temp_db_path): ] +class ThrowingHook(Hook): + async def after_ingest(self, client, event): + raise RuntimeError("ingest hook boom") + + async def after_delete(self, client, event): + raise RuntimeError("delete hook boom") + + async def before_search(self, client, request): + raise RuntimeError("search hook boom") + + +@pytest.mark.asyncio +async def test_after_ingest_hook_failure_is_logged_not_raised(temp_db_path, caplog): + spy = RecordingHook() + dim = Config.embeddings.model.vector_dim + + async with HaikuRAG(temp_db_path, create=True) as client: + client._hooks = [ThrowingHook(), spy] + + with caplog.at_level(logging.ERROR, logger="haiku.rag.hooks"): + doc = await client.import_document( + _docling_doc("a", "Alpha body"), + [Chunk(content="Alpha body", embedding=[0.1] * dim, order=0)], + uri="mem://a", + title="Alpha", + ) + + assert doc.id is not None + stored = await client.get_document_by_id(doc.id) + assert stored is not None + + # Subsequent hooks still run after a failing one. + assert spy.events == [("ingest", "create", ((doc.id, "mem://a"),))] + + record = next(r for r in caplog.records if "after_ingest" in r.message) + assert "tests.test_hooks.ThrowingHook" in record.message + assert str(doc.id) in record.message + + +@pytest.mark.asyncio +async def test_after_delete_hook_failure_is_logged_not_raised(temp_db_path, caplog): + spy = RecordingHook() + dim = Config.embeddings.model.vector_dim + + async with HaikuRAG(temp_db_path, create=True) as client: + doc = await client.import_document( + _docling_doc("a", "Alpha body"), + [Chunk(content="Alpha body", embedding=[0.1] * dim, order=0)], + uri="mem://a", + title="Alpha", + ) + assert doc.id is not None + client._hooks = [ThrowingHook(), spy] + + with caplog.at_level(logging.ERROR, logger="haiku.rag.hooks"): + assert await client.delete_document(doc.id) is True + + assert await client.get_document_by_id(doc.id) is None + assert spy.events == [("delete", ((doc.id, "mem://a"),))] + + record = next(r for r in caplog.records if "after_delete" in r.message) + assert "tests.test_hooks.ThrowingHook" in record.message + assert str(doc.id) in record.message + + +@pytest.mark.asyncio +async def test_before_search_hook_failure_propagates(temp_db_path): + async with HaikuRAG(temp_db_path, create=True) as client: + client._hooks = [ThrowingHook()] + + with pytest.raises(RuntimeError, match="search hook boom"): + await client.search("alpha") + + @pytest.mark.asyncio async def test_delete_missing_document_fires_nothing(temp_db_path): spy = RecordingHook()