Skip to content

Commit 3b5f131

Browse files
authored
perf(planner): read fingerprint state once and keep plan generation linear (#904)
* perf(planner): read fingerprint state once per plan Full-project plans and projects declaring migrations read the latest fingerprint per identity without a node filter, apply the selection in memory, and share the rows with migration inspection instead of reading the state table a second time. Narrow selections keep the filtered read. * perf(observability): cut per-statement monitoring overhead Statement monitors without a query-ID provider now start their thread only once a statement outlives the heartbeat threshold, through one shared deferred-start daemon, instead of starting and joining a thread for every statement. Lifecycle events are validated once at construction rather than again on publish and on sequencing, JSON freezing builds error paths only on failure, and statements hash their SQL once. Recorded events, diagnostics, and -v output are unchanged. * perf(planner): resolve graph identity hashes in linear time Expected and write identity resolution copied the whole hash cache for every resolved node, which made identity context building and change reconciliation quadratic in project size. A memoized resolver now records each node once with the same resolution order, cycle handling, and results. * perf(planner): overlay per-test fixture types instead of copying them SQL-test fixture completion copied the project-wide expected-type map for every test, which made planning test-heavy projects quadratic. Each test now overlays only the models of its chain on the shared map. * fix(cli): report rules phase wall time on its progress line The rules completion line reported only built-in and custom rule time, which run concurrently and are not phase wall time, so users could not see how long the rules phase took next to the compile line. It now leads with the phase wall time in the same form as other phase lines. * test(planner): guard plan phase scaling on the inspection benchmark Plan the inspection benchmark at 1,000 and 3,000 models without the compile cache and fail when warehouse inspection or plan generation grows more than 1.5 times faster than the project, or when either phase exceeds a loose ceiling. * fix(observability): release finished statements from deferred monitor starts A stopped monitor now cancels its pending deferred start and drops its callbacks and query-ID provider, so a finished statement and its SQL are no longer retained until the heartbeat deadline. A monitor thread is recorded only after it starts, so a refused start no longer makes stop() join an unstarted thread and fail an otherwise successful statement. * test(pipeline): account for the rules wall-time clock read in phase timings The rules progress line reads the clock once before rules run, between the compile and planning windows. The phase-timing test now supplies that read, gives rules a measurable duration, and proves compile and planning stay disjoint from it, with and without progress reporting.
1 parent 6d9564a commit 3b5f131

41 files changed

Lines changed: 1464 additions & 148 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎src/sqlbuild/cli/commands/_helpers/compile/pipeline.py‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
from sqlbuild.presentation.classes.transient_status_reporter import TransientStatusReporter
5454
from sqlbuild.rule_engine.classes.early_sql_lint import EarlySqlLint
5555
from sqlbuild.rule_engine.main.load_config import load_rules_config
56+
from sqlbuild.rule_engine.main.render_rules_progress import format_rules_progress
5657
from sqlbuild.rule_engine.main.run_rules import run_rules
5758
from sqlbuild.rule_engine.models import RulesConfig, RulesRunResult
5859
from sqlbuild.runtime.observability.classes.operation_lifecycle import OperationLifecycle
@@ -204,6 +205,7 @@ def _analyze_compile_project(
204205
):
205206
prepared_artifacts.start(project=graph.project, adapter=adapter)
206207
_ = start_compile_phase(status=status, message="Evaluating built-in and custom rules...")
208+
rules_start: float = time.monotonic()
207209
rules_result = run_rules(
208210
graph=graph,
209211
discovered_inputs=discovered_inputs,
@@ -217,9 +219,8 @@ def _analyze_compile_project(
217219
)
218220
_ = complete_compile_phase(
219221
status=status,
220-
message=(
221-
f"Evaluated rules. (built-in {rules_result.built_in_ms / 1000:.2f}s, "
222-
f"custom {rules_result.custom_ms / 1000:.2f}s)"
222+
message=format_rules_progress(
223+
elapsed_seconds=time.monotonic() - rules_start, result=rules_result
223224
),
224225
)
225226
rule_diagnostics: tuple[CompilerDiagnostic, ...] = tuple(
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
"""Latest fingerprint set assembly shared by warehouse reads and in-memory selection."""
2+
3+
from __future__ import annotations
4+
5+
from collections.abc import Iterable
6+
7+
from sqlbuild.compiler.fingerprints.constants import NODE_TYPE_MODEL
8+
from sqlbuild.compiler.fingerprints.models import Fingerprint, FingerprintSet
9+
10+
11+
def build_latest_fingerprint_set(*, schema: str, latest: Iterable[Fingerprint]) -> FingerprintSet:
12+
"""Index latest rows by identity and by node name, preferring models on name collisions."""
13+
14+
fingerprints: dict[str, Fingerprint] = {}
15+
fingerprints_by_identity: dict[tuple[str, str], Fingerprint] = {}
16+
fingerprint: Fingerprint
17+
for fingerprint in latest:
18+
fingerprints_by_identity[(fingerprint.node_type, fingerprint.node_name)] = fingerprint
19+
if fingerprint.node_type == NODE_TYPE_MODEL or fingerprint.node_name not in fingerprints:
20+
fingerprints[fingerprint.node_name] = fingerprint
21+
return FingerprintSet(
22+
schema=schema,
23+
fingerprints=fingerprints,
24+
fingerprints_by_identity=fingerprints_by_identity,
25+
)
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
"""In-memory node selection over an unfiltered latest fingerprint read."""
2+
3+
from __future__ import annotations
4+
5+
from sqlbuild.compiler.fingerprints._helpers.latest import build_latest_fingerprint_set
6+
from sqlbuild.compiler.fingerprints.models import FingerprintSet
7+
8+
9+
def select_latest_fingerprints(
10+
*,
11+
fingerprint_set: FingerprintSet,
12+
node_names: tuple[str, ...],
13+
filtered_node_types: tuple[str, ...] = (),
14+
) -> FingerprintSet:
15+
"""Apply the ``read_latest_fingerprints`` node filter to an unfiltered latest read."""
16+
17+
if not node_names and not filtered_node_types:
18+
return FingerprintSet(schema=fingerprint_set.schema, fingerprints={})
19+
names: frozenset[str] = frozenset(node_names)
20+
types: frozenset[str] = frozenset(filtered_node_types)
21+
return build_latest_fingerprint_set(
22+
schema=fingerprint_set.schema,
23+
latest=(
24+
fingerprint
25+
for fingerprint in (fingerprint_set.fingerprints_by_identity or {}).values()
26+
if fingerprint.node_name in names or (types and fingerprint.node_type not in types)
27+
),
28+
)

‎src/sqlbuild/compiler/fingerprints/main/read.py‎

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@
99
from typing import Any
1010

1111
from sqlbuild.adapter.contract.types import AdapterExecute
12+
from sqlbuild.compiler.fingerprints._helpers.latest import build_latest_fingerprint_set
1213
from sqlbuild.compiler.fingerprints._helpers.sql import (
1314
build_qualified_table_name,
1415
)
15-
from sqlbuild.compiler.fingerprints.constants import NODE_TYPE_MODEL
1616
from sqlbuild.compiler.fingerprints.exceptions import FingerprintInputError
1717
from sqlbuild.compiler.fingerprints.models import Fingerprint, FingerprintSet
1818

@@ -75,18 +75,9 @@ def read_latest_fingerprints(
7575
"fingerprint table to regenerate fingerprints."
7676
) from error
7777
rows: list[tuple[Any, ...]] = result.fetchall()
78-
fingerprints: dict[str, Fingerprint] = {}
79-
fingerprints_by_identity: dict[tuple[str, str], Fingerprint] = {}
80-
row: tuple[Any, ...]
81-
for row in rows:
82-
fingerprint: Fingerprint = _row_to_fingerprint(row=row, qualified_name=qualified_name)
83-
fingerprints_by_identity[(fingerprint.node_type, fingerprint.node_name)] = fingerprint
84-
if fingerprint.node_type == NODE_TYPE_MODEL or fingerprint.node_name not in fingerprints:
85-
fingerprints[fingerprint.node_name] = fingerprint
86-
return FingerprintSet(
78+
return build_latest_fingerprint_set(
8779
schema=schema,
88-
fingerprints=fingerprints,
89-
fingerprints_by_identity=fingerprints_by_identity,
80+
latest=(_row_to_fingerprint(row=row, qualified_name=qualified_name) for row in rows),
9081
)
9182

9283

‎src/sqlbuild/compiler/pipeline/main/compile.py‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@
6666
from sqlbuild.rule_engine.exceptions import RulesError
6767
from sqlbuild.rule_engine.main.load_config import load_rules_config
6868
from sqlbuild.rule_engine.main.render_result import format_result
69-
from sqlbuild.rule_engine.main.render_skipped_rules import format_skipped_type_proof_rules
69+
from sqlbuild.rule_engine.main.render_rules_progress import format_rules_progress
7070
from sqlbuild.rule_engine.main.run_rules import run_rules
7171
from sqlbuild.rule_engine.models import RulesCacheConfig, RulesConfig, RulesResult, RulesRunResult
7272
from sqlbuild.runtime.contracts.main.open_connection import open_connection_with_hooks
@@ -106,6 +106,7 @@ def run_compile_pipeline(
106106
rules_config = replace(rules_config, cache=RulesCacheConfig(enabled=False))
107107
if on_progress is not None:
108108
on_progress("Evaluating configured rules...")
109+
rules_start: float = time.monotonic()
109110
rules_result: RulesRunResult = run_rules(
110111
graph=graph,
111112
discovered_inputs=discovered_inputs,
@@ -120,13 +121,12 @@ def run_compile_pipeline(
120121
no_sql_analysis=resolved_options.no_sql_validation,
121122
)
122123
if on_progress is not None:
123-
skipped_rules_note: str | None = format_skipped_type_proof_rules(
124-
codes=rules_result.skipped_type_proof_rules
125-
)
126124
on_progress(
127-
f"Evaluated rules. (built-in {rules_result.built_in_ms / 1000:.2f}s, "
128-
f"custom {rules_result.custom_ms / 1000:.2f}s)"
129-
+ ("" if skipped_rules_note is None else f"; {skipped_rules_note}")
125+
format_rules_progress(
126+
elapsed_seconds=time.monotonic() - rules_start,
127+
result=rules_result,
128+
note_skipped_rules=True,
129+
)
130130
)
131131
if rules_result.findings:
132132
rendered: str = format_result(

‎src/sqlbuild/compiler/planner/_helpers/fixtures/completion.py‎

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

55
import re
6-
from collections.abc import Sequence
6+
from collections import ChainMap
7+
from collections.abc import Mapping, Sequence
78
from functools import lru_cache
89
from typing import Any
910

@@ -91,7 +92,7 @@ def build_relation_fixture_completion(
9192
authoritative_columns=context.authoritative_columns,
9293
adapter=adapter,
9394
)
94-
expected_types: dict[FixtureKey, dict[str, str]] = _expected_fixture_types(
95+
expected_types: Mapping[FixtureKey, dict[str, str]] = _expected_fixture_types(
9596
base_expected_types=context.expected_types,
9697
ordered_model_names=ordered_model_names,
9798
model_map=model_map,
@@ -327,7 +328,7 @@ def complete_empty_fixture_sql(
327328
def _typed_null_fixture_types(
328329
*,
329330
null_literal_names_by_fixture: dict[FixtureKey, frozenset[str]],
330-
expected_types: dict[FixtureKey, dict[str, str]],
331+
expected_types: Mapping[FixtureKey, dict[str, str]],
331332
) -> dict[FixtureKey, dict[str, str]]:
332333
result: dict[FixtureKey, dict[str, str]] = {}
333334
for key, null_literal_names in null_literal_names_by_fixture.items():
@@ -665,8 +666,8 @@ def _expected_fixture_types(
665666
base_expected_types: dict[FixtureKey, dict[str, str]],
666667
ordered_model_names: tuple[str, ...],
667668
model_map: dict[str, CompiledModel],
668-
) -> dict[FixtureKey, dict[str, str]]:
669-
expected_types: dict[FixtureKey, dict[str, str]] = dict(base_expected_types)
669+
) -> Mapping[FixtureKey, dict[str, str]]:
670+
expected_types: ChainMap[FixtureKey, dict[str, str]] = ChainMap({}, base_expected_types)
670671
for model_name in ordered_model_names:
671672
model: CompiledModel | None = model_map.get(model_name)
672673
if model is None:

‎src/sqlbuild/compiler/planner/_helpers/identity/graph.py‎

Lines changed: 14 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from collections.abc import Mapping
66

7+
from sqlbuild.compiler.planner.classes.graph_identity_resolver import GraphIdentityResolver
78
from sqlbuild.compiler.planner.models import GraphIdentityNode, GraphNodeKey
89
from sqlbuild.compiler.planner.types import GraphIdentityComposer
910

@@ -14,37 +15,13 @@ def build_expected_graph_identity_hashes(
1415
execution_order: tuple[GraphNodeKey, ...],
1516
compose_identity: GraphIdentityComposer,
1617
) -> dict[GraphNodeKey, str | None]:
17-
hashes: dict[GraphNodeKey, str | None] = {}
18-
19-
def resolve(
20-
*,
21-
key: GraphNodeKey,
22-
visiting: frozenset[GraphNodeKey],
23-
cache: dict[GraphNodeKey, str | None],
24-
) -> tuple[str | None, dict[GraphNodeKey, str | None]]:
25-
if key in cache:
26-
return cache[key], cache
27-
node: GraphIdentityNode | None = nodes.get(key)
28-
if node is None or node.local_hash is None:
29-
return None, {**cache, key: None}
30-
if key in visiting:
31-
return node.local_hash, {**cache, key: node.local_hash}
32-
upstream_hashes: list[tuple[GraphNodeKey, str]] = []
33-
upstream_key: GraphNodeKey
34-
for upstream_key in node.upstream_keys:
35-
upstream_hash, cache = resolve(key=upstream_key, visiting=visiting | {key}, cache=cache)
36-
if upstream_hash is not None:
37-
upstream_hashes.append((upstream_key, upstream_hash))
38-
composed: str = compose_identity(
39-
local_hash=node.local_hash,
40-
upstream_hashes=tuple(upstream_hashes),
41-
)
42-
return composed, {**cache, key: composed}
43-
18+
resolver: GraphIdentityResolver = GraphIdentityResolver(
19+
nodes=nodes, compose_identity=compose_identity, base_hashes={}, selected_keys=None
20+
)
4421
key: GraphNodeKey
4522
for key in execution_order:
46-
_, hashes = resolve(key=key, visiting=frozenset(), cache=hashes)
47-
return hashes
23+
_ = resolver.resolve_expected(key)
24+
return resolver.hashes
4825

4926

5027
def build_graph_write_identity_hashes(
@@ -55,48 +32,13 @@ def build_graph_write_identity_hashes(
5532
base_identity_hashes: Mapping[GraphNodeKey, str],
5633
compose_identity: GraphIdentityComposer,
5734
) -> dict[GraphNodeKey, str]:
58-
hashes: dict[GraphNodeKey, str] = dict(base_identity_hashes)
59-
resolved_selected: dict[GraphNodeKey, str] = {}
60-
61-
def resolve(
62-
*,
63-
key: GraphNodeKey,
64-
visiting: frozenset[GraphNodeKey],
65-
cache: dict[GraphNodeKey, str],
66-
selected_cache: dict[GraphNodeKey, str],
67-
) -> tuple[str | None, dict[GraphNodeKey, str], dict[GraphNodeKey, str]]:
68-
if key not in selected_keys:
69-
return cache.get(key), cache, selected_cache
70-
if key in selected_cache:
71-
return selected_cache[key], cache, selected_cache
72-
node: GraphIdentityNode | None = nodes.get(key)
73-
if node is None or node.local_hash is None:
74-
return cache.get(key), cache, selected_cache
75-
if key in visiting:
76-
return node.local_hash, cache, selected_cache
77-
upstream_hashes: list[tuple[GraphNodeKey, str]] = []
78-
upstream_key: GraphNodeKey
79-
for upstream_key in node.upstream_keys:
80-
upstream_hash, cache, selected_cache = resolve(
81-
key=upstream_key,
82-
visiting=visiting | {key},
83-
cache=cache,
84-
selected_cache=selected_cache,
85-
)
86-
if upstream_hash is not None:
87-
upstream_hashes.append((upstream_key, upstream_hash))
88-
composed: str = compose_identity(
89-
local_hash=node.local_hash,
90-
upstream_hashes=tuple(upstream_hashes),
91-
)
92-
return composed, {**cache, key: composed}, {**selected_cache, key: composed}
93-
35+
resolver: GraphIdentityResolver = GraphIdentityResolver(
36+
nodes=nodes,
37+
compose_identity=compose_identity,
38+
base_hashes=base_identity_hashes,
39+
selected_keys=selected_keys,
40+
)
9441
key: GraphNodeKey
9542
for key in execution_order:
96-
_, hashes, resolved_selected = resolve(
97-
key=key,
98-
visiting=frozenset(),
99-
cache=hashes,
100-
selected_cache=resolved_selected,
101-
)
102-
return hashes
43+
_ = resolver.resolve_write(key)
44+
return {hashed_key: value for hashed_key, value in resolver.hashes.items() if value is not None}

‎src/sqlbuild/compiler/planner/_helpers/migrations/planning.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ def plan_model_migrations(
147147
adapter=runtime.adapter,
148148
connection=runtime.connection,
149149
database=planning_database(runtime=runtime),
150+
known_fingerprints=snapshot.fingerprints,
150151
)
151152
declares_migrations: bool = any(
152153
_declared_request(model) is not None for model in runtime.project.models

0 commit comments

Comments
 (0)