feat: multi-invocation incremental builds — --sync-key, changes/verify subcommands, snapshot hardening - #398
Conversation
…mmands, zero-chunk snapshot commit, LEANN_NO_REGISTER (#2) * feat(cli): --sync-key stable snapshot identity Keyed builds use one global FileSynchronizer over the expanded file manifest (snapshot sync_key_{sha256(key)[:12]}.pickle) instead of per-docs-list hashed snapshots, so varying --docs lists across invocations keep merkle incrementality. Key lifecycle persisted in sync_roots.json (reuse stored key; rekey requires --force). load_snapshot() now raises SnapshotCorruptError on unreadable pickles (missing file still means first build); keyed builds fail loudly on corruption instead of warn-and-skip. Also: remove-only deltas on non-IVF backends now trigger the full rebuild instead of returning early (previously gated on IVF). * feat(cli): leann changes subcommand Non-mutating merkle diff against the stored snapshot: prints one JSON document {added, modified, removed} (sorted) on stdout, diagnostics on stderr. Scope comes from --docs or, when omitted, from the index's stored sync_roots.json. Snapshot errors are strict (nonzero exit, no false-clean report) instead of build's warn-and-skip. LeannCLI no longer eagerly creates .leann/indexes at construction, so read-only commands leave the working directory untouched. * feat(cli): leann verify subcommand Cross-artifact integrity check: passages.jsonl/.idx offset and id-set consistency for any backend, plus IVF invariants (id-map exact inverses, id-map values vs offset-map keys, FAISS ntotal vs id-map cardinality, next_id monotonicity, missing index file). Exit 0 clean, 1 with a findings report. Covers vector/id-map/passage artifacts only (not BM25 sqlite or ids.txt). * fix(cli): commit snapshot on safely-empty incremental deltas An add/modify-only delta that loads successfully but produces zero chunks now commits the snapshot and sync config, so the same no-op delta is not rediscovered on every subsequent build. Loader failures and removal-bearing deltas still leave the snapshot untouched. Snapshot pickles and sync_roots.json now publish via tmp + os.replace so an interrupted write cannot corrupt existing state. * feat(registry): LEANN_NO_REGISTER env switch LEANN_NO_REGISTER=1 makes register_project_directory a no-op before any path traversal or ~/.leann creation, for automation running builds in disposable directories. * fix(cli): verify reads real on-disk IVF artifact names E2E smoke against a real IVF build showed verify looked for documents.leann.{meta.json → wrong via with_suffix, ivf_id_map.json, index}; the backend actually writes documents.leann.meta.json, documents.ivf_id_map.json and documents.index (id map and FAISS file use the stem without .leann). Test fixtures now mirror the real layout. * fix(cli): harden incremental build/changes/verify error paths from PR #2 review - refuse empty-delta snapshot commit when document loads failed (masked data loss) - changes: exit 1 on missing index, empty scope, or wrong --sync-key - corrupt sync snapshot / sync_roots.json fail loud; --force resets - sync: unreadable existing file keeps previous hash instead of 'removed' - verify: no crash on non-numeric id-map keys; no misleading findings when idx unreadable - drop dead 'changes --json' flag; register project dir on empty-delta commit - changelog entry for the PR's features * fix: address Gemini review findings for PR #2 - abort incremental build before mutation on any document load failure - verify: snapshot presence/unpickle checks, IVF type + mapped-id invlists check, type validation for decodable-but-malformed artifacts - verify test fixture builds a real IndexIVFFlat (was IndexFlatL2) - sync: os.walk onerror fails loud instead of mass-removal on unreadable subtree - changes: index-existence check applies with --docs too - sync_roots.json wrong-JSON-type handled by the --force recovery path 7 issues fixed, 0 false positives, 0 skipped See .doc/pr-review-comments/PR-2-multi-invocation-incremental.md * fix: watch survives fail-loud errors; changes rejects key on unkeyed index * fix: CI lint/type errors in new tests
With two faiss SWIG builds loaded in one process (faiss-cpu + the hnsw bundle, as on CI), downcast_index silently loses invlists access and the mapped-id check was skipped, returning a false healthy verdict. A subprocess loads exactly one build.
ubuntu-22.04's apt patchelf (0.14.3) fell below current auditwheel's >=0.14.5 floor, failing every Linux wheel-repair job.
|
Note on the Linux build failures: they are unrelated to this PR's code — current I've folded a one-line fix into this branch (ec83163): pip-install (The earlier macos failure was a real bug in this PR's |
* fix(verify): allow duplicate content-hash passage ids (#5) Content-hash ids are legitimately many-to-one for byte-identical chunks; verify's bijection invariants flagged healthy indexes. - jsonl dup ids allowed only when text is identical per id - idx cardinality compared against unique jsonl ids - IVF passage_to_id checked as last-wins partial inverse over deduped id_to_passage values * docs: changelog entry for verify duplicate-id fix (#5) * fix(verify): harden duplicate-id conflict check against mixed types Codex review of PR #6: type-prefixed text hash so 1 vs "1" counts as different text, and type-independent sort of conflicting ids so mixed int/str ids report findings instead of raising TypeError.
|
Appended |
|
@ww2283 Can we fix CI? |
|
@ASuresh0524 of course, the ci is too comprehensive:) |
andylizf
left a comment
There was a problem hiding this comment.
I read the whole diff. The production surface is small (506 of the 2,181 added lines, in cli.py, sync.py and registry.py; the rest is tests) and the comment on each guard saying what it defends against made it easy to follow. Four things before I approve.
- test_env_unset_registers_project fails on Windows, and it is new in this PR. All four windows-2022 jobs (3.11 through 3.14) report 1 failed, 389 passed, 71 skipped on it; macOS and Linux are green, so nothing else in the suite is unhappy. The failure is AssertionError: assert False, where the false term is WindowsPath('C:/Users/runneradmin/AppData/Local/Temp/pytest-of-runneradmin/pytest-0/test_env_unset_registers_proje0/home/.leann/projects.json').exists().
The fake_home fixture sets only HOME, but register_project_directory resolves the registry with Path.home(), and on Windows that reads USERPROFILE and ignores HOME. So the call writes into the runner's real home and the assertion looks in the temp dir. Setting USERPROFILE alongside HOME in the fixture fixes it.
Same fixture, second effect worth fixing at the same time: test_no_register_env_set_skips_registration passes vacuously on Windows, because it asserts the absence of a path that nothing was going to write to either way. As it stands it cannot catch a regression in LEANN_NO_REGISTER there.
- The --sync-key contract in the description is not the one in the code. The description says two builds with different --docs lists and the same key share one snapshot, with the files common to both embedded once. --help says the snapshot is "independent of the exact --docs invocation". Both read as: pass whatever subset this leg covers, the key holds on to the rest.
What the code does: _create_synchronizers builds the keyed manifest from the current invocation's --docs alone, then hands it to FileSynchronizer(explicit_files=...) against the shared keyed snapshot. detect_changes classifies everything in the snapshot but absent from that manifest as removed, and the build deletes those chunks. test_directory_dropped_from_docs_triggers_rebuild_with_remaining_files_only asserts exactly that, so the behaviour is deliberate.
The real contract is then "every invocation passes the cumulative list", and a caller who reads the motivation and passes leg-specific lists silently loses the earlier legs. Could you spell that out in --help and the changelog, and reword the sentence about differing --docs lists? test_same_key_different_docs_lists_share_snapshot_and_load_only_new_files covers {a,b} then {a,b,c}, which stays inside the contract, so no test currently contradicts the docs.
-
The _load_errors guard only covers the incremental branch. The check sits right after load_documents on the incremental path. Two routes get past it: a first build or --force, which never enters that path; and the rebuild fallback, whose else branch calls load_documents a second time, resetting _load_errors to 0 before the full rebuild. Both end at fs.create_snapshot() with no check in between, so a file that failed to load is hashed into the snapshot as if indexed, and the next incremental run sees no change and never retries it. That is the state the guard exists to prevent. test_loader_failure_does_not_advance_snapshot, test_swallowed_loader_failure_blocks_snapshot_commit and test_partial_loader_failure_aborts_before_mutating_index all build once and then add files, so all three sit on the incremental branch.
-
Dropping self.indexes_dir.mkdir(...) from LeannCLI.init is a default behaviour change. Minor, and I think an improvement, since leann no longer creates .leann/indexes/ in whatever directory you happen to run any subcommand from. But the description says there are no default behaviour changes and the changelog does not mention it, while test_cli_construction_does_not_create_indexes_dir_in_cwd shows it is deliberate. It deserves a changelog line. I checked the callers: build_index does its own mkdir(parents=True, exist_ok=True) and the read paths guard with .exists(), so nothing breaks.
The patchelf>=0.14.5 pin that came along is unrelated to this PR but correct, and Linux is green again.
One process note: the branch conflicts with main now. cli.py has moved twice since you last pushed (#395 on 08-21, #269 on 09-05), so this needs a rebase and a fresh CI run before it can go in.
Motivation
Callers that split large index builds into checkpointed multi-leg invocations (e.g. automation driving
leann buildwith varying--docslists) hit two limitations:--docslist — any variation between invocations creates a fresh snapshot namespace, losing all Merkle incrementality: unchanged files re-embed and duplicate.Changes (all opt-in / additive; no default behavior changes)
leann build --sync-key <key>— caller-supplied stable snapshot identity. Two builds with different--docslists but the same key share one snapshot; files common to both are embedded once. Mismatched keys are rejected unless--forcerekeys.leann changes <index> [--docs ...] [--sync-key K]— non-mutating Merkle diff; prints one JSON doc{added, modified, removed}(sorted) on stdout, exit 0. Errors (exit 1) on missing index, empty sync scope, wrong sync key, or corrupt snapshot rather than reporting a false clean delta.leann verify <index>— cross-artifact integrity check: passages.jsonl vs offsets pickle, IVF id-map exact inversion, FAISSntotalvs id-map cardinality, mapped ids present in the inverted lists,next_idmonotonicity, sync snapshots unpickle. Exit 0 healthy / 1 with findings.LEANN_NO_REGISTER=1— skip global~/.leann/projects.jsonregistration, for automation running builds in disposable directories.sync_roots.jsonfail loud on build (--forceresets); a transiently unreadable file keeps its previous hash instead of being classified as removed (which deleted its chunks); unreadable subtrees abort change detection instead of reading as mass removal;leann watchskips a tick on these errors instead of dying.Testing
40+ new tests across
tests/test_sync_key.py,test_cli_changes.py,test_cli_verify.py,test_zero_chunk_commit.py,test_no_register.py, plus additions totest_sync.py/test_watch_sync_scope.py. The verify fixtures build a realIndexIVFFlat+DirectMap.Hashtable. The changes went through three review rounds (two independent reviewer passes + a re-review) on our fork before this PR; 17 findings fixed.Rebased onto current main (dc85934);
import leann+ the touched suites pass and ruff is clean on this branch.