Skip to content

Commit b5ebf52

Browse files
committed
fix(indexer): roll back the PageIndex blob when indexing fails after col.add
Since the add mutation stopped eagerly snapshotting .openkb/files (it registers the new blob via track_new only on success), a blob written by col.add() leaks if index_long_document raises afterward: pageindex.db is rolled back by the snapshot but the blob file is not, and nothing reclaims it. Delete the doc on the failure path so the indexer owns cleanup of a half-applied add, restoring the .openkb/files rollback-surface guarantee.
1 parent 619e78e commit b5ebf52

2 files changed

Lines changed: 84 additions & 42 deletions

File tree

‎openkb/indexer.py‎

Lines changed: 61 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -188,49 +188,68 @@ def index_long_document(
188188
if attempt == max_retries:
189189
raise RuntimeError(f"Failed to index {pdf_path.name} after {max_retries} attempts: {exc}") from exc
190190

191-
# Fetch complete document (metadata + structure + text)
192-
doc = col.get_document(doc_id, include_text=True)
193-
indexed_doc_name: str = doc.get("doc_name", pdf_path.stem)
194-
description: str = doc.get("doc_description", "")
195-
structure: list = doc.get("structure", [])
196-
197-
# Debug: print doc keys and page_count to diagnose get_page_content range
198-
logger.info("Doc keys: %s", list(doc.keys()))
199-
logger.info("page_count from doc: %s", doc.get("page_count", "NOT PRESENT"))
200-
201-
tree = {
202-
"doc_name": indexed_doc_name,
203-
"doc_description": description,
204-
"structure": structure,
205-
}
206-
207-
# Write wiki/sources/ — per-page content
208-
sources_dir = kb_dir / "wiki" / "sources"
209-
sources_dir.mkdir(parents=True, exist_ok=True)
210-
images_dir = sources_dir / "images" / source_name
211-
212-
all_pages: list[dict[str, Any]] = []
213-
if pageindex_api_key:
214-
# Cloud mode: fetch OCR'd markdown from PageIndex. get_page_content
215-
# requires a page range, so pass "1-N".
216-
page_count = _get_pdf_page_count(pdf_path)
217-
try:
218-
all_pages = _normalize_page_content(col.get_page_content(doc_id, f"1-{page_count}"))
219-
except Exception as exc:
220-
logger.warning("Cloud get_page_content failed for %s: %s", pdf_path.name, exc)
221-
222-
if not all_pages:
191+
# The PageIndex blob for doc_id is now durably on disk. The add mutation no
192+
# longer eagerly snapshots .openkb/files — it registers the new blob via
193+
# snapshot.track_new() only on a successful return — so if any step below
194+
# fails, delete the document we just added. Otherwise the blob leaks as an
195+
# orphan that pageindex.db (rolled back by the snapshot) no longer refs and
196+
# no reaper reclaims.
197+
try:
198+
# Fetch complete document (metadata + structure + text)
199+
doc = col.get_document(doc_id, include_text=True)
200+
indexed_doc_name: str = doc.get("doc_name", pdf_path.stem)
201+
description: str = doc.get("doc_description", "")
202+
structure: list = doc.get("structure", [])
203+
204+
# Debug: print doc keys and page_count to diagnose get_page_content range
205+
logger.info("Doc keys: %s", list(doc.keys()))
206+
logger.info("page_count from doc: %s", doc.get("page_count", "NOT PRESENT"))
207+
208+
tree = {
209+
"doc_name": indexed_doc_name,
210+
"doc_description": description,
211+
"structure": structure,
212+
}
213+
214+
# Write wiki/sources/ — per-page content
215+
sources_dir = kb_dir / "wiki" / "sources"
216+
sources_dir.mkdir(parents=True, exist_ok=True)
217+
images_dir = sources_dir / "images" / source_name
218+
219+
all_pages: list[dict[str, Any]] = []
223220
if pageindex_api_key:
224-
logger.warning("Cloud returned no pages for %s; falling back to local pymupdf", pdf_path.name)
225-
all_pages = _normalize_page_content(_convert_pdf_to_pages(pdf_path, source_name, images_dir))
226-
227-
if not all_pages:
228-
raise RuntimeError(f"No page content extracted for {pdf_path.name}")
229-
230-
_write_long_doc_artifacts(
231-
tree, all_pages, source_name, doc_id, kb_dir, description=description
232-
)
233-
return IndexResult(doc_id=doc_id, description=description, tree=tree)
221+
# Cloud mode: fetch OCR'd markdown from PageIndex. get_page_content
222+
# requires a page range, so pass "1-N".
223+
page_count = _get_pdf_page_count(pdf_path)
224+
try:
225+
all_pages = _normalize_page_content(col.get_page_content(doc_id, f"1-{page_count}"))
226+
except Exception as exc:
227+
logger.warning("Cloud get_page_content failed for %s: %s", pdf_path.name, exc)
228+
229+
if not all_pages:
230+
if pageindex_api_key:
231+
logger.warning("Cloud returned no pages for %s; falling back to local pymupdf", pdf_path.name)
232+
all_pages = _normalize_page_content(_convert_pdf_to_pages(pdf_path, source_name, images_dir))
233+
234+
if not all_pages:
235+
raise RuntimeError(f"No page content extracted for {pdf_path.name}")
236+
237+
_write_long_doc_artifacts(
238+
tree, all_pages, source_name, doc_id, kb_dir, description=description
239+
)
240+
return IndexResult(doc_id=doc_id, description=description, tree=tree)
241+
except BaseException:
242+
# Best-effort: remove the blob this add created. A failure here (e.g. a
243+
# second interrupt) only means the blob may stay orphaned — the original
244+
# error still propagates so the caller (mutation coordinator) rolls back
245+
# everything else it snapshotted.
246+
try:
247+
col.delete_document(doc_id)
248+
except Exception:
249+
logger.warning(
250+
"PageIndex cleanup of %s failed after error; blob may be orphaned", doc_id
251+
)
252+
raise
234253

235254

236255
# PageIndex's get_page_content rejects a single page range covering more than

‎tests/test_indexer.py‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,29 @@ def test_returns_index_result(self, kb_dir, sample_tree, tmp_path):
8989
assert result.description == sample_tree["doc_description"]
9090
assert result.tree is not None
9191

92+
def test_deletes_pageindex_doc_when_a_post_add_step_fails(self, kb_dir, sample_tree, tmp_path):
93+
"""The PageIndex blob is durably written by col.add(), but .openkb/files is
94+
no longer in the add mutation's eager snapshot — track_new only registers
95+
the blob on a successful return. So if any step after col.add() raises
96+
(here: get_document), index_long_document must delete the doc it just
97+
added; otherwise the blob leaks as an orphan that pageindex.db — rolled
98+
back by the snapshot — no longer references, and no reaper reclaims."""
99+
doc_id = "abc-123"
100+
col = self._make_fake_collection(doc_id, sample_tree)
101+
col.get_document.side_effect = RuntimeError("get_document blew up")
102+
103+
fake_client = MagicMock()
104+
fake_client.collection.return_value = col
105+
106+
pdf_path = tmp_path / "sample.pdf"
107+
pdf_path.write_bytes(b"%PDF-1.4 fake")
108+
109+
with patch("openkb.indexer.PageIndexClient", return_value=fake_client):
110+
with pytest.raises(RuntimeError, match="get_document blew up"):
111+
index_long_document(pdf_path, kb_dir)
112+
113+
col.delete_document.assert_called_once_with(doc_id)
114+
92115
def test_source_page_written_as_json(self, kb_dir, sample_tree, tmp_path):
93116
"""Long doc source should be written as JSON, not markdown."""
94117
import json as json_mod

0 commit comments

Comments
 (0)