From 6bf6e34e430e637bdc125ac7f9b6de453ac3e54d Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Wed, 14 Oct 2026 22:54:02 +0200 Subject: [PATCH] perf(store): evict idle adjacency layers, budget them, own memory class The daemon keeps adjacency layers (store/graph_mem.h) resident across requests -- 237 MB for the kernel -- with no end of life but LRU pressure. - Idle eviction: a layer nobody has held for CBM_GRAPH_MEM_IDLE_EVICT_MS (10 minutes, measured from its last release) is evicted on the next request: cbm_mcp_handle_tool sweeps after releasing the request store, and every acquire sweeps too. No timer thread. Ten minutes outlasts the gaps between an agent's graph calls within one task, while a session that moved on stops pinning the memory; a wrong guess costs one rebuild (1.2-1.5 s on the 17 GB kernel index, tens of ms on ordinary repos). The clock is a test seam (cbm_graph_mem_set_clock_for_testing), so tests move time instead of waiting. - Worker budget: application_worker_memory_slice_locked hands index workers the aggregate minus the resident layer bytes (cbm_graph_mem_cached_bytes), at least one byte per active job so a slice never reads as "no cap". - Memory class: layers are charged to the new CBM_MEM_CLASS_GRAPH_CACHE ("graph_cache" in census and class logs) instead of STORE. Responses stay byte-identical to the baseline on django and redis (tools and Cypher) and on the kernel trace/hub and extra BFS/degree cases. Signed-off-by: Martin Vogel --- src/daemon/application.c | 20 ++++- src/foundation/mem_core.c | 2 +- src/foundation/mem_core.h | 4 + src/mcp/mcp.c | 4 + src/store/graph_mem.c | 91 ++++++++++++++++++---- src/store/graph_mem.h | 43 +++++++++-- tests/test_daemon_application.c | 28 +++++++ tests/test_graph_mem.c | 130 ++++++++++++++++++++++++++++++++ tests/test_mem.c | 1 + 9 files changed, 299 insertions(+), 24 deletions(-) diff --git a/src/daemon/application.c b/src/daemon/application.c index 7ad9fd21e..22b6f0d1a 100644 --- a/src/daemon/application.c +++ b/src/daemon/application.c @@ -21,6 +21,7 @@ #include "mcp/mcp.h" #include "mcp/mcp_internal.h" #include "pipeline/pipeline.h" +#include "store/graph_mem.h" #include "ui/config.h" #include "watcher/watcher.h" @@ -1893,7 +1894,13 @@ static size_t application_active_job_count_locked(cbm_daemon_application_t *appl /* Decision 2 (#1997 #832): a worker's memory slice is the aggregate budget * divided by the jobs active at spawn time. The job being spawned is already * in the table, so the divisor never drops below one; a zero aggregate means - * "no cap" and the worker falls back to its own RAM-fraction budget. */ + * "no cap" and the worker falls back to its own RAM-fraction budget. + * + * The daemon's resident adjacency layers (store/graph_mem.h) are part of the + * same aggregate, so workers split what is left after them. They are capped at + * a quarter of the daemon budget, which the aggregate defaults to; only a + * smaller configured aggregate can be exceeded, and then every job still gets + * at least one byte, as above — a zero slice would read as "no cap". */ static size_t application_worker_memory_slice_locked(cbm_daemon_application_t *application, size_t *active_jobs_out) { size_t active = application_active_job_count_locked(application); @@ -1903,7 +1910,16 @@ static size_t application_worker_memory_slice_locked(cbm_daemon_application_t *a if (active_jobs_out) { *active_jobs_out = active; } - return application->aggregate_memory_budget_bytes / active; + size_t aggregate = application->aggregate_memory_budget_bytes; + if (aggregate == 0) { + return 0; + } + size_t held = cbm_graph_mem_cached_bytes(); + size_t available = held < aggregate ? aggregate - held : 0; + if (available < active) { + available = active; + } + return available / active; } /* Compare the effective index request, not its JSON spelling. yyjson's deep diff --git a/src/foundation/mem_core.c b/src/foundation/mem_core.c index 121d2696d..cc6e8f9d5 100644 --- a/src/foundation/mem_core.c +++ b/src/foundation/mem_core.c @@ -67,7 +67,7 @@ static mem_class_stats_t g_classes[CBM_MEM_CLASS_COUNT]; static const char *const g_class_names[CBM_MEM_CLASS_COUNT] = { "other", "gbuf_node", "gbuf_edge", "gbuf_string", "gbuf_index", "extract", "arena", - "ts_tree", "semantic", "dump", "store", "hash_table", "dyn_array", + "ts_tree", "semantic", "dump", "store", "hash_table", "dyn_array", "graph_cache", }; const char *cbm_mem_class_name(cbm_mem_class_t cls) { diff --git a/src/foundation/mem_core.h b/src/foundation/mem_core.h index 1514c3c68..e61de900a 100644 --- a/src/foundation/mem_core.h +++ b/src/foundation/mem_core.h @@ -66,6 +66,10 @@ typedef enum { CBM_MEM_CLASS_STORE, /* SQLite (bound mem methods) + store batches and row buffers */ CBM_MEM_CLASS_HASH_TABLE, /* CBMHashTable buckets/entries not claimed by an owner class */ CBM_MEM_CLASS_DYN_ARRAY, /* CBM_DYN_ARRAY item storage (every cbm_da_* user) */ + /* The daemon's in-memory adjacency layers (store/graph_mem.h): resident + * across requests, so they are subtracted from index-worker budgets and + * must read apart from SQLite's request-scoped memory (STORE). */ + CBM_MEM_CLASS_GRAPH_CACHE, CBM_MEM_CLASS_COUNT } cbm_mem_class_t; diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index e67846453..dbd09ea51 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -59,6 +59,7 @@ enum { #include "mcp/architecture_jobs.h" #include "mcp/test_impact.h" #include "mcp/test_impact_engine.h" +#include "store/graph_mem.h" #include "store/store.h" #include "store/store_impact.h" #include @@ -20618,6 +20619,9 @@ char *cbm_mcp_handle_tool(cbm_mcp_server_t *srv, const char *tool_name, const ch } cbm_mem_phase_mark("request.release_store"); release_request_store(srv); + /* Adjacency layers nobody used for a while go now: the next request is + * the daemon's clock, so no timer thread is needed (store/graph_mem.h). */ + cbm_graph_mem_evict_expired(); cbm_mem_phase_mark("idle"); /* One census per completed request, so growth can be attributed to a POOL * rather than inferred from a process total (#581). Emitted after the diff --git a/src/store/graph_mem.c b/src/store/graph_mem.c index 02d5d6cd9..e046e3801 100644 --- a/src/store/graph_mem.c +++ b/src/store/graph_mem.c @@ -39,7 +39,8 @@ struct cbm_graph_mem { bool ready; /* built; false while its builder runs */ bool detached; /* unlinked from the registry; freed at the last release */ uint64_t last_use; - size_t bytes; /* reserved estimate while building, actual once ready */ + uint64_t idle_since_ms; /* when the last reference went away */ + size_t bytes; /* reserved estimate while building, actual once ready */ /* The graph, immutable once ready. Offsets cover node ids 0..n_ids-1: * the out-edges of id u are out_nbr/out_type[out_off[u] .. out_off[u+1]). */ @@ -67,12 +68,30 @@ static sqlite3_mutex *gm_registry_mutex(void) { return sqlite3_mutex_alloc(SQLITE_MUTEX_STATIC_APP2); } +#ifdef CBM_ENABLE_TEST_SEAMS +static uint64_t (*g_clock_seam)(void); +static size_t g_cached_bytes_seam; +void cbm_graph_mem_set_clock_for_testing(uint64_t (*now_ms)(void)) { + g_clock_seam = now_ms; +} +void cbm_graph_mem_set_cached_bytes_for_testing(size_t bytes) { + g_cached_bytes_seam = bytes; +} +static uint64_t gm_now_ms(void) { + return g_clock_seam ? g_clock_seam() : cbm_now_ms(); +} +#else +static uint64_t gm_now_ms(void) { + return cbm_now_ms(); +} +#endif + static void *gm_alloc(size_t bytes) { - return cbm_alloc(CBM_MEM_CLASS_STORE, bytes ? bytes : 1); + return cbm_alloc(CBM_MEM_CLASS_GRAPH_CACHE, bytes ? bytes : 1); } static void gm_free_ptr(void *p) { - cbm_free(CBM_MEM_CLASS_STORE, p); + cbm_free(CBM_MEM_CLASS_GRAPH_CACHE, p); } static void gm_free_graph(cbm_graph_mem_t *g) { @@ -166,6 +185,31 @@ static bool gm_make_room(size_t bytes) { return cbm_mem_charged() + bytes <= budget; } +/* Retire every layer no request has held for CBM_GRAPH_MEM_IDLE_EVICT_MS. + * Returns the bytes given back. */ +static size_t gm_evict_expired_locked(uint64_t now) { + size_t freed = 0; + cbm_graph_mem_t *g = g_layers; + while (g) { + cbm_graph_mem_t *next = g->next; + if (g->ready && g->refs == 0 && now >= g->idle_since_ms && + now - g->idle_since_ms >= CBM_GRAPH_MEM_IDLE_EVICT_MS) { + freed += g->bytes; + gm_retire(g); + } + g = next; + } + return freed; +} + +static void gm_log_evicted(size_t freed) { + if (freed > 0) { + char bytes[CBM_SZ_32]; + (void)snprintf(bytes, sizeof(bytes), "%zu", freed); + cbm_log_info("graph_mem.evict", "reason", "idle", "bytes", bytes); + } +} + /* ── Build ──────────────────────────────────────────────────────── */ static bool gm_bounds(sqlite3 *db, int64_t *max_node, int64_t *max_edge) { @@ -226,7 +270,7 @@ static bool gm_grow_u32(uint32_t **arr, size_t *cap, size_t need) { while (next < need) { next *= GM_GROWTH; } - uint32_t *grown = cbm_realloc(CBM_MEM_CLASS_STORE, *arr, next * sizeof(uint32_t)); + uint32_t *grown = cbm_realloc(CBM_MEM_CLASS_GRAPH_CACHE, *arr, next * sizeof(uint32_t)); if (!grown) { return false; } @@ -264,12 +308,12 @@ static bool gm_scan_row(cbm_graph_mem_t *g, gm_scan_t *sc, sqlite3_stmt *st) { } if (g->n_edges >= sc->edge_cap) { size_t next = sc->edge_cap ? sc->edge_cap * GM_GROWTH : 1; - uint32_t *nbr = cbm_realloc(CBM_MEM_CLASS_STORE, g->out_nbr, next * sizeof(uint32_t)); + uint32_t *nbr = cbm_realloc(CBM_MEM_CLASS_GRAPH_CACHE, g->out_nbr, next * sizeof(uint32_t)); if (!nbr) { return false; } g->out_nbr = nbr; - uint8_t *types = cbm_realloc(CBM_MEM_CLASS_STORE, g->out_type, next); + uint8_t *types = cbm_realloc(CBM_MEM_CLASS_GRAPH_CACHE, g->out_type, next); if (!types) { return false; } @@ -294,7 +338,7 @@ static bool gm_scan_row(cbm_graph_mem_t *g, gm_scan_t *sc, sqlite3_stmt *st) { * back. Iterating sources in id order keeps every in-list sorted by source. */ static bool gm_build_inbound(cbm_graph_mem_t *g) { size_t n_ids = g->n_ids; - g->in_off = cbm_calloc(CBM_MEM_CLASS_STORE, (n_ids + 1) * sizeof(uint32_t)); + g->in_off = cbm_calloc(CBM_MEM_CLASS_GRAPH_CACHE, (n_ids + 1) * sizeof(uint32_t)); g->in_nbr = gm_alloc((size_t)g->n_edges * sizeof(uint32_t)); g->in_type = gm_alloc(g->n_edges); if (!g->in_off || !g->in_nbr || !g->in_type) { @@ -361,8 +405,8 @@ static bool gm_build(cbm_graph_mem_t *g, sqlite3 *db, int64_t max_node, int64_t /* max(edges.id) over-counts after deletions; give the slack back. */ if (g->n_edges < sc.edge_cap) { size_t keep = g->n_edges ? g->n_edges : 1; - uint32_t *nbr = cbm_realloc(CBM_MEM_CLASS_STORE, g->out_nbr, keep * sizeof(uint32_t)); - uint8_t *types = nbr ? cbm_realloc(CBM_MEM_CLASS_STORE, g->out_type, keep) : NULL; + uint32_t *nbr = cbm_realloc(CBM_MEM_CLASS_GRAPH_CACHE, g->out_nbr, keep * sizeof(uint32_t)); + uint8_t *types = nbr ? cbm_realloc(CBM_MEM_CLASS_GRAPH_CACHE, g->out_type, keep) : NULL; if (nbr) { g->out_nbr = nbr; } @@ -397,11 +441,11 @@ static cbm_graph_mem_t *gm_take(cbm_graph_mem_t *hit) { } static cbm_graph_mem_t *gm_new_entry(const char *db_path, const char *version, size_t est) { - cbm_graph_mem_t *g = cbm_calloc(CBM_MEM_CLASS_STORE, sizeof(*g)); + cbm_graph_mem_t *g = cbm_calloc(CBM_MEM_CLASS_GRAPH_CACHE, sizeof(*g)); if (!g) { return NULL; } - g->path = cbm_mem_strdup(CBM_MEM_CLASS_STORE, db_path); + g->path = cbm_mem_strdup(CBM_MEM_CLASS_GRAPH_CACHE, db_path); if (!g->path) { gm_free_ptr(g); return NULL; @@ -416,11 +460,14 @@ cbm_graph_mem_t *cbm_graph_mem_acquire(sqlite3 *db, const char *db_path, const c if (!db || !db_path || !version || !version[0] || strlen(version) >= GM_VERSION_MAX) { return NULL; } + uint64_t now = gm_now_ms(); sqlite3_mutex *mu = gm_registry_mutex(); sqlite3_mutex_enter(mu); + size_t freed = gm_evict_expired_locked(now); cbm_graph_mem_t *hit = gm_find(db_path, version); cbm_graph_mem_t *taken = hit ? gm_take(hit) : NULL; sqlite3_mutex_leave(mu); + gm_log_evicted(freed); if (hit) { return taken; } @@ -496,9 +543,13 @@ void cbm_graph_mem_release(cbm_graph_mem_t *g) { if (!g) { return; } + uint64_t now = gm_now_ms(); sqlite3_mutex *mu = gm_registry_mutex(); sqlite3_mutex_enter(mu); g->refs--; + if (g->refs == 0) { + g->idle_since_ms = now; + } if (g->refs == 0 && g->detached) { g_total_bytes -= g->bytes; gm_destroy(g); @@ -506,6 +557,15 @@ void cbm_graph_mem_release(cbm_graph_mem_t *g) { sqlite3_mutex_leave(mu); } +void cbm_graph_mem_evict_expired(void) { + uint64_t now = gm_now_ms(); + sqlite3_mutex *mu = gm_registry_mutex(); + sqlite3_mutex_enter(mu); + size_t freed = gm_evict_expired_locked(now); + sqlite3_mutex_leave(mu); + gm_log_evicted(freed); +} + /* ── Queries ────────────────────────────────────────────────────── */ cbm_graph_mem_types_t cbm_graph_mem_type_mask(const cbm_graph_mem_t *g, const char *const *types, @@ -557,7 +617,7 @@ static size_t gm_set_home(uint32_t id, size_t mask) { static bool gm_set_grow(gm_set_t *s) { size_t cap = s->cap ? s->cap * GM_GROWTH : GM_SET_MIN_CAP; - uint32_t *slots = cbm_calloc(CBM_MEM_CLASS_STORE, cap * sizeof(uint32_t)); + uint32_t *slots = cbm_calloc(CBM_MEM_CLASS_GRAPH_CACHE, cap * sizeof(uint32_t)); if (!slots) { return false; } @@ -619,7 +679,7 @@ static bool gm_hops_push(gm_hops_t *h, uint32_t id, int hop) { if (h->count == h->cap) { size_t next = h->cap ? h->cap * GM_GROWTH : GM_SET_MIN_CAP; cbm_graph_mem_hop_t *rows = - cbm_realloc(CBM_MEM_CLASS_STORE, h->rows, next * sizeof(cbm_graph_mem_hop_t)); + cbm_realloc(CBM_MEM_CLASS_GRAPH_CACHE, h->rows, next * sizeof(cbm_graph_mem_hop_t)); if (!rows) { return false; } @@ -742,6 +802,11 @@ int cbm_graph_mem_cached_count(void) { } size_t cbm_graph_mem_cached_bytes(void) { +#ifdef CBM_ENABLE_TEST_SEAMS + if (g_cached_bytes_seam) { + return g_cached_bytes_seam; + } +#endif sqlite3_mutex *mu = gm_registry_mutex(); sqlite3_mutex_enter(mu); size_t bytes = g_total_bytes; diff --git a/src/store/graph_mem.h b/src/store/graph_mem.h index 575a1e148..7e8d30a39 100644 --- a/src/store/graph_mem.h +++ b/src/store/graph_mem.h @@ -19,12 +19,15 @@ * * Lifetime: the daemon closes its query stores after every request, so layers * live in a process-wide registry, shared by refcount across request threads. - * Every byte is allocated through the memory core (CBM_MEM_CLASS_STORE) and - * the registry is bounded: the layers together stay under a quarter of the - * memory budget, idle ones are evicted oldest-first to make room, and a layer - * that still would not fit (or that would push the process over its budget) is - * not built — the caller keeps the SQL path. A build is all-or-nothing: a - * failed or refused build leaves no partial layer behind. + * Every byte is allocated through the memory core (CBM_MEM_CLASS_GRAPH_CACHE) + * and the registry is bounded: the layers together stay under a quarter of + * the memory budget, unheld ones are evicted oldest-first to make room, and a + * layer that still would not fit (or that would push the process over its + * budget) is not built — the caller keeps the SQL path. A build is + * all-or-nothing: a failed or refused build leaves no partial layer behind. + * A layer nobody held for CBM_GRAPH_MEM_IDLE_EVICT_MS is evicted on the next + * request (no timer thread), and the daemon subtracts the resident bytes from + * what it hands index workers (cbm_graph_mem_cached_bytes). */ #ifndef CBM_GRAPH_MEM_H #define CBM_GRAPH_MEM_H @@ -81,15 +84,39 @@ int cbm_graph_mem_degree(const cbm_graph_mem_t *g, int64_t id, bool inbound, /* Bytes this layer holds (all through the memory core). */ size_t cbm_graph_mem_bytes(const cbm_graph_mem_t *g); +/* ── Idle eviction ──────────────────────────────────────────────── */ + +/* How long a layer may sit unheld before it goes. Ten minutes outlasts the + * gaps between an agent's graph calls within one task (seconds to a few + * minutes), so a working session keeps its layer, while a session that moved + * on stops pinning it (237 MB for the kernel). A wrong guess costs one rebuild + * — about 1.2-1.5 s on the 17 GB kernel index, tens of ms on ordinary repos — + * against ten minutes of held memory. */ +enum { CBM_GRAPH_MEM_IDLE_EVICT_MS = 10 * 60 * 1000 }; + +/* Evict every layer unheld for CBM_GRAPH_MEM_IDLE_EVICT_MS. The daemon calls + * it at the end of every request and acquire runs it too: a daemon that stops + * asking stops paying, with no timer thread. */ +void cbm_graph_mem_evict_expired(void); + /* ── Registry introspection (tests, diagnostics) ─────────────────── */ -/* Layers currently held by the registry, and their total bytes. */ +/* Layers currently held by the registry, and their total bytes — every + * resident layer, held or not, which is what index-worker budgets give up. */ int cbm_graph_mem_cached_count(void); size_t cbm_graph_mem_cached_bytes(void); /* Builds completed since process start: a test's proof that a call hit the * cache (unchanged) or built (incremented). */ uint64_t cbm_graph_mem_build_count(void); -/* Drop every idle layer (refs == 0). Held layers stay until released. */ +/* Drop every unheld layer (refs == 0), however recently used. */ void cbm_graph_mem_drop_idle(void); +#ifdef CBM_ENABLE_TEST_SEAMS +/* The clock idle eviction reads; NULL restores cbm_now_ms. Tests move time + * instead of waiting for it. */ +void cbm_graph_mem_set_clock_for_testing(uint64_t (*now_ms)(void)); +/* Pin cbm_graph_mem_cached_bytes to `bytes`; 0 restores the live reading. */ +void cbm_graph_mem_set_cached_bytes_for_testing(size_t bytes); +#endif + #endif /* CBM_GRAPH_MEM_H */ diff --git a/tests/test_daemon_application.c b/tests/test_daemon_application.c index e4f65adca..a923043ca 100644 --- a/tests/test_daemon_application.c +++ b/tests/test_daemon_application.c @@ -19,6 +19,7 @@ #include "mcp/mcp.h" #include "mcp/mcp_internal.h" #include "pipeline/pipeline.h" +#include "store/graph_mem.h" #include "store/store.h" #include "ui/config.h" #include "watcher/watcher.h" @@ -5889,6 +5890,32 @@ TEST(daemon_application_worker_slice_is_aggregate_over_active_jobs) { PASS(); } +/* The daemon's resident adjacency layers are its own memory: a worker's slice + * is the aggregate minus them, and never "no cap" (0) when they exceed it. The + * held bytes come through the graph_mem seam, so no layer has to be built. */ +TEST(daemon_application_worker_slice_leaves_room_for_graph_cache) { + const size_t mib = (size_t)1024U * 1024U; + const size_t aggregate = 1000U * mib; + cbm_daemon_application_config_t config = {.aggregate_memory_budget_bytes = aggregate}; + cbm_graph_mem_drop_idle(); + cbm_daemon_application_t *application = cbm_daemon_application_new(&config); + size_t without_layers = cbm_daemon_application_worker_memory_budget_bytes(application); + cbm_graph_mem_set_cached_bytes_for_testing(200U * mib); + size_t with_layers = cbm_daemon_application_worker_memory_budget_bytes(application); + cbm_graph_mem_set_cached_bytes_for_testing(aggregate + 5U); + size_t layers_over_aggregate = cbm_daemon_application_worker_memory_budget_bytes(application); + cbm_graph_mem_set_cached_bytes_for_testing(0); + bool stopped = application && cbm_daemon_application_shutdown(application, APP_TEST_TIMEOUT_MS); + cbm_daemon_application_free(application); + + ASSERT_NOT_NULL(application); + ASSERT_TRUE(stopped); + ASSERT_EQ(without_layers, aggregate); + ASSERT_EQ(with_layers, aggregate - 200U * mib); + ASSERT_EQ(layers_over_aggregate, 1); + PASS(); +} + /* Decision A (#1997 #832): the over-budget verdict travels as a CLEAN worker * exit carrying an error response. The daemon passes that response through * verbatim on the first attempt — no crash/hang recovery loop, no quarantine @@ -6960,6 +6987,7 @@ SUITE(daemon_application) { RUN_TEST(daemon_application_queues_explicit_index_behind_physical_job_limit); RUN_TEST(daemon_application_default_limit_admits_four_and_rejects_fifth); RUN_TEST(daemon_application_worker_slice_is_aggregate_over_active_jobs); + RUN_TEST(daemon_application_worker_slice_leaves_room_for_graph_cache); RUN_TEST(daemon_application_over_budget_response_passes_through_without_recovery); RUN_TEST(daemon_application_free_reports_retained_live_ownership); RUN_TEST(daemon_application_tool_error_is_failed_job_even_after_clean_worker_exit); diff --git a/tests/test_graph_mem.c b/tests/test_graph_mem.c index 7be3c6d14..9e4020b61 100644 --- a/tests/test_graph_mem.c +++ b/tests/test_graph_mem.c @@ -12,6 +12,8 @@ #include "test_helpers.h" #include #include +#include +#include #include #include #include @@ -598,6 +600,131 @@ TEST(graph_mem_concurrent_requests_build_once) { PASS(); } +/* Idle eviction reads this clock through the seam: tests move time instead + * of waiting for it. */ +static uint64_t g_gmt_now_ms; + +static uint64_t gmt_fake_clock(void) { + return g_gmt_now_ms; +} + +static void gmt_clock_pin(uint64_t now_ms) { + g_gmt_now_ms = now_ms; + cbm_graph_mem_set_clock_for_testing(gmt_fake_clock); +} + +TEST(graph_mem_idle_layer_evicted_after_idle_time) { + gmt_fixture_t f; + ASSERT_TRUE(gmt_fixture_build(&f)); + uint64_t builds = cbm_graph_mem_build_count(); + size_t saved = gmt_budget_pin(gmt_budget_roomy()); + const uint64_t t0 = 1000000; + gmt_clock_pin(t0); + cbm_mem_class_flush_thread(); + size_t class_before = cbm_mem_class_live_bytes(CBM_MEM_CLASS_GRAPH_CACHE); + + int first = gmt_request(f.db, f.ids[0]); /* built and released at t0 */ + cbm_mem_class_flush_thread(); + size_t class_cached = cbm_mem_class_live_bytes(CBM_MEM_CLASS_GRAPH_CACHE); + g_gmt_now_ms = t0 + CBM_GRAPH_MEM_IDLE_EVICT_MS - 1; + cbm_graph_mem_evict_expired(); + int cached_just_before = cbm_graph_mem_cached_count(); + g_gmt_now_ms = t0 + CBM_GRAPH_MEM_IDLE_EVICT_MS; + cbm_graph_mem_evict_expired(); + int cached_at_expiry = cbm_graph_mem_cached_count(); + size_t bytes_at_expiry = cbm_graph_mem_cached_bytes(); + cbm_mem_class_flush_thread(); + size_t class_evicted = cbm_mem_class_live_bytes(CBM_MEM_CLASS_GRAPH_CACHE); + /* The next walk builds again: eviction is a cache miss, not an error. */ + int again = gmt_request(f.db, f.ids[0]); + uint64_t built = cbm_graph_mem_build_count() - builds; + + cbm_graph_mem_set_clock_for_testing(NULL); + cbm_mem_set_budget_for_tests(saved); + gmt_fixture_free(&f); + + ASSERT_GT(first, 0); + /* The layer is charged to its own class and given back in full. */ + ASSERT_GT(class_cached, class_before); + ASSERT_EQ(class_evicted, class_before); + ASSERT_EQ(cached_just_before, 1); + ASSERT_EQ(cached_at_expiry, 0); + ASSERT_EQ(bytes_at_expiry, 0); + ASSERT_EQ(again, first); + ASSERT_EQ(built, 2); + PASS(); +} + +/* Idle time counts from the LAST release: a layer a request still holds is + * never evicted, however old its build. */ +TEST(graph_mem_held_layer_is_not_idle) { + gmt_fixture_t f; + ASSERT_TRUE(gmt_fixture_build(&f)); + size_t saved = gmt_budget_pin(gmt_budget_roomy()); + const uint64_t t0 = 5000000; + gmt_clock_pin(t0); + + cbm_store_t *held = cbm_store_open_path_query(f.db); + int walked = held ? gmt_visited(held, f.ids[0]) : -1; /* acquired at t0, still held */ + g_gmt_now_ms = t0 + 3 * (uint64_t)CBM_GRAPH_MEM_IDLE_EVICT_MS; + cbm_graph_mem_evict_expired(); + int cached_while_held = cbm_graph_mem_cached_count(); + const uint64_t released_at = g_gmt_now_ms; + cbm_store_close(held); /* released now: idle from here */ + cbm_graph_mem_evict_expired(); + int cached_after_release = cbm_graph_mem_cached_count(); + g_gmt_now_ms = released_at + CBM_GRAPH_MEM_IDLE_EVICT_MS; + cbm_graph_mem_evict_expired(); + int cached_after_idle = cbm_graph_mem_cached_count(); + + cbm_graph_mem_set_clock_for_testing(NULL); + cbm_mem_set_budget_for_tests(saved); + gmt_fixture_free(&f); + + ASSERT_GT(walked, 0); + ASSERT_EQ(cached_while_held, 1); + ASSERT_EQ(cached_after_release, 1); + ASSERT_EQ(cached_after_idle, 0); + PASS(); +} + +/* The daemon has no timer: the next request is the clock. Any MCP request — + * here one for a tool that does not exist — sweeps expired layers on its way + * out, and a walk on another DB sweeps them on its way in. */ +TEST(graph_mem_next_request_evicts_expired_layers) { + gmt_fixture_t f; + gmt_fixture_t g; + ASSERT_TRUE(gmt_fixture_build(&f)); + ASSERT_TRUE(gmt_fixture_build(&g)); + size_t saved = gmt_budget_pin(gmt_budget_roomy()); + const uint64_t t0 = 9000000; + gmt_clock_pin(t0); + + (void)gmt_request(f.db, f.ids[0]); + g_gmt_now_ms = t0 + CBM_GRAPH_MEM_IDLE_EVICT_MS; + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + char *reply = srv ? cbm_mcp_handle_tool(srv, "no_such_tool", "{}") : NULL; + int cached_after_mcp = cbm_graph_mem_cached_count(); + free(reply); + cbm_mcp_server_free(srv); + + (void)gmt_request(f.db, f.ids[0]); /* f rebuilt at t0 + idle */ + g_gmt_now_ms += CBM_GRAPH_MEM_IDLE_EVICT_MS; + (void)gmt_request(g.db, g.ids[0]); /* acquiring g retires the expired f */ + int cached_after_walk = cbm_graph_mem_cached_count(); + + cbm_graph_mem_set_clock_for_testing(NULL); + cbm_mem_set_budget_for_tests(saved); + gmt_fixture_free(&f); + gmt_fixture_free(&g); + + ASSERT_NOT_NULL(srv); + ASSERT_NOT_NULL(reply); + ASSERT_EQ(cached_after_mcp, 0); + ASSERT_EQ(cached_after_walk, 1); /* only g */ + PASS(); +} + SUITE(graph_mem) { RUN_TEST(graph_mem_bfs_matches_sql_on_every_shape); RUN_TEST(graph_mem_degree_search_matches_sql); @@ -606,4 +733,7 @@ SUITE(graph_mem) { RUN_TEST(graph_mem_over_budget_keeps_sql_path); RUN_TEST(graph_mem_legacy_generation_keeps_sql_path); RUN_TEST(graph_mem_concurrent_requests_build_once); + RUN_TEST(graph_mem_idle_layer_evicted_after_idle_time); + RUN_TEST(graph_mem_held_layer_is_not_idle); + RUN_TEST(graph_mem_next_request_evicts_expired_layers); } diff --git a/tests/test_mem.c b/tests/test_mem.c index 11b3e2c2c..4c9e9af41 100644 --- a/tests/test_mem.c +++ b/tests/test_mem.c @@ -1715,6 +1715,7 @@ TEST(mem_core_class_names_are_total) { ASSERT_TRUE(strcmp(cbm_mem_class_name(CBM_MEM_CLASS_STORE), "store") == 0); ASSERT_TRUE(strcmp(cbm_mem_class_name(CBM_MEM_CLASS_HASH_TABLE), "hash_table") == 0); ASSERT_TRUE(strcmp(cbm_mem_class_name(CBM_MEM_CLASS_DYN_ARRAY), "dyn_array") == 0); + ASSERT_TRUE(strcmp(cbm_mem_class_name(CBM_MEM_CLASS_GRAPH_CACHE), "graph_cache") == 0); /* Out of range must still answer, so a log line never takes a NULL. */ ASSERT_TRUE(cbm_mem_class_name((cbm_mem_class_t)(CBM_MEM_CLASS_COUNT + 5)) != NULL); ASSERT_TRUE(cbm_mem_class_name((cbm_mem_class_t)-1) != NULL);