Skip to content

Commit 3749648

Browse files
committed
fix(mutation): only track blobs this add created (dedup-hit rollback bug)
Self-review of the previous commit found a regression it introduced: track_new globbed `.openkb/files/*/<doc_id>*` and registered whatever matched for removal on rollback. But PageIndex content-dedups — `add_document` returns an EXISTING doc_id and writes no new blob when the same content is already indexed. If hashes.json and pageindex.db diverge (e.g. a prior `remove` whose PageIndex cleanup failed left the row + blob but dropped the hashes.json entry), re-adding that content makes col.add() return the OLD doc_id, so a subsequent compile failure would roll back and DELETE that prior document's blob. The old whole-store hardlink snapshot did not have this bug (a dedup-hit blob shares the backup inode and is left in place on rollback). Fix: capture the blob set *before* indexing and register only the paths this add actually created (set difference), guarded by `if index_result.doc_id`. That also neutralizes an unexpected empty/falsy doc_id, which would otherwise glob `*/*` and register — then delete on rollback — the entire blob store. Tests (tests/test_add_command.py): - test_long_doc_rollback_removes_only_the_new_blob: a failed long-doc add rolls back its own new blob + images subtree while a pre-existing blob survives. - test_long_doc_dedup_hit_does_not_delete_existing_blob: a dedup hit (existing doc_id, no new blob) must not delete the pre-existing blob on rollback — verified this test FAILS on the pre-fix code. Claude-Session: https://claude.ai/code/session_018WiFnTo1YW9mtw47Fzir9K
1 parent 723c8e9 commit 3749648

2 files changed

Lines changed: 106 additions & 10 deletions

File tree

openkb/cli.py

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -488,6 +488,16 @@ def _add_single_file_locked(
488488
if result.raw_path is None:
489489
raise RuntimeError(f"Converted long document has no raw artifact: {file_path.name}")
490490
click.echo(" Long document detected — indexing with PageIndex...")
491+
# PageIndex content-dedups: if the same content is already indexed
492+
# (e.g. hashes.json and pageindex.db diverged after a remove whose
493+
# PageIndex cleanup failed), col.add() returns the EXISTING doc_id
494+
# and writes no new blob. Capture the blob set *before* indexing so
495+
# we register only blobs THIS add actually created — otherwise
496+
# rollback would delete a prior document's blob.
497+
files_root = kb_dir / ".openkb" / "files"
498+
blobs_before = (
499+
set(files_root.glob("*/*")) if files_root.exists() else set()
500+
)
491501
try:
492502
from openkb.indexer import index_long_document
493503

@@ -499,16 +509,19 @@ def _add_single_file_locked(
499509
logger.debug("Indexing traceback:", exc_info=True)
500510
raise
501511

502-
# Indexing just created the append-only blob(s) for this doc_id
503-
# under .openkb/files/<collection>/. Register them now (their names
504-
# weren't known at snapshot time) so rollback + crash recovery
505-
# remove exactly this doc's blob instead of us snapshotting the
506-
# whole store up front.
507-
files_root = kb_dir / ".openkb" / "files"
508-
if files_root.exists():
509-
snapshot.track_new(
510-
sorted(files_root.glob(f"*/{index_result.doc_id}*"))
511-
)
512+
# Register only the newly-created blob artifacts for this doc (the
513+
# {doc_id} file + its images dir) — the append-only store means the
514+
# name isn't known until now — so rollback + crash recovery remove
515+
# exactly this add's blob, never a pre-existing one, instead of
516+
# snapshotting the whole store up front. The doc_id guard + the
517+
# blobs_before diff keep a dedup hit (or an unexpected empty doc_id)
518+
# from registering — and later deleting — existing blobs.
519+
if index_result.doc_id and files_root.exists():
520+
snapshot.track_new([
521+
p
522+
for p in files_root.glob(f"*/{index_result.doc_id}*")
523+
if p not in blobs_before
524+
])
512525

513526
summary_path = kb_dir / "wiki" / "summaries" / f"{doc_name}.md"
514527
_run_compile_with_retry(

tests/test_add_command.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,89 @@ def test_add_single_file_compile_failure_rolls_back_converted_artifacts(self, tm
9090
assert not (kb_dir / "wiki" / "sources" / "notes.md").exists()
9191
assert HashRegistry(kb_dir / ".openkb" / "hashes.json").all_entries() == {}
9292

93+
def _long_doc_conv(self, kb_dir, name, file_hash):
94+
from openkb.converter import ConvertResult
95+
96+
return ConvertResult(
97+
raw_path=kb_dir / "raw" / f"{name}.pdf",
98+
source_path=None,
99+
is_long_doc=True,
100+
file_hash=file_hash,
101+
doc_name=name,
102+
)
103+
104+
def test_long_doc_rollback_removes_only_the_new_blob(self, tmp_path):
105+
"""A failed long-doc add must roll back the blob IT created under
106+
.openkb/files, while a pre-existing blob (another document) survives —
107+
the targeted track_new must not touch blobs this add didn't create."""
108+
from openkb.cli import add_single_file
109+
from openkb.indexer import IndexResult
110+
111+
kb_dir = self._setup_kb(tmp_path)
112+
files = kb_dir / ".openkb" / "files" / "default"
113+
files.mkdir(parents=True)
114+
other = files / "other-doc.pdf"
115+
other.write_bytes(b"another-doc-keep-me")
116+
117+
new_id = "11111111-1111-1111-1111-111111111111"
118+
119+
def fake_index(raw_path, kb_dir_arg, doc_name=None):
120+
(files / f"{new_id}.pdf").write_bytes(b"new-blob")
121+
(files / new_id / "images").mkdir(parents=True)
122+
(files / new_id / "images" / "p1.png").write_bytes(b"img")
123+
return IndexResult(doc_id=new_id, description="", tree={"structure": []})
124+
125+
doc = tmp_path / "paper.pdf"
126+
doc.write_bytes(b"%PDF-1.4 fake")
127+
conv = self._long_doc_conv(kb_dir, "paper", "cafebabe00" * 8)
128+
129+
with patch("openkb.cli.convert_document", return_value=conv), \
130+
patch("openkb.indexer.index_long_document", side_effect=fake_index), \
131+
patch("openkb.agent.compiler.compile_long_doc",
132+
side_effect=RuntimeError("boom")), \
133+
patch("openkb.cli.time.sleep"), \
134+
patch("openkb.cli._setup_llm_key"):
135+
outcome = add_single_file(doc, kb_dir)
136+
137+
assert outcome == "failed"
138+
assert not (files / f"{new_id}.pdf").exists() # new blob rolled back
139+
assert not (files / new_id).exists() # new images subtree rolled back
140+
assert other.read_bytes() == b"another-doc-keep-me" # pre-existing survives
141+
142+
def test_long_doc_dedup_hit_does_not_delete_existing_blob(self, tmp_path):
143+
"""PageIndex content-dedup can return an EXISTING doc_id and write no new
144+
blob (diverged hashes.json/pageindex.db). A failed add must NOT delete
145+
that pre-existing blob on rollback (regression: track_new globbing the
146+
doc_id would otherwise register and delete it)."""
147+
from openkb.cli import add_single_file
148+
from openkb.indexer import IndexResult
149+
150+
kb_dir = self._setup_kb(tmp_path)
151+
files = kb_dir / ".openkb" / "files" / "default"
152+
files.mkdir(parents=True)
153+
existing_id = "22222222-2222-2222-2222-222222222222"
154+
existing_blob = files / f"{existing_id}.pdf"
155+
existing_blob.write_bytes(b"pre-existing-do-not-delete")
156+
157+
def fake_index_dedup(raw_path, kb_dir_arg, doc_name=None):
158+
# Dedup hit: return the existing doc_id, create NO new blob.
159+
return IndexResult(doc_id=existing_id, description="", tree={"structure": []})
160+
161+
doc = tmp_path / "dup.pdf"
162+
doc.write_bytes(b"%PDF-1.4 dup")
163+
conv = self._long_doc_conv(kb_dir, "dup", "feedface00" * 8)
164+
165+
with patch("openkb.cli.convert_document", return_value=conv), \
166+
patch("openkb.indexer.index_long_document", side_effect=fake_index_dedup), \
167+
patch("openkb.agent.compiler.compile_long_doc",
168+
side_effect=RuntimeError("boom")), \
169+
patch("openkb.cli.time.sleep"), \
170+
patch("openkb.cli._setup_llm_key"):
171+
outcome = add_single_file(doc, kb_dir)
172+
173+
assert outcome == "failed"
174+
assert existing_blob.read_bytes() == b"pre-existing-do-not-delete"
175+
93176
def test_add_directory_calls_helper_for_each_file(self, tmp_path):
94177
kb_dir = self._setup_kb(tmp_path)
95178
docs_dir = tmp_path / "docs"

0 commit comments

Comments
 (0)