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 <martin.vogel.tech@gmail.com>
This commit is contained in:
Martin Vogel committed 2026-10-14 22:54:02 +02:00
1 parent 8b4d5e8b5b
commit 6bf6e34e43
9 files changed
+299 -24

No files matched your search

+18 -2
View File
@@ -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
+1 -1
View File
@@ -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) {
+4
View File
@@ -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;
+4
View File
@@ -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 <sqlite3.h>
@@ -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
+78 -13
View File
@@ -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;
+35 -8
View File
@@ -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 */
+28
View File
@@ -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);
+130
View File
@@ -12,6 +12,8 @@
#include "test_helpers.h"
#include <foundation/compat_thread.h>
#include <foundation/mem.h>
#include <foundation/mem_core.h>
#include <mcp/mcp.h>
#include <store/graph_mem.h>
#include <store/store.h>
#include <sqlite3.h>
@@ -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);
}
+1
View File
@@ -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);