Files
codebase-memory-mcp/tests/test_store_bulk.c
T
2026-10-07 23:50:20 +00:00

435 lines
18 KiB
C

/*
* test_store_bulk.c — Crash-safety tests for bulk write mode.
*
* Verifies that cbm_store_begin_bulk / cbm_store_end_bulk never switch away
* from WAL journal mode. Switching to MEMORY journal mode during bulk writes
* makes the database unrecoverable on a crash because the in-memory rollback
* journal is lost. WAL mode is inherently crash-safe: uncommitted WAL entries
* are discarded on the next open.
*
* Tests:
* bulk_pragma_wal_invariant — journal_mode stays "wal" after begin_bulk
* bulk_pragma_end_wal_invariant — journal_mode stays "wal" after end_bulk
* bulk_crash_recovery — DB is readable after simulated crash mid-bulk
*/
#include "test_framework.h"
#include <store/store.h>
#include <foundation/compat.h>
#include <sqlite3.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#ifndef _WIN32
#include <unistd.h>
#include <sys/wait.h>
#endif
/* ── Helpers ──────────────────────────────────────────────────── */
/* Query journal_mode via a separate read-only connection so the result is
* independent of any state held inside the cbm_store_t under test. */
static char *get_journal_mode(const char *db_path) {
sqlite3 *db;
if (sqlite3_open_v2(db_path, &db, SQLITE_OPEN_READONLY, NULL) != SQLITE_OK)
return NULL;
sqlite3_stmt *stmt;
char *mode = NULL;
if (sqlite3_prepare_v2(db, "PRAGMA journal_mode;", -1, &stmt, NULL) == SQLITE_OK) {
if (sqlite3_step(stmt) == SQLITE_ROW)
mode = strdup((const char *)sqlite3_column_text(stmt, 0));
sqlite3_finalize(stmt);
}
sqlite3_close(db);
return mode;
}
static void make_temp_path(char *buf, size_t n) {
snprintf(buf, n, "%s/cmm_bulk_test_%d.db", cbm_tmpdir(), (int)getpid());
}
static void cleanup_db(const char *path) {
remove(path);
char aux[512];
snprintf(aux, sizeof(aux), "%s-wal", path);
remove(aux);
snprintf(aux, sizeof(aux), "%s-shm", path);
remove(aux);
}
/* ── Tests ──────────────────────────────────────────────────────── */
/* begin_bulk must NOT switch journal_mode away from WAL. */
TEST(bulk_pragma_wal_invariant) {
char db_path[256];
make_temp_path(db_path, sizeof(db_path));
cleanup_db(db_path);
cbm_store_t *s = cbm_store_open_path(db_path);
ASSERT_NOT_NULL(s);
char *before = get_journal_mode(db_path);
ASSERT_NOT_NULL(before);
ASSERT_STR_EQ(before, "wal");
free(before);
int rc = cbm_store_begin_bulk(s);
ASSERT_EQ(rc, CBM_STORE_OK);
char *after = get_journal_mode(db_path);
ASSERT_NOT_NULL(after);
ASSERT_STR_EQ(after, "wal"); /* FAILS with bug, PASSES with fix */
free(after);
cbm_store_end_bulk(s);
cbm_store_close(s);
cleanup_db(db_path);
PASS();
}
/* end_bulk must also leave journal_mode as WAL. */
TEST(bulk_pragma_end_wal_invariant) {
char db_path[256];
make_temp_path(db_path, sizeof(db_path));
cleanup_db(db_path);
cbm_store_t *s = cbm_store_open_path(db_path);
ASSERT_NOT_NULL(s);
cbm_store_begin_bulk(s);
cbm_store_end_bulk(s);
char *mode = get_journal_mode(db_path);
ASSERT_NOT_NULL(mode);
ASSERT_STR_EQ(mode, "wal");
free(mode);
cbm_store_close(s);
cleanup_db(db_path);
PASS();
}
/* Simulate a crash mid-bulk-write: fork a child that calls begin_bulk, opens
* an explicit transaction, and then calls _exit() without committing or calling
* end_bulk. The parent verifies the database is still openable and that
* committed baseline data is intact and uncommitted data is absent.
*
* This test uses fork()/waitpid() and is therefore POSIX-only. */
#ifndef _WIN32
TEST(bulk_crash_recovery) {
char db_path[256];
make_temp_path(db_path, sizeof(db_path));
cleanup_db(db_path);
/* Write committed baseline data. */
cbm_store_t *s = cbm_store_open_path(db_path);
ASSERT_NOT_NULL(s);
int rc = cbm_store_upsert_project(s, "baseline", "/tmp/baseline");
ASSERT_EQ(rc, CBM_STORE_OK);
cbm_store_close(s);
/* Child: enter bulk mode, start a transaction, write, then crash. */
pid_t pid = fork();
if (pid == 0) {
cbm_store_t *cs = cbm_store_open_path(db_path);
if (!cs)
_exit(1);
cbm_store_begin_bulk(cs);
cbm_store_begin(cs); /* explicit open transaction */
cbm_store_upsert_project(cs, "crashed", "/tmp/crashed");
/* Crash: no COMMIT, no end_bulk, no close. */
_exit(0);
}
ASSERT_GT(pid, 0);
int status;
waitpid(pid, &status, 0);
/* Confirm child exited normally so the write actually occurred. */
ASSERT(WIFEXITED(status) && WEXITSTATUS(status) == 0);
/* Recovery: database must open cleanly. */
cbm_store_t *recovered = cbm_store_open_path(db_path);
ASSERT_NOT_NULL(recovered); /* NULL would indicate corruption */
/* Baseline commit must survive. */
cbm_project_t p = {0};
rc = cbm_store_get_project(recovered, "baseline", &p);
ASSERT_EQ(rc, CBM_STORE_OK);
ASSERT_STR_EQ(p.name, "baseline");
cbm_project_free_fields(&p);
/* Uncommitted "crashed" write must NOT appear after recovery. */
cbm_project_t p2 = {0};
int rc2 = cbm_store_get_project(recovered, "crashed", &p2);
ASSERT_NEQ(rc2, CBM_STORE_OK); /* row must be absent */
cbm_store_close(recovered);
cleanup_db(db_path);
PASS();
}
#endif /* _WIN32 */
TEST(coverage_meta_has_independent_unresolved_completeness) {
cbm_store_t *s = cbm_store_open_memory();
ASSERT_NOT_NULL(s);
sqlite3_stmt *stmt = NULL;
ASSERT_EQ(sqlite3_prepare_v2(cbm_store_get_db(s),
"SELECT unresolved_calls_complete FROM index_coverage_meta;", -1,
&stmt, NULL),
SQLITE_OK);
sqlite3_finalize(stmt);
cbm_store_close(s);
PASS();
}
TEST(coverage_meta_legacy_reader_preserves_general_metadata) {
char path[256];
make_temp_path(path, sizeof(path));
cleanup_db(path);
cbm_store_t *writer = cbm_store_open_path(path);
ASSERT_NOT_NULL(writer);
ASSERT_EQ(cbm_store_upsert_project(writer, "legacy-coverage", "/tmp/legacy-coverage"),
CBM_STORE_OK);
ASSERT_EQ(
sqlite3_exec(cbm_store_get_db(writer),
"DROP TABLE index_coverage_meta;"
"CREATE TABLE index_coverage_meta(project TEXT PRIMARY KEY,generation TEXT,"
"index_mode TEXT,recorded_at TEXT,recording_status TEXT,"
"ignored_files_stored INTEGER,ignored_files_total INTEGER,"
"coverage_version INTEGER,hash_records_complete INTEGER);"
"INSERT INTO index_coverage_meta VALUES('legacy-coverage','generation','full',"
"'recorded','truncated',1,2,5,1);",
NULL, NULL, NULL),
SQLITE_OK);
cbm_store_close(writer);
cbm_store_t *reader = cbm_store_open_path_query(path);
ASSERT_NOT_NULL(reader);
cbm_coverage_meta_t meta = {0};
ASSERT_EQ(cbm_store_coverage_meta_get(reader, "legacy-coverage", &meta), CBM_STORE_OK);
ASSERT_STR_EQ(meta.recording_status, "truncated");
ASSERT_EQ(meta.ignored_files_stored, 1);
ASSERT_EQ(meta.ignored_files_total, 2);
ASSERT_EQ(meta.coverage_version, 5);
ASSERT_TRUE(meta.hash_records_complete);
ASSERT_FALSE(meta.unresolved_calls_complete);
cbm_store_coverage_meta_clear(&meta);
cbm_store_close(reader);
writer = cbm_store_open_path(path);
ASSERT_NOT_NULL(writer);
sqlite3_stmt *stmt = NULL;
ASSERT_EQ(sqlite3_prepare_v2(cbm_store_get_db(writer),
"SELECT unresolved_calls_complete FROM index_coverage_meta;", -1,
&stmt, NULL),
SQLITE_OK);
ASSERT_EQ(sqlite3_step(stmt), SQLITE_ROW);
ASSERT_EQ(sqlite3_column_int(stmt, 0), 0);
sqlite3_finalize(stmt);
ASSERT_EQ(cbm_store_coverage_meta_get(writer, "legacy-coverage", &meta), CBM_STORE_OK);
ASSERT_STR_EQ(meta.recording_status, "truncated");
ASSERT_FALSE(meta.unresolved_calls_complete);
cbm_store_coverage_meta_clear(&meta);
cbm_store_close(writer);
cleanup_db(path);
PASS();
}
TEST(coverage_meta_completeness_round_trip_and_failed_replace) {
cbm_store_t *s = cbm_store_open_memory();
ASSERT_NOT_NULL(s);
const char *project = "capture-meta";
ASSERT_EQ(cbm_store_upsert_project(s, project, "/tmp/capture-meta"), CBM_STORE_OK);
ASSERT_EQ(cbm_store_upsert_file_hash(s, project, "gap.js", "fixture", 0, 0), CBM_STORE_OK);
cbm_coverage_row_t row = {.rel_path = "gap.js", .kind = "parse_partial", .detail = "2-3"};
cbm_coverage_meta_t meta = {.generation = "baseline",
.index_mode = "full",
.recording_status = "complete",
.coverage_version = 5,
.hash_records_complete = true,
.unresolved_calls_complete = true};
ASSERT_EQ(cbm_store_coverage_replace_ex(s, project, &row, 1, &meta), CBM_STORE_OK);
cbm_coverage_meta_t fetched = {0};
ASSERT_EQ(cbm_store_coverage_meta_get(s, project, &fetched), CBM_STORE_OK);
ASSERT_TRUE(fetched.unresolved_calls_complete);
cbm_store_coverage_meta_clear(&fetched);
ASSERT_EQ(
sqlite3_exec(cbm_store_get_db(s),
"CREATE TRIGGER reject_capture_meta BEFORE UPDATE ON index_coverage_meta "
"BEGIN SELECT RAISE(ABORT, 'metadata fault'); END;",
NULL, NULL, NULL),
SQLITE_OK);
meta.generation = "failed";
meta.unresolved_calls_complete = false;
ASSERT_EQ(cbm_store_coverage_replace_ex(s, project, NULL, 0, &meta), CBM_STORE_ERR);
ASSERT_EQ(cbm_store_coverage_meta_get(s, project, &fetched), CBM_STORE_OK);
ASSERT_STR_EQ(fetched.generation, "baseline");
ASSERT_TRUE(fetched.unresolved_calls_complete);
cbm_store_coverage_meta_clear(&fetched);
cbm_coverage_row_t *rows = NULL;
int count = 0;
ASSERT_EQ(cbm_store_coverage_get(s, project, &rows, &count), CBM_STORE_OK);
ASSERT_EQ(count, 1);
ASSERT_STR_EQ(rows[0].kind, "parse_partial");
cbm_store_free_coverage(rows, count);
ASSERT_EQ(
sqlite3_exec(cbm_store_get_db(s), "DROP TRIGGER reject_capture_meta;", NULL, NULL, NULL),
SQLITE_OK);
ASSERT_EQ(cbm_store_coverage_replace_ex(s, project, &row, 1, &meta), CBM_STORE_OK);
ASSERT_EQ(cbm_store_coverage_meta_get(s, project, &fetched), CBM_STORE_OK);
ASSERT_FALSE(fetched.unresolved_calls_complete);
ASSERT_STR_EQ(fetched.recording_status, "complete");
cbm_store_coverage_meta_clear(&fetched);
ASSERT_EQ(cbm_store_coverage_replace_ex(s, project, &row, 1, NULL), CBM_STORE_OK);
ASSERT_EQ(cbm_store_coverage_meta_get(s, project, &fetched), CBM_STORE_NOT_FOUND);
cbm_store_close(s);
PASS();
}
typedef struct {
int statements;
int fullscan_steps;
int vm_steps;
} unresolved_query_cost_t;
static int record_unresolved_query_cost(unsigned type, void *userdata, void *statement,
void *elapsed) {
(void)elapsed;
unresolved_query_cost_t *cost = userdata;
sqlite3_stmt *stmt = statement;
const char *sql = sqlite3_sql(stmt);
if (type == SQLITE_TRACE_PROFILE && sql &&
(strstr(sql, "FROM index_unresolved_candidates") ||
strstr(sql, "AND kind = 'unresolved_calls'"))) {
cost->statements++;
cost->fullscan_steps += sqlite3_stmt_status(stmt, SQLITE_STMTSTATUS_FULLSCAN_STEP, 0);
cost->vm_steps += sqlite3_stmt_status(stmt, SQLITE_STMTSTATUS_VM_STEP, 0);
}
return 0;
}
TEST(unresolved_coverage_queries_use_file_and_candidate_indexes) {
enum { UNRELATED_FILES = 2000 };
cbm_store_t *s = cbm_store_open_memory();
ASSERT_NOT_NULL(s);
const char *project = "bounded-lookups";
ASSERT_EQ(cbm_store_upsert_project(s, project, "/tmp/bounded-lookups"), CBM_STORE_OK);
sqlite3 *db = cbm_store_get_db(s);
ASSERT_EQ(sqlite3_exec(db, "BEGIN;", NULL, NULL, NULL), SQLITE_OK);
sqlite3_stmt *coverage = NULL;
sqlite3_stmt *candidate = NULL;
ASSERT_EQ(sqlite3_prepare_v2(db,
"INSERT INTO index_coverage(project,rel_path,kind,detail) "
"VALUES(?1,?2,'unresolved_calls',?3);",
-1, &coverage, NULL),
SQLITE_OK);
ASSERT_EQ(
sqlite3_prepare_v2(db,
"INSERT INTO index_unresolved_candidates(project,candidate,rel_path) "
"VALUES(?1,?2,?3);",
-1, &candidate, NULL),
SQLITE_OK);
for (int i = 0; i <= UNRELATED_FILES; i++) {
char path[64];
snprintf(path, sizeof(path), "file-%04d.js", i);
ASSERT_EQ(sqlite3_bind_text(coverage, 1, project, -1, SQLITE_STATIC), SQLITE_OK);
ASSERT_EQ(sqlite3_bind_text(coverage, 2, path, -1, SQLITE_TRANSIENT), SQLITE_OK);
ASSERT_EQ(sqlite3_bind_text(coverage, 3,
i == 0 ? "[{\"candidate\":\"target.fn\"}]"
: "[{\"candidate\":\"other.fn\"}]",
-1, SQLITE_STATIC),
SQLITE_OK);
ASSERT_EQ(sqlite3_step(coverage), SQLITE_DONE);
ASSERT_EQ(sqlite3_reset(coverage), SQLITE_OK);
ASSERT_EQ(sqlite3_bind_text(candidate, 1, project, -1, SQLITE_STATIC), SQLITE_OK);
ASSERT_EQ(
sqlite3_bind_text(candidate, 2, i == 0 ? "target.fn" : "other.fn", -1, SQLITE_STATIC),
SQLITE_OK);
ASSERT_EQ(sqlite3_bind_text(candidate, 3, path, -1, SQLITE_TRANSIENT), SQLITE_OK);
ASSERT_EQ(sqlite3_step(candidate), SQLITE_DONE);
ASSERT_EQ(sqlite3_reset(candidate), SQLITE_OK);
}
sqlite3_finalize(coverage);
sqlite3_finalize(candidate);
ASSERT_EQ(sqlite3_exec(db, "COMMIT;", NULL, NULL, NULL), SQLITE_OK);
unresolved_query_cost_t cost = {0};
ASSERT_EQ(sqlite3_trace_v2(db, SQLITE_TRACE_PROFILE, record_unresolved_query_cost, &cost),
SQLITE_OK);
cbm_coverage_row_t *rows = NULL;
int count = 0;
ASSERT_EQ(cbm_store_coverage_get_unresolved_path(s, project, "file-0000.js", &rows, &count),
CBM_STORE_OK);
ASSERT_EQ(count, 1);
ASSERT_STR_EQ(rows[0].rel_path, "file-0000.js");
cbm_store_free_coverage(rows, count);
bool found = false;
ASSERT_EQ(cbm_store_coverage_has_unresolved_candidate(s, project, "target.fn", &found),
CBM_STORE_OK);
ASSERT_TRUE(found);
ASSERT_EQ(cbm_store_coverage_has_unresolved_candidate(s, project, "absent.fn", &found),
CBM_STORE_OK);
ASSERT_FALSE(found);
ASSERT_EQ(sqlite3_trace_v2(db, 0, NULL, NULL), SQLITE_OK);
ASSERT_EQ(cost.statements, 3);
ASSERT_EQ(cost.fullscan_steps, 0);
ASSERT_TRUE(cost.vm_steps < 300);
cbm_store_close(s);
PASS();
}
TEST(unresolved_candidate_index_tracks_replacement_pruning_and_markers) {
cbm_store_t *s = cbm_store_open_memory();
ASSERT_NOT_NULL(s);
const char *project = "candidate-lifecycle";
ASSERT_EQ(cbm_store_upsert_project(s, project, "/tmp/candidate-lifecycle"), CBM_STORE_OK);
ASSERT_EQ(cbm_store_upsert_file_hash(s, project, "calls.js", "fixture", 0, 0), CBM_STORE_OK);
cbm_coverage_row_t row = {.rel_path = "calls.js",
.kind = "unresolved_calls",
.detail = "[{\"candidate\":\"old.fn\"}]"};
ASSERT_EQ(cbm_store_coverage_replace(s, project, &row, 1), CBM_STORE_OK);
bool found = false;
ASSERT_EQ(cbm_store_coverage_has_unresolved_candidate(s, project, "old.fn", &found),
CBM_STORE_OK);
ASSERT_TRUE(found);
row.detail = "[{\"candidate\":\"new.fn\"}]";
ASSERT_EQ(cbm_store_coverage_replace(s, project, &row, 1), CBM_STORE_OK);
ASSERT_EQ(cbm_store_coverage_has_unresolved_candidate(s, project, "old.fn", &found),
CBM_STORE_OK);
ASSERT_FALSE(found);
ASSERT_EQ(cbm_store_coverage_has_unresolved_candidate(s, project, "new.fn", &found),
CBM_STORE_OK);
ASSERT_TRUE(found);
const char *markers[] = {"[{\"truncated\":true}]", "invalid-json", "{}", "[1]"};
for (size_t i = 0; i < sizeof(markers) / sizeof(markers[0]); i++) {
row.detail = markers[i];
ASSERT_EQ(cbm_store_coverage_replace(s, project, &row, 1), CBM_STORE_OK);
ASSERT_EQ(cbm_store_coverage_has_unresolved_candidate(s, project, "absent.fn", &found),
CBM_STORE_OK);
ASSERT_TRUE(found);
}
ASSERT_EQ(cbm_store_delete_file_hash(s, project, "calls.js"), CBM_STORE_OK);
ASSERT_EQ(cbm_store_coverage_replace(s, project, &row, 1), CBM_STORE_OK);
ASSERT_EQ(cbm_store_coverage_has_unresolved_candidate(s, project, "absent.fn", &found),
CBM_STORE_OK);
ASSERT_FALSE(found);
ASSERT_EQ(cbm_store_upsert_file_hash(s, project, "calls.js", "fixture", 0, 0), CBM_STORE_OK);
ASSERT_EQ(cbm_store_coverage_replace(s, project, &row, 1), CBM_STORE_OK);
ASSERT_EQ(cbm_store_delete_project(s, project), CBM_STORE_OK);
ASSERT_EQ(cbm_store_coverage_has_unresolved_candidate(s, project, "absent.fn", &found),
CBM_STORE_OK);
ASSERT_FALSE(found);
cbm_store_close(s);
PASS();
}
/* ── Suite ──────────────────────────────────────────────────────── */
SUITE(store_bulk) {
RUN_TEST(coverage_meta_has_independent_unresolved_completeness);
RUN_TEST(coverage_meta_legacy_reader_preserves_general_metadata);
RUN_TEST(coverage_meta_completeness_round_trip_and_failed_replace);
RUN_TEST(unresolved_coverage_queries_use_file_and_candidate_indexes);
RUN_TEST(unresolved_candidate_index_tracks_replacement_pruning_and_markers);
RUN_TEST(bulk_pragma_wal_invariant);
RUN_TEST(bulk_pragma_end_wal_invariant);
#ifndef _WIN32
RUN_TEST(bulk_crash_recovery);
#endif
}