Skip to content

Commit 5053cbe

Browse files
perf(mutation): don't snapshot the whole blob store on every add (#155)
* perf(mutation): don't snapshot the whole blob store on every add The serial crash-safe add path (#142) listed `.openkb/files` (the PageIndex blob store) in both the snapshot path set and `hardlink_dirs`, so every add hardlinked the entire store into the rollback backup — one `os.link` per existing blob, plus the matching unlink on commit — a cost that scales with total KB size, not with the document being added. On a filesystem without hardlink support (cross-device staging, some Windows / cloud-sync folders) `_hardlink_or_copy` fell back to a full byte copy of the whole store on *every* add. The blob store is append-only by `{doc_id}`: an add only ever creates the new doc's blob, and that name is assigned during indexing — after the snapshot is taken. So instead of snapshotting the whole tree up front, register just the new blob once indexing has run, via `MutationSnapshot.track_new()`, which records it with no backup and rewrites the active journal so both in-process rollback and crash recovery remove exactly this doc's artifacts. Cloud import never writes a local blob, so `.openkb/files` is dropped from its snapshot entirely (it was pure waste there). - `MutationSnapshot.track_new(paths)`: register post-snapshot-created paths for removal on rollback; persists to the journal for crash recovery. - add: drop `.openkb/files` from the eager snapshot + `hardlink_dirs`; call `track_new(files/*/<doc_id>*)` right after `index_long_document`. - cloud import: drop `.openkb/files` from `hardlink_dirs`. Tests: new-blob rollback removes exactly the doc's blob + images subtree and leaves existing blobs untouched (same inode); track_new persists so recover_pending_journals cleans a crashed add; `_snapshot_add_paths` no longer lists `.openkb/files`. Full suite green (pre-existing trafilatura-missing url_ingest failures aside). Claude-Session: https://claude.ai/code/session_018WiFnTo1YW9mtw47Fzir9K * 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 4616e49 commit 5053cbe

4 files changed

Lines changed: 208 additions & 3 deletions

File tree

openkb/cli.py

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -362,13 +362,18 @@ def _snapshot_add_paths(
362362
final_raw: Path | None,
363363
final_source: Path | None,
364364
) -> list[Path]:
365+
# NOTE: .openkb/files (the PageIndex blob store) is intentionally NOT
366+
# snapshotted here. It is append-only by {doc_id}, and the doc_id is only
367+
# assigned during indexing (after this snapshot). Eagerly snapshotting the
368+
# whole tree cost one os.link per existing blob on every add; instead the
369+
# long-doc add path registers just the new blob via snapshot.track_new()
370+
# once indexing has run.
365371
paths = [
366372
kb_dir / ".openkb" / "hashes.json",
367373
kb_dir / ".openkb" / "pageindex.db",
368374
kb_dir / ".openkb" / "pageindex.db-wal",
369375
kb_dir / ".openkb" / "pageindex.db-shm",
370376
kb_dir / ".openkb" / "pageindex.db-journal",
371-
kb_dir / ".openkb" / "files",
372377
kb_dir / "wiki" / "summaries" / f"{doc_name}.md",
373378
kb_dir / "wiki" / "sources" / f"{doc_name}.json",
374379
kb_dir / "wiki" / "sources" / "images" / doc_name,
@@ -470,7 +475,6 @@ def _add_single_file_locked(
470475
hardlink_dirs={
471476
kb_dir / "wiki" / "concepts",
472477
kb_dir / "wiki" / "entities",
473-
kb_dir / ".openkb" / "files",
474478
},
475479
)
476480
publish_staged_tree(staging_dir, kb_dir)
@@ -484,6 +488,16 @@ def _add_single_file_locked(
484488
if result.raw_path is None:
485489
raise RuntimeError(f"Converted long document has no raw artifact: {file_path.name}")
486490
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+
)
487501
try:
488502
from openkb.indexer import index_long_document
489503

@@ -495,6 +509,20 @@ def _add_single_file_locked(
495509
logger.debug("Indexing traceback:", exc_info=True)
496510
raise
497511

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+
])
525+
498526
summary_path = kb_dir / "wiki" / "summaries" / f"{doc_name}.md"
499527
_run_compile_with_retry(
500528
lambda: compile_long_doc(
@@ -618,10 +646,11 @@ def import_from_pageindex_cloud(
618646
_snapshot_add_paths(kb_dir, doc_name, None, None),
619647
operation="cloud_import",
620648
details={"doc_id": doc_id, "doc_name": doc_name},
649+
# Cloud import reads from PageIndex Cloud and writes no local blob,
650+
# so .openkb/files is never touched — nothing to snapshot there.
621651
hardlink_dirs={
622652
kb_dir / "wiki" / "concepts",
623653
kb_dir / "wiki" / "entities",
624-
kb_dir / ".openkb" / "files",
625654
},
626655
)
627656
summary_path = _write_long_doc_artifacts(

openkb/mutation.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,30 @@ def mark_committed(self) -> None:
175175
"""
176176
self.write_journal("committed")
177177

178+
def track_new(self, paths: list[Path]) -> None:
179+
"""Register paths created *after* the snapshot for removal on rollback.
180+
181+
Some artifacts get their final name only once the mutation runs — the
182+
PageIndex ``{doc_id}`` blob under ``.openkb/files`` is named by indexing,
183+
which happens after :func:`snapshot_paths`. Rather than eagerly
184+
snapshotting the whole append-only blob store up front (an ``os.link``
185+
per existing blob on *every* add — O(total blobs), not O(this doc)),
186+
the caller invokes this once the new artifacts exist. Each is recorded
187+
with no backup, so both :meth:`rollback` and a crash-recovery replay
188+
delete exactly the new paths and nothing else. The active journal is
189+
rewritten so a crash after the artifacts land but before commit still
190+
cleans them up. Paths already tracked are ignored; missing paths are a
191+
no-op (nothing was created).
192+
"""
193+
changed = False
194+
for path in paths:
195+
target = path.resolve()
196+
if target not in self.entries:
197+
self.entries[target] = None
198+
changed = True
199+
if changed:
200+
self.write_journal("active")
201+
178202
def rollback(self) -> None:
179203
# Restore children before parents so directory deletes cannot remove
180204
# paths that still need to be restored from a more specific backup.

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"

tests/test_mutation.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -518,3 +518,72 @@ def test_hardlinked_dir_rollback_prunes_new_nested_blob_dirs(tmp_path):
518518
assert existing.stat().st_ino == existing_inode # untouched, not recopied
519519
assert not (files / "col" / "newdoc.pdf").exists()
520520
assert not (files / "col" / "newdoc").exists() # empty new dir pruned
521+
522+
523+
# --- track_new: cheap blob-store rollback without whole-tree snapshot -------
524+
525+
def test_track_new_removes_new_blob_on_rollback(tmp_path):
526+
"""The PageIndex blob under .openkb/files gets its {doc_id} name only once
527+
indexing runs — after snapshot_paths. Instead of snapshotting the whole
528+
(append-only) blob store up front, the add path calls track_new() with the
529+
new artifacts; rollback must then remove exactly those and leave every
530+
pre-existing blob untouched.
531+
"""
532+
kb_dir = tmp_path
533+
blobs = kb_dir / ".openkb" / "files" / "col"
534+
blobs.mkdir(parents=True)
535+
existing = blobs / "old-doc.pdf"
536+
existing.write_bytes(b"keep-me")
537+
existing_inode = existing.stat().st_ino
538+
539+
# Snapshot does NOT include .openkb/files at all.
540+
snapshot = snapshot_paths(kb_dir, [kb_dir / "wiki"], operation="add", details={})
541+
542+
# Indexing creates the new blob + its images subtree (doc_id now known).
543+
new_blob = blobs / "new-doc.pdf"
544+
new_blob.write_bytes(b"remove-me")
545+
new_images = blobs / "new-doc"
546+
(new_images / "images").mkdir(parents=True)
547+
(new_images / "images" / "p1.png").write_bytes(b"img")
548+
549+
snapshot.track_new([new_blob, new_images])
550+
snapshot.rollback()
551+
snapshot.discard_best_effort()
552+
553+
assert not new_blob.exists() # new blob removed
554+
assert not new_images.exists() # new images subtree removed
555+
assert existing.read_bytes() == b"keep-me" # pre-existing untouched
556+
assert existing.stat().st_ino == existing_inode # not recopied/relinked
557+
558+
559+
def test_track_new_persists_to_journal_for_crash_recovery(tmp_path):
560+
"""track_new() must rewrite the active journal so a crash *after* indexing
561+
but before commit still rolls back the new blob on the next exclusive-lock
562+
acquisition (recover_pending_journals), not just via in-process rollback.
563+
"""
564+
kb_dir = tmp_path
565+
blobs = kb_dir / ".openkb" / "files" / "col"
566+
blobs.mkdir(parents=True)
567+
568+
snapshot = snapshot_paths(kb_dir, [kb_dir / "wiki"], operation="add", details={})
569+
new_blob = blobs / "new-doc.pdf"
570+
new_blob.write_bytes(b"remove-me")
571+
snapshot.track_new([new_blob])
572+
# Simulate a crash: journal left "active", no rollback()/mark_committed().
573+
574+
messages = recover_pending_journals(kb_dir)
575+
576+
assert not new_blob.exists()
577+
assert any("Rolled back" in m for m in messages)
578+
579+
580+
def test_snapshot_add_paths_excludes_blob_store(tmp_path):
581+
"""The blob store is registered lazily via track_new(), so it must NOT be
582+
in the eager add snapshot path list (that was the O(total blobs)-per-add
583+
cost this change removes)."""
584+
from openkb.cli import _snapshot_add_paths
585+
586+
paths = _snapshot_add_paths(tmp_path, "doc", None, None)
587+
assert (tmp_path / ".openkb" / "files") not in paths
588+
# hashes.json / pageindex.db are still snapshotted eagerly.
589+
assert (tmp_path / ".openkb" / "hashes.json") in paths

0 commit comments

Comments
 (0)