Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 55 additions & 1 deletion src/store/store.c
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ enum {
ST_FOUND = -1,
ST_BUF_16 = 16,
ST_BUF_64 = 64,
/* #1083: warn when a PASSIVE checkpoint sees a WAL this large (frames) but
* can't reset it — ~1 GiB at a 4 KiB page, far past the healthy ~1000-frame
* autocheckpoint, so it only fires under genuine starvation. */
ST_WAL_STARVE_WARN_FRAMES = 262144,
/* file: URI for the immutable read-only fallback. A path is at most
* CBM_SZ_1K; percent-encoding can triple it, plus the "file://" prefix,
* the "?immutable=1" suffix and a leading '/'. */
Expand Down Expand Up @@ -432,6 +436,20 @@ static int configure_pragmas(cbm_store_t *s, bool in_memory, bool read_only) {
if (rc != CBM_STORE_OK) {
return rc;
}
/* #1083: bound the WAL file so a checkpoint-starved log is physically
* reclaimed the next time a checkpoint can reset it. Our checkpoints are
* all PASSIVE (they never ftruncate — see cbm_store_checkpoint's SIGBUS
* note), so without a size limit the -wal file only ever grows; a
* journal_size_limit truncates it back to N bytes on the next successful
* reset. N is far above the healthy WAL (~4 MiB under the default
* 1000-page autocheckpoint), so normal indexing never triggers
* truncate/regrow churn — it only fires after abnormal growth. We do NOT
* use a TRUNCATE checkpoint: its ftruncate(fd,0) can raise SIGBUS in a
* sibling process that has the DB mmap'd on macOS. */
rc = exec_sql(s, "PRAGMA journal_size_limit = 268435456;"); /* 256 MiB */
if (rc != CBM_STORE_OK) {
return rc;
}
char mmap_sql[ST_BUF_64];
snprintf(mmap_sql, sizeof(mmap_sql), "PRAGMA mmap_size = %lld;",
(long long)cbm_store_resolve_mmap_size());
Expand Down Expand Up @@ -1073,14 +1091,50 @@ int cbm_store_checkpoint(cbm_store_t *s) {
* SIGBUS in a sibling process that has the DB mmap'd through SQLite
* when it next faults a page in the now-shorter region.
* See https://www.sqlite.org/c3ref/c_checkpoint_full.html */
int rc = sqlite3_wal_checkpoint_v2(s->db, NULL, SQLITE_CHECKPOINT_PASSIVE, NULL, NULL);
int wal_frames = 0;
int checkpointed = 0;
int rc = sqlite3_wal_checkpoint_v2(s->db, NULL, SQLITE_CHECKPOINT_PASSIVE, &wal_frames,
&checkpointed);
if (rc != SQLITE_OK) {
store_set_error_sqlite(s, "checkpoint");
return CBM_STORE_ERR;
}
/* #1083: a large WAL that a PASSIVE checkpoint can't fully reset — because
* concurrent readers hold marks — is the checkpoint-starvation signal. Warn
* so an operator can see it (and the driving processes) before the -wal file
* fills the disk; journal_size_limit only reclaims once a reset succeeds.
* wal_frames = frames in the WAL, checkpointed = frames reclaimed this pass. */
if (wal_frames >= ST_WAL_STARVE_WARN_FRAMES && checkpointed < wal_frames) {
char frames_buf[ST_BUF_16];
char ckpt_buf[ST_BUF_16];
snprintf(frames_buf, sizeof(frames_buf), "%d", wal_frames);
snprintf(ckpt_buf, sizeof(ckpt_buf), "%d", checkpointed);
cbm_log_warn("store.wal.starving", "wal_frames", frames_buf, "checkpointed", ckpt_buf,
"hint",
"concurrent readers block the WAL reset — the -wal file keeps growing");
}
return exec_sql(s, "PRAGMA optimize;");
}

/* #1083: the WAL size limit configured on this (write) connection, in bytes.
* -1 means unlimited (SQLite's default — the pre-fix behavior). Per-connection
* and not persisted, so it can only be read on the connection that set it. */
int64_t cbm_store_journal_size_limit(cbm_store_t *s) {
if (!s || !s->db) {
return -1;
}
sqlite3_stmt *stmt = NULL;
if (sqlite3_prepare_v2(s->db, "PRAGMA journal_size_limit;", -1, &stmt, NULL) != SQLITE_OK) {
return -1;
}
int64_t limit = -1;
if (sqlite3_step(stmt) == SQLITE_ROW) {
limit = sqlite3_column_int64(stmt, 0);
}
sqlite3_finalize(stmt);
return limit;
}

/* ── Dump ───────────────────────────────────────────────────────── */

/* Dump entire in-memory database to a file via sqlite3_backup.
Expand Down
4 changes: 4 additions & 0 deletions src/store/store.h
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,10 @@ int cbm_store_create_indexes(cbm_store_t *s);
/* Force WAL checkpoint + PRAGMA optimize. */
int cbm_store_checkpoint(cbm_store_t *s);

/* #1083: the WAL size limit (journal_size_limit) applied to this write
* connection, in bytes; -1 = unlimited (SQLite default / pre-fix). */
int64_t cbm_store_journal_size_limit(cbm_store_t *s);

/* Resolve the mmap_size pragma value applied to on-disk stores from the
* CBM_SQLITE_MMAP_SIZE environment variable. Defaults to 67108864 (64 MB)
* when the variable is unset, malformed, or partially numeric. Negative
Expand Down
28 changes: 28 additions & 0 deletions tests/test_store_pragmas.c
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,33 @@ TEST(store_open_with_mmap_disabled) {
PASS();
}

/* #1083: on-disk write connections must bound the WAL via journal_size_limit
* so a checkpoint-starved log is physically reclaimed once a checkpoint can
* reset it. On main this is UNSET (-1 = unlimited), so the -wal file only ever
* grows (all our checkpoints are PASSIVE and never ftruncate). Read the pragma
* back on the SAME connection — it's per-connection and not persisted. */
TEST(journal_size_limit_bounds_wal_issue1083) {
char tmp_path[256];
snprintf(tmp_path, sizeof(tmp_path), "%s/cbm_test_jsl_%d.db", cbm_tmpdir(), (int)getpid());
unlink(tmp_path);

cbm_store_t *s = cbm_store_open_path(tmp_path);
ASSERT(s != NULL);
/* 256 MiB — far above the healthy WAL (~4 MiB), so no truncate/regrow churn
* in normal operation; it only fires after abnormal (starved) growth. */
ASSERT(cbm_store_journal_size_limit(s) == (int64_t)268435456);
cbm_store_close(s);

unlink(tmp_path);
char tmp_wal[300];
char tmp_shm[300];
snprintf(tmp_wal, sizeof(tmp_wal), "%s-wal", tmp_path);
snprintf(tmp_shm, sizeof(tmp_shm), "%s-shm", tmp_path);
unlink(tmp_wal);
unlink(tmp_shm);
PASS();
}


/* #896: a row-scan that dies mid-stream (SQLITE_CORRUPT) must surface a
* loud store error, not masquerade as a clean end of results. Counts are
Expand Down Expand Up @@ -179,6 +206,7 @@ TEST(corrupt_page_scan_returns_error_not_truncation) {
}

SUITE(store_pragmas) {
RUN_TEST(journal_size_limit_bounds_wal_issue1083);
RUN_TEST(corrupt_page_scan_returns_error_not_truncation);
RUN_TEST(mmap_size_default_when_unset);
RUN_TEST(mmap_size_zero_disables_mmap);
Expand Down
Loading