Skip to content

Commit 9fae2ec

Browse files
authored
perf(compile): stop the unused early SQL lint and map lint lines once per file (#919)
Signal the speculative lint thread to stop as soon as the compile knows its result will not be consumed (graph errors or end of analysis), checking the stop between files and bodies instead of joining a full lint run. Report any remaining join as compile_timings.early_lint_wait_ms. Build each file's newline table once per native lint pass instead of once per violation.
1 parent 2fafd63 commit 9fae2ec

19 files changed

Lines changed: 375 additions & 32 deletions

File tree

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,8 @@ def _analyze_compile_project(
155155
_ = complete_compile_phase(
156156
status=status, message=f"Compiled project graph. ({graph_ms / 1000:.2f}s)"
157157
)
158+
graph_failed: bool = any(diagnostic.is_error for diagnostic in graph.project.diagnostics)
159+
early_lint_wait_ms: int = early_lint.stop() if graph_failed else 0
158160
lineage_start: float = time.monotonic()
159161
_ = start_compile_phase(status=status, message="Analyzing column lineage...")
160162
lineage: ProjectColumnLineage | None = build_compile_lineage(
@@ -197,7 +199,7 @@ def _analyze_compile_project(
197199
*graph.project.diagnostics,
198200
*contract_result.diagnostics,
199201
)
200-
if not any(diagnostic.is_error for diagnostic in graph.project.diagnostics):
202+
if not graph_failed:
201203
rules_config: RulesConfig = load_rules_config(project_dir=project_dir)
202204
if (
203205
prepared_artifacts is not None
@@ -224,6 +226,7 @@ def _analyze_compile_project(
224226
elapsed_seconds=time.monotonic() - rules_start, result=rules_result
225227
),
226228
)
229+
early_lint_wait_ms = early_lint.stop()
227230
rule_diagnostics: tuple[CompilerDiagnostic, ...] = tuple(
228231
CompilerDiagnostic(
229232
phase=DiagnosticPhase.RULE,
@@ -252,6 +255,7 @@ def _analyze_compile_project(
252255
contract_ms=contract_ms,
253256
built_in_rules_ms=rules_result.built_in_ms,
254257
custom_rules_ms=rules_result.custom_ms,
258+
early_lint_wait_ms=early_lint_wait_ms,
255259
rule_cache_hits=rules_result.cache_hits,
256260
rule_cache_misses=rules_result.cache_misses,
257261
skipped_type_proof_rules=rules_result.skipped_type_proof_rules,

‎src/sqlbuild/cli/commands/main/project/_compile.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ def _run_compile_with_status(
124124
"contracts_ms": analysis.contract_ms,
125125
"built_in_rules_ms": analysis.built_in_rules_ms,
126126
"custom_rules_ms": analysis.custom_rules_ms,
127+
"early_lint_wait_ms": analysis.early_lint_wait_ms,
127128
"rule_cache_hits": analysis.rule_cache_hits,
128129
"rule_cache_misses": analysis.rule_cache_misses,
129130
"write_ms": write_result.write_ms,

‎src/sqlbuild/cli/compile/models.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ class CompileAnalysis:
6060
contract_ms: int
6161
built_in_rules_ms: int = 0
6262
custom_rules_ms: int = 0
63+
early_lint_wait_ms: int = 0
6364
rule_cache_hits: int = 0
6465
rule_cache_misses: int = 0
6566
skipped_type_proof_rules: tuple[str, ...] = ()

‎src/sqlbuild/lint/_helpers/native_sql.py‎

Lines changed: 33 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import re
66
from bisect import bisect_right
77
from pathlib import Path
8+
from threading import Event
89
from typing import Any, cast
910

1011
import orjson
@@ -28,14 +29,15 @@
2829
from sqlbuild.compiler.compile.main.map_expanded_offset import map_expanded_offset
2930
from sqlbuild.compiler.compile.models import MappedOffset
3031
from sqlbuild.lint._helpers.sqlbuild_tokens import interpolation_text_at
32+
from sqlbuild.lint.classes.stop_context import LintStopContext
3133
from sqlbuild.lint.constants import (
3234
GENERATED_SQL_MESSAGE_SUFFIX,
3335
GENERATED_SQL_MESSAGE_TEMPLATE,
3436
LINT_ENGINE_NATIVE,
3537
VIOLATION_SEVERITY_FAULT,
3638
VIOLATION_SEVERITY_WARNING,
3739
)
38-
from sqlbuild.lint.exceptions import NativeLintError
40+
from sqlbuild.lint.exceptions import LintStoppedError, NativeLintError
3941
from sqlbuild.lint.models import LintBody, LintConfig, LintEdit, LintViolation
4042

4143
_NATIVE_LINT_API_VERSION: int = 1
@@ -87,6 +89,7 @@ def run_native_sql_lint(
8789
"""Lint expanded bodies in Rust and map diagnostics to authored source."""
8890

8991
violations_by_file: dict[Path, list[LintViolation]] = {}
92+
line_starts_by_path: dict[Path, tuple[int, ...]] = {}
9093
requests: dict[_NativeCacheKey, dict[str, object]] = {}
9194
for body in bodies:
9295
cache_key: _NativeCacheKey = _native_cache_key(body=body, config=config)
@@ -118,15 +121,20 @@ def run_native_sql_lint(
118121
if cache_key[-1]:
119122
payload["relation_keys"] = dict(cache_key[-1])
120123
requests[cache_key] = payload
124+
check_lint_not_stopped()
121125
response_cache: dict[_NativeCacheKey, _NativeResult] = _native_responses(requests=requests)
122126

123127
for body in bodies:
128+
check_lint_not_stopped()
124129
response: _NativeResult = response_cache[_native_cache_key(body=body, config=config)]
130+
contents: str = contents_by_path[body.file_path]
131+
line_starts: tuple[int, ...] | None = line_starts_by_path.get(body.file_path)
132+
if line_starts is None:
133+
line_starts = _line_starts(contents)
134+
line_starts_by_path[body.file_path] = line_starts
125135
if isinstance(response, NativeLintError):
126136
parse_violation: LintViolation | None = _parse_failure_violation(
127-
error=response,
128-
body=body,
129-
contents=contents_by_path[body.file_path],
137+
error=response, body=body, line_starts=line_starts
130138
)
131139
if parse_violation is None:
132140
raise response
@@ -140,18 +148,27 @@ def run_native_sql_lint(
140148
violation: LintViolation | None = _authored_violation(
141149
raw_diagnostic=raw_diagnostic,
142150
body=body,
143-
contents=contents_by_path[body.file_path],
151+
contents=contents,
152+
line_starts=line_starts,
144153
dialect=config.dialect,
145154
)
146155
except NativeLintError as error:
147156
violation = _parse_failure_violation(
148-
error=error, body=body, contents=contents_by_path[body.file_path]
157+
error=error, body=body, line_starts=line_starts
149158
)
150159
if violation is not None:
151160
violations_by_file.setdefault(body.file_path, []).append(violation)
152161
return {path: tuple(entries) for path, entries in violations_by_file.items()}
153162

154163

164+
def check_lint_not_stopped() -> None:
165+
"""Abandon a speculative lint run on this thread once its owner no longer needs it."""
166+
167+
stop: Event | None = LintStopContext.active.get()
168+
if stop is not None and stop.is_set():
169+
raise LintStoppedError
170+
171+
155172
def _native_cache_key(*, body: LintBody, config: LintConfig) -> _NativeCacheKey:
156173
return (
157174
body.lint_text,
@@ -223,7 +240,7 @@ def _native_result(*, response: object) -> _NativeResult:
223240

224241

225242
def _parse_failure_violation(
226-
*, error: NativeLintError, body: LintBody, contents: str
243+
*, error: NativeLintError, body: LintBody, line_starts: tuple[int, ...]
227244
) -> LintViolation:
228245
"""Convert a per-body native parse rejection into an authored project fault."""
229246

@@ -237,7 +254,7 @@ def _parse_failure_violation(
237254
expanded_offset += min(expanded_column - 1, len(expanded_lines[expanded_line - 1]))
238255
mapped: MappedOffset = map_expanded_offset(offset=expanded_offset, passes=body.passes)
239256
absolute_offset: int = body.body_start + mapped.offset
240-
line, column = _offset_position(offset=absolute_offset, line_starts=_line_starts(contents))
257+
line, column = _offset_position(offset=absolute_offset, line_starts=line_starts)
241258
return LintViolation(
242259
file_path=body.file_path,
243260
line=line,
@@ -251,7 +268,12 @@ def _parse_failure_violation(
251268

252269

253270
def _authored_violation(
254-
*, raw_diagnostic: object, body: LintBody, contents: str, dialect: str
271+
*,
272+
raw_diagnostic: object,
273+
body: LintBody,
274+
contents: str,
275+
line_starts: tuple[int, ...],
276+
dialect: str,
255277
) -> LintViolation | None:
256278
if not isinstance(raw_diagnostic, dict):
257279
raise NativeLintError("native lint diagnostic must be an object")
@@ -286,16 +308,15 @@ def _authored_violation(
286308
_SQLBUILD_HARNESS_CTE_PREFIXES, absolute_offset
287309
):
288310
return None
289-
starts: tuple[int, ...] = _line_starts(contents)
290-
line, column = _offset_position(offset=absolute_offset, line_starts=starts)
311+
line, column = _offset_position(offset=absolute_offset, line_starts=line_starts)
291312
end_position: tuple[int, int] | None = _authored_end_position(
292313
start=start,
293314
end=end,
294315
mapped_start=mapped,
295316
absolute_start=absolute_offset,
296317
body=body,
297318
contents=contents,
298-
line_starts=starts,
319+
line_starts=line_starts,
299320
dialect=dialect,
300321
)
301322
fix: LintEdit | None = _authored_fix(
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Runtime classes for the lint and format layer."""
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
"""Thread-local stop signal for speculative lint runs."""
2+
3+
from contextvars import ContextVar
4+
from threading import Event
5+
from typing import ClassVar
6+
7+
8+
class LintStopContext:
9+
"""Own the stop signal that lets a speculative lint run be abandoned between files."""
10+
11+
active: ClassVar[ContextVar[Event | None]] = ContextVar("active_lint_stop", default=None)

‎src/sqlbuild/lint/exceptions.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,7 @@ class ProjectCompileError(LintError):
3131
"""The project could not be compiled, so its SQL cannot be linted."""
3232

3333
code: str = "L005"
34+
35+
36+
class LintStoppedError(Exception):
37+
"""A speculative lint run was stopped because its result is no longer needed."""

‎src/sqlbuild/lint/main/run_lint.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from sqlbuild.lint._helpers.expansion import build_lint_expansion_context, prepare_lint_body
1818
from sqlbuild.lint._helpers.headers import lint_body_ranges, lint_file_role, scan_headers
1919
from sqlbuild.lint._helpers.native import external_identifiers_for_headers, lint_native_headers
20-
from sqlbuild.lint._helpers.native_sql import run_native_sql_lint
20+
from sqlbuild.lint._helpers.native_sql import check_lint_not_stopped, run_native_sql_lint
2121
from sqlbuild.lint._helpers.project_files import collect_project_files, sort_violations
2222
from sqlbuild.lint._helpers.suppressions import apply_suppressions
2323
from sqlbuild.lint.constants import HEADER_KIND_SCENARIO, HEADER_KIND_TEST
@@ -154,6 +154,7 @@ def _prepared_bodies(
154154
) -> tuple[LintBody, ...] | str:
155155
"""Prepared bodies of one file, or the reason its SQL cannot be expanded when skipping."""
156156

157+
check_lint_not_stopped()
157158
bodies: list[LintBody] = []
158159
role: LintFileRole = lint_file_role(
159160
file_path=file_path, project_dir=project_dir, relative_path=relative_path

‎src/sqlbuild/rule_engine/_helpers/run/rules.py‎

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@
77
import os
88
import time
99
from concurrent.futures import Executor, Future, ThreadPoolExecutor
10+
from contextvars import Token
1011
from dataclasses import asdict, dataclass, replace
1112
from pathlib import Path
13+
from threading import Event
1214
from typing import Any
1315

1416
import sqlbuild._native as _native
@@ -25,6 +27,7 @@
2527
from sqlbuild.compiler.compile.types import CompiledResourceType
2628
from sqlbuild.compiler.discovery.models import DiscoveredProjectInputs
2729
from sqlbuild.compiler.pipeline.models import ProjectGraph
30+
from sqlbuild.lint.classes.stop_context import LintStopContext
2831
from sqlbuild.lint.constants import (
2932
DEFAULT_MAX_LITERAL_LENGTH,
3033
DEFAULT_MAX_RANKING_ORDER_BY,
@@ -244,7 +247,7 @@ def _config_references_custom_rules(config: RulesConfig) -> bool:
244247

245248

246249
def prepare_sql_rules(
247-
*, inputs: CompileProjectInputs, executor: Executor, dialect: str
250+
*, inputs: CompileProjectInputs, executor: Executor, dialect: str, stop: Event
248251
) -> PreparedSqlLint | None:
249252
project_dir: Path | None = inputs.discovered_inputs.project_dir
250253
if project_dir is None or any(diagnostic.is_error for diagnostic in inputs.diagnostics):
@@ -302,6 +305,7 @@ def prepare_sql_rules(
302305
for model in inputs.model_inputs
303306
if model.sql_expansion is not None
304307
},
308+
stop=stop,
305309
),
306310
)
307311

@@ -313,19 +317,24 @@ def _prepare_sql_lint(
313317
discovered_inputs: DiscoveredProjectInputs,
314318
static_declaration_scope: DeclarationScopeBuild | None,
315319
compiled_expansions: dict[Path, CompiledSqlExpansion],
320+
stop: Event,
316321
) -> PreparedSqlLintResult:
317-
context: SqlExpansionContext = build_expansion_context(
318-
project_dir=project_dir,
319-
discovered_inputs=discovered_inputs,
320-
static_declaration_scope=static_declaration_scope,
321-
)
322-
result: LintRunResult = run_lint(
323-
project_dir=project_dir,
324-
config=config,
325-
discovered_inputs=discovered_inputs,
326-
compiled_expansions=compiled_expansions,
327-
expansion_context=context,
328-
)
322+
stop_token: Token[Event | None] = LintStopContext.active.set(stop)
323+
try:
324+
context: SqlExpansionContext = build_expansion_context(
325+
project_dir=project_dir,
326+
discovered_inputs=discovered_inputs,
327+
static_declaration_scope=static_declaration_scope,
328+
)
329+
result: LintRunResult = run_lint(
330+
project_dir=project_dir,
331+
config=config,
332+
discovered_inputs=discovered_inputs,
333+
compiled_expansions=compiled_expansions,
334+
expansion_context=context,
335+
)
336+
finally:
337+
LintStopContext.active.reset(stop_token)
329338
return PreparedSqlLintResult(result=result, context=context)
330339

331340

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

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22

33
from __future__ import annotations
44

5+
import time
56
from concurrent.futures import ThreadPoolExecutor
7+
from threading import Event
68
from types import TracebackType
79

810
from sqlbuild.compiler.compile.models import CompileProjectInputs
@@ -15,6 +17,7 @@ def __init__(self, *, enabled: bool) -> None:
1517
self.enabled: bool = enabled
1618
self.preparation: PreparedSqlLint | None = None
1719
self.expansion_reuse: SqlExpansionReuse | None = None
20+
self.stop_requested: Event = Event()
1821
self.executor: ThreadPoolExecutor = ThreadPoolExecutor(
1922
max_workers=1, thread_name_prefix="sqlbuild-lint"
2023
)
@@ -28,12 +31,15 @@ def __exit__(
2831
exception: BaseException | None,
2932
traceback: TracebackType | None,
3033
) -> None:
31-
self.executor.shutdown(wait=True, cancel_futures=True)
34+
_ = self.stop()
3235

3336
def start(self, *, inputs: CompileProjectInputs, dialect: str) -> None:
3437
if self.enabled:
3538
self.preparation = prepare_sql_rules(
36-
inputs=inputs, executor=self.executor, dialect=dialect
39+
inputs=inputs,
40+
executor=self.executor,
41+
dialect=dialect,
42+
stop=self.stop_requested,
3743
)
3844
self.expansion_reuse = (
3945
None
@@ -43,3 +49,11 @@ def start(self, *, inputs: CompileProjectInputs, dialect: str) -> None:
4349
declaration_scope=inputs.declaration_scope,
4450
)
4551
)
52+
53+
def stop(self) -> int:
54+
"""Abandon unconsumed speculative work and return the milliseconds spent joining it."""
55+
56+
stop_start: float = time.monotonic()
57+
self.stop_requested.set()
58+
self.executor.shutdown(wait=True, cancel_futures=True)
59+
return int((time.monotonic() - stop_start) * 1000)

0 commit comments

Comments
 (0)