@@ -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
27632806static 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 );
0 commit comments