From 4a3a9b745faa37a5c1eb4746d307bdfb553ec4dd Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Thu, 16 Jul 2026 18:53:35 +0200 Subject: [PATCH 1/2] fix(store): bound the WAL with journal_size_limit so a starved log is reclaimed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On-disk write connections set no journal_size_limit, so the -wal file is never physically shrunk: all our checkpoints are PASSIVE (TRUNCATE is deliberately avoided because its ftruncate(fd,0) can SIGBUS a sibling mmap'd process on macOS), and PASSIVE never ftruncates. Under checkpoint starvation the WAL then grows without bound (reported: 115 GB, filled the drive) and even a later successful checkpoint leaves the file at its peak size (#1083). Set journal_size_limit = 256 MiB on write connections so SQLite truncates the -wal back to that bound whenever a checkpoint can finally reset the log — reclaiming disk opportunistically during a session, not only on clean close. 256 MiB is far above the healthy WAL (~4 MiB under the default 1000-page autocheckpoint), so normal indexing never triggers truncate/regrow churn; the limit only fires after abnormal growth. No TRUNCATE checkpoint is introduced. Scope: this is the *reclaim* half of #1083. It does not prevent the peak under continuous reader contention (a checkpoint can't reset the log while a reader holds a mark) — the write-amplification driver (concurrent full re-indexes of one repo) is serialized by the cross-process pipeline lock in #892. Test: a file-backed write store now reports journal_size_limit = 256 MiB (RED on main: -1 / unlimited). Signed-off-by: Martin Vogel --- src/store/store.c | 33 +++++++++++++++++++++++++++++++++ src/store/store.h | 4 ++++ tests/test_store_pragmas.c | 28 ++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+) diff --git a/src/store/store.c b/src/store/store.c index 112d5ea71..66abce06e 100644 --- a/src/store/store.c +++ b/src/store/store.c @@ -432,6 +432,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()); @@ -1081,6 +1095,25 @@ int cbm_store_checkpoint(cbm_store_t *s) { 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. diff --git a/src/store/store.h b/src/store/store.h index 963837f88..a07facbc4 100644 --- a/src/store/store.h +++ b/src/store/store.h @@ -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 diff --git a/tests/test_store_pragmas.c b/tests/test_store_pragmas.c index d0432d449..5aff16235 100644 --- a/tests/test_store_pragmas.c +++ b/tests/test_store_pragmas.c @@ -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 @@ -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); From 9dfe52f16e1cbec0b240690c84f53743f82dc2e4 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Thu, 16 Jul 2026 19:39:15 +0200 Subject: [PATCH 2/2] fix(store): warn when a checkpoint can't reset a large WAL (starvation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a checkpoint-starvation warning to cbm_store_checkpoint: read the wal_checkpoint_v2 out-params (frames in the WAL vs frames reclaimed) and, when the WAL is large (>= ~1 GiB of frames) but the PASSIVE checkpoint couldn't reset it because readers hold marks, log store.wal.starving. This is the operator visibility the reclaim limit can't provide on its own — it surfaces the starvation (and its scale) before the -wal file fills the disk. Portable (no file stat / no path handling), fires only under genuine starvation. Signed-off-by: Martin Vogel --- src/store/store.c | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/store/store.c b/src/store/store.c index 66abce06e..efd56f531 100644 --- a/src/store/store.c +++ b/src/store/store.c @@ -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 '/'. */ @@ -1087,11 +1091,28 @@ 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;"); }