Skip to content

refactor(incremental): batch + single-stat persist_hashes (2/3)#869

Open
moofone wants to merge 9 commits into
DeusData:mainfrom
moofone:fix/incremental-batch-persist
Open

refactor(incremental): batch + single-stat persist_hashes (2/3)#869
moofone wants to merge 9 commits into
DeusData:mainfrom
moofone:fix/incremental-batch-persist

Conversation

@moofone

@moofone moofone commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Tracking issue: #867 (Refs #867). This is part 2 of 3, stacked on #868. Review only commit refactor(incremental): persist hashes in one batch from classify-time stamps (the P1 commit is tracked by #868).

What & why

persist_hashes (src/pipeline/pipeline_incremental.c) re-stat()ed every discovered file after the run and upserted hash rows one at a time (one implicit SQLite write transaction per file). Two problems:

  1. Wasted workclassify_files had already stat()ed every file with a stored hash.
  2. Correctness bug — re-stat'ing after the run records the post-run mtime, so a file edited mid-index is stamped "current" and the next run misses the edit. Using the classify-time stat is both faster and more correct.

Change

  • classify_files now outputs a cbm_file_stamp_t array (one stat() per file at classify time, including for new files).
  • persist_hashes builds one cbm_file_hash_t array from files[]+stamps[] plus mode_skipped[] and upserts it via the existing-but-unused cbm_store_upsert_file_hash_batch (single BEGIN…COMMIT).
  • On batch failure (it rolls back on first row error) it falls back to a row-at-a-time loop so one bad row still can't lose all persistence — preserving the documented "partial preservation beats total loss" contract.
  • stamps flows classify_files → cbm_pipeline_run_incremental → dump_and_persist → persist_hashes (all static, file-local signature changes), freed at every return path (noop / load_db_failed / normal).

Tests (tests/test_artifact.c)

  • incremental_persist_batch_roundtrip — exercises the batch path via the real pipeline route (full index → modify → incremental reindex → noop), asserts the persisted mtime equals the classify-time mtime and the subsequent run classifies no-change.
  • The existing incremental suite (incr_modify_file / incr_add_file / incr_delete_file / incr_noop_reindex, ~5900 cases) is the regression guard.

scripts/test.sh (ASan/UBSan) green locally.

Benchmark (S3 = steady-state incremental, K = 5 modified)

N baseline wall (export ran) candidate wall (export skipped*)
500 297 ms 226 ms
2000 673 ms 540 ms
8000 2290 ms 1755 ms

Candidate ≤ baseline at every size. (*the export-skip is P3, tracked by part 3; P2's contribution is the batched, single-stat persist.) S4 (noop) and S1 (full) unchanged within noise.

Checklist

  • C only; conventional commit; DCO signed.
  • scripts/test.sh (ASan/UBSan) green; no regressions in the incremental suite.
  • No new popen/system/network calls (no security-allowlist change needed).
  • scripts/lint.sh — not installed locally; verified by hand, CI is the gate.

Commit message notes the mid-run-edit correctness fix.

moofone added 2 commits July 4, 2026 20:13
Artifact bootstrap byte-copies a teammate's DB into the local cache, but the
imported file_hashes rows carry the exporter's mtime_ns. A fresh clone stamps
every file with checkout time, so classify_files (mtime_ns + size only) marks
~every file changed and the first incremental run re-parses the whole repo --
the exact run artifact bootstrap exists to make cheap.

Add cbm_artifact_reconcile_hashes(), called from try_artifact_bootstrap right
after a successful cbm_artifact_import. It re-stamps the hash rows of files
git reports unchanged between the artifact's commit and the local working tree
with local stat() values, so the existing, untouched classify_files logic then
classifies correctly. Zero changes to the incremental pipeline itself.

Trust gate (so a stale/corrupt artifact can never mark a genuinely changed
file unchanged -> graph corruption): cbm_artifact_export now writes an optional
"reconcile_basis":"git-clean-head" marker into the existing artifact.json ONLY
when it can prove the DB matches a clean checked-out tree at `commit` --
commit is a validated hex OID, the working tree has no changes outside
.codebase-memory, and every file_hashes row's on-disk mtime+size matches.
Reconciliation requires the marker and skips (returns -1) on any doubt: no git,
untrusted/missing marker, non-hex/unknown commit, shallow clone, or any popen
or parse uncertainty. A skip leaves rows foreign and falls back to today's
slow-safe full incremental. No schema_version bump (older binaries ignore the
new optional field).

Security: commit is hex-validated before command construction and repo_path is
shell-validated via cbm_validate_shell_arg (same pattern as git_context.c /
watcher.c); git output is parsed as NUL-delimited (-z) directly, never via
line-oriented parsing, so paths with newlines/quotes are handled. New popen
call site added to scripts/security-allowlist.txt.

Reuses two pieces of existing dead/unused code: cbm_artifact_commit() (was
never called in production) and cbm_store_upsert_file_hash_batch() (existed but
was never called in production -- reconciliation persists all restamped rows in
one transaction).

Tests (tests/test_artifact.c, following the existing suite's structure):
export sets the marker on a clean tree and drops it when dirty; reconcile
restamps unchanged rows and leaves changed rows foreign; reconcile skips
(-1, rows untouched) on untrusted metadata, unknown commit, and no-git, plus
null-arg safety.

Signed-off-by: Greg Tiller <tiller@dal.ca>
… stamps

persist_hashes re-stat()ed every discovered file AFTER the run and upserted
rows one at a time (one implicit SQLite write transaction per file). Two
problems:

1. Wasted work: classify_files had already stat()ed every file with a stored
   hash. persist_hashes stat()ed them all again.
2. Correctness: re-stat'ing AFTER the run records the post-run mtime, so a file
   edited mid-index is stamped "current" and the NEXT run misses the edit
   (classifies it unchanged). Using the classify-time stat is both faster and
   more correct.

classify_files now outputs a cbm_file_stamp_t array (one stat per file at
classify time, including for new files). persist_hashes builds one
cbm_file_hash_t array from files[]+stamps[] plus mode_skipped[] and upserts it
via the existing-but-unused cbm_store_upsert_file_hash_batch (single
BEGIN...COMMIT). On batch failure (it rolls back on first row error) it falls
back to a row-at-a-time loop so one bad row still can't nuke all persistence --
preserving the documented "partial preservation beats total loss" contract.

Threading: stamps flows classify_files -> cbm_pipeline_run_incremental ->
dump_and_persist -> persist_hashes (all static, file-local signature changes),
freed at every return path (noop, load_db_failed, normal).

Test: incremental_persist_batch_roundtrip exercises the batch path via the real
pipeline route (full index -> modify -> incremental reindex -> noop) and
asserts the persisted mtime equals the classify-time mtime. The existing
incremental suite (incr_modify_file / incr_add_file / incr_delete_file /
incr_noop_reindex, ~5900 cases) is the regression guard.

Signed-off-by: Greg Tiller <tiller@dal.ca>
@DeusData DeusData added bug Something isn't working stability/performance Server crashes, OOM, hangs, high CPU/memory priority/normal Standard review queue; useful PR with ordinary maintainer urgency. labels Jul 5, 2026
@DeusData

DeusData commented Jul 5, 2026

Copy link
Copy Markdown
Owner

Thanks for keeping part 2 scoped. Triage: performance/correctness bug in the incremental path, stacked after #868.

Review should verify that classify-time stamps really close the mid-index edit miss without widening the race window, and that the batch write path keeps failure behavior conservative. Since this also carries the #868 base changes, we should review/merge in stack order.

moofone added 2 commits July 5, 2026 09:08
git diff/ls-files are blind to gitignored files, but a gitignored file can
still be indexed (.cbmignore negation un-skipping a generated dir, DeusData#500) and
thus carry a file_hashes row. Without a tracked-set gate such a row would be
restamped as "unchanged" even though git cannot vouch for its content,
leaving stale graph data after bootstrap.

Reconciliation now also captures git ls-tree -r -z --name-only <commit> and
restamps only rows tracked at the artifact commit AND absent from the changed
set; everything git cannot vouch for stays foreign and is re-parsed. Same
validation funnel (hex-validated commit, shell-validated repo path, NUL-
delimited parse with truncation guard); allowlist entry text extended to
mention ls-tree.

Adds regression test artifact_reconcile_skips_untracked_rows.

Refs DeusData#867

Signed-off-by: Greg Tiller <tiller@dal.ca>
@moofone

moofone commented Jul 5, 2026

Copy link
Copy Markdown
Contributor Author

On the two review questions from triage:

Does the classify-time stamp close the mid-index miss without widening the race window? Yes — the race can only narrow, by construction. The stamp for each file is taken before its content is parsed. So for any edit relative to the stamp time:

  • Edit before the stat → the parse (later) sees the edited content, and the stamp reflects the edited mtime. Consistent.
  • Edit after the stat (mid-index, the old bug's window) → the parsed content is newer than the stamp, so on the next run the file's actual mtime differs from the stored stamp → re-parse. The edit is caught one run later, never lost.

The old post-run re-stat had the opposite property: it stamped the post-edit mtime against pre-edit parsed content, so the next run compared equal and the edit was silently missed. There is no input ordering under which the new scheme misses an edit that the old one caught; the possible outcomes are "consistent" or "conservative re-parse".

Does the batch path keep failure behavior conservative? The batch upsert is a single BEGIN…COMMIT that rolls back on the first row error. On any non-OK return, persist_hashes logs incremental.persist_batch_failed and falls back to persist_hashes_row_by_row — the historical loop, preserved verbatim including its per-scope warnings — so one bad row still cannot lose all hash persistence (the documented "partial preservation beats total loss" contract). The same fallback covers batch-array OOM. A file that couldn't be stat'ed at classify time is skipped in both paths → re-parsed next run (conservative direction).

Test incremental_persist_batch_roundtrip drives the batch path through the real pipeline route and asserts the persisted mtime equals the classify-time mtime; the existing incremental suite (incr_modify_file/incr_add_file/incr_delete_file/incr_noop_reindex) is the regression guard.

Branch has been updated with the #868 base changes merged in (this PR's reviewable commit is unchanged: e9d4190).

moofone added 3 commits July 5, 2026 09:34
CI lint (clang-format-20 --dry-run --Werror) flagged comment alignment and
line-break style in the P1 additions. Formatting only; no code changes.

Refs DeusData#867

Signed-off-by: Greg Tiller <tiller@dal.ca>
CI lint (clang-format-20 --dry-run --Werror) flagged wrap style in the P2
signature changes. Formatting only; no code changes.

Refs DeusData#867

Signed-off-by: Greg Tiller <tiller@dal.ca>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working priority/normal Standard review queue; useful PR with ordinary maintainer urgency. stability/performance Server crashes, OOM, hangs, high CPU/memory

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants