From 5c54201ef8ed8903ffdaf8f417308c9f679aa09c Mon Sep 17 00:00:00 2001 From: Shane McCarron Date: Thu, 29 Oct 2026 11:46:21 -0500 Subject: [PATCH 1/2] fix(watcher): pace re-indexes by the cost of the last successful run After a successful re-index the next poll waits max(interval, 3 x index duration), capped at 30 min, counted from when the index finished. A project whose full rebuild takes minutes no longer re-indexes back-to-back under continuous writes; indexing spends at most ~1/4 of wall time. Sub-second indexes keep the existing cadence; failure backoff and busy-skip are unchanged. Logs watcher.cooldown when the delay is extended. Refs #2835 Signed-off-by: Shane McCarron --- src/watcher/watcher.c | 44 +++++++++++++-- src/watcher/watcher.h | 9 ++++ tests/test_watcher.c | 123 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 172 insertions(+), 4 deletions(-) diff --git a/src/watcher/watcher.c b/src/watcher/watcher.c index ac7c47b7ff..cef4fd4829 100644 --- a/src/watcher/watcher.c +++ b/src/watcher/watcher.c @@ -154,6 +154,16 @@ struct cbm_watcher { * daemon log names the stuck project instead of only repeating the warning. */ #define INDEX_FAIL_SUSTAINED 10 +/* Success cooldown (#2835): after a successful re-index the next poll waits + * at least INDEX_COOLDOWN_FACTOR x the index's own duration, counted from when + * the index finished, so the watcher spends at most ~1/(K+1) of wall time + * indexing one project — a 5-minute rebuild idles 15 minutes. Sub-second + * indexes stay at the plain cadence. The ceiling bounds staleness after one + * pathological run; it is NOT INDEX_FAIL_CEILING_MS (5 min), which would cut + * the cooldown of any rebuild over 100 s below K x its duration. */ +#define INDEX_COOLDOWN_FACTOR 3 +#define INDEX_COOLDOWN_CEILING_MS 1800000 /* 30 min */ + /* Stale-root pruning (#286): a watched project whose root directory stays * missing is pruned — its cached DB is deleted and the watch entry removed. * Deletion is destructive (the DB can hold user-authored data such as the @@ -204,6 +214,20 @@ int cbm_watcher_index_backoff_ms(int interval_ms, int consecutive_failures) { return (int)(delay < interval_ms ? interval_ms : delay); } +int cbm_watcher_success_delay_ms(int interval_ms, int64_t index_elapsed_ms) { + if (interval_ms < 0) { + interval_ms = 0; + } + if (index_elapsed_ms <= 0) { + return interval_ms; + } + /* Divide instead of multiply so a huge elapsed cannot overflow. */ + int64_t cooldown = index_elapsed_ms > INDEX_COOLDOWN_CEILING_MS / INDEX_COOLDOWN_FACTOR + ? INDEX_COOLDOWN_CEILING_MS + : index_elapsed_ms * INDEX_COOLDOWN_FACTOR; + return (int)(cooldown > interval_ms ? cooldown : interval_ms); +} + int cbm_watcher_poll_interval_ms(int file_count) { int ms = POLL_BASE_MS + ((file_count / POLL_FILE_STEP) * CBM_MSEC_PER_SEC); if (ms > POLL_MAX_MS) { @@ -1693,12 +1717,22 @@ static void poll_project(const char *key, void *val, void *ud) { } cbm_log_info("watcher.changed", "project", s->project_name, "strategy", s->is_git ? "git" : "tree"); + int delay_ms = -1; /* < 0: use the failure-backoff cadence below */ + int64_t anchor_ns = ctx->now; /* success re-anchors to when the index finished */ if (ctx->w->index_fn) { + int64_t index_start = now_ns(); int rc = ctx->w->index_fn(s->project_name, s->root_path, ctx->w->user_data); if (rc == 0) { ctx->reindexed++; s->index_failures = 0; commit_baselines(ctx->w, s); + anchor_ns = now_ns(); + delay_ms = + cbm_watcher_success_delay_ms(s->interval_ms, (anchor_ns - index_start) / US_PER_MS); + if (delay_ms > s->interval_ms) { + cbm_log_info("watcher.cooldown", "project", s->project_name, "delay_ms", + itoa_buf(delay_ms)); + } } else if (rc > 0) { /* Busy-skip: baseline stays uncommitted, next poll retries. */ cbm_log_info("watcher.index.retry", "project", s->project_name); @@ -1721,10 +1755,12 @@ static void poll_project(const char *key, void *val, void *ud) { } } - /* Failures back off; success and busy-skip keep the adaptive cadence. */ - s->next_poll_ns = - ctx->now + - ((int64_t)cbm_watcher_index_backoff_ms(s->interval_ms, s->index_failures) * US_PER_MS); + /* Success pays back the index's cost; failures back off; busy-skip keeps + * the adaptive cadence. */ + if (delay_ms < 0) { + delay_ms = cbm_watcher_index_backoff_ms(s->interval_ms, s->index_failures); + } + s->next_poll_ns = anchor_ns + ((int64_t)delay_ms * US_PER_MS); } /* Callback to snapshot project state pointers into an array. */ diff --git a/src/watcher/watcher.h b/src/watcher/watcher.h index 2986cb57da..eb7e929153 100644 --- a/src/watcher/watcher.h +++ b/src/watcher/watcher.h @@ -195,6 +195,15 @@ int cbm_watcher_poll_interval_ms(int file_count); * Negative inputs are treated as zero. */ int cbm_watcher_index_backoff_ms(int interval_ms, int consecutive_failures); +/* Return the delay (ms) before the next poll after a SUCCESSFUL re-index that + * took `index_elapsed_ms`: max(interval_ms, INDEX_COOLDOWN_FACTOR (3) x + * index_elapsed_ms), with the cooldown term capped at INDEX_COOLDOWN_CEILING_MS + * (30 min). The caller counts it from when the index finished, so the watcher + * spends at most ~1/4 of wall time indexing a project; sub-second indexes keep + * `interval_ms`. + * Never below `interval_ms`; overflow-safe; negative inputs act as zero. */ +int cbm_watcher_success_delay_ms(int interval_ms, int64_t index_elapsed_ms); + /* Classify a stat() errno observed on a watched project root: returns true * only for values that mean the root itself is gone (ENOENT, ENOTDIR) and * may count toward stale-root pruning (#286). Any other failure (EACCES, diff --git a/tests/test_watcher.c b/tests/test_watcher.c index 8be483ca8b..c2796e3a6a 100644 --- a/tests/test_watcher.c +++ b/tests/test_watcher.c @@ -2260,6 +2260,124 @@ TEST(watcher_sustained_failure_logs_once_issue2015) { PASS(); } +/* ── Success cooldown (#2835) ─────────────────────────────────────── */ + +TEST(success_delay_fast_index_keeps_interval) { + ASSERT_EQ(cbm_watcher_success_delay_ms(5000, 0), 5000); + ASSERT_EQ(cbm_watcher_success_delay_ms(5000, 1), 5000); + ASSERT_EQ(cbm_watcher_success_delay_ms(5000, 1666), 5000); /* 3x < interval */ + ASSERT_EQ(cbm_watcher_success_delay_ms(60000, 20000), 60000); + PASS(); +} + +TEST(success_delay_slow_index_scales_by_factor) { + ASSERT_EQ(cbm_watcher_success_delay_ms(5000, 1667), 5001); + ASSERT_EQ(cbm_watcher_success_delay_ms(5000, 10000), 30000); + /* 5-minute rebuild → next poll 15 min after it finishes. */ + ASSERT_EQ(cbm_watcher_success_delay_ms(60000, 300000), 900000); + PASS(); +} + +TEST(success_delay_clamped_and_degenerate) { + ASSERT_EQ(cbm_watcher_success_delay_ms(5000, 600000), 1800000); + ASSERT_EQ(cbm_watcher_success_delay_ms(5000, 600001), 1800000); + ASSERT_EQ(cbm_watcher_success_delay_ms(5000, INT64_MAX), 1800000); + ASSERT_EQ(cbm_watcher_success_delay_ms(5000, -1), 5000); + ASSERT_EQ(cbm_watcher_success_delay_ms(5000, INT64_MIN), 5000); + ASSERT_EQ(cbm_watcher_success_delay_ms(-1, 0), 0); + /* Never below the caller's own cadence, even above the ceiling. */ + ASSERT_EQ(cbm_watcher_success_delay_ms(INT_MAX, INT64_MAX), INT_MAX); + PASS(); +} + +TEST(success_delay_is_monotonic) { + int previous = cbm_watcher_success_delay_ms(5000, 0); + for (int64_t e = 0; e <= 2000000; e += 997) { + int delay = cbm_watcher_success_delay_ms(5000, e); + ASSERT_TRUE(delay >= previous); + ASSERT_TRUE(delay >= 5000 && delay <= 1800000); + previous = delay; + } + PASS(); +} + +/* Wire-up: the cooldown must be measured around the real index_fn call. An + * instant callback keeps the plain cadence (no cooldown line); a callback + * slower than interval/3 (5000/3 ms) extends it and logs once. */ +static int cooldown_log_hits = 0; +static void cooldown_log_sink(const char *line) { + if (line && strstr(line, "watcher.cooldown") != NULL) { + cooldown_log_hits++; + } +} +static int slow_index_sleep_ms = 0; +static int slow_index_calls = 0; +static int slow_index_callback(const char *name, const char *path, void *ud) { + (void)name; + (void)path; + (void)ud; + slow_index_calls++; + if (slow_index_sleep_ms > 0) { + cbm_usleep((unsigned)slow_index_sleep_ms * 1000U); + } + return 0; +} + +TEST(watcher_success_cooldown_measures_index_issue2835) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_cool_XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + FAIL("cbm_mkdtemp failed"); + if (wt_git(tmpdir, "init -q") != 0) { + th_rmtree(tmpdir); + FAIL("git init failed"); + } + char p[300]; + th_write_file(wt_path(p, sizeof(p), tmpdir, "file.txt"), "hello\n"); + wt_git(tmpdir, "add file.txt"); + wt_git(tmpdir, "commit -q -m init"); + + cbm_store_t *store = cbm_store_open_memory(); + cbm_watcher_t *w = cbm_watcher_new(store, slow_index_callback, NULL); + cbm_watcher_watch(w, "cool-repo", tmpdir); + slow_index_calls = 0; + slow_index_sleep_ms = 0; + cbm_watcher_poll_once(w); /* baseline */ + + CBMLogLevel old_level = cbm_log_get_level(); + cbm_log_set_level(CBM_LOG_INFO); + cooldown_log_hits = 0; + cbm_log_set_sink(cooldown_log_sink); + + /* Instant index: no cooldown. Loop on the attempt count (a transient git + * probe failure makes a poll a no-op, see the sustained-failure test). */ + th_append_file(wt_path(p, sizeof(p), tmpdir, "file.txt"), "a\n"); + while (slow_index_calls < 1) { + cbm_watcher_touch(w, "cool-repo"); + cbm_watcher_poll_once(w); + } + int fast_hits = cooldown_log_hits; + + /* 1.8 s index → 3 x 1800 = 5400 ms > 5000 ms interval: cooldown. */ + slow_index_sleep_ms = 1800; + th_append_file(wt_path(p, sizeof(p), tmpdir, "file.txt"), "b\n"); + while (slow_index_calls < 2) { + cbm_watcher_touch(w, "cool-repo"); + cbm_watcher_poll_once(w); + } + + cbm_log_set_sink(NULL); + cbm_log_set_level(old_level); + + ASSERT_EQ(fast_hits, 0); + ASSERT_EQ(cooldown_log_hits, 1); + + cbm_watcher_free(w); + cbm_store_close(store); + th_rmtree(tmpdir); + PASS(); +} + TEST(watcher_index_failure_count_unknown_project) { cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *w = cbm_watcher_new(store, index_callback, NULL); @@ -4043,6 +4161,11 @@ SUITE(watcher) { RUN_TEST(index_backoff_never_schedules_sooner_than_the_interval); RUN_TEST(watcher_index_failure_streak_increments_and_resets_issue2015); RUN_TEST(watcher_index_failure_backoff_gates_repolling_issue2015); + RUN_TEST(success_delay_fast_index_keeps_interval); + RUN_TEST(success_delay_slow_index_scales_by_factor); + RUN_TEST(success_delay_clamped_and_degenerate); + RUN_TEST(success_delay_is_monotonic); + RUN_TEST(watcher_success_cooldown_measures_index_issue2835); RUN_TEST(watcher_sustained_failure_logs_once_issue2015); RUN_TEST(watcher_index_failure_count_unknown_project); RUN_TEST(watcher_project_info_publishes_scan_issue2167); From cbf379472f2358c6d3ca26fa64d5969779ae07e8 Mon Sep 17 00:00:00 2001 From: Shane McCarron Date: Mon, 2 Nov 2026 07:52:34 -0600 Subject: [PATCH 2/2] fix(watcher): cap success cooldown at 5 min; test the schedule without sleeping Review feedback on #2840: - Cap the success cooldown at INDEX_FAIL_CEILING_MS (5 min) instead of a separate 30-minute ceiling, so under continuous writes a project falls at most one rebuild plus five minutes behind. The 3x factor is unchanged below the cap. - Add two CBM_ENABLE_TEST_SEAMS hooks: freeze the watcher's scheduling clock, and read a project's next_poll_ns. The wire-up test now advances the frozen clock inside index_fn instead of sleeping 1.8 s, and asserts the exact scheduled poll time for an instant index, a 60 s index (180 s, counted from the END of the index) and a 200 s index (capped at 300 s). Refs #2835 Signed-off-by: Shane McCarron --- src/watcher/watcher.c | 42 +++++++++++++---- src/watcher/watcher.h | 17 +++++-- tests/test_watcher.c | 107 ++++++++++++++++++++++-------------------- 3 files changed, 101 insertions(+), 65 deletions(-) diff --git a/src/watcher/watcher.c b/src/watcher/watcher.c index cef4fd4829..3bdd1a6c32 100644 --- a/src/watcher/watcher.c +++ b/src/watcher/watcher.c @@ -155,14 +155,13 @@ struct cbm_watcher { #define INDEX_FAIL_SUSTAINED 10 /* Success cooldown (#2835): after a successful re-index the next poll waits - * at least INDEX_COOLDOWN_FACTOR x the index's own duration, counted from when - * the index finished, so the watcher spends at most ~1/(K+1) of wall time - * indexing one project — a 5-minute rebuild idles 15 minutes. Sub-second - * indexes stay at the plain cadence. The ceiling bounds staleness after one - * pathological run; it is NOT INDEX_FAIL_CEILING_MS (5 min), which would cut - * the cooldown of any rebuild over 100 s below K x its duration. */ + * INDEX_COOLDOWN_FACTOR x the index's own duration, counted from when the + * index finished, so short rebuilds spend at most ~1/(K+1) of wall time + * indexing one project. Sub-second indexes stay at the plain cadence. The + * cooldown is capped at INDEX_FAIL_CEILING_MS (5 min) so that, under + * continuous writes, a project never falls more than one rebuild plus five + * minutes behind. */ #define INDEX_COOLDOWN_FACTOR 3 -#define INDEX_COOLDOWN_CEILING_MS 1800000 /* 30 min */ /* Stale-root pruning (#286): a watched project whose root directory stays * missing is pruned — its cached DB is deleted and the watch entry removed. @@ -185,7 +184,17 @@ struct cbm_watcher { /* ── Time helper ────────────────────────────────────────────────── */ +#if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS +/* Test seam: nonzero freezes the watcher's scheduling clock at this value. */ +static int64_t test_clock_ns = 0; +#endif + static int64_t now_ns(void) { +#if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS + if (test_clock_ns != 0) { + return test_clock_ns; + } +#endif struct timespec ts; cbm_clock_gettime(CLOCK_MONOTONIC, &ts); return ((int64_t)ts.tv_sec * NS_PER_SEC) + ts.tv_nsec; @@ -222,8 +231,8 @@ int cbm_watcher_success_delay_ms(int interval_ms, int64_t index_elapsed_ms) { return interval_ms; } /* Divide instead of multiply so a huge elapsed cannot overflow. */ - int64_t cooldown = index_elapsed_ms > INDEX_COOLDOWN_CEILING_MS / INDEX_COOLDOWN_FACTOR - ? INDEX_COOLDOWN_CEILING_MS + int64_t cooldown = index_elapsed_ms > INDEX_FAIL_CEILING_MS / INDEX_COOLDOWN_FACTOR + ? INDEX_FAIL_CEILING_MS : index_elapsed_ms * INDEX_COOLDOWN_FACTOR; return (int)(cooldown > interval_ms ? cooldown : interval_ms); } @@ -1912,4 +1921,19 @@ int cbm_watcher_test_pending_free_count(cbm_watcher_t *w) { cbm_mutex_unlock(&w->projects_lock); return count; } + +void cbm_watcher_test_set_clock_ns(int64_t ns) { + test_clock_ns = ns; +} + +int64_t cbm_watcher_test_next_poll_ns(cbm_watcher_t *w, const char *project) { + if (!w || !project) { + return -1; + } + cbm_mutex_lock(&w->projects_lock); + project_state_t *s = cbm_ht_get(w->projects, project); + int64_t next = s ? s->next_poll_ns : -1; + cbm_mutex_unlock(&w->projects_lock); + return next; +} #endif diff --git a/src/watcher/watcher.h b/src/watcher/watcher.h index eb7e929153..571b201239 100644 --- a/src/watcher/watcher.h +++ b/src/watcher/watcher.h @@ -145,6 +145,15 @@ int cbm_watcher_index_failure_count(cbm_watcher_t *w, const char *project_name); * otherwise unobservable — cbm_watcher_free frees the same list, so a poll * that stopped draining would neither crash nor leak. Test builds only. */ int cbm_watcher_test_pending_free_count(cbm_watcher_t *w); + +/* Test seam: freeze the watcher's monotonic scheduling clock at `ns` + * (nonzero), or restore the real clock (0). Process-global; single-threaded + * tests only. Lets a test advance time inside index_fn without sleeping. */ +void cbm_watcher_test_set_clock_ns(int64_t ns); + +/* Test seam: a project's scheduled next poll (monotonic ns), read under + * projects_lock; -1 when it is not watched. */ +int64_t cbm_watcher_test_next_poll_ns(cbm_watcher_t *w, const char *project); #endif /* Per-project watch status, for index_status (#2167). */ @@ -197,10 +206,10 @@ int cbm_watcher_index_backoff_ms(int interval_ms, int consecutive_failures); /* Return the delay (ms) before the next poll after a SUCCESSFUL re-index that * took `index_elapsed_ms`: max(interval_ms, INDEX_COOLDOWN_FACTOR (3) x - * index_elapsed_ms), with the cooldown term capped at INDEX_COOLDOWN_CEILING_MS - * (30 min). The caller counts it from when the index finished, so the watcher - * spends at most ~1/4 of wall time indexing a project; sub-second indexes keep - * `interval_ms`. + * index_elapsed_ms), with the cooldown term capped at INDEX_FAIL_CEILING_MS + * (5 min). The caller counts it from when the index finished, so short + * rebuilds spend at most ~1/4 of wall time indexing a project; sub-second + * indexes keep `interval_ms`. * Never below `interval_ms`; overflow-safe; negative inputs act as zero. */ int cbm_watcher_success_delay_ms(int interval_ms, int64_t index_elapsed_ms); diff --git a/tests/test_watcher.c b/tests/test_watcher.c index c2796e3a6a..670d4f5a2a 100644 --- a/tests/test_watcher.c +++ b/tests/test_watcher.c @@ -2273,15 +2273,17 @@ TEST(success_delay_fast_index_keeps_interval) { TEST(success_delay_slow_index_scales_by_factor) { ASSERT_EQ(cbm_watcher_success_delay_ms(5000, 1667), 5001); ASSERT_EQ(cbm_watcher_success_delay_ms(5000, 10000), 30000); - /* 5-minute rebuild → next poll 15 min after it finishes. */ - ASSERT_EQ(cbm_watcher_success_delay_ms(60000, 300000), 900000); + /* 90 s rebuild → next poll 4.5 min after it finishes. */ + ASSERT_EQ(cbm_watcher_success_delay_ms(60000, 90000), 270000); PASS(); } TEST(success_delay_clamped_and_degenerate) { - ASSERT_EQ(cbm_watcher_success_delay_ms(5000, 600000), 1800000); - ASSERT_EQ(cbm_watcher_success_delay_ms(5000, 600001), 1800000); - ASSERT_EQ(cbm_watcher_success_delay_ms(5000, INT64_MAX), 1800000); + /* Capped at 5 min (INDEX_FAIL_CEILING_MS) from 100 s of indexing up. */ + ASSERT_EQ(cbm_watcher_success_delay_ms(5000, 100000), 300000); + ASSERT_EQ(cbm_watcher_success_delay_ms(5000, 100001), 300000); + ASSERT_EQ(cbm_watcher_success_delay_ms(60000, 300000), 300000); + ASSERT_EQ(cbm_watcher_success_delay_ms(5000, INT64_MAX), 300000); ASSERT_EQ(cbm_watcher_success_delay_ms(5000, -1), 5000); ASSERT_EQ(cbm_watcher_success_delay_ms(5000, INT64_MIN), 5000); ASSERT_EQ(cbm_watcher_success_delay_ms(-1, 0), 0); @@ -2295,34 +2297,45 @@ TEST(success_delay_is_monotonic) { for (int64_t e = 0; e <= 2000000; e += 997) { int delay = cbm_watcher_success_delay_ms(5000, e); ASSERT_TRUE(delay >= previous); - ASSERT_TRUE(delay >= 5000 && delay <= 1800000); + ASSERT_TRUE(delay >= 5000 && delay <= 300000); previous = delay; } PASS(); } -/* Wire-up: the cooldown must be measured around the real index_fn call. An - * instant callback keeps the plain cadence (no cooldown line); a callback - * slower than interval/3 (5000/3 ms) extends it and logs once. */ -static int cooldown_log_hits = 0; -static void cooldown_log_sink(const char *line) { - if (line && strstr(line, "watcher.cooldown") != NULL) { - cooldown_log_hits++; - } -} -static int slow_index_sleep_ms = 0; -static int slow_index_calls = 0; -static int slow_index_callback(const char *name, const char *path, void *ud) { +/* Wire-up: the cooldown must be measured around the real index_fn call and + * counted from when the index finished. The watcher's clock is frozen via a + * test seam and the callback advances it, so an index "takes" a chosen time + * with no sleep and the scheduled poll time can be asserted exactly. */ +#define COOL_NS_PER_MS 1000000LL +static int64_t cool_clock_ns = 0; +static int64_t cool_index_ms = 0; +static int cool_index_calls = 0; +static int cool_index_callback(const char *name, const char *path, void *ud) { (void)name; (void)path; (void)ud; - slow_index_calls++; - if (slow_index_sleep_ms > 0) { - cbm_usleep((unsigned)slow_index_sleep_ms * 1000U); - } + cool_index_calls++; + cool_clock_ns += cool_index_ms * COOL_NS_PER_MS; + cbm_watcher_test_set_clock_ns(cool_clock_ns); return 0; } +/* Re-index once (touch + poll until the callback runs; a transient git probe + * failure makes a poll a no-op, see the sustained-failure test) and return + * the scheduled next poll relative to when the index finished, in ms. */ +static int64_t cool_reindex_delay_ms(cbm_watcher_t *w, const char *file, const char *line, + int64_t index_ms) { + cool_index_ms = index_ms; + th_append_file(file, line); + int target = cool_index_calls + 1; + while (cool_index_calls < target) { + cbm_watcher_touch(w, "cool-repo"); + cbm_watcher_poll_once(w); + } + return (cbm_watcher_test_next_poll_ns(w, "cool-repo") - cool_clock_ns) / COOL_NS_PER_MS; +} + TEST(watcher_success_cooldown_measures_index_issue2835) { char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_cool_XXXXXX"); @@ -2337,40 +2350,30 @@ TEST(watcher_success_cooldown_measures_index_issue2835) { wt_git(tmpdir, "add file.txt"); wt_git(tmpdir, "commit -q -m init"); + cool_clock_ns = 1000LL * 1000 * COOL_NS_PER_MS; /* any nonzero start */ + cbm_watcher_test_set_clock_ns(cool_clock_ns); + cool_index_calls = 0; + cbm_store_t *store = cbm_store_open_memory(); - cbm_watcher_t *w = cbm_watcher_new(store, slow_index_callback, NULL); + cbm_watcher_t *w = cbm_watcher_new(store, cool_index_callback, NULL); cbm_watcher_watch(w, "cool-repo", tmpdir); - slow_index_calls = 0; - slow_index_sleep_ms = 0; cbm_watcher_poll_once(w); /* baseline */ - CBMLogLevel old_level = cbm_log_get_level(); - cbm_log_set_level(CBM_LOG_INFO); - cooldown_log_hits = 0; - cbm_log_set_sink(cooldown_log_sink); - - /* Instant index: no cooldown. Loop on the attempt count (a transient git - * probe failure makes a poll a no-op, see the sustained-failure test). */ - th_append_file(wt_path(p, sizeof(p), tmpdir, "file.txt"), "a\n"); - while (slow_index_calls < 1) { - cbm_watcher_touch(w, "cool-repo"); - cbm_watcher_poll_once(w); - } - int fast_hits = cooldown_log_hits; - - /* 1.8 s index → 3 x 1800 = 5400 ms > 5000 ms interval: cooldown. */ - slow_index_sleep_ms = 1800; - th_append_file(wt_path(p, sizeof(p), tmpdir, "file.txt"), "b\n"); - while (slow_index_calls < 2) { - cbm_watcher_touch(w, "cool-repo"); - cbm_watcher_poll_once(w); - } - - cbm_log_set_sink(NULL); - cbm_log_set_level(old_level); - - ASSERT_EQ(fast_hits, 0); - ASSERT_EQ(cooldown_log_hits, 1); + wt_path(p, sizeof(p), tmpdir, "file.txt"); + /* Instant index: plain cadence. */ + int64_t fast = cool_reindex_delay_ms(w, p, "a\n", 0); + /* 60 s index → 3 x 60 s, counted from the END of the index (anchoring at + * the start would give 120 s here). */ + int64_t slow = cool_reindex_delay_ms(w, p, "b\n", 60000); + /* 200 s index → 600 s, capped at 5 min. */ + int64_t capped = cool_reindex_delay_ms(w, p, "c\n", 200000); + + /* Restore the real clock before any assert can return early. */ + cbm_watcher_test_set_clock_ns(0); + + ASSERT_EQ(fast, cbm_watcher_poll_interval_ms(1)); + ASSERT_EQ(slow, 180000); + ASSERT_EQ(capped, 300000); cbm_watcher_free(w); cbm_store_close(store);