Skip to content

Commit 5d150ea

Browse files
authored
Merge pull request DeusData#1109 from DeusData/fix/601-cypher-exec-deadline
fix(cypher): add a wall-clock execution deadline to abort runaway queries (DeusData#601)
2 parents 3d6aa67 + 6bd11ff commit 5d150ea

3 files changed

Lines changed: 127 additions & 0 deletions

File tree

‎src/cypher/cypher.c‎

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2758,6 +2758,49 @@ static void rb_add_row(result_builder_t *rb, const char **values) {
27582758
* Prevents accidental multi-GB JSON payloads from unbounded MATCH (n) RETURN n. */
27592759
#define CYPHER_RESULT_CEILING 100000
27602760

2761+
/* Wall-clock execution deadline (#601). The row ceiling above only fires once
2762+
* rows exist, but an unbounded `OPTIONAL MATCH` over the full node set (or a
2763+
* GROUP BY with ~one group per node) does O(bindings x groups) work and can run
2764+
* for minutes — exhausting RAM/CPU — before a single row is produced, so the
2765+
* ceiling never trips and the caller just hangs. A monotonic deadline aborts
2766+
* such runaway queries with a clear, actionable error. Checked (throttled) in
2767+
* the scan, expansion and aggregation hot loops. */
2768+
#define CYPHER_DEADLINE_BUDGET_MS 30000 /* 30s: generous for legit heavy queries */
2769+
#define CYPHER_DEADLINE_CHECK_MASK 0x3FF /* sample the clock every 1024 iterations */
2770+
2771+
static _Thread_local uint64_t g_cypher_deadline_ms = 0; /* absolute; 0 = disarmed */
2772+
static _Thread_local bool g_cypher_timed_out = false;
2773+
static _Thread_local int64_t g_cypher_deadline_override_ms = -1; /* test hook; <0 = default */
2774+
2775+
static void cypher_deadline_arm(void) {
2776+
g_cypher_timed_out = false;
2777+
int64_t budget = g_cypher_deadline_override_ms >= 0 ? g_cypher_deadline_override_ms
2778+
: CYPHER_DEADLINE_BUDGET_MS;
2779+
g_cypher_deadline_ms = cbm_now_ms() + (uint64_t)budget;
2780+
}
2781+
2782+
/* True once the query has run past its wall-clock budget. Sticky: after the
2783+
* first trip every subsequent call returns true, so later loops short-circuit. */
2784+
static bool cypher_deadline_exceeded(void) {
2785+
if (g_cypher_timed_out) {
2786+
return true;
2787+
}
2788+
if (g_cypher_deadline_ms == 0) {
2789+
return false;
2790+
}
2791+
if (cbm_now_ms() >= g_cypher_deadline_ms) {
2792+
g_cypher_timed_out = true;
2793+
return true;
2794+
}
2795+
return false;
2796+
}
2797+
2798+
/* Test-only: force the execution budget (ms) for subsequent queries on this
2799+
* thread. 0 = trip on the first hot-loop check; <0 restores the default. */
2800+
void cbm_cypher_test_set_deadline_ms(int64_t budget_ms) {
2801+
g_cypher_deadline_override_ms = budget_ms;
2802+
}
2803+
27612804
/* ── Binding virtual variables (for WITH clause) ──────────────── */
27622805

27632806
static const char *binding_get_virtual(binding_t *b, const char *var, const char *prop) {
@@ -3162,6 +3205,11 @@ static void expand_pattern_rels(cbm_store_t *store, cbm_pattern_t *pat, binding_
31623205
int *bind_count, const int *bind_cap, const char **var_name,
31633206
bool is_optional) {
31643207
for (int ri = 0; ri < pat->rel_count; ri++) {
3208+
/* #601: stop expanding further hops once the wall-clock budget is spent
3209+
* (an unbounded expansion is exactly what blows up here). */
3210+
if (cypher_deadline_exceeded()) {
3211+
return;
3212+
}
31653213
cbm_rel_pattern_t *rel = &pat->rels[ri];
31663214
cbm_node_pattern_t *target_node = &pat->nodes[ri + SKIP_ONE];
31673215
const char *to_var = target_node->variable ? target_node->variable : "_n_t";
@@ -3176,6 +3224,9 @@ static void expand_pattern_rels(cbm_store_t *store, cbm_pattern_t *pat, binding_
31763224
int new_count = 0;
31773225

31783226
for (int bi = 0; bi < *bind_count; bi++) {
3227+
if ((bi & CYPHER_DEADLINE_CHECK_MASK) == 0 && cypher_deadline_exceeded()) {
3228+
break;
3229+
}
31793230
binding_t *b = &(*bindings)[bi];
31803231
cbm_node_t *src = binding_get(b, *var_name);
31813232
if (!src) {
@@ -4219,6 +4270,11 @@ static void execute_return_agg(cbm_return_clause_t *ret, binding_t *bindings, in
42194270
int agg_count = 0;
42204271

42214272
for (int bi = 0; bi < bind_count; bi++) {
4273+
/* #601: grouping is O(bindings x groups) — the dominant cost on a
4274+
* whole-graph GROUP BY. Abort if we blow the wall-clock budget. */
4275+
if ((bi & CYPHER_DEADLINE_CHECK_MASK) == 0 && cypher_deadline_exceeded()) {
4276+
break;
4277+
}
42224278
char key[CBM_SZ_1K] = "";
42234279
const char *vals[CBM_SZ_32];
42244280
char valbufs[CBM_SZ_32][CBM_SZ_512];
@@ -4587,6 +4643,9 @@ static int execute_single(cbm_store_t *store, cbm_query_t *q, const char *projec
45874643
const char *var_name = pat0->nodes[0].variable ? pat0->nodes[0].variable : "_n0";
45884644

45894645
for (int i = 0; i < scan_count && bind_count < bind_cap; i++) {
4646+
if ((i & CYPHER_DEADLINE_CHECK_MASK) == 0 && cypher_deadline_exceeded()) {
4647+
break;
4648+
}
45904649
binding_t b = {0};
45914650
b.store = store;
45924651
binding_set(&b, var_name, &scanned[i]);
@@ -4635,6 +4694,7 @@ int cbm_cypher_execute(cbm_store_t *store, const char *query, const char *projec
46354694
cbm_cypher_result_t *out) {
46364695
memset(out, 0, sizeof(*out));
46374696
g_cypher_depth_clamped = 0;
4697+
cypher_deadline_arm(); /* #601: start the wall-clock budget for this query */
46384698
if (max_rows <= 0) {
46394699
max_rows = CYPHER_RESULT_CEILING;
46404700
}
@@ -4678,6 +4738,18 @@ int cbm_cypher_execute(cbm_store_t *store, const char *query, const char *projec
46784738
rb_apply_distinct(&rb);
46794739
}
46804740

4741+
/* #601: abort a runaway query that blew the wall-clock budget before it can
4742+
* return a misleading partial result. Checked before the row ceiling. */
4743+
if (g_cypher_timed_out) {
4744+
rb_free(&rb);
4745+
cbm_query_free(q);
4746+
out->error =
4747+
heap_strdup("query exceeded the execution time limit — narrow the pattern with a WHERE "
4748+
"filter, use a directed MATCH instead of an unbounded OPTIONAL MATCH, or "
4749+
"add LIMIT");
4750+
return CBM_NOT_FOUND;
4751+
}
4752+
46814753
/* Check ceiling */
46824754
if (rb.row_count >= CYPHER_RESULT_CEILING) {
46834755
rb_free(&rb);

‎src/cypher/cypher.h‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,4 +337,9 @@ int cbm_cypher_parse(const char *query, cbm_query_t **out, char **error);
337337
/* Free a query AST. */
338338
void cbm_query_free(cbm_query_t *q);
339339

340+
/* Test-only (#601): force the wall-clock execution budget in milliseconds for
341+
* subsequent queries on the calling thread. 0 = trip on the first hot-loop
342+
* check; a negative value restores the default budget. */
343+
void cbm_cypher_test_set_deadline_ms(int64_t budget_ms);
344+
340345
#endif /* CBM_CYPHER_H */

‎tests/test_cypher.c‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2997,6 +2997,54 @@ TEST(cypher_wide_return_projection_bounded) {
29972997
#endif
29982998
}
29992999

3000+
/* #601: an unbounded whole-graph OPTIONAL MATCH / GROUP BY does
3001+
* O(bindings x groups) work and can run for minutes with no wall-clock guard —
3002+
* the 100k row ceiling never fires because no rows are produced, so query_graph
3003+
* just hangs. With the execution deadline armed to trip immediately (budget 0),
3004+
* the runaway query must abort with a clear error instead of returning a
3005+
* (misleading, possibly partial) result.
3006+
*
3007+
* RED on unfixed code: no deadline exists, so the query completes and returns
3008+
* rc==0 with rows and no error — the assertions below fail. */
3009+
TEST(cypher_exec_deadline_aborts_runaway_query_issue601) {
3010+
cbm_store_t *s = setup_cypher_store();
3011+
cbm_cypher_result_t r = {0};
3012+
3013+
cbm_cypher_test_set_deadline_ms(0); /* trip on the first hot-loop check */
3014+
int rc = cbm_cypher_execute(
3015+
s, "MATCH (a) OPTIONAL MATCH (a)-[:CALLS]->(b) RETURN a.qualified_name, count(b)", "test",
3016+
0, &r);
3017+
cbm_cypher_test_set_deadline_ms(-1); /* restore default before asserting (thread-local) */
3018+
3019+
ASSERT_TRUE(rc != 0); /* CBM_NOT_FOUND (-1) — query aborted, not success */
3020+
ASSERT_NOT_NULL(r.error);
3021+
ASSERT_TRUE(strstr(r.error, "time limit") != NULL);
3022+
ASSERT_EQ(r.row_count, 0);
3023+
3024+
cbm_cypher_result_free(&r);
3025+
cbm_store_close(s);
3026+
PASS();
3027+
}
3028+
3029+
/* #601 companion: the default (ample) budget must NOT false-positive on a
3030+
* normal small query — it still returns its rows. */
3031+
TEST(cypher_exec_deadline_allows_normal_query_issue601) {
3032+
cbm_store_t *s = setup_cypher_store();
3033+
cbm_cypher_result_t r = {0};
3034+
3035+
int rc = cbm_cypher_execute(
3036+
s, "MATCH (a) OPTIONAL MATCH (a)-[:CALLS]->(b) RETURN a.qualified_name, count(b)", "test",
3037+
0, &r);
3038+
3039+
ASSERT_EQ(rc, 0);
3040+
ASSERT_TRUE(r.error == NULL);
3041+
ASSERT_GT(r.row_count, 0);
3042+
3043+
cbm_cypher_result_free(&r);
3044+
cbm_store_close(s);
3045+
PASS();
3046+
}
3047+
30003048
/* ══════════════════════════════════════════════════════════════════ */
30013049

30023050
SUITE(cypher) {
@@ -3029,6 +3077,8 @@ SUITE(cypher) {
30293077
RUN_TEST(cypher_parse_inline_props);
30303078
RUN_TEST(cypher_parse_error);
30313079
/* Execution */
3080+
RUN_TEST(cypher_exec_deadline_aborts_runaway_query_issue601);
3081+
RUN_TEST(cypher_exec_deadline_allows_normal_query_issue601);
30323082
RUN_TEST(cypher_exec_match_all_functions);
30333083
RUN_TEST(cypher_issue240_labels_function);
30343084
RUN_TEST(cypher_issue237_distinct_order_limit);

0 commit comments

Comments
 (0)