From a372a1fa5e010e6a0f30913f90a50f45f90213a2 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Wed, 14 Oct 2026 20:29:55 +0200 Subject: [PATCH] perf(pipeline): prove an unchanged re-index on the live generation Every index_repository call copied the whole existing database into a stage (sqlite3 backup) before routing, and the exact no-op then sealed that copy and renamed it back over the live file. On the Linux kernel index the copy alone was a 19 GB write per unchanged re-index. The copy is now deferred until routing needs it. The exact no-op is decided read-only on the live generation with the same checks the staged route applies to its copy (shallow integrity, format version, current coverage metadata and doc-link layer, byte-identical semantic manifest); the run then publishes nothing and only refreshes the artifact from the live database. Any other outcome copies and routes exactly as before, so every change -- including a same-size edit that keeps the original mtime -- still rebuilds from content hashes. cbm_pipeline_new no longer resolves the Git context the run start always re-resolved (seven git subprocesses per call). No-op index_repository, macOS, median of three runs of n=3: django 840.8 -> 539.0 ms, redis 450.7 -> 310.5 ms, Linux kernel 18449.5 -> 2405.0 ms. Responses and graphs are byte-identical to the baseline for the no-op and for modify/add/delete/rename edits. Signed-off-by: Martin Vogel --- src/pipeline/pipeline.c | 138 ++++++++++++++++++--- src/pipeline/pipeline_incremental.c | 76 +++++++++--- src/pipeline/pipeline_internal.h | 7 ++ tests/test_pipeline.c | 186 +++++++++++++++++++++++++++- 4 files changed, 367 insertions(+), 40 deletions(-) diff --git a/src/pipeline/pipeline.c b/src/pipeline/pipeline.c index 002b5a46c..92e23de64 100644 --- a/src/pipeline/pipeline.c +++ b/src/pipeline/pipeline.c @@ -260,6 +260,15 @@ struct cbm_pipeline { * "invalid_existing_db" (#1864). */ bool final_existed; bool existing_generation; + /* The copy into the stage is deferred until routing needs it: an + * unchanged repository is proven current on the live generation + * (live_db_path, read-only) and never copies, seals or renames a + * database. backup_succeeded records the copy once it ran; live_noop + * marks a run that ended on the live proof with nothing staged. */ + bool stage_backup_pending; + bool backup_succeeded; + bool live_noop; + const char *live_db_path; /* borrowed from cbm_pipeline_run */ /* ADR (project_summaries) captured before a full-reindex DB delete, so it * can be restored after the rebuild. NULL when no ADR existed. Issue #516. */ @@ -848,7 +857,10 @@ cbm_pipeline_t *cbm_pipeline_new(const char *repo_path, const char *db_path, p->repo_path = strdup(repo_path); p->db_path = db_path ? strdup(db_path) : NULL; p->project_name = cbm_project_name_from_path(repo_path); - (void)cbm_git_context_resolve(repo_path, &p->git_ctx); + /* The Git context is resolved once, when the run starts + * (pipeline_refresh_git_context): a context resolved here was always + * replaced before any use, at the cost of seven git subprocesses per + * index call. Until then the branch QN names the working tree. */ p->branch_qn = cbm_git_context_branch_qn(p->project_name, &p->git_ctx); p->requested_mode = mode; p->mode = mode; @@ -2738,12 +2750,69 @@ static int capture_existing_adr(cbm_pipeline_t *p, const char *db_path) { return 0; } +static int remove_stage_files(const char *stage_path); + +/* Copy the live generation into this run's stage: every route except the + * live no-op works on that copy. A failed copy behaves as the eager copy it + * replaces did: the stage files go (the name and its lock stay this run's, + * the rebuilt generation is renamed over it later) and the run has no + * previous generation, so it rebuilds in full. */ +static void materialize_stage_backup(cbm_pipeline_t *p) { + p->stage_backup_pending = false; + p->backup_succeeded = cbm_store_backup_path(p->live_db_path, p->db_path) == CBM_STORE_OK; + if (!p->backup_succeeded) { + cbm_log_warn("pipeline.stage", "action", "backup_failed_full_rebuild", "path", + p->live_db_path); + (void)remove_stage_files(p->db_path); + } + p->existing_generation = p->backup_succeeded; +} + +/* The live generation is provably current: read-only, it passes the shallow + * integrity and format checks the staged route applies to its copy, and the + * exact semantic-manifest test the staged route would answer no-op with. A + * copy of it would route to that no-op and publish the same database again, + * so the run ends here with nothing copied, sealed or renamed. Any doubt + * answers false and the run copies and routes exactly as before. */ +static bool live_generation_current(cbm_pipeline_t *p, const cbm_file_hash_t *baseline_manifest, + int baseline_count) { + struct stat db_st; + if (!p->live_db_path || stat(p->live_db_path, &db_st) != 0) { + return false; + } + cbm_store_t *store = cbm_store_open_path_query(p->live_db_path); + if (!store) { + return false; + } + int fmt = 0; + bool current = cbm_store_check_integrity(store); + if (current) { + cbm_store_get_format_version(store, &fmt); + current = + fmt == CBM_INDEX_FORMAT_VERSION && + cbm_pipeline_incremental_generation_exact(p, store, baseline_manifest, baseline_count); + } + cbm_store_close(store); + return current; +} + /* Route an existing generation. Full rebuilds never delete the live DB here: * publication owns the eventual atomic replacement after every pass and * metadata write has succeeded. */ static int try_incremental_or_delete_db(cbm_pipeline_t *p, cbm_file_info_t *files, int file_count, const cbm_file_hash_t *baseline_manifest, int baseline_count, bool force_full_on_mismatch) { + if (p->stage_backup_pending) { + if (live_generation_current(p, baseline_manifest, baseline_count)) { + p->stage_backup_pending = false; + p->live_noop = true; + cbm_log_info("pipeline.route", "path", "incremental_manifest", "generation", "live"); + cbm_log_info("incremental.noop", "reason", "semantic_manifest_equal", "generation", + "live"); + return 0; + } + materialize_stage_backup(p); + } if (!p->existing_generation) { /* Nothing to be incremental against: a first index, or a * destination that could not be copied (already reported as @@ -2846,12 +2915,15 @@ static bool promote_mode_to_existing_coverage(cbm_pipeline_t *p) { if (!p || !p->project_name) { return false; } - char *db_path = resolve_db_path(p); + /* Until routing copies it, the stage is an empty placeholder: the + * coverage the copy would carry is read from the live generation. */ + char *resolved = p->stage_backup_pending ? NULL : resolve_db_path(p); + const char *db_path = p->stage_backup_pending ? p->live_db_path : resolved; if (!db_path) { return false; } cbm_store_t *store = cbm_store_open_path_query(db_path); - free(db_path); + free(resolved); if (!store) { return false; } @@ -4542,6 +4614,29 @@ static void sweep_orphan_stages(const char *final_path) { free(dir_path); } +/* End a run whose exact no-op was proven on the live generation. Nothing was + * staged, so nothing is sealed or renamed and the live database stays the + * current generation. The checks publication makes on the run's sources and + * destination still decide success, and the artifact is refreshed from the + * live database exactly as it would be from a republished one. */ +static int finish_live_noop(cbm_pipeline_t *p, const char *final_path) { + cbm_userconfig_t *fresh_config = cbm_userconfig_load_with_source(p->repo_path); + bool source_matches = cbm_pipeline_project_source_matches(p, fresh_config); + cbm_userconfig_free(fresh_config); + struct stat final_st; + int rc = 0; + if (!source_matches) { + cbm_log_error("pipeline.err", "phase", "test_declarations_source_changed"); + rc = CBM_PIPELINE_ABORT_PRESERVE_DB; + } else if (stat(final_path, &final_st) != 0) { + /* The destination vanished during the run: someone else owns it. */ + rc = CBM_PIPELINE_ABORT_PRESERVE_DB; + } else { + rc = export_after_publish(p, final_path); + } + return rc; +} + int cbm_pipeline_run(cbm_pipeline_t *p) { /* Per-index attribution: peaks and phase totals are about THIS index, not * the process history, so they start clean here. The first mark opens @@ -4571,21 +4666,12 @@ int cbm_pipeline_run(cbm_pipeline_t *p) { return CBM_NOT_FOUND; } - bool backup_succeeded = false; - if (final_existed) { - backup_succeeded = cbm_store_backup_path(final_path, staging_path) == CBM_STORE_OK; - if (!backup_succeeded) { - cbm_log_warn("pipeline.stage", "action", "backup_failed_full_rebuild", "path", - final_path); - /* The copy is gone but the NAME stays ours: the rebuilt - * generation is renamed over it by the inner finalize and then - * published from it below, so its lock is held to the end. */ - remove_stage_files(staging_path); - } - } - + /* The existing generation is copied into the stage only when routing + * needs a copy (materialize_stage_backup): an exact no-op is proven on + * the live generation and copies nothing -- on a kernel-scale index the + * copy alone was a 19 GB write per unchanged re-index. */ p->final_existed = final_existed; - p->existing_generation = final_existed && backup_succeeded; + p->existing_generation = final_existed; char *configured_db_path = p->db_path; p->db_path = strdup(staging_path); if (!p->db_path) { @@ -4595,10 +4681,19 @@ int cbm_pipeline_run(cbm_pipeline_t *p) { free(final_path); return CBM_NOT_FOUND; } + p->stage_backup_pending = final_existed; + p->backup_succeeded = false; + p->live_noop = false; + p->live_db_path = final_path; cbm_log_debug("pipeline.setup", "step", "staged"); int rc = cbm_pipeline_run_staged(p); free(p->db_path); p->db_path = configured_db_path; + bool backup_succeeded = p->backup_succeeded; + bool live_noop = p->live_noop; + p->stage_backup_pending = false; + p->live_noop = false; + p->live_db_path = NULL; /* Report WHY the run stopped. Everything below happens before the final * rename, so the live database is still the previous generation and an @@ -4612,12 +4707,17 @@ int cbm_pipeline_run(cbm_pipeline_t *p) { pipeline_release_test_config(p); return rc; } - if (check_cancel(p)) { + /* A cancelled run and a live no-op both end here without publishing: + * the stage placeholder is discarded, and only the no-op goes on to + * finish on the live generation. */ + bool cancelled = check_cancel(p); + if (cancelled || live_noop) { cleanup_staging_db(staging_path); free(staging_path); + rc = cancelled ? CBM_PIPELINE_ABORT_PRESERVE_DB : finish_live_noop(p, final_path); free(final_path); pipeline_release_test_config(p); - return CBM_PIPELINE_ABORT_PRESERVE_DB; + return rc; } if (seal_staging_db(staging_path) != 0) { cleanup_staging_db(staging_path); diff --git a/src/pipeline/pipeline_incremental.c b/src/pipeline/pipeline_incremental.c index c5f4ffafb..e0d4e4947 100644 --- a/src/pipeline/pipeline_incremental.c +++ b/src/pipeline/pipeline_incremental.c @@ -3557,6 +3557,62 @@ static bool incr_doc_links_current(cbm_store_t *store, const char *project) { return current; } +/* The exact no-op test: the stored generation's metadata is current for this + * mode, its doc-link layer is usable, and its recorded semantic manifest + * equals the baseline byte for byte. Read-only. The two out flags report the + * halves the staged route also needs for its own routing. */ +static bool incr_generation_exact(cbm_pipeline_t *p, cbm_store_t *store, const char *project, + const cbm_file_hash_t *stored, int stored_count, + const cbm_file_hash_t *baseline_manifest, int baseline_count, + bool *metadata_current_out, bool *doc_links_current_out) { + cbm_coverage_meta_t meta = {0}; + int meta_rc = cbm_store_coverage_meta_get(store, project, &meta); + const char *mode_name = incr_mode_name(cbm_pipeline_get_mode(p)); + bool metadata_current = + meta_rc == CBM_STORE_OK && meta.coverage_version == CBM_SEMANTIC_INDEX_VERSION && + meta.hash_records_complete && meta.index_mode && strcmp(meta.index_mode, mode_name) == 0; + /* A generation whose doc-link layer is missing or failed is not + * current even with identical inputs: index_status tells the user to + * re-run, and the re-run has to rebuild. */ + bool doc_links_current = metadata_current && incr_doc_links_current(store, project); + bool exact = doc_links_current && cbm_pipeline_semantic_manifests_equal( + stored, stored_count, baseline_manifest, baseline_count); + cbm_store_coverage_meta_clear(&meta); + *metadata_current_out = metadata_current; + *doc_links_current_out = doc_links_current; + return exact; +} + +bool cbm_pipeline_incremental_generation_exact(cbm_pipeline_t *p, cbm_store_t *store, + const cbm_file_hash_t *baseline_manifest, + int baseline_count) { +#if defined(CBM_INCREMENTAL_TEST_API) && CBM_INCREMENTAL_TEST_API + /* A forced legacy-partial run must reach the staged route that takes + * the one-shot flag; answering here would consume the run without it. */ + if (atomic_load(&g_incr_test_force_legacy_partial)) { + return false; + } +#endif + const char *project = cbm_pipeline_project_name(p); + cbm_file_hash_t *stored = NULL; + int stored_count = 0; + if (!store || + cbm_store_get_file_hashes(store, project, &stored, &stored_count) != CBM_STORE_OK) { + return false; + } + bool metadata_current = false; + bool doc_links_current = false; + bool exact = incr_generation_exact(p, store, project, stored, stored_count, baseline_manifest, + baseline_count, &metadata_current, &doc_links_current); + cbm_store_free_file_hashes(stored, stored_count); +#if defined(CBM_INCREMENTAL_TEST_API) && CBM_INCREMENTAL_TEST_API + if (exact) { + incr_test_set_last_route(CBM_INCREMENTAL_ROUTE_NOOP); + } +#endif + return exact; +} + int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_file_info_t *files, int file_count, const cbm_file_hash_t *baseline_manifest, int baseline_count, bool force_full_on_mismatch) { @@ -3602,21 +3658,11 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil * rebuilds the complete graph. This avoids partial LSP/package/config * convergence gaps while retaining the common unchanged-repo fast path. */ if (!force_legacy_partial) { - cbm_coverage_meta_t meta = {0}; - int meta_rc = cbm_store_coverage_meta_get(store, project, &meta); - const char *mode_name = incr_mode_name(cbm_pipeline_get_mode(p)); - bool metadata_current = meta_rc == CBM_STORE_OK && - meta.coverage_version == CBM_SEMANTIC_INDEX_VERSION && - meta.hash_records_complete && meta.index_mode && - strcmp(meta.index_mode, mode_name) == 0; - /* A generation whose doc-link layer is missing or failed is not - * current even with identical inputs: index_status tells the user to - * re-run, and the re-run has to rebuild. */ - bool doc_links_current = metadata_current && incr_doc_links_current(store, project); - bool exact = doc_links_current && - cbm_pipeline_semantic_manifests_equal(stored, stored_count, baseline_manifest, - baseline_count); - cbm_store_coverage_meta_clear(&meta); + bool metadata_current = false; + bool doc_links_current = false; + bool exact = + incr_generation_exact(p, store, project, stored, stored_count, baseline_manifest, + baseline_count, &metadata_current, &doc_links_current); if (exact) { cbm_store_free_file_hashes(stored, stored_count); cbm_store_close(store); diff --git a/src/pipeline/pipeline_internal.h b/src/pipeline/pipeline_internal.h index 5d8d9241c..e39defa20 100644 --- a/src/pipeline/pipeline_internal.h +++ b/src/pipeline/pipeline_internal.h @@ -1382,6 +1382,13 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil int file_count, const cbm_file_hash_t *baseline_manifest, int baseline_count, bool force_full_on_mismatch); +/* The exact no-op test cbm_pipeline_run_incremental routes by, read-only on + * an already opened generation: true when that generation is current for the + * pipeline's mode and records exactly the baseline manifest. */ +bool cbm_pipeline_incremental_generation_exact(cbm_pipeline_t *p, cbm_store_t *store, + const cbm_file_hash_t *baseline_manifest, + int baseline_count); + /* Exact semantic inputs for no-op/forced-full routing. The manifest contains * every discovered source plus repository controls actually consumed by * discovery, package mapping, path aliases, and extension overrides. */ diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 434ee1ba7..86d59454c 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -5759,6 +5759,177 @@ TEST(pipeline_concurrent_sweep_must_not_remove_inflight_stage) { PASS(); } +static int live_noop_run(const char *repo, const char *db_path, cbm_incremental_route_t *route) { + cbm_pipeline_incremental_test_reset_faults(); + *route = CBM_INCREMENTAL_ROUTE_NONE; + cbm_pipeline_t *p = cbm_pipeline_new(repo, db_path, CBM_MODE_FULL); + if (!p) { + return CBM_NOT_FOUND; + } + int rc = cbm_pipeline_run(p); + *route = cbm_pipeline_incremental_test_last_route(); + cbm_pipeline_free(p); + return rc; +} + +/* An unchanged re-index proves the exact no-op on the LIVE generation. It + * never copies the database into a stage, seals the copy and renames it back + * (on a kernel-scale index that copy alone was a 19 GB write per call): the + * live file keeps its identity, size and mtime, no stage artifact remains, + * and the graph still serves. */ +TEST(pipeline_unchanged_reindex_leaves_live_generation_untouched) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_live_noop_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); + write_temp_file(tmp, "generation.py", "def StableGeneration():\n return 1\n"); + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/generation.db", tmp); + char *project = cbm_project_name_from_path(tmp); + ASSERT_NOT_NULL(project); + + cbm_incremental_route_t first_route; + int first_rc = live_noop_run(tmp, db_path, &first_route); + cbm_path_info_t before = {0}; + struct stat before_st; + bool before_ok = + cbm_path_info_utf8(db_path, &before) == CBM_PATH_INFO_OK && stat(db_path, &before_st) == 0; + + cbm_incremental_route_t noop_route; + int noop_rc = live_noop_run(tmp, db_path, &noop_route); + cbm_path_info_t after = {0}; + struct stat after_st; + bool after_ok = + cbm_path_info_utf8(db_path, &after) == CBM_PATH_INFO_OK && stat(db_path, &after_st) == 0; + int defined_count = -1; + int absent_count = -1; + observe_named_generation(db_path, project, "StableGeneration", "NeverDefined", &defined_count, + &absent_count); + int stage_count = count_generation_stage_artifacts(tmp, "generation.db"); + cbm_pipeline_incremental_test_reset_faults(); + free(project); + th_rmtree(tmp); + + ASSERT_EQ(first_rc, 0); + ASSERT_TRUE(before_ok); + ASSERT_EQ(noop_rc, 0); + ASSERT_EQ(noop_route, CBM_INCREMENTAL_ROUTE_NOOP); + ASSERT_TRUE(after_ok); + ASSERT_TRUE(after_st.st_ino == before_st.st_ino); + ASSERT_TRUE(after.mtime_ns == before.mtime_ns); + ASSERT_TRUE(after.size == before.size); + ASSERT_EQ(stage_count, 0); + ASSERT_EQ(defined_count, 1); + ASSERT_EQ(absent_count, 0); + PASS(); +} + +/* The live proof answers only for an exact manifest. Every real change -- a + * same-size edit that keeps the original mtime, an addition, a deletion and + * a rename -- still routes past it into a rebuild that the graph shows, and + * only the unchanged re-index after them takes the no-op. */ +TEST(pipeline_live_noop_proof_never_hides_a_change) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_live_noop_change_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); + write_temp_file(tmp, "alpha.py", "def AlphaDefined():\n return 1\n"); + write_temp_file(tmp, "beta.py", "def BetaDefined():\n return 2\n"); + char db_path[512]; + char alpha_path[512]; + char beta_path[512]; + char gamma_path[512]; + char delta_path[512]; + snprintf(db_path, sizeof(db_path), "%s/generation.db", tmp); + snprintf(alpha_path, sizeof(alpha_path), "%s/alpha.py", tmp); + snprintf(beta_path, sizeof(beta_path), "%s/beta.py", tmp); + snprintf(gamma_path, sizeof(gamma_path), "%s/gamma.py", tmp); + snprintf(delta_path, sizeof(delta_path), "%s/delta.py", tmp); + char *project = cbm_project_name_from_path(tmp); + ASSERT_NOT_NULL(project); + + cbm_incremental_route_t initial_route; + int initial_rc = live_noop_run(tmp, db_path, &initial_route); + + /* Same size, original mtime restored: only the bytes differ. */ + cbm_path_info_t alpha_info = {0}; + bool alpha_stat_ok = cbm_path_info_utf8(alpha_path, &alpha_info) == CBM_PATH_INFO_OK; + write_temp_file(tmp, "alpha.py", "def AlphbDefined():\n return 1\n"); + int mtime_rc = pipeline_test_set_mtime(alpha_path, (time_t)(alpha_info.mtime_ns / 1000000000), + (long)(alpha_info.mtime_ns % 1000000000)); + cbm_path_info_t edited_info = {0}; + bool edited_same_stat = cbm_path_info_utf8(alpha_path, &edited_info) == CBM_PATH_INFO_OK && + edited_info.size == alpha_info.size && + edited_info.mtime_ns == alpha_info.mtime_ns; + cbm_incremental_route_t modify_route; + int modify_rc = live_noop_run(tmp, db_path, &modify_route); + int old_alpha = -1; + int new_alpha = -1; + observe_named_generation(db_path, project, "AlphaDefined", "AlphbDefined", &old_alpha, + &new_alpha); + + write_temp_file(tmp, "gamma.py", "def GammaDefined():\n return 3\n"); + cbm_incremental_route_t add_route; + int add_rc = live_noop_run(tmp, db_path, &add_route); + int gamma_added = -1; + int unused = -1; + observe_named_generation(db_path, project, "GammaDefined", "NeverDefined", &gamma_added, + &unused); + + int unlink_rc = cbm_unlink(beta_path); + cbm_incremental_route_t delete_route; + int delete_rc = live_noop_run(tmp, db_path, &delete_route); + int beta_left = -1; + observe_named_generation(db_path, project, "BetaDefined", "NeverDefined", &beta_left, &unused); + + int rename_rc = cbm_rename_replace(gamma_path, delta_path); + cbm_incremental_route_t rename_route; + int renamed_rc = live_noop_run(tmp, db_path, &rename_route); + int gamma_renamed = -1; + observe_named_generation(db_path, project, "GammaDefined", "NeverDefined", &gamma_renamed, + &unused); + int old_path_hash = CBM_STORE_OK; + int new_path_hash = CBM_STORE_NOT_FOUND; + cbm_store_t *store = cbm_store_open_path_query(db_path); + if (store) { + cbm_file_hash_t hash = {0}; + old_path_hash = cbm_store_get_file_hash(store, project, "gamma.py", &hash); + cbm_store_clear_file_hash(&hash); + new_path_hash = cbm_store_get_file_hash(store, project, "delta.py", &hash); + cbm_store_clear_file_hash(&hash); + cbm_store_close(store); + } + + cbm_incremental_route_t final_route; + int final_rc = live_noop_run(tmp, db_path, &final_route); + cbm_pipeline_incremental_test_reset_faults(); + free(project); + th_rmtree(tmp); + + ASSERT_EQ(initial_rc, 0); + ASSERT_TRUE(alpha_stat_ok); + ASSERT_EQ(mtime_rc, 0); + ASSERT_TRUE(edited_same_stat); + ASSERT_EQ(modify_rc, 0); + ASSERT_TRUE(modify_route != CBM_INCREMENTAL_ROUTE_NOOP); + ASSERT_EQ(old_alpha, 0); + ASSERT_EQ(new_alpha, 1); + ASSERT_EQ(add_rc, 0); + ASSERT_TRUE(add_route != CBM_INCREMENTAL_ROUTE_NOOP); + ASSERT_EQ(gamma_added, 1); + ASSERT_EQ(unlink_rc, 0); + ASSERT_EQ(delete_rc, 0); + ASSERT_TRUE(delete_route != CBM_INCREMENTAL_ROUTE_NOOP); + ASSERT_EQ(beta_left, 0); + ASSERT_EQ(rename_rc, 0); + ASSERT_EQ(renamed_rc, 0); + ASSERT_TRUE(rename_route != CBM_INCREMENTAL_ROUTE_NOOP); + ASSERT_EQ(gamma_renamed, 1); + ASSERT_EQ(old_path_hash, CBM_STORE_NOT_FOUND); + ASSERT_EQ(new_path_hash, CBM_STORE_OK); + ASSERT_EQ(final_rc, 0); + ASSERT_EQ(final_route, CBM_INCREMENTAL_ROUTE_NOOP); + PASS(); +} + static char g_route_log_capture[8192]; static atomic_flag g_route_log_spin = ATOMIC_FLAG_INIT; @@ -6368,15 +6539,15 @@ TEST(pipeline_existing_artifact_refreshes_after_default_forced_full_reindex) { ASSERT_EQ(reindex_observer.rename_calls, 1); ASSERT_EQ(reindex_observer.exports_before_publish, 0); ASSERT_EQ(reindex_observer.export_count, 1); + /* The no-ops are proven on the live generation and publish nothing; the + * artifact is still refreshed, from that already published database. */ ASSERT_EQ(explicit_rc, 0); ASSERT_EQ(explicit_route, CBM_INCREMENTAL_ROUTE_NOOP); - ASSERT_EQ(explicit_observer.rename_calls, 1); - ASSERT_EQ(explicit_observer.exports_before_publish, 0); + ASSERT_EQ(explicit_observer.rename_calls, 0); ASSERT_EQ(explicit_observer.export_count, 1); ASSERT_EQ(unchanged_rc, 0); ASSERT_EQ(unchanged_route, CBM_INCREMENTAL_ROUTE_NOOP); - ASSERT_EQ(observer.rename_calls, 1); - ASSERT_EQ(observer.exports_before_publish, 0); + ASSERT_EQ(observer.rename_calls, 0); ASSERT_EQ(observer.export_count, 1); ASSERT_TRUE(journal_ok); ASSERT_STR_EQ(journal_mode, "delete"); @@ -18853,8 +19024,9 @@ TEST(incremental_downgrade_preserves_scope_and_artifact_across_change_noop_delet ASSERT_EQ(noop.rc, 0); ASSERT_EQ(noop.route, CBM_INCREMENTAL_ROUTE_NOOP); ASSERT_TRUE(noop.tools_excluded); - ASSERT_EQ(noop.publish.rename_calls, 1); - ASSERT_EQ(noop.publish.exports_before_publish, 0); + /* Proven on the live generation: nothing is published, the artifact is + * still refreshed from the live database. */ + ASSERT_EQ(noop.publish.rename_calls, 0); ASSERT_EQ(noop.publish.export_count, 1); ASSERT_EQ(deleted.rc, 0); @@ -23352,6 +23524,8 @@ SUITE(pipeline_semantic_manifest_repro) { RUN_TEST(pipeline_sweep_reclaims_orphans_of_other_projects_and_keeps_their_live_stages); RUN_TEST(pipeline_sweep_removes_dead_writer_stage_keeps_locked_stage); RUN_TEST(pipeline_concurrent_sweep_must_not_remove_inflight_stage); + RUN_TEST(pipeline_unchanged_reindex_leaves_live_generation_untouched); + RUN_TEST(pipeline_live_noop_proof_never_hides_a_change); RUN_TEST(pipeline_fresh_index_never_reports_invalid_existing_db); RUN_TEST(pipeline_source_mutation_before_publication_preserves_previous_generation); RUN_TEST(pipeline_source_addition_before_publication_preserves_previous_generation);