diff --git a/Makefile.cbm b/Makefile.cbm index f1402881b3..a1839b52b8 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -449,7 +449,7 @@ PREPROCESSOR_SRC = $(CBM_DIR)/preprocessor.cpp SQLITE_WRITER_SRC = $(CBM_DIR)/sqlite_writer.c # Store module (new) -STORE_SRCS = src/store/store.c src/store/architecture_projection.c src/store/store_impact.c src/store/store_graph_digest.c src/store/store_content_digest.c +STORE_SRCS = src/store/store.c src/store/graph_mem.c src/store/architecture_projection.c src/store/store_impact.c src/store/store_graph_digest.c src/store/store_content_digest.c # Cypher module (new) CYPHER_SRCS = src/cypher/cypher.c @@ -744,6 +744,7 @@ TEST_STORE_SRCS = \ tests/test_store_impact.c \ tests/test_store_scope.c \ tests/test_store_graph_digest.c \ + tests/test_graph_mem.c \ tests/test_store_search.c \ tests/test_store_arch.c \ tests/test_architecture_projection.c \ diff --git a/scripts/memory-core-baseline.txt b/scripts/memory-core-baseline.txt index b52f1d30a1..d3c3ef8bd2 100644 --- a/scripts/memory-core-baseline.txt +++ b/scripts/memory-core-baseline.txt @@ -24,7 +24,7 @@ src/cli/config_text_edit.c 72 src/cli/config_toml_edit.c 73 src/cli/config_yaml_edit.c 117 src/cli/hook_augment.c 31 -src/cypher/cypher.c 132 +src/cypher/cypher.c 125 src/daemon/application.c 107 src/daemon/bootstrap.c 13 src/daemon/daemon.c 31 diff --git a/src/cypher/cypher.c b/src/cypher/cypher.c index 1889efe8a0..8eaae7a268 100644 --- a/src/cypher/cypher.c +++ b/src/cypher/cypher.c @@ -1830,7 +1830,7 @@ static int parse_return_or_with(parser_t *p, cbm_return_clause_t **out, bool is_ } while (check(p, TOK_COMMA)); /* Projection is materialized per row into fixed-width stack arrays sized at - * CBM_SZ_32 columns (execute_return_simple and its siblings). Bound the + * CBM_SZ_32 columns (ret_sink_feed_rows and its siblings). Bound the * parsed item count to that width so an over-wide RETURN is rejected here * instead of writing past those arrays downstream. * @@ -2619,6 +2619,28 @@ static void binding_set(binding_t *b, const char *var, const cbm_node_t *node) { b->var_count++; } +/* binding_set for a node the caller owns and is done with: the binding takes + * its strings instead of copying them, and *node is left empty, so the + * caller's node_fields_free of it is a no-op. A binding with no free slot + * takes nothing, as binding_set stores nothing. */ +static void binding_take_node(binding_t *b, const char *var, cbm_node_t *node) { + for (int i = 0; i < b->var_count; i++) { + if (strcmp(b->var_names[i], var) == 0) { + node_fields_free(&b->var_nodes[i]); + b->var_nodes[i] = *node; + memset(node, 0, sizeof(*node)); + return; + } + } + if (b->var_count >= CYP_MAX_VARS) { + return; + } + b->var_names[b->var_count] = var; /* not owned — points to AST string */ + b->var_nodes[b->var_count] = *node; + memset(node, 0, sizeof(*node)); + b->var_count++; +} + static const char *eval_multiarg_func(binding_t *b, const cbm_return_item_t *item, char *buf, size_t bufsz); @@ -3162,6 +3184,9 @@ static _Thread_local bool g_cypher_timed_out = false; /* Sticky for one cbm_cypher_execute call. Unlike row_count, this survives caps * that happen before DISTINCT, aggregation, ORDER BY, or the final projection. */ static _Thread_local bool g_cypher_truncated = false; +/* Set when the streamed start-node scan failed in this execution: the query + * fails with this store error rather than answer from the rows read so far. */ +static _Thread_local char g_cypher_scan_error[CBM_SZ_512]; static _Thread_local int64_t g_cypher_deadline_override_ms = -1; /* test hook; <0 = default */ #ifdef CBM_ENABLE_TEST_SEAMS @@ -3508,7 +3533,7 @@ static void process_edges(cbm_store_t *store, cbm_edge_t *edges, int edge_count, (*match_count)++; /* a real neighbour exists, OOM or not */ binding_t nb = {0}; binding_copy(&nb, b); - binding_set(&nb, to_var, &found); + binding_take_node(&nb, to_var, &found); if (rel_var) { binding_set_edge(&nb, rel_var, &edges[ei]); } @@ -3716,6 +3741,113 @@ static void expand_pattern_rels(cbm_store_t *store, cbm_pattern_t *pat, binding_ } } +/* ── Sorting and key lookup ──────────────────────────────────────── + * ORDER BY, GROUP BY and DISTINCT used to compare every row with every other + * row (bubble sorts, linear group and duplicate scans). These helpers keep the + * exact same comparisons and keys, in O(n log n) and O(n). */ + +/* Compare items a and b; positive means a sorts after b. */ +typedef int (*cypher_index_cmp_fn)(const void *ctx, int a, int b); + +/* Stable sort of idx[0..n). Both this and the bubble sorts it replaces are + * stable, so for any consistent ordering they give the same permutation. An + * item moves before an earlier one only when cmp(earlier, item) > 0, which is + * the bubble sorts' swap test. Without scratch memory it degrades to an + * insertion sort with the same test and result. */ +static void cypher_stable_sort(int *idx, int n, cypher_index_cmp_fn cmp, const void *ctx) { + if (n < PAIR_LEN) { + return; + } + int *tmp = cbm_alloc(CBM_MEM_CLASS_OTHER, (size_t)n * sizeof(int)); + if (!tmp) { + for (int i = SKIP_ONE; i < n; i++) { + for (int j = i; j > 0 && cmp(ctx, idx[j - SKIP_ONE], idx[j]) > 0; j--) { + int swap = idx[j]; + idx[j] = idx[j - SKIP_ONE]; + idx[j - SKIP_ONE] = swap; + } + } + return; + } + for (int64_t width = SKIP_ONE; width < n; width *= PAIR_LEN) { + for (int64_t lo = 0; lo < n; lo += PAIR_LEN * width) { + int64_t mid = lo + width < n ? lo + width : n; + int64_t hi = mid + width < n ? mid + width : n; + int64_t i = lo; + int64_t j = mid; + int64_t k = lo; + while (i < mid && j < hi) { + tmp[k++] = cmp(ctx, idx[i], idx[j]) > 0 ? idx[j++] : idx[i++]; + } + while (i < mid) { + tmp[k++] = idx[i++]; + } + while (j < hi) { + tmp[k++] = idx[j++]; + } + } + memcpy(idx, tmp, (size_t)n * sizeof(int)); + } + cbm_free(CBM_MEM_CLASS_OTHER, tmp); +} + +/* String key → the index it was first recorded with. Keys are copied: the + * arrays they mirror are reallocated as they grow. */ +typedef struct { + CBMHashTable *table; + char **keys; + int count; + int cap; + bool failed; /* an allocation failed: callers fall back to their scans */ +} cypher_key_index_t; + +/* The recorded index, CYP_FOUND_NONE, or KEY_INDEX_UNKNOWN after a failed + * allocation (the caller then scans, so a result never depends on memory). */ +enum { KEY_INDEX_UNKNOWN = -2 }; + +static int key_index_find(const cypher_key_index_t *ix, const char *key) { + if (ix->failed) { + return KEY_INDEX_UNKNOWN; + } + void *value = ix->table ? cbm_ht_get(ix->table, key) : NULL; + return value ? (int)((intptr_t)value - SKIP_ONE) : CYP_FOUND_NONE; +} + +static void key_index_add(cypher_key_index_t *ix, const char *key, int index) { + if (ix->failed) { + return; + } + if (!ix->table) { + ix->table = cbm_ht_create(CBM_SZ_256); + } + if (ix->table && ix->count >= ix->cap) { + int cap = ix->cap ? ix->cap * PAIR_LEN : CBM_SZ_256; + char **keys = cbm_realloc(CBM_MEM_CLASS_OTHER, ix->keys, (size_t)cap * sizeof(char *)); + if (keys) { + ix->keys = keys; + ix->cap = cap; + } + } + char *copy = ix->table && ix->count < ix->cap ? cbm_mem_strdup(CBM_MEM_CLASS_OTHER, key) : NULL; + if (!copy) { + ix->failed = true; + return; + } + ix->keys[ix->count++] = copy; + (void)cbm_ht_set(ix->table, copy, (void *)(intptr_t)(index + SKIP_ONE)); +} + +static void key_index_free(cypher_key_index_t *ix) { + if (ix->table) { + cbm_ht_free(ix->table); + } + for (int i = 0; i < ix->count; i++) { + cbm_free(CBM_MEM_CLASS_OTHER, ix->keys[i]); + } + cbm_free(CBM_MEM_CLASS_OTHER, ix->keys); + memset(ix, 0, sizeof(*ix)); +} + /* ── Result postprocessing helpers ─────────────────────────────── */ /* Find the column index for one ORDER BY key, checking both column names and @@ -3755,6 +3887,43 @@ static bool rb_is_numeric_column(const result_builder_t *rb, int col) { return false; } +typedef struct { + const int *cols; + const bool *numeric; + const bool *descs; + int keys; + const char ***rows; /* the rows in their pre-sort order */ + const int *values; /* numeric keys parsed once: values[row * keys + k], or NULL */ +} rb_order_ctx_t; + +/* One ORDER BY comparison: numeric columns by their (int)strtol value, + * others by strcmp, a later key breaking ties; positive = a after b. + * va/vb are the rows' parsed numeric keys, or NULL to parse here. */ +static int rb_order_compare_rows(const rb_order_ctx_t *ctx, const char **ra, const char **rb_row, + const int *va, const int *vb) { + int cmp = 0; + for (int k = 0; k < ctx->keys && cmp == 0; k++) { + if (ctx->numeric[k]) { + int a = va ? va[k] : (int)strtol(ra[ctx->cols[k]], NULL, CBM_DECIMAL_BASE); + int b = vb ? vb[k] : (int)strtol(rb_row[ctx->cols[k]], NULL, CBM_DECIMAL_BASE); + cmp = (a > b) - (a < b); + } else { + cmp = strcmp(ra[ctx->cols[k]], rb_row[ctx->cols[k]]); + } + if (ctx->descs[k]) { + cmp = -cmp; + } + } + return cmp; +} + +static int rb_order_compare(const void *context, int a, int b) { + const rb_order_ctx_t *ctx = context; + const int *va = ctx->values ? ctx->values + (size_t)a * (size_t)ctx->keys : NULL; + const int *vb = ctx->values ? ctx->values + (size_t)b * (size_t)ctx->keys : NULL; + return rb_order_compare_rows(ctx, ctx->rows[a], ctx->rows[b], va, vb); +} + static void rb_apply_order_by(result_builder_t *rb, const cbm_return_clause_t *ret) { if (ret->order_key_count == 0) { return; @@ -3776,30 +3945,46 @@ static void rb_apply_order_by(result_builder_t *rb, const cbm_return_clause_t *r descs[keys] = ret->order_descs[k]; keys++; } - if (keys == 0) { + if (keys == 0 || rb->row_count < PAIR_LEN) { return; } - for (int i = 0; i < rb->row_count - SKIP_ONE; i++) { - for (int j = 0; j < rb->row_count - i - SKIP_ONE; j++) { - int cmp = 0; - for (int k = 0; k < keys && cmp == 0; k++) { - if (numeric[k]) { - cmp = (int)strtol(rb->rows[j][cols[k]], NULL, CBM_DECIMAL_BASE) - - (int)strtol(rb->rows[j + SKIP_ONE][cols[k]], NULL, CBM_DECIMAL_BASE); - } else { - cmp = strcmp(rb->rows[j][cols[k]], rb->rows[j + SKIP_ONE][cols[k]]); - } - if (descs[k]) { - cmp = -cmp; - } - } - if (cmp > 0) { + int n = rb->row_count; + rb_order_ctx_t ctx = {.cols = cols, .numeric = numeric, .descs = descs, .keys = keys}; + int *idx = cbm_alloc(CBM_MEM_CLASS_OTHER, (size_t)n * sizeof(int)); + const char ***rows = cbm_alloc(CBM_MEM_CLASS_OTHER, (size_t)n * sizeof(const char **)); + if (!idx || !rows) { + /* No scratch memory: insertion sort in place, same comparisons. */ + cbm_free(CBM_MEM_CLASS_OTHER, idx); + cbm_free(CBM_MEM_CLASS_OTHER, rows); + for (int i = SKIP_ONE; i < n; i++) { + for (int j = i; j > 0 && rb_order_compare_rows(&ctx, rb->rows[j - SKIP_ONE], + rb->rows[j], NULL, NULL) > 0; + j--) { const char **tmp = rb->rows[j]; - rb->rows[j] = rb->rows[j + SKIP_ONE]; - rb->rows[j + SKIP_ONE] = tmp; + rb->rows[j] = rb->rows[j - SKIP_ONE]; + rb->rows[j - SKIP_ONE] = tmp; } } + return; + } + int *values = cbm_alloc(CBM_MEM_CLASS_OTHER, (size_t)n * (size_t)keys * sizeof(int)); + for (int r = 0; r < n; r++) { + idx[r] = r; + rows[r] = rb->rows[r]; + for (int k = 0; values && k < keys; k++) { + values[(size_t)r * (size_t)keys + (size_t)k] = + numeric[k] ? (int)strtol(rb->rows[r][cols[k]], NULL, CBM_DECIMAL_BASE) : 0; + } + } + ctx.rows = rows; + ctx.values = values; + cypher_stable_sort(idx, n, rb_order_compare, &ctx); + for (int r = 0; r < n; r++) { + rb->rows[r] = rows[idx[r]]; } + cbm_free(CBM_MEM_CLASS_OTHER, values); + cbm_free(CBM_MEM_CLASS_OTHER, rows); + cbm_free(CBM_MEM_CLASS_OTHER, idx); } static void rb_apply_skip_limit(result_builder_t *rb, int skip_n, int limit, @@ -3838,25 +4023,70 @@ static void rb_apply_skip_limit(result_builder_t *rb, int skip_n, int limit, } } +/* A row's DISTINCT identity: every column, length-prefixed so no value can + * run into the next — equal keys exactly when every column is equal. Built in + * *buf (grown as needed); NULL when out of memory. */ +static const char *rb_row_key(const result_builder_t *rb, const char **row, char **buf, + size_t *cap) { + size_t need = SKIP_ONE; + for (int c = 0; c < rb->col_count; c++) { + need += strlen(row[c]) + CBM_SZ_32; + } + if (need > *cap) { + char *grown = cbm_realloc(CBM_MEM_CLASS_OTHER, *buf, need); + if (!grown) { + return NULL; + } + *buf = grown; + *cap = need; + } + size_t used = 0; + for (int c = 0; c < rb->col_count; c++) { + size_t len = strlen(row[c]); + char prefix[CBM_SZ_32]; + int prefix_len = snprintf(prefix, sizeof(prefix), "%zu:", len); + if (prefix_len <= 0 || (size_t)prefix_len >= sizeof(prefix) || + (size_t)prefix_len + len >= *cap - used) { + return NULL; /* cannot happen: `need` reserved this */ + } + memcpy(*buf + used, prefix, (size_t)prefix_len); + used += (size_t)prefix_len; + memcpy(*buf + used, row[c], len); + used += len; + } + (*buf)[used] = '\0'; + return *buf; +} + +/* Whether row i equals one of the first `kept` rows, column by column. */ +static bool rb_row_seen_before(const result_builder_t *rb, int i, int kept) { + for (int j = 0; j < kept; j++) { + bool same = true; + for (int c = 0; c < rb->col_count && same; c++) { + same = strcmp(rb->rows[i][c], rb->rows[j][c]) == 0; + } + if (same) { + return true; + } + } + return false; +} + static void rb_apply_distinct(result_builder_t *rb) { if (rb->row_count <= SKIP_ONE) { return; } - int kept = SKIP_ONE; - for (int i = SKIP_ONE; i < rb->row_count; i++) { - bool dup = false; - for (int j = 0; j < kept && !dup; j++) { - bool same = true; - for (int c = 0; c < rb->col_count && same; c++) { - if (strcmp(rb->rows[i][c], rb->rows[j][c]) != 0) { - same = false; - } - } - if (same) { - dup = true; - } - } + cypher_key_index_t seen = {0}; + char *key_buf = NULL; + size_t key_cap = 0; + int kept = 0; + for (int i = 0; i < rb->row_count; i++) { + const char *key = rb_row_key(rb, rb->rows[i], &key_buf, &key_cap); + seen.failed = seen.failed || !key; + int found = key_index_find(&seen, key); + bool dup = found == KEY_INDEX_UNKNOWN ? rb_row_seen_before(rb, i, kept) : found >= 0; if (!dup) { + key_index_add(&seen, key, kept); if (kept != i) { rb->rows[kept] = rb->rows[i]; } @@ -3869,6 +4099,8 @@ static void rb_apply_distinct(result_builder_t *rb) { } } rb->row_count = kept; + key_index_free(&seen); + cbm_free(CBM_MEM_CLASS_OTHER, key_buf); } static void rb_free(result_builder_t *rb) { @@ -4113,17 +4345,57 @@ static bool is_aggregate_func(const char *func) { strcmp(func, "MIN") == 0 || strcmp(func, "MAX") == 0 || strcmp(func, "COLLECT") == 0); } +/* Grow a string list for one more entry at `idx`: the capacity doubles at + * every power of two, so n appends cost O(n) copying, not O(n^2). */ +static char **string_list_reserve(char **list, int idx) { + if (idx > 0 && (idx & (idx - SKIP_ONE)) != 0) { + return list; /* capacity is the next power of two above idx */ + } + return safe_realloc(list, (size_t)(idx ? idx * PAIR_LEN : SKIP_ONE) * sizeof(char *)); +} + +enum { DISTINCT_SET_MIN = 16 }; /* below this a set is scanned, not hashed */ + /* Append `val` to a string list only if not already present — i.e. maintain a - * set of distinct values. Used by COUNT(DISTINCT x) (#239). */ -static void distinct_list_add(char ***list, int *count, const char *val) { - for (int i = 0; i < *count; i++) { - if (strcmp((*list)[i], val) == 0) { + * set of distinct values. Used by COUNT(DISTINCT x) (#239). Past + * DISTINCT_SET_MIN values the membership test goes through *set, whose keys + * are the list's own strings; a set that cannot be built stays a scan. */ +static void distinct_list_add(char ***list, int *count, CBMHashTable **set, const char *val) { + if (*set) { + if (cbm_ht_has(*set, val)) { return; } + } else { + for (int i = 0; i < *count; i++) { + if (strcmp((*list)[i], val) == 0) { + return; + } + } + } + int idx = *count; + *list = string_list_reserve(*list, idx); + char *copy = *list ? heap_strdup(val) : NULL; + if (!copy) { + /* Out of memory: keep a consistent (possibly emptied) set. */ + if (!*list) { + *count = 0; + if (*set) { + cbm_ht_free(*set); + *set = NULL; + } + } + return; + } + (*list)[idx] = copy; + (*count)++; + if (*set) { + (void)cbm_ht_set(*set, (*list)[idx], (void *)SKIP_ONE); + } else if (*count >= DISTINCT_SET_MIN) { + *set = cbm_ht_create(CBM_SZ_64); + for (int i = 0; *set && i < *count; i++) { + (void)cbm_ht_set(*set, (*list)[i], (void *)SKIP_ONE); + } } - int idx = (*count)++; - *list = safe_realloc(*list, (size_t)(idx + SKIP_ONE) * sizeof(char *)); - (*list)[idx] = heap_strdup(val); } /* Resolve one WITH ORDER BY key on a projected binding. The key is either a @@ -4150,31 +4422,110 @@ static const char *order_key_value(binding_t *b, const char *key) { return binding_get_virtual(b, var, dot + SKIP_ONE); } -/* Sort bindings by the ORDER BY key list (virtual variables) using bubble - * sort; later keys break ties, direction is per key (#1334). */ +/* One WITH ORDER BY key comparison: numerically when both values start with a + * number, else by strcmp; positive = a after b. */ +static int with_order_compare_value(const char *va, const char *vb) { + char *ea = NULL; + char *eb = NULL; + double da = strtod(va, &ea); + double db = strtod(vb, &eb); + return (ea != va && eb != vb) ? ((da > db) - (da < db)) : strcmp(va, vb); +} + +/* The full comparison over the ORDER BY key list: later keys break ties, + * direction is per key (#1334). */ +static int with_order_compare_bindings(const cbm_return_clause_t *wc, binding_t *a, binding_t *b) { + int cmp = 0; + for (int k = 0; k < wc->order_key_count && cmp == 0; k++) { + cmp = with_order_compare_value(order_key_value(a, wc->order_keys[k]), + order_key_value(b, wc->order_keys[k])); + if (wc->order_descs[k]) { + cmp = -cmp; + } + } + return cmp; +} + +typedef struct { + const cbm_return_clause_t *wc; + char **values; /* values[i * keys + k]: owned copy of binding i's key k */ + bool *numbered; /* whether strtod consumed a number from that value */ + double *numbers; +} with_order_ctx_t; + +/* The same comparison on values resolved once per binding. order_key_value + * may return a rotating thread-local buffer, so the values are copies. */ +static int with_order_compare(const void *context, int a, int b) { + const with_order_ctx_t *ctx = context; + size_t keys = (size_t)ctx->wc->order_key_count; + int cmp = 0; + for (size_t k = 0; k < keys && cmp == 0; k++) { + size_t ia = (size_t)a * keys + k; + size_t ib = (size_t)b * keys + k; + if (ctx->numbered[ia] && ctx->numbered[ib]) { + cmp = (ctx->numbers[ia] > ctx->numbers[ib]) - (ctx->numbers[ia] < ctx->numbers[ib]); + } else { + cmp = strcmp(ctx->values[ia], ctx->values[ib]); + } + if (ctx->wc->order_descs[k]) { + cmp = -cmp; + } + } + return cmp; +} + +/* Sort bindings by the ORDER BY key list (virtual variables), stably. */ static void sort_bindings(binding_t *vbindings, int count, const cbm_return_clause_t *wc) { - for (int i = 0; i < count - SKIP_ONE; i++) { - for (int j = 0; j < count - i - SKIP_ONE; j++) { - int cmp = 0; - for (int k = 0; k < wc->order_key_count && cmp == 0; k++) { - const char *va = order_key_value(&vbindings[j], wc->order_keys[k]); - const char *vb2 = order_key_value(&vbindings[j + SKIP_ONE], wc->order_keys[k]); - char *ea = NULL; - char *eb = NULL; - double da = strtod(va, &ea); - double db = strtod(vb2, &eb); - cmp = (ea != va && eb != vb2) ? ((da > db) - (da < db)) : strcmp(va, vb2); - if (wc->order_descs[k]) { - cmp = -cmp; - } - } - if (cmp > 0) { + if (count < PAIR_LEN || wc->order_key_count <= 0) { + return; + } + size_t keys = (size_t)wc->order_key_count; + size_t cells = (size_t)count * keys; + with_order_ctx_t ctx = {.wc = wc, + .values = cbm_calloc(CBM_MEM_CLASS_OTHER, cells * sizeof(char *)), + .numbered = cbm_calloc(CBM_MEM_CLASS_OTHER, cells * sizeof(bool)), + .numbers = cbm_calloc(CBM_MEM_CLASS_OTHER, cells * sizeof(double))}; + int *idx = cbm_alloc(CBM_MEM_CLASS_OTHER, (size_t)count * sizeof(int)); + binding_t *sorted = cbm_alloc(CBM_MEM_CLASS_OTHER, (size_t)count * sizeof(binding_t)); + bool ready = ctx.values && ctx.numbered && ctx.numbers && idx && sorted; + for (int i = 0; ready && i < count; i++) { + idx[i] = i; + for (size_t k = 0; ready && k < keys; k++) { + size_t cell = (size_t)i * keys + k; + ctx.values[cell] = cbm_mem_strdup(CBM_MEM_CLASS_OTHER, + order_key_value(&vbindings[i], wc->order_keys[k])); + ready = ctx.values[cell] != NULL; + char *end = NULL; + ctx.numbers[cell] = ready ? strtod(ctx.values[cell], &end) : 0; + ctx.numbered[cell] = ready && end != ctx.values[cell]; + } + } + if (ready) { + cypher_stable_sort(idx, count, with_order_compare, &ctx); + for (int i = 0; i < count; i++) { + sorted[i] = vbindings[idx[i]]; + } + memcpy(vbindings, sorted, (size_t)count * sizeof(binding_t)); + } else { + /* No scratch memory: insertion sort in place, same comparisons. */ + for (int i = SKIP_ONE; i < count; i++) { + for (int j = i; j > 0 && with_order_compare_bindings(wc, &vbindings[j - SKIP_ONE], + &vbindings[j]) > 0; + j--) { binding_t tmp = vbindings[j]; - vbindings[j] = vbindings[j + SKIP_ONE]; - vbindings[j + SKIP_ONE] = tmp; + vbindings[j] = vbindings[j - SKIP_ONE]; + vbindings[j - SKIP_ONE] = tmp; } } } + for (size_t c = 0; ctx.values && c < cells; c++) { + cbm_free(CBM_MEM_CLASS_OTHER, ctx.values[c]); + } + cbm_free(CBM_MEM_CLASS_OTHER, ctx.values); + cbm_free(CBM_MEM_CLASS_OTHER, ctx.numbered); + cbm_free(CBM_MEM_CLASS_OTHER, ctx.numbers); + cbm_free(CBM_MEM_CLASS_OTHER, idx); + cbm_free(CBM_MEM_CLASS_OTHER, sorted); } /* Apply skip and limit to a binding array, freeing discarded entries */ @@ -4230,9 +4581,10 @@ typedef struct { double *sums; int *counts; double *mins, *maxs; - char ***distinct_lists; /* per-item set of seen values for COUNT(DISTINCT) */ - int *distinct_n; /* per-item distinct count (#239) */ - cbm_node_t *group_nodes; /* per-item carried node (deep copy; id 0 = not a node) */ + char ***distinct_lists; /* per-item set of seen values for COUNT(DISTINCT) */ + int *distinct_n; /* per-item distinct count (#239) */ + CBMHashTable **distinct_sets; /* per-item hash over distinct_lists (lazy) */ + cbm_node_t *group_nodes; /* per-item carried node (deep copy; id 0 = not a node) */ } with_agg_t; /* A WITH item that is a bare, bound node variable (`WITH f`, `WITH f AS g`) @@ -4278,18 +4630,24 @@ static int with_agg_build_key(cbm_return_clause_t *wc, binding_t *b, char *key, /* Find or create an aggregation group. Returns index. */ static int with_agg_find_or_create(with_agg_t **aggs, int *agg_cnt, int *agg_cap, - cbm_return_clause_t *wc, binding_t *b, const char *key) { - for (int a = 0; a < *agg_cnt; a++) { + cypher_key_index_t *groups, cbm_return_clause_t *wc, + binding_t *b, const char *key) { + int existing = key_index_find(groups, key); + for (int a = 0; existing == KEY_INDEX_UNKNOWN && a < *agg_cnt; a++) { if (strcmp((*aggs)[a].group_key, key) == 0) { - return a; + existing = a; } } + if (existing >= 0) { + return existing; + } if (*agg_cnt >= *agg_cap) { *agg_cap *= PAIR_LEN; *aggs = safe_realloc(*aggs, *agg_cap * sizeof(with_agg_t)); } int found = (*agg_cnt)++; snprintf((*aggs)[found].group_key, sizeof((*aggs)[found].group_key), "%s", key); + key_index_add(groups, (*aggs)[found].group_key, found); (*aggs)[found].group_vals = calloc(wc->count, sizeof(const char *)); (*aggs)[found].sums = calloc(wc->count, sizeof(double)); (*aggs)[found].counts = calloc(wc->count, sizeof(int)); @@ -4297,6 +4655,8 @@ static int with_agg_find_or_create(with_agg_t **aggs, int *agg_cnt, int *agg_cap (*aggs)[found].maxs = calloc(wc->count, sizeof(double)); (*aggs)[found].distinct_lists = calloc(wc->count, sizeof(char **)); (*aggs)[found].distinct_n = calloc(wc->count, sizeof(int)); + (*aggs)[found].distinct_sets = + cbm_calloc(CBM_MEM_CLASS_OTHER, (size_t)wc->count * sizeof(CBMHashTable *)); (*aggs)[found].group_nodes = calloc(wc->count, sizeof(cbm_node_t)); for (int ci = 0; ci < wc->count; ci++) { (*aggs)[found].mins[ci] = CYP_DBL_MAX; @@ -4327,7 +4687,8 @@ static void with_agg_accumulate(with_agg_t *agg, cbm_return_clause_t *wc, bindin agg->counts[ci]++; const char *raw = binding_get_virtual(b, wc->items[ci].variable, wc->items[ci].property); if (wc->items[ci].distinct && strcmp(wc->items[ci].func, "COUNT") == 0) { - distinct_list_add(&agg->distinct_lists[ci], &agg->distinct_n[ci], raw); + distinct_list_add(&agg->distinct_lists[ci], &agg->distinct_n[ci], + &agg->distinct_sets[ci], raw); } double dv = strtod(raw, NULL); agg->sums[ci] += dv; @@ -4373,6 +4734,9 @@ static void with_agg_free(with_agg_t *aggs, int agg_cnt, int item_count) { for (int a = 0; a < agg_cnt; a++) { for (int ci = 0; ci < item_count; ci++) { safe_str_free(&aggs[a].group_vals[ci]); + if (aggs[a].distinct_sets && aggs[a].distinct_sets[ci]) { + cbm_ht_free(aggs[a].distinct_sets[ci]); + } if (aggs[a].distinct_lists && aggs[a].distinct_lists[ci]) { for (int j = 0; j < aggs[a].distinct_n[ci]; j++) { free(aggs[a].distinct_lists[ci][j]); @@ -4387,6 +4751,7 @@ static void with_agg_free(with_agg_t *aggs, int agg_cnt, int item_count) { free(aggs[a].maxs); free(aggs[a].distinct_lists); free(aggs[a].distinct_n); + cbm_free(CBM_MEM_CLASS_OTHER, aggs[a].distinct_sets); if (aggs[a].group_nodes) { for (int ci = 0; ci < item_count; ci++) { node_fields_free(&aggs[a].group_nodes[ci]); @@ -4397,30 +4762,57 @@ static void with_agg_free(with_agg_t *aggs, int agg_cnt, int item_count) { free(aggs); } -/* Execute WITH aggregation path */ -static void execute_with_aggregate(cbm_return_clause_t *wc, binding_t *bindings, int bind_count, - binding_t **vbindings, int *vcount) { - int agg_cap = CBM_SZ_256; - with_agg_t *aggs = calloc(agg_cap, sizeof(with_agg_t)); - int agg_cnt = 0; - - for (int bi = 0; bi < bind_count; bi++) { - char key[CBM_SZ_1K] = ""; - with_agg_build_key(wc, &bindings[bi], key, sizeof(key)); - int found = with_agg_find_or_create(&aggs, &agg_cnt, &agg_cap, wc, &bindings[bi], key); - with_agg_accumulate(&aggs[found], wc, &bindings[bi]); +/* WITH as a consumer of the binding stream (execute_chunk): aggregation groups + * and projected rows accumulate across feeds in input order, so a chunked run + * keeps the first-seen group order and row order of a single pass. */ +typedef struct { + cbm_return_clause_t *wc; /* NULL: the query has no WITH */ + bool has_agg; + bool fed_any; + cbm_store_t *store; /* the first fed binding's store */ + with_agg_t *aggs; + int agg_cnt; + int agg_cap; + cypher_key_index_t groups; + binding_t *vbindings; /* simple projection: one row per binding (mem core, class OTHER) */ + int vcount; + int vcap; + bool oom; /* a row buffer could not grow: the query fails */ +} with_sink_t; + +static void with_sink_init(with_sink_t *s, cbm_return_clause_t *wc) { + memset(s, 0, sizeof(*s)); + s->wc = wc; + if (!wc) { + return; + } + for (int i = 0; i < wc->count; i++) { + if (is_aggregate_func(wc->items[i].func)) { + s->has_agg = true; + break; + } + } + if (s->has_agg) { + s->agg_cap = CBM_SZ_256; + s->aggs = calloc(s->agg_cap, sizeof(with_agg_t)); } +} - *vbindings = safe_realloc(*vbindings, (agg_cnt + SKIP_ONE) * sizeof(binding_t)); - if (!*vbindings) { - with_agg_free(aggs, agg_cnt, wc->count); - return; +/* One projected row per aggregation group, in first-seen group order. */ +static binding_t *with_agg_rows(with_sink_t *s, int *vcount) { + cbm_return_clause_t *wc = s->wc; + with_agg_t *aggs = s->aggs; + int agg_cnt = s->agg_cnt; + binding_t *vbindings = + cbm_alloc(CBM_MEM_CLASS_OTHER, ((size_t)agg_cnt + SKIP_ONE) * sizeof(binding_t)); + if (!vbindings) { + return NULL; } for (int a = 0; a < agg_cnt; a++) { binding_t vb = {0}; /* Carry the store so node_prop can re-fetch a carried node's properties * (and compute in_degree/out_degree) on the projected virtual binding. */ - vb.store = (bind_count > 0) ? bindings[0].store : NULL; + vb.store = s->store; for (int ci = 0; ci < wc->count; ci++) { char name_buf[CBM_SZ_256]; const char *alias = resolve_item_alias(&wc->items[ci], name_buf, sizeof(name_buf)); @@ -4438,9 +4830,9 @@ static void execute_with_aggregate(cbm_return_clause_t *wc, binding_t *bindings, with_add_vbinding_var(&vb, alias, aggs[a].group_vals[ci]); } } - (*vbindings)[(*vcount)++] = vb; + vbindings[(*vcount)++] = vb; } - with_agg_free(aggs, agg_cnt, wc->count); + return vbindings; } /* Execute WITH simple (non-aggregate) projection */ @@ -4466,6 +4858,49 @@ static void execute_with_simple(cbm_return_clause_t *wc, binding_t *bindings, in } } +static void with_sink_feed(with_sink_t *s, binding_t *bindings, int bind_count) { + if (bind_count > 0 && !s->fed_any) { + s->fed_any = true; + s->store = bindings[0].store; + } + if (!s->has_agg) { + if (s->vcount + bind_count + SKIP_ONE > s->vcap) { + int need = s->vcount + bind_count + SKIP_ONE; + int cap = s->vcap * CYP_GROWTH_2 > need ? s->vcap * CYP_GROWTH_2 : need; + binding_t *grown = + cbm_realloc(CBM_MEM_CLASS_OTHER, s->vbindings, (size_t)cap * sizeof(binding_t)); + if (!grown) { + s->oom = true; /* the query fails rather than lose rows */ + return; + } + s->vbindings = grown; + s->vcap = cap; + } + execute_with_simple(s->wc, bindings, bind_count, s->vbindings, &s->vcount); + return; + } + for (int bi = 0; bi < bind_count; bi++) { + char key[CBM_SZ_1K] = ""; + with_agg_build_key(s->wc, &bindings[bi], key, sizeof(key)); + int found = with_agg_find_or_create(&s->aggs, &s->agg_cnt, &s->agg_cap, &s->groups, s->wc, + &bindings[bi], key); + with_agg_accumulate(&s->aggs[found], s->wc, &bindings[bi]); + } +} + +/* Release a WITH sink that will not be finished (a failed query). */ +static void with_sink_free(with_sink_t *s) { + if (s->has_agg) { + key_index_free(&s->groups); + with_agg_free(s->aggs, s->agg_cnt, s->wc->count); + } + for (int i = 0; i < s->vcount; i++) { + binding_free(&s->vbindings[i]); + } + cbm_free(CBM_MEM_CLASS_OTHER, s->vbindings); + memset(s, 0, sizeof(*s)); +} + /* Apply post-WITH WHERE filter */ static void filter_bindings_where(const cbm_where_clause_t *where, binding_t *vbindings, int *vcount) { @@ -4505,22 +4940,23 @@ static void with_proj_key(cbm_return_clause_t *wc, binding_t *b, char *key, size /* Apply WITH DISTINCT: drop projected rows whose value tuple duplicates an * earlier one, keeping first occurrence (#238 — previously silently ignored). */ static void with_apply_distinct(cbm_return_clause_t *wc, binding_t *vbindings, int *vcount) { + cypher_key_index_t seen = {0}; int kept = 0; for (int i = 0; i < *vcount; i++) { char key[CBM_SZ_1K]; with_proj_key(wc, &vbindings[i], key, sizeof(key)); - bool dup = false; - for (int j = 0; j < kept; j++) { + int found = key_index_find(&seen, key); + for (int j = 0; found == KEY_INDEX_UNKNOWN && j < kept; j++) { char pkey[CBM_SZ_1K]; with_proj_key(wc, &vbindings[j], pkey, sizeof(pkey)); if (strcmp(key, pkey) == 0) { - dup = true; - break; + found = j; } } - if (dup) { + if (found >= 0) { binding_free(&vbindings[i]); } else { + key_index_add(&seen, key, kept); if (kept != i) { vbindings[kept] = vbindings[i]; } @@ -4528,32 +4964,23 @@ static void with_apply_distinct(cbm_return_clause_t *wc, binding_t *vbindings, i } } *vcount = kept; + key_index_free(&seen); } -static void execute_with_clause(cbm_query_t *q, binding_t **bindings_ptr, int *bind_count_ptr) { - cbm_return_clause_t *wc = q->with_clause; - if (!wc) { - return; - } - binding_t *bindings = *bindings_ptr; - int bind_count = *bind_count_ptr; - - binding_t *vbindings = malloc((bind_count + SKIP_ONE) * sizeof(binding_t)); - int vcount = 0; - - bool has_agg = false; - for (int i = 0; i < wc->count; i++) { - if (is_aggregate_func(wc->items[i].func)) { - has_agg = true; - break; - } - } - - if (has_agg) { - execute_with_aggregate(wc, bindings, bind_count, &vbindings, &vcount); - } else { - execute_with_simple(wc, bindings, bind_count, vbindings, &vcount); +/* The WITH output once every binding has been fed: projected rows (one per + * group or per binding), de-duplicated, sorted, skipped/limited and filtered + * by the post-WITH WHERE. The caller owns the returned rows. */ +static binding_t *with_sink_finish(with_sink_t *s, cbm_query_t *q, int *out_count) { + cbm_return_clause_t *wc = s->wc; + binding_t *vbindings = s->vbindings; + int vcount = s->vcount; + if (s->has_agg) { + key_index_free(&s->groups); + vcount = 0; + vbindings = with_agg_rows(s, &vcount); + with_agg_free(s->aggs, s->agg_cnt, wc->count); } + memset(s, 0, sizeof(*s)); /* WITH DISTINCT: dedup projected rows (no-op for aggregation, which already * collapses to one row per group). */ @@ -4563,17 +4990,12 @@ static void execute_with_clause(cbm_query_t *q, binding_t **bindings_ptr, int *b with_sort_skip_limit(wc, vbindings, &vcount); - for (int bi = 0; bi < bind_count; bi++) { - binding_free(&bindings[bi]); - } - free(bindings); - if (q->post_with_where) { filter_bindings_where(q->post_with_where, vbindings, &vcount); } - *bindings_ptr = vbindings; - *bind_count_ptr = vcount; + *out_count = vcount; + return vbindings; } /* ── Execute a single query (no UNION recursion) ──────────────── */ @@ -4659,71 +5081,80 @@ static void project_star_row(binding_t *b, const char **vars, int vc, const char } } +/* ── RETURN as a consumer of the binding stream ─────────────────── + * execute_chunk feeds the projection chunk by chunk. Each projection walks its + * input in order and keeps its state between feeds (row cap, DISTINCT set, + * aggregation groups, bindings seen), so a chunked run yields exactly the + * rows, row order, first-seen group order and truncation of a single pass + * over all bindings. A query with a WITH feeds the WITH rows once. */ +typedef enum { + RET_SINK_DEFAULT, /* no RETURN: name, qualified_name, label per pattern-0 var */ + RET_SINK_SIMPLE, + RET_SINK_AGG, + RET_SINK_STAR, + RET_SINK_STAR_AFTER_WITH, +} ret_sink_kind_t; + +typedef struct ret_agg_entry ret_agg_entry_t; + +typedef struct { + cbm_return_clause_t *ret; /* NULL for the default projection */ + result_builder_t *rb; + int max_rows; + ret_sink_kind_t kind; + bool has_agg; + bool done; /* the row cap is full, or the deadline tripped: later rows add nothing */ + int64_t fed; /* bindings fed so far */ + int cap; /* projection row cap */ + bool cap_is_engine_budget; + rb_distinct_t seen; + const char *vars[CBM_SZ_32]; /* projected variables, or the WITH names of RETURN * */ + int vc; + char names[CYP_MAX_VARS][CBM_SZ_128]; /* RETURN * after WITH: computed names */ + ret_agg_entry_t *aggs; + int agg_count; + int agg_cap; + cypher_key_index_t groups; +} ret_sink_t; + /* RETURN * after a WITH. * * The pattern's variables are out of scope by this point — the WITH replaced * them with the names it made. Each of those names holds one value, not a * node, so each is ONE column rather than the four a node variable gets. * - * Reading the pattern here instead is the fault this function exists to avoid: - * it named variables the bindings no longer hold, found nothing for every one - * of them, and answered a full result of empty strings with no error. */ -static void execute_return_star_after_with(cbm_query_t *q, binding_t *bindings, int bind_count, - int max_rows, result_builder_t *rb) { + * Reading the pattern here instead is the fault this projection exists to + * avoid: it named variables the bindings no longer hold, found nothing for + * every one of them, and answered a full result of empty strings with no + * error. */ +static void ret_sink_init_star_after_with(ret_sink_t *s, const cbm_query_t *q) { cbm_return_clause_t *wc = q->with_clause; - char name_bufs[CYP_MAX_VARS][CBM_SZ_128]; - const char *cols[CYP_MAX_VARS]; + s->kind = RET_SINK_STAR_AFTER_WITH; /* parse_return_or_with refuses a WITH wider than CYP_MAX_VARS, so this - * clamp cannot fire. It stays as the bound this function relies on. */ - int col_n = wc->count < CYP_MAX_VARS ? wc->count : CYP_MAX_VARS; - for (int i = 0; i < col_n; i++) { - cols[i] = resolve_item_alias(&wc->items[i], name_bufs[i], sizeof(name_bufs[i])); - } - rb_set_columns(rb, cols, col_n); - rb_distinct_t seen; - rb_distinct_init(&seen, q->ret && q->ret->distinct); - for (int bi = 0; bi < bind_count; bi++) { - const char *vals[CYP_MAX_VARS]; - for (int i = 0; i < col_n; i++) { - cbm_node_t *vn = binding_get(&bindings[bi], cols[i]); - vals[i] = vn && vn->name ? vn->name : ""; - } - if (!rb_add_row_capped(rb, &seen, vals, max_rows)) { - g_cypher_truncated = true; - break; - } - } - rb_distinct_free(&seen); -} - -static void execute_return_star(cbm_query_t *q, binding_t *bindings, int bind_count, int max_rows, - result_builder_t *rb) { - if (q->with_clause) { - execute_return_star_after_with(q, bindings, bind_count, max_rows, rb); - return; - } - const char *vars[CBM_SZ_32]; - int vc = collect_pattern_vars(q, vars, CBM_SZ_32); - build_star_columns(rb, vars, vc); - int projection_cap = max_rows; - bool cap_is_engine_budget = true; - cbm_return_clause_t *ret = q->ret; - if (ret && ret->limit >= 0 && ret->limit <= max_rows && ret->order_key_count == 0 && + * clamp cannot fire. It stays as the bound this projection relies on. */ + s->vc = wc->count < CYP_MAX_VARS ? wc->count : CYP_MAX_VARS; + for (int i = 0; i < s->vc; i++) { + s->vars[i] = resolve_item_alias(&wc->items[i], s->names[i], sizeof(s->names[i])); + } + rb_set_columns(s->rb, s->vars, s->vc); + s->cap = s->max_rows; + s->cap_is_engine_budget = true; + rb_distinct_init(&s->seen, s->ret->distinct); +} + +static void ret_sink_init_star(ret_sink_t *s, cbm_query_t *q) { + s->kind = RET_SINK_STAR; + s->vc = collect_pattern_vars(q, s->vars, CBM_SZ_32); + build_star_columns(s->rb, s->vars, s->vc); + cbm_return_clause_t *ret = s->ret; + s->cap = s->max_rows; + s->cap_is_engine_budget = true; + if (ret->limit >= 0 && ret->limit <= s->max_rows && ret->order_key_count == 0 && ret->skip <= 0) { - projection_cap = ret->limit; /* DISTINCT: the cap counts distinct rows (#1364) */ - cap_is_engine_budget = false; - } - rb_distinct_t seen; - rb_distinct_init(&seen, ret && ret->distinct); - for (int bi = 0; bi < bind_count; bi++) { - const char *vals[CBM_SZ_128]; - project_star_row(&bindings[bi], vars, vc, vals); - if (!rb_add_row_capped(rb, &seen, vals, projection_cap)) { - g_cypher_truncated = g_cypher_truncated || cap_is_engine_budget; - break; - } + s->cap = ret->limit; /* DISTINCT: the cap counts distinct rows (#1364) */ + s->cap_is_engine_budget = false; } - rb_distinct_free(&seen); + rb_distinct_init(&s->seen, ret->distinct); } /* Format an aggregate value into buf based on function name */ @@ -4765,8 +5196,8 @@ static void format_agg_value(const char *func, int count, double sum, double min } } -/* RETURN aggregation entry */ -typedef struct { +/* RETURN aggregation entry (ret_agg_entry_t, declared with ret_sink_t) */ +struct ret_agg_entry { char group_key[CBM_SZ_1K]; const char **group_vals; double *sums; @@ -4774,7 +5205,8 @@ typedef struct { double *mins, *maxs; char ***collect_lists; int *collect_counts; -} ret_agg_entry_t; + CBMHashTable **distinct_sets; /* per-item hash over a COUNT(DISTINCT) list (lazy) */ +}; /* Initialize a new RETURN aggregation group */ static void ret_agg_init_group(ret_agg_entry_t *entry, const char *key, int item_count, @@ -4790,6 +5222,8 @@ static void ret_agg_init_group(ret_agg_entry_t *entry, const char *key, int item entry->maxs = calloc(item_count, sizeof(double)); entry->collect_lists = calloc(item_count, sizeof(char **)); entry->collect_counts = calloc(item_count, sizeof(int)); + entry->distinct_sets = + cbm_calloc(CBM_MEM_CLASS_OTHER, (size_t)item_count * sizeof(CBMHashTable *)); for (int ci = 0; ci < item_count; ci++) { entry->mins[ci] = CYP_DBL_MAX; entry->maxs[ci] = -CYP_DBL_MAX; @@ -4836,13 +5270,18 @@ static void ret_agg_accumulate(ret_agg_entry_t *entry, cbm_return_clause_t *ret, entry->maxs[ci] = dv; } if (strcmp(ret->items[ci].func, "COLLECT") == 0) { - int idx = entry->collect_counts[ci]++; - entry->collect_lists[ci] = - safe_realloc(entry->collect_lists[ci], (idx + SKIP_ONE) * sizeof(char *)); + int idx = entry->collect_counts[ci]; + entry->collect_lists[ci] = string_list_reserve(entry->collect_lists[ci], idx); + if (!entry->collect_lists[ci]) { + entry->collect_counts[ci] = 0; + continue; + } entry->collect_lists[ci][idx] = heap_strdup(raw); + entry->collect_counts[ci]++; } else if (ret->items[ci].distinct && strcmp(ret->items[ci].func, "COUNT") == 0) { /* COUNT(DISTINCT x): track unique values; emit the set size (#239). */ - distinct_list_add(&entry->collect_lists[ci], &entry->collect_counts[ci], raw); + distinct_list_add(&entry->collect_lists[ci], &entry->collect_counts[ci], + &entry->distinct_sets[ci], raw); } } } @@ -4852,6 +5291,9 @@ static void ret_agg_free(ret_agg_entry_t *aggs, int agg_count, int item_count) { for (int a = 0; a < agg_count; a++) { for (int ci = 0; ci < item_count; ci++) { safe_str_free(&aggs[a].group_vals[ci]); + if (aggs[a].distinct_sets && aggs[a].distinct_sets[ci]) { + cbm_ht_free(aggs[a].distinct_sets[ci]); + } for (int j = 0; j < aggs[a].collect_counts[ci]; j++) { free(aggs[a].collect_lists[ci][j]); } @@ -4864,6 +5306,7 @@ static void ret_agg_free(ret_agg_entry_t *aggs, int agg_count, int item_count) { free(aggs[a].maxs); free(aggs[a].collect_lists); free(aggs[a].collect_counts); + cbm_free(CBM_MEM_CLASS_OTHER, aggs[a].distinct_sets); } free(aggs); } @@ -4916,16 +5359,13 @@ static void ret_agg_emit_row(cbm_return_clause_t *ret, ret_agg_entry_t *agg, res rb_add_row(rb, row); } -static void execute_return_agg(cbm_return_clause_t *ret, binding_t *bindings, int bind_count, - result_builder_t *rb) { - int agg_cap = CBM_SZ_256; - ret_agg_entry_t *aggs = calloc(agg_cap, sizeof(ret_agg_entry_t)); - int agg_count = 0; - - for (int bi = 0; bi < bind_count; bi++) { - /* #601: grouping is O(bindings x groups) — the dominant cost on a - * whole-graph GROUP BY. Abort if we blow the wall-clock budget. */ - if ((bi & CYPHER_DEADLINE_CHECK_MASK) == 0 && cypher_deadline_exceeded()) { +static void ret_sink_feed_agg(ret_sink_t *s, binding_t *bindings, int bind_count) { + cbm_return_clause_t *ret = s->ret; + for (int bi = 0; bi < bind_count && !s->done; bi++) { + /* #601: a whole-graph GROUP BY visits every binding. Abort if we blow + * the wall-clock budget. */ + if (((s->fed + bi) & CYPHER_DEADLINE_CHECK_MASK) == 0 && cypher_deadline_exceeded()) { + s->done = true; break; } char key[CBM_SZ_1K] = ""; @@ -4933,28 +5373,23 @@ static void execute_return_agg(cbm_return_clause_t *ret, binding_t *bindings, in char valbufs[CBM_SZ_32][CBM_SZ_512]; ret_agg_build_key(ret, &bindings[bi], key, sizeof(key), vals, valbufs); - int found = CYP_FOUND_NONE; - for (int a = 0; a < agg_count; a++) { - if (strcmp(aggs[a].group_key, key) == 0) { + int found = key_index_find(&s->groups, key); + for (int a = 0; found == KEY_INDEX_UNKNOWN && a < s->agg_count; a++) { + if (strcmp(s->aggs[a].group_key, key) == 0) { found = a; - break; } } if (found < 0) { - if (agg_count >= agg_cap) { - agg_cap *= PAIR_LEN; - aggs = safe_realloc(aggs, agg_cap * sizeof(ret_agg_entry_t)); + if (s->agg_count >= s->agg_cap) { + s->agg_cap *= PAIR_LEN; + s->aggs = safe_realloc(s->aggs, s->agg_cap * sizeof(ret_agg_entry_t)); } - found = agg_count++; - ret_agg_init_group(&aggs[found], key, ret->count, vals); + found = s->agg_count++; + ret_agg_init_group(&s->aggs[found], key, ret->count, vals); + key_index_add(&s->groups, s->aggs[found].group_key, found); } - ret_agg_accumulate(&aggs[found], ret, &bindings[bi]); - } - - for (int a = 0; a < agg_count; a++) { - ret_agg_emit_row(ret, &aggs[a], rb); + ret_agg_accumulate(&s->aggs[found], ret, &bindings[bi]); } - ret_agg_free(aggs, agg_count, ret->count); } /* Build RETURN column names from items */ @@ -4987,30 +5422,30 @@ static void build_return_columns(result_builder_t *rb, cbm_return_clause_t *ret) } } -/* Execute simple (non-aggregate) RETURN projection */ -static void execute_return_simple(cbm_return_clause_t *ret, binding_t *bindings, int bind_count, - int max_rows, result_builder_t *rb) { - int proj_cap = max_rows; - bool cap_is_engine_budget = true; - if (ret->limit >= 0 && ret->order_key_count == 0 && ret->skip <= 0) { - proj_cap = ret->limit; /* DISTINCT: the cap counts distinct rows (#1364) */ - cap_is_engine_budget = false; - } - rb_distinct_t seen; - rb_distinct_init(&seen, ret->distinct); - for (int bi = 0; bi < bind_count; bi++) { - const char *vals[CBM_SZ_32]; +/* Row projections (simple RETURN, RETURN *, RETURN * after WITH): one row per + * binding until the row cap is full. */ +static void ret_sink_feed_rows(ret_sink_t *s, binding_t *bindings, int bind_count) { + for (int bi = 0; bi < bind_count && !s->done; bi++) { + const char *vals[CBM_SZ_128]; char func_bufs[CBM_SZ_32][CBM_SZ_512]; - for (int ci = 0; ci < ret->count; ci++) { - vals[ci] = - project_item(&bindings[bi], &ret->items[ci], func_bufs[ci], sizeof(func_bufs[ci])); + if (s->kind == RET_SINK_SIMPLE) { + for (int ci = 0; ci < s->ret->count; ci++) { + vals[ci] = project_item(&bindings[bi], &s->ret->items[ci], func_bufs[ci], + sizeof(func_bufs[ci])); + } + } else if (s->kind == RET_SINK_STAR) { + project_star_row(&bindings[bi], s->vars, s->vc, vals); + } else { + for (int i = 0; i < s->vc; i++) { + cbm_node_t *vn = binding_get(&bindings[bi], s->vars[i]); + vals[i] = vn && vn->name ? vn->name : ""; + } } - if (!rb_add_row_capped(rb, &seen, vals, proj_cap)) { - g_cypher_truncated = g_cypher_truncated || cap_is_engine_budget; - break; + if (!rb_add_row_capped(s->rb, &s->seen, vals, s->cap)) { + g_cypher_truncated = g_cypher_truncated || s->cap_is_engine_budget; + s->done = true; } } - rb_distinct_free(&seen); } /* Build default 3-column headers (name, qualified_name, label) per variable */ @@ -5033,24 +5468,26 @@ static void build_default_columns(result_builder_t *rb, const char **vars, int v } /* Default projection when no RETURN clause */ -static void execute_default_projection(cbm_pattern_t *pat0, binding_t *bindings, int bind_count, - int max_rows, result_builder_t *rb) { - const char *vars[CYP_MAX_VARS]; - int vc = 0; +static void ret_sink_init_default(ret_sink_t *s, const cbm_pattern_t *pat0) { + s->kind = RET_SINK_DEFAULT; for (int ni = 0; ni < pat0->node_count; ni++) { if (pat0->nodes[ni].variable) { - if (vc < CYP_MAX_VARS) { - vars[vc++] = pat0->nodes[ni].variable; + if (s->vc < CYP_MAX_VARS) { + s->vars[s->vc++] = pat0->nodes[ni].variable; } else { g_cypher_truncated = true; } } } - build_default_columns(rb, vars, vc); - for (int bi = 0; bi < bind_count && rb->row_count < max_rows; bi++) { + build_default_columns(s->rb, s->vars, s->vc); +} + +static void ret_sink_feed_default(ret_sink_t *s, binding_t *bindings, int bind_count) { + result_builder_t *rb = s->rb; + for (int bi = 0; bi < bind_count && rb->row_count < s->max_rows; bi++) { const char *vals[CYP_COL_BUF]; - for (int v = 0; v < vc; v++) { - cbm_node_t *n = binding_get(&bindings[bi], vars[v]); + for (int v = 0; v < s->vc; v++) { + cbm_node_t *n = binding_get(&bindings[bi], s->vars[v]); vals[(size_t)v * CYP_EDGE_COLS] = n && n->name ? n->name : ""; vals[((size_t)v * CYP_EDGE_COLS) + SKIP_ONE] = n && n->qualified_name ? n->qualified_name : ""; @@ -5058,9 +5495,98 @@ static void execute_default_projection(cbm_pattern_t *pat0, binding_t *bindings, } rb_add_row(rb, vals); } - if (bind_count > max_rows) { - g_cypher_truncated = true; +} + +static void ret_sink_init(ret_sink_t *s, cbm_query_t *q, int max_rows, result_builder_t *rb) { + memset(s, 0, sizeof(*s)); + s->ret = q->ret; + s->rb = rb; + s->max_rows = max_rows; + rb_init(rb); + cbm_return_clause_t *ret = q->ret; + if (!ret) { + ret_sink_init_default(s, &q->patterns[0]); + return; + } + for (int i = 0; i < ret->count; i++) { + if (is_aggregate_func(ret->items[i].func)) { + s->has_agg = true; + break; + } } + if (ret->star) { + if (q->with_clause) { + ret_sink_init_star_after_with(s, q); + } else { + ret_sink_init_star(s, q); + } + return; + } + build_return_columns(rb, ret); + if (s->has_agg) { + s->kind = RET_SINK_AGG; + s->agg_cap = CBM_SZ_256; + s->aggs = calloc(s->agg_cap, sizeof(ret_agg_entry_t)); + return; + } + s->kind = RET_SINK_SIMPLE; + s->cap = max_rows; + s->cap_is_engine_budget = true; + if (ret->limit >= 0 && ret->order_key_count == 0 && ret->skip <= 0) { + s->cap = ret->limit; /* DISTINCT: the cap counts distinct rows (#1364) */ + s->cap_is_engine_budget = false; + } + rb_distinct_init(&s->seen, ret->distinct); +} + +static void ret_sink_feed(ret_sink_t *s, binding_t *bindings, int bind_count) { + if (s->kind == RET_SINK_DEFAULT) { + ret_sink_feed_default(s, bindings, bind_count); + } else if (s->kind == RET_SINK_AGG) { + ret_sink_feed_agg(s, bindings, bind_count); + } else { + ret_sink_feed_rows(s, bindings, bind_count); + } + s->fed += bind_count; +} + +/* Release the sink's own state; the projected rows stay in the builder. */ +static void ret_sink_free(ret_sink_t *s) { + if (s->kind == RET_SINK_AGG) { + key_index_free(&s->groups); + ret_agg_free(s->aggs, s->agg_count, s->ret->count); + s->aggs = NULL; + s->agg_count = 0; + } + rb_distinct_free(&s->seen); +} + +/* Emit the aggregation groups, then DISTINCT / ORDER BY / SKIP / LIMIT. */ +static void ret_sink_finish(ret_sink_t *s) { + result_builder_t *rb = s->rb; + cbm_return_clause_t *ret = s->ret; + if (s->kind == RET_SINK_DEFAULT) { + if (s->fed > s->max_rows) { + g_cypher_truncated = true; + } + return; + } + if (s->kind == RET_SINK_AGG) { + for (int a = 0; a < s->agg_count; a++) { + ret_agg_emit_row(ret, &s->aggs[a], rb); + } + } + ret_sink_free(s); + + /* The simple and star projections de-duplicate while projecting (#1364); + * only aggregate rows still need the post-pass. */ + if (ret->distinct && s->has_agg && !ret->star) { + rb_apply_distinct(rb); + } + rb_apply_order_by(rb, ret); + bool limit_is_engine_budget = ret->limit < 0; + rb_apply_skip_limit(rb, ret->skip, limit_is_engine_budget ? s->max_rows : ret->limit, + limit_is_engine_budget); } /* Worst-case binding_t slot count for a node cross-join: one row per @@ -5253,7 +5779,7 @@ static void expand_from_bound_terminal(cbm_store_t *store, cbm_pattern_t *patn, match_count++; binding_t nb = {0}; binding_copy(&nb, b); - binding_set(&nb, start_var, &found); + binding_take_node(&nb, start_var, &found); if (rel->variable) { binding_set_edge(&nb, rel->variable, &edges[ei]); } @@ -5330,39 +5856,6 @@ static int expand_additional_patterns(cbm_store_t *store, cbm_query_t *q, const return 0; } -/* Project RETURN clause results */ -static void execute_return_clause(cbm_query_t *q, cbm_return_clause_t *ret, binding_t *bindings, - int bind_count, int max_rows, result_builder_t *rb) { - bool has_agg = false; - for (int i = 0; i < ret->count; i++) { - if (is_aggregate_func(ret->items[i].func)) { - has_agg = true; - break; - } - } - - if (ret->star) { - execute_return_star(q, bindings, bind_count, max_rows, rb); - } else { - build_return_columns(rb, ret); - if (has_agg) { - execute_return_agg(ret, bindings, bind_count, rb); - } else { - execute_return_simple(ret, bindings, bind_count, max_rows, rb); - } - } - - /* The simple and star projections de-duplicate while projecting (#1364); - * only aggregate rows still need the post-pass. */ - if (ret->distinct && has_agg && !ret->star) { - rb_apply_distinct(rb); - } - rb_apply_order_by(rb, ret); - bool limit_is_engine_budget = ret->limit < 0; - rb_apply_skip_limit(rb, ret->skip, limit_is_engine_budget ? max_rows : ret->limit, - limit_is_engine_budget); -} - /* ── Planner: seed a single-hop pattern from its selective end ────────────── * * The executor scans pattern node 0 and expands from there. Written as @@ -5469,33 +5962,247 @@ static void cypher_plan_seed_from_selective_end(cbm_query_t *q) { } } -static int execute_single(cbm_store_t *store, cbm_query_t *q, const char *project, int max_rows, - result_builder_t *rb) { - cypher_plan_seed_from_selective_end(q); - cbm_pattern_t *pat0 = &q->patterns[0]; +/* ── Streamed execution ────────────────────────────────────────────── + * + * The start nodes run through the pipeline (pattern-0 hops, late WHERE, WITH, + * RETURN) in chunks of CYPHER_STREAM_CHUNK instead of materialising every + * binding of the whole set first. On the kernel graph (7.5 M nodes, 1.9 M + * CALLS edges) `MATCH (a)-[:CALLS]->(b) RETURN count(*)` held a deep-copied + * binding per start node and per expanded row at once: 23 GB RSS, killed in a + * 12 GB container. Each hop emits a binding's expansions in the order of its + * input bindings, and every consumer keeps its state between chunks, so the + * concatenated chunk outputs are the single-pass output, order included. + * + * Additional MATCH patterns keep one pass: they pick their join strategy from + * the first binding of the whole set and refuse a cross join by its size. */ +#define CYPHER_STREAM_CHUNK 1024 + +#ifdef CBM_ENABLE_TEST_SEAMS +static _Thread_local int g_cypher_stream_chunk = 0; /* test override; 0 = default */ + +void cbm_cypher_test_set_stream_chunk(int start_nodes) { + g_cypher_stream_chunk = start_nodes; +} +#endif + +static int cypher_stream_chunk(void) { +#ifdef CBM_ENABLE_TEST_SEAMS + if (g_cypher_stream_chunk > 0) { + return g_cypher_stream_chunk; + } +#endif + return CYPHER_STREAM_CHUNK; +} + +typedef struct { + with_sink_t with; + ret_sink_t ret; +} cypher_sink_t; + +static void sink_feed(cypher_sink_t *s, binding_t *bindings, int bind_count) { + if (s->with.wc) { + with_sink_feed(&s->with, bindings, bind_count); + } else { + ret_sink_feed(&s->ret, bindings, bind_count); + } +} + +static void sink_finish(cypher_sink_t *s, cbm_query_t *q) { + if (s->with.wc) { + int vcount = 0; + binding_t *vbindings = with_sink_finish(&s->with, q, &vcount); + ret_sink_feed(&s->ret, vbindings, vcount); + for (int bi = 0; bi < vcount; bi++) { + binding_free(&vbindings[bi]); + } + cbm_free(CBM_MEM_CLASS_OTHER, vbindings); + } + ret_sink_finish(&s->ret); +} + +static void sink_free(cypher_sink_t *s) { + if (s->with.wc) { + with_sink_free(&s->with); + } + ret_sink_free(&s->ret); +} + +/* Pattern 0's start nodes in scan_pattern_nodes' order, pulled a chunk at a + * time from the store's streamed scan instead of materialised up front: a + * label alternation reads each label's scan in turn, and the inline property + * filter drops nodes as they arrive. */ +typedef struct { + cbm_store_t *store; + const char *project; + const cbm_node_pattern_t *node; + char *labels; /* label alternation "A|B": strtok_r copy, else NULL */ + char *labels_save; + bool labels_started; + cbm_store_node_scan_t *scan; + bool done; + bool failed; /* the scan could not be read to its end: the query fails */ +} start_source_t; + +/* A start-node scan failed part-way. The rows read so far must not answer the + * query as if they were all of them: record the store's error for + * cbm_cypher_execute, which fails the query with it. */ +static void start_source_fail(start_source_t *src, const char *why) { + src->failed = true; + src->done = true; + snprintf(g_cypher_scan_error, sizeof(g_cypher_scan_error), "%s", + why && why[0] ? why : "unknown store error"); + cbm_log_error("cypher.scan_failed", "error", g_cypher_scan_error); +} + +static void start_source_open(start_source_t *src, cbm_store_t *store, const char *project, + const cbm_node_pattern_t *node) { + memset(src, 0, sizeof(*src)); + src->store = store; + src->project = project; + src->node = node; + if (node->label && strchr(node->label, '|')) { + src->labels = cbm_mem_strdup(CBM_MEM_CLASS_OTHER, node->label); + if (!src->labels) { + start_source_fail(src, "out of memory"); + } + return; /* one scan per label, opened as the previous one ends */ + } + src->scan = cbm_store_node_scan_open(store, project, node->label); + if (!src->scan) { + start_source_fail(src, cbm_store_error(store)); + } +} + +/* Open the next label's scan of an alternation; false when none is left. */ +static bool start_source_next_label(start_source_t *src) { + char *tok = strtok_r(src->labels_started ? NULL : src->labels, "|", &src->labels_save); + src->labels_started = true; + if (!tok) { + return false; + } + src->scan = cbm_store_node_scan_open(src->store, src->project, tok); + if (!src->scan) { + start_source_fail(src, cbm_store_error(src->store)); + } + return true; +} + +/* Fill out[0..max) with the next start nodes. Fewer than max only once the + * scan is exhausted or has failed. */ +static int start_source_next(start_source_t *src, cbm_node_t *out, int max) { + const cbm_node_pattern_t *node = src->node; + int got = 0; + while (got < max && !src->done) { + if (!src->scan) { + if (!src->labels || !start_source_next_label(src)) { + src->done = true; + } + continue; + } + int n = 0; + if (cbm_store_node_scan_next(src->scan, out + got, max - got, &n) != CBM_STORE_OK) { + start_source_fail(src, cbm_store_error(src->store)); + break; + } + if (n == 0) { + cbm_store_node_scan_close(src->scan); + src->scan = NULL; + src->done = !src->labels; + continue; + } + /* Apply inline property filters — free rejected nodes' strings */ + int kept = got; + for (int i = got; i < got + n; i++) { + if (node->prop_count == 0 || + check_inline_props(&out[i], node->props, node->prop_count, src->store)) { + if (kept != i) { + out[kept] = out[i]; + } + kept++; + } else { + node_fields_free(&out[i]); + } + } + got = kept; + } + return got; +} + +static void start_source_close(start_source_t *src) { + cbm_store_node_scan_close(src->scan); + cbm_free(CBM_MEM_CLASS_OTHER, src->labels); + memset(src, 0, sizeof(*src)); +} + +/* Release a start-node batch: the strings the store gave each node (left + * empty once moved into a binding) and the array itself. */ +static void start_nodes_free(cbm_node_t *nodes, int count) { + for (int i = 0; nodes && i < count; i++) { + node_fields_free(&nodes[i]); + } + cbm_free(CBM_MEM_CLASS_OTHER, nodes); +} - /* Step 1: Scan initial nodes */ - cbm_node_t *scanned = NULL; - int scan_count = 0; - scan_pattern_nodes(store, project, &pat0->nodes[0], &scanned, &scan_count); +/* Pull up to `chunk` start nodes (INT_MAX: all of them) into *out. */ +static int pull_start_nodes(start_source_t *src, int chunk, cbm_node_t **out, int *count) { + int cap = chunk < CYPHER_STREAM_CHUNK ? chunk : CYPHER_STREAM_CHUNK; + cbm_node_t *nodes = cbm_alloc(CBM_MEM_CLASS_OTHER, (size_t)cap * sizeof(cbm_node_t)); + int n = 0; + while (nodes) { + n += start_source_next(src, nodes + n, cap - n); + if (n < cap || cap == chunk) { + break; /* scan exhausted, or the chunk is full */ + } + int grown_cap = cap > chunk / PAIR_LEN ? chunk : cap * PAIR_LEN; + cbm_node_t *grown = + cbm_realloc(CBM_MEM_CLASS_OTHER, nodes, (size_t)grown_cap * sizeof(cbm_node_t)); + if (!grown) { + start_nodes_free(nodes, n); + nodes = NULL; + break; + } + nodes = grown; + cap = grown_cap; + } + *out = nodes; + *count = nodes ? n : 0; + return nodes ? 0 : CBM_NOT_FOUND; +} + +/* Run the next chunk of start nodes through the pipeline into the sink. The + * start nodes' strings move into their bindings and are released with them. + * *start_index is the chunk's position in the whole start set. */ +static int execute_chunk(cbm_store_t *store, cbm_query_t *q, const char *project, int max_rows, + start_source_t *src, int chunk, int *start_index, cypher_sink_t *sink) { + cbm_pattern_t *pat0 = &q->patterns[0]; + cbm_node_t *nodes = NULL; + int node_count = 0; + if (pull_start_nodes(src, chunk, &nodes, &node_count) != 0) { + return CBM_NOT_FOUND; /* start node array refused */ + } + if (src->failed) { + start_nodes_free(nodes, node_count); + return CBM_NOT_FOUND; /* the scan failed: no answer from part of it */ + } /* Build initial bindings with early WHERE */ - int bind_cap = scan_count > max_rows ? scan_count : (max_rows > 0 ? max_rows : SKIP_ONE); + int bind_cap = node_count; binding_t *bindings = malloc(((size_t)bind_cap + SKIP_ONE) * sizeof(binding_t)); if (!bindings) { - cbm_store_free_nodes(scanned, scan_count); + start_nodes_free(nodes, node_count); return CBM_NOT_FOUND; /* initial binding array refused */ } int bind_count = 0; const char *var_name = pat0->nodes[0].variable ? pat0->nodes[0].variable : CYP_ANON_HEAD_VAR; - for (int i = 0; i < scan_count && bind_count < bind_cap; i++) { - if ((i & CYPHER_DEADLINE_CHECK_MASK) == 0 && cypher_deadline_exceeded()) { + int first = *start_index; + for (int i = 0; i < node_count; i++) { + if (((first + i) & CYPHER_DEADLINE_CHECK_MASK) == 0 && cypher_deadline_exceeded()) { break; } binding_t b = {0}; b.store = store; - binding_set(&b, var_name, &scanned[i]); + binding_take_node(&b, var_name, &nodes[i]); bool pass = eval_where_partial(q->where, &b) != CYP_PARTIAL_FALSE; if (pass) { bindings[bind_count++] = b; @@ -5503,44 +6210,64 @@ static int execute_single(cbm_store_t *store, cbm_query_t *q, const char *projec binding_free(&b); } } + *start_index = first + node_count; + start_nodes_free(nodes, node_count); /* + strings of nodes a deadline left untaken */ - /* Step 2: Expand first pattern's relationships */ + /* Expand first pattern's relationships */ expand_pattern_rels(store, pat0, &bindings, &bind_count, &bind_cap, &var_name, q->pattern_optional[0]); - /* Step 2b: Additional patterns */ - if (expand_additional_patterns(store, q, project, max_rows, &bindings, &bind_count, - &bind_cap) != 0) { - for (int bi = 0; bi < bind_count; bi++) { - binding_free(&bindings[bi]); + /* Additional patterns */ + int rc = + expand_additional_patterns(store, q, project, max_rows, &bindings, &bind_count, &bind_cap); + if (rc == 0) { + /* Late WHERE */ + if (q->where && (pat0->rel_count > 0 || q->pattern_count > SKIP_ONE)) { + filter_bindings_where(q->where, bindings, &bind_count); + } + sink_feed(sink, bindings, bind_count); + if (sink->with.oom) { + rc = CBM_NOT_FOUND; /* a WITH row buffer could not grow */ } - free(bindings); - cbm_store_free_nodes(scanned, scan_count); - return CBM_NOT_FOUND; /* cross-join allocation refused/failed */ } - /* Step 3: Late WHERE */ - if (q->where && (pat0->rel_count > 0 || q->pattern_count > SKIP_ONE)) { - filter_bindings_where(q->where, bindings, &bind_count); + for (int bi = 0; bi < bind_count; bi++) { + binding_free(&bindings[bi]); } + free(bindings); + return rc != 0 ? CBM_NOT_FOUND : 0; /* an allocation was refused or failed */ +} - /* Step 3b: WITH clause */ - execute_with_clause(q, &bindings, &bind_count); +static int execute_single(cbm_store_t *store, cbm_query_t *q, const char *project, int max_rows, + result_builder_t *rb) { + cypher_plan_seed_from_selective_end(q); + cbm_pattern_t *pat0 = &q->patterns[0]; - /* Step 4: Project results */ - rb_init(rb); - if (q->ret) { - execute_return_clause(q, q->ret, bindings, bind_count, max_rows, rb); - } else { - execute_default_projection(pat0, bindings, bind_count, max_rows, rb); - } + /* Step 1: Scan initial nodes, streamed */ + start_source_t src; + start_source_open(&src, store, project, &pat0->nodes[0]); - for (int bi = 0; bi < bind_count; bi++) { - binding_free(&bindings[bi]); + cypher_sink_t sink; + with_sink_init(&sink.with, q->with_clause); + ret_sink_init(&sink.ret, q, max_rows, rb); + + /* Steps 2-4 per chunk: expand, late WHERE, WITH / RETURN. The pipeline + * runs at least once, so an empty scan still reaches the additional + * patterns and the projection exactly as a single pass does. */ + int chunk = q->pattern_count > SKIP_ONE ? INT_MAX : cypher_stream_chunk(); + int rc = 0; + int start_index = 0; + do { + rc = execute_chunk(store, q, project, max_rows, &src, chunk, &start_index, &sink); + } while (rc == 0 && !src.done && !g_cypher_timed_out); + start_source_close(&src); + + if (rc == 0) { + sink_finish(&sink, q); + } else { + sink_free(&sink); } - free(bindings); - cbm_store_free_nodes(scanned, scan_count); - return 0; + return rc; } /* ── Main entry point ─────────────────────────────────────────── */ @@ -5788,6 +6515,18 @@ static char *check_projection_scope(const cbm_query_t *q) { return NULL; } +/* The error of an execution execute_single aborted: a failed start-node scan + * names the store's error, anything else was an allocation. */ +static char *cypher_abort_error(void) { + if (g_cypher_scan_error[0]) { + char buf[CBM_SZ_1K]; + snprintf(buf, sizeof(buf), "query aborted: reading the graph failed (%s)", + g_cypher_scan_error); + return heap_strdup(buf); + } + return heap_strdup("query aborted: out of memory or an allocation limit was reached"); +} + int cbm_cypher_execute(cbm_store_t *store, const char *query, const char *project, int max_rows, cbm_cypher_result_t *out) { memset(out, 0, sizeof(*out)); @@ -5795,6 +6534,7 @@ int cbm_cypher_execute(cbm_store_t *store, const char *query, const char *projec g_cypher_trail_truncated = 0; g_cypher_truncated = false; g_cypher_regex_refused = false; + g_cypher_scan_error[0] = '\0'; cypher_deadline_arm(); /* #601: start the wall-clock budget for this query */ /* max_rows sizes the initial binding array: non-positive means the * ceiling, and nothing above the ceiling is ever materialized anyway. */ @@ -5823,7 +6563,7 @@ int cbm_cypher_execute(cbm_store_t *store, const char *query, const char *projec rb_free(&rb); cbm_query_free(q); out->truncated = g_cypher_truncated; - out->error = heap_strdup("query aborted: out of memory or an allocation limit was reached"); + out->error = cypher_abort_error(); return CBM_NOT_FOUND; } @@ -5836,8 +6576,7 @@ int cbm_cypher_execute(cbm_store_t *store, const char *query, const char *projec rb_free(&rb2); cbm_query_free(q); out->truncated = g_cypher_truncated; - out->error = - heap_strdup("query aborted: out of memory or an allocation limit was reached"); + out->error = cypher_abort_error(); return CBM_NOT_FOUND; } /* Concatenate rows from rb2 into rb */ diff --git a/src/cypher/cypher.h b/src/cypher/cypher.h index dda0ecbe3d..10ef0aeec6 100644 --- a/src/cypher/cypher.h +++ b/src/cypher/cypher.h @@ -359,6 +359,9 @@ void cbm_cypher_test_set_deadline_ms(int64_t budget_ms); /* Override only the deadline clock on this thread; NULL restores real time. */ typedef uint64_t (*cbm_cypher_test_clock_fn)(void); void cbm_cypher_test_set_deadline_clock(cbm_cypher_test_clock_fn clock_fn); +/* Start nodes per streamed execution chunk on this thread; 0 restores the + * default. INT_MAX runs the whole start set as one chunk (a single pass). */ +void cbm_cypher_test_set_stream_chunk(int start_nodes); #endif /* Worst-case binding slot count for a node cross-join. Computes the count in diff --git a/src/daemon/application.c b/src/daemon/application.c index 7ad9fd21e3..22b6f0d1a7 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 121d2696de..cc6e8f9d55 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 1514c3c688..e61de900a7 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/git/git_context.c b/src/git/git_context.c index f0be0c4b53..66431333b4 100644 --- a/src/git/git_context.c +++ b/src/git/git_context.c @@ -44,11 +44,14 @@ static bool git_validate_repo_path(const char *repo_path) { return cbm_validate_shell_path_arg(repo_path); } -static int git_capture(const char *repo_path, const char *git_args, char **out) { - if (!out) { +/* Run `git -C repo_path git_args` and copy the first output line (newlines + * trimmed) into buf. Returns 0 only when git exited 0 with a non-empty line. */ +static int git_capture_into(const char *repo_path, const char *git_args, char *buf, + size_t buf_size) { + if (!buf || buf_size == 0) { return CBM_NOT_FOUND; } - *out = NULL; + buf[0] = '\0'; if (!repo_path || !git_args || !git_validate_repo_path(repo_path)) { return CBM_NOT_FOUND; } @@ -76,8 +79,8 @@ static int git_capture(const char *repo_path, const char *git_args, char **out) } cbm_log_debug("git.capture", "stage", "read", "args", git_args); - char buf[GIT_OUTPUT_MAX]; - if (!fgets(buf, sizeof(buf), fp)) { + if (!fgets(buf, (int)buf_size, fp)) { + buf[0] = '\0'; cbm_log_debug("git.capture", "stage", "close", "args", git_args, "output", "none"); cbm_pclose(fp); cbm_log_debug("git.capture", "stage", "done", "args", git_args); @@ -89,9 +92,21 @@ static int git_capture(const char *repo_path, const char *git_args, char **out) int rc = cbm_pclose(fp); cbm_log_debug("git.capture", "stage", "done", "args", git_args); if (rc != 0 || buf[0] == '\0') { + buf[0] = '\0'; return CBM_NOT_FOUND; } + return 0; +} +static int git_capture(const char *repo_path, const char *git_args, char **out) { + if (!out) { + return CBM_NOT_FOUND; + } + *out = NULL; + char buf[GIT_OUTPUT_MAX]; + if (git_capture_into(repo_path, git_args, buf, sizeof(buf)) != 0) { + return CBM_NOT_FOUND; + } *out = git_strdup(buf); return *out ? 0 : CBM_NOT_FOUND; } @@ -313,6 +328,30 @@ int cbm_git_context_resolve(const char *path, cbm_git_context_t *out) { return 0; } +int cbm_git_branch_resolve(const char *path, bool *is_git, char *branch, size_t branch_size) { + if (!is_git || !branch || branch_size == 0) { + return CBM_NOT_FOUND; + } + *is_git = false; + branch[0] = '\0'; + if (!path || !path[0]) { + return CBM_NOT_FOUND; + } + if (!cbm_is_dir(path)) { + return 0; + } + char worktree_root[GIT_OUTPUT_MAX]; + if (git_capture_into(path, "rev-parse --show-toplevel", worktree_root, sizeof(worktree_root)) != + 0) { + return 0; + } + *is_git = true; + if (git_capture_into(path, "symbolic-ref --quiet --short HEAD", branch, branch_size) != 0) { + snprintf(branch, branch_size, "%s", "DETACHED"); + } + return 0; +} + char *cbm_git_context_branch_qn(const char *project_name, const cbm_git_context_t *ctx) { const char *project = project_name && project_name[0] ? project_name : "project"; const char *slug = "working-tree"; diff --git a/src/git/git_context.h b/src/git/git_context.h index 876309eb6a..2ac4195228 100644 --- a/src/git/git_context.h +++ b/src/git/git_context.h @@ -2,6 +2,7 @@ #define CBM_GIT_CONTEXT_H #include +#include typedef struct { bool is_git; @@ -20,6 +21,12 @@ typedef struct { } cbm_git_context_t; int cbm_git_context_resolve(const char *path, cbm_git_context_t *out); +/* Only the is_git and branch fields of cbm_git_context_resolve, from the same + * two git commands it uses for them (2 git processes instead of 7). branch + * gets "" when path is not in a git work tree; pass CBM_GIT_BRANCH_MAX bytes + * to read exactly what cbm_git_context_resolve reads. */ +#define CBM_GIT_BRANCH_MAX 4096 +int cbm_git_branch_resolve(const char *path, bool *is_git, char *branch, size_t branch_size); void cbm_git_context_free(cbm_git_context_t *ctx); char *cbm_git_context_branch_qn(const char *project_name, const cbm_git_context_t *ctx); int cbm_git_context_props_json(const cbm_git_context_t *ctx, char *buf, int buf_size); diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index 03741e23a7..dbd09ea51f 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 @@ -82,6 +83,7 @@ enum { #include "foundation/compat_thread.h" #include "foundation/log.h" #include "foundation/limits.h" +#include "foundation/hash_table.h" #include "foundation/subprocess.h" #include "foundation/sha256.h" #include "mcp/index_supervisor.h" @@ -1863,6 +1865,7 @@ struct cbm_mcp_server { cbm_mcp_snapshot_read_test_hook_fn snapshot_read_test_hook; void *snapshot_read_test_context; #endif + bool read_batch_tool; /* this request's store reads run in a read batch */ size_t search_output_limit_override; const char *search_scan_command_override; const char *search_scratch_dir_override; /* NULL = cbm_tmpdir() */ @@ -2648,7 +2651,11 @@ static cbm_store_t *resolve_store_internal(cbm_mcp_server_t *srv, const char *pr static cbm_store_t *resolve_store(cbm_mcp_server_t *srv, const char *project) { /* Query-only callers (every read tool): strictly non-mutating resolve. */ - return resolve_store_internal(srv, project, false, false, NULL, false); + cbm_store_t *store = resolve_store_internal(srv, project, false, false, NULL, false); + if (store && srv->read_batch_tool) { + (void)cbm_store_read_batch_begin(store); + } + return store; } /* Forward decl — definition lives below in handle_trace_call_path's helpers. */ @@ -3018,16 +3025,15 @@ static bool populate_project_record(const char *dir_path, bool include_stats, * null for non-git roots — cost ~10KB across a full cache and is one * index_status call away for the project you actually care about. */ if (record->root_path[0]) { - cbm_git_context_t gctx = {0}; - (void)cbm_git_context_resolve(record->root_path, &gctx); - if (gctx.is_git && gctx.branch) { - record->branch = heap_strdup(gctx.branch); + bool is_git = false; + char branch[CBM_GIT_BRANCH_MAX]; + (void)cbm_git_branch_resolve(record->root_path, &is_git, branch, sizeof(branch)); + if (is_git && branch[0]) { + record->branch = heap_strdup(branch); if (!record->branch) { - cbm_git_context_free(&gctx); return false; } } - cbm_git_context_free(&gctx); } record->populated = true; return true; @@ -10431,20 +10437,33 @@ static char *read_file_lines(const char *path, int start, int end) { return NULL; } + /* Whole line spans per memchr instead of one locked fgetc and one append + * per byte: search_code's full mode reads the source of every result, and + * the byte loop was half of a 500-result page. Same text-mode stream, so + * Windows CRLF translation is unchanged. */ + enum { READ_FILE_LINES_CHUNK = 16384 }; cbm_sb_t selected; cbm_sb_init(&selected); int lineno = 1; - int byte; - while ((byte = fgetc(fp)) != EOF) { - if (lineno >= start && lineno <= end) { - char ch = (char)byte; - cbm_sb_append_n(&selected, &ch, 1U); - } - if (byte == '\n') { - if (lineno >= end) { - break; + char chunk[READ_FILE_LINES_CHUNK]; + bool done = false; + size_t got; + while (!done && (got = fread(chunk, 1, sizeof(chunk), fp)) > 0) { + size_t pos = 0; + while (pos < got) { + const char *newline = memchr(chunk + pos, '\n', got - pos); + size_t span_end = newline ? (size_t)(newline - chunk) + SKIP_ONE : got; + if (lineno >= start && lineno <= end) { + cbm_sb_append_n(&selected, chunk + pos, span_end - pos); + } + pos = span_end; + if (newline) { + if (lineno >= end) { + done = true; + break; + } + lineno++; } - lineno++; } } @@ -12738,6 +12757,64 @@ bool cbm_path_within_root(const char *root_path, const char *abs_path) { return false; } +/* cbm_path_within_root with the root resolved once per request: search_code + * checks every hit file and every rendered result against the same root, and + * resolving the root again for each check was a third of a 500-result page. + * An unresolvable root fails every check, exactly as cbm_path_within_root. */ +typedef struct { + char canonical[CBM_SZ_4K]; + bool resolved; + /* Verdict per absolute path, for the life of one request: the reply is + * rendered up to dozens of times while it is fitted to its byte budget, + * and each render checks every result's file again. Keys are owned. */ + CBMHashTable *verdicts; +} root_containment_t; + +enum { ROOT_CONTAINMENT_INSIDE = 1, ROOT_CONTAINMENT_OUTSIDE = 2 }; + +static void root_containment_init(root_containment_t *containment, const char *root_path) { + containment->verdicts = NULL; + containment->resolved = root_path && resolve_canonical_path(root_path, containment->canonical, + sizeof(containment->canonical)); +} + +static void root_containment_free_key(const char *key, void *value, void *userdata) { + (void)value; + (void)userdata; + cbm_free(CBM_MEM_CLASS_OTHER, (void *)key); +} + +static void root_containment_free(root_containment_t *containment) { + if (containment->verdicts) { + cbm_ht_foreach(containment->verdicts, root_containment_free_key, NULL); + cbm_ht_free(containment->verdicts); + containment->verdicts = NULL; + } +} + +static bool root_containment_check(root_containment_t *containment, const char *abs_path) { + if (!containment->resolved || !abs_path) { + return false; + } + void *cached = containment->verdicts ? cbm_ht_get(containment->verdicts, abs_path) : NULL; + if (cached) { + return (uintptr_t)cached == ROOT_CONTAINMENT_INSIDE; + } + char real_file[CBM_SZ_4K]; + bool inside = resolve_canonical_path(abs_path, real_file, sizeof(real_file)) && + canonical_path_has_root(containment->canonical, real_file); + if (!containment->verdicts) { + containment->verdicts = cbm_ht_create(0); + } + char *key = containment->verdicts ? cbm_mem_strdup(CBM_MEM_CLASS_OTHER, abs_path) : NULL; + if (key) { + (void)cbm_ht_set( + containment->verdicts, key, + (void *)(uintptr_t)(inside ? ROOT_CONTAINMENT_INSIDE : ROOT_CONTAINMENT_OUTSIDE)); + } + return inside; +} + /* #1690: a path-shaped `project` names a directory; the installed guidance tells * agents to pass their working directory. Turning it into the path-DERIVED name * alone missed a checkout indexed under an explicit name and every directory @@ -14285,7 +14362,7 @@ static yyjson_mut_val *build_dedup_files_array(yyjson_mut_doc *doc, search_resul /* Attach source or context lines to a search result JSON item. */ static void attach_result_source(yyjson_mut_doc *doc, yyjson_mut_val *item, search_result_t *r, int mode, int context_lines, int source_max_lines, - const char *root_path) { + const char *root_path, root_containment_t *containment) { enum { MODE_FULL = 1 }; if (r->start_line <= 0 || r->end_line <= 0) { return; @@ -14307,7 +14384,7 @@ static void attach_result_source(yyjson_mut_doc *doc, yyjson_mut_val *item, sear * project root (a `..` segment, or a symlink/junction that discovery * followed) must not be read back into the response. Same guard the * snippet path already uses. */ - if (!cbm_path_within_root(root_path, abs_path)) { + if (!root_containment_check(containment, abs_path)) { free(abs_path); return; } @@ -14712,7 +14789,8 @@ static char *assemble_search_output( int gm_count, int result_start, int result_limit, int output_count, int raw_start, int raw_output, int directory_start, int directory_output, int raw_limit, int directory_limit, int match_limit, int mode, int context_lines, int source_max_lines, const char *root_path, - bool scan_saturated, bool warn_literal_pipe, const search_metrics_t *metrics, bool budget_hit) { + bool scan_saturated, bool warn_literal_pipe, const search_metrics_t *metrics, bool budget_hit, + root_containment_t *containment) { enum { MODE_COMPACT = 0, MODE_FULL = 1, MODE_FILES = 2, SEARCH_SLOW_MS = 5000 }; yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); @@ -14769,7 +14847,8 @@ static char *assemble_search_output( yyjson_mut_arr_add_int(doc, row, r->out_degree); if (mode == MODE_FULL || attach_context) { yyjson_mut_val *src = yyjson_mut_obj(doc); - attach_result_source(doc, src, r, mode, context_lines, source_max_lines, root_path); + attach_result_source(doc, src, r, mode, context_lines, source_max_lines, root_path, + containment); yyjson_mut_arr_add_val(row, src); } yyjson_mut_arr_add_val(results_arr, row); @@ -14953,15 +15032,13 @@ static char *assemble_search_output( * form. Compact search has a purpose-built table renderer so repeated paths * and qualified-name prefixes can use the response-local directory. Full and * files mode first build the canonical JSON model, then project it to tree. */ -static char *render_search_payload(search_result_t *sr, int sr_count, grep_match_t *raw, - int raw_count, int raw_content_truncated, int gm_count, - int result_start, int result_limit, int output_count, - int raw_start, int raw_output, int directory_start, - int directory_output, int raw_limit, int directory_limit, - int match_limit, int mode, int context_lines, - int source_max_lines, const char *root_path, bool scan_saturated, - bool warn_literal_pipe, const search_metrics_t *metrics, - bool budget_hit, bool json_format) { +static char *render_search_payload( + search_result_t *sr, int sr_count, grep_match_t *raw, int raw_count, int raw_content_truncated, + int gm_count, int result_start, int result_limit, int output_count, int raw_start, + int raw_output, int directory_start, int directory_output, int raw_limit, int directory_limit, + int match_limit, int mode, int context_lines, int source_max_lines, const char *root_path, + bool scan_saturated, bool warn_literal_pipe, const search_metrics_t *metrics, bool budget_hit, + bool json_format, root_containment_t *request_containment) { if (mode == 0 && !json_format) { return assemble_search_output_toon(sr, sr_count, raw, raw_count, raw_content_truncated, gm_count, result_start, result_limit, output_count, @@ -14970,11 +15047,22 @@ static char *render_search_payload(search_result_t *sr, int sr_count, grep_match warn_literal_pipe, metrics, budget_hit, -1); } + /* A caller that renders once passes NULL; search_code passes the + * containment it keeps for the whole request (see root_containment_t). */ + root_containment_t local_containment; + root_containment_t *containment = request_containment; + if (!containment) { + root_containment_init(&local_containment, root_path); + containment = &local_containment; + } char *json = assemble_search_output( sr, sr_count, raw, raw_count, raw_content_truncated, gm_count, result_start, result_limit, output_count, raw_start, raw_output, directory_start, directory_output, raw_limit, directory_limit, match_limit, mode, context_lines, source_max_lines, root_path, - scan_saturated, warn_literal_pipe, metrics, budget_hit); + scan_saturated, warn_literal_pipe, metrics, budget_hit, containment); + if (!request_containment) { + root_containment_free(&local_containment); + } if (!json || json_format) { return json; } @@ -15016,7 +15104,7 @@ char *cbm_mcp_render_search_rows_for_testing(const char *const *qualified_names, char *payload = render_search_payload(results, row_count, NULL, 0, 0, row_count, 0, row_count, row_count, 0, 0, 0, row_count, 0, row_count > 0 ? 1 : 0, row_count > 0 ? 1 : 0, 0, - 0, 0, "", false, false, &zero_metrics, false, json_format); + 0, 0, "", false, false, &zero_metrics, false, json_format, NULL); free_search_results(results, initialized); return payload; } @@ -15250,7 +15338,7 @@ char *cbm_mcp_render_raw_preview_for_testing(const char *content, bool content_o search_raw_preview(&raw, content, content_offset_set, content_offset, false, 0, 0); return render_search_payload(NULL, 0, &raw, 1, raw.content_truncated ? 1 : 0, 1, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, "", false, false, &zero_metrics, false, - json_format); + json_format, NULL); } #endif @@ -15324,13 +15412,87 @@ static bool append_search_match_line(search_result_t *result, int line) { return true; } -/* Add a grep hit to the search result set (merge into existing or create new). */ -static bool add_to_search_results(search_result_t **sr, int *sr_count, int *sr_cap, cbm_node_t *n, - int line) { - for (int j = 0; j < *sr_count; j++) { - if ((*sr)[j].node_id == n->id) { - return append_search_match_line(&(*sr)[j], line); +/* Node id -> index into the search result array, so merging a hit into its + * node's result is O(1). Finding it by scanning every result so far made a + * common pattern quadratic: `def ` on django is 33,672 hits against a result + * list that grows to tens of thousands. Open addressing over a power-of-two + * table; a slot stores index + 1, so 0 is free and no node id is reserved. */ +typedef struct { + int64_t *ids; + int *slots; + int capacity; + int count; +} search_result_index_t; + +enum { SEARCH_RESULT_INDEX_MIN_CAPACITY = 64 }; + +static uint32_t search_result_index_hash(int64_t id, int capacity) { + uint64_t mixed = (uint64_t)id * UINT64_C(0x9E3779B97F4A7C15); + return (uint32_t)(mixed >> 32) & (uint32_t)(capacity - 1); +} + +static void search_result_index_free(search_result_index_t *index) { + cbm_free(CBM_MEM_CLASS_OTHER, index->ids); + cbm_free(CBM_MEM_CLASS_OTHER, index->slots); + memset(index, 0, sizeof(*index)); +} + +static int search_result_index_find(const search_result_index_t *index, int64_t id) { + if (index->capacity == 0) { + return CBM_NOT_FOUND; + } + uint32_t at = search_result_index_hash(id, index->capacity); + while (index->slots[at] != 0) { + if (index->ids[at] == id) { + return index->slots[at] - SKIP_ONE; } + at = (at + SKIP_ONE) & (uint32_t)(index->capacity - 1); + } + return CBM_NOT_FOUND; +} + +/* Insert into a table with room (search_result_index_reserve succeeded). */ +static void search_result_index_insert(search_result_index_t *index, int64_t id, int value) { + uint32_t at = search_result_index_hash(id, index->capacity); + while (index->slots[at] != 0) { + at = (at + SKIP_ONE) & (uint32_t)(index->capacity - 1); + } + index->ids[at] = id; + index->slots[at] = value + SKIP_ONE; + index->count++; +} + +/* Make room for one more entry (at most half full), growing the table. */ +static bool search_result_index_reserve(search_result_index_t *index) { + if ((index->count + SKIP_ONE) * PAIR_LEN <= index->capacity) { + return true; + } + int capacity = index->capacity ? index->capacity * PAIR_LEN : SEARCH_RESULT_INDEX_MIN_CAPACITY; + search_result_index_t grown = { + .ids = cbm_calloc(CBM_MEM_CLASS_OTHER, (size_t)capacity * sizeof(int64_t)), + .slots = cbm_calloc(CBM_MEM_CLASS_OTHER, (size_t)capacity * sizeof(int)), + .capacity = capacity, + }; + if (!grown.ids || !grown.slots) { + search_result_index_free(&grown); + return false; + } + for (int i = 0; i < index->capacity; i++) { + if (index->slots[i] != 0) { + search_result_index_insert(&grown, index->ids[i], index->slots[i] - SKIP_ONE); + } + } + search_result_index_free(index); + *index = grown; + return true; +} + +/* Add a grep hit to the search result set (merge into existing or create new). */ +static bool add_to_search_results(search_result_t **sr, int *sr_count, int *sr_cap, + search_result_index_t *index, cbm_node_t *n, int line) { + int existing = search_result_index_find(index, n->id); + if (existing >= 0) { + return append_search_match_line(&(*sr)[existing], line); } if (*sr_count >= *sr_cap) { int next_capacity = *sr_cap * PAIR_LEN; @@ -15342,6 +15504,11 @@ static bool add_to_search_results(search_result_t **sr, int *sr_count, int *sr_c *sr = grown; *sr_cap = next_capacity; } + /* Room in the index first, so nothing below can fail after the result's + * strings exist. */ + if (!search_result_index_reserve(index)) { + return false; + } search_result_t *r = &(*sr)[*sr_count]; if (!set_search_result_identity(r, n)) { return false; @@ -15353,18 +15520,20 @@ static bool add_to_search_results(search_result_t **sr, int *sr_count, int *sr_c r->file = NULL; return false; } + search_result_index_insert(index, n->id, *sr_count); (*sr_count)++; return true; } /* Match a single grep hit to the tightest containing node, then add to sr or raw. */ static bool classify_grep_hit(grep_match_t *hit, cbm_node_t *file_nodes, int file_node_count, - search_result_t **sr, int *sr_count, int *sr_cap, grep_match_t **raw, - int raw_offset, int raw_limit, int *raw_count, int *raw_stored_count, - int *raw_cap, int *raw_content_truncated) { + search_result_t **sr, int *sr_count, int *sr_cap, + search_result_index_t *sr_index, grep_match_t **raw, int raw_offset, + int raw_limit, int *raw_count, int *raw_stored_count, int *raw_cap, + int *raw_content_truncated) { int best = find_tightest_node(file_nodes, file_node_count, hit->line); if (best >= 0) { - return add_to_search_results(sr, sr_count, sr_cap, &file_nodes[best], hit->line); + return add_to_search_results(sr, sr_count, sr_cap, sr_index, &file_nodes[best], hit->line); } else { if (*raw_count == INT_MAX) { return false; @@ -15421,13 +15590,14 @@ static void free_file_nodes(cbm_node_t *nodes, int count) { * applies before it reads a result's source, applied to the match itself, * once per distinct file (grep groups a file's hits). A path too long for * the canonical-path buffers is one that check cannot resolve either. */ -static bool search_hit_file_within_root(const char *root_path, const char *file) { +static bool search_hit_file_within_root(root_containment_t *containment, const char *root_path, + const char *file) { char abs_path[CBM_SZ_4K]; int length = snprintf(abs_path, sizeof(abs_path), "%s/%s", root_path, file); if (length < 0 || (size_t)length >= sizeof(abs_path)) { return false; } - return cbm_path_within_root(root_path, abs_path); + return root_containment_check(containment, abs_path); } /* Parse and classify the complete grep stream without retaining one object per @@ -15446,6 +15616,17 @@ static bool scan_and_classify_grep_matches( bool current_file_inside = false; cbm_node_t *file_nodes = NULL; int file_node_count = 0; + root_containment_t containment; + root_containment_init(&containment, root_path); + search_result_index_t sr_index = {0}; + for (int i = 0; i < *sr_count; i++) { + if (!search_result_index_reserve(&sr_index)) { + search_result_index_free(&sr_index); + root_containment_free(&containment); + return false; + } + search_result_index_insert(&sr_index, (*sr)[i].node_id, i); + } cbm_regex_t content_regex; bool content_regex_ready = use_regex && pattern && cbm_regcomp(&content_regex, pattern, CBM_REG_EXTENDED) == CBM_REG_OK; @@ -15499,7 +15680,7 @@ static bool scan_and_classify_grep_matches( ok = false; break; } - current_file_inside = search_hit_file_within_root(root_path, file); + current_file_inside = search_hit_file_within_root(&containment, root_path, file); if (store && current_file_inside) { (void)cbm_store_find_nodes_by_file(store, project, file, &file_nodes, &file_node_count); @@ -15531,8 +15712,8 @@ static bool scan_and_classify_grep_matches( &match_start, &match_end); search_raw_preview(&hit, content, raw_content_offset_set, raw_content_offset, match_known, match_start, match_end); - if (!classify_grep_hit(&hit, file_nodes, file_node_count, sr, sr_count, sr_cap, raw, - raw_offset, raw_limit, raw_count, raw_stored_count, raw_cap, + if (!classify_grep_hit(&hit, file_nodes, file_node_count, sr, sr_count, sr_cap, &sr_index, + raw, raw_offset, raw_limit, raw_count, raw_stored_count, raw_cap, raw_content_truncated)) { free(hit.file); ok = false; @@ -15541,6 +15722,8 @@ static bool scan_and_classify_grep_matches( free(hit.file); } + search_result_index_free(&sr_index); + root_containment_free(&containment); if (content_regex_ready) { cbm_regfree(&content_regex); } @@ -15832,6 +16015,374 @@ static void search_scratch_close(search_scratch_t *scratch) { } } +/* ── In-process content scan ──────────────────────────────────────── + * search_code used to hand the scoped file list to `xargs sh grep` on POSIX and + * to a PowerShell Select-String pipeline on Windows: a process spawn per call, + * BSD grep's ~130 MB/s on macOS, and one cmdlet invocation per file on Windows + * (23-31 s per call on GitHub's windows-11-arm image, against the 30 s scan + * deadline). This scanner reads the same list and writes the records + * `grep -Hn` printed, so the classifier and everything after it are unchanged: + * - patterns: the pattern split on '\n', as `grep -f` reads its file; an + * empty pattern matches every line (grep special-cases it in -E mode too); + * - literal mode is a substring match (grep -F); regex mode runs each line + * through cbm_regex, the platform's POSIX ERE engine (grep -E); + * - a NUL byte in the first 32 KiB marks the file binary and yields no + * record, as grep prints no lines for it; after a later NUL nothing more + * is reported, as GNU grep stops line output there; + * - a record is `\n` with + * ':' (POSIX) or TAB (Windows), the separators the classifier reads; the + * line keeps a CR, which the classifier strips as before. + * Deadline, cancellation and the output limit end the scan with the causes the + * subprocess runner reported; an unreadable listed file fails the scan closed, + * as grep's error exit did. */ +enum { + SEARCH_SCAN_BINARY_PROBE = 32768, + SEARCH_SCAN_CHECK_EVERY = 4096, /* lines between deadline/cancel checks */ +}; + +typedef struct { + char **items; + size_t *lengths; + int count; + cbm_regex_t *regexes; /* regex mode: one per non-empty pattern */ + int compiled; + bool any_empty; /* an empty pattern: every line matches */ +} search_scan_patterns_t; + +static void search_scan_patterns_free(search_scan_patterns_t *patterns) { + for (int i = 0; i < patterns->count; i++) { + cbm_free(CBM_MEM_CLASS_OTHER, patterns->items[i]); + } + for (int i = 0; patterns->regexes && i < patterns->count; i++) { + if (i < patterns->compiled && patterns->lengths[i] > 0) { + cbm_regfree(&patterns->regexes[i]); + } + } + cbm_free(CBM_MEM_CLASS_OTHER, patterns->items); + cbm_free(CBM_MEM_CLASS_OTHER, patterns->lengths); + cbm_free(CBM_MEM_CLASS_OTHER, patterns->regexes); + memset(patterns, 0, sizeof(*patterns)); +} + +static bool search_scan_patterns_parse(search_scan_patterns_t *patterns, const char *pattern, + bool use_regex) { + memset(patterns, 0, sizeof(*patterns)); + int count = 1; + for (const char *p = pattern; *p; p++) { + count += *p == '\n' ? SKIP_ONE : 0; + } + patterns->items = cbm_calloc(CBM_MEM_CLASS_OTHER, (size_t)count * sizeof(char *)); + patterns->lengths = cbm_calloc(CBM_MEM_CLASS_OTHER, (size_t)count * sizeof(size_t)); + patterns->regexes = + use_regex ? cbm_calloc(CBM_MEM_CLASS_OTHER, (size_t)count * sizeof(cbm_regex_t)) : NULL; + if (!patterns->items || !patterns->lengths || (use_regex && !patterns->regexes)) { + search_scan_patterns_free(patterns); + return false; + } + const char *start = pattern; + for (int i = 0; i < count; i++) { + const char *end = strchr(start, '\n'); + size_t length = end ? (size_t)(end - start) : strlen(start); + patterns->items[i] = cbm_alloc(CBM_MEM_CLASS_OTHER, length + SKIP_ONE); + if (!patterns->items[i]) { + patterns->count = i; + search_scan_patterns_free(patterns); + return false; + } + memcpy(patterns->items[i], start, length); + patterns->items[i][length] = '\0'; + patterns->lengths[i] = length; + patterns->any_empty = patterns->any_empty || length == 0; + start = end ? end + SKIP_ONE : start + length; + } + patterns->count = count; + for (int i = 0; use_regex && i < count; i++) { + if (patterns->lengths[i] > 0 && + cbm_regcomp(&patterns->regexes[i], patterns->items[i], + CBM_REG_EXTENDED | CBM_REG_NOSUB) != CBM_REG_OK) { + patterns->compiled = i; + search_scan_patterns_free(patterns); + return false; + } + patterns->compiled = i + SKIP_ONE; + } + return true; +} + +/* First occurrence of needle in hay; memmem where the C library has it. */ +static const char *search_scan_find(const char *hay, size_t hay_length, const char *needle, + size_t needle_length) { + if (needle_length == 0) { + return hay; + } + if (needle_length > hay_length) { + return NULL; + } +#ifdef _WIN32 + const char *last = hay + (hay_length - needle_length); + for (const char *p = hay; p <= last;) { + p = memchr(p, (unsigned char)needle[0], (size_t)(last - p) + SKIP_ONE); + if (!p) { + return NULL; + } + if (memcmp(p, needle, needle_length) == 0) { + return p; + } + p++; + } + return NULL; +#else + return memmem(hay, hay_length, needle, needle_length); +#endif +} + +static size_t search_scan_count_newlines(const char *from, const char *to) { + size_t count = 0; + while (from < to) { + const char *newline = memchr(from, '\n', (size_t)(to - from)); + if (!newline) { + break; + } + count++; + from = newline + SKIP_ONE; + } + return count; +} + +typedef struct { + FILE *out; + size_t written; + size_t output_limit; + char separator; +} search_scan_sink_t; + +/* One `grep -Hn` record. False on a write error or once past the output limit. */ +static bool search_scan_emit(search_scan_sink_t *sink, const char *path, size_t path_length, + size_t line_number, const char *line, size_t line_length, + bool *over_limit) { + char number[CBM_SZ_32]; + int number_length = + snprintf(number, sizeof(number), "%c%zu%c", sink->separator, line_number, sink->separator); + if (number_length <= 0 || (size_t)number_length >= sizeof(number) || + fwrite(path, 1, path_length, sink->out) != path_length || + fwrite(number, 1, (size_t)number_length, sink->out) != (size_t)number_length || + fwrite(line, 1, line_length, sink->out) != line_length || fputc('\n', sink->out) == EOF) { + return false; + } + sink->written += path_length + (size_t)number_length + line_length + SKIP_ONE; + if (sink->output_limit > 0 && sink->written > sink->output_limit) { + *over_limit = true; + return false; + } + return true; +} + +static bool search_scan_line_matches(const search_scan_patterns_t *patterns, bool use_regex, + const char *line) { + if (patterns->any_empty) { + return true; + } + for (int i = 0; i < patterns->count; i++) { + if (use_regex ? cbm_regexec(&patterns->regexes[i], line, 0, NULL, 0) == CBM_REG_OK + : strstr(line, patterns->items[i]) != NULL) { + return true; + } + } + return false; +} + +/* Scan one file. `buffer` holds its bytes plus one spare byte. */ +static mcp_scan_cause_t search_scan_buffer(cbm_mcp_server_t *srv, search_scan_sink_t *sink, + const search_scan_patterns_t *patterns, bool use_regex, + const char *path, size_t path_length, char *buffer, + size_t length, uint64_t deadline_ms) { + size_t probe = length < SEARCH_SCAN_BINARY_PROBE ? length : SEARCH_SCAN_BINARY_PROBE; + if (memchr(buffer, '\0', probe)) { + return MCP_SCAN_SUCCESS; + } + const char *nul = memchr(buffer, '\0', length); + if (nul) { + /* Lines up to the last complete line before the NUL. */ + size_t keep = 0; + for (size_t i = (size_t)(nul - buffer); i > 0; i--) { + if (buffer[i - SKIP_ONE] == '\n') { + keep = i; + break; + } + } + length = keep; + } + buffer[length] = '\0'; + bool over_limit = false; + size_t line_number = 1; + size_t since_check = 0; + bool single_literal = !use_regex && !patterns->any_empty && patterns->count == 1; + size_t pos = 0; + while (pos < length) { + if (++since_check >= SEARCH_SCAN_CHECK_EVERY) { + since_check = 0; + if (mcp_request_cancelled(srv)) { + return MCP_SCAN_CANCELLED; + } + if (cbm_now_ms() >= deadline_ms) { + return MCP_SCAN_DEADLINE; + } + } + size_t line_start = pos; + if (single_literal) { + /* Jump to the next occurrence instead of visiting every line. */ + const char *hit = search_scan_find(buffer + pos, length - pos, patterns->items[0], + patterns->lengths[0]); + if (!hit) { + break; + } + line_start = (size_t)(hit - buffer); + while (line_start > pos && buffer[line_start - SKIP_ONE] != '\n') { + line_start--; + } + line_number += search_scan_count_newlines(buffer + pos, buffer + line_start); + } + char *newline = memchr(buffer + line_start, '\n', length - line_start); + size_t line_end = newline ? (size_t)(newline - buffer) : length; + /* Windows matched Select-String's Line, which has no trailing CR, so + * `foo$` matched a CRLF line there; POSIX grep matches with the CR. */ + size_t match_end = line_end; +#ifdef _WIN32 + if (match_end > line_start && buffer[match_end - SKIP_ONE] == '\r') { + match_end--; + } +#endif + char saved = buffer[match_end]; + buffer[match_end] = '\0'; + bool matched = + single_literal || search_scan_line_matches(patterns, use_regex, buffer + line_start); + buffer[match_end] = saved; + if (newline) { + *newline = '\n'; + } + if (matched && !search_scan_emit(sink, path, path_length, line_number, buffer + line_start, + line_end - line_start, &over_limit)) { + return over_limit ? MCP_SCAN_OUTPUT_LIMIT : MCP_SCAN_COMMAND_FAILURE; + } + pos = line_end + SKIP_ONE; + line_number++; + } + return MCP_SCAN_SUCCESS; +} + +/* Read a whole file into *buffer (grown as needed, CBM_MEM_CLASS_EXTRACT), + * leaving one spare byte. */ +static bool search_scan_read_file(const char *path, char **buffer, size_t *capacity, + size_t *length) { + FILE *fp = cbm_fopen(path, "rb"); + if (!fp) { + return false; + } + size_t used = 0; + bool ok = true; + for (;;) { + if (*capacity - used < CBM_SZ_64K + SKIP_ONE) { + size_t grown = *capacity ? *capacity * PAIR_LEN : (size_t)CBM_SZ_64K * PAIR_LEN; + if (grown <= *capacity || grown > (size_t)INT_MAX) { + ok = false; + break; + } + char *next = cbm_realloc(CBM_MEM_CLASS_EXTRACT, *buffer, grown); + if (!next) { + ok = false; + break; + } + *buffer = next; + *capacity = grown; + } + size_t want = *capacity - used - SKIP_ONE; + size_t got = fread(*buffer + used, 1, want, fp); + used += got; + if (got < want) { + ok = !ferror(fp); + break; + } + } + (void)fclose(fp); + *length = used; + return ok; +} + +static mcp_scan_cause_t search_scan_in_process(cbm_mcp_server_t *srv, const char *filelist_path, + const char *pattern, bool use_regex, FILE *out, + size_t output_limit, uint64_t deadline_ms) { +#ifdef _WIN32 + const char record_separator = '\n'; + const char field_separator = '\t'; +#else + const char record_separator = '\0'; + const char field_separator = ':'; +#endif + search_scan_patterns_t patterns; + if (!search_scan_patterns_parse(&patterns, pattern, use_regex)) { + return MCP_SCAN_COMMAND_FAILURE; + } + char *list = NULL; + size_t list_capacity = 0; + size_t list_length = 0; + if (!search_scan_read_file(filelist_path, &list, &list_capacity, &list_length)) { + cbm_free(CBM_MEM_CLASS_EXTRACT, list); + search_scan_patterns_free(&patterns); + return MCP_SCAN_COMMAND_FAILURE; + } + search_scan_sink_t sink = { + .out = out, .output_limit = output_limit, .separator = field_separator}; + char *buffer = NULL; + size_t capacity = 0; + mcp_scan_cause_t cause = MCP_SCAN_SUCCESS; + size_t pos = 0; + while (cause == MCP_SCAN_SUCCESS && pos < list_length) { + const char *end = memchr(list + pos, record_separator, list_length - pos); + size_t record_end = end ? (size_t)(end - list) : list_length; + list[record_end] = '\0'; + const char *path = list + pos; + size_t path_length = record_end - pos; + pos = record_end + SKIP_ONE; + if (path_length == 0) { + continue; + } + if (mcp_request_cancelled(srv)) { + cause = MCP_SCAN_CANCELLED; + break; + } + if (cbm_now_ms() >= deadline_ms) { + cause = MCP_SCAN_DEADLINE; + break; + } + size_t length = 0; + if (!search_scan_read_file(path, &buffer, &capacity, &length)) { + cause = MCP_SCAN_COMMAND_FAILURE; + break; + } + cause = search_scan_buffer(srv, &sink, &patterns, use_regex, path, path_length, buffer, + length, deadline_ms); + } + cbm_free(CBM_MEM_CLASS_EXTRACT, buffer); + cbm_free(CBM_MEM_CLASS_EXTRACT, list); + search_scan_patterns_free(&patterns); + return cause; +} + +int cbm_mcp_search_scan_for_test(cbm_mcp_server_t *srv, const char *filelist_path, + const char *pattern, bool use_regex, const char *out_path, + size_t output_limit, uint64_t deadline_ms) { + FILE *out = cbm_fopen(out_path, "wb"); + if (!out) { + return MCP_SCAN_COMMAND_FAILURE; + } + mcp_scan_cause_t cause = search_scan_in_process(srv, filelist_path, pattern, use_regex, out, + output_limit, deadline_ms); + if (fclose(out) != 0 && cause == MCP_SCAN_SUCCESS) { + cause = MCP_SCAN_COMMAND_FAILURE; + } + return (int)cause; +} + /* Open the scratch directory, write `pattern` to the grep -f file, and leave the * file list open for write_scoped_filelist. Returns true on success; on failure * everything already created is removed before returning. */ @@ -16258,10 +16809,39 @@ static char *handle_search_code(cbm_mcp_server_t *srv, const char *args) { : MCP_SEARCH_OUTPUT_MAX; const char *scan_command = srv->search_scan_command_override ? srv->search_scan_command_override : cmd; - mcp_scan_cause_t scan_cause = mcp_run_shell_command_cancellable_bounded( - srv, scan_command, output_path, scan_output_limit, scan_deadline_ms, true, - scan_deadline_latched, /*exit_one_is_no_match=*/false, /*git_child=*/false, - &scan_result); + /* An indexed project is scanned in process (see search_scan_in_process). + * The recursive fallback for an unindexed root, a Windows file_pattern + * whose -like post-filter cannot be applied ahead of the scan, and the + * test command override keep the subprocess runner. */ + bool scan_in_process = scoped && !srv->search_scan_command_override; +#ifdef _WIN32 + scan_in_process = + scan_in_process && + (!file_pattern || cbm_search_code_file_pattern_can_prefilter(file_pattern)); +#endif + mcp_scan_cause_t scan_cause; + if (scan_in_process) { + FILE *scan_out = NULL; + if (scan_cancellation_latched || mcp_request_cancelled(srv)) { + scan_cause = MCP_SCAN_CANCELLED; + } else if (scan_deadline_latched || cbm_now_ms() >= scan_deadline_ms) { + scan_cause = MCP_SCAN_DEADLINE; + } else if (!(scan_out = search_scratch_file(scratch.dir, "scan", output_path, + sizeof(output_path)))) { + scan_cause = MCP_SCAN_COMMAND_FAILURE; + } else { + scan_cause = search_scan_in_process(srv, filelist, pattern, use_regex, scan_out, + scan_output_limit, scan_deadline_ms); + } + if (scan_out && fclose(scan_out) != 0 && scan_cause == MCP_SCAN_SUCCESS) { + scan_cause = MCP_SCAN_COMMAND_FAILURE; + } + } else { + scan_cause = mcp_run_shell_command_cancellable_bounded( + srv, scan_command, output_path, scan_output_limit, scan_deadline_ms, true, + scan_deadline_latched, /*exit_one_is_no_match=*/false, /*git_child=*/false, + &scan_result); + } /* Both POSIX commands wrap grep and map its no-match status to 0, so any * non-zero exit (a failed find/sort, an unreadable operand, a broken * grep) is an incomplete scan and fails closed. */ @@ -16409,11 +16989,15 @@ static char *handle_search_code(cbm_mcp_server_t *srv, const char *args) { directory_output = directory_limit; } bool warn_literal_pipe = pat_has_pipe && !use_regex; + /* Every render below re-checks each result's file against the root; one + * containment for the request keeps that to one resolution per file. */ + root_containment_t request_containment; + root_containment_init(&request_containment, root_path); char *payload = render_search_payload( sr, sr_count, raw, raw_count, raw_content_truncated, gm_count, result_start, result_limit, output_count, raw_start, raw_output, directory_start, directory_output, raw_limit, directory_limit, match_limit, mode, context_lines, source_max_lines, root_path, - scan_saturated, warn_literal_pipe, &metrics, false, sc_legacy_json); + scan_saturated, warn_literal_pipe, &metrics, false, sc_legacy_json, &request_containment); if (payload && strlen(payload) > byte_budget) { free(payload); @@ -16421,7 +17005,8 @@ static char *handle_search_code(cbm_mcp_server_t *srv, const char *args) { sr, sr_count, raw, raw_count, raw_content_truncated, gm_count, result_start, result_limit, output_count, raw_start, raw_output, directory_start, directory_output, raw_limit, directory_limit, match_limit, mode, context_lines, source_max_lines, - root_path, scan_saturated, warn_literal_pipe, &metrics, true, sc_legacy_json); + root_path, scan_saturated, warn_literal_pipe, &metrics, true, sc_legacy_json, + &request_containment); } /* Preserve ranked graph answers. Unclassified raw grep rows and directory @@ -16433,7 +17018,8 @@ static char *handle_search_code(cbm_mcp_server_t *srv, const char *args) { sr, sr_count, raw, raw_count, raw_content_truncated, gm_count, result_start, result_limit, output_count, raw_start, raw_output, directory_start, directory_output, raw_limit, directory_limit, match_limit, mode, context_lines, source_max_lines, - root_path, scan_saturated, warn_literal_pipe, &metrics, true, sc_legacy_json); + root_path, scan_saturated, warn_literal_pipe, &metrics, true, sc_legacy_json, + &request_containment); } while (payload && strlen(payload) > byte_budget && directory_output > 0) { directory_output--; @@ -16442,7 +17028,8 @@ static char *handle_search_code(cbm_mcp_server_t *srv, const char *args) { sr, sr_count, raw, raw_count, raw_content_truncated, gm_count, result_start, result_limit, output_count, raw_start, raw_output, directory_start, directory_output, raw_limit, directory_limit, match_limit, mode, context_lines, source_max_lines, - root_path, scan_saturated, warn_literal_pipe, &metrics, true, sc_legacy_json); + root_path, scan_saturated, warn_literal_pipe, &metrics, true, sc_legacy_json, + &request_containment); } /* Full source is the next detail tier. Find the largest whole-line window @@ -16459,7 +17046,7 @@ static char *handle_search_code(cbm_mcp_server_t *srv, const char *args) { result_limit, output_count, raw_start, raw_output, directory_start, directory_output, raw_limit, directory_limit, match_limit, mode, context_lines, middle, root_path, scan_saturated, warn_literal_pipe, &metrics, true, - sc_legacy_json); + sc_legacy_json, &request_containment); if (candidate && strlen(candidate) <= byte_budget) { free(best_payload); best_payload = candidate; @@ -16481,7 +17068,7 @@ static char *handle_search_code(cbm_mcp_server_t *srv, const char *args) { result_limit, output_count, raw_start, raw_output, directory_start, directory_output, raw_limit, directory_limit, match_limit, mode, context_lines, source_max_lines, root_path, scan_saturated, warn_literal_pipe, &metrics, true, - sc_legacy_json); + sc_legacy_json, &request_containment); } } @@ -16497,7 +17084,7 @@ static char *handle_search_code(cbm_mcp_server_t *srv, const char *args) { result_limit, candidate_rows, raw_start, raw_output, directory_start, directory_output, raw_limit, directory_limit, match_limit, mode, context_lines, source_max_lines, root_path, scan_saturated, warn_literal_pipe, &metrics, true, - sc_legacy_json); + sc_legacy_json, &request_containment); if (candidate && strlen(candidate) <= byte_budget) { best_payload = candidate; break; @@ -16514,6 +17101,7 @@ static char *handle_search_code(cbm_mcp_server_t *srv, const char *args) { raw_start, raw_limit, directory_start, directory_limit, dir_total, scan_saturated, sc_legacy_json, max_output_tokens); } + root_containment_free(&request_containment); char *result = cbm_mcp_text_result(payload ? payload : "out of memory", payload == NULL); free(payload); free_search_results(sr, sr_count); @@ -19963,6 +20551,24 @@ static char *dispatch_tool(cbm_mcp_server_t *srv, const char *tool_name, const c return cbm_mcp_text_result(msg, true); } +/* Read tools whose work is store reads from start to end run them in one store + * read batch (cbm_store_read_batch_begin). Left out: tools that write, open + * their own transaction or read scope (detect_changes), or hold the store + * across a long scan or a git subprocess (search_code, index_status). */ +static bool mcp_tool_reads_in_batch(const char *tool_name) { + static const char *const batched[] = { + "query_graph", "trace_path", "trace_call_path", "search_graph", + "get_architecture", "get_graph_schema", "get_code_snippet", "get_file_outline", + "compare_graphs", "check_index_coverage", + }; + for (size_t i = 0; tool_name && i < sizeof(batched) / sizeof(batched[0]); i++) { + if (strcmp(tool_name, batched[i]) == 0) { + return true; + } + } + return false; +} + /* File-backed query stores are request-scoped. Keeping one open between MCP * calls pins an old database generation after another process atomically * replaces the project DB. On Windows it can also prevent that replacement @@ -19998,13 +20604,24 @@ char *cbm_mcp_handle_tool(cbm_mcp_server_t *srv, const char *tool_name, const ch return cbm_mcp_text_result("request cancellation scope unavailable", true); } cbm_mem_phase_mark("request.dispatch_tool"); + if (srv) { + srv->read_batch_tool = mcp_tool_reads_in_batch(tool_name); + } char *result = dispatch_tool(srv, tool_name, args_json); + if (srv) { + /* An embedded store outlives the request: end its batch here. */ + srv->read_batch_tool = false; + cbm_store_read_batch_end(srv->store); + } cbm_mem_phase_mark("request.scope_end"); if (srv) { cbm_mcp_server_request_scope_end(srv); } 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/mcp/mcp_internal.h b/src/mcp/mcp_internal.h index 02da636b2d..7035d80baf 100644 --- a/src/mcp/mcp_internal.h +++ b/src/mcp/mcp_internal.h @@ -39,6 +39,13 @@ void cbm_mcp_server_set_search_scan_timeout_for_test(cbm_mcp_server_t *srv, uint * scratch count cannot see other processes' entries in the shared temp dir. * The server borrows dir until it is reset or the server is freed. */ void cbm_mcp_server_set_search_scratch_dir_for_test(cbm_mcp_server_t *srv, const char *dir); +/* search_code's in-process scan of `filelist_path` (records separated as the + * scoped list is written: NUL on POSIX, newline on Windows) into `out_path`. + * Returns the scan cause in mcp_scan_cause_t order: 0 success, 1 command + * failure, 3 output limit, 4 deadline, 5 cancelled. */ +int cbm_mcp_search_scan_for_test(cbm_mcp_server_t *srv, const char *filelist_path, + const char *pattern, bool use_regex, const char *out_path, + size_t output_limit, uint64_t deadline_ms); /* Release only the constructor-created pristine in-memory store. Public * cbm_mcp_server_new(NULL) semantics remain unchanged; daemon sessions use diff --git a/src/pipeline/pipeline.c b/src/pipeline/pipeline.c index 002b5a46c2..92e23de64f 100644 --- a/src/pipeline/pipeline.c +++ b/src/pipeline/pipeline.c @@ -260,6 +260,15 @@ struct cbm_pipeline { * "invalid_existing_db" (#1864). */ bool final_existed; bool existing_generation; + /* The copy into the stage is deferred until routing needs it: an + * unchanged repository is proven current on the live generation + * (live_db_path, read-only) and never copies, seals or renames a + * database. backup_succeeded records the copy once it ran; live_noop + * marks a run that ended on the live proof with nothing staged. */ + bool stage_backup_pending; + bool backup_succeeded; + bool live_noop; + const char *live_db_path; /* borrowed from cbm_pipeline_run */ /* ADR (project_summaries) captured before a full-reindex DB delete, so it * can be restored after the rebuild. NULL when no ADR existed. Issue #516. */ @@ -848,7 +857,10 @@ cbm_pipeline_t *cbm_pipeline_new(const char *repo_path, const char *db_path, p->repo_path = strdup(repo_path); p->db_path = db_path ? strdup(db_path) : NULL; p->project_name = cbm_project_name_from_path(repo_path); - (void)cbm_git_context_resolve(repo_path, &p->git_ctx); + /* The Git context is resolved once, when the run starts + * (pipeline_refresh_git_context): a context resolved here was always + * replaced before any use, at the cost of seven git subprocesses per + * index call. Until then the branch QN names the working tree. */ p->branch_qn = cbm_git_context_branch_qn(p->project_name, &p->git_ctx); p->requested_mode = mode; p->mode = mode; @@ -2738,12 +2750,69 @@ static int capture_existing_adr(cbm_pipeline_t *p, const char *db_path) { return 0; } +static int remove_stage_files(const char *stage_path); + +/* Copy the live generation into this run's stage: every route except the + * live no-op works on that copy. A failed copy behaves as the eager copy it + * replaces did: the stage files go (the name and its lock stay this run's, + * the rebuilt generation is renamed over it later) and the run has no + * previous generation, so it rebuilds in full. */ +static void materialize_stage_backup(cbm_pipeline_t *p) { + p->stage_backup_pending = false; + p->backup_succeeded = cbm_store_backup_path(p->live_db_path, p->db_path) == CBM_STORE_OK; + if (!p->backup_succeeded) { + cbm_log_warn("pipeline.stage", "action", "backup_failed_full_rebuild", "path", + p->live_db_path); + (void)remove_stage_files(p->db_path); + } + p->existing_generation = p->backup_succeeded; +} + +/* The live generation is provably current: read-only, it passes the shallow + * integrity and format checks the staged route applies to its copy, and the + * exact semantic-manifest test the staged route would answer no-op with. A + * copy of it would route to that no-op and publish the same database again, + * so the run ends here with nothing copied, sealed or renamed. Any doubt + * answers false and the run copies and routes exactly as before. */ +static bool live_generation_current(cbm_pipeline_t *p, const cbm_file_hash_t *baseline_manifest, + int baseline_count) { + struct stat db_st; + if (!p->live_db_path || stat(p->live_db_path, &db_st) != 0) { + return false; + } + cbm_store_t *store = cbm_store_open_path_query(p->live_db_path); + if (!store) { + return false; + } + int fmt = 0; + bool current = cbm_store_check_integrity(store); + if (current) { + cbm_store_get_format_version(store, &fmt); + current = + fmt == CBM_INDEX_FORMAT_VERSION && + cbm_pipeline_incremental_generation_exact(p, store, baseline_manifest, baseline_count); + } + cbm_store_close(store); + return current; +} + /* Route an existing generation. Full rebuilds never delete the live DB here: * publication owns the eventual atomic replacement after every pass and * metadata write has succeeded. */ static int try_incremental_or_delete_db(cbm_pipeline_t *p, cbm_file_info_t *files, int file_count, const cbm_file_hash_t *baseline_manifest, int baseline_count, bool force_full_on_mismatch) { + if (p->stage_backup_pending) { + if (live_generation_current(p, baseline_manifest, baseline_count)) { + p->stage_backup_pending = false; + p->live_noop = true; + cbm_log_info("pipeline.route", "path", "incremental_manifest", "generation", "live"); + cbm_log_info("incremental.noop", "reason", "semantic_manifest_equal", "generation", + "live"); + return 0; + } + materialize_stage_backup(p); + } if (!p->existing_generation) { /* Nothing to be incremental against: a first index, or a * destination that could not be copied (already reported as @@ -2846,12 +2915,15 @@ static bool promote_mode_to_existing_coverage(cbm_pipeline_t *p) { if (!p || !p->project_name) { return false; } - char *db_path = resolve_db_path(p); + /* Until routing copies it, the stage is an empty placeholder: the + * coverage the copy would carry is read from the live generation. */ + char *resolved = p->stage_backup_pending ? NULL : resolve_db_path(p); + const char *db_path = p->stage_backup_pending ? p->live_db_path : resolved; if (!db_path) { return false; } cbm_store_t *store = cbm_store_open_path_query(db_path); - free(db_path); + free(resolved); if (!store) { return false; } @@ -4542,6 +4614,29 @@ static void sweep_orphan_stages(const char *final_path) { free(dir_path); } +/* End a run whose exact no-op was proven on the live generation. Nothing was + * staged, so nothing is sealed or renamed and the live database stays the + * current generation. The checks publication makes on the run's sources and + * destination still decide success, and the artifact is refreshed from the + * live database exactly as it would be from a republished one. */ +static int finish_live_noop(cbm_pipeline_t *p, const char *final_path) { + cbm_userconfig_t *fresh_config = cbm_userconfig_load_with_source(p->repo_path); + bool source_matches = cbm_pipeline_project_source_matches(p, fresh_config); + cbm_userconfig_free(fresh_config); + struct stat final_st; + int rc = 0; + if (!source_matches) { + cbm_log_error("pipeline.err", "phase", "test_declarations_source_changed"); + rc = CBM_PIPELINE_ABORT_PRESERVE_DB; + } else if (stat(final_path, &final_st) != 0) { + /* The destination vanished during the run: someone else owns it. */ + rc = CBM_PIPELINE_ABORT_PRESERVE_DB; + } else { + rc = export_after_publish(p, final_path); + } + return rc; +} + int cbm_pipeline_run(cbm_pipeline_t *p) { /* Per-index attribution: peaks and phase totals are about THIS index, not * the process history, so they start clean here. The first mark opens @@ -4571,21 +4666,12 @@ int cbm_pipeline_run(cbm_pipeline_t *p) { return CBM_NOT_FOUND; } - bool backup_succeeded = false; - if (final_existed) { - backup_succeeded = cbm_store_backup_path(final_path, staging_path) == CBM_STORE_OK; - if (!backup_succeeded) { - cbm_log_warn("pipeline.stage", "action", "backup_failed_full_rebuild", "path", - final_path); - /* The copy is gone but the NAME stays ours: the rebuilt - * generation is renamed over it by the inner finalize and then - * published from it below, so its lock is held to the end. */ - remove_stage_files(staging_path); - } - } - + /* The existing generation is copied into the stage only when routing + * needs a copy (materialize_stage_backup): an exact no-op is proven on + * the live generation and copies nothing -- on a kernel-scale index the + * copy alone was a 19 GB write per unchanged re-index. */ p->final_existed = final_existed; - p->existing_generation = final_existed && backup_succeeded; + p->existing_generation = final_existed; char *configured_db_path = p->db_path; p->db_path = strdup(staging_path); if (!p->db_path) { @@ -4595,10 +4681,19 @@ int cbm_pipeline_run(cbm_pipeline_t *p) { free(final_path); return CBM_NOT_FOUND; } + p->stage_backup_pending = final_existed; + p->backup_succeeded = false; + p->live_noop = false; + p->live_db_path = final_path; cbm_log_debug("pipeline.setup", "step", "staged"); int rc = cbm_pipeline_run_staged(p); free(p->db_path); p->db_path = configured_db_path; + bool backup_succeeded = p->backup_succeeded; + bool live_noop = p->live_noop; + p->stage_backup_pending = false; + p->live_noop = false; + p->live_db_path = NULL; /* Report WHY the run stopped. Everything below happens before the final * rename, so the live database is still the previous generation and an @@ -4612,12 +4707,17 @@ int cbm_pipeline_run(cbm_pipeline_t *p) { pipeline_release_test_config(p); return rc; } - if (check_cancel(p)) { + /* A cancelled run and a live no-op both end here without publishing: + * the stage placeholder is discarded, and only the no-op goes on to + * finish on the live generation. */ + bool cancelled = check_cancel(p); + if (cancelled || live_noop) { cleanup_staging_db(staging_path); free(staging_path); + rc = cancelled ? CBM_PIPELINE_ABORT_PRESERVE_DB : finish_live_noop(p, final_path); free(final_path); pipeline_release_test_config(p); - return CBM_PIPELINE_ABORT_PRESERVE_DB; + return rc; } if (seal_staging_db(staging_path) != 0) { cleanup_staging_db(staging_path); diff --git a/src/pipeline/pipeline_incremental.c b/src/pipeline/pipeline_incremental.c index c5f4ffafb5..e0d4e4947d 100644 --- a/src/pipeline/pipeline_incremental.c +++ b/src/pipeline/pipeline_incremental.c @@ -3557,6 +3557,62 @@ static bool incr_doc_links_current(cbm_store_t *store, const char *project) { return current; } +/* The exact no-op test: the stored generation's metadata is current for this + * mode, its doc-link layer is usable, and its recorded semantic manifest + * equals the baseline byte for byte. Read-only. The two out flags report the + * halves the staged route also needs for its own routing. */ +static bool incr_generation_exact(cbm_pipeline_t *p, cbm_store_t *store, const char *project, + const cbm_file_hash_t *stored, int stored_count, + const cbm_file_hash_t *baseline_manifest, int baseline_count, + bool *metadata_current_out, bool *doc_links_current_out) { + cbm_coverage_meta_t meta = {0}; + int meta_rc = cbm_store_coverage_meta_get(store, project, &meta); + const char *mode_name = incr_mode_name(cbm_pipeline_get_mode(p)); + bool metadata_current = + meta_rc == CBM_STORE_OK && meta.coverage_version == CBM_SEMANTIC_INDEX_VERSION && + meta.hash_records_complete && meta.index_mode && strcmp(meta.index_mode, mode_name) == 0; + /* A generation whose doc-link layer is missing or failed is not + * current even with identical inputs: index_status tells the user to + * re-run, and the re-run has to rebuild. */ + bool doc_links_current = metadata_current && incr_doc_links_current(store, project); + bool exact = doc_links_current && cbm_pipeline_semantic_manifests_equal( + stored, stored_count, baseline_manifest, baseline_count); + cbm_store_coverage_meta_clear(&meta); + *metadata_current_out = metadata_current; + *doc_links_current_out = doc_links_current; + return exact; +} + +bool cbm_pipeline_incremental_generation_exact(cbm_pipeline_t *p, cbm_store_t *store, + const cbm_file_hash_t *baseline_manifest, + int baseline_count) { +#if defined(CBM_INCREMENTAL_TEST_API) && CBM_INCREMENTAL_TEST_API + /* A forced legacy-partial run must reach the staged route that takes + * the one-shot flag; answering here would consume the run without it. */ + if (atomic_load(&g_incr_test_force_legacy_partial)) { + return false; + } +#endif + const char *project = cbm_pipeline_project_name(p); + cbm_file_hash_t *stored = NULL; + int stored_count = 0; + if (!store || + cbm_store_get_file_hashes(store, project, &stored, &stored_count) != CBM_STORE_OK) { + return false; + } + bool metadata_current = false; + bool doc_links_current = false; + bool exact = incr_generation_exact(p, store, project, stored, stored_count, baseline_manifest, + baseline_count, &metadata_current, &doc_links_current); + cbm_store_free_file_hashes(stored, stored_count); +#if defined(CBM_INCREMENTAL_TEST_API) && CBM_INCREMENTAL_TEST_API + if (exact) { + incr_test_set_last_route(CBM_INCREMENTAL_ROUTE_NOOP); + } +#endif + return exact; +} + int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_file_info_t *files, int file_count, const cbm_file_hash_t *baseline_manifest, int baseline_count, bool force_full_on_mismatch) { @@ -3602,21 +3658,11 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil * rebuilds the complete graph. This avoids partial LSP/package/config * convergence gaps while retaining the common unchanged-repo fast path. */ if (!force_legacy_partial) { - cbm_coverage_meta_t meta = {0}; - int meta_rc = cbm_store_coverage_meta_get(store, project, &meta); - const char *mode_name = incr_mode_name(cbm_pipeline_get_mode(p)); - bool metadata_current = meta_rc == CBM_STORE_OK && - meta.coverage_version == CBM_SEMANTIC_INDEX_VERSION && - meta.hash_records_complete && meta.index_mode && - strcmp(meta.index_mode, mode_name) == 0; - /* A generation whose doc-link layer is missing or failed is not - * current even with identical inputs: index_status tells the user to - * re-run, and the re-run has to rebuild. */ - bool doc_links_current = metadata_current && incr_doc_links_current(store, project); - bool exact = doc_links_current && - cbm_pipeline_semantic_manifests_equal(stored, stored_count, baseline_manifest, - baseline_count); - cbm_store_coverage_meta_clear(&meta); + bool metadata_current = false; + bool doc_links_current = false; + bool exact = + incr_generation_exact(p, store, project, stored, stored_count, baseline_manifest, + baseline_count, &metadata_current, &doc_links_current); if (exact) { cbm_store_free_file_hashes(stored, stored_count); cbm_store_close(store); diff --git a/src/pipeline/pipeline_internal.h b/src/pipeline/pipeline_internal.h index 5d8d9241ca..e39defa200 100644 --- a/src/pipeline/pipeline_internal.h +++ b/src/pipeline/pipeline_internal.h @@ -1382,6 +1382,13 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil int file_count, const cbm_file_hash_t *baseline_manifest, int baseline_count, bool force_full_on_mismatch); +/* The exact no-op test cbm_pipeline_run_incremental routes by, read-only on + * an already opened generation: true when that generation is current for the + * pipeline's mode and records exactly the baseline manifest. */ +bool cbm_pipeline_incremental_generation_exact(cbm_pipeline_t *p, cbm_store_t *store, + const cbm_file_hash_t *baseline_manifest, + int baseline_count); + /* Exact semantic inputs for no-op/forced-full routing. The manifest contains * every discovered source plus repository controls actually consumed by * discovery, package mapping, path aliases, and extension overrides. */ diff --git a/src/store/graph_mem.c b/src/store/graph_mem.c new file mode 100644 index 0000000000..e046e3801d --- /dev/null +++ b/src/store/graph_mem.c @@ -0,0 +1,837 @@ +/* + * graph_mem.c — read-only in-memory adjacency over a published store's edges. + * See graph_mem.h for the identity, lifetime and budget contract. + */ +#include "store/graph_mem.h" +#include "foundation/constants.h" +#include "foundation/log.h" +#include "foundation/mem.h" +#include "foundation/mem_core.h" +#include "foundation/platform.h" + +#include +#include +#include +#include + +enum { + GM_MAX_TYPES = 64, /* one bit each in cbm_graph_mem_types_t */ + GM_VERSION_MAX = 96, /* generation + file state, see store_graph_mem */ + GM_CAP_DIVISOR = 4, /* all layers together: at most budget / 4 */ + GM_SET_MIN_CAP = 64, /* BFS visited set: initial slots (power of two) */ + GM_SET_LOAD_NUM = 7, /* grow the visited set past 7/10 load */ + GM_SET_LOAD_DEN = 10, + GM_GROWTH = 2, + GM_COL_TARGET = 1, + GM_COL_TYPE = 2, +}; + +/* Ids and edge counts must fit the uint32 CSR with room for "+1" offsets. */ +#define GM_ID_LIMIT ((int64_t)INT32_MAX - 1) +#define GM_HASH_MUL 2654435761U + +struct cbm_graph_mem { + /* Registry state, guarded by the registry mutex. */ + struct cbm_graph_mem *next; + char *path; + char version[GM_VERSION_MAX]; + int refs; + bool ready; /* built; false while its builder runs */ + bool detached; /* unlinked from the registry; freed at the last release */ + uint64_t last_use; + 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]). */ + uint32_t n_ids; + uint32_t n_edges; + uint32_t *out_off; + uint32_t *out_nbr; /* target of each out-edge */ + uint8_t *out_type; + uint32_t *in_off; + uint32_t *in_nbr; /* source of each in-edge */ + uint8_t *in_type; + int type_count; + char *types[GM_MAX_TYPES]; + size_t type_len[GM_MAX_TYPES]; +}; + +static cbm_graph_mem_t *g_layers; /* linked (not detached) layers */ +static size_t g_total_bytes; /* every live layer, linked or detached */ +static uint64_t g_use_clock; /* logical LRU clock */ +static uint64_t g_builds; + +/* A static SQLite mutex needs no init and works on every platform SQLite + * builds on (SQLITE_THREADSAFE=1). store.c owns APP1. */ +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_GRAPH_CACHE, bytes ? bytes : 1); +} + +static void gm_free_ptr(void *p) { + cbm_free(CBM_MEM_CLASS_GRAPH_CACHE, p); +} + +static void gm_free_graph(cbm_graph_mem_t *g) { + gm_free_ptr(g->out_off); + gm_free_ptr(g->out_nbr); + gm_free_ptr(g->out_type); + gm_free_ptr(g->in_off); + gm_free_ptr(g->in_nbr); + gm_free_ptr(g->in_type); + g->out_off = g->out_nbr = g->in_off = g->in_nbr = NULL; + g->out_type = g->in_type = NULL; + for (int i = 0; i < g->type_count; i++) { + gm_free_ptr(g->types[i]); + g->types[i] = NULL; + } + g->type_count = 0; +} + +static void gm_destroy(cbm_graph_mem_t *g) { + gm_free_graph(g); + gm_free_ptr(g->path); + gm_free_ptr(g); +} + +/* ── Registry (caller holds the mutex) ───────────────────────────── */ + +static cbm_graph_mem_t *gm_find(const char *path, const char *version) { + for (cbm_graph_mem_t *g = g_layers; g; g = g->next) { + if (strcmp(g->path, path) == 0 && strcmp(g->version, version) == 0) { + return g; + } + } + return NULL; +} + +static void gm_unlink(cbm_graph_mem_t *g) { + for (cbm_graph_mem_t **p = &g_layers; *p; p = &(*p)->next) { + if (*p == g) { + *p = g->next; + g->next = NULL; + g->detached = true; + return; + } + } +} + +/* Unlink a layer; free it now when nobody holds it, else at its last release. */ +static void gm_retire(cbm_graph_mem_t *g) { + gm_unlink(g); + if (g->refs == 0) { + g_total_bytes -= g->bytes; + gm_destroy(g); + } +} + +/* Older versions of a path are never asked for again by a new store (one + * opened later reads the current version), so they go now. */ +static void gm_retire_other_versions(const char *path, const char *version) { + cbm_graph_mem_t *g = g_layers; + while (g) { + cbm_graph_mem_t *next = g->next; + if (strcmp(g->path, path) == 0 && strcmp(g->version, version) != 0) { + gm_retire(g); + } + g = next; + } +} + +/* Make room for `bytes` under the cap by evicting idle layers, oldest first. + * False when it cannot fit: the cap is unset (no budget) or every remaining + * byte is held by a request. */ +static bool gm_make_room(size_t bytes) { + size_t budget = cbm_mem_budget(); + size_t cap = budget / GM_CAP_DIVISOR; + if (cap == 0 || bytes > cap) { + return false; + } + while (g_total_bytes + bytes > cap) { + cbm_graph_mem_t *victim = NULL; + for (cbm_graph_mem_t *g = g_layers; g; g = g->next) { + if (g->ready && g->refs == 0 && (!victim || g->last_use < victim->last_use)) { + victim = g; + } + } + if (!victim) { + return false; + } + gm_retire(victim); + } + /* The cap bounds the layers; the process as a whole must still fit. */ + 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) { + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2(db, "SELECT (SELECT max(id) FROM nodes), (SELECT max(id) FROM edges);", + -1, &st, NULL) != SQLITE_OK) { + sqlite3_finalize(st); + return false; + } + bool ok = sqlite3_step(st) == SQLITE_ROW; + if (ok) { + *max_node = sqlite3_column_int64(st, 0); + *max_edge = sqlite3_column_int64(st, 1); + } + sqlite3_finalize(st); + return ok && *max_node >= 0 && *max_edge >= 0 && *max_node < GM_ID_LIMIT && + *max_edge < GM_ID_LIMIT; +} + +/* Upper bound of a layer's bytes: edge ids are unique positive rowids, so the + * edge count never exceeds max(edges.id). */ +static size_t gm_estimate(int64_t max_node, int64_t max_edge) { + size_t offsets = ((size_t)max_node + 2) * sizeof(uint32_t); + size_t per_edge = sizeof(uint32_t) + sizeof(uint8_t); + return GM_GROWTH * offsets + GM_GROWTH * (size_t)max_edge * per_edge; +} + +static int gm_intern(cbm_graph_mem_t *g, const unsigned char *text, size_t len, int *last) { + if (*last >= 0 && g->type_len[*last] == len && memcmp(g->types[*last], text, len) == 0) { + return *last; + } + for (int i = 0; i < g->type_count; i++) { + if (g->type_len[i] == len && memcmp(g->types[i], text, len) == 0) { + *last = i; + return i; + } + } + if (g->type_count >= GM_MAX_TYPES) { + return -1; + } + char *copy = gm_alloc(len + 1); + if (!copy) { + return -1; + } + memcpy(copy, text, len); + copy[len] = '\0'; + g->types[g->type_count] = copy; + g->type_len[g->type_count] = len; + *last = g->type_count; + return g->type_count++; +} + +static bool gm_grow_u32(uint32_t **arr, size_t *cap, size_t need) { + if (need <= *cap) { + return true; + } + size_t next = *cap ? *cap : 1; + while (next < need) { + next *= GM_GROWTH; + } + uint32_t *grown = cbm_realloc(CBM_MEM_CLASS_GRAPH_CACHE, *arr, next * sizeof(uint32_t)); + if (!grown) { + return false; + } + *arr = grown; + *cap = next; + return true; +} + +/* The out-CSR, filled straight from the scan: rows arrive ordered by + * source_id, so each node's list is contiguous and its start offset is the + * edge count when its first row (or a later id's) shows up. */ +typedef struct { + size_t off_cap; + size_t edge_cap; + uint32_t next_start; /* smallest id whose start offset is not set yet */ + uint32_t max_id; /* largest endpoint seen */ + int64_t prev_src; + int last_type; +} gm_scan_t; + +static bool gm_scan_row(cbm_graph_mem_t *g, gm_scan_t *sc, sqlite3_stmt *st) { + int64_t src = sqlite3_column_int64(st, 0); + int64_t dst = sqlite3_column_int64(st, GM_COL_TARGET); + const unsigned char *type = sqlite3_column_text(st, GM_COL_TYPE); + if (src < 0 || dst < 0 || src >= GM_ID_LIMIT || dst >= GM_ID_LIMIT || src < sc->prev_src || + !type || g->n_edges >= (uint32_t)GM_ID_LIMIT) { + return false; + } + int type_id = gm_intern(g, type, (size_t)sqlite3_column_bytes(st, GM_COL_TYPE), &sc->last_type); + if (type_id < 0 || !gm_grow_u32(&g->out_off, &sc->off_cap, (size_t)src + GM_GROWTH)) { + return false; + } + while (sc->next_start <= (uint32_t)src) { + g->out_off[sc->next_start++] = g->n_edges; + } + 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_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_GRAPH_CACHE, g->out_type, next); + if (!types) { + return false; + } + g->out_type = types; + sc->edge_cap = next; + } + g->out_nbr[g->n_edges] = (uint32_t)dst; + g->out_type[g->n_edges] = (uint8_t)type_id; + g->n_edges++; + sc->prev_src = src; + if ((uint32_t)src > sc->max_id) { + sc->max_id = (uint32_t)src; + } + if ((uint32_t)dst > sc->max_id) { + sc->max_id = (uint32_t)dst; + } + return true; +} + +/* The in-CSR from the out-CSR: count, prefix-sum, then place each edge using + * in_off[v] as v's cursor, which leaves in_off shifted by one id — shift it + * 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_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) { + return false; + } + for (uint32_t e = 0; e < g->n_edges; e++) { + g->in_off[g->out_nbr[e] + 1]++; + } + for (size_t v = 0; v < n_ids; v++) { + g->in_off[v + 1] += g->in_off[v]; + } + for (uint32_t u = 0; u < g->n_ids; u++) { + for (uint32_t e = g->out_off[u]; e < g->out_off[u + 1]; e++) { + uint32_t pos = g->in_off[g->out_nbr[e]]++; + g->in_nbr[pos] = u; + g->in_type[pos] = g->out_type[e]; + } + } + for (size_t v = n_ids; v > 0; v--) { + g->in_off[v] = g->in_off[v - 1]; + } + g->in_off[0] = 0; + return true; +} + +static bool gm_build(cbm_graph_mem_t *g, sqlite3 *db, int64_t max_node, int64_t max_edge) { + gm_scan_t sc = {.prev_src = 0, .last_type = -1, .max_id = (uint32_t)max_node}; + sc.edge_cap = (size_t)max_edge; + g->out_nbr = gm_alloc(sc.edge_cap * sizeof(uint32_t)); + g->out_type = gm_alloc(sc.edge_cap); + if (!g->out_nbr || !g->out_type || + !gm_grow_u32(&g->out_off, &sc.off_cap, (size_t)max_node + GM_GROWTH)) { + return false; + } + /* ORDER BY the covering UNIQUE index's leading columns: a sequential walk + * of sqlite_autoindex_edges_1, no sort and no table access. */ + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2(db, + "SELECT source_id, target_id, type FROM edges " + "ORDER BY source_id, target_id, type;", + -1, &st, NULL) != SQLITE_OK) { + sqlite3_finalize(st); + return false; + } + int rc; + bool ok = true; + while ((rc = sqlite3_step(st)) == SQLITE_ROW) { + if (!gm_scan_row(g, &sc, st)) { + ok = false; + break; + } + } + sqlite3_finalize(st); + if (!ok || rc != SQLITE_DONE) { + return false; + } + g->n_ids = sc.max_id + 1; + if (!gm_grow_u32(&g->out_off, &sc.off_cap, (size_t)g->n_ids + 1)) { + return false; + } + while (sc.next_start <= g->n_ids) { + g->out_off[sc.next_start++] = g->n_edges; + } + /* 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_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; + } + if (types) { + g->out_type = types; + } + } + return gm_build_inbound(g); +} + +static size_t gm_actual_bytes(const cbm_graph_mem_t *g) { + size_t offsets = ((size_t)g->n_ids + 1) * sizeof(uint32_t); + size_t per_edge = sizeof(uint32_t) + sizeof(uint8_t); + size_t bytes = GM_GROWTH * offsets + GM_GROWTH * (size_t)g->n_edges * per_edge; + for (int i = 0; i < g->type_count; i++) { + bytes += g->type_len[i] + 1; + } + return bytes + sizeof(*g) + strlen(g->path) + 1; +} + +/* ── Acquire / release ──────────────────────────────────────────── */ + +/* A ready hit takes a reference; a hit still being built answers NULL (the + * caller uses SQL rather than waiting on another thread's build). */ +static cbm_graph_mem_t *gm_take(cbm_graph_mem_t *hit) { + if (!hit->ready) { + return NULL; + } + hit->refs++; + hit->last_use = ++g_use_clock; + return 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_GRAPH_CACHE, sizeof(*g)); + if (!g) { + return NULL; + } + g->path = cbm_mem_strdup(CBM_MEM_CLASS_GRAPH_CACHE, db_path); + if (!g->path) { + gm_free_ptr(g); + return NULL; + } + memcpy(g->version, version, strlen(version) + 1); + g->refs = 1; + g->bytes = est; + return g; +} + +cbm_graph_mem_t *cbm_graph_mem_acquire(sqlite3 *db, const char *db_path, const char *version) { + 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; + } + + int64_t max_node = 0; + int64_t max_edge = 0; + if (!gm_bounds(db, &max_node, &max_edge)) { + return NULL; + } + size_t est = gm_estimate(max_node, max_edge); + + sqlite3_mutex_enter(mu); + hit = gm_find(db_path, version); + if (hit) { + taken = gm_take(hit); + sqlite3_mutex_leave(mu); + return taken; + } + gm_retire_other_versions(db_path, version); + bool fits = gm_make_room(est); + cbm_graph_mem_t *g = fits ? gm_new_entry(db_path, version, est) : NULL; + if (g) { + g->next = g_layers; + g_layers = g; + g_total_bytes += est; + } + sqlite3_mutex_leave(mu); + if (!g) { + if (!fits) { + char need[CBM_SZ_32]; + (void)snprintf(need, sizeof(need), "%zu", est); + cbm_log_info("graph_mem.skip", "reason", "budget", "bytes", need); + } + return NULL; + } + + /* The build runs outside the mutex: other keys stay servable, and a + * request for this key meanwhile takes the SQL path (gm_take). */ + uint64_t started_ms = cbm_now_ms(); + bool ok = gm_build(g, db, max_node, max_edge); + if (!ok) { + gm_free_graph(g); /* all-or-nothing: no partial layer survives */ + } + size_t actual = ok ? gm_actual_bytes(g) : 0; + if (ok) { + char edges[CBM_SZ_32]; + char bytes[CBM_SZ_32]; + char elapsed[CBM_SZ_32]; + (void)snprintf(edges, sizeof(edges), "%u", g->n_edges); + (void)snprintf(bytes, sizeof(bytes), "%zu", actual); + (void)snprintf(elapsed, sizeof(elapsed), "%llu", + (unsigned long long)(cbm_now_ms() - started_ms)); + cbm_log_info("graph_mem.build", "edges", edges, "bytes", bytes, "elapsed_ms", elapsed); + } + + sqlite3_mutex_enter(mu); + g_total_bytes = g_total_bytes - g->bytes + actual; + g->bytes = actual; + if (ok) { + g->ready = true; + g->last_use = ++g_use_clock; + g_builds++; + } else { + g->refs = 0; + gm_retire(g); + g = NULL; + } + sqlite3_mutex_leave(mu); + return g; +} + +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); + } + 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, + int count) { + cbm_graph_mem_types_t mask = 0; + for (int i = 0; g && types && i < count; i++) { + if (!types[i]) { + continue; + } + size_t len = strlen(types[i]); + for (int t = 0; t < g->type_count; t++) { + if (g->type_len[t] == len && memcmp(g->types[t], types[i], len) == 0) { + mask |= (cbm_graph_mem_types_t)1 << t; + break; + } + } + } + return mask; +} + +int cbm_graph_mem_degree(const cbm_graph_mem_t *g, int64_t id, bool inbound, + cbm_graph_mem_types_t mask) { + if (!g || id < 0 || id >= (int64_t)g->n_ids) { + return 0; + } + const uint32_t *off = inbound ? g->in_off : g->out_off; + const uint8_t *type = inbound ? g->in_type : g->out_type; + int n = 0; + for (uint32_t e = off[id]; e < off[id + 1]; e++) { + n += (int)((mask >> type[e]) & 1U); + } + return n; +} + +size_t cbm_graph_mem_bytes(const cbm_graph_mem_t *g) { + return g ? g->bytes : 0; +} + +/* Visited set for one BFS: open addressing over id + 1 (0 marks a free slot). */ +typedef struct { + uint32_t *slots; + size_t cap; + size_t count; +} gm_set_t; + +static size_t gm_set_home(uint32_t id, size_t mask) { + return (size_t)(id * GM_HASH_MUL) & 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_GRAPH_CACHE, cap * sizeof(uint32_t)); + if (!slots) { + return false; + } + for (size_t i = 0; i < s->cap; i++) { + uint32_t key = s->slots[i]; + if (key) { + size_t j = gm_set_home(key - 1, cap - 1); + while (slots[j]) { + j = (j + 1) & (cap - 1); + } + slots[j] = key; + } + } + gm_free_ptr(s->slots); + s->slots = slots; + s->cap = cap; + return true; +} + +/* 1 = added, 0 = already present, -1 = out of memory. */ +static int gm_set_add(gm_set_t *s, uint32_t id) { + if ((s->count + 1) * GM_SET_LOAD_DEN > s->cap * GM_SET_LOAD_NUM && !gm_set_grow(s)) { + return -1; + } + size_t mask = s->cap - 1; + for (size_t i = gm_set_home(id, mask);; i = (i + 1) & mask) { + if (s->slots[i] == 0) { + s->slots[i] = id + 1; + s->count++; + return 1; + } + if (s->slots[i] == id + 1) { + return 0; + } + } +} + +typedef struct { + uint32_t *ids; + size_t count; + size_t cap; +} gm_list_t; + +static bool gm_list_push(gm_list_t *l, uint32_t id) { + if (!gm_grow_u32(&l->ids, &l->cap, l->count + 1)) { + return false; + } + l->ids[l->count++] = id; + return true; +} + +typedef struct { + cbm_graph_mem_hop_t *rows; + size_t count; + size_t cap; +} gm_hops_t; + +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_GRAPH_CACHE, h->rows, next * sizeof(cbm_graph_mem_hop_t)); + if (!rows) { + return false; + } + h->rows = rows; + h->cap = next; + } + h->rows[h->count].id = id; + h->rows[h->count].hop = hop; + h->count++; + return true; +} + +static int gm_hop_id_cmp(const void *pa, const void *pb) { + const cbm_graph_mem_hop_t *a = pa; + const cbm_graph_mem_hop_t *b = pb; + return (a->id > b->id) - (a->id < b->id); +} + +/* One BFS level: expand every frontier node over the masked edges. A node is + * first reached at its shortest distance; the start is never re-entered (its + * neighbours were all reached at hop 1), only recorded once at the length of + * the shortest cycle back to it — the CTE's (start, hop>0) row. */ +typedef struct { + const uint32_t *off; + const uint32_t *nbr; + const uint8_t *type; + cbm_graph_mem_types_t mask; + uint32_t start; + bool start_back; + gm_set_t seen; + gm_hops_t out; +} gm_walk_t; + +static bool gm_expand_level(gm_walk_t *w, const gm_list_t *frontier, gm_list_t *next, int hop) { + for (size_t f = 0; f < frontier->count; f++) { + uint32_t u = frontier->ids[f]; + for (uint32_t e = w->off[u]; e < w->off[u + 1]; e++) { + if (((w->mask >> w->type[e]) & 1U) == 0) { + continue; + } + uint32_t v = w->nbr[e]; + if (v == w->start) { + if (!w->start_back) { + w->start_back = true; + if (!gm_hops_push(&w->out, v, hop)) { + return false; + } + } + continue; + } + int added = gm_set_add(&w->seen, v); + if (added < 0) { + return false; + } + if (added > 0 && (!gm_hops_push(&w->out, v, hop) || !gm_list_push(next, v))) { + return false; + } + } + } + return true; +} + +bool cbm_graph_mem_bfs(const cbm_graph_mem_t *g, int64_t start, bool inbound, + cbm_graph_mem_types_t mask, int max_depth, cbm_graph_mem_hop_t **out, + int *count) { + *out = NULL; + *count = 0; + if (!g || max_depth < 1 || mask == 0 || start < 0 || start >= (int64_t)g->n_ids) { + return true; /* nothing reachable: the CTE yields only the anchor */ + } + gm_walk_t w = { + .off = inbound ? g->in_off : g->out_off, + .nbr = inbound ? g->in_nbr : g->out_nbr, + .type = inbound ? g->in_type : g->out_type, + .mask = mask, + .start = (uint32_t)start, + }; + gm_list_t frontier = {0}; + gm_list_t next = {0}; + bool ok = gm_list_push(&frontier, w.start); + for (int hop = 1; ok && hop <= max_depth && frontier.count > 0; hop++) { + size_t level_begin = w.out.count; + next.count = 0; + ok = gm_expand_level(&w, &frontier, &next, hop); + if (ok && w.out.count - level_begin > 1) { + qsort(w.out.rows + level_begin, w.out.count - level_begin, sizeof(cbm_graph_mem_hop_t), + gm_hop_id_cmp); + } + gm_list_t swap = frontier; + frontier = next; + next = swap; + } + gm_free_ptr(frontier.ids); + gm_free_ptr(next.ids); + gm_free_ptr(w.seen.slots); + if (!ok || w.out.count > (size_t)INT32_MAX) { + gm_free_ptr(w.out.rows); + return false; + } + *out = w.out.rows; + *count = (int)w.out.count; + return true; +} + +void cbm_graph_mem_hops_free(cbm_graph_mem_hop_t *hops) { + gm_free_ptr(hops); +} + +/* ── Registry introspection ─────────────────────────────────────── */ + +int cbm_graph_mem_cached_count(void) { + sqlite3_mutex *mu = gm_registry_mutex(); + sqlite3_mutex_enter(mu); + int n = 0; + for (cbm_graph_mem_t *g = g_layers; g; g = g->next) { + n++; + } + sqlite3_mutex_leave(mu); + return n; +} + +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; + sqlite3_mutex_leave(mu); + return bytes; +} + +uint64_t cbm_graph_mem_build_count(void) { + sqlite3_mutex *mu = gm_registry_mutex(); + sqlite3_mutex_enter(mu); + uint64_t n = g_builds; + sqlite3_mutex_leave(mu); + return n; +} + +void cbm_graph_mem_drop_idle(void) { + sqlite3_mutex *mu = gm_registry_mutex(); + sqlite3_mutex_enter(mu); + cbm_graph_mem_t *g = g_layers; + while (g) { + cbm_graph_mem_t *next = g->next; + if (g->ready && g->refs == 0) { + gm_retire(g); + } + g = next; + } + sqlite3_mutex_leave(mu); +} diff --git a/src/store/graph_mem.h b/src/store/graph_mem.h new file mode 100644 index 0000000000..7e8d30a392 --- /dev/null +++ b/src/store/graph_mem.h @@ -0,0 +1,122 @@ +/* + * graph_mem.h — read-only in-memory adjacency over a published store's edges. + * + * A graph walk on a kernel-scale index (8.5M nodes, 16.9M edges) spends its + * time in SQLite B-tree seeks: trace_path's recursive CTE took 6.6 s and a + * degree-filtered search_graph 3.9 s, where the same questions on django take + * 20-100 ms. This layer reads the edges ONCE, in one sequential scan of the + * covering UNIQUE(source_id, target_id, type, ...) index, into a typed CSR + * (node id -> out/in neighbours, each with its interned edge type) and answers + * those walks from RAM. Node rows still come from SQLite; only the edge + * structure is held. + * + * Identity: a layer is keyed by (db path, version), where the caller promises + * that one version names one edge set. store.c builds the version from the + * store generation (every publish stamps a new one) plus the rollback-journal + * file state (the header change counter every commit bumps — it also catches + * writers that mutate a published DB in place without a new generation), and + * admits only read-only query stores of a sealed, non-legacy DB. + * + * 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_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 + +#include +#include +#include + +struct sqlite3; + +typedef struct cbm_graph_mem cbm_graph_mem_t; + +/* A set of interned edge types: bit i = the layer's type i. */ +typedef uint64_t cbm_graph_mem_types_t; + +/* One reached node: its id and its shortest distance from the start. */ +typedef struct { + int64_t id; + int hop; +} cbm_graph_mem_hop_t; + +/* The layer for (db_path, version): the cached one, or one built now from + * `db`'s edges table. The caller must hold one read transaction across the + * version read and this call, so the scan sees that version's edges. + * NULL means "use the SQL path": no budget, over the cap, another thread is + * building this key right now, a schema or id range the layer cannot hold, or + * a failed scan. A non-NULL layer is held until cbm_graph_mem_release. */ +cbm_graph_mem_t *cbm_graph_mem_acquire(struct sqlite3 *db, const char *db_path, + const char *version); +void cbm_graph_mem_release(cbm_graph_mem_t *g); + +/* The type set naming `types` (exact byte match, like SQL `type IN (...)`). + * NULL entries and types absent from the graph contribute nothing. */ +cbm_graph_mem_types_t cbm_graph_mem_type_mask(const cbm_graph_mem_t *g, const char *const *types, + int count); + +/* Shortest-walk BFS with the exact semantics of store_bfs's recursive CTE: + * every node reached by a walk of 1..max_depth edges whose types are in + * `mask`, at its MINIMAL such length. The start itself appears only when a + * cycle leads back to it (at the cycle's length). Rows come sorted by + * (hop, id); ids absent from the nodes table are NOT filtered here. + * Returns false only on allocation failure. *out is freed with + * cbm_graph_mem_hops_free. */ +bool cbm_graph_mem_bfs(const cbm_graph_mem_t *g, int64_t start, bool inbound, + cbm_graph_mem_types_t mask, int max_depth, cbm_graph_mem_hop_t **out, + int *count); +void cbm_graph_mem_hops_free(cbm_graph_mem_hop_t *hops); + +/* Number of edges with a type in `mask` ending at (inbound) or leaving + * (outbound) node `id` — SQL's COUNT(*) over target_id/source_id = id. */ +int cbm_graph_mem_degree(const cbm_graph_mem_t *g, int64_t id, bool inbound, + cbm_graph_mem_types_t mask); + +/* 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 — 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 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/src/store/store.c b/src/store/store.c index 7a2cbe6162..d19e528908 100644 --- a/src/store/store.c +++ b/src/store/store.c @@ -9,6 +9,7 @@ // for ISO timestamp #include +#include #include #include "foundation/constants.h" #include "foundation/arena.h" @@ -54,9 +55,13 @@ enum { ST_GLOB_MIN_LEN = 3, ST_GLOB_SKIP = 2, ST_MAX_LANG = 10, - ST_SEARCH_MAX_BINDS = 32, /* increased: LIKE pre-filter adds binds per pattern */ - ST_LIKE_POOL_MAX = 12, /* max malloc'd LIKE strings alive during one search */ - ST_LIKE_HINT_MAX = 2, /* max LIKE hints extracted per regex pattern */ + ST_SEARCH_MAX_BINDS = 48, /* LIKE pre-filters + anchored-name range bounds add binds */ + ST_LIKE_POOL_MAX = 12, /* max malloc'd LIKE strings alive during one search */ + ST_LIKE_HINT_MAX = 2, /* max LIKE hints extracted per regex pattern */ + ST_NAME_RANGE_PREFIX = 64, /* longest anchored name prefix seeked on */ + ST_NAME_RANGE_LETTERS = 3, /* case-insensitive: letters expanded into case variants */ + ST_NAME_RANGE_MAX = 8, /* 2^ST_NAME_RANGE_LETTERS name ranges (UNION ALL arms) */ + ST_NAME_RANGE_ARM_SQL = 96, /* per-arm SQL beyond the select list and WHERE */ ST_MAX_PKGS = 64, ST_INIT_CAP_4 = 4, ST_HEADER_PREFIX = 3, @@ -69,10 +74,21 @@ enum { ST_HANDLER_PROP_LEN = 9, ST_BFS_CTE_ROW_MULTIPLIER = 8, ST_BFS_MAX_CTE_ROWS = 4096, + /* SQLite database header (fileformat2 §1.3) */ + ST_DB_HEADER_LEN = 100, + ST_HDR_WRITE_VERSION = 18, /* 1 = rollback journal, 2 = WAL */ + ST_HDR_READ_VERSION = 19, + ST_HDR_ROLLBACK = 1, + ST_HDR_CHANGE_COUNTER = 24, + ST_HDR_PAGE_COUNT = 28, + ST_BE_SHIFT_8 = 8, + ST_BE_SHIFT_16 = 16, + ST_BE_SHIFT_24 = 24, }; #define SLEN(s) (sizeof(s) - 1) #include "store/store.h" +#include "store/graph_mem.h" #include #include "foundation/compat_fs.h" @@ -121,6 +137,16 @@ struct cbm_store { const void *progress_owner; /* exclusive native progress guard token */ const char *db_path; /* heap-allocated, or NULL for :memory: */ char errbuf[CBM_SZ_512]; + bool read_batch; /* a read batch's transaction is open */ + uint64_t read_batch_started_ms; /* when it was last (re)begun */ + /* In-memory adjacency (graph_mem.h). Only a read-only query store of a + * sealed (rollback-journal) DB qualifies, keyed by its generation and + * file state (store_rollback_file_state). Acquired on the first walk, + * released at close. */ + bool query_store; + bool graph_mem_tried; + cbm_graph_mem_t *graph_mem; + cbm_graph_mem_types_t graph_mem_degree_mask; /* search's in_deg/out_deg types */ /* Prepared statements (lazily initialized, cached for lifetime) */ sqlite3_stmt *stmt_upsert_node; @@ -207,10 +233,60 @@ static char *heap_strdup(const char *s) { } /* Prepare a statement (cached). If already prepared, reset+clear. */ +/* ── Read batch ──────────────────────────────────────────────────── + * A read tool issues thousands of small statements. In autocommit mode each + * one takes and drops the database file lock and stats the journal and WAL + * files — most of a whole-graph count(*)'s time. A read batch runs them in one + * deferred read transaction, renewed every ST_READ_BATCH_MS at the next + * statement: a writer never waits behind the batch much longer than that, and + * every statement still reads data committed at most that long ago (while the + * batch holds a rollback-journal database's shared lock, no commit happens). + * Committing a read transaction is allowed with read statements still + * stepping; their lock is then held until they finish, as before. */ +enum { ST_READ_BATCH_MS = 100 }; + +bool cbm_store_read_batch_begin(cbm_store_t *s) { + if (!s || !s->db || s->read_batch || !sqlite3_get_autocommit(s->db)) { + return false; + } + if (sqlite3_exec(s->db, "BEGIN DEFERRED;", NULL, NULL, NULL) != SQLITE_OK) { + return false; + } + s->read_batch = true; + s->read_batch_started_ms = cbm_now_ms(); + return true; +} + +void cbm_store_read_batch_end(cbm_store_t *s) { + if (!s || !s->read_batch) { + return; + } + s->read_batch = false; + if (s->db && !sqlite3_get_autocommit(s->db)) { + (void)sqlite3_exec(s->db, "COMMIT;", NULL, NULL, NULL); + } +} + +void cbm_store_read_batch_expire_for_test(cbm_store_t *s) { + if (s && s->read_batch) { + uint64_t now = cbm_now_ms(); + s->read_batch_started_ms = now > ST_READ_BATCH_MS ? now - ST_READ_BATCH_MS : 0; + } +} + +static void read_batch_renew(cbm_store_t *s) { + if (!s->read_batch || cbm_now_ms() - s->read_batch_started_ms < ST_READ_BATCH_MS) { + return; + } + cbm_store_read_batch_end(s); + (void)cbm_store_read_batch_begin(s); +} + static sqlite3_stmt *prepare_cached(cbm_store_t *s, sqlite3_stmt **slot, const char *sql) { if (!s || !s->db) { return NULL; } + read_batch_renew(s); if (*slot) { sqlite3_reset(*slot); sqlite3_clear_bindings(*slot); @@ -1132,6 +1208,7 @@ cbm_store_t *cbm_store_open_path_query(const char *db_path) { } s->db_path = heap_strdup(db_path); + s->query_store = true; /* Security: block ATTACH/DETACH to prevent file creation via SQL injection. */ sqlite3_set_authorizer(s->db, store_authorizer, NULL); @@ -1363,6 +1440,7 @@ void cbm_store_close(cbm_store_t *s) { if (!s) { return; } + cbm_store_read_batch_end(s); /* Checkpoint WAL before close to prevent orphan WAL accumulation. * Best-effort — silently skips if concurrent reader holds a lock. */ @@ -1409,6 +1487,7 @@ void cbm_store_close(cbm_store_t *s) { /* Use sqlite3_close_v2 — auto-deallocates when last statement finalizes. * Prevents ASan false-positive leaks from sqlite3 internal state. */ sqlite3_close_v2(s->db); + cbm_graph_mem_release(s->graph_mem); safe_str_free(&s->db_path); free(s); } @@ -3056,11 +3135,12 @@ static void scan_node(sqlite3_stmt *stmt, cbm_node_t *n) { n->properties_json = heap_strdup((const char *)sqlite3_column_text(stmt, ST_COL_8)); } +static const char st_find_node_by_id_sql[] = + "SELECT id, project, label, name, qualified_name, file_path, " + "start_line, end_line, properties FROM nodes WHERE id = ?1;"; + int cbm_store_find_node_by_id(cbm_store_t *s, int64_t id, cbm_node_t *out) { - sqlite3_stmt *stmt = - prepare_cached(s, &s->stmt_find_node_by_id, - "SELECT id, project, label, name, qualified_name, file_path, " - "start_line, end_line, properties FROM nodes WHERE id = ?1;"); + sqlite3_stmt *stmt = prepare_cached(s, &s->stmt_find_node_by_id, st_find_node_by_id_sql); if (!stmt) { return CBM_STORE_ERR; } @@ -3295,6 +3375,280 @@ int cbm_store_find_nodes_by_file(cbm_store_t *s, const char *project, const char project, file_path, out, count); } +/* ── Streamed node scan ────────────────────────────────────────────── + * cbm_store_find_nodes / cbm_store_find_nodes_by_label materialise every row + * at once: 8.5 M kernel nodes with all their strings are 4.9 GB before the + * caller sees the first one. The scan below hands the same rows out a page at + * a time, and resets each page's statement before returning it, so no read + * lock outlives a page: a writer waits behind one page, as behind one eager + * lookup, never behind the caller's whole query. + * + * Pages continue by keyset along the index the eager query walks, so they + * concatenate to the eager order: + * all nodes idx_nodes_file (project, file_path): the NULL file_path rows by + * id (index order puts NULL first), then by (file_path, id). A + * page continues with the rest of its last row's file (by id), + * then with the files after it: two exact index seeks, where a + * (file_path, id) row-value bound seeks the file only and would + * rescan a large file from its start on every page; + * one label idx_nodes_label (project, label): by id. + * open checks that plan with EXPLAIN QUERY PLAN on the eager query itself. + * Any other plan (a changed schema, planner statistics) falls back to the + * eager scan handed out in batches: the same rows, without the saving. */ +#define NODE_SCAN_COLS \ + "SELECT id, project, label, name, qualified_name, file_path, start_line, end_line, " \ + "properties FROM nodes " + +typedef enum { + NODE_SCAN_LABEL, /* label = ?2 AND id >= ?3 */ + NODE_SCAN_NULL_FILE, /* file_path IS NULL AND id >= ?2 */ + NODE_SCAN_FILE_FIRST, /* file_path IS NOT NULL: the first file page */ + NODE_SCAN_FILE_SAME, /* file_path = ?2 AND id > ?3: the rest of the last file */ + NODE_SCAN_FILE_AFTER, /* file_path > ?2: the files after it */ + NODE_SCAN_STMTS, +} node_scan_stmt_t; + +enum { NODE_SCAN_COL_FILE = 5 }; /* file_path in NODE_SCAN_COLS */ + +static const char *const node_scan_sql[NODE_SCAN_STMTS] = { + NODE_SCAN_COLS "WHERE project = ?1 AND label = ?2 AND id >= ?3 ORDER BY id LIMIT ?4;", + NODE_SCAN_COLS "WHERE project = ?1 AND file_path IS NULL AND id >= ?2 ORDER BY id LIMIT ?3;", + NODE_SCAN_COLS "WHERE project = ?1 AND file_path IS NOT NULL ORDER BY file_path, id LIMIT ?2;", + NODE_SCAN_COLS "WHERE project = ?1 AND file_path = ?2 AND id > ?3 ORDER BY id LIMIT ?4;", + NODE_SCAN_COLS "WHERE project = ?1 AND file_path > ?2 ORDER BY file_path, id LIMIT ?3;", +}; + +struct cbm_store_node_scan { + cbm_store_t *store; + char *project; + char *label; + bool paged; + node_scan_stmt_t phase; /* LABEL, NULL_FILE, FILE_SAME or FILE_AFTER */ + bool keyed; /* last_id (and for files last_file) hold the previous page's end */ + int64_t last_id; + sqlite3_value *last_file; + sqlite3_stmt *stmts[NODE_SCAN_STMTS]; + bool done; + bool eager_failed; /* fallback: the eager scan itself failed; next reports it once */ + cbm_node_t *eager; /* fallback: the eager scan's rows, handed out in order */ + int eager_count; + int eager_next; +}; + +#ifdef CBM_ENABLE_TEST_SEAMS +static atomic_int node_scan_test_fail_countdown = ATOMIC_VAR_INIT(CBM_NOT_FOUND); + +void cbm_store_node_scan_test_fail_page_after(int successful_pages) { + atomic_store(&node_scan_test_fail_countdown, successful_pages); +} +#endif + +/* True when SQLite runs `sql` as the single index search `plan`. */ +static bool node_scan_plan_is(cbm_store_t *s, const char *sql, const char *plan) { + char eqp[CBM_SZ_512]; + snprintf(eqp, sizeof(eqp), "EXPLAIN QUERY PLAN %s", sql); + sqlite3_stmt *stmt = NULL; + if (sqlite3_prepare_v2(s->db, eqp, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { + sqlite3_finalize(stmt); + return false; + } + int rows = 0; + bool match = false; + while (sqlite3_step(stmt) == SQLITE_ROW) { + const char *detail = (const char *)sqlite3_column_text(stmt, ST_COL_3); + match = rows == 0 && detail && strcmp(detail, plan) == 0; + rows++; + } + sqlite3_finalize(stmt); + return match && rows == SKIP_ONE; +} + +cbm_store_node_scan_t *cbm_store_node_scan_open(cbm_store_t *s, const char *project, + const char *label) { + if (!s || !s->db) { + return NULL; + } + cbm_store_node_scan_t *scan = cbm_calloc(CBM_MEM_CLASS_STORE, sizeof(*scan)); + if (!scan) { + return NULL; + } + scan->store = s; + scan->project = cbm_mem_strdup(CBM_MEM_CLASS_STORE, project); + scan->label = cbm_mem_strdup(CBM_MEM_CLASS_STORE, label); + if ((project && !scan->project) || (label && !scan->label)) { + cbm_store_node_scan_close(scan); + return NULL; + } + scan->phase = label ? NODE_SCAN_LABEL : NODE_SCAN_NULL_FILE; /* NULL file_path rows first */ + if (label) { + scan->paged = + node_scan_plan_is(s, NODE_SCAN_COLS "WHERE project = ?1 AND label = ?2;", + "SEARCH nodes USING INDEX idx_nodes_label (project=? AND label=?)"); + } else { + scan->paged = node_scan_plan_is(s, NODE_SCAN_COLS "WHERE project = ?1;", + "SEARCH nodes USING INDEX idx_nodes_file (project=?)"); + } + if (!scan->paged) { + int rc = label ? cbm_store_find_nodes_by_label(s, project, label, &scan->eager, + &scan->eager_count) + : cbm_store_find_nodes(s, project, &scan->eager, &scan->eager_count); + scan->eager_failed = rc != CBM_STORE_OK; + } + return scan; +} + +bool cbm_store_node_scan_paged(const cbm_store_node_scan_t *scan) { + return scan && scan->paged; +} + +/* The current phase has no rows left. The NULL file_path rows hand over to + * the files, the rest of a file to the files after it; a label's rows and + * the files after the last one end the scan. */ +static void node_scan_phase_done(cbm_store_node_scan_t *scan) { + if (scan->phase == NODE_SCAN_NULL_FILE) { + scan->keyed = false; + scan->phase = NODE_SCAN_FILE_AFTER; + } else if (scan->phase == NODE_SCAN_FILE_SAME) { + scan->phase = NODE_SCAN_FILE_AFTER; /* after last_file */ + } else { + scan->done = true; + } +} + +static void node_scan_bind(cbm_store_node_scan_t *scan, sqlite3_stmt *stmt, node_scan_stmt_t which, + int max) { + int col = SKIP_ONE; + bind_text(stmt, col++, scan->project); + if (which == NODE_SCAN_LABEL) { + bind_text(stmt, col++, scan->label); + } + if (which == NODE_SCAN_FILE_SAME || which == NODE_SCAN_FILE_AFTER) { + sqlite3_bind_value(stmt, col++, scan->last_file); + } + if (which == NODE_SCAN_LABEL || which == NODE_SCAN_NULL_FILE) { + sqlite3_bind_int64(stmt, col++, scan->keyed ? scan->last_id + SKIP_ONE : INT64_MIN); + } else if (which == NODE_SCAN_FILE_SAME) { + sqlite3_bind_int64(stmt, col++, scan->last_id); + } + sqlite3_bind_int(stmt, col, max); +} + +/* One page of the current phase into out[0..max). A short page ends the + * phase; the statement is reset before returning either way. */ +static int node_scan_page(cbm_store_node_scan_t *scan, cbm_node_t *out, int max, int *got) { + *got = 0; + cbm_store_t *s = scan->store; + node_scan_stmt_t which = scan->phase; + if (which == NODE_SCAN_FILE_AFTER && !scan->keyed) { + which = NODE_SCAN_FILE_FIRST; + } + bool after_id = + which == NODE_SCAN_LABEL || which == NODE_SCAN_NULL_FILE || which == NODE_SCAN_FILE_SAME; + if (after_id && scan->keyed && scan->last_id == INT64_MAX) { + node_scan_phase_done(scan); /* nothing can follow the largest id */ + return CBM_STORE_OK; + } +#ifdef CBM_ENABLE_TEST_SEAMS + if (graph_compare_test_countdown_fires(&node_scan_test_fail_countdown)) { + snprintf(s->errbuf, sizeof(s->errbuf), "node scan aborted: injected page failure"); + return CBM_STORE_ERR; + } +#endif + read_batch_renew(s); + sqlite3_stmt **slot = &scan->stmts[which]; + if (!*slot && + sqlite3_prepare_v2(s->db, node_scan_sql[which], CBM_NOT_FOUND, slot, NULL) != SQLITE_OK) { + store_set_error_sqlite(s, "node scan prepare"); + return CBM_STORE_ERR; + } + sqlite3_stmt *stmt = *slot; + node_scan_bind(scan, stmt, which, max); + + int n = 0; + int rc = SQLITE_ROW; + while (n < max && (rc = sqlite3_step(stmt)) == SQLITE_ROW) { + scan_node(stmt, &out[n]); + n++; + } + bool failed = n < max && rc != SQLITE_DONE; + if (n == max) { /* the statement still sits on the page's last row */ + scan->keyed = true; + scan->last_id = out[n - SKIP_ONE].id; + if (which == NODE_SCAN_FILE_FIRST || which == NODE_SCAN_FILE_AFTER) { + /* a new last file: continue with the rest of it */ + sqlite3_value_free(scan->last_file); + scan->last_file = sqlite3_value_dup(sqlite3_column_value(stmt, NODE_SCAN_COL_FILE)); + failed = !scan->last_file; + scan->phase = NODE_SCAN_FILE_SAME; + } + } + sqlite3_reset(stmt); + if (failed) { + store_set_error_sqlite(s, "node scan aborted"); + for (int i = 0; i < n; i++) { + cbm_node_free_fields(&out[i]); + } + return CBM_STORE_ERR; + } + if (n < max) { + node_scan_phase_done(scan); + } + *got = n; + return CBM_STORE_OK; +} + +int cbm_store_node_scan_next(cbm_store_node_scan_t *scan, cbm_node_t *out, int max, int *count) { + *count = 0; + if (!scan || max <= 0) { + return CBM_STORE_OK; + } + if (scan->eager_failed) { + scan->eager_failed = false; /* reported once; the scan then reads as exhausted */ + return CBM_STORE_ERR; + } + if (!scan->paged) { + int left = scan->eager_count - scan->eager_next; + int n = left < max ? left : max; + if (n > 0) { + memcpy(out, scan->eager + scan->eager_next, (size_t)n * sizeof(cbm_node_t)); + memset(scan->eager + scan->eager_next, 0, (size_t)n * sizeof(cbm_node_t)); + scan->eager_next += n; + } + *count = n; + return CBM_STORE_OK; + } + int n = 0; + while (n < max && !scan->done) { + int got = 0; + if (node_scan_page(scan, out + n, max - n, &got) != CBM_STORE_OK) { + /* A failed page yields no rows from this call and ends the scan; + * cbm_store_error() names the failure. */ + for (int i = 0; i < n; i++) { + cbm_node_free_fields(&out[i]); + } + scan->done = true; + return CBM_STORE_ERR; + } + n += got; + } + *count = n; + return CBM_STORE_OK; +} + +void cbm_store_node_scan_close(cbm_store_node_scan_t *scan) { + if (!scan) { + return; + } + for (int i = 0; i < NODE_SCAN_STMTS; i++) { + sqlite3_finalize(scan->stmts[i]); + } + sqlite3_value_free(scan->last_file); + cbm_store_free_nodes(scan->eager, scan->eager_count); /* rows not handed out */ + cbm_free(CBM_MEM_CLASS_STORE, scan->project); + cbm_free(CBM_MEM_CLASS_STORE, scan->label); + cbm_free(CBM_MEM_CLASS_STORE, scan); +} + typedef struct { cbm_store_cancel_fn cancel; void *context; @@ -6302,6 +6656,100 @@ int cbm_store_restore_from(cbm_store_t *dst, cbm_store_t *src) { return CBM_STORE_OK; } +/* ── In-memory adjacency ─────────────────────────────────────────── */ + +/* The edge types search_graph's in_deg/out_deg count (cbm_store_search). */ +static const char *const st_degree_types[] = {"CALLS", "USAGE", "CALL_REFERENCE", "INHERITS", + "IMPLEMENTS"}; + +/* cbm_adj_degree(id, inbound): the in-memory twin of search's two + * correlated COUNT(*) subqueries over st_degree_types. */ +static void sqlite_graph_mem_degree(sqlite3_context *ctx, int argc, sqlite3_value **argv) { + (void)argc; + const cbm_store_t *s = sqlite3_user_data(ctx); + sqlite3_result_int(ctx, cbm_graph_mem_degree(s->graph_mem, sqlite3_value_int64(argv[0]), + sqlite3_value_int(argv[SKIP_ONE]) != 0, + s->graph_mem_degree_mask)); +} + +static uint32_t st_be32(const unsigned char *p) { + return ((uint32_t)p[0] << ST_BE_SHIFT_24) | ((uint32_t)p[SKIP_ONE] << ST_BE_SHIFT_16) | + ((uint32_t)p[ST_COL_2] << ST_BE_SHIFT_8) | (uint32_t)p[ST_COL_3]; +} + +/* The committed state of a rollback-journal DB: its header's change counter + * and page count, read through SQLite's own file handle while this + * connection's read transaction holds the SHARED lock, so no commit can land + * mid-read. Every rollback-mode commit bumps the counter (it is how SQLite + * tells other connections their page cache is stale), and switching into or + * out of WAL is itself such a commit. That makes the state exact where the + * store generation is not: the cross-repo pass writes CROSS_* edges straight + * into other projects' published DBs without stamping a generation. A + * WAL-mode file is refused — a WAL commit need not touch the header — which + * is the state of a published DB after any in-place writer switched it to + * WAL; it keeps the SQL path until the next publish seals it again. */ +static bool store_rollback_file_state(cbm_store_t *s, char *out, size_t out_sz) { + sqlite3_file *file = NULL; + unsigned char hdr[ST_DB_HEADER_LEN]; + if (sqlite3_file_control(s->db, "main", SQLITE_FCNTL_FILE_POINTER, (void *)&file) != + SQLITE_OK || + !file || !file->pMethods || + file->pMethods->xRead(file, hdr, (int)sizeof(hdr), 0) != SQLITE_OK) { + return false; + } + if (hdr[ST_HDR_WRITE_VERSION] != ST_HDR_ROLLBACK || + hdr[ST_HDR_READ_VERSION] != ST_HDR_ROLLBACK) { + return false; + } + snprintf(out, out_sz, "c%u.p%u", st_be32(hdr + ST_HDR_CHANGE_COUNTER), + st_be32(hdr + ST_HDR_PAGE_COUNT)); + return true; +} + +/* This store's adjacency layer, or NULL for the SQL path; tried once per + * store. The layer's version is the store generation plus the file state, and + * both — like a miss's edge scan — are read inside one read transaction, so a + * layer holds exactly the edges of the version it is keyed to. */ +static cbm_graph_mem_t *store_graph_mem(cbm_store_t *s) { + if (s->graph_mem_tried) { + return s->graph_mem; + } + s->graph_mem_tried = true; + if (!s->query_store || !s->db || !s->db_path) { + return NULL; + } + bool own_txn = sqlite3_get_autocommit(s->db) != 0; + if (own_txn && sqlite3_exec(s->db, "BEGIN DEFERRED;", NULL, NULL, NULL) != SQLITE_OK) { + return NULL; + } + char generation[CBM_SZ_128]; + char state[CBM_SZ_64]; + char version[CBM_SZ_256]; + /* The generation read is the transaction's first read: the SHARED lock + * the file-state read relies on is held from here on. */ + if (cbm_store_generation(s, generation, sizeof(generation)) == CBM_STORE_OK && + strcmp(generation, "legacy") != 0 && store_rollback_file_state(s, state, sizeof(state))) { + snprintf(version, sizeof(version), "%s/%s", generation, state); + s->graph_mem = cbm_graph_mem_acquire(s->db, s->db_path, version); + } + if (own_txn) { + (void)sqlite3_exec(s->db, "COMMIT;", NULL, NULL, NULL); + } + if (s->graph_mem && + sqlite3_create_function(s->db, "cbm_adj_degree", ST_COL_2, + SQLITE_UTF8 | SQLITE_DETERMINISTIC | SQLITE_DIRECTONLY, s, + sqlite_graph_mem_degree, NULL, NULL) != SQLITE_OK) { + cbm_graph_mem_release(s->graph_mem); + s->graph_mem = NULL; + } + if (s->graph_mem) { + s->graph_mem_degree_mask = + cbm_graph_mem_type_mask(s->graph_mem, st_degree_types, + (int)(sizeof(st_degree_types) / sizeof(st_degree_types[0]))); + } + return s->graph_mem; +} + /* ── Search ─────────────────────────────────────────────────────── */ /* Convert a glob pattern to SQL LIKE pattern. */ @@ -6405,6 +6853,36 @@ int cbm_extract_like_hints(const char *pattern, char **out, int max_out) { return count; } +/* Patterns run as POSIX ERE without REG_NEWLINE, so '^' anchors at the start of + * the name only, and a pattern without '|' is one concatenation: every match + * begins with the literal run after the '^'. A following '*', '?', '{' may + * repeat the run's last character zero times and '+' may be combined with them + * (glibc reads "a+?" as "(a+)?"), so that character is dropped; so is the one + * before a backslash, which no rule here needs to interpret. */ +int cbm_regex_anchored_prefix(const char *pattern, char *out, int out_sz) { + if (!pattern || !out || out_sz <= 1 || pattern[0] != '^' || strchr(pattern, '|')) { + if (out && out_sz > 0) { + out[0] = '\0'; + } + return 0; + } + int len = 0; + const char *p = pattern + SKIP_ONE; + while (*p && len < out_sz - SKIP_ONE) { + unsigned char c = (unsigned char)*p; + if (c < ' ' || c > '~' || strchr(".[]()*+?{}|^$\\", c)) { + break; + } + out[len++] = (char)c; + p++; + } + if (len > 0 && *p && strchr("*+?{\\", *p)) { + len--; + } + out[len] = '\0'; + return len; +} + /* ── ensureCaseInsensitive / stripCaseFlag ────────────────────── */ const char *cbm_ensure_case_insensitive(const char *pattern) { @@ -6701,6 +7179,151 @@ static int search_build_where(const cbm_search_params_t *params, char *where, in return nparams; } +/* Anchored name patterns ("^schedule$", "^kmalloc"): every matching name begins + * with the pattern's literal prefix (cbm_regex_anchored_prefix), so it lies in a + * half-open idx_nodes_name range [prefix, prefix with its last byte + 1). Under + * iregexp the prefix matches in any letter case: the process never calls + * setlocale, so the regex runs in the C locale and folds ASCII letters only, and + * each of the first ST_NAME_RANGE_LETTERS letters doubles the ranges. The ranges + * are ADDED to the full WHERE (LIKE hints and regex stay), so the rows are those + * of the plain query — which instead scans every name of the project. */ +typedef struct { + char lo[ST_NAME_RANGE_MAX][ST_NAME_RANGE_PREFIX]; + char hi[ST_NAME_RANGE_MAX][ST_NAME_RANGE_PREFIX]; + int count; +} search_name_ranges_t; + +static bool name_range_letter(char c) { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'); +} + +static char name_range_case(char c, bool lower) { + if (lower && c >= 'A' && c <= 'Z') { + return (char)(c - 'A' + 'a'); + } + if (!lower && c >= 'a' && c <= 'z') { + return (char)(c - 'a' + 'A'); + } + return c; +} + +static int search_name_ranges(const cbm_search_params_t *p, search_name_ranges_t *r) { + r->count = 0; + /* Without the project the index cannot seek the name; degree filters wrap + * the query in a subselect this rewrite leaves alone. */ + if (!p->project || !p->name_pattern || p->min_degree >= 0 || p->max_degree >= 0) { + return 0; + } + char prefix[ST_NAME_RANGE_PREFIX]; + int len = cbm_regex_anchored_prefix(p->name_pattern, prefix, (int)sizeof(prefix)); + if (len == 0) { + return 0; + } + /* A pattern the regex function rejects fails the plain query once a row + * reaches the regex; fewer rows reach it here, so it keeps the plain query. */ + int flags = CBM_REG_EXTENDED | CBM_REG_NOSUB | (p->case_sensitive ? 0 : CBM_REG_ICASE); + cbm_regex_t re; + if (cbm_regcomp(&re, p->name_pattern, flags) != 0) { + return 0; + } + cbm_regfree(&re); + int letters = 0; + if (!p->case_sensitive) { + int k = 0; + for (; k < len; k++) { + if (name_range_letter(prefix[k])) { + if (letters == ST_NAME_RANGE_LETTERS) { + break; + } + letters++; + } + } + len = k; + } + int variants = SKIP_ONE << letters; + for (int v = 0; v < variants; v++) { + int bit = letters - SKIP_ONE; + for (int i = 0; i < len; i++) { + char c = prefix[i]; + if (!p->case_sensitive && name_range_letter(c)) { + /* Variant 0 is all upper case: ranges in ascending byte order. */ + c = name_range_case(c, ((v >> bit) & SKIP_ONE) != 0); + bit--; + } + r->lo[v][i] = c; + r->hi[v][i] = c; + } + r->lo[v][len] = '\0'; + r->hi[v][len] = '\0'; + r->hi[v][len - SKIP_ONE]++; /* printable ASCII: never wraps */ + } + r->count = variants; + return variants; +} + +/* Main and count SQL over the name ranges: one UNION ALL arm per range, each + * the plain WHERE plus its range, so every arm seeks idx_nodes_name. The arms + * are disjoint and each yields (name, id) order, so ORDER BY 4, 1 (n.name, + * n.id) merges them without a sort and the per-row degree subqueries run only + * for rows the page emits; the count sums the per-range counts. On false + * (allocation, bind slots) nothing is bound and the plain query runs. */ +/* Append to buf[0..cap) at *len; false (nothing written past cap) when it + * does not fit. */ +static bool search_sql_append(char *buf, size_t cap, size_t *len, const char *fmt, ...) { + if (*len >= cap) { + return false; + } + va_list args; + va_start(args, fmt); + int wrote = vsnprintf(buf + *len, cap - *len, fmt, args); + va_end(args); + if (wrote < 0 || (size_t)wrote >= cap - *len) { + return false; + } + *len += (size_t)wrote; + return true; +} + +static bool search_build_ranged_sql(const char *select_cols, const char *where, + const search_name_ranges_t *r, int limit, int offset, + search_bind_t *binds, int *bind_idx, char **out_sql, + char **out_count_sql) { + if (*bind_idx + (r->count * ST_COL_2) > ST_SEARCH_MAX_BINDS) { + return false; + } + size_t cap = (strlen(select_cols) + strlen(where) + ST_NAME_RANGE_ARM_SQL) * (size_t)r->count + + ST_NAME_RANGE_ARM_SQL; + char *sql = cbm_alloc(CBM_MEM_CLASS_STORE, cap); + char *cnt = cbm_alloc(CBM_MEM_CLASS_STORE, cap); + int first_bind = *bind_idx; + size_t sl = 0; + size_t cl = 0; + bool ok = sql && cnt && search_sql_append(cnt, cap, &cl, "SELECT "); + for (int i = 0; ok && i < r->count; i++) { + int lo = *bind_idx + SKIP_ONE; + where_bind_text(binds, bind_idx, r->lo[i]); + where_bind_text(binds, bind_idx, r->hi[i]); + ok = search_sql_append(sql, cap, &sl, + "%s%s FROM nodes n WHERE (%s) AND n.name >= ?%d AND n.name < ?%d", + i > 0 ? " UNION ALL " : "", select_cols, where, lo, lo + SKIP_ONE) && + search_sql_append(cnt, cap, &cl, + "%s(SELECT COUNT(*) FROM nodes n WHERE (%s) AND n.name >= ?%d AND " + "n.name < ?%d)", + i > 0 ? " + " : "", where, lo, lo + SKIP_ONE); + } + ok = ok && search_sql_append(sql, cap, &sl, " ORDER BY 4, 1 LIMIT %d OFFSET %d", limit, offset); + if (!ok) { + /* Nothing is bound: the caller runs the plain query. */ + *bind_idx = first_bind; + cbm_free(CBM_MEM_CLASS_STORE, sql); + cbm_free(CBM_MEM_CLASS_STORE, cnt); + return false; + } + *out_sql = sql; + *out_count_sql = cnt; + return true; +} + int cbm_store_search(cbm_store_t *s, const cbm_search_params_t *params, cbm_search_output_t *out) { memset(out, 0, sizeof(*out)); if (!s || !s->db) { @@ -6719,6 +7342,16 @@ int cbm_store_search(cbm_store_t *s, const cbm_search_params_t *params, cbm_sear "(SELECT COUNT(*) FROM edges e WHERE e.source_id = n.id AND " "e.type IN ('CALLS', 'USAGE', 'CALL_REFERENCE', 'INHERITS', " "'IMPLEMENTS')) AS out_deg "; + /* A degree filter evaluates both counts for EVERY candidate row (787k + * Functions on the kernel: two B-tree seeks each, 1.9 s per pass). The + * adjacency layer answers them from RAM; the query is otherwise the same, + * so filter, order, page and total are unchanged. */ + bool has_degree_filter = (params->min_degree >= 0 || params->max_degree >= 0); + if (has_degree_filter && store_graph_mem(s)) { + select_cols = "SELECT n.id, n.project, n.label, n.name, n.qualified_name, " + "n.file_path, n.start_line, n.end_line, n.properties, " + "cbm_adj_degree(n.id, 1) AS in_deg, cbm_adj_degree(n.id, 0) AS out_deg "; + } char where[CBM_SZ_2K] = ""; search_bind_t binds[ST_SEARCH_MAX_BINDS]; @@ -6735,7 +7368,6 @@ int cbm_store_search(cbm_store_t *s, const cbm_search_params_t *params, cbm_sear } /* Degree filters */ - bool has_degree_filter = (params->min_degree >= 0 || params->max_degree >= 0); search_apply_degree_filter(sql, sizeof(sql), params); /* Count query — stripped of per-row edge subqueries for the common (no-degree-filter) @@ -6761,9 +7393,21 @@ int cbm_store_search(cbm_store_t *s, const cbm_search_params_t *params, cbm_sear id_col, limit, offset); strncat(sql, order_limit, sizeof(sql) - strlen(sql) - 1); + /* Anchored name pattern: the same rows through idx_nodes_name seeks. */ + search_name_ranges_t name_ranges; + char *ranged_sql = NULL; + char *ranged_count_sql = NULL; + if (nparams > 0 && search_name_ranges(params, &name_ranges) > 0) { + (void)search_build_ranged_sql(select_cols, where, &name_ranges, limit, offset, binds, + &bind_idx, &ranged_sql, &ranged_count_sql); + } + const char *main_sql = ranged_sql ? ranged_sql : sql; + const char *total_sql = ranged_count_sql ? ranged_count_sql : count_sql; + /* Execute count query */ sqlite3_stmt *cnt_stmt = NULL; - int rc = sqlite3_prepare_v2(s->db, count_sql, CBM_NOT_FOUND, &cnt_stmt, NULL); + int rc = sqlite3_prepare_v2(s->db, total_sql, CBM_NOT_FOUND, &cnt_stmt, NULL); + cbm_free(CBM_MEM_CLASS_STORE, ranged_count_sql); /* prepare_v2 copies the text */ if (rc == SQLITE_OK) { for (int i = 0; i < bind_idx; i++) { bind_text(cnt_stmt, i + SKIP_ONE, binds[i].text); @@ -6776,7 +7420,8 @@ int cbm_store_search(cbm_store_t *s, const cbm_search_params_t *params, cbm_sear /* Execute main query */ sqlite3_stmt *main_stmt = NULL; - rc = sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &main_stmt, NULL); + rc = sqlite3_prepare_v2(s->db, main_sql, CBM_NOT_FOUND, &main_stmt, NULL); + cbm_free(CBM_MEM_CLASS_STORE, ranged_sql); if (rc != SQLITE_OK) { store_set_error_sqlite(s, "search prepare"); like_pool_free(&like_pool); @@ -7011,43 +7656,47 @@ static int bfs_cte_row_limit_for_depth(int max_results, int max_depth) { return (int)scaled; } -static int store_bfs(cbm_store_t *s, int64_t start_id, const char *direction, - const char **edge_types, int edge_type_count, int max_depth, int max_results, - bool trail, int max_edges, cbm_traverse_result_t *out) { - memset(out, 0, sizeof(*out)); - if (max_results < 0) { - max_results = 0; - } - - cbm_node_t root = {0}; - int rc = cbm_store_find_node_by_id(s, start_id, &root); - if (rc != CBM_STORE_OK) { - return rc; - } - out->root = root; +/* One store_bfs walk, shared by its SQL and in-memory paths. */ +typedef struct { + int64_t start_id; + bool inbound; + const char *types_clause; + const char **edge_types; + int edge_type_count; + int max_depth; + int max_results; + int probe_limit; /* max_results + 1: saturation stays observable */ + bool trail; +} bfs_query_t; - char types_clause[CBM_SZ_512]; - bfs_build_types_clause(edge_type_count, types_clause, (int)sizeof(types_clause)); +typedef struct { + cbm_node_hop_t *visited; + int count; + int cap; +} bfs_rows_t; - /* Build recursive CTE for BFS */ - char sql[CBM_SZ_4K]; - const char *join_cond; - const char *next_id; - bool is_inbound = (direction != NULL) && (strcmp(direction, "inbound") == 0); +static void bfs_rows_init(bfs_rows_t *rows) { + rows->cap = ST_INIT_CAP_16; + rows->count = 0; + rows->visited = malloc(rows->cap * sizeof(cbm_node_hop_t)); +} - if (is_inbound) { - join_cond = "e.target_id = bfs.node_id"; - next_id = "e.source_id"; - } else { - join_cond = "e.source_id = bfs.node_id"; - next_id = "e.target_id"; +static cbm_node_hop_t *bfs_rows_next(bfs_rows_t *rows) { + if (rows->count >= rows->cap) { + rows->cap *= ST_GROWTH; + rows->visited = safe_realloc(rows->visited, rows->cap * sizeof(cbm_node_hop_t)); } + return &rows->visited[rows->count++]; +} - /* One extra row makes max_results saturation observable on both branches. */ - int probe_limit = max_results < INT_MAX ? max_results + SKIP_ONE : max_results; - int cte_row_limit = 0; - if (trail) { - cte_row_limit = bfs_cte_row_limit_for_depth(max_results, max_depth); +/* The recursive CTE: trail (relationship-unique, for Cypher) or shortest-path. */ +static int bfs_visit_sql(cbm_store_t *s, const bfs_query_t *q, bfs_rows_t *rows, int *cte_rows, + int *cte_row_limit) { + char sql[CBM_SZ_4K]; + const char *join_cond = q->inbound ? "e.target_id = bfs.node_id" : "e.source_id = bfs.node_id"; + const char *next_id = q->inbound ? "e.source_id" : "e.target_id"; + if (q->trail) { + *cte_row_limit = bfs_cte_row_limit_for_depth(q->max_results, q->max_depth); snprintf(sql, sizeof(sql), "WITH RECURSIVE bfs(node_id, hop, edge_path) AS (" " SELECT %lld, 0, ''" @@ -7072,8 +7721,8 @@ static int store_bfs(cbm_store_t *s, int64_t start_id, const char *direction, "(SELECT count(*) FROM bfs) " "FROM bfs JOIN nodes n ON n.id = bfs.node_id " "WHERE bfs.hop > 0 ORDER BY bfs.hop, n.id LIMIT %d;", - (long long)start_id, next_id, join_cond, types_clause, max_depth, - cte_row_limit + SKIP_ONE, probe_limit); + (long long)q->start_id, next_id, join_cond, q->types_clause, q->max_depth, + *cte_row_limit + SKIP_ONE, q->probe_limit); } else { snprintf(sql, sizeof(sql), /* SHORTEST-PATH semantics: the UNION dedupes (node, hop) pairs. @@ -7096,52 +7745,130 @@ static int store_bfs(cbm_store_t *s, int64_t start_id, const char *direction, "GROUP BY n.id " "ORDER BY hop, n.id " "LIMIT %d;", - (long long)start_id, next_id, join_cond, types_clause, max_depth, probe_limit); + (long long)q->start_id, next_id, join_cond, q->types_clause, q->max_depth, + q->probe_limit); } sqlite3_stmt *stmt = NULL; - rc = sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL); - if (rc != SQLITE_OK) { + if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { store_set_error_sqlite(s, "bfs prepare"); return CBM_STORE_ERR; } /* Bind edge type parameters */ - if (edge_type_count > 0) { - for (int i = 0; i < edge_type_count; i++) { - bind_text(stmt, i + SKIP_ONE, edge_types[i]); + if (q->edge_type_count > 0) { + for (int i = 0; i < q->edge_type_count; i++) { + bind_text(stmt, i + SKIP_ONE, q->edge_types[i]); } } else { bind_text(stmt, SKIP_ONE, "CALLS"); } - int cap = ST_INIT_CAP_16; - int n = 0; - int cte_rows = 0; - cbm_node_hop_t *visited = malloc(cap * sizeof(cbm_node_hop_t)); - + bfs_rows_init(rows); int scan_rc15; while ((scan_rc15 = sqlite3_step(stmt)) == SQLITE_ROW) { - if (n >= cap) { - cap *= ST_GROWTH; - visited = safe_realloc(visited, cap * sizeof(cbm_node_hop_t)); + cbm_node_hop_t *row = bfs_rows_next(rows); + scan_node(stmt, &row->node); + row->hop = sqlite3_column_int(stmt, ST_COL_9); + if (q->trail) { + *cte_rows = sqlite3_column_int(stmt, ST_COL_10); } - scan_node(stmt, &visited[n].node); - visited[n].hop = sqlite3_column_int(stmt, ST_COL_9); - if (trail) { - cte_rows = sqlite3_column_int(stmt, ST_COL_10); - } - n++; } if (scan_rc15 != SQLITE_DONE) { /* SCANCHK:15:stmt */ store_set_error_sqlite(s, "row scan aborted"); sqlite3_finalize(stmt); - out->visited = visited; - out->visited_count = n; return CBM_STORE_ERR; } - sqlite3_finalize(stmt); + return CBM_STORE_OK; +} + +/* The shortest-path walk from the adjacency layer: the same rows as the CTE's + * JOIN nodes + ORDER BY hop, n.id + LIMIT, because cbm_graph_mem_bfs yields + * every reached id at its minimal hop in (hop, id) order and the node rows are + * read here in that order. An id with no node row is skipped, as the JOIN + * skips it. */ +static int bfs_visit_mem(cbm_store_t *s, const cbm_graph_mem_t *gm, const bfs_query_t *q, + bfs_rows_t *rows) { + static const char *const calls_only[] = {"CALLS"}; + bool typed = q->edge_type_count > 0; + cbm_graph_mem_types_t mask = cbm_graph_mem_type_mask(gm, typed ? q->edge_types : calls_only, + typed ? q->edge_type_count : SKIP_ONE); + cbm_graph_mem_hop_t *hops = NULL; + int hop_count = 0; + if (!cbm_graph_mem_bfs(gm, q->start_id, q->inbound, mask, q->max_depth, &hops, &hop_count)) { + store_set_error(s, "bfs: adjacency walk out of memory"); + return CBM_STORE_ERR; + } + bfs_rows_init(rows); + int rc = CBM_STORE_OK; + for (int i = 0; i < hop_count && rows->count < q->probe_limit && rc == CBM_STORE_OK; i++) { + sqlite3_stmt *stmt = prepare_cached(s, &s->stmt_find_node_by_id, st_find_node_by_id_sql); + if (!stmt) { + rc = CBM_STORE_ERR; + break; + } + sqlite3_bind_int64(stmt, SKIP_ONE, hops[i].id); + int step = sqlite3_step(stmt); + if (step == SQLITE_ROW) { + cbm_node_hop_t *row = bfs_rows_next(rows); + scan_node(stmt, &row->node); + row->hop = hops[i].hop; + } else if (step != SQLITE_DONE) { + store_set_error_sqlite(s, "bfs node row"); + rc = CBM_STORE_ERR; + } + sqlite3_reset(stmt); + } + cbm_graph_mem_hops_free(hops); + return rc; +} + +static int store_bfs(cbm_store_t *s, int64_t start_id, const char *direction, + const char **edge_types, int edge_type_count, int max_depth, int max_results, + bool trail, int max_edges, cbm_traverse_result_t *out) { + memset(out, 0, sizeof(*out)); + if (max_results < 0) { + max_results = 0; + } + + cbm_node_t root = {0}; + int rc = cbm_store_find_node_by_id(s, start_id, &root); + if (rc != CBM_STORE_OK) { + return rc; + } + out->root = root; + + char types_clause[CBM_SZ_512]; + bfs_build_types_clause(edge_type_count, types_clause, (int)sizeof(types_clause)); + + bfs_query_t q = { + .start_id = start_id, + .inbound = (direction != NULL) && (strcmp(direction, "inbound") == 0), + .types_clause = types_clause, + .edge_types = edge_types, + .edge_type_count = edge_type_count, + .max_depth = max_depth, + .max_results = max_results, + /* One extra row makes max_results saturation observable on both branches. */ + .probe_limit = max_results < INT_MAX ? max_results + SKIP_ONE : max_results, + .trail = trail, + }; + bfs_rows_t rows = {0}; + int cte_rows = 0; + int cte_row_limit = 0; + /* A shortest-path walk is answered from the adjacency layer when this + * store has one; trail mode keeps its relationship-unique CTE. */ + cbm_graph_mem_t *gm = trail ? NULL : store_graph_mem(s); + rc = gm ? bfs_visit_mem(s, gm, &q, &rows) + : bfs_visit_sql(s, &q, &rows, &cte_rows, &cte_row_limit); + cbm_node_hop_t *visited = rows.visited; + int n = rows.count; + if (rc != CBM_STORE_OK) { + out->visited = visited; + out->visited_count = n; + return rc; + } if (trail && cte_rows > cte_row_limit) { out->truncated = true; @@ -7629,6 +8356,25 @@ int cbm_store_count_edges_scoped(cbm_store_t *s, const char *project, const char return n; } +/* Per-label and per-type counts. GROUP BY over the (project, label) index + * decodes every entry to compare its group key; stepping through the distinct + * values with MIN(col) > previous seeks and counting each value's index range + * is ~1.6x faster (kernel, 8.5M nodes: 0.26 -> 0.16 s; 16.9M edges: 0.54 -> + * 0.32 s). The values reach ORDER BY in the ascending order GROUP BY emitted + * them in, so tied counts keep their order. label and type are NOT NULL. */ +static const char ST_LABEL_COUNTS_SQL[] = + "WITH RECURSIVE v(x) AS (SELECT MIN(label) FROM nodes WHERE project = ?1 UNION ALL " + "SELECT (SELECT MIN(label) FROM nodes WHERE project = ?1 AND label > v.x) FROM v " + "WHERE v.x IS NOT NULL) " + "SELECT x, (SELECT COUNT(*) FROM nodes WHERE project = ?1 AND label = v.x) AS c " + "FROM v WHERE x IS NOT NULL ORDER BY c DESC;"; +static const char ST_TYPE_COUNTS_SQL[] = + "WITH RECURSIVE v(x) AS (SELECT MIN(type) FROM edges WHERE project = ?1 UNION ALL " + "SELECT (SELECT MIN(type) FROM edges WHERE project = ?1 AND type > v.x) FROM v " + "WHERE v.x IS NOT NULL) " + "SELECT x, (SELECT COUNT(*) FROM edges WHERE project = ?1 AND type = v.x) AS c " + "FROM v WHERE x IS NOT NULL ORDER BY c DESC;"; + /* with_props=false skips the per-label/per-type JSON property-key discovery: * those json_each() scans walk EVERY row of each label/type (minutes-scale on * multi-million-node graphs) and get_architecture only needs the counts. */ @@ -7641,8 +8387,7 @@ static int get_schema_impl(cbm_store_t *s, const char *project, cbm_schema_info_ /* Node labels */ { - const char *sql = "SELECT label, COUNT(*) FROM nodes WHERE project = ?1 GROUP BY label " - "ORDER BY COUNT(*) DESC;"; + const char *sql = ST_LABEL_COUNTS_SQL; sqlite3_stmt *stmt = NULL; if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK || !stmt) { if (stmt) { @@ -7714,8 +8459,7 @@ static int get_schema_impl(cbm_store_t *s, const char *project, cbm_schema_info_ /* Edge types */ { - const char *sql = "SELECT type, COUNT(*) FROM edges WHERE project = ?1 GROUP BY type ORDER " - "BY COUNT(*) DESC;"; + const char *sql = ST_TYPE_COUNTS_SQL; sqlite3_stmt *stmt = NULL; if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK || !stmt) { if (stmt) { @@ -8686,15 +9430,24 @@ static int arch_packages(cbm_store_t *s, const char *project, const char *path, char like[CBM_SZ_512]; bool scoped = arch_path_prepare(path, norm, sizeof(norm), like, sizeof(like)); char sqlbuf[ST_SQL_BUF]; + /* GROUP BY +n.name, not n.name: with a bare column the planner walks + * idx_nodes_name over EVERY node of the project to get groups in name order, + * with a table lookup per node to test the label (6.3 s on the 8.5M-node + * kernel graph, for 5 Package nodes). The unary plus keeps the value and its + * BINARY collation but not the index, so the rows come from the + * idx_nodes_label seek. The ORDER BY sorter keeps tied counts in group + * emission order, which was name order from the index; a sorted GROUP BY + * follows the ORDER BY direction instead, so the name tie-break is explicit. */ const char *base = "SELECT n.name, COUNT(*) as cnt FROM nodes n " "WHERE n.project=?1 AND n.label='Package'"; if (scoped) { snprintf(sqlbuf, sizeof(sqlbuf), "%s AND (n.file_path = ?2 OR n.file_path LIKE ?3) " - "GROUP BY n.name ORDER BY cnt DESC LIMIT 15", + "GROUP BY +n.name ORDER BY cnt DESC, +n.name LIMIT 15", base); } else { - snprintf(sqlbuf, sizeof(sqlbuf), "%s GROUP BY n.name ORDER BY cnt DESC LIMIT 15", base); + snprintf(sqlbuf, sizeof(sqlbuf), "%s GROUP BY +n.name ORDER BY cnt DESC, +n.name LIMIT 15", + base); } sqlite3_stmt *stmt = NULL; if (sqlite3_prepare_v2(s->db, sqlbuf, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { diff --git a/src/store/store.h b/src/store/store.h index da4026d291..123e7645e5 100644 --- a/src/store/store.h +++ b/src/store/store.h @@ -478,6 +478,16 @@ int cbm_store_commit(cbm_store_t *s); /* Rollback the current transaction. */ int cbm_store_rollback(cbm_store_t *s); +/* Read batch: run the reads that follow in one deferred read transaction, + * renewed every 100 ms so a writer is never held back longer, instead of one + * lock round trip per statement. Begin returns false (and does nothing) when + * a transaction is already open. For read-only work only; end it before any + * write, transaction or read scope on the same store. Close ends it. */ +bool cbm_store_read_batch_begin(cbm_store_t *s); +void cbm_store_read_batch_end(cbm_store_t *s); +/* Test seam: make an open read batch due for renewal at its next statement. */ +void cbm_store_read_batch_expire_for_test(cbm_store_t *s); + /* Stable request read scope. Borrows an exclusively owned, single-thread * store and callback context. Refuses a transaction, busy statement or owned * progress handler; idle cached statements are allowed. Saves query_only and @@ -615,6 +625,29 @@ int cbm_store_find_nodes_by_label(cbm_store_t *s, const char *project, const cha int cbm_store_find_nodes_by_file(cbm_store_t *s, const char *project, const char *file_path, cbm_node_t **out, int *count); +/* Streamed node scan: the rows of cbm_store_find_nodes (label NULL) or + * cbm_store_find_nodes_by_label, in the same order, a batch at a time. Pages + * are keyset queries along the eager query's index, and no read lock is held + * between calls. open returns NULL on error. next fills out[0..max) and sets + * *count; a count of 0 means the scan is exhausted. The caller frees each + * returned node with cbm_node_free_fields. A failed read returns + * CBM_STORE_ERR with no rows from that call and ends the scan; + * cbm_store_error() names the failure. paged is false when the eager query's + * plan is not the one the pages reproduce: the scan then hands out the eager + * scan's rows (same order, no memory saving). Close the scan before closing + * the store. */ +typedef struct cbm_store_node_scan cbm_store_node_scan_t; +cbm_store_node_scan_t *cbm_store_node_scan_open(cbm_store_t *s, const char *project, + const char *label); +int cbm_store_node_scan_next(cbm_store_node_scan_t *scan, cbm_node_t *out, int max, int *count); +bool cbm_store_node_scan_paged(const cbm_store_node_scan_t *scan); +void cbm_store_node_scan_close(cbm_store_node_scan_t *scan); +#ifdef CBM_ENABLE_TEST_SEAMS +/* One-shot fault: after this many successful pages (of any scan), the next + * page fails like a SQLite step error; -1 disables the seam. */ +void cbm_store_node_scan_test_fail_page_after(int successful_pages); +#endif + /* Return a stable, paginated outline for one exact repository-relative file. * File/folder/container nodes are excluded. labels may be NULL when * label_count is zero; otherwise labels are exact-match filters. The query is @@ -1210,6 +1243,13 @@ char *cbm_glob_to_like(const char *pattern); * Each out[i] is malloc'd — caller must free each string. */ int cbm_extract_like_hints(const char *pattern, char **out, int max_out); +/* Literal prefix every match of an anchored POSIX ERE must begin with: the + * plain printable-ASCII run after a leading '^', minus its last character when + * a quantifier or escape follows it. Returns its length (written NUL-terminated + * to out); 0 when the pattern is not anchored, contains '|', or starts with an + * operator. Case is kept as written. */ +int cbm_regex_anchored_prefix(const char *pattern, char *out, int out_sz); + /* Prepend (?i) to a regex pattern if not already present. * Returns a static buffer — do NOT free. */ const char *cbm_ensure_case_insensitive(const char *pattern); diff --git a/tests/test_cypher.c b/tests/test_cypher.c index e13ea5c434..e9d65b31fe 100644 --- a/tests/test_cypher.c +++ b/tests/test_cypher.c @@ -5580,6 +5580,518 @@ TEST(cypher_fifty_union_branches) { PASS(); } +/* ── ORDER BY, DISTINCT and grouping against a reference ─────────── + * These were bubble sorts and linear duplicate/group scans — seconds to + * minutes on a 30k-row result. The results must stay exactly the old ones: a + * stable sort of the unsorted rows, first-occurrence DISTINCT, first-seen + * group order. Each case derives that reference from the engine's own + * unsorted rows, on a graph built for ties, duplicates and large groups. */ + +enum { REF_NODES = 400, REF_NAMES = 97 }; + +static cbm_store_t *setup_reference_store(void) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "test", "/tmp/test"); + int64_t ids[REF_NODES]; + for (int i = 0; i < REF_NODES; i++) { + char name[32]; + char qn[32]; + char file[32]; + snprintf(name, sizeof(name), "fn_%02d", (i * 7) % REF_NAMES); /* repeated names */ + snprintf(qn, sizeof(qn), "test.n%d", i); + snprintf(file, sizeof(file), "f%02d.c", i % 13); + cbm_node_t n = {.project = "test", + .label = "Function", + .name = name, + .qualified_name = qn, + .file_path = file, + .start_line = (i * 31) % 50, /* tied lines */ + .end_line = 60}; + ids[i] = cbm_store_upsert_node(s, &n); + } + /* Every node calls both hubs (n0, n1: ~400 callers, 97 distinct names, + * past the hashed COUNT(DISTINCT) threshold) and one more node. */ + for (int i = 0; i < REF_NODES; i++) { + int targets[3] = {0, 1, (i * i + 3) % REF_NODES}; + for (int t = 0; t < 3; t++) { + if (targets[t] != i) { + cbm_edge_t e = {.project = "test", + .source_id = ids[i], + .target_id = ids[targets[t]], + .type = "CALLS"}; + cbm_store_insert_edge(s, &e); + } + } + } + return s; +} + +typedef int (*ref_cmp_fn)(const cbm_cypher_result_t *r, int a, int b); + +/* The reference order: an insertion sort that moves a row before an earlier + * one only when the earlier one compares greater — stable, like the old + * bubble sorts. */ +static void ref_stable_order(const cbm_cypher_result_t *r, int *order, ref_cmp_fn cmp) { + for (int i = 0; i < r->row_count; i++) { + order[i] = i; + } + for (int i = 1; i < r->row_count; i++) { + for (int j = i; j > 0 && cmp(r, order[j - 1], order[j]) > 0; j--) { + int swap = order[j]; + order[j] = order[j - 1]; + order[j - 1] = swap; + } + } +} + +/* Columns 0 (name) and 1 (line): line descending, then name ascending. */ +static int ref_line_desc_name(const cbm_cypher_result_t *r, int a, int b) { + long la = strtol(r->rows[a][1], NULL, 10); + long lb = strtol(r->rows[b][1], NULL, 10); + int cmp = (lb > la) - (lb < la); + return cmp != 0 ? cmp : strcmp(r->rows[a][0], r->rows[b][0]); +} + +static int ref_all_equal(const cbm_cypher_result_t *r, int a, int b) { + (void)r; + (void)a; + (void)b; + return 0; +} + +/* `sorted` must hold the rows of `unsorted` in the reference order. */ +static bool ref_rows_in_order(const cbm_cypher_result_t *sorted, + const cbm_cypher_result_t *unsorted, ref_cmp_fn cmp) { + if (sorted->row_count != unsorted->row_count || sorted->col_count != unsorted->col_count || + unsorted->row_count < REF_NODES) { + return false; + } + int order[REF_NODES * 4]; + ref_stable_order(unsorted, order, cmp); + for (int i = 0; i < sorted->row_count; i++) { + for (int c = 0; c < sorted->col_count; c++) { + if (strcmp(sorted->rows[i][c], unsorted->rows[order[i]][c]) != 0) { + return false; + } + } + } + return true; +} + +static bool ref_query(cbm_store_t *s, const char *query, cbm_cypher_result_t *out) { + memset(out, 0, sizeof(*out)); + return cbm_cypher_execute(s, query, "test", 0, out) == 0 && !out->truncated; +} + +TEST(cypher_order_by_is_the_stable_reference_order) { + cbm_store_t *s = setup_reference_store(); + cbm_cypher_result_t plain; + cbm_cypher_result_t sorted; + ASSERT_TRUE(ref_query(s, "MATCH (f:Function) RETURN f.name, f.start_line", &plain)); + + /* RETURN: numeric key descending, ties broken by the next key. */ + ASSERT_TRUE(ref_query( + s, "MATCH (f:Function) RETURN f.name, f.start_line ORDER BY f.start_line DESC, f.name", + &sorted)); + ASSERT_TRUE(ref_rows_in_order(&sorted, &plain, ref_line_desc_name)); + cbm_cypher_result_free(&sorted); + + /* WITH: the same keys through a projection. */ + ASSERT_TRUE(ref_query(s, + "MATCH (f:Function) WITH f.name AS n, f.start_line AS l " + "ORDER BY l DESC, n RETURN n, l", + &sorted)); + ASSERT_TRUE(ref_rows_in_order(&sorted, &plain, ref_line_desc_name)); + cbm_cypher_result_free(&sorted); + cbm_cypher_result_free(&plain); + + /* All keys tied: both sorts keep the unsorted order. */ + ASSERT_TRUE(ref_query(s, "MATCH (f:Function) RETURN f.name, f.label", &plain)); + ASSERT_TRUE( + ref_query(s, "MATCH (f:Function) RETURN f.name, f.label ORDER BY f.label", &sorted)); + ASSERT_TRUE(ref_rows_in_order(&sorted, &plain, ref_all_equal)); + cbm_cypher_result_free(&sorted); + ASSERT_TRUE(ref_query( + s, "MATCH (f:Function) WITH f.name AS n, f.label AS l ORDER BY l RETURN n, l", &sorted)); + ASSERT_TRUE(ref_rows_in_order(&sorted, &plain, ref_all_equal)); + cbm_cypher_result_free(&sorted); + cbm_cypher_result_free(&plain); + cbm_store_close(s); + PASS(); +} + +/* `got` must be the first occurrence of every value in column 0 of `all`. */ +static bool ref_first_occurrences(const cbm_cypher_result_t *got, const cbm_cypher_result_t *all) { + int kept = 0; + for (int i = 0; i < all->row_count; i++) { + bool seen = false; + for (int j = 0; j < i && !seen; j++) { + seen = strcmp(all->rows[i][0], all->rows[j][0]) == 0; + } + if (seen) { + continue; + } + if (kept >= got->row_count || strcmp(got->rows[kept][0], all->rows[i][0]) != 0) { + return false; + } + kept++; + } + return kept == got->row_count; +} + +TEST(cypher_distinct_keeps_first_occurrences) { + cbm_store_t *s = setup_reference_store(); + cbm_cypher_result_t all; + cbm_cypher_result_t got; + ASSERT_TRUE(ref_query(s, "MATCH (f:Function) RETURN f.name", &all)); + ASSERT_TRUE(ref_query(s, "MATCH (f:Function) RETURN DISTINCT f.name", &got)); + ASSERT_EQ(got.row_count, REF_NAMES); + ASSERT_TRUE(ref_first_occurrences(&got, &all)); + cbm_cypher_result_free(&got); + ASSERT_TRUE(ref_query(s, "MATCH (f:Function) WITH DISTINCT f.name AS n RETURN n", &got)); + ASSERT_EQ(got.row_count, REF_NAMES); + ASSERT_TRUE(ref_first_occurrences(&got, &all)); + cbm_cypher_result_free(&got); + cbm_cypher_result_free(&all); + cbm_store_close(s); + PASS(); +} + +/* `got` (column 0 = callee, column 1 = count) must list the callees of + * `pairs` (column 0 = callee, column 1 = caller name) in first-seen order, + * each with its caller count — of distinct names when `distinct`. */ +static bool ref_groups_match(const cbm_cypher_result_t *got, const cbm_cypher_result_t *pairs, + bool distinct) { + enum { REF_PAIRS_MAX = REF_NODES * 4 }; + if (pairs->row_count > REF_PAIRS_MAX) { + return false; + } + bool first_callee[REF_PAIRS_MAX]; + bool first_pair[REF_PAIRS_MAX]; + for (int k = 0; k < pairs->row_count; k++) { + first_callee[k] = true; + first_pair[k] = true; + for (int m = 0; m < k; m++) { + if (strcmp(pairs->rows[m][0], pairs->rows[k][0]) == 0) { + first_callee[k] = false; + first_pair[k] = first_pair[k] && strcmp(pairs->rows[m][1], pairs->rows[k][1]) != 0; + } + } + } + int groups = 0; + for (int i = 0; i < pairs->row_count; i++) { + if (!first_callee[i]) { + continue; + } + int count = 0; + for (int k = 0; k < pairs->row_count; k++) { + if (strcmp(pairs->rows[k][0], pairs->rows[i][0]) == 0) { + count += !distinct || first_pair[k] ? 1 : 0; + } + } + char want[16]; + snprintf(want, sizeof(want), "%d", count); + if (groups >= got->row_count || strcmp(got->rows[groups][0], pairs->rows[i][0]) != 0 || + strcmp(got->rows[groups][1], want) != 0) { + return false; + } + groups++; + } + return groups == got->row_count; +} + +TEST(cypher_grouping_keeps_first_seen_groups) { + cbm_store_t *s = setup_reference_store(); + cbm_cypher_result_t pairs; + cbm_cypher_result_t got; + ASSERT_TRUE(ref_query( + s, "MATCH (a)-[:CALLS]->(b:Function) RETURN b.qualified_name, a.name", &pairs)); + ASSERT_GT(pairs.row_count, REF_NODES * 2); + + ASSERT_TRUE(ref_query( + s, "MATCH (a)-[:CALLS]->(b:Function) RETURN b.qualified_name, count(a) AS c", &got)); + ASSERT_TRUE(ref_groups_match(&got, &pairs, false)); + cbm_cypher_result_free(&got); + + ASSERT_TRUE(ref_query(s, + "MATCH (a)-[:CALLS]->(b:Function) " + "RETURN b.qualified_name, count(DISTINCT a.name) AS c", + &got)); + ASSERT_TRUE(ref_groups_match(&got, &pairs, true)); + cbm_cypher_result_free(&got); + + ASSERT_TRUE(ref_query(s, + "MATCH (a)-[:CALLS]->(b:Function) WITH b.qualified_name AS q, " + "count(DISTINCT a.name) AS c RETURN q, c", + &got)); + ASSERT_TRUE(ref_groups_match(&got, &pairs, true)); + cbm_cypher_result_free(&got); + cbm_cypher_result_free(&pairs); + cbm_store_close(s); + PASS(); +} + +/* ── Streamed execution: chunks of start nodes give the single pass ── + * + * execute_single runs the start nodes through the pipeline in chunks rather + * than materialising every binding first (kernel count(*): 18 GB RSS). The + * chunked run must be indistinguishable from one pass over the whole start + * set: same rows in the same order, first-seen group order, ORDER BY ties, + * DISTINCT, LIMIT / max_rows truncation and OPTIONAL fallback rows. Each query + * runs as one chunk (the reference) and in chunks of 1, 7, 64 and the + * production default, so chunk boundaries fall inside groups and inside + * every start node's expansion order. */ + +static int stream_run(cbm_store_t *s, const char *query, int max_rows, int chunk, + cbm_cypher_result_t *out) { + memset(out, 0, sizeof(*out)); + cbm_cypher_test_set_stream_chunk(chunk); + int rc = cbm_cypher_execute(s, query, "test", max_rows, out); + cbm_cypher_test_set_stream_chunk(0); + return rc; +} + +static bool stream_same_str(const char *a, const char *b) { + return (!a && !b) || (a && b && strcmp(a, b) == 0); +} + +static bool stream_results_equal(const cbm_cypher_result_t *a, const cbm_cypher_result_t *b) { + if (a->col_count != b->col_count || a->row_count != b->row_count || + a->truncated != b->truncated || !stream_same_str(a->warning, b->warning) || + !stream_same_str(a->error, b->error)) { + return false; + } + for (int c = 0; c < a->col_count; c++) { + if (strcmp(a->columns[c], b->columns[c]) != 0) { + return false; + } + } + for (int r = 0; r < a->row_count; r++) { + for (int c = 0; c < a->col_count; c++) { + if (strcmp(a->rows[r][c], b->rows[r][c]) != 0) { + return false; + } + } + } + return true; +} + +/* True when every chunk size reproduces the single-pass result, which is + * left in *ref for the caller's own checks (caller frees). */ +static bool stream_matches_single_pass(cbm_store_t *s, const char *query, int max_rows, + cbm_cypher_result_t *ref) { + static const int chunks[] = {1, 7, 64, 0 /* production default */}; + int ref_rc = stream_run(s, query, max_rows, INT_MAX, ref); + bool same = ref_rc == 0; + for (size_t i = 0; same && i < sizeof(chunks) / sizeof(chunks[0]); i++) { + cbm_cypher_result_t got; + int rc = stream_run(s, query, max_rows, chunks[i], &got); + same = rc == ref_rc && stream_results_equal(ref, &got); + if (!same) { + printf(" chunk %d differs from the single pass: %s\n", chunks[i], query); + } + cbm_cypher_result_free(&got); + } + return same; +} + +/* Every pipeline consumer across chunk boundaries: unsorted rows, global and + * grouped aggregates (groups fed from many chunks), DISTINCT, late WHERE, + * collect, WITH aggregation and WITH sorting, RETURN * with and without a + * WITH, the default projection, edge variables and variable-length hops. */ +TEST(cypher_stream_chunks_match_single_pass) { + static const char *const queries[] = { + "MATCH (a)-[:CALLS]->(b) RETURN a.name, b.qualified_name", + "MATCH (a)-[:CALLS]->(b) RETURN count(*)", + "MATCH (a)-[:CALLS]->(b:Function) RETURN b.qualified_name, count(a) AS c ORDER BY c DESC", + "MATCH (a:Function)-[:CALLS]->(b) RETURN b.name, count(DISTINCT a.name) AS c", + "MATCH (a)-[:CALLS]->(b) RETURN DISTINCT b.name", + "MATCH (a)-[:CALLS]->(b) WHERE b.start_line > 20 RETURN a.name, b.name, b.start_line", + "MATCH (a)-[:CALLS]->(b) RETURN b.name, collect(a.name) AS cs", + ("MATCH (a:Function)-[:CALLS]->(b) WITH b, count(a) AS c WHERE c > 1 " + "RETURN b.name, c ORDER BY c DESC LIMIT 20"), + "MATCH (a)-[:CALLS]->(b) WITH a.name AS n, b.start_line AS l ORDER BY l DESC RETURN n, l", + "MATCH (a)-[:CALLS]->(b) WITH DISTINCT b.name AS n RETURN n", + "MATCH (a)-[:CALLS]->(b) RETURN *", + "MATCH (a)-[:CALLS]->(b) WITH a.name AS n RETURN *", + "MATCH (a)-[:CALLS]->(b)", + "MATCH (a)-[r:CALLS]->(b) RETURN type(r), a.name ORDER BY a.name", + "MATCH (a)-[:CALLS*1..2]->(b) RETURN a.name, b.name", + }; + cbm_store_t *s = setup_reference_store(); + for (size_t i = 0; i < sizeof(queries) / sizeof(queries[0]); i++) { + cbm_cypher_result_t ref; + ASSERT_TRUE(stream_matches_single_pass(s, queries[i], 0, &ref)); + ASSERT_GT(ref.row_count, 0); + ASSERT_FALSE(ref.truncated); + cbm_cypher_result_free(&ref); + } + cbm_store_close(s); + PASS(); +} + +/* OPTIONAL fallback rows (a start node with no match keeps its row, target + * unbound) sit between the expanded rows of their neighbours in every chunk + * layout; the multi-pattern form keeps its single pass. */ +TEST(cypher_stream_optional_across_chunks) { + cbm_store_t *s = setup_reference_store(); + cbm_cypher_result_t ref; + ASSERT_TRUE(stream_matches_single_pass( + s, "OPTIONAL MATCH (a)<-[:CALLS]-(b) RETURN a.name, b.name", 0, &ref)); + int fallback = 0; + for (int r = 0; r < ref.row_count; r++) { + fallback += ref.rows[r][1][0] == '\0' ? 1 : 0; + } + ASSERT_GT(fallback, 0); /* uncalled nodes keep a row */ + ASSERT_GT(ref.row_count, fallback); /* and called nodes expand */ + cbm_cypher_result_free(&ref); + + ASSERT_TRUE(stream_matches_single_pass( + s, "OPTIONAL MATCH (a)<-[:CALLS]-(b) RETURN a.qualified_name, count(b) AS c", 0, &ref)); + ASSERT_EQ(ref.row_count, REF_NODES); + cbm_cypher_result_free(&ref); + + ASSERT_TRUE(stream_matches_single_pass( + s, "MATCH (a:Function) OPTIONAL MATCH (a)<-[:CALLS]-(b) RETURN a.name, count(b)", 0, &ref)); + ASSERT_GT(ref.row_count, 0); + cbm_cypher_result_free(&ref); + cbm_store_close(s); + PASS(); +} + +/* A LIMIT or the max_rows budget fills up in one chunk while later chunks + * still arrive: they must add nothing, and truncation is reported exactly + * when the single pass reports it (engine budget yes, explicit LIMIT no). */ +TEST(cypher_stream_limit_and_max_rows_across_chunks) { + cbm_store_t *s = setup_reference_store(); + cbm_cypher_result_t ref; + ASSERT_TRUE(stream_matches_single_pass( + s, "MATCH (a)-[:CALLS]->(b) RETURN a.name, b.name LIMIT 25", 0, &ref)); + ASSERT_EQ(ref.row_count, 25); + ASSERT_FALSE(ref.truncated); + cbm_cypher_result_free(&ref); + + ASSERT_TRUE(stream_matches_single_pass( + s, "MATCH (a)-[:CALLS]->(b) RETURN DISTINCT b.name LIMIT 30", 0, &ref)); + ASSERT_EQ(ref.row_count, 30); + cbm_cypher_result_free(&ref); + + ASSERT_TRUE(stream_matches_single_pass( + s, "MATCH (a)-[:CALLS]->(b) RETURN a.name, b.name SKIP 5 LIMIT 10", 0, &ref)); + ASSERT_EQ(ref.row_count, 10); + cbm_cypher_result_free(&ref); + + static const char *const budgeted[] = { + "MATCH (a)-[:CALLS]->(b) RETURN a.name, b.name", + "MATCH (a)-[:CALLS]->(b) RETURN a.name, b.name ORDER BY b.name", + "MATCH (a)-[:CALLS]->(b) RETURN *", + "MATCH (a)-[:CALLS]->(b)", + }; + for (size_t i = 0; i < sizeof(budgeted) / sizeof(budgeted[0]); i++) { + ASSERT_TRUE(stream_matches_single_pass(s, budgeted[i], 30, &ref)); + ASSERT_EQ(ref.row_count, 30); + ASSERT_TRUE(ref.truncated); + cbm_cypher_result_free(&ref); + } + cbm_store_close(s); + PASS(); +} + +/* A start-node scan that fails part-way fails the whole query: the rows read + * before the failure must not answer it as if they were all of them. Pages + * hold 7 rows at chunk 7; the store seam fails a page after earlier pages + * delivered rows (the all-nodes scan's first page is its empty NULL + * file_path pass, hence one more page before the fault). */ +TEST(cypher_stream_scan_failure_fails_query) { + static const struct { + const char *query; + int pages_before_fault; + } cases[] = { + {"MATCH (a:Function)-[:CALLS]->(b) RETURN a.name, b.name", 1}, + {"MATCH (a:Function)-[:CALLS]->(b) RETURN count(*)", 1}, + {"MATCH (n) RETURN n.name", 2}, + }; + cbm_store_t *s = setup_reference_store(); + cbm_cypher_result_t r; + for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) { + ASSERT_EQ(stream_run(s, cases[i].query, 0, 7, &r), 0); + ASSERT_GT(r.row_count, 0); + cbm_cypher_result_free(&r); + + cbm_store_node_scan_test_fail_page_after(cases[i].pages_before_fault); + int rc = stream_run(s, cases[i].query, 0, 7, &r); + cbm_store_node_scan_test_fail_page_after(-1); + ASSERT_TRUE(rc != 0); + ASSERT_NOT_NULL(r.error); + ASSERT_TRUE(strstr(r.error, "reading the graph failed") != NULL); + ASSERT_TRUE(strstr(r.error, "injected page failure") != NULL); + ASSERT_EQ(r.row_count, 0); + cbm_cypher_result_free(&r); + } + /* The failure does not outlive its query. */ + ASSERT_EQ(stream_run(s, cases[0].query, 0, 7, &r), 0); + ASSERT_GT(r.row_count, 0); + ASSERT_NULL(r.error); + cbm_cypher_result_free(&r); + cbm_store_close(s); + PASS(); +} + +/* The production chunk boundary itself: a start set of 2.5 chunks, a ring of + * calls plus five hubs whose caller groups straddle both boundaries. */ +TEST(cypher_stream_default_chunk_boundary) { + enum { STREAM_NODES = 2600, STREAM_HUBS = 5 }; + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "test", "/tmp/test"); + static int64_t ids[STREAM_NODES]; + for (int i = 0; i < STREAM_NODES; i++) { + char name[32]; + char qn[32]; + snprintf(name, sizeof(name), "fn_%d", i % 211); + snprintf(qn, sizeof(qn), "test.s%d", i); + cbm_node_t n = {.project = "test", + .label = "Function", + .name = name, + .qualified_name = qn, + .file_path = "ring.c", + .start_line = i % 37}; + ids[i] = cbm_store_upsert_node(s, &n); + } + for (int i = 0; i < STREAM_NODES; i++) { + cbm_edge_t ring = {.project = "test", + .source_id = ids[i], + .target_id = ids[(i + 1) % STREAM_NODES], + .type = "CALLS"}; + cbm_edge_t hub = {.project = "test", + .source_id = ids[i], + .target_id = ids[i % STREAM_HUBS], + .type = "CALLS"}; + cbm_store_insert_edge(s, &ring); + if (i >= STREAM_HUBS) { /* no hub self-loops */ + cbm_store_insert_edge(s, &hub); + } + } + static const char *const queries[] = { + "MATCH (a)-[:CALLS]->(b) RETURN count(*)", + "MATCH (a)-[:CALLS]->(b) RETURN a.qualified_name, b.qualified_name", + "MATCH (a:Function)-[:CALLS]->(b) RETURN b.qualified_name, count(a) AS c ORDER BY c DESC", + "MATCH (a)-[:CALLS]->(b) WITH b.name AS n, count(DISTINCT a.name) AS c RETURN n, c", + "MATCH (a)-[:CALLS]->(b) RETURN DISTINCT b.name LIMIT 300", + }; + for (size_t i = 0; i < sizeof(queries) / sizeof(queries[0]); i++) { + cbm_cypher_result_t ref; + ASSERT_TRUE(stream_matches_single_pass(s, queries[i], 0, &ref)); + ASSERT_GT(ref.row_count, 0); + cbm_cypher_result_free(&ref); + } + cbm_cypher_result_t ref; + ASSERT_TRUE(stream_matches_single_pass(s, queries[0], 0, &ref)); + ASSERT_STR_EQ(ref.rows[0][0], "5195"); /* 2600 ring + 2595 hub edges */ + cbm_cypher_result_free(&ref); + cbm_store_close(s); + PASS(); +} + /* ══════════════════════════════════════════════════════════════════ */ SUITE(cypher) { @@ -5823,4 +6335,13 @@ SUITE(cypher) { RUN_TEST(cypher_chain_with_unbound_alias_keeps_seed); RUN_TEST(cypher_many_union_branches); RUN_TEST(cypher_fifty_union_branches); + RUN_TEST(cypher_order_by_is_the_stable_reference_order); + RUN_TEST(cypher_distinct_keeps_first_occurrences); + RUN_TEST(cypher_grouping_keeps_first_seen_groups); + /* Streamed execution: chunked start set == single pass */ + RUN_TEST(cypher_stream_chunks_match_single_pass); + RUN_TEST(cypher_stream_optional_across_chunks); + RUN_TEST(cypher_stream_limit_and_max_rows_across_chunks); + RUN_TEST(cypher_stream_default_chunk_boundary); + RUN_TEST(cypher_stream_scan_failure_fails_query); } diff --git a/tests/test_daemon_application.c b/tests/test_daemon_application.c index e4f65adca4..a923043ca4 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_git_context.c b/tests/test_git_context.c index 20bea722f6..c508e25f34 100644 --- a/tests/test_git_context.c +++ b/tests/test_git_context.c @@ -440,9 +440,65 @@ TEST(githistory_ignores_inherited_repo_env) { #endif /* _WIN32 */ } +/* ── Branch-only resolve (list_projects) ─────────────────────────── + * The listing prints only the branch; cbm_git_branch_resolve gets it from the + * two git calls cbm_git_context_resolve uses for it, so both must agree. */ + +#ifndef _WIN32 +/* The branch resolver against the full context at `path`; NULL `want` = not git. */ +static bool branch_resolve_agrees(const char *path, const char *want) { + cbm_git_context_t ctx = {0}; + (void)cbm_git_context_resolve(path, &ctx); + bool is_git = false; + char branch[CBM_GIT_BRANCH_MAX]; + (void)cbm_git_branch_resolve(path, &is_git, branch, sizeof(branch)); + bool agrees = is_git == ctx.is_git && is_git == (want != NULL) && + (want ? (ctx.branch && strcmp(branch, want) == 0 && strcmp(ctx.branch, want) == 0) + : branch[0] == '\0'); + cbm_git_context_free(&ctx); + return agrees; +} +#endif /* _WIN32 */ + +TEST(git_branch_resolve_matches_full_context) { +#ifdef _WIN32 + SKIP_PLATFORM("git-based branch test not supported on Windows CI"); +#else + char plain[512]; + char *tmp = th_mktempdir("cbm_gitbranch_plain"); + if (!tmp) FAIL("th_mktempdir returned NULL"); + snprintf(plain, sizeof(plain), "%s", tmp); + tmp = th_mktempdir("cbm_gitbranch"); + if (!tmp) FAIL("th_mktempdir returned NULL"); + if (make_git_repo(tmp) != 0) { + th_rmtree(tmp); + th_rmtree(plain); + SKIP_PLATFORM("git not available to init a repo"); + } + char sub[600]; + snprintf(sub, sizeof(sub), "%s/sub", tmp); + ASSERT_EQ(th_mkdir_p(sub), 0); + char missing[600]; + snprintf(missing, sizeof(missing), "%s/missing", plain); + + ASSERT_TRUE(branch_resolve_agrees(plain, NULL)); + ASSERT_TRUE(branch_resolve_agrees(missing, NULL)); + ASSERT_EQ(git_run(tmp, "checkout -q -b feat/x"), 0); + ASSERT_TRUE(branch_resolve_agrees(tmp, "feat/x")); + ASSERT_TRUE(branch_resolve_agrees(sub, "feat/x")); + ASSERT_EQ(git_run(tmp, "checkout -q --detach"), 0); + ASSERT_TRUE(branch_resolve_agrees(tmp, "DETACHED")); + + th_rmtree(tmp); + th_rmtree(plain); + PASS(); +#endif /* _WIN32 */ +} + /* ── Suite ──────────────────────────────────────────────────────── */ SUITE(git_context) { + RUN_TEST(git_branch_resolve_matches_full_context); RUN_TEST(root_exists_non_ascii_directory); RUN_TEST(canonical_root_repo_root); RUN_TEST(canonical_root_subdir); diff --git a/tests/test_graph_mem.c b/tests/test_graph_mem.c new file mode 100644 index 0000000000..9e4020b61a --- /dev/null +++ b/tests/test_graph_mem.c @@ -0,0 +1,739 @@ +/* + * test_graph_mem.c — the in-memory adjacency layer (store/graph_mem.h). + * + * The contract is byte-identical output: a read-only query store answers + * BFS and degree filters from the layer, a writable store of the same file + * keeps the SQL path, and every row, order, hop, degree, total and flag must + * match. The fixture mixes a seeded random graph with the shapes that break a + * naive BFS: ties at one hop, a cycle back to the start, a self-loop, a + * diamond, duplicate IMPORTS edges, a cross-project edge and edgeless nodes. + */ +#include "test_framework.h" +#include "test_helpers.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +enum { + GMT_NODES = 40, + GMT_RANDOM_EDGES = 140, + GMT_THREADS = 4, + GMT_SEED = 20261014, + GMT_PATH_MAX = 512, +}; + +typedef struct { + char dir[GMT_PATH_MAX]; + char db[GMT_PATH_MAX]; + int64_t ids[GMT_NODES]; + int64_t other_id; /* the cross-project node */ +} gmt_fixture_t; + +static const char *const gmt_types[] = {"CALLS", "USAGE", "DATA_FLOWS", "IMPORTS"}; + +static uint32_t gmt_next(uint32_t *state) { + *state = *state * 1103515245U + 12345U; + return *state >> 8; +} + +static int64_t gmt_node(cbm_store_t *s, const char *project, int i, const char *name) { + char qn[64]; + char file[32]; + snprintf(qn, sizeof(qn), "%s.mod%d.n%02d", project, i % 5, i); + snprintf(file, sizeof(file), "src/m%d.c", i % 5); + cbm_node_t n = {.project = project, + .label = (i % 7 == 3) ? "Method" : "Function", + .name = name, + .qualified_name = qn, + .file_path = file, + .start_line = i, + .end_line = i + 3}; + return cbm_store_upsert_node(s, &n); +} + +static void gmt_edge(cbm_store_t *s, int64_t src, int64_t dst, const char *type, + const char *props) { + cbm_edge_t e = { + .project = "p", .source_id = src, .target_id = dst, .type = type, .properties_json = props}; + (void)cbm_store_insert_edge(s, &e); +} + +/* Build the fixture file and stamp a generation, the way a publish does. */ +static bool gmt_fixture_build(gmt_fixture_t *f) { + char *tmp = th_mktempdir("cbm_graph_mem"); + if (!tmp) { + return false; + } + snprintf(f->dir, sizeof(f->dir), "%s", tmp); + snprintf(f->db, sizeof(f->db), "%s/graph.db", f->dir); + cbm_store_t *s = cbm_store_open_path(f->db); + if (!s || cbm_store_upsert_project(s, "p", "/p") != CBM_STORE_OK || + cbm_store_upsert_project(s, "q", "/q") != CBM_STORE_OK) { + cbm_store_close(s); + return false; + } + for (int i = 0; i < GMT_NODES; i++) { + char name[16]; + /* Every fifth node shares one name: search's (name, id) order has ties. */ + snprintf(name, sizeof(name), (i % 5 == 4) ? "dup" : "n%02d", i); + f->ids[i] = gmt_node(s, "p", i, name); + } + f->other_id = gmt_node(s, "q", 99, "other"); + const int64_t *id = f->ids; + /* chain with a cycle back to the start (0 at hop 3), a self-loop, a diamond */ + gmt_edge(s, id[0], id[1], "CALLS", NULL); + gmt_edge(s, id[1], id[2], "CALLS", NULL); + gmt_edge(s, id[2], id[0], "CALLS", NULL); + gmt_edge(s, id[2], id[3], "CALLS", NULL); + gmt_edge(s, id[5], id[5], "CALLS", NULL); + gmt_edge(s, id[0], id[6], "CALLS", NULL); + gmt_edge(s, id[0], id[7], "CALLS", NULL); + gmt_edge(s, id[6], id[8], "CALLS", NULL); + gmt_edge(s, id[7], id[8], "CALLS", NULL); + /* two IMPORTS rows for one (source, target): distinct local names */ + gmt_edge(s, id[3], id[4], "IMPORTS", "{\"local_name\":\"a\"}"); + gmt_edge(s, id[3], id[4], "IMPORTS", "{\"local_name\":\"b\"}"); + /* a cross-project hop: BFS does not filter by project */ + gmt_edge(s, id[8], f->other_id, "CALLS", NULL); + gmt_edge(s, f->other_id, id[9], "CALLS", NULL); + /* a hub for the degree filters */ + for (int i = 10; i < 30; i++) { + gmt_edge(s, id[i], id[1], (i % 2) ? "CALLS" : "USAGE", NULL); + } + /* seeded random edges over the first 34 nodes; 34..39 stay edgeless */ + uint32_t state = GMT_SEED; + for (int k = 0; k < GMT_RANDOM_EDGES; k++) { + int a = (int)(gmt_next(&state) % 34U); + int b = (int)(gmt_next(&state) % 34U); + const char *type = gmt_types[gmt_next(&state) % 4U]; + gmt_edge(s, id[a], id[b], type, "{\"w\":1}"); + } + /* Stamp and seal, as a publish does: a sealed rollback-journal file is + * the only kind of DB the layer serves. */ + bool ok = cbm_store_generation_advance(s) == CBM_STORE_OK && + cbm_store_prepare_for_publish(s) == CBM_STORE_OK; + cbm_store_close(s); + return ok; +} + +static bool gmt_reseal(const gmt_fixture_t *f) { + cbm_store_t *w = cbm_store_open_path(f->db); + bool ok = w && cbm_store_prepare_for_publish(w) == CBM_STORE_OK; + cbm_store_close(w); + return ok; +} + +static void gmt_fixture_free(gmt_fixture_t *f) { + cbm_graph_mem_drop_idle(); + th_rmtree(f->dir); +} + +static bool gmt_str_eq(const char *a, const char *b) { + return (a == NULL && b == NULL) || (a && b && strcmp(a, b) == 0); +} + +static bool gmt_node_eq(const cbm_node_t *a, const cbm_node_t *b) { + return a->id == b->id && gmt_str_eq(a->project, b->project) && gmt_str_eq(a->label, b->label) && + gmt_str_eq(a->name, b->name) && gmt_str_eq(a->qualified_name, b->qualified_name) && + gmt_str_eq(a->file_path, b->file_path) && a->start_line == b->start_line && + a->end_line == b->end_line && gmt_str_eq(a->properties_json, b->properties_json); +} + +static bool gmt_edge_eq(const cbm_edge_info_t *a, const cbm_edge_info_t *b) { + return gmt_str_eq(a->from_name, b->from_name) && gmt_str_eq(a->to_name, b->to_name) && + gmt_str_eq(a->type, b->type) && a->confidence == b->confidence && + a->source_id == b->source_id && a->target_id == b->target_id && + gmt_str_eq(a->properties_json, b->properties_json); +} + +static bool gmt_traverse_eq(const cbm_traverse_result_t *a, const cbm_traverse_result_t *b) { + if (!gmt_node_eq(&a->root, &b->root) || a->visited_count != b->visited_count || + a->edge_count != b->edge_count || a->truncated != b->truncated || + a->edges_truncated != b->edges_truncated) { + return false; + } + for (int i = 0; i < a->visited_count; i++) { + if (a->visited[i].hop != b->visited[i].hop || + !gmt_node_eq(&a->visited[i].node, &b->visited[i].node)) { + return false; + } + } + for (int i = 0; i < a->edge_count; i++) { + if (!gmt_edge_eq(&a->edges[i], &b->edges[i])) { + return false; + } + } + return true; +} + +typedef struct { + const char *types[3]; + int count; +} gmt_typeset_t; + +static const gmt_typeset_t gmt_typesets[] = { + {{NULL}, 0}, /* the default: CALLS */ + {{"CALLS", "USAGE"}, 2}, + {{"DATA_FLOWS"}, 1}, + {{"IMPORTS"}, 1}, + {{"NO_SUCH_TYPE", "CALLS"}, 2}, +}; + +/* One BFS shape on both stores; prints the shape that diverged. */ +static bool gmt_bfs_same(cbm_store_t *sql, cbm_store_t *mem, int64_t start, const char *dir, + const gmt_typeset_t *ts, int depth, int max_results, int max_edges) { + cbm_traverse_result_t a = {0}; + cbm_traverse_result_t b = {0}; + const char **types = (const char **)ts->types; + int rca = cbm_store_bfs_with_edge_limit(sql, start, dir, types, ts->count, depth, max_results, + max_edges, &a); + int rcb = cbm_store_bfs_with_edge_limit(mem, start, dir, types, ts->count, depth, max_results, + max_edges, &b); + bool same = rca == rcb && gmt_traverse_eq(&a, &b); + if (!same) { + (void)fprintf(stderr, + " graph_mem divergence: start=%lld dir=%s types=%d depth=%d max=%d " + "edges=%d rc=%d/%d visited=%d/%d\n", + (long long)start, dir, ts->count, depth, max_results, max_edges, rca, rcb, + a.visited_count, b.visited_count); + } + cbm_store_traverse_free(&a); + cbm_store_traverse_free(&b); + return same; +} + +/* The runner initializes no memory budget unless the mem suite ran first, and + * without one no layer is ever built. Each test pins a budget the 3 KB fixture + * layer fits, collects its observations, and restores the budget BEFORE + * asserting (the hook's contract: a failed assert must not leak the budget). */ +static size_t gmt_budget_pin(size_t bytes) { + size_t saved = cbm_mem_budget(); + cbm_mem_set_budget_for_tests(bytes); + return saved; +} + +static size_t gmt_budget_roomy(void) { + return cbm_mem_charged() + ((size_t)1 << 30); +} + +static int gmt_visited(cbm_store_t *s, int64_t start) { + cbm_traverse_result_t tr = {0}; + int n = cbm_store_bfs(s, start, "outbound", NULL, 0, 3, 100, &tr) == CBM_STORE_OK + ? tr.visited_count + : -1; + cbm_store_traverse_free(&tr); + return n; +} + +/* One request: open a query store, walk from `start`, close. */ +static int gmt_request(const char *db, int64_t start) { + cbm_store_t *q = cbm_store_open_path_query(db); + int n = q ? gmt_visited(q, start) : -1; + cbm_store_close(q); + return n; +} + +/* The SQL reference: a read-only store like the one under test (a writable + * open would switch the sealed file to WAL), pinned to the SQL path because + * its one layer attempt runs under a budget no layer fits. */ +static cbm_store_t *gmt_open_sql_reference(const gmt_fixture_t *f) { + size_t saved = gmt_budget_pin(64); + cbm_store_t *s = cbm_store_open_path_query(f->db); + if (s) { + (void)gmt_visited(s, f->ids[0]); + } + cbm_mem_set_budget_for_tests(saved); + return s; +} + +typedef struct { + int shapes; + int diverged; + uint64_t builds; + bool start_back; /* the cycle 0->1->2->0 returns node 0 at hop 3 */ +} gmt_bfs_run_t; + +static void gmt_bfs_all_shapes(const gmt_fixture_t *f, cbm_store_t *sql, cbm_store_t *mem, + gmt_bfs_run_t *run) { + static const char *const dirs[] = {"outbound", "inbound"}; + static const int depths[] = {0, 1, 2, 3, 6}; + for (int n = 0; n <= GMT_NODES; n++) { + int64_t start = n < GMT_NODES ? f->ids[n] : f->other_id; + for (int d = 0; d < 2; d++) { + for (size_t t = 0; t < sizeof(gmt_typesets) / sizeof(gmt_typesets[0]); t++) { + for (size_t k = 0; k < sizeof(depths) / sizeof(depths[0]); k++) { + const gmt_typeset_t *ts = &gmt_typesets[t]; + /* lean, with edge data, and saturated (probe row + edge ceiling) */ + run->diverged += !gmt_bfs_same(sql, mem, start, dirs[d], ts, depths[k], 100, 0); + run->diverged += + !gmt_bfs_same(sql, mem, start, dirs[d], ts, depths[k], 100, 1000); + run->diverged += !gmt_bfs_same(sql, mem, start, dirs[d], ts, depths[k], 3, 4); + run->shapes += 3; + } + } + } + } + cbm_traverse_result_t tr = {0}; + if (cbm_store_bfs(mem, f->ids[0], "outbound", NULL, 0, 3, 100, &tr) == CBM_STORE_OK) { + for (int i = 0; i < tr.visited_count; i++) { + if (tr.visited[i].node.id == f->ids[0]) { + run->start_back = tr.visited[i].hop == 3; + } + } + } + cbm_store_traverse_free(&tr); +} + +TEST(graph_mem_bfs_matches_sql_on_every_shape) { + gmt_fixture_t f; + ASSERT_TRUE(gmt_fixture_build(&f)); + uint64_t builds = cbm_graph_mem_build_count(); + cbm_store_t *sql = gmt_open_sql_reference(&f); + size_t saved = gmt_budget_pin(gmt_budget_roomy()); + cbm_store_t *mem = cbm_store_open_path_query(f.db); + gmt_bfs_run_t run = {0}; + if (sql && mem) { + gmt_bfs_all_shapes(&f, sql, mem, &run); + } + run.builds = cbm_graph_mem_build_count() - builds; + cbm_mem_set_budget_for_tests(saved); + cbm_store_close(sql); + cbm_store_close(mem); + gmt_fixture_free(&f); + + ASSERT_GT(run.shapes, 0); + ASSERT_EQ(run.diverged, 0); + /* The layer really answered: one build, for the store under test only. */ + ASSERT_EQ(run.builds, 1); + ASSERT_TRUE(run.start_back); + PASS(); +} + +static bool gmt_search_same(cbm_store_t *sql, cbm_store_t *mem, cbm_search_params_t p) { + cbm_search_output_t a = {0}; + cbm_search_output_t b = {0}; + int rca = cbm_store_search(sql, &p, &a); + int rcb = cbm_store_search(mem, &p, &b); + bool same = rca == rcb && a.total == b.total && a.count == b.count; + for (int i = 0; same && i < a.count; i++) { + same = gmt_node_eq(&a.results[i].node, &b.results[i].node) && + a.results[i].in_degree == b.results[i].in_degree && + a.results[i].out_degree == b.results[i].out_degree; + } + if (!same) { + (void)fprintf(stderr, + " graph_mem search divergence: min=%d max=%d limit=%d offset=%d " + "total=%d/%d count=%d/%d\n", + p.min_degree, p.max_degree, p.limit, p.offset, a.total, b.total, a.count, + b.count); + } + cbm_store_search_free(&a); + cbm_store_search_free(&b); + return same; +} + +static int gmt_search_all_shapes(cbm_store_t *sql, cbm_store_t *mem) { + static const int ranges[][2] = {{1, -1}, {3, -1}, {10, -1}, {0, 0}, {-1, 0}, + {-1, 2}, {2, 5}, {0, -1}, {50, -1}}; + static const char *const labels[] = {NULL, "Function"}; + int diverged = 0; + for (size_t r = 0; r < sizeof(ranges) / sizeof(ranges[0]); r++) { + for (int l = 0; l < 2; l++) { + for (int page = 0; page < 3; page++) { + cbm_search_params_t p = {.project = "p", + .label = labels[l], + .min_degree = ranges[r][0], + .max_degree = ranges[r][1], + .limit = page == 2 ? 100 : 4, + .offset = page == 1 ? 3 : 0}; + diverged += !gmt_search_same(sql, mem, p); + } + } + } + cbm_search_params_t named = { + .project = "p", .name_pattern = "dup", .min_degree = 1, .max_degree = -1, .limit = 10}; + diverged += !gmt_search_same(sql, mem, named); + return diverged; +} + +TEST(graph_mem_degree_search_matches_sql) { + gmt_fixture_t f; + ASSERT_TRUE(gmt_fixture_build(&f)); + uint64_t builds = cbm_graph_mem_build_count(); + cbm_store_t *sql = gmt_open_sql_reference(&f); + size_t saved = gmt_budget_pin(gmt_budget_roomy()); + + /* No degree filter: the layer is neither needed nor built. */ + cbm_store_t *plain = cbm_store_open_path_query(f.db); + cbm_search_params_t nofilter = {.project = "p", .min_degree = -1, .max_degree = -1}; + cbm_search_output_t out = {0}; + int plain_rc = plain ? cbm_store_search(plain, &nofilter, &out) : CBM_STORE_ERR; + cbm_store_search_free(&out); + cbm_store_close(plain); + uint64_t plain_builds = cbm_graph_mem_build_count() - builds; + + cbm_store_t *mem = cbm_store_open_path_query(f.db); + int diverged = sql && mem ? gmt_search_all_shapes(sql, mem) : -1; + uint64_t filter_builds = cbm_graph_mem_build_count() - builds; + cbm_mem_set_budget_for_tests(saved); + cbm_store_close(sql); + cbm_store_close(mem); + gmt_fixture_free(&f); + + ASSERT_EQ(plain_rc, CBM_STORE_OK); + ASSERT_EQ(plain_builds, 0); + ASSERT_EQ(diverged, 0); + ASSERT_EQ(filter_builds, 1); + PASS(); +} + +TEST(graph_mem_generation_change_invalidates) { + gmt_fixture_t f; + ASSERT_TRUE(gmt_fixture_build(&f)); + uint64_t builds = cbm_graph_mem_build_count(); + int64_t leaf = f.ids[36]; /* edgeless */ + size_t saved = gmt_budget_pin(gmt_budget_roomy()); + + int first = gmt_request(f.db, leaf); + uint64_t first_builds = cbm_graph_mem_build_count() - builds; + /* A later request store on the same version reuses the layer. */ + int second = gmt_request(f.db, leaf); + uint64_t second_builds = cbm_graph_mem_build_count() - builds; + + /* A mutation stamped with a new generation and sealed: a publish. */ + cbm_store_t *w = cbm_store_open_path(f.db); + if (w) { + gmt_edge(w, leaf, f.ids[37], "CALLS", NULL); + } + bool published = w && cbm_store_generation_advance(w) == CBM_STORE_OK && + cbm_store_prepare_for_publish(w) == CBM_STORE_OK; + cbm_store_close(w); + + cbm_store_t *q3 = cbm_store_open_path_query(f.db); + int third = q3 ? gmt_visited(q3, leaf) : -1; + cbm_store_t *sql = gmt_open_sql_reference(&f); + int third_sql = sql ? gmt_visited(sql, leaf) : -1; + bool same = + sql && q3 && gmt_bfs_same(sql, q3, f.ids[0], "outbound", &gmt_typesets[1], 6, 100, 1000); + uint64_t third_builds = cbm_graph_mem_build_count() - builds; + /* The old version's layer is retired: one layer for this file. */ + int cached = cbm_graph_mem_cached_count(); + cbm_store_close(sql); + cbm_store_close(q3); + cbm_mem_set_budget_for_tests(saved); + gmt_fixture_free(&f); + int cached_after = cbm_graph_mem_cached_count(); + + ASSERT_EQ(first, 0); + ASSERT_EQ(first_builds, 1); + ASSERT_EQ(second, 0); + ASSERT_EQ(second_builds, 1); + ASSERT_TRUE(published); + ASSERT_EQ(third, 1); + ASSERT_EQ(third_sql, 1); + ASSERT_TRUE(same); + ASSERT_EQ(third_builds, 2); + ASSERT_EQ(cached, 1); + ASSERT_EQ(cached_after, 0); + PASS(); +} + +/* The cross-repo shape: edges written straight into a published file, with + * NO new generation. A layer of the old content must never answer for it — + * not while the file is in WAL mode, and not after it is sealed again. */ +TEST(graph_mem_in_place_write_never_serves_stale) { + gmt_fixture_t f; + ASSERT_TRUE(gmt_fixture_build(&f)); + uint64_t builds = cbm_graph_mem_build_count(); + int64_t leaf = f.ids[36]; /* edgeless */ + size_t saved = gmt_budget_pin(gmt_budget_roomy()); + + int before = gmt_request(f.db, leaf); + uint64_t before_builds = cbm_graph_mem_build_count() - builds; + + /* The writer stays open across two commits, so no checkpoint lands + * between them: the main file — header included — is the same for both. + * Only refusing WAL mode keeps the second request from a stale answer. */ + cbm_store_t *w = cbm_store_open_path(f.db); + if (w) { + gmt_edge(w, leaf, f.ids[37], "CALLS", NULL); + } + int wal_one = gmt_request(f.db, leaf); + if (w) { + gmt_edge(w, leaf, f.ids[38], "CALLS", NULL); + } + int wal_two = gmt_request(f.db, leaf); + uint64_t wal_builds = cbm_graph_mem_build_count() - builds; + cbm_store_close(w); + + bool resealed = gmt_reseal(&f); + int after = gmt_request(f.db, leaf); + uint64_t after_builds = cbm_graph_mem_build_count() - builds; + int cached = cbm_graph_mem_cached_count(); + cbm_mem_set_budget_for_tests(saved); + gmt_fixture_free(&f); + + ASSERT_NOT_NULL(w); + ASSERT_EQ(before, 0); + ASSERT_EQ(before_builds, 1); + /* WAL mode: the SQL path answers, with each new edge. */ + ASSERT_EQ(wal_one, 1); + ASSERT_EQ(wal_two, 2); + ASSERT_EQ(wal_builds, 1); + /* Sealed again under the SAME generation: a new version, a new layer. */ + ASSERT_TRUE(resealed); + ASSERT_EQ(after, 2); + ASSERT_EQ(after_builds, 2); + ASSERT_EQ(cached, 1); + PASS(); +} + +TEST(graph_mem_over_budget_keeps_sql_path) { + gmt_fixture_t f; + ASSERT_TRUE(gmt_fixture_build(&f)); + uint64_t builds = cbm_graph_mem_build_count(); + cbm_store_t *sql = gmt_open_sql_reference(&f); + /* A budget whose quarter cannot hold the layer: no build, SQL answers. The + * file stays sealed, so the budget is the only reason to refuse. */ + size_t saved = gmt_budget_pin(64); + cbm_store_t *mem = cbm_store_open_path_query(f.db); + bool same = + sql && mem && gmt_bfs_same(sql, mem, f.ids[0], "outbound", &gmt_typesets[1], 3, 100, 1000); + cbm_search_params_t p = {.project = "p", .min_degree = 3, .max_degree = -1, .limit = 50}; + bool same_search = sql && mem && gmt_search_same(sql, mem, p); + uint64_t builds_after = cbm_graph_mem_build_count(); + int cached = cbm_graph_mem_cached_count(); + cbm_mem_set_budget_for_tests(saved); + cbm_store_close(sql); + cbm_store_close(mem); + gmt_fixture_free(&f); + + ASSERT_TRUE(same); + ASSERT_TRUE(same_search); + ASSERT_EQ(builds_after, builds); + ASSERT_EQ(cached, 0); + PASS(); +} + +TEST(graph_mem_legacy_generation_keeps_sql_path) { + gmt_fixture_t f; + ASSERT_TRUE(gmt_fixture_build(&f)); + /* A DB that predates generation tracking cannot detect staleness. The raw + * connection leaves the file in rollback mode: legacy is the only reason + * to refuse. */ + sqlite3 *raw = NULL; + ASSERT_EQ(sqlite3_open(f.db, &raw), SQLITE_OK); + ASSERT_EQ(sqlite3_exec(raw, "DROP TABLE store_meta;", NULL, NULL, NULL), SQLITE_OK); + sqlite3_close(raw); + uint64_t builds = cbm_graph_mem_build_count(); + size_t saved = gmt_budget_pin(gmt_budget_roomy()); + int visited = gmt_request(f.db, f.ids[0]); + uint64_t builds_after = cbm_graph_mem_build_count(); + cbm_mem_set_budget_for_tests(saved); + gmt_fixture_free(&f); + + ASSERT_GT(visited, 0); + ASSERT_EQ(builds_after, builds); + PASS(); +} + +typedef struct { + const char *db; + int64_t start; + const cbm_traverse_result_t *expect; + bool same; +} gmt_thread_arg_t; + +static void *gmt_thread_main(void *arg) { + gmt_thread_arg_t *t = arg; + cbm_store_t *s = cbm_store_open_path_query(t->db); + cbm_traverse_result_t got = {0}; + t->same = s && cbm_store_bfs(s, t->start, "inbound", NULL, 0, 6, 100, &got) == CBM_STORE_OK && + gmt_traverse_eq(t->expect, &got); + cbm_store_traverse_free(&got); + cbm_store_close(s); + return NULL; +} + +TEST(graph_mem_concurrent_requests_build_once) { + gmt_fixture_t f; + ASSERT_TRUE(gmt_fixture_build(&f)); + uint64_t builds = cbm_graph_mem_build_count(); + cbm_store_t *sql = gmt_open_sql_reference(&f); + cbm_traverse_result_t expect = {0}; + int expect_rc = + sql ? cbm_store_bfs(sql, f.ids[1], "inbound", NULL, 0, 6, 100, &expect) : CBM_STORE_ERR; + size_t saved = gmt_budget_pin(gmt_budget_roomy()); + cbm_thread_t threads[GMT_THREADS]; + gmt_thread_arg_t args[GMT_THREADS]; + int started = 0; + for (int i = 0; i < GMT_THREADS; i++) { + args[i] = (gmt_thread_arg_t){.db = f.db, .start = f.ids[1], .expect = &expect}; + started += cbm_thread_create(&threads[i], 0, gmt_thread_main, &args[i]) == 0; + } + int joined = 0; + for (int i = 0; i < started; i++) { + joined += cbm_thread_join(&threads[i]) == 0; + } + uint64_t built = cbm_graph_mem_build_count() - builds; + cbm_mem_set_budget_for_tests(saved); + int same = 0; + for (int i = 0; i < started; i++) { + same += args[i].same; + } + cbm_store_traverse_free(&expect); + cbm_store_close(sql); + gmt_fixture_free(&f); + + ASSERT_EQ(expect_rc, CBM_STORE_OK); + ASSERT_EQ(started, GMT_THREADS); + ASSERT_EQ(joined, GMT_THREADS); + ASSERT_EQ(same, GMT_THREADS); + /* A request meeting another's build takes SQL; nobody builds twice. */ + ASSERT_EQ(built, 1); + 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); + RUN_TEST(graph_mem_generation_change_invalidates); + RUN_TEST(graph_mem_in_place_write_never_serves_stale); + 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_main.c b/tests/test_main.c index 962fc1482d..5ff6bdb9f2 100644 --- a/tests/test_main.c +++ b/tests/test_main.c @@ -1732,6 +1732,7 @@ extern void suite_store_edges(void); extern void suite_store_impact(void); extern void suite_store_scope(void); extern void suite_store_graph_digest(void); +extern void suite_graph_mem(void); extern void suite_test_impact(void); extern void suite_test_impact_profiles(void); extern void suite_test_impact_changes(void); @@ -2194,6 +2195,7 @@ int main(int argc, char **argv) { RUN_SELECTED_SUITE(store_impact); RUN_SELECTED_SUITE(store_scope); RUN_SELECTED_SUITE(store_graph_digest); + RUN_SELECTED_SUITE(graph_mem); RUN_SELECTED_SUITE(test_impact); RUN_SELECTED_SUITE(test_impact_profiles); RUN_SELECTED_SUITE(test_impact_changes); diff --git a/tests/test_mcp.c b/tests/test_mcp.c index 907471f306..ebf24a827f 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -12205,6 +12205,16 @@ TEST(search_code_windows_scope_prefilter_removes_pipeline_filter) { #endif } +/* An indexed project's scan runs in process; the supervised runner now serves + * the unindexed recursive fallback and a Windows file_pattern it cannot + * prefilter. The supervision tests below pin that runner through the command + * override, with a command that does nothing: their probes act at spawn. */ +#ifdef _WIN32 +static const char MCP_SUPERVISED_SCAN_NOOP[] = "cd ."; +#else +static const char MCP_SUPERVISED_SCAN_NOOP[] = ":"; +#endif + TEST(search_code_cancel_cleans_supervised_scan) { mcp_search_cache_t cache; ASSERT_TRUE(mcp_search_cache_open(&cache, "cbm-search-cancel")); @@ -12213,6 +12223,7 @@ TEST(search_code_cancel_cleans_supervised_scan) { cbm_mcp_server_t *srv = setup_prefilter_server(tmp, sizeof(tmp), src_path, sizeof(src_path), vendor_path, sizeof(vendor_path)); ASSERT_NOT_NULL(srv); + cbm_mcp_server_set_search_scan_command_for_test(srv, MCP_SUPERVISED_SCAN_NOOP); mcp_search_command_probe_t probe = { .server = srv, .cancel_on_call = true, @@ -12490,6 +12501,7 @@ TEST(search_code_scan_deadline_precedes_output_limit) { cbm_mcp_server_t *srv = setup_prefilter_server(tmp, sizeof(tmp), src_path, sizeof(src_path), vendor_path, sizeof(vendor_path)); ASSERT_NOT_NULL(srv); + cbm_mcp_server_set_search_scan_command_for_test(srv, MCP_SUPERVISED_SCAN_NOOP); mcp_search_command_probe_t probe = {.fill_output_directory = logs, .delay_ms = 1100}; cbm_mcp_server_set_command_test_hook(srv, mcp_search_command_hook_probe, &probe); cbm_mcp_server_set_search_scan_timeout_for_test(srv, 1000, true); @@ -12521,6 +12533,7 @@ TEST(search_code_scan_hook_rejection_is_contained_and_cleans_up) { vendor_path, sizeof(vendor_path)); ASSERT_NOT_NULL(srv); cbm_mcp_server_set_search_scratch_dir_for_test(srv, cache.path); + cbm_mcp_server_set_search_scan_command_for_test(srv, MCP_SUPERVISED_SCAN_NOOP); mcp_search_command_probe_t probe = {.reject = true, .scratch_directory = cache.path}; cbm_mcp_server_set_command_test_hook(srv, mcp_search_command_hook_probe, &probe); @@ -24546,6 +24559,272 @@ TEST(search_code_drops_walker_hits_that_resolve_outside_root) { #endif } +/* ── In-process search_code scan ─────────────────────────────────── + * The scoped scan used to shell out to grep (POSIX) or Select-String + * (Windows) and now runs in process. These cases pin its records to the + * `grep -Hn` shape the subprocess wrote: path:line:content on POSIX, the + * same fields TAB-separated on Windows. */ + +#ifdef _WIN32 +#define SCAN_FS "\t" +#define SCAN_RS '\n' +#else +#define SCAN_FS ":" +#define SCAN_RS '\0' +#endif + +typedef struct { + char dir[256]; + char list[512]; + char out[512]; + char files[4][512]; + int count; +} scan_fixture_t; + +static bool scan_fixture_open(scan_fixture_t *fx) { + memset(fx, 0, sizeof(*fx)); + const char *tmp = th_mktempdir("cbm_scan"); + if (!tmp) { + return false; + } + snprintf(fx->dir, sizeof(fx->dir), "%s", tmp); + snprintf(fx->list, sizeof(fx->list), "%s/list", tmp); + snprintf(fx->out, sizeof(fx->out), "%s/out", tmp); + return true; +} + +/* Add a file holding exactly `length` bytes (NUL bytes allowed). */ +static bool scan_fixture_add(scan_fixture_t *fx, const char *name, const char *data, + size_t length) { + if (fx->count >= 4) { + return false; + } + /* Format from a copy: GCC's -Werror=restrict rejects a source and a + * destination inside the same struct. */ + char dir[sizeof(fx->dir)]; + memcpy(dir, fx->dir, sizeof(dir)); + char *path = fx->files[fx->count]; + snprintf(path, sizeof(fx->files[0]), "%s/%s", dir, name); + FILE *fp = cbm_fopen(path, "wb"); + if (!fp) { + return false; + } + bool ok = fwrite(data, 1, length, fp) == length; + ok = fclose(fp) == 0 && ok; + fx->count += ok ? 1 : 0; + return ok; +} + +/* Write the scoped file list as search_code does (NUL records on POSIX, + * lines on Windows); `extra` names one more path that need not exist. */ +static bool scan_fixture_list(const scan_fixture_t *fx, const char *extra) { + FILE *fp = cbm_fopen(fx->list, "wb"); + if (!fp) { + return false; + } + bool ok = true; + for (int i = 0; i < fx->count; i++) { + ok = ok && fputs(fx->files[i], fp) != EOF && fputc(SCAN_RS, fp) != EOF; + } + if (extra) { + ok = ok && fputs(extra, fp) != EOF && fputc(SCAN_RS, fp) != EOF; + } + return fclose(fp) == 0 && ok; +} + +/* Run the scan; returns the records it wrote (caller frees), CRs kept. */ +static char *scan_fixture_run(const scan_fixture_t *fx, const char *pattern, bool use_regex, + size_t output_limit, int *cause) { + *cause = cbm_mcp_search_scan_for_test(NULL, fx->list, pattern, use_regex, fx->out, + output_limit, UINT64_MAX); + FILE *fp = cbm_fopen(fx->out, "rb"); + if (!fp) { + return NULL; + } + char *text = calloc(CBM_SZ_64K, 1); + size_t length = text ? fread(text, 1, CBM_SZ_64K - 1, fp) : 0; + (void)fclose(fp); + if (text) { + text[length] = '\0'; + } + return text; +} + +/* Append one expected record to `buf`. */ +static void scan_expect(char *buf, size_t cap, const char *path, int line, const char *content) { + size_t used = strlen(buf); + snprintf(buf + used, cap - used, "%s" SCAN_FS "%d" SCAN_FS "%s\n", path, line, content); +} + +TEST(search_code_scan_in_process_writes_grep_records) { + scan_fixture_t fx; + ASSERT_TRUE(scan_fixture_open(&fx)); + /* The last line has no newline and line 3 is empty. */ + static const char a[] = "alpha\nbeta gamma\n\nalpha beta\nlast alpha"; + static const char b[] = "nothing\nalpha\n"; + ASSERT_TRUE(scan_fixture_add(&fx, "a.txt", a, sizeof(a) - 1)); + ASSERT_TRUE(scan_fixture_add(&fx, "b.txt", b, sizeof(b) - 1)); + ASSERT_TRUE(scan_fixture_list(&fx, NULL)); + const char *pa = fx.files[0]; + const char *pb = fx.files[1]; + char want[4096]; + int cause = -1; + + /* Literal: every matching line, file order, then line order. */ + want[0] = '\0'; + scan_expect(want, sizeof(want), pa, 1, "alpha"); + scan_expect(want, sizeof(want), pa, 4, "alpha beta"); + scan_expect(want, sizeof(want), pa, 5, "last alpha"); + scan_expect(want, sizeof(want), pb, 2, "alpha"); + char *got = scan_fixture_run(&fx, "alpha", false, 0, &cause); + ASSERT_NOT_NULL(got); + ASSERT_EQ(cause, 0); + ASSERT_STR_EQ(got, want); + free(got); + + /* A multi-line pattern is one pattern per line, as with grep -f. */ + want[0] = '\0'; + scan_expect(want, sizeof(want), pa, 2, "beta gamma"); + scan_expect(want, sizeof(want), pa, 4, "alpha beta"); + got = scan_fixture_run(&fx, "gamma\nbeta", false, 0, &cause); + ASSERT_NOT_NULL(got); + ASSERT_EQ(cause, 0); + ASSERT_STR_EQ(got, want); + free(got); + + /* An empty pattern matches every line, the empty one included. */ + want[0] = '\0'; + scan_expect(want, sizeof(want), pa, 1, "alpha"); + scan_expect(want, sizeof(want), pa, 2, "beta gamma"); + scan_expect(want, sizeof(want), pa, 3, ""); + scan_expect(want, sizeof(want), pa, 4, "alpha beta"); + scan_expect(want, sizeof(want), pa, 5, "last alpha"); + scan_expect(want, sizeof(want), pb, 1, "nothing"); + scan_expect(want, sizeof(want), pb, 2, "alpha"); + got = scan_fixture_run(&fx, "", false, 0, &cause); + ASSERT_NOT_NULL(got); + ASSERT_EQ(cause, 0); + ASSERT_STR_EQ(got, want); + free(got); + + /* Extended regex, anchored per line. */ + want[0] = '\0'; + scan_expect(want, sizeof(want), pa, 1, "alpha"); + scan_expect(want, sizeof(want), pa, 4, "alpha beta"); + scan_expect(want, sizeof(want), pa, 5, "last alpha"); + scan_expect(want, sizeof(want), pb, 2, "alpha"); + got = scan_fixture_run(&fx, "^(alpha|last)", true, 0, &cause); + ASSERT_NOT_NULL(got); + ASSERT_EQ(cause, 0); + ASSERT_STR_EQ(got, want); + free(got); + + /* No match: success with no records. */ + got = scan_fixture_run(&fx, "zzqqxx", false, 0, &cause); + ASSERT_NOT_NULL(got); + ASSERT_EQ(cause, 0); + ASSERT_STR_EQ(got, ""); + free(got); + th_rmtree(fx.dir); + PASS(); +} + +TEST(search_code_scan_in_process_binary_and_crlf) { + scan_fixture_t fx; + ASSERT_TRUE(scan_fixture_open(&fx)); + /* A NUL in the first 32 KiB marks the whole file binary: no records. */ + static const char bin[] = "alpha\n\0alpha\n"; + ASSERT_TRUE(scan_fixture_add(&fx, "bin.dat", bin, sizeof(bin) - 1)); + /* A later NUL ends the text at the last complete line before it. */ + enum { FILLER_LINES = 4000 }; /* 10 bytes each: past the 32 KiB probe */ + size_t late_cap = (size_t)FILLER_LINES * 10 + 64; + char *late = malloc(late_cap); + ASSERT_NOT_NULL(late); + size_t used = 0; + for (int i = 0; i < FILLER_LINES; i++) { + memcpy(late + used, "xxxxxxxxx\n", 10); + used += 10; + } + static const char tail[] = "alpha one\nalpha two\0\nalpha three\n"; + memcpy(late + used, tail, sizeof(tail) - 1); + used += sizeof(tail) - 1; + ASSERT_TRUE(scan_fixture_add(&fx, "late.txt", late, used)); + free(late); + static const char crlf[] = "alpha\r\nbeta\r\n"; + ASSERT_TRUE(scan_fixture_add(&fx, "crlf.txt", crlf, sizeof(crlf) - 1)); + ASSERT_TRUE(scan_fixture_list(&fx, NULL)); + char want[4096]; + int cause = -1; + + /* The record keeps the line's CR; the result parser trims it. */ + want[0] = '\0'; + scan_expect(want, sizeof(want), fx.files[1], FILLER_LINES + 1, "alpha one"); + scan_expect(want, sizeof(want), fx.files[2], 1, "alpha\r"); + char *got = scan_fixture_run(&fx, "alpha", false, 0, &cause); + ASSERT_NOT_NULL(got); + ASSERT_EQ(cause, 0); + ASSERT_STR_EQ(got, want); + free(got); + + /* `$` before a CR: grep on POSIX sees the CR and does not match; + * Select-String's Line on Windows had no CR and did. */ + want[0] = '\0'; +#ifdef _WIN32 + scan_expect(want, sizeof(want), fx.files[2], 2, "beta\r"); +#endif + got = scan_fixture_run(&fx, "^beta$", true, 0, &cause); + ASSERT_NOT_NULL(got); + ASSERT_EQ(cause, 0); + ASSERT_STR_EQ(got, want); + free(got); + th_rmtree(fx.dir); + PASS(); +} + +TEST(search_code_scan_in_process_limits_and_failures) { + scan_fixture_t fx; + ASSERT_TRUE(scan_fixture_open(&fx)); + static const char a[] = "alpha\nalpha\nalpha\n"; + ASSERT_TRUE(scan_fixture_add(&fx, "a.txt", a, sizeof(a) - 1)); + ASSERT_TRUE(scan_fixture_list(&fx, NULL)); + int cause = -1; + + /* Past the output limit: the output-limit cause (3), not a failure. */ + char *got = scan_fixture_run(&fx, "alpha", false, 8, &cause); + free(got); + ASSERT_EQ(cause, 3); + + /* An invalid regex is a command failure (1), as grep's exit 2 was. */ + got = scan_fixture_run(&fx, "(", true, 0, &cause); + free(got); + ASSERT_EQ(cause, 1); + + /* A passed deadline stops the scan before the next file (4); a request + * cancelled under the server stops it too, and takes precedence (5). */ + ASSERT_EQ(cbm_mcp_search_scan_for_test(NULL, fx.list, "alpha", false, fx.out, 0, 0), 4); + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + ASSERT_TRUE(cbm_mcp_server_request_scope_begin(srv)); + ASSERT_TRUE(cbm_mcp_server_cancel_active(srv)); + cause = cbm_mcp_search_scan_for_test(srv, fx.list, "alpha", false, fx.out, 0, 0); + cbm_mcp_server_request_scope_end(srv); + cbm_mcp_server_free(srv); + ASSERT_EQ(cause, 5); + + /* A listed file that cannot be read is a command failure (1) as well. */ + char missing[600]; + snprintf(missing, sizeof(missing), "%s/missing.txt", fx.dir); + ASSERT_TRUE(scan_fixture_list(&fx, missing)); + got = scan_fixture_run(&fx, "alpha", false, 0, &cause); + free(got); + ASSERT_EQ(cause, 1); + th_rmtree(fx.dir); + PASS(); +} + +#undef SCAN_FS +#undef SCAN_RS + SUITE(mcp) { RUN_TEST(index_repository_async_and_status_refused_without_daemon_issue2144); RUN_TEST(index_repository_schema_documents_async_polling_issue2144); @@ -24813,6 +25092,9 @@ SUITE(mcp) { RUN_TEST(search_code_scan_deadline_override_is_per_server); RUN_TEST(search_code_scan_setup_failures_respect_cause_precedence); RUN_TEST(search_code_scan_live_child_deadline_is_bounded_and_fails_closed); + RUN_TEST(search_code_scan_in_process_writes_grep_records); + RUN_TEST(search_code_scan_in_process_binary_and_crlf); + RUN_TEST(search_code_scan_in_process_limits_and_failures); RUN_TEST(search_code_scan_cancellation_precedes_zero_deadline); RUN_TEST(search_code_scan_deadline_precedes_output_limit); RUN_TEST(search_code_scan_hook_rejection_is_contained_and_cleans_up); diff --git a/tests/test_mem.c b/tests/test_mem.c index 11b3e2c2c3..4c9e9af41b 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); diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 434ee1ba7a..86d59454ca 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -5759,6 +5759,177 @@ TEST(pipeline_concurrent_sweep_must_not_remove_inflight_stage) { PASS(); } +static int live_noop_run(const char *repo, const char *db_path, cbm_incremental_route_t *route) { + cbm_pipeline_incremental_test_reset_faults(); + *route = CBM_INCREMENTAL_ROUTE_NONE; + cbm_pipeline_t *p = cbm_pipeline_new(repo, db_path, CBM_MODE_FULL); + if (!p) { + return CBM_NOT_FOUND; + } + int rc = cbm_pipeline_run(p); + *route = cbm_pipeline_incremental_test_last_route(); + cbm_pipeline_free(p); + return rc; +} + +/* An unchanged re-index proves the exact no-op on the LIVE generation. It + * never copies the database into a stage, seals the copy and renames it back + * (on a kernel-scale index that copy alone was a 19 GB write per call): the + * live file keeps its identity, size and mtime, no stage artifact remains, + * and the graph still serves. */ +TEST(pipeline_unchanged_reindex_leaves_live_generation_untouched) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_live_noop_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); + write_temp_file(tmp, "generation.py", "def StableGeneration():\n return 1\n"); + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/generation.db", tmp); + char *project = cbm_project_name_from_path(tmp); + ASSERT_NOT_NULL(project); + + cbm_incremental_route_t first_route; + int first_rc = live_noop_run(tmp, db_path, &first_route); + cbm_path_info_t before = {0}; + struct stat before_st; + bool before_ok = + cbm_path_info_utf8(db_path, &before) == CBM_PATH_INFO_OK && stat(db_path, &before_st) == 0; + + cbm_incremental_route_t noop_route; + int noop_rc = live_noop_run(tmp, db_path, &noop_route); + cbm_path_info_t after = {0}; + struct stat after_st; + bool after_ok = + cbm_path_info_utf8(db_path, &after) == CBM_PATH_INFO_OK && stat(db_path, &after_st) == 0; + int defined_count = -1; + int absent_count = -1; + observe_named_generation(db_path, project, "StableGeneration", "NeverDefined", &defined_count, + &absent_count); + int stage_count = count_generation_stage_artifacts(tmp, "generation.db"); + cbm_pipeline_incremental_test_reset_faults(); + free(project); + th_rmtree(tmp); + + ASSERT_EQ(first_rc, 0); + ASSERT_TRUE(before_ok); + ASSERT_EQ(noop_rc, 0); + ASSERT_EQ(noop_route, CBM_INCREMENTAL_ROUTE_NOOP); + ASSERT_TRUE(after_ok); + ASSERT_TRUE(after_st.st_ino == before_st.st_ino); + ASSERT_TRUE(after.mtime_ns == before.mtime_ns); + ASSERT_TRUE(after.size == before.size); + ASSERT_EQ(stage_count, 0); + ASSERT_EQ(defined_count, 1); + ASSERT_EQ(absent_count, 0); + PASS(); +} + +/* The live proof answers only for an exact manifest. Every real change -- a + * same-size edit that keeps the original mtime, an addition, a deletion and + * a rename -- still routes past it into a rebuild that the graph shows, and + * only the unchanged re-index after them takes the no-op. */ +TEST(pipeline_live_noop_proof_never_hides_a_change) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_live_noop_change_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); + write_temp_file(tmp, "alpha.py", "def AlphaDefined():\n return 1\n"); + write_temp_file(tmp, "beta.py", "def BetaDefined():\n return 2\n"); + char db_path[512]; + char alpha_path[512]; + char beta_path[512]; + char gamma_path[512]; + char delta_path[512]; + snprintf(db_path, sizeof(db_path), "%s/generation.db", tmp); + snprintf(alpha_path, sizeof(alpha_path), "%s/alpha.py", tmp); + snprintf(beta_path, sizeof(beta_path), "%s/beta.py", tmp); + snprintf(gamma_path, sizeof(gamma_path), "%s/gamma.py", tmp); + snprintf(delta_path, sizeof(delta_path), "%s/delta.py", tmp); + char *project = cbm_project_name_from_path(tmp); + ASSERT_NOT_NULL(project); + + cbm_incremental_route_t initial_route; + int initial_rc = live_noop_run(tmp, db_path, &initial_route); + + /* Same size, original mtime restored: only the bytes differ. */ + cbm_path_info_t alpha_info = {0}; + bool alpha_stat_ok = cbm_path_info_utf8(alpha_path, &alpha_info) == CBM_PATH_INFO_OK; + write_temp_file(tmp, "alpha.py", "def AlphbDefined():\n return 1\n"); + int mtime_rc = pipeline_test_set_mtime(alpha_path, (time_t)(alpha_info.mtime_ns / 1000000000), + (long)(alpha_info.mtime_ns % 1000000000)); + cbm_path_info_t edited_info = {0}; + bool edited_same_stat = cbm_path_info_utf8(alpha_path, &edited_info) == CBM_PATH_INFO_OK && + edited_info.size == alpha_info.size && + edited_info.mtime_ns == alpha_info.mtime_ns; + cbm_incremental_route_t modify_route; + int modify_rc = live_noop_run(tmp, db_path, &modify_route); + int old_alpha = -1; + int new_alpha = -1; + observe_named_generation(db_path, project, "AlphaDefined", "AlphbDefined", &old_alpha, + &new_alpha); + + write_temp_file(tmp, "gamma.py", "def GammaDefined():\n return 3\n"); + cbm_incremental_route_t add_route; + int add_rc = live_noop_run(tmp, db_path, &add_route); + int gamma_added = -1; + int unused = -1; + observe_named_generation(db_path, project, "GammaDefined", "NeverDefined", &gamma_added, + &unused); + + int unlink_rc = cbm_unlink(beta_path); + cbm_incremental_route_t delete_route; + int delete_rc = live_noop_run(tmp, db_path, &delete_route); + int beta_left = -1; + observe_named_generation(db_path, project, "BetaDefined", "NeverDefined", &beta_left, &unused); + + int rename_rc = cbm_rename_replace(gamma_path, delta_path); + cbm_incremental_route_t rename_route; + int renamed_rc = live_noop_run(tmp, db_path, &rename_route); + int gamma_renamed = -1; + observe_named_generation(db_path, project, "GammaDefined", "NeverDefined", &gamma_renamed, + &unused); + int old_path_hash = CBM_STORE_OK; + int new_path_hash = CBM_STORE_NOT_FOUND; + cbm_store_t *store = cbm_store_open_path_query(db_path); + if (store) { + cbm_file_hash_t hash = {0}; + old_path_hash = cbm_store_get_file_hash(store, project, "gamma.py", &hash); + cbm_store_clear_file_hash(&hash); + new_path_hash = cbm_store_get_file_hash(store, project, "delta.py", &hash); + cbm_store_clear_file_hash(&hash); + cbm_store_close(store); + } + + cbm_incremental_route_t final_route; + int final_rc = live_noop_run(tmp, db_path, &final_route); + cbm_pipeline_incremental_test_reset_faults(); + free(project); + th_rmtree(tmp); + + ASSERT_EQ(initial_rc, 0); + ASSERT_TRUE(alpha_stat_ok); + ASSERT_EQ(mtime_rc, 0); + ASSERT_TRUE(edited_same_stat); + ASSERT_EQ(modify_rc, 0); + ASSERT_TRUE(modify_route != CBM_INCREMENTAL_ROUTE_NOOP); + ASSERT_EQ(old_alpha, 0); + ASSERT_EQ(new_alpha, 1); + ASSERT_EQ(add_rc, 0); + ASSERT_TRUE(add_route != CBM_INCREMENTAL_ROUTE_NOOP); + ASSERT_EQ(gamma_added, 1); + ASSERT_EQ(unlink_rc, 0); + ASSERT_EQ(delete_rc, 0); + ASSERT_TRUE(delete_route != CBM_INCREMENTAL_ROUTE_NOOP); + ASSERT_EQ(beta_left, 0); + ASSERT_EQ(rename_rc, 0); + ASSERT_EQ(renamed_rc, 0); + ASSERT_TRUE(rename_route != CBM_INCREMENTAL_ROUTE_NOOP); + ASSERT_EQ(gamma_renamed, 1); + ASSERT_EQ(old_path_hash, CBM_STORE_NOT_FOUND); + ASSERT_EQ(new_path_hash, CBM_STORE_OK); + ASSERT_EQ(final_rc, 0); + ASSERT_EQ(final_route, CBM_INCREMENTAL_ROUTE_NOOP); + PASS(); +} + static char g_route_log_capture[8192]; static atomic_flag g_route_log_spin = ATOMIC_FLAG_INIT; @@ -6368,15 +6539,15 @@ TEST(pipeline_existing_artifact_refreshes_after_default_forced_full_reindex) { ASSERT_EQ(reindex_observer.rename_calls, 1); ASSERT_EQ(reindex_observer.exports_before_publish, 0); ASSERT_EQ(reindex_observer.export_count, 1); + /* The no-ops are proven on the live generation and publish nothing; the + * artifact is still refreshed, from that already published database. */ ASSERT_EQ(explicit_rc, 0); ASSERT_EQ(explicit_route, CBM_INCREMENTAL_ROUTE_NOOP); - ASSERT_EQ(explicit_observer.rename_calls, 1); - ASSERT_EQ(explicit_observer.exports_before_publish, 0); + ASSERT_EQ(explicit_observer.rename_calls, 0); ASSERT_EQ(explicit_observer.export_count, 1); ASSERT_EQ(unchanged_rc, 0); ASSERT_EQ(unchanged_route, CBM_INCREMENTAL_ROUTE_NOOP); - ASSERT_EQ(observer.rename_calls, 1); - ASSERT_EQ(observer.exports_before_publish, 0); + ASSERT_EQ(observer.rename_calls, 0); ASSERT_EQ(observer.export_count, 1); ASSERT_TRUE(journal_ok); ASSERT_STR_EQ(journal_mode, "delete"); @@ -18853,8 +19024,9 @@ TEST(incremental_downgrade_preserves_scope_and_artifact_across_change_noop_delet ASSERT_EQ(noop.rc, 0); ASSERT_EQ(noop.route, CBM_INCREMENTAL_ROUTE_NOOP); ASSERT_TRUE(noop.tools_excluded); - ASSERT_EQ(noop.publish.rename_calls, 1); - ASSERT_EQ(noop.publish.exports_before_publish, 0); + /* Proven on the live generation: nothing is published, the artifact is + * still refreshed from the live database. */ + ASSERT_EQ(noop.publish.rename_calls, 0); ASSERT_EQ(noop.publish.export_count, 1); ASSERT_EQ(deleted.rc, 0); @@ -23352,6 +23524,8 @@ SUITE(pipeline_semantic_manifest_repro) { RUN_TEST(pipeline_sweep_reclaims_orphans_of_other_projects_and_keeps_their_live_stages); RUN_TEST(pipeline_sweep_removes_dead_writer_stage_keeps_locked_stage); RUN_TEST(pipeline_concurrent_sweep_must_not_remove_inflight_stage); + RUN_TEST(pipeline_unchanged_reindex_leaves_live_generation_untouched); + RUN_TEST(pipeline_live_noop_proof_never_hides_a_change); RUN_TEST(pipeline_fresh_index_never_reports_invalid_existing_db); RUN_TEST(pipeline_source_mutation_before_publication_preserves_previous_generation); RUN_TEST(pipeline_source_addition_before_publication_preserves_previous_generation); diff --git a/tests/test_store_arch.c b/tests/test_store_arch.c index 4a6d7eb66f..8ed3f46364 100644 --- a/tests/test_store_arch.c +++ b/tests/test_store_arch.c @@ -298,6 +298,45 @@ TEST(arch_path_scoping) { PASS(); } +/* packages: count DESC, tied counts in name (BINARY) order. The aggregation + * runs off the label index now; a sorted GROUP BY follows the ORDER BY + * direction, which reversed the ties until the name tie-break was explicit. */ +TEST(arch_packages_tied_counts_in_name_order) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK); + static const char *const pkg_names[] = {"gamma", "alpha", "Zeta", "beta", + "Alpha", "gamma", "beta"}; + enum { PKG_NODES = 7, PKG_GROUPS = 5 }; + for (int i = 0; i < PKG_NODES; i++) { + char qn[64]; + snprintf(qn, sizeof(qn), "test.pkg%d.%s", i, pkg_names[i]); + cbm_node_t n = {.project = "test", + .label = "Package", + .name = pkg_names[i], + .qualified_name = qn, + .file_path = "pkg/manifest.toml"}; + ASSERT_TRUE(cbm_store_upsert_node(s, &n) > 0); + } + static const char *const want_names[] = {"beta", "gamma", "Alpha", "Zeta", "alpha"}; + static const int want_counts[] = {2, 2, 1, 1, 1}; + const char *aspects[] = {"packages"}; + const char *paths[] = {NULL, "pkg"}; /* unscoped and path-scoped SQL */ + for (int p = 0; p < 2; p++) { + cbm_architecture_info_t info; + ASSERT_EQ(cbm_store_get_architecture(s, "test", paths[p], aspects, 1, &info), + CBM_STORE_OK); + ASSERT_EQ(info.package_count, PKG_GROUPS); + for (int i = 0; i < PKG_GROUPS; i++) { + ASSERT_STR_EQ(info.packages[i].name, want_names[i]); + ASSERT_EQ(info.packages[i].node_count, want_counts[i]); + } + cbm_store_architecture_free(&info); + } + cbm_store_close(s); + PASS(); +} + TEST(arch_empty_project) { cbm_store_t *s = cbm_store_open_memory(); ASSERT_NOT_NULL(s); @@ -1859,6 +1898,7 @@ SUITE(store_arch) { RUN_TEST(arch_hotspots_exclude_tests); RUN_TEST(arch_specific_aspects); RUN_TEST(arch_path_scoping); + RUN_TEST(arch_packages_tied_counts_in_name_order); RUN_TEST(arch_empty_project); RUN_TEST(arch_languages); RUN_TEST(arch_routes); diff --git a/tests/test_store_nodes.c b/tests/test_store_nodes.c index 4f1d6dad2b..ccd3fc6fa8 100644 --- a/tests/test_store_nodes.c +++ b/tests/test_store_nodes.c @@ -402,6 +402,122 @@ TEST(store_node_find_by_label) { PASS(); } +static bool scan_str_same(const char *a, const char *b) { + return (!a && !b) || (a && b && strcmp(a, b) == 0); +} + +static bool scan_node_same(const cbm_node_t *a, const cbm_node_t *b) { + return a->id == b->id && scan_str_same(a->project, b->project) && + scan_str_same(a->label, b->label) && scan_str_same(a->name, b->name) && + scan_str_same(a->qualified_name, b->qualified_name) && + scan_str_same(a->file_path, b->file_path) && a->start_line == b->start_line && + a->end_line == b->end_line && scan_str_same(a->properties_json, b->properties_json); +} + +/* The streamed scan gives the eager scan's rows, field for field and in the + * same order, at any batch size, while other lookups (including the eager + * scans' own cached statements) run between batches. Returns the row count, + * or -1 on the first difference; *paged tells whether the scan paged. */ +static int scan_matches_eager(cbm_store_t *s, const char *project, const char *label, int batch, + bool *paged) { + cbm_node_t *eager = NULL; + int n = 0; + int rc = label ? cbm_store_find_nodes_by_label(s, project, label, &eager, &n) + : cbm_store_find_nodes(s, project, &eager, &n); + if (rc != CBM_STORE_OK) { + return -1; + } + cbm_store_node_scan_t *scan = cbm_store_node_scan_open(s, project, label); + *paged = cbm_store_node_scan_paged(scan); + cbm_node_t buf[64]; + int seen = 0; + bool same = scan != NULL && batch <= 64; + int got = 0; + while (same && cbm_store_node_scan_next(scan, buf, batch, &got) == CBM_STORE_OK && got > 0) { + for (int i = 0; i < got; i++, seen++) { + same = same && seen < n && scan_node_same(&buf[i], &eager[seen]); + cbm_node_free_fields(&buf[i]); + } + cbm_node_t *other = NULL; + int other_n = 0; + (void)cbm_store_find_nodes(s, project, &other, &other_n); + cbm_store_free_nodes(other, other_n); + (void)cbm_store_find_nodes_by_label(s, project, "Class", &other, &other_n); + cbm_store_free_nodes(other, other_n); + } + /* An exhausted scan stays exhausted. */ + same = same && cbm_store_node_scan_next(scan, buf, batch, &got) == CBM_STORE_OK && got == 0; + cbm_store_node_scan_close(scan); + cbm_store_free_nodes(eager, n); + return same && seen == n ? n : -1; +} + +/* Pages of 1, 3 and 64 rows end inside files, at file boundaries and inside + * the NULL file_path rows, which the eager all-nodes order puts first. The + * upsert API stores "" for a missing path; other writers may store NULL. */ +TEST(store_node_scan_streams_eager_scan_order) { + enum { SCAN_NODES = 60 }; + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "test", "/tmp/test"); + cbm_store_upsert_project(s, "other", "/tmp/other"); + static const char *const labels[] = {"Function", "Class", "Method"}; + static const char *const files[] = {"b.c", "a.c", "", "c.c"}; + int in_test = 0; + int test_functions = 0; + int other_methods = 0; + for (int i = 0; i < SCAN_NODES; i++) { + bool other = i % 4 == 3; + char name[32]; + char qn[64]; + char props[64]; + snprintf(name, sizeof(name), "n%d", (i * 17) % 40); + snprintf(qn, sizeof(qn), "%s.n%d", other ? "other" : "test", i); + snprintf(props, sizeof(props), "{\"complexity\":%d}", i); + cbm_node_t n = {.project = other ? "other" : "test", + .label = labels[i % 3], + .name = name, + .qualified_name = qn, + .file_path = files[(i / 2) % 4], + .start_line = i, + .end_line = i + 5, + .properties_json = i % 5 ? props : NULL}; + ASSERT_GT(cbm_store_upsert_node(s, &n), 0); + in_test += other ? 0 : 1; + test_functions += !other && i % 3 == 0 ? 1 : 0; + other_methods += other && i % 3 == 2 ? 1 : 0; + } + ASSERT_EQ(cbm_store_exec(s, "UPDATE nodes SET file_path = NULL WHERE start_line % 7 = 0;"), 0); + cbm_node_t *eager = NULL; + int eager_n = 0; + ASSERT_EQ(cbm_store_find_nodes(s, "test", &eager, &eager_n), CBM_STORE_OK); + ASSERT_TRUE(eager_n > 0 && eager[0].file_path == NULL); /* NULL rows come first */ + cbm_store_free_nodes(eager, eager_n); + + static const int batches[] = {1, 3, 64}; + bool paged = false; + for (size_t b = 0; b < sizeof(batches) / sizeof(batches[0]); b++) { + ASSERT_EQ(scan_matches_eager(s, "test", NULL, batches[b], &paged), in_test); + ASSERT_TRUE(paged); + ASSERT_EQ(scan_matches_eager(s, "test", "Function", batches[b], &paged), test_functions); + ASSERT_TRUE(paged); + ASSERT_EQ(scan_matches_eager(s, "other", NULL, batches[b], &paged), + SCAN_NODES - in_test); /* project filter */ + ASSERT_EQ(scan_matches_eager(s, "other", "Method", batches[b], &paged), other_methods); + ASSERT_EQ(scan_matches_eager(s, "test", "NoSuchLabel", batches[b], &paged), 0); + ASSERT_EQ(scan_matches_eager(s, "missing", NULL, batches[b], &paged), 0); + } + + /* Without the node indexes the eager queries take other plans, which the + * pages would not reproduce: the scan hands out the eager rows instead. */ + ASSERT_EQ(cbm_store_drop_indexes(s), 0); + ASSERT_EQ(scan_matches_eager(s, "test", NULL, 3, &paged), in_test); + ASSERT_FALSE(paged); + ASSERT_EQ(scan_matches_eager(s, "test", "Function", 3, &paged), test_functions); + ASSERT_FALSE(paged); + cbm_store_close(s); + PASS(); +} + TEST(store_node_find_by_file) { cbm_store_t *s = cbm_store_open_memory(); cbm_store_upsert_project(s, "test", "/tmp/test"); @@ -2516,6 +2632,7 @@ SUITE(store_nodes) { RUN_TEST(store_node_crud); RUN_TEST(store_node_dedup); RUN_TEST(store_node_find_by_label); + RUN_TEST(store_node_scan_streams_eager_scan_order); RUN_TEST(store_node_find_by_file); RUN_TEST(store_file_outline_is_filtered_stable_bounded_and_cancellable_issue469); RUN_TEST(store_file_outline_fails_closed_on_text_budget_issue469); diff --git a/tests/test_store_pragmas.c b/tests/test_store_pragmas.c index a81d73cbd0..0db6a6e526 100644 --- a/tests/test_store_pragmas.c +++ b/tests/test_store_pragmas.c @@ -345,7 +345,75 @@ TEST(corrupt_page_scan_returns_error_not_truncation) { PASS(); } +/* A read tool's statements share one read batch instead of taking the file + * lock once per statement. The batch reads one snapshot until it renews + * (every 100 ms, forced here through the seam), then sees later commits. */ +static char *read_batch_name(cbm_store_t *s) { + cbm_node_t found = {0}; + if (cbm_store_find_node_by_qn(s, "p", "p.a", &found) != CBM_STORE_OK) { + return NULL; + } + char *name = found.name ? strdup(found.name) : NULL; + cbm_node_free_fields(&found); + return name; +} + +static bool read_batch_rename(cbm_store_t *writer, const char *name) { + cbm_node_t node = { + .project = "p", .label = "Function", .name = name, .qualified_name = "p.a", .file_path = "a.c"}; + return cbm_store_upsert_node(writer, &node) > 0; +} + +TEST(store_read_batch_snapshot_renews) { + char *td = th_mktempdir("cbm_read_batch"); + ASSERT_NOT_NULL(td); + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/batch.db", td); + cbm_store_t *writer = cbm_store_open_path(db_path); /* WAL: commits beside a reader */ + ASSERT_NOT_NULL(writer); + ASSERT_EQ(cbm_store_upsert_project(writer, "p", td), CBM_STORE_OK); + ASSERT_TRUE(read_batch_rename(writer, "before")); + cbm_store_t *reader = cbm_store_open_path_query(db_path); + ASSERT_NOT_NULL(reader); + + ASSERT_TRUE(cbm_store_read_batch_begin(reader)); + ASSERT_FALSE(cbm_store_read_batch_begin(reader)); /* one batch at a time */ + char *name = read_batch_name(reader); + ASSERT_STR_EQ(name, "before"); + free(name); + + /* Inside the batch the reader keeps its snapshot... */ + ASSERT_TRUE(read_batch_rename(writer, "after")); + name = read_batch_name(reader); + ASSERT_STR_EQ(name, "before"); + free(name); + + /* ...until the batch renews at its next statement. */ + cbm_store_read_batch_expire_for_test(reader); + name = read_batch_name(reader); + ASSERT_STR_EQ(name, "after"); + free(name); + + /* Ended, every statement reads the latest commit again. */ + cbm_store_read_batch_end(reader); + ASSERT_TRUE(read_batch_rename(writer, "latest")); + name = read_batch_name(reader); + ASSERT_STR_EQ(name, "latest"); + free(name); + + /* Begin refuses while another transaction is open; close ends a batch. */ + ASSERT_EQ(cbm_store_begin(writer), CBM_STORE_OK); + ASSERT_FALSE(cbm_store_read_batch_begin(writer)); + ASSERT_EQ(cbm_store_rollback(writer), CBM_STORE_OK); + ASSERT_TRUE(cbm_store_read_batch_begin(reader)); + cbm_store_close(reader); + cbm_store_close(writer); + th_rmtree(td); + PASS(); +} + SUITE(store_pragmas) { + RUN_TEST(store_read_batch_snapshot_renews); RUN_TEST(journal_size_limit_bounds_wal_issue1083); RUN_TEST(store_role_pragmas_issue1419); RUN_TEST(store_generation_tracks_mutations); diff --git a/tests/test_store_search.c b/tests/test_store_search.c index 4c1063bcc9..cc0fbf1af2 100644 --- a/tests/test_store_search.c +++ b/tests/test_store_search.c @@ -621,6 +621,56 @@ TEST(store_schema_info) { PASS(); } +/* Label and edge-type counts: count DESC, tied counts in BINARY name order — + * the order the GROUP BY scan produced and the distinct-value walk keeps. + * Another project's rows must not be counted. */ +TEST(store_schema_counts_ties_in_name_order) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + cbm_store_upsert_project(s, "test", "/tmp/test"); + cbm_store_upsert_project(s, "other", "/tmp/other"); + static const char *const labels[] = {"gamma", "Delta", "alpha", "Zeta", "Beta", + "Delta", "Zeta", "Alpha", "gamma", "Delta"}; + enum { NODE_COUNT = 10, LABEL_GROUPS = 6, TYPE_GROUPS = 4, EDGE_COUNT = 8 }; + int64_t ids[NODE_COUNT]; + for (int i = 0; i < NODE_COUNT; i++) { + char qn[32]; + snprintf(qn, sizeof(qn), "test.n%d", i); + cbm_node_t n = {.project = "test", .label = labels[i], .name = qn, .qualified_name = qn}; + ids[i] = cbm_store_upsert_node(s, &n); + ASSERT_TRUE(ids[i] > 0); + snprintf(qn, sizeof(qn), "other.n%d", i); + cbm_node_t o = {.project = "other", .label = "Alpha", .name = qn, .qualified_name = qn}; + ASSERT_TRUE(cbm_store_upsert_node(s, &o) > 0); + } + static const char *const types[] = {"USAGE", "DEFINES", "CALLS", "IMPORTS", + "CALLS", "DEFINES", "USAGE", "DEFINES"}; + for (int i = 0; i < EDGE_COUNT; i++) { + cbm_edge_t e = { + .project = "test", .source_id = ids[i], .target_id = ids[i + 1], .type = types[i]}; + ASSERT_TRUE(cbm_store_insert_edge(s, &e) > 0); + } + cbm_schema_info_t schema = {0}; + ASSERT_EQ(cbm_store_get_schema_counts(s, "test", &schema), CBM_STORE_OK); + static const char *const want_labels[] = {"Delta", "Zeta", "gamma", "Alpha", "Beta", "alpha"}; + static const int want_label_counts[] = {3, 2, 2, 1, 1, 1}; + ASSERT_EQ(schema.node_label_count, LABEL_GROUPS); + for (int i = 0; i < LABEL_GROUPS; i++) { + ASSERT_STR_EQ(schema.node_labels[i].label, want_labels[i]); + ASSERT_EQ(schema.node_labels[i].count, want_label_counts[i]); + } + static const char *const want_types[] = {"DEFINES", "CALLS", "USAGE", "IMPORTS"}; + static const int want_type_counts[] = {3, 2, 2, 1}; + ASSERT_EQ(schema.edge_type_count, TYPE_GROUPS); + for (int i = 0; i < TYPE_GROUPS; i++) { + ASSERT_STR_EQ(schema.edge_types[i].type, want_types[i]); + ASSERT_EQ(schema.edge_types[i].count, want_type_counts[i]); + } + cbm_store_schema_free(&schema); + cbm_store_close(s); + PASS(); +} + /* ── Search with exclude_labels ─────────────────────────────────── */ TEST(store_search_exclude_labels) { @@ -859,6 +909,173 @@ TEST(store_search_case_insensitive) { PASS(); } +/* ── Anchored name patterns: the idx_nodes_name range path ─────── */ + +TEST(store_regex_anchored_prefix) { + char out[64]; + ASSERT_EQ(cbm_regex_anchored_prefix("^schedule$", out, sizeof(out)), 8); + ASSERT_STR_EQ(out, "schedule"); + ASSERT_EQ(cbm_regex_anchored_prefix("^kmalloc", out, sizeof(out)), 7); + ASSERT_STR_EQ(out, "kmalloc"); + ASSERT_EQ(cbm_regex_anchored_prefix("^get_", out, sizeof(out)), 4); + ASSERT_STR_EQ(out, "get_"); + ASSERT_EQ(cbm_regex_anchored_prefix("^o'neil", out, sizeof(out)), 6); + ASSERT_STR_EQ(out, "o'neil"); + /* The run stops at the first operator; the prefix before it holds. */ + ASSERT_EQ(cbm_regex_anchored_prefix("^foo.bar", out, sizeof(out)), 3); + ASSERT_STR_EQ(out, "foo"); + ASSERT_EQ(cbm_regex_anchored_prefix("^foo[0-9]", out, sizeof(out)), 3); + ASSERT_EQ(cbm_regex_anchored_prefix("^foo(bar)?", out, sizeof(out)), 3); + ASSERT_EQ(cbm_regex_anchored_prefix("^foo$", out, sizeof(out)), 3); + /* A quantifier or escape after the run drops its last character. */ + ASSERT_EQ(cbm_regex_anchored_prefix("^abc*", out, sizeof(out)), 2); + ASSERT_STR_EQ(out, "ab"); + ASSERT_EQ(cbm_regex_anchored_prefix("^abc?", out, sizeof(out)), 2); + ASSERT_EQ(cbm_regex_anchored_prefix("^abc+?", out, sizeof(out)), 2); + ASSERT_EQ(cbm_regex_anchored_prefix("^abc{0,1}", out, sizeof(out)), 2); + ASSERT_EQ(cbm_regex_anchored_prefix("^abc\\.d", out, sizeof(out)), 2); + ASSERT_EQ(cbm_regex_anchored_prefix("^a*", out, sizeof(out)), 0); + /* No provable prefix: unanchored, alternation, leading operator, + * non-ASCII. */ + ASSERT_EQ(cbm_regex_anchored_prefix("schedule", out, sizeof(out)), 0); + ASSERT_EQ(cbm_regex_anchored_prefix(".*schedule", out, sizeof(out)), 0); + ASSERT_EQ(cbm_regex_anchored_prefix("^foo|bar", out, sizeof(out)), 0); + ASSERT_EQ(cbm_regex_anchored_prefix("^(foo|bar)x", out, sizeof(out)), 0); + ASSERT_EQ(cbm_regex_anchored_prefix("^(foo)", out, sizeof(out)), 0); + ASSERT_EQ(cbm_regex_anchored_prefix("^[Ff]oo", out, sizeof(out)), 0); + ASSERT_EQ(cbm_regex_anchored_prefix("^.foo", out, sizeof(out)), 0); + ASSERT_EQ(cbm_regex_anchored_prefix("^\\x", out, sizeof(out)), 0); + ASSERT_EQ(cbm_regex_anchored_prefix("^\xc3\xbc" + "ber", + out, sizeof(out)), + 0); + ASSERT_EQ(cbm_regex_anchored_prefix("^ab\xc3\xbc", out, sizeof(out)), 2); + ASSERT_EQ(cbm_regex_anchored_prefix("(?i)^foo", out, sizeof(out)), 0); + ASSERT_EQ(cbm_regex_anchored_prefix(NULL, out, sizeof(out)), 0); + /* Bounded by the buffer; a quantifier right after the cut still drops. */ + ASSERT_EQ(cbm_regex_anchored_prefix("^abcdef", out, 4), 3); + ASSERT_STR_EQ(out, "abc"); + ASSERT_EQ(cbm_regex_anchored_prefix("^abc*", out, 4), 2); + PASS(); +} + +/* Statements a search ran with the name-range predicate (SQLITE_TRACE_STMT). */ +static int anchored_trace_cb(unsigned type, void *ctx, void *stmt, void *sql) { + (void)stmt; + if (type == SQLITE_TRACE_STMT && sql && strstr((const char *)sql, "n.name >= ?")) { + (*(int *)ctx)++; + } + return 0; +} + +/* "name#label;" per row plus the total, for comparing two searches. */ +static void anchored_search_rows(cbm_store_t *s, const cbm_search_params_t *p, int *rc, + char *buf, size_t buf_sz) { + cbm_search_output_t out = {0}; + *rc = cbm_store_search(s, p, &out); + int len = snprintf(buf, buf_sz, "total=%d:", out.total); + for (int i = 0; i < out.count && len > 0 && (size_t)len < buf_sz; i++) { + len += snprintf(buf + len, buf_sz - (size_t)len, "%s#%s#%lld;", out.results[i].node.name, + out.results[i].node.label, (long long)out.results[i].node.id); + } + cbm_store_search_free(&out); +} + +/* An anchored literal pattern must return exactly what the same regex returns + * without the range path. Wrapping it in a group, "(^schedule$)", keeps the + * regex semantics but has no provable prefix, so it runs the plain scan. The + * names cover every letter case of the prefix, prefix-vs-substring, and the + * byte order the UNION ALL arms are merged in. */ +TEST(store_search_anchored_name_matches_plain_scan) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + cbm_store_upsert_project(s, "p", "/tmp/p"); + static const char *const names[] = { + "schedule", "Schedule", "SCHEDULE", "sCHEDULE", "scheduler", "schedule_work", + "reschedule", "sched", "Sched_x", "get_name", "Get_Name", "getname", + "o'neil", "ScHeDuLe", "schedule", "zabc"}; + enum { NAME_COUNT = 16 }; + for (int i = 0; i < NAME_COUNT; i++) { + char qn[64]; + snprintf(qn, sizeof(qn), "p.m%d.%s", i, names[i]); + cbm_node_t n = {.project = "p", + .label = (i % 3 == 1) ? "Class" : "Function", + .name = names[i], + .qualified_name = qn, + .file_path = "m.c"}; + ASSERT_TRUE(cbm_store_upsert_node(s, &n) > 0); + } + int ranged = 0; + sqlite3_trace_v2(cbm_store_get_db(s), SQLITE_TRACE_STMT, anchored_trace_cb, &ranged); + + struct { + const char *pattern; + const char *label; + bool case_sensitive; + int limit; + int offset; + int want_total; + } cases[] = { + {"^schedule$", NULL, false, 0, 0, 6}, {"^sched", NULL, false, 0, 0, 10}, + {"^sched", "Function", false, 0, 0, 6}, {"^SCHED", NULL, true, 0, 0, 1}, + {"^Sched", NULL, true, 0, 0, 2}, {"^sche+dule$", NULL, false, 0, 0, 6}, + {"^s.hedule$", NULL, false, 0, 0, 6}, {"^get_", NULL, false, 0, 0, 2}, + {"^o'n", NULL, false, 0, 0, 1}, {"^zzz", NULL, false, 0, 0, 0}, + {"^sched", NULL, false, 3, 2, 10}, {"^sched", NULL, false, 4, 8, 10}, + }; + char fast[4096]; + char plain[4096]; + char twin[64]; + for (size_t c = 0; c < sizeof(cases) / sizeof(cases[0]); c++) { + cbm_search_params_t p = {.project = "p", + .label = cases[c].label, + .name_pattern = cases[c].pattern, + .case_sensitive = cases[c].case_sensitive, + .limit = cases[c].limit, + .offset = cases[c].offset, + .min_degree = -1, + .max_degree = -1}; + int rc_fast = 0; + int rc_plain = 0; + int before = ranged; + anchored_search_rows(s, &p, &rc_fast, fast, sizeof(fast)); + ASSERT_EQ(ranged - before, 2); /* count + page both took the range path */ + snprintf(twin, sizeof(twin), "(%s)", cases[c].pattern); + p.name_pattern = twin; + before = ranged; + anchored_search_rows(s, &p, &rc_plain, plain, sizeof(plain)); + ASSERT_EQ(ranged - before, 0); + ASSERT_EQ(rc_fast, CBM_STORE_OK); + ASSERT_EQ(rc_plain, CBM_STORE_OK); + ASSERT_STR_EQ(fast, plain); + char want_total[32]; + snprintf(want_total, sizeof(want_total), "total=%d:", cases[c].want_total); + ASSERT_EQ(strncmp(fast, want_total, strlen(want_total)), 0); + } + sqlite3_trace_v2(cbm_store_get_db(s), 0, NULL, NULL); + cbm_store_close(s); + PASS(); +} + +/* A regex the engine rejects fails the plain query as soon as one row reaches + * it ("zabc" passes the LIKE '%abc%' hint). The range path would never reach + * the regex (no name starts with "abc") and answer an empty page instead, so a + * pattern that does not compile keeps the plain query and its error. */ +TEST(store_search_anchored_invalid_regex_keeps_error) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + cbm_store_upsert_project(s, "p", "/tmp/p"); + cbm_node_t n = {.project = "p", .label = "Function", .name = "zabc", .qualified_name = "p.zabc"}; + ASSERT_TRUE(cbm_store_upsert_node(s, &n) > 0); + cbm_search_params_t p = { + .project = "p", .name_pattern = "^abc(", .min_degree = -1, .max_degree = -1}; + cbm_search_output_t out = {0}; + ASSERT_EQ(cbm_store_search(s, &p, &out), CBM_STORE_ERR); + cbm_store_search_free(&out); + cbm_store_close(s); + PASS(); +} + /* ── Impact: HopToRisk ─────────────────────────────────────────── */ TEST(store_hop_to_risk) { @@ -1873,6 +2090,9 @@ SUITE(store_search) { RUN_TEST(store_search_all); RUN_TEST(store_search_exclude_labels); RUN_TEST(store_search_case_insensitive); + RUN_TEST(store_regex_anchored_prefix); + RUN_TEST(store_search_anchored_name_matches_plain_scan); + RUN_TEST(store_search_anchored_invalid_regex_keeps_error); RUN_TEST(store_bfs_outbound); RUN_TEST(store_bfs_inbound); RUN_TEST(store_bfs_cross_service); @@ -1881,6 +2101,7 @@ SUITE(store_search) { RUN_TEST(store_transaction_rollback); RUN_TEST(store_bulk_write_mode); RUN_TEST(store_schema_info); + RUN_TEST(store_schema_counts_ties_in_name_order); RUN_TEST(store_dump_to_file); RUN_TEST(store_hop_to_risk); RUN_TEST(store_build_impact_summary);