Skip to content

Commit 5bf7c4a

Browse files
committed
fix(rules): keep split custom-rule hosts exact, bounded, and cancellable
Rules that change module-level state on any host are evaluated again in one host over all of their subjects, replacing their split results, so cross-subject state such as a duplicate detector reports exactly what one host reports. The host count now honours cgroup v1 and v2 CPU quotas and is capped at eight. The first failing host writes a per-run cancellation marker. The other hosts check it between invocations, outside any Rule's guarded scope, and exit with a distinct cancelled status, and hosts that start late exit at once; no process is ever signalled. One invocation that is already running still ends or reaches its own timeout. One host then reruns the plan to report the single-host error.
1 parent 9974af6 commit 5bf7c4a

14 files changed

Lines changed: 628 additions & 54 deletions

File tree

‎src/sqlbuild/.agents/skills/sqlbuild/references/docs/concepts/rules/custom-rules/testing-and-determinism.md‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@
66
77
Online: https://sqlbuild.com/docs/concepts/rules/custom-rules/testing-and-determinism/
88

9+
## Contents
10+
11+
- Test Rules
12+
- Parallel evaluation
13+
- Helpers
14+
- Deterministic inputs
15+
- Cache granularity
16+
917
## Test Rules
1018

1119
Use the public harness to exercise discovery, compilation, and Rules evaluation:
@@ -39,6 +47,19 @@ the next. Compute findings from the subject and `ctx` alone.
3947

4048
Repository pytest files remain outside the SQLBuild project's SQL `tests/` directory.
4149

50+
## Parallel evaluation
51+
52+
On large projects, SQLBuild splits custom Rule evaluation across several host processes, at most
53+
one per available CPU and never more than eight. Each process evaluates a share of the subjects,
54+
and SQLBuild merges the findings back into the order a single process reports them.
55+
56+
A Rule that changes module-level state while it runs, for example by adding each model to a set of
57+
already-seen names, could report different findings when its subjects are split. SQLBuild detects
58+
the state change and evaluates that Rule again in one process over all of its subjects, so its
59+
findings and cache entries match a single-process run. The result stays correct, but the Rule no
60+
longer runs in parallel and costs an extra host process. Keep each subject's findings independent
61+
of the others so Rules can run in parallel.
62+
4263
## Helpers
4364

4465
Custom Rules can import repository-owned helpers under `rules/` and these deterministic standard

‎src/sqlbuild/rule_engine/_helpers/host/custom_evaluation.py‎

Lines changed: 44 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
from collections import Counter
6+
from collections.abc import Callable
67
from contextlib import AbstractContextManager, nullcontext
78
from dataclasses import dataclass
89
from itertools import chain
@@ -22,13 +23,18 @@
2223
build_rule_fact_views,
2324
)
2425
from sqlbuild.rule_engine.classes.runtime_guard import RuntimeGuard
25-
from sqlbuild.rule_engine.constants import CUSTOM_RULE_PROJECT_SUBJECT
26+
from sqlbuild.rule_engine.constants import (
27+
CUSTOM_HOST_CANCELLED_MESSAGE,
28+
CUSTOM_RULE_PROJECT_SUBJECT,
29+
)
2630
from sqlbuild.rule_engine.exceptions import (
31+
HostCancelledError,
2732
NonHermeticRuleError,
2833
OpaqueModuleStateError,
2934
RulesError,
3035
)
3136
from sqlbuild.rule_engine.models import (
37+
CustomHostPartition,
3238
CustomRuleEvaluation,
3339
CustomRuleRun,
3440
Finding,
@@ -52,6 +58,7 @@ class _PassInputs:
5258
facts: RuleFactViews
5359
plan: CustomRulePlan | None
5460
guard: RuntimeGuard | None
61+
cancelled: Callable[[], bool] | None
5562

5663

5764
def evaluate_custom_rules(
@@ -65,6 +72,7 @@ def evaluate_custom_rules(
6572
track_reads: bool = False,
6673
verify_determinism: bool = False,
6774
guard: RuntimeGuard | None = None,
75+
partition: CustomHostPartition | None = None,
6876
) -> CustomRuleRun:
6977
"""Run selected custom rules for every planned subject through the Python authoring API."""
7078

@@ -81,7 +89,10 @@ def evaluate_custom_rules(
8189
)
8290
reads: FactReads | None = FactReads() if track_reads else None
8391
tracker: _ReadTracker = _ReadTracker(
84-
reads=reads, rules=custom_rules, rules_root=project_dir / "rules"
92+
reads=reads,
93+
rules=custom_rules,
94+
rules_root=project_dir / "rules",
95+
detect_state=partition is not None,
8596
)
8697

8798
inputs: _PassInputs = _PassInputs(
@@ -93,6 +104,7 @@ def evaluate_custom_rules(
93104
facts=facts,
94105
plan=plan,
95106
guard=guard,
107+
cancelled=None if partition is None else partition.cancelled,
96108
)
97109

98110
def run_pass(
@@ -119,22 +131,29 @@ def run_pass(
119131
untracked_codes=frozenset(tracker.untracked_codes),
120132
uncacheable_codes=frozenset(tracker.uncacheable_codes),
121133
observed=() if reads is None else tuple(reads.observed.items()),
134+
stateful_codes=frozenset(tracker.stateful_codes),
122135
)
123136

124137

125138
class _ReadTracker:
126139
"""Attribute reads to one invocation; rules whose module state changes become untracked."""
127140

128141
def __init__(
129-
self, *, reads: FactReads | None, rules: tuple[Rule, ...], rules_root: Path
142+
self,
143+
*,
144+
reads: FactReads | None,
145+
rules: tuple[Rule, ...],
146+
rules_root: Path,
147+
detect_state: bool = False,
130148
) -> None:
131149
self.reads: FactReads | None = reads
132150
self.untracked_codes: set[str] = set()
133151
self.uncacheable_codes: set[str] = set()
152+
self.stateful_codes: set[str] = set()
134153
self._rules_root: Path = rules_root
135154
self._namespaces: dict[str, tuple[dict[str, object], ...]] = (
136155
{rule.code: rule_namespaces((rule.check,)) for rule in rules}
137-
if reads is not None
156+
if reads is not None or detect_state
138157
else {}
139158
)
140159
self._states: dict[str, str | None] = {
@@ -149,20 +168,22 @@ def start(self) -> None:
149168

150169
def finish(self, *, code: str) -> frozenset[tuple[object, ...]] | None:
151170
reads: FactReads | None = self.reads
152-
if reads is None:
153-
return None
154-
observed: set[tuple[object, ...]] | None = reads.current
155-
reads.current = None
156-
if reads.untracked:
157-
self.untracked_codes.add(code)
158-
if reads.uncacheable:
159-
self.uncacheable_codes.add(code)
171+
observed: set[tuple[object, ...]] | None = None
172+
if reads is not None:
173+
observed = reads.current
174+
reads.current = None
175+
if reads.untracked:
176+
self.untracked_codes.add(code)
177+
if reads.uncacheable:
178+
self.uncacheable_codes.add(code)
160179
for rule_code, namespaces in self._namespaces.items():
161-
if rule_code in self.untracked_codes:
180+
if rule_code in self.stateful_codes:
162181
continue
163182
state: str | None = self._state_token(namespaces)
164183
if state is None or state != self._states[rule_code]:
165-
self.untracked_codes.add(rule_code)
184+
self.stateful_codes.add(rule_code)
185+
if reads is not None:
186+
self.untracked_codes.add(rule_code)
166187
return None if observed is None else frozenset(observed)
167188

168189
def _state_token(self, namespaces: tuple[dict[str, object], ...]) -> str | None:
@@ -216,6 +237,7 @@ def context(rule: Rule) -> EvaluationRuleContext:
216237
if planned_project is not None and CUSTOM_RULE_PROJECT_SUBJECT not in planned_project:
217238
continue
218239
ctx: EvaluationRuleContext = context(rule)
240+
_stop_if_cancelled(inputs)
219241
tracker.start()
220242
try:
221243
findings: list[Finding] = _invoke(
@@ -245,6 +267,7 @@ def context(rule: Rule) -> EvaluationRuleContext:
245267
for rule, ctx, planned in model_contexts:
246268
if planned is not None and path not in planned:
247269
continue
270+
_stop_if_cancelled(inputs)
248271
tracker.start()
249272
try:
250273
findings = _invoke(rule=rule, subject=subject, ctx=ctx, guard=guard)
@@ -265,6 +288,13 @@ def context(rule: Rule) -> EvaluationRuleContext:
265288
return evaluations
266289

267290

291+
def _stop_if_cancelled(inputs: _PassInputs) -> None:
292+
"""Stop between invocations, outside any Rule's guarded scope, once the run is cancelled."""
293+
294+
if inputs.cancelled is not None and inputs.cancelled():
295+
raise HostCancelledError(CUSTOM_HOST_CANCELLED_MESSAGE)
296+
297+
268298
def _invoke(
269299
*, rule: Rule, subject: object, ctx: EvaluationRuleContext, guard: RuntimeGuard | None
270300
) -> list[Finding]:

‎src/sqlbuild/rule_engine/_helpers/host/custom_host.py‎

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,23 @@
1818
from sqlbuild.rule_engine._helpers.host.custom_evaluation import evaluate_custom_rules
1919
from sqlbuild.rule_engine.classes.runtime_guard import RuntimeGuard
2020
from sqlbuild.rule_engine.constants import (
21+
CUSTOM_HOST_CANCELLED_EXIT_CODE,
22+
CUSTOM_HOST_CANCELLED_MESSAGE,
2123
CUSTOM_HOST_HASH_SEED,
2224
CUSTOM_HOST_INPUT_TUPLE_SIZE,
2325
CUSTOM_HOST_LAUNCH_MODULE,
2426
CUSTOM_HOST_MAX_TRACKED_READS,
2527
CUSTOM_HOST_PROTOCOL_VERSION,
2628
CUSTOM_HOST_RUNTIME_VERSION,
2729
)
28-
from sqlbuild.rule_engine.exceptions import RulesError
29-
from sqlbuild.rule_engine.models import CustomRuleRun, Finding, Rule, RulesConfig
30+
from sqlbuild.rule_engine.exceptions import HostCancelledError, RulesError
31+
from sqlbuild.rule_engine.models import (
32+
CustomHostPartition,
33+
CustomRuleRun,
34+
Finding,
35+
Rule,
36+
RulesConfig,
37+
)
3038
from sqlbuild.rule_engine.types import CustomRulePlan
3139

3240

@@ -50,6 +58,9 @@ def main() -> int:
5058
payload: object = request["payload"]
5159
if not isinstance(payload, dict):
5260
raise RulesError("custom host payload must be an object")
61+
cancel_marker: Path | None = _cancel_marker(payload)
62+
if cancel_marker is not None and cancel_marker.exists():
63+
raise HostCancelledError(CUSTOM_HOST_CANCELLED_MESSAGE)
5364
project, config = _decode_inputs(payload)
5465
project_dir: Path = Path(str(payload["project_dir"])).resolve()
5566
dialect: str = str(payload.get("dialect", "generic"))
@@ -77,7 +88,15 @@ def main() -> int:
7788
track_reads=track_reads,
7889
verify_determinism=verify_determinism,
7990
guard=guard,
91+
partition=(
92+
None
93+
if cancel_marker is None
94+
else CustomHostPartition(cancelled=cancel_marker.exists)
95+
),
8096
)
97+
except HostCancelledError:
98+
sys.stderr.write(CUSTOM_HOST_CANCELLED_MESSAGE)
99+
return CUSTOM_HOST_CANCELLED_EXIT_CODE
81100
except Exception as error:
82101
violation: Exception | None = None if guard is None else guard.violation
83102
return _write_error(str(violation or error))
@@ -114,6 +133,20 @@ def _decode_inputs(payload: dict[str, Any]) -> tuple[CompiledProject, RulesConfi
114133
return decoded
115134

116135

136+
def _cancel_marker(payload: dict[str, Any]) -> Path | None:
137+
"""Return the run's cancellation marker, which must live beside the project payload."""
138+
139+
value: object = payload.get("cancel_marker")
140+
if value is None:
141+
return None
142+
project_dir: Path = Path(str(payload["project_dir"])).resolve()
143+
input_root: Path = (project_dir / "target" / "rules-cache" / "host-inputs").resolve()
144+
marker: Path = Path(str(value))
145+
if not marker.is_absolute() or not marker.resolve().is_relative_to(input_root):
146+
raise RulesError("custom host cancellation marker path is invalid")
147+
return marker
148+
149+
117150
def _decode_plan(value: object) -> CustomRulePlan:
118151
if not isinstance(value, dict):
119152
raise RulesError("custom host plan must be an object")
@@ -164,6 +197,7 @@ def _run_payload(run: CustomRuleRun) -> dict[str, object]:
164197
"uncacheable": sorted(run.uncacheable_codes),
165198
"observed": sorted([list(key), digest] for key, digest in run.observed),
166199
"bounded": bounded,
200+
"stateful": sorted(run.stateful_codes),
167201
}
168202

169203

0 commit comments

Comments
 (0)