Skip to content

Commit 3d69717

Browse files
committed
perf(rules): digest shared whole-project facts once
Whole-project facts such as project.sources repeat objects shared between resources: every source declared in one file carries that file's contents and entries. Digests of these facts now encode each compiler dataclass once and refer to it by content digest, so the digest grows with distinct content instead of with repetitions. Model and project-tree facts keep the digest a host observes. A real-CLI edit chain with model- and project-subject custom rules split across hosts compares every warm compile with a cache-free oracle, and the oracle is shown to fail when source digests are deliberately stale.
1 parent fe5d292 commit 3d69717

10 files changed

Lines changed: 516 additions & 5 deletions

File tree

‎src/sqlbuild/rule_engine/_helpers/engine/fact_replay.py‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
import orjson
1111

12+
from sqlbuild.rule_engine.classes.shared_fact_encoder import SharedFactEncoder
1213
from sqlbuild.rule_engine.classes.sql_document import SqlDocument
1314
from sqlbuild.rule_engine.constants import (
1415
FACT_AUDITS_ALL,
@@ -98,6 +99,7 @@
9899
FACT_TREE_READ_TEXT: 1,
99100
}
100101
_TREE_FACTS: frozenset[str] = frozenset({FACT_TREE_PATHS, *_TEXT_FACTS})
102+
_SHARED_FACTS: frozenset[str] = frozenset(_GLOBAL_FACTS) - _TREE_FACTS
101103

102104

103105
def fact_key_payload(key: tuple[object, ...]) -> list[str]:
@@ -123,18 +125,31 @@ def full_fact_keys(views: RuleFactViews) -> tuple[FactKey, ...]:
123125
return tuple(keys)
124126

125127

126-
def fact_outcome_digest(*, views: RuleFactViews, key: FactKey) -> str:
127-
"""Recompute one recorded fact and digest its value or raised error."""
128+
def fact_outcome_digest(
129+
*, views: RuleFactViews, key: FactKey, shared: SharedFactEncoder | None = None
130+
) -> str:
131+
"""Recompute one recorded fact and digest its value, sharing whole-project encodings."""
128132

129133
try:
130134
value: object = _fact_value(views=views, key=key)
131135
except FactDigestError:
132136
raise
133137
except Exception as error:
134138
return fact_error_digest(error)
139+
if shared is not None and key[0] in _SHARED_FACTS:
140+
try:
141+
return hashlib.sha256(shared.encode(("value", value))).hexdigest()
142+
except (TypeError, FactDigestError) as error:
143+
raise FactDigestError(str(error)) from error
135144
return fact_value_digest(value)
136145

137146

147+
def shared_fact_encoder() -> SharedFactEncoder:
148+
"""Return an encoder for whole-project facts that share compiler objects."""
149+
150+
return SharedFactEncoder(options=_ORJSON_OPTIONS, encode_default=_encode_default)
151+
152+
138153
def fact_value_digest(value: object) -> str:
139154
"""Digest one fact value exactly as a recomputed fact would digest it."""
140155

‎src/sqlbuild/rule_engine/classes/fact_digests.py‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@
88

99
import orjson
1010

11-
from sqlbuild.rule_engine._helpers.engine.fact_replay import fact_outcome_digest
11+
from sqlbuild.rule_engine._helpers.engine.fact_replay import (
12+
fact_outcome_digest,
13+
shared_fact_encoder,
14+
)
15+
from sqlbuild.rule_engine.classes.shared_fact_encoder import SharedFactEncoder
1216
from sqlbuild.rule_engine.models import RuleFactViews
1317
from sqlbuild.rule_engine.types import FactKey
1418

@@ -20,6 +24,7 @@ def __init__(self, *, views: Callable[[], RuleFactViews]) -> None:
2024
self._views_factory: Callable[[], RuleFactViews] = views
2125
self._views: RuleFactViews | None = None
2226
self._digests: dict[FactKey, str] = {}
27+
self._shared: SharedFactEncoder = shared_fact_encoder()
2328

2429
@property
2530
def views(self) -> RuleFactViews:
@@ -33,7 +38,7 @@ def digest(self, key: FactKey) -> str:
3338
cached: str | None = self._digests.get(key)
3439
if cached is not None:
3540
return cached
36-
digest: str = fact_outcome_digest(views=self.views, key=key)
41+
digest: str = fact_outcome_digest(views=self.views, key=key, shared=self._shared)
3742
self._digests[key] = digest
3843
return digest
3944

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
"""Digest whole-project fact values whose resources share large compiler objects."""
2+
3+
from __future__ import annotations
4+
5+
import dataclasses
6+
import hashlib
7+
from collections.abc import Callable
8+
9+
import orjson
10+
11+
from sqlbuild.rule_engine.constants import SHARED_FACT_DATACLASS_TAG
12+
13+
14+
class SharedFactEncoder:
15+
"""Encode each compiler dataclass once and refer to it by digest wherever it repeats."""
16+
17+
def __init__(self, *, options: int, encode_default: Callable[[object], object]) -> None:
18+
self._options: int = options | orjson.OPT_PASSTHROUGH_DATACLASS
19+
self._encode_default: Callable[[object], object] = encode_default
20+
self._fields: dict[type, tuple[str, ...]] = {}
21+
self._digests: dict[int, tuple[object, str]] = {}
22+
23+
def encode(self, value: object) -> bytes:
24+
return orjson.dumps(value, default=self._default, option=self._options)
25+
26+
def _default(self, value: object) -> object:
27+
kind: type = type(value)
28+
names: tuple[str, ...] | None = self._fields.get(kind)
29+
if names is None:
30+
if not dataclasses.is_dataclass(kind):
31+
return self._encode_default(value)
32+
names = tuple(field.name for field in dataclasses.fields(kind))
33+
self._fields[kind] = names
34+
cached: tuple[object, str] | None = self._digests.get(id(value))
35+
if cached is None:
36+
encoded: bytes = self.encode(
37+
[f"{kind.__module__}.{kind.__qualname__}", [getattr(value, name) for name in names]]
38+
)
39+
cached = (value, hashlib.sha256(encoded).hexdigest())
40+
self._digests[id(value)] = cached
41+
return [SHARED_FACT_DATACLASS_TAG, cached[1]]

‎src/sqlbuild/rule_engine/constants.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
NATIVE_RULES_MEMO_VERSION: str = "native-rules-response-v2"
1717
NATIVE_RULES_MEMO_FILE: str = "target/rules-cache/bulk/native-response.json"
1818
CUSTOM_HOST_INPUT_TUPLE_SIZE: int = 2
19+
SHARED_FACT_DATACLASS_TAG: str = "\0dataclass"
1920
CUSTOM_HOST_ABANDONED_SUFFIX: str = ".abandoned"
2021
CUSTOM_HOST_INPUT_POLL_SECONDS: float = 0.002
2122
CUSTOM_HOST_INPUT_WAIT_SECONDS: float = 120.0

‎tests/e2e/src/sqlbuild/cli/commands/main/rules/_test_types.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,3 +222,13 @@ class RulesEditChainCase:
222222
edits: tuple[RulesCacheEditCase, ...]
223223
expected_cold_codes: tuple[str, ...]
224224
expected_exit_code: int = 1
225+
226+
227+
@dataclass(frozen=True)
228+
class BrokenInvalidationChainCase:
229+
"""An edit chain compiled with deliberately stale fact digests against a cache-free oracle."""
230+
231+
description: str
232+
edits: tuple[RulesCacheEditCase, ...]
233+
stale_digests_sitecustomize: str
234+
expected_first_divergent_edit: str

‎tests/e2e/src/sqlbuild/cli/commands/main/rules/helpers.py‎

Lines changed: 120 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import hashlib
44
import json
55
import os
6+
import shutil
67
import subprocess
78
import sys
89
from collections.abc import Callable
@@ -411,6 +412,121 @@ def flagged_values(*, model: Model, ctx: RuleContext) -> list[Finding]:
411412
}
412413

413414

415+
_ORDER_SLICE_COUNT: int = 45
416+
CUSTOM_RULES_EDIT_PROJECT: dict[str, str] = {
417+
"sqlbuild_project.toml": (
418+
'name = "orders"\nadapter = "duckdb"\n\n'
419+
'[connection]\ndatabase = "warehouse.duckdb"\n\n'
420+
'[rules]\nselect = ["XSQBR"]\n\n'
421+
"[rules.thresholds]\nmin_custom_rule_test_cases = 0\n"
422+
),
423+
"sources/raw.yml": (
424+
"sources:\n"
425+
" - name: raw_orders\n description: Raw orders.\n"
426+
' expression: "(SELECT 1 AS id, CAST(10.5 AS DOUBLE) AS amount, 1 AS customer_id)"\n'
427+
" - name: raw_customers\n description: Raw customers.\n"
428+
' expression: "(SELECT 1 AS customer_id)"\n'
429+
" - name: raw_products\n description: Raw products.\n"
430+
' expression: "(SELECT 1 AS product_id)"\n'
431+
),
432+
"models/staging/stg_orders.sql": (
433+
'MODEL (description "Staged orders");\n\n'
434+
'SELECT id AS order_id, amount, customer_id FROM __source("raw_orders")\n'
435+
),
436+
"models/staging/stg_customers.sql": (
437+
'MODEL (description "Staged customers");\n\n'
438+
'SELECT customer_id FROM __source("raw_customers")\n'
439+
),
440+
"models/marts/order_totals.sql": (
441+
'MODEL (description "Order totals");\n\n'
442+
'SELECT order_id, amount * 2 AS doubled_amount FROM __ref("stg_orders") WHERE amount > 7\n'
443+
),
444+
**{
445+
f"models/intermediate/orders_{index:03d}.sql": (
446+
f'MODEL (description "Order slice {index}");\n\n'
447+
'SELECT order_id FROM __ref("stg_orders")\n'
448+
)
449+
for index in range(_ORDER_SLICE_COUNT)
450+
},
451+
"rules/approved_sources.yaml": "raw_orders\nraw_customers\n",
452+
"rules/orders.py": """from sqlbuild.rules import Finding, Model, Project, RuleContext, rule
453+
454+
455+
@rule(code="XSQBRORD101", message="Filters hard-code an amount", remediation="Use a constant.")
456+
def high_amount_filters(*, model: Model, ctx: RuleContext) -> list[Finding]:
457+
expanded: str = ctx.sql.for_model(model).expanded.source
458+
return [ctx.finding(subject=model)] if "amount > 9" in expanded else []
459+
460+
461+
@rule(code="XSQBRORD102", message="Staging models need a consumer", remediation="Use it.")
462+
def staging_consumers(*, model: Model, ctx: RuleContext) -> list[Finding]:
463+
if model.path.parent.name != "staging":
464+
return []
465+
return [] if ctx.graph.dependents(model) else [ctx.finding(subject=model)]
466+
467+
468+
@rule(code="XSQBRORD103", message="Marts must enforce a contract", remediation="Enforce it.")
469+
def mart_contracts(*, model: Model, ctx: RuleContext) -> list[Finding]:
470+
if model.path.parent.name != "marts":
471+
return []
472+
return [] if ctx.contracts.enforced(model) else [ctx.finding(subject=model)]
473+
474+
475+
@rule(code="XSQBRORD201", message="Sources must be approved", remediation="Approve it.")
476+
def approved_sources(*, project: Project, ctx: RuleContext) -> list[Finding]:
477+
del project
478+
approved: set[str] = set(ctx.project.tree.read_text("rules/approved_sources.yaml").split())
479+
names: list[str] = sorted(str(getattr(source, "name")) for source in ctx.project.sources)
480+
return [
481+
ctx.finding(subject="sources/raw.yml", message=f"Source {name} is not approved")
482+
for name in names
483+
if name not in approved
484+
]
485+
486+
487+
@rule(code="XSQBRORD202", message="Marts must stay few", remediation="Consolidate them.")
488+
def few_marts(*, project: Project, ctx: RuleContext) -> list[Finding]:
489+
del project
490+
marts: list[Model] = [item for item in ctx.project.models if item.path.parent.name == "marts"]
491+
if len(marts) < 2:
492+
return []
493+
return [ctx.finding(subject=marts[0], message=f"{len(marts)} marts")]
494+
""",
495+
}
496+
STALE_SOURCE_FACTS_SITECUSTOMIZE: str = """from sqlbuild.rule_engine.classes import fact_digests
497+
498+
_digest = fact_digests.FactDigests.digest
499+
500+
501+
def _stale_sources(self, key):
502+
return "stale" if key == ("project.sources",) else _digest(self, key)
503+
504+
505+
fact_digests.FactDigests.digest = _stale_sources
506+
"""
507+
508+
509+
def custom_rules_edit_chain(
510+
*, root: Path, edits: tuple[Any, ...], environment: tuple[tuple[str, str], ...] = ()
511+
) -> tuple[
512+
tuple[int, str, str], tuple[tuple[int, str, str], ...], tuple[tuple[int, str, str], ...]
513+
]:
514+
"""Compile the custom-rule project cold, then after each edit beside a cache-free oracle."""
515+
516+
project_dir: Path = root / "orders"
517+
write_project_files(project_dir=project_dir, files=CUSTOM_RULES_EDIT_PROJECT)
518+
cold: tuple[int, str, str] = rules_compile_outcome(project_dir, environment=environment)
519+
edited: list[tuple[int, str, str]] = []
520+
oracles: list[tuple[int, str, str]] = []
521+
for step, edit_case in enumerate(edits):
522+
edit_case.edit(project_dir)
523+
edited.append(rules_compile_outcome(project_dir, environment=environment))
524+
oracle_dir: Path = root / f"oracle_{step}"
525+
shutil.copytree(project_dir, oracle_dir, ignore=shutil.ignore_patterns("target"))
526+
oracles.append(rules_compile_outcome(oracle_dir, "--no-cache"))
527+
return cold, tuple(edited), tuple(oracles)
528+
529+
414530
def write_project_files(*, project_dir: Path, files: dict[str, str]) -> None:
415531
"""Write authored project files beneath one project directory."""
416532

@@ -420,7 +536,9 @@ def write_project_files(*, project_dir: Path, files: dict[str, str]) -> None:
420536
path.write_text(contents, encoding="utf-8")
421537

422538

423-
def rules_compile_outcome(project_dir: Path, *arguments: str) -> tuple[int, str, str]:
539+
def rules_compile_outcome(
540+
project_dir: Path, *arguments: str, environment: tuple[tuple[str, str], ...] = ()
541+
) -> tuple[int, str, str]:
424542
"""Compile in a fresh process and return its exit code, diagnostics, and artifacts digest."""
425543

426544
result: subprocess.CompletedProcess[str] = subprocess.run(
@@ -436,6 +554,7 @@ def rules_compile_outcome(project_dir: Path, *arguments: str) -> tuple[int, str,
436554
capture_output=True,
437555
text=True,
438556
check=False,
557+
env={**os.environ, **dict(environment)},
439558
)
440559
payload: dict[str, Any] = json.loads(result.stdout or "{}")
441560
artifacts: Any = hashlib.sha256()

0 commit comments

Comments
 (0)