mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-04 10:48:20 +00:00
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:
1 parent
8b4d5e8b5b
commit
6bf6e34e43
9 files changed
+299
-24
No files matched your search
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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 */
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user