mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-03 08:03:38 +08:00
fix(mem): dedicated SQLite heap only in the worker; guard the committed stat
The daemon leaked 180 KB per query. The Linux soak's quick leg went from 11 to 144 MB of RSS in ten minutes (13.1x, 628 MB/hr), mimalloc's committed bytes tracking RSS almost byte for byte and the growth linear in the query count, file descriptors flat -- where the same leg had been flat at 15 MB in July. The cause is the previous commit's SQLite heap: one mimalloc heap per thread, created on first use. The daemon runs a thread per connection, so every query ran on a fresh thread with a fresh heap, and every block the shared connection keeps beyond the request -- page cache pages, the statement cache, schema objects -- was allocated in that heap's pages. When the thread exited, its heap was deleted and those pages were left behind holding a few live blocks each: committed for as long as the connection lives, a few pages per query, never reused by anyone. The dedicated heap exists for one reason: the index worker's default heap holds the graph (40M+ blocks), and SQLite churn on that heap paid a page walk per allocation during the coverage publish (132 s vs 9.7 s on the kernel). That process has long-lived threads and exits when the index is done. So the dedicated heap is now a switch, off by default, that the index worker turns on at its entry (main.c, --index-worker); everywhere else SQLite allocates from the calling thread's default heap exactly as before the previous commit. The switch exists in every build and changes nothing where the allocator binds are compiled out. Linux soak, quick leg, before -> after: RSS 11 -> 144 MB in ten minutes (13.1x, 628 MB/hr) -> 10.6 -> 11.5 MB and flat from the first minute over 564 queries with reindexes every two minutes; query-leak leg: 11.6 MB flat over 1,376 queries; committed heap 6.8 MB flat. The same soak showed a second thing. From its second sample on, the query-leak leg reported a committed heap of 2^64 - 121 MB: mimalloc's committed statistic is a signed counter merged per thread at thread exit, and a process whose long-lived thread commits what its short-lived threads free reads it negative -- cast to size_t on the way out. That figure is one of the two the budget takes the larger of. cbm_mem_allocator_committed now reports 0 for a reading above SIZE_MAX/2, so the charge falls back to the OS number instead of a 16 EB budget breach, and the diagnostics report the committed pair through it (the peak never undercuts the current value and never carries a wrapped one), the way they already did for RSS. Detector: the soak's quick and query-leak legs (scripts/soak-legs.sh), the release-gating sequence in every venue -- RED on the previous tree (13.1x), GREEN on this one. A unit test cannot see this class: the test build keeps SQLite on libc so the sanitizers see every block. Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
+23
-3
@@ -300,6 +300,20 @@ static TSParser *get_thread_parser(const TSLanguage *ts_lang, CBMLanguage lang)
|
||||
* to mimalloc would mismatch ASan/CRT frees — there these binds compile to
|
||||
* no-ops and the build stays unchanged. */
|
||||
|
||||
/* SQLite on a dedicated mimalloc heap per thread: ON only in the index worker,
|
||||
* whose default heap holds the graph (SQLite churn on that heap paid a page
|
||||
* walk per allocation: 132 s vs 9.7 s on the kernel's coverage publish). OFF
|
||||
* everywhere else: the daemon runs a thread per connection, and a heap
|
||||
* created per such thread pins every SQLite block the shared connection
|
||||
* keeps (page cache, statement cache) to pages nobody's heap owns any more --
|
||||
* the Linux soak grew 180 KB per query, 11 -> 144 MB in ten minutes, where
|
||||
* the default thread heap had been flat (2026-09-14). */
|
||||
static _Atomic int g_sqlite_dedicated_heap;
|
||||
|
||||
void cbm_sqlite_dedicated_heap(bool on) {
|
||||
atomic_store_explicit(&g_sqlite_dedicated_heap, on ? 1 : 0, memory_order_relaxed);
|
||||
}
|
||||
|
||||
#if defined(CBM_BIND_TS_ALLOCATOR) && CBM_BIND_TS_ALLOCATOR
|
||||
#include <assert.h>
|
||||
|
||||
@@ -322,15 +336,20 @@ static TSParser *get_thread_parser(const TSLanguage *ts_lang, CBMLanguage lang)
|
||||
* thread's heap is released with the thread. */
|
||||
static _Thread_local mi_heap_t *tl_sqlite_heap;
|
||||
|
||||
/* NULL = the calling thread's default heap (mi_malloc); see the switch above. */
|
||||
static mi_heap_t *sqlite_heap(void) {
|
||||
if (!atomic_load_explicit(&g_sqlite_dedicated_heap, memory_order_relaxed)) {
|
||||
return NULL;
|
||||
}
|
||||
if (!tl_sqlite_heap) {
|
||||
tl_sqlite_heap = mi_heap_new();
|
||||
}
|
||||
return tl_sqlite_heap ? tl_sqlite_heap : mi_heap_main();
|
||||
return tl_sqlite_heap;
|
||||
}
|
||||
|
||||
static void *cbm_sqlite_malloc(int n) {
|
||||
void *block = mi_heap_malloc(sqlite_heap(), (size_t)n);
|
||||
mi_heap_t *heap = sqlite_heap();
|
||||
void *block = heap ? mi_heap_malloc(heap, (size_t)n) : mi_malloc((size_t)n);
|
||||
if (block) {
|
||||
cbm_mem_class_add_external(CBM_MEM_CLASS_STORE, mi_usable_size(block));
|
||||
}
|
||||
@@ -344,7 +363,8 @@ static void cbm_sqlite_free(void *p) {
|
||||
}
|
||||
static void *cbm_sqlite_realloc(void *p, int n) {
|
||||
size_t old_size = p ? mi_usable_size(p) : 0;
|
||||
void *grown = mi_heap_realloc(sqlite_heap(), p, (size_t)n);
|
||||
mi_heap_t *heap = sqlite_heap();
|
||||
void *grown = heap ? mi_heap_realloc(heap, p, (size_t)n) : mi_realloc(p, (size_t)n);
|
||||
if (grown) {
|
||||
cbm_mem_class_remove_external(CBM_MEM_CLASS_STORE, old_size);
|
||||
cbm_mem_class_add_external(CBM_MEM_CLASS_STORE, mi_usable_size(grown));
|
||||
|
||||
@@ -669,6 +669,12 @@ typedef struct {
|
||||
// also calls it so non-main entry points (pipeline passes) still get the binds.
|
||||
// In the test build (no CBM_BIND_TS_ALLOCATOR) this is a no-op.
|
||||
void cbm_alloc_init(void);
|
||||
/* SQLite allocates from a dedicated mimalloc heap per thread while on; the
|
||||
* index worker turns it on (its default heap holds the graph). Off elsewhere:
|
||||
* a thread-per-connection daemon would pin connection-lifetime blocks to
|
||||
* dead threads. The switch exists in every build; it changes nothing where
|
||||
* the allocator binds are compiled out. */
|
||||
void cbm_sqlite_dedicated_heap(bool on);
|
||||
|
||||
// Initialize the library. Call once at startup. Returns 0 on success.
|
||||
int cbm_init(void);
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
#include <mimalloc.h>
|
||||
#include <stdatomic.h>
|
||||
#include <stdio.h>
|
||||
#include <stdint.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
@@ -538,6 +539,20 @@ static void write_diagnostics(void) {
|
||||
* macOS/Windows, and peak is reconciled to never undercut current. */
|
||||
current_rss = cbm_mem_rss();
|
||||
peak_rss = cbm_mem_peak_rss();
|
||||
/* The committed pair has the same failure: the counter is signed and
|
||||
* merged per thread at thread exit, so a thread-per-connection daemon
|
||||
* reads it negative once short-lived threads have freed what a long-lived
|
||||
* one committed (query-leak soak, Linux 2026-09-14: heap_committed_bytes
|
||||
* printed 2^64 - 121 MB from the second sample on). cbm_mem_allocator_
|
||||
* committed reports 0 for a negative reading; the peak never undercuts
|
||||
* the current value and never carries a wrapped one. */
|
||||
current_commit = cbm_mem_allocator_committed();
|
||||
if (peak_commit > (SIZE_MAX >> 1)) {
|
||||
peak_commit = 0;
|
||||
}
|
||||
if (peak_commit < current_commit) {
|
||||
peak_commit = current_commit;
|
||||
}
|
||||
|
||||
int fds = count_open_fds();
|
||||
time_t now = time(NULL);
|
||||
|
||||
@@ -515,6 +515,15 @@ void cbm_mem_set_budget_for_tests(size_t bytes) {
|
||||
size_t cbm_mem_allocator_committed(void) {
|
||||
size_t commit = 0;
|
||||
mi_process_info(NULL, NULL, NULL, NULL, NULL, &commit, NULL, NULL);
|
||||
/* The statistic behind this is a signed counter merged per thread at
|
||||
* thread exit; a process whose long-lived thread commits what its
|
||||
* short-lived threads free reads it NEGATIVE, cast to size_t here. The
|
||||
* daemon's query-leak soak reported 2^64 - 121 MB from the second sample
|
||||
* on (Linux, 2026-09-14). A negative reading is no reading: report 0 so
|
||||
* the charge falls back to the OS number instead of a 16 EB budget breach. */
|
||||
if (commit > (SIZE_MAX >> 1)) {
|
||||
return 0;
|
||||
}
|
||||
return commit;
|
||||
}
|
||||
|
||||
|
||||
@@ -854,6 +854,11 @@ static int run_cli(int argc, char **argv, cbm_project_lock_manager_t *project_lo
|
||||
const char *worker_marker = cli_strip_flag_value(&argc, argv, CBM_INDEX_WORKER_MARKER_ARG);
|
||||
const char *worker_quarantine =
|
||||
cli_strip_flag_value(&argc, argv, CBM_INDEX_WORKER_QUARANTINE_ARG);
|
||||
if (index_worker) {
|
||||
/* The graph lives on this process's heaps: SQLite gets heaps of its
|
||||
* own here, and only here (cbm_sqlite_dedicated_heap). */
|
||||
cbm_sqlite_dedicated_heap(true);
|
||||
}
|
||||
cbm_index_set_worker_role_options(index_worker, response_out, worker_single_thread,
|
||||
worker_marker, worker_quarantine,
|
||||
cbm_index_worker_memory_budget_bytes());
|
||||
|
||||
Reference in New Issue
Block a user