Skip to content

Commit 94be485

Browse files
authored
Merge pull request DeusData#1045 from DeusData/fix/937-watcher-dirty-signature
fix(watcher): reindex dirty repos once per distinct state, not per poll
2 parents 1c42569 + 9789a7c commit 94be485

4 files changed

Lines changed: 367 additions & 60 deletions

File tree

‎src/main.c‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -159,16 +159,18 @@ static void *http_thread(void *arg) {
159159
static int watcher_index_fn(const char *project_name, const char *root_path, void *user_data) {
160160
(void)user_data;
161161

162-
/* Skip indexing if shutdown is in progress */
162+
/* Skip indexing if shutdown is in progress (skipped, not indexed) */
163163
if (atomic_load(&g_shutdown)) {
164-
return 0;
164+
return 1;
165165
}
166166

167-
/* Non-blocking: skip if another pipeline is already running.
168-
* Watcher will retry on next poll cycle (5-60s). */
167+
/* Non-blocking: skip if another pipeline is already running. The
168+
* positive return keeps the watcher's baselines uncommitted so the
169+
* change is retried on the next poll cycle (5-60s) instead of being
170+
* recorded as seen and lost (#937). */
169171
if (!cbm_pipeline_try_lock()) {
170172
cbm_log_info("watcher.skip", "project", project_name, "reason", "pipeline_busy");
171-
return 0;
173+
return 1;
172174
}
173175

174176
cbm_log_info("watcher.reindex", "project", project_name, "path", root_path);

‎src/watcher/watcher.c‎

Lines changed: 175 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,15 @@
77
*
88
* Per-project state tracks:
99
* - Last git HEAD hash (detects commits, checkout, pull)
10+
* - Dirty-state signature (#937): porcelain entries + per-file size/mtime,
11+
* so a persistently dirty tree reindexes once per distinct state instead
12+
* of on every poll (write amplification)
1013
* - Last poll time + adaptive interval
1114
* - Whether the project is a git repo
1215
*
16+
* Baselines are committed only after a successful reindex; busy-skips and
17+
* failed runs leave them untouched so the change is retried, never lost.
18+
*
1319
* Adaptive interval: 5s base + 1s per 500 files, capped at 60s.
1420
* Matches the Go watcher's `pollInterval()` logic.
1521
*/
@@ -38,14 +44,22 @@
3844
typedef struct {
3945
char *project_name;
4046
char *root_path;
41-
char last_head[CBM_SZ_64]; /* git HEAD hash */
47+
char last_head[CBM_SZ_64]; /* git HEAD hash (committed baseline) */
4248
bool is_git; /* false → skip polling */
4349
bool baseline_done; /* true after first poll */
4450
int missing_root_count; /* consecutive polls where root was missing (ENOENT/ENOTDIR) */
4551
uint64_t first_missing_ms; /* cbm_now_ms() of the streak's first miss (0 = no streak) */
4652
int file_count; /* approximate, for interval calc */
4753
int interval_ms; /* adaptive poll interval */
4854
int64_t next_poll_ns; /* next poll time (monotonic ns) */
55+
/* Dirty-state signature (#937): a persistently dirty worktree must
56+
* reindex once per DISTINCT dirty state, not on every poll. Baselines
57+
* are committed only after a SUCCESSFUL reindex (busy-skips and failed
58+
* runs retry); check_changes stages its observations in the pending_*
59+
* fields. 0 = clean tree. */
60+
uint64_t last_dirty_sig; /* committed dirty-state signature */
61+
uint64_t pending_dirty_sig; /* observed at check time */
62+
char pending_head[CBM_SZ_64]; /* HEAD observed at check time */
4963
} project_state_t;
5064

5165
/* ── Watcher struct ─────────────────────────────────────────────── */
@@ -150,64 +164,144 @@ static int git_head(const char *root_path, char *out, size_t out_size) {
150164
return CBM_NOT_FOUND;
151165
}
152166

153-
/* Returns true if working tree has changes (modified, untracked, etc.).
154-
* Also checks submodules via `git submodule foreach` to detect uncommitted
155-
* changes inside submodules that `git status` alone would not report. */
156-
static bool git_is_dirty(const char *root_path) {
167+
/* ── Dirty-state signature (#937) ───────────────────────────────── */
168+
169+
#define SIG_FNV_OFFSET 1469598103934665603ULL
170+
#define SIG_FNV_PRIME 1099511628211ULL
171+
172+
static uint64_t sig_fold(uint64_t h, const void *data, size_t len) {
173+
const unsigned char *p = data;
174+
for (size_t i = 0; i < len; i++) {
175+
h ^= p[i];
176+
h *= SIG_FNV_PRIME;
177+
}
178+
return h;
179+
}
180+
181+
/* Platform-portable mtime_ns (mirrors pipeline_incremental.c). */
182+
static int64_t sig_stat_mtime_ns(const struct stat *st) {
183+
#ifdef __APPLE__
184+
return ((int64_t)st->st_mtimespec.tv_sec * NS_PER_SEC) + (int64_t)st->st_mtimespec.tv_nsec;
185+
#elif defined(_WIN32)
186+
return (int64_t)st->st_mtime * NS_PER_SEC;
187+
#else
188+
return ((int64_t)st->st_mtim.tv_sec * NS_PER_SEC) + (int64_t)st->st_mtim.tv_nsec;
189+
#endif
190+
}
191+
192+
/* Fold a listed path's (size, mtime) into the signature so an in-place edit
193+
* of an already-dirty file still produces a new signature. A failed stat
194+
* (deleted file, quoting artifact) degrades to the entry text alone — the
195+
* deletion itself is represented by the porcelain status. */
196+
static uint64_t sig_fold_path_stat(uint64_t h, const char *root_path, const char *rel) {
197+
char abs[CBM_SZ_4K];
198+
snprintf(abs, sizeof(abs), "%s/%s", root_path, rel);
199+
struct stat st;
200+
if (stat(abs, &st) == 0) {
201+
int64_t mt = sig_stat_mtime_ns(&st);
202+
int64_t sz = (int64_t)st.st_size;
203+
h = sig_fold(h, &mt, sizeof(mt));
204+
h = sig_fold(h, &sz, sizeof(sz));
205+
}
206+
return h;
207+
}
208+
209+
/* Signature of the current dirty state: FNV-1a over the entries of
210+
* `git status --porcelain -uall -z` plus each listed path's (size, mtime).
211+
* Returns 0 for a clean tree. Two polls over an untouched dirty tree yield
212+
* the same value; editing a dirty file, adding/removing one, or reverting
213+
* the tree to clean each yield a new one. -uall lists files inside
214+
* untracked directories individually (a nested addition under `?? dir/`
215+
* would otherwise be invisible); -z gives unquoted NUL-separated paths that
216+
* hash identically across polls and stat cleanly. */
217+
static uint64_t git_dirty_signature(const char *root_path) {
157218
char cmd[CBM_SZ_1K];
158-
snprintf(cmd, sizeof(cmd),
159-
"git --no-optional-locks -C \"%s\" status --porcelain "
160-
"--untracked-files=normal 2>%s",
219+
snprintf(cmd, sizeof(cmd), "git --no-optional-locks -C \"%s\" status --porcelain -uall -z 2>%s",
161220
root_path, WATCHER_NULDEV);
162221
FILE *fp = cbm_popen(cmd, "r");
163222
if (!fp) {
164-
return false;
223+
return 0;
165224
}
166225

167-
char line[CBM_SZ_256];
168-
bool dirty = false;
169-
if (fgets(line, sizeof(line), fp)) {
170-
size_t len = strlen(line);
171-
while (len > 0 && (line[len - SKIP_ONE] == '\n' || line[len - SKIP_ONE] == '\r')) {
172-
line[--len] = '\0';
226+
uint64_t h = SIG_FNV_OFFSET;
227+
bool any = false;
228+
char entry[CBM_SZ_4K];
229+
size_t elen = 0;
230+
bool overflow = false;
231+
/* Rename/copy entries are followed by a second NUL token (the origin
232+
* path) that carries no XY prefix — fold it as text, don't stat it. */
233+
bool origin_token = false;
234+
int c;
235+
for (;;) {
236+
c = fgetc(fp);
237+
if (c != EOF && c != '\0') {
238+
if (elen + 1 < sizeof(entry)) {
239+
entry[elen] = (char)c;
240+
} else {
241+
overflow = true; /* keep hashing prefix; skip stat for entry */
242+
}
243+
elen++;
244+
continue;
245+
}
246+
size_t stored = elen < sizeof(entry) ? elen : sizeof(entry) - 1;
247+
entry[stored] = '\0';
248+
if (stored > 0) {
249+
any = true;
250+
h = sig_fold(h, entry, stored);
251+
h = sig_fold(h, "", 1); /* token separator */
252+
if (origin_token) {
253+
origin_token = false;
254+
} else if (!overflow && stored > 3 && entry[2] == ' ') {
255+
if (entry[0] == 'R' || entry[0] == 'C') {
256+
origin_token = true;
257+
}
258+
h = sig_fold_path_stat(h, root_path, entry + 3);
259+
}
173260
}
174-
if (len > 0) {
175-
dirty = true;
261+
elen = 0;
262+
overflow = false;
263+
if (c == EOF) {
264+
break;
176265
}
177266
}
178267
cbm_pclose(fp);
179268

180-
if (dirty) {
181-
return true;
182-
}
183-
184269
#if !defined(_WIN32)
185-
/* Check submodules: uncommitted changes inside a submodule are invisible
186-
* to the parent's git status. Use `git submodule foreach` as a portable
187-
* fallback (Apple Git lacks --recurse-submodules). POSIX-only: foreach takes
188-
* an inner shell command that cmd.exe cannot pass intact; the parent-repo
189-
* status check above already covers the common (non-submodule) case. */
270+
/* Submodules (POSIX-only, mirroring git_is_dirty): fold each submodule's
271+
* porcelain output with paths re-rooted at the superproject so they stat
272+
* correctly. Line mode here (foreach output is line-oriented); rename
273+
* lines fail the stat and degrade to text-only. */
190274
snprintf(cmd, sizeof(cmd),
191275
"git --no-optional-locks -C '%s' submodule foreach --quiet --recursive "
192-
"'git status --porcelain --untracked-files=normal 2>/dev/null' "
193-
"2>/dev/null",
276+
"'git status --porcelain -uall 2>/dev/null | "
277+
"sed -e \"s@^\\(..\\) @\\1 $displaypath/@\"' 2>/dev/null",
194278
root_path);
195279
fp = cbm_popen(cmd, "r");
196-
if (!fp) {
197-
return false;
198-
}
199-
if (fgets(line, sizeof(line), fp)) {
200-
size_t len = strlen(line);
201-
while (len > 0 && (line[len - SKIP_ONE] == '\n' || line[len - SKIP_ONE] == '\r')) {
202-
line[--len] = '\0';
203-
}
204-
if (len > 0) {
205-
dirty = true;
280+
if (fp) {
281+
char line[CBM_SZ_4K];
282+
while (fgets(line, sizeof(line), fp)) {
283+
size_t len = strlen(line);
284+
while (len > 0 && (line[len - SKIP_ONE] == '\n' || line[len - SKIP_ONE] == '\r')) {
285+
line[--len] = '\0';
286+
}
287+
if (len == 0) {
288+
continue;
289+
}
290+
any = true;
291+
h = sig_fold(h, line, len);
292+
h = sig_fold(h, "", 1);
293+
if (len > 3 && line[2] == ' ') {
294+
h = sig_fold_path_stat(h, root_path, line + 3);
295+
}
206296
}
297+
cbm_pclose(fp);
207298
}
208-
cbm_pclose(fp);
209299
#endif
210-
return dirty;
300+
301+
if (!any) {
302+
return 0;
303+
}
304+
return h ? h : 1; /* reserve 0 for "clean" */
211305
}
212306

213307
/* Count tracked files via git ls-files */
@@ -497,6 +591,11 @@ static void init_baseline(project_state_t *s) {
497591

498592
if (s->is_git) {
499593
git_head(s->root_path, s->last_head, sizeof(s->last_head));
594+
/* last_dirty_sig stays 0 ("clean known"): a tree that is ALREADY
595+
* dirty at baseline reindexes once on the first poll — the watcher
596+
* cannot know whether that state made it into the DB (e.g. server
597+
* restart with a stale artifact). At-least-once, then the signature
598+
* gates further polls (#937). */
500599
s->file_count = git_file_count(s->root_path);
501600
s->interval_ms = cbm_watcher_poll_interval_ms(s->file_count);
502601
cbm_log_info("watcher.baseline", "project", s->project_name, "strategy", "git", "files",
@@ -508,25 +607,41 @@ static void init_baseline(project_state_t *s) {
508607
s->next_poll_ns = now_ns() + ((int64_t)s->interval_ms * US_PER_MS);
509608
}
510609

511-
/* Check if a project has changes. Returns true if reindex needed. */
610+
/* Check if a project has changes. Returns true if reindex needed.
611+
* Must NOT mutate the committed baselines (last_head, last_dirty_sig):
612+
* poll_project commits them only after a SUCCESSFUL reindex so that
613+
* busy-skips and failed runs retry instead of silently losing the change
614+
* (#937). Observations are staged in the pending_* fields. */
512615
static bool check_changes(project_state_t *s) {
513616
if (!s->is_git) {
514617
return false;
515618
}
516619

517-
/* Check HEAD movement */
620+
bool changed = false;
621+
622+
/* Check HEAD movement (commit, checkout, pull) */
623+
s->pending_head[0] = '\0';
518624
char head[CBM_SZ_64] = {0};
519625
if (git_head(s->root_path, head, sizeof(head)) == 0) {
520-
if (s->last_head[0] != '\0' && strcmp(head, s->last_head) != 0) {
521-
/* HEAD moved — commit, checkout, pull */
522-
strncpy(s->last_head, head, sizeof(s->last_head) - 1);
523-
return true;
626+
if (s->last_head[0] == '\0') {
627+
/* First observed HEAD: adopt as baseline, not a change. */
628+
snprintf(s->last_head, sizeof(s->last_head), "%s", head);
629+
} else if (strcmp(head, s->last_head) != 0) {
630+
changed = true;
524631
}
525-
strncpy(s->last_head, head, sizeof(s->last_head) - 1);
632+
snprintf(s->pending_head, sizeof(s->pending_head), "%s", head);
633+
}
634+
635+
/* Working tree: reindex only when the DIRTY STATE ITSELF changed —
636+
* a persistently dirty tree polled while idle must not re-trigger
637+
* full reindex/write cycles (#937 write amplification). */
638+
uint64_t sig = git_dirty_signature(s->root_path);
639+
if (sig != s->last_dirty_sig) {
640+
changed = true;
526641
}
642+
s->pending_dirty_sig = sig;
527643

528-
/* Check working tree */
529-
return git_is_dirty(s->root_path);
644+
return changed;
530645
}
531646

532647
/* Context for poll_once foreach callback */
@@ -637,11 +752,20 @@ static void poll_project(const char *key, void *val, void *ud) {
637752
int rc = ctx->w->index_fn(s->project_name, s->root_path, ctx->w->user_data);
638753
if (rc == 0) {
639754
ctx->reindexed++;
640-
/* Update HEAD after successful reindex */
641-
git_head(s->root_path, s->last_head, sizeof(s->last_head));
755+
/* Commit the baselines OBSERVED AT CHECK TIME — the state whose
756+
* reindex just succeeded. A commit/edit landing during the
757+
* reindex is deliberately not absorbed: the next poll sees it
758+
* as a new delta (at-least-once, never lost). */
759+
if (s->pending_head[0] != '\0') {
760+
snprintf(s->last_head, sizeof(s->last_head), "%s", s->pending_head);
761+
}
762+
s->last_dirty_sig = s->pending_dirty_sig;
642763
/* Refresh file count for interval */
643764
s->file_count = git_file_count(s->root_path);
644765
s->interval_ms = cbm_watcher_poll_interval_ms(s->file_count);
766+
} else if (rc > 0) {
767+
/* Busy-skip: baseline stays uncommitted, next poll retries. */
768+
cbm_log_info("watcher.index.retry", "project", s->project_name);
645769
} else {
646770
cbm_log_warn("watcher.index.err", "project", s->project_name);
647771
}

‎src/watcher/watcher.h‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,11 @@ typedef struct cbm_watcher cbm_watcher_t;
2222

2323
/* ── Index callback ─────────────────────────────────────────────── */
2424

25-
/* Called when file changes are detected. Return 0 on success, -1 on error.
25+
/* Called when file changes are detected. Return 0 on success, a POSITIVE
26+
* value when the reindex was skipped and should be retried on the next poll
27+
* (e.g. another pipeline holds the lock), negative on error. Only a 0 return
28+
* commits the watcher's change baselines — a skipped or failed reindex keeps
29+
* the change pending so it is retried, never silently lost (#937).
2630
* project_name: project identifier
2731
* root_path: absolute path to the repository root */
2832
typedef int (*cbm_index_fn)(const char *project_name, const char *root_path, void *user_data);

0 commit comments

Comments
 (0)