Skip to content

Commit cee318b

Browse files
petercoxphotoDeusData
authored andcommitted
fix(incremental): preserve mode-skipped files and their hash rows (#251)
cbm_pipeline_run_incremental was destructively overwriting a project's graph when the index mode was downgraded. find_deleted_files classified ANY stored file absent from the current discovery list as deleted and purged its nodes via cbm_gbuf_delete_by_file. In a full → fast mode downgrade, directories matching FAST_SKIP_DIRS (tools, scripts, bin, build, docs, __tests__, migrations, ...) are excluded from discovery but still exist on disk — so they were getting silently destroyed. Real-world incident, 2026-04-13: the Skyline graph collapsed from 2819 nodes (full-mode nightly) to 2303 mid-day when a concurrent fast-mode reindex obliterated packages/mcp/src/tools/ (~500 nodes / 18 files). Fix: - find_deleted_files now stat()s each stored-but-missing file. Files that exist on disk are mode-skipped (nodes AND hash rows preserved); only ENOENT/ENOTDIR results purge. Other errnos (EACCES, EIO, ELOOP, transient NFS, ...) preserve. NULL repo_path preserves. snprintf truncation preserves. strdup OOM mid-record drops the entry rather than persist a NULL rel_path that would silently fail upsert. stat() not lstat() is intentional — the indexer follows symlinks during discovery so a stale symlink has no source to parse. - Mode-skipped hash rows are threaded through dump_and_persist → persist_hashes and re-upserted into the rebuilt DB. Without this, the next reindex's stored hashes wouldn't include the file → the deletion-detection contract breaks → orphaned graph nodes survive forever once the file is eventually deleted. - persist_hashes now checks the upsert return code on both the current-files loop and the new mode_skipped loop and logs a warning with scope/rel_path/rc on failure. Partial-failure policy: a single bad row no longer silently drops, but does not abort the whole reindex either — partial preservation beats total loss. Tests: - incremental_fast_preserves_mode_skipped_tools_dir walks the failure end-to-end across four phases: full index, fast reindex preserves the tools dir, mutate-and-fast-reindex exercises dump_and_persist's hash carry (NOT the noop fast-path that would hide bugs there), then delete-on-disk full reindex confirms the deletion-detection contract still holds. - store_file_hash_upsert_rejects_null_required_fields pins the API contract that cbm_store_upsert_file_hash returns CBM_STORE_ERR on NULL required fields, so a future schema change relaxing any NOT NULL is caught at the producer layer rather than letting the new warning go silent. Conflict resolution against current main: the dump_and_persist signature now takes both mode_skipped/mode_skipped_count (this PR) and repo_path (added on main for cbm_artifact_export); the call site passes cbm_pipeline_repo_path(p) alongside the mode_skipped pair. Full suite: 2837 passed, 0 failed under ASan + UBSan + LeakSanitizer. Closes #251. Co-authored-by: Peter Cox <info@petercox.ie>
1 parent 2d00c4a commit cee318b

3 files changed

Lines changed: 438 additions & 25 deletions

File tree

src/pipeline/pipeline_incremental.c

Lines changed: 245 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ enum { INCR_RING_BUF = 4, INCR_RING_MASK = 3, INCR_TS_BUF = 24, INCR_WAL_BUF = 1
2626
#include "foundation/compat_fs.h"
2727
#include "foundation/platform.h"
2828

29+
#include <errno.h>
2930
#include <stdlib.h>
3031
#include <string.h>
3132
#include <sys/stat.h>
@@ -121,48 +122,247 @@ static bool *classify_files(cbm_file_info_t *files, int file_count, cbm_file_has
121122
return changed;
122123
}
123124

124-
/* Find stored files that no longer exist on disk. Returns count. */
125-
static int find_deleted_files(cbm_file_info_t *files, int file_count, cbm_file_hash_t *stored,
126-
int stored_count, char ***out_deleted) {
125+
/* Classify stored files that are absent from current discovery. Returns the
126+
* count of truly-deleted files (output via out_deleted) and ALSO collects
127+
* mode-skipped files into out_mode_skipped (caller frees both).
128+
*
129+
* A stored file is classified as:
130+
* - "deleted" — `stat()` returns ENOENT or ENOTDIR. Its nodes will
131+
* be purged and its hash row dropped.
132+
* - "mode-skipped" — `stat()` succeeds. The file exists on disk but the
133+
* current discovery pass didn't visit it (e.g. excluded
134+
* by FAST_SKIP_DIRS in fast/moderate mode). Its nodes
135+
* must be preserved AND its hash row must be carried
136+
* forward into the new DB so subsequent reindexes can
137+
* still see it as "known" rather than treating it as
138+
* new-or-deleted.
139+
*
140+
* Without this distinction, a fast-mode reindex after a full-mode index
141+
* would silently purge every file under `tools/`, `scripts/`, `bin/`,
142+
* `build/`, `docs/`, `__tests__/`, etc. — see task
143+
* claude-connectors/codebase-memory-index-repository-is-destructive-...
144+
* and the 2026-04-13 Skyline incident (packages/mcp/src/tools/ vanished
145+
* from a live graph mid-session).
146+
*
147+
* Mode-skipped hash preservation is the second half of the additive-merge
148+
* contract: dump_and_persist re-upserts these hash rows so the next reindex
149+
* can correctly detect a real on-disk deletion of a mode-skipped file (as
150+
* opposed to seeing it as "never existed" → noop → orphaned graph nodes).
151+
*
152+
* Fail-safe rules (preserve nodes on uncertainty):
153+
* - repo_path NULL → log error and preserve everything (return 0
154+
* deletions, empty mode_skipped). The caller contract is that
155+
* repo_path is required; a NULL means a misconfigured pipeline,
156+
* not a deletion signal.
157+
* - snprintf truncation (combined path ≥ CBM_SZ_4K) → preserve. We can't
158+
* reliably stat a truncated path. Treat as mode-skipped.
159+
* - stat() errno != ENOENT/ENOTDIR (EACCES, EIO, ELOOP, transient NFS,
160+
* etc.) → preserve. The file may exist; we just can't see it right now.
161+
* Treat as mode-skipped.
162+
*
163+
* Note: we use stat() (not lstat()) on purpose. A symlink whose target was
164+
* deleted should be classified as deleted from the indexer's perspective
165+
* because the indexer follows symlinks during discovery — a stale symlink
166+
* has no source to parse. */
167+
static int find_deleted_files(const char *repo_path, cbm_file_info_t *files, int file_count,
168+
cbm_file_hash_t *stored, int stored_count, char ***out_deleted,
169+
cbm_file_hash_t **out_mode_skipped, int *out_mode_skipped_count) {
170+
*out_deleted = NULL;
171+
*out_mode_skipped = NULL;
172+
*out_mode_skipped_count = 0;
173+
174+
if (!repo_path) {
175+
/* Misconfigured pipeline. Preserve everything rather than risk
176+
* silently re-introducing the destructive overwrite this function
177+
* was rewritten to prevent. */
178+
cbm_log_error("incremental.err", "msg", "find_deleted_files_null_repo_path");
179+
return 0;
180+
}
181+
127182
CBMHashTable *current = cbm_ht_create((size_t)file_count * PAIR_LEN);
128183
for (int i = 0; i < file_count; i++) {
129184
cbm_ht_set(current, files[i].rel_path, &files[i]);
130185
}
131186

132-
int count = 0;
133-
int cap = CBM_SZ_64;
134-
char **deleted = malloc((size_t)cap * sizeof(char *));
187+
int del_count = 0;
188+
int del_cap = CBM_SZ_64;
189+
char **deleted = malloc((size_t)del_cap * sizeof(char *));
190+
if (!deleted) {
191+
cbm_log_error("incremental.err", "msg", "find_deleted_files_oom");
192+
cbm_ht_free(current);
193+
return 0;
194+
}
195+
196+
int ms_count = 0;
197+
int ms_cap = CBM_SZ_64;
198+
cbm_file_hash_t *mode_skipped = malloc((size_t)ms_cap * sizeof(cbm_file_hash_t));
199+
if (!mode_skipped) {
200+
cbm_log_error("incremental.err", "msg", "find_deleted_files_oom_ms");
201+
free(deleted);
202+
cbm_ht_free(current);
203+
return 0;
204+
}
135205

136206
for (int i = 0; i < stored_count; i++) {
137-
if (!cbm_ht_get(current, stored[i].rel_path)) {
138-
if (count >= cap) {
139-
cap *= PAIR_LEN;
140-
char **tmp = realloc(deleted, (size_t)cap * sizeof(char *));
207+
if (cbm_ht_get(current, stored[i].rel_path)) {
208+
continue; /* still visited by current pass */
209+
}
210+
/* Not in current discovery — check if it's truly deleted or just
211+
* mode-skipped (excluded by FAST_SKIP_DIRS etc.). */
212+
bool preserve = false;
213+
char abs_path[CBM_SZ_4K];
214+
int n = snprintf(abs_path, sizeof(abs_path), "%s/%s", repo_path, stored[i].rel_path);
215+
if (n < 0 || n >= (int)sizeof(abs_path)) {
216+
/* Truncation or encoding error — can't reliably stat. Preserve. */
217+
cbm_log_warn("incremental.path_truncated", "rel_path", stored[i].rel_path);
218+
preserve = true;
219+
} else {
220+
struct stat st;
221+
if (stat(abs_path, &st) == 0) {
222+
/* File exists on disk — mode-skipped, not deleted. */
223+
preserve = true;
224+
} else if (errno != ENOENT && errno != ENOTDIR) {
225+
/* Transient or permission error — fail safe by preserving.
226+
* EACCES, EIO, ELOOP, ENAMETOOLONG, etc. */
227+
cbm_log_warn("incremental.stat_uncertain", "rel_path", stored[i].rel_path, "errno",
228+
itoa_buf(errno));
229+
preserve = true;
230+
}
231+
}
232+
233+
if (preserve) {
234+
/* Carry forward the existing hash row so subsequent reindexes
235+
* can correctly classify this file. */
236+
if (ms_count >= ms_cap) {
237+
ms_cap *= PAIR_LEN;
238+
cbm_file_hash_t *tmp = realloc(mode_skipped, (size_t)ms_cap * sizeof(*tmp));
141239
if (!tmp) {
240+
cbm_log_error("incremental.err", "msg",
241+
"find_deleted_files_realloc_oom_ms");
142242
break;
143243
}
144-
deleted = tmp;
244+
mode_skipped = tmp;
145245
}
146-
deleted[count++] = strdup(stored[i].rel_path);
246+
char *rp = strdup(stored[i].rel_path);
247+
char *sh = stored[i].sha256 ? strdup(stored[i].sha256) : NULL;
248+
if (!rp || (stored[i].sha256 && !sh)) {
249+
/* OOM mid-record. Drop this entry rather than persist a
250+
* row with a NULL rel_path that would silently fail the
251+
* NOT NULL constraint in upsert and reintroduce the
252+
* orphaned-node bug. */
253+
cbm_log_error("incremental.err", "msg", "find_deleted_files_strdup_oom",
254+
"rel_path", stored[i].rel_path);
255+
free(rp);
256+
free(sh);
257+
break;
258+
}
259+
mode_skipped[ms_count].project = NULL; /* unused by upsert API */
260+
mode_skipped[ms_count].rel_path = rp;
261+
mode_skipped[ms_count].sha256 = sh;
262+
mode_skipped[ms_count].mtime_ns = stored[i].mtime_ns;
263+
mode_skipped[ms_count].size = stored[i].size;
264+
ms_count++;
265+
continue;
266+
}
267+
268+
/* File is truly gone — record for purge. */
269+
if (del_count >= del_cap) {
270+
del_cap *= PAIR_LEN;
271+
char **tmp = realloc(deleted, (size_t)del_cap * sizeof(char *));
272+
if (!tmp) {
273+
cbm_log_error("incremental.err", "msg", "find_deleted_files_realloc_oom");
274+
break;
275+
}
276+
deleted = tmp;
147277
}
278+
deleted[del_count++] = strdup(stored[i].rel_path);
148279
}
149280

150281
cbm_ht_free(current);
151282
*out_deleted = deleted;
152-
return count;
283+
*out_mode_skipped = mode_skipped;
284+
*out_mode_skipped_count = ms_count;
285+
return del_count;
286+
}
287+
288+
/* Free a mode_skipped array allocated by find_deleted_files. */
289+
static void free_mode_skipped(cbm_file_hash_t *ms, int count) {
290+
if (!ms) {
291+
return;
292+
}
293+
for (int i = 0; i < count; i++) {
294+
free((void *)ms[i].rel_path);
295+
free((void *)ms[i].sha256);
296+
}
297+
free(ms);
153298
}
154299

155300
/* ── Persist file hashes ─────────────────────────────────────────── */
156301

302+
/* Persist file hash rows for the current discovery and any mode-skipped
303+
* files preserved from the previous DB.
304+
*
305+
* Partial-failure policy: an `upsert` failure on any single row is logged
306+
* as a warning and the loop continues. We deliberately do NOT abort the
307+
* whole reindex on a single bad row — partial preservation is better than
308+
* total loss, and a transient failure on one file should not invalidate
309+
* the entire incremental update. The trade-off is that a silently-failed
310+
* row produces the same downstream effect as if the file were never
311+
* indexed at all (forced re-parse on the next run for current-files,
312+
* potential orphaned-node revival for mode_skipped). The warning surface
313+
* is the only signal that something went wrong. */
157314
static void persist_hashes(cbm_store_t *store, const char *project, cbm_file_info_t *files,
158-
int file_count) {
315+
int file_count, const cbm_file_hash_t *mode_skipped,
316+
int mode_skipped_count) {
317+
int current_failed = 0;
318+
int ms_failed = 0;
319+
320+
/* Current discovery: re-stat to capture any mtime/size that changed
321+
* during the run, and write fresh hash rows for visited files. */
159322
for (int i = 0; i < file_count; i++) {
160323
struct stat st;
161324
if (stat(files[i].path, &st) != 0) {
162325
continue;
163326
}
164-
cbm_store_upsert_file_hash(store, project, files[i].rel_path, "", stat_mtime_ns(&st),
165-
st.st_size);
327+
int rc = cbm_store_upsert_file_hash(store, project, files[i].rel_path, "",
328+
stat_mtime_ns(&st), st.st_size);
329+
if (rc != CBM_STORE_OK) {
330+
cbm_log_warn("incremental.persist_hash_failed", "scope", "current", "rel_path",
331+
files[i].rel_path, "rc", itoa_buf(rc));
332+
current_failed++;
333+
}
334+
}
335+
336+
/* Mode-skipped (preserved): re-upsert hash rows from the previous DB
337+
* so the next reindex can still classify these files correctly. Without
338+
* this, an orphaned-node bug emerges where:
339+
* - full mode indexes everything
340+
* - fast mode runs and drops mode-skipped hash rows
341+
* - file is then deleted on disk
342+
* - next reindex's stored hashes don't include the file → noop or
343+
* can't detect the deletion → graph nodes for the deleted file
344+
* remain forever (or until a destructive rebuild).
345+
*
346+
* A failure here is more serious than a current-files failure because
347+
* it can revive the orphaned-node bug for that specific file. Logged
348+
* with scope=mode_skipped so the warning is searchable. */
349+
if (mode_skipped) {
350+
for (int i = 0; i < mode_skipped_count; i++) {
351+
int rc = cbm_store_upsert_file_hash(store, project, mode_skipped[i].rel_path,
352+
mode_skipped[i].sha256 ? mode_skipped[i].sha256
353+
: "",
354+
mode_skipped[i].mtime_ns, mode_skipped[i].size);
355+
if (rc != CBM_STORE_OK) {
356+
cbm_log_warn("incremental.persist_hash_failed", "scope", "mode_skipped",
357+
"rel_path", mode_skipped[i].rel_path, "rc", itoa_buf(rc));
358+
ms_failed++;
359+
}
360+
}
361+
}
362+
363+
if (current_failed > 0 || ms_failed > 0) {
364+
cbm_log_warn("incremental.persist_summary", "current_failed", itoa_buf(current_failed),
365+
"mode_skipped_failed", itoa_buf(ms_failed));
166366
}
167367
}
168368

@@ -257,9 +457,14 @@ static void run_postpasses(cbm_pipeline_ctx_t *ctx, cbm_file_info_t *changed_fil
257457
itoa_buf((int)elapsed_ms(t)));
258458
}
259459
}
260-
/* Delete old DB and dump merged graph + hashes to disk. */
460+
/* Delete old DB and dump merged graph + hashes to disk.
461+
* Mode-skipped hash rows are preserved across the rebuild so subsequent
462+
* reindexes can correctly distinguish "never indexed" from "indexed but
463+
* not visited this pass". */
261464
static void dump_and_persist(cbm_gbuf_t *gbuf, const char *db_path, const char *project,
262-
cbm_file_info_t *files, int file_count, const char *repo_path) {
465+
cbm_file_info_t *files, int file_count,
466+
const cbm_file_hash_t *mode_skipped, int mode_skipped_count,
467+
const char *repo_path) {
263468
struct timespec t;
264469
cbm_clock_gettime(CLOCK_MONOTONIC, &t);
265470

@@ -277,7 +482,7 @@ static void dump_and_persist(cbm_gbuf_t *gbuf, const char *db_path, const char *
277482

278483
cbm_store_t *hash_store = cbm_store_open_path(db_path);
279484
if (hash_store) {
280-
persist_hashes(hash_store, project, files, file_count);
485+
persist_hashes(hash_store, project, files, file_count, mode_skipped, mode_skipped_count);
281486

282487
/* FTS5 rebuild after incremental dump. The btree dump path bypasses
283488
* any triggers that could have kept nodes_fts synchronized, so we
@@ -329,18 +534,27 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil
329534
bool *is_changed =
330535
classify_files(files, file_count, stored, stored_count, &n_changed, &n_unchanged);
331536

332-
/* Find deleted files */
537+
/* Classify stored files absent from current discovery: truly-deleted
538+
* (purge) vs mode-skipped (preserve nodes AND hash rows). */
333539
char **deleted = NULL;
334-
int deleted_count = find_deleted_files(files, file_count, stored, stored_count, &deleted);
540+
cbm_file_hash_t *mode_skipped = NULL;
541+
int mode_skipped_count = 0;
542+
int deleted_count =
543+
find_deleted_files(cbm_pipeline_repo_path(p), files, file_count, stored, stored_count,
544+
&deleted, &mode_skipped, &mode_skipped_count);
335545

336546
cbm_log_info("incremental.classify", "changed", itoa_buf(n_changed), "unchanged",
337-
itoa_buf(n_unchanged), "deleted", itoa_buf(deleted_count));
547+
itoa_buf(n_unchanged), "deleted", itoa_buf(deleted_count), "mode_skipped",
548+
itoa_buf(mode_skipped_count));
338549

339-
/* Fast path: nothing changed → skip */
550+
/* Fast path: nothing changed → skip. The on-disk DB is left untouched,
551+
* which means existing hash rows (including for any mode-skipped files
552+
* that were already preserved by an earlier run) remain intact. */
340553
if (n_changed == 0 && deleted_count == 0) {
341554
cbm_log_info("incremental.noop", "reason", "no_changes");
342555
free(is_changed);
343556
free(deleted);
557+
free_mode_skipped(mode_skipped, mode_skipped_count);
344558
cbm_store_free_file_hashes(stored, stored_count);
345559
cbm_store_close(store);
346560
return 0;
@@ -380,6 +594,7 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil
380594
free(deleted[i]);
381595
}
382596
free(deleted);
597+
free_mode_skipped(mode_skipped, mode_skipped_count);
383598
cbm_store_close(store);
384599
return CBM_NOT_FOUND;
385600
}
@@ -435,8 +650,13 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil
435650
cbm_registry_free(registry);
436651
cbm_path_alias_collection_free(path_aliases);
437652

438-
/* Step 7: Dump to disk */
439-
dump_and_persist(existing, db_path, project, files, file_count, cbm_pipeline_repo_path(p));
653+
/* Step 7: Dump to disk (preserves mode-skipped hash rows so the next
654+
* reindex can correctly classify those files instead of seeing them
655+
* as never-existed; also exports a fast-mode artifact when one is
656+
* already present alongside the repo). */
657+
dump_and_persist(existing, db_path, project, files, file_count, mode_skipped,
658+
mode_skipped_count, cbm_pipeline_repo_path(p));
659+
free_mode_skipped(mode_skipped, mode_skipped_count);
440660
cbm_gbuf_free(existing);
441661

442662
cbm_log_info("incremental.done", "elapsed_ms", itoa_buf((int)elapsed_ms(t0)));

0 commit comments

Comments
 (0)