Skip to content

Commit 0b86fac

Browse files
authored
perf(compile): reuse the previous compile when no input changed (#951)
* perf(compile): reuse the previous compile when no input changed Replay the stored report, diagnostics, and exit code when the project files, invocation, tracked environment, runtime, and written artifacts all match the previous compile. Misses, bypasses, and damaged entries fall back to a full compile. * docs: describe compile reuse of unchanged inputs
1 parent 4ddb437 commit 0b86fac

55 files changed

Lines changed: 4842 additions & 24 deletions

Some content is hidden

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

‎src/sqlbuild/.agents/skills/sqlbuild/references/docs/cli/compile.md‎

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ Online: https://sqlbuild.com/docs/cli/compile/
1212
- Flags
1313
- What compile does
1414
- Focused compilation
15+
- Reusing an unchanged compile
1516
- Static analysis
1617
- Output
1718
- Column lineage modes
@@ -69,6 +70,78 @@ with selected model, seed, and function counts.
6970
Manifest and DAG outputs remain full-project artifacts. Focused compilation changes analysis and
7071
reporting scope; it does not produce a partial project graph.
7172

73+
## Reusing an unchanged compile
74+
75+
When nothing that can change the result has changed since the last compile, `sqb compile` replays
76+
that result instead of compiling again. It prints one line to stderr, for example
77+
`Inputs unchanged; reused the previous compile (0.1 s)`, then the same report and exit code as the
78+
previous run. Failed compiles are replayed too, with the same diagnostics.
79+
80+
A compile is reused only when all of these match the stored run:
81+
82+
- Every file and directory under the project, compared by type, size, modification and change
83+
times, and inode. When those differ, SQLBuild compares content only for files whose content
84+
digest it already knows; otherwise the compile runs in full. Digests are recorded for files that
85+
changed within two seconds of a compile and for files whose timestamps moved since the previous
86+
compile, so large unchanged files are never read. After a checkout that only moves timestamps,
87+
the next compile runs in full once, and later timestamp-only changes are reused.
88+
- The command line: project directory, working directory, flags, selectors, `--vars`, target, and
89+
whether color output is active.
90+
- Environment variables that SQLBuild reads: every `SQLBUILD_*` and `SQB_*` variable, every
91+
variable that a template or `@@ENV` read during the stored compile, and every variable a
92+
provider's settings can read, including `env_prefix`, aliases, and nested-delimiter variables.
93+
Values are stored only as hashes.
94+
- The contents of each provider settings `env_file` and `secrets_dir`.
95+
- The Python interpreter, the SQLBuild version and native extension, `sys.path` entries, and every
96+
loaded Python module file outside the project.
97+
- Every artifact the stored compile wrote under `target/compiled/`, and the `--dag` file. If one is
98+
missing or changed, compile runs in full and rewrites it. A compile is stored only when these
99+
artifacts still hold the bytes it wrote, so a concurrent compile cannot leave another compile's
100+
artifacts behind a reused result.
101+
102+
These project paths are not inputs: `target/`, `logs/`, `venv/`, `.git/` and `__pycache__/`
103+
anywhere, and these folders at the project root: `.cache`, `.fensu`, `.hg`, `.idea`,
104+
`.mypy_cache`, `.nox`, `.pytest_cache`, `.ruff_cache`, `.sqlbuild`, `.svn`, `.tox`, `.venv`, and
105+
`.vscode`. Other hidden folders are inputs, because custom rules can read them. DuckDB database
106+
files count only by presence.
107+
108+
Reuse assumes macros, hooks, providers, adapters, and custom rules are deterministic: the same inputs
109+
must produce the same output. A compile whose templates read `run.id` is never reused.
110+
111+
Compile always runs in full with `--no-cache`, `--manifest`, `--debug`, or a profiling flag, when
112+
`SQLBUILD_DISABLE_COMPILE_CACHE=1` or `SQLBUILD_DISABLE_COMPILE_REUSE=1` is set, when the project has
113+
no `sqlbuild_project.toml` or `sqlbuild_project.yml`, when the target sets `compile_cache = false`,
114+
and when a provider's settings use sources SQLBuild cannot list exactly, such as a custom
115+
`settings_customise_sources` or command-line parsing. The reason is logged at debug level.
116+
117+
SQLBuild keeps one stored result per project and target under
118+
`target/cache/compiler/project-reuse-v1/`. It is replaced atomically; a damaged or unreadable entry is
119+
ignored and compile runs in full. Storing a result normally takes a fraction of a second after the
120+
report is ready. When it has to hash a lot of data or the project has very many files, compile
121+
prints `Recording compile for reuse...` and a completion line to stderr before the report.
122+
123+
Known limits:
124+
125+
- Python packages outside the project are checked by the stamps of the module files the compile
126+
loaded. A newly added submodule or package data file that the compile reads without importing it
127+
first is not tracked.
128+
- On network filesystems that cache file attributes, or when clocks differ between machines,
129+
timestamps may not reflect a recent edit.
130+
- On Windows, a file's change time is its creation time. An in-place rewrite that keeps the same
131+
size and restores the previous modification time, such as `touch -r`, `rsync -t`, or
132+
`robocopy /COPY:T`, is not detected unless the file changed within two seconds of the stored
133+
compile. The same applies to filesystems without a real change time, such as some FUSE, SMB, and
134+
exFAT mounts.
135+
- In these cases, or whenever a result looks stale, run `sqb compile --no-cache` or set
136+
`SQLBUILD_DISABLE_COMPILE_REUSE=1`.
137+
138+
A replayed JSON report is byte-identical to the previous one except for `compile_timings`, which
139+
holds `project_reuse_hits`, `project_reuse_misses`, `project_reuse_bypasses`,
140+
`project_reuse_check_ms`, and `total_ms`. Full compiles report the same reuse counters alongside
141+
the phase timings. In text mode, a replayed compile does not print the per-phase progress lines.
142+
Lifecycle sinks receive only the invocation events for a replayed compile; per-phase `operation`
143+
events are not emitted because no compile phase runs.
144+
72145
## Static analysis
73146

74147
When SQL analysis is enabled (default), compile performs static analysis on your models without connecting to the warehouse:

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

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

33
from __future__ import annotations
44

5+
import os
56
import time
67
from dataclasses import replace
78
from functools import partial
@@ -28,6 +29,7 @@
2829
CompileAnalysis,
2930
CompileWriteResult,
3031
)
32+
from sqlbuild.cli.compile_reuse.constants import COMPILE_ARTIFACT_WRITES
3133
from sqlbuild.cli.entry.models import CompileProfileFlags
3234
from sqlbuild.cli.output.models import (
3335
WrittenTarget,
@@ -319,13 +321,14 @@ def write_compile_dag_artifact(
319321
dag_path=dag_path,
320322
)
321323
resolved_dag_path.parent.mkdir(parents=True, exist_ok=True)
322-
resolved_dag_path.write_text(
323-
build_dag_json(
324-
graph=analysis.graph,
325-
project_name=analysis.discovered_inputs.project_config.name,
326-
python_graph=python_graph,
327-
),
328-
encoding="utf-8",
324+
dag_json: str = build_dag_json(
325+
graph=analysis.graph,
326+
project_name=analysis.discovered_inputs.project_config.name,
327+
python_graph=python_graph,
328+
)
329+
_ = resolved_dag_path.write_text(dag_json, encoding="utf-8")
330+
COMPILE_ARTIFACT_WRITES.written(
331+
path=resolved_dag_path, contents=dag_json.replace("\n", os.linesep).encode("utf-8")
329332
)
330333
_ = complete_compile_phase(
331334
status=status, message=f"Wrote DAG artifact. ({time.monotonic() - dag_start:.2f}s)"

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
SqlTestArtifactCacheRecord,
2828
SqlTestArtifactIdentityContext,
2929
)
30+
from sqlbuild.cli.compile_reuse.constants import COMPILE_ARTIFACT_WRITES
3031
from sqlbuild.cli.output.models import (
3132
WrittenTarget,
3233
)
@@ -235,6 +236,7 @@ def _move_staged_tree(*, staged_dir: Path, path: Path) -> bool:
235236
os.rename(staged_dir, path)
236237
except OSError:
237238
return False
239+
COMPILE_ARTIFACT_WRITES.moved_tree(source=staged_dir, destination=path)
238240
return True
239241

240242

@@ -250,6 +252,7 @@ def _staged_files(*, staged_dir: Path) -> list[tuple[Path, str]]:
250252

251253
def _publish_staged_file(*, source: Path, path: Path, check_existing: bool) -> None:
252254
with record_compile_timing("physical_write_ms"):
255+
COMPILE_ARTIFACT_WRITES.moved(source=source, destination=path)
253256
if check_existing and path.is_file():
254257
contents: bytes = source.read_bytes()
255258
existing: bytes = path.read_bytes()
@@ -542,6 +545,11 @@ def _partition_cached_static_tests(
542545
identity=artifact_identity,
543546
)
544547
if cached_path is not None:
548+
COMPILE_ARTIFACT_WRITES.kept(
549+
path=cached_path,
550+
size=cached_record.size,
551+
mtime_ns=cached_record.mtime_ns,
552+
)
545553
managed_paths.add(cached_path)
546554
current_records[record_key] = cached_record
547555
continue
@@ -664,6 +672,10 @@ def _write_sql(*, path: Path, sql: str, check_existing: bool = True) -> None:
664672

665673
def _write_text_if_changed(*, path: Path, contents: str, check_existing: bool = True) -> None:
666674
with record_compile_timing("physical_write_ms"):
675+
COMPILE_ARTIFACT_WRITES.written(
676+
path=path,
677+
contents=contents.replace(_POSIX_LINE_SEPARATOR, os.linesep).encode("utf-8"),
678+
)
667679
if check_existing and path.is_file() and path.read_text(encoding="utf-8") == contents:
668680
return
669681
try:
@@ -675,6 +687,7 @@ def _write_text_if_changed(*, path: Path, contents: str, check_existing: bool =
675687

676688
def _write_bytes_if_changed(*, path: Path, contents: bytes, check_existing: bool = True) -> None:
677689
with record_compile_timing("physical_write_ms"):
690+
COMPILE_ARTIFACT_WRITES.written(path=path, contents=contents)
678691
if check_existing:
679692
existing: bytes | None = _read_existing_file(path=path)
680693
if existing == contents:

‎src/sqlbuild/cli/commands/_helpers/entry/dispatch.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -501,6 +501,7 @@ def _dispatch_compile_or_contract(
501501
skip_contracts=args.profile_skip_contracts,
502502
skip_write=args.profile_skip_write,
503503
),
504+
debug=args.debug,
504505
)
505506
)
506507
return None

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

Lines changed: 81 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@
22

33
from __future__ import annotations
44

5-
import sys
65
import time
76
from dataclasses import replace
87
from pathlib import Path
98

109
from sqlbuild.cli.commands._helpers.compile.collection import paused_cyclic_collection
10+
from sqlbuild.cli.commands._helpers.compile.dag import resolve_compile_dag_path
1111
from sqlbuild.cli.commands._helpers.compile.output import (
1212
format_compile_json,
1313
format_compile_text,
@@ -25,9 +25,17 @@
2525
from sqlbuild.cli.commands.types import CompileLineageMode
2626
from sqlbuild.cli.compile.models import (
2727
CompileAnalysis,
28+
CompileCommandResult,
2829
CompileWriteResult,
2930
)
31+
from sqlbuild.cli.compile_reuse.classes.recorded_compile_output import RecordedCompileOutput
32+
from sqlbuild.cli.compile_reuse.constants import COMPILE_ARTIFACT_WRITES
33+
from sqlbuild.cli.compile_reuse.main._compile_reuse_timings import compile_reuse_timings
34+
from sqlbuild.cli.compile_reuse.main._write_reusable_compile import write_reusable_compile
35+
from sqlbuild.cli.compile_reuse.models import CompileReuseAttempt
36+
from sqlbuild.cli.compile_reuse.types import CompileReuseOutcome
3037
from sqlbuild.cli.entry.models import CompileCommandRequest
38+
from sqlbuild.compiler.compile.constants import COMPILE_INPUT_READS
3139
from sqlbuild.compiler.compile.models import CompileAnalysisSelection, CompilerDiagnostic
3240
from sqlbuild.compiler.compile.types import DiagnosticPhase
3341
from sqlbuild.compiler.profiling.main.collect import collect_compile_timings
@@ -37,13 +45,25 @@
3745
from sqlbuild.rule_engine.main.render_skipped_rules import format_skipped_type_proof_rules
3846

3947

40-
def run_compile(request: CompileCommandRequest) -> int:
48+
def run_compile(
49+
*, request: CompileCommandRequest, reuse_attempt: CompileReuseAttempt | None = None
50+
) -> int:
4151
"""Execute the compile command."""
4252

43-
total_start: float = time.monotonic()
53+
total_start: float = time.monotonic() if reuse_attempt is None else reuse_attempt.started
4454
effective_request: CompileCommandRequest = (
4555
request if request.project_dir is not None else replace(request, project_dir=Path.cwd())
4656
)
57+
attempt: CompileReuseAttempt = (
58+
CompileReuseAttempt(
59+
outcome=CompileReuseOutcome.BYPASS,
60+
project_dir=Path(effective_request.project_dir or Path.cwd()),
61+
started=total_start,
62+
)
63+
if reuse_attempt is None
64+
else reuse_attempt
65+
)
66+
output: RecordedCompileOutput = RecordedCompileOutput()
4767
status: TransientStatusReporter | None = start_compile_status(
4868
json_output=request.json_output,
4969
no_color=request.no_color,
@@ -52,20 +72,40 @@ def run_compile(request: CompileCommandRequest) -> int:
5272
with (
5373
paused_cyclic_collection(),
5474
collect_compile_timings() as detailed_timings,
75+
COMPILE_INPUT_READS.recording() as input_reads,
76+
COMPILE_ARTIFACT_WRITES.recording() as artifact_writes,
5577
PreparedCompileArtifacts(
5678
enabled=not request.profile_flags.skip_write
5779
) as prepared_artifacts,
5880
):
59-
return _run_compile_with_status(
81+
result: CompileCommandResult = _run_compile_with_status(
6082
request=effective_request,
6183
total_start=total_start,
6284
status=status,
6385
detailed_timings=detailed_timings,
6486
prepared_artifacts=prepared_artifacts,
87+
output=output,
88+
reuse_timings_ms=compile_reuse_timings(attempt=attempt),
6589
)
6690
finally:
6791
if status is not None:
6892
status.close()
93+
try:
94+
with paused_cyclic_collection():
95+
write_reusable_compile(
96+
attempt=attempt,
97+
output=output,
98+
exit_code=result.exit_code,
99+
input_reads=input_reads,
100+
artifact_writes=artifact_writes,
101+
compile_cache_enabled=result.compile_cache_enabled,
102+
artifacts_written=result.artifacts_written,
103+
dag_artifact_path=result.dag_artifact_path,
104+
json_output=request.json_output,
105+
)
106+
finally:
107+
output.print_stdout()
108+
return result.exit_code
69109

70110

71111
def _run_compile_with_status(
@@ -75,7 +115,9 @@ def _run_compile_with_status(
75115
status: TransientStatusReporter | None,
76116
detailed_timings: CompileTimingCollector,
77117
prepared_artifacts: PreparedCompileArtifacts,
78-
) -> int:
118+
output: RecordedCompileOutput,
119+
reuse_timings_ms: dict[str, int],
120+
) -> CompileCommandResult:
79121
"""Execute compile after the optional interactive status reporter is initialized."""
80122

81123
project_dir: Path = request.project_dir if request.project_dir is not None else Path.cwd()
@@ -132,6 +174,7 @@ def _run_compile_with_status(
132174
"rule_cache_misses": analysis.rule_cache_misses,
133175
"write_ms": write_result.write_ms,
134176
**detailed_timings.as_milliseconds(),
177+
**reuse_timings_ms,
135178
"total_ms": elapsed_ms(total_start),
136179
}
137180
withheld_test_diagnostics: tuple[CompilerDiagnostic, ...] = (
@@ -154,7 +197,14 @@ def _run_compile_with_status(
154197
codes=analysis.skipped_type_proof_rules
155198
)
156199
if skipped_rules_note is not None:
157-
print(f"note: {skipped_rules_note}", file=sys.stderr)
200+
output.print_stderr_line(f"note: {skipped_rules_note}")
201+
result: CompileCommandResult = _command_result(
202+
request=request,
203+
project_dir=project_dir,
204+
exit_code=exit_code,
205+
rules_failed=rules_failed,
206+
analysis=analysis,
207+
)
158208

159209
if json_output:
160210
notice: str | None = semantic_coverage_notice(
@@ -163,8 +213,8 @@ def _run_compile_with_status(
163213
sql_validation_enabled=not request.no_sql_validation,
164214
)
165215
if notice:
166-
print(notice, file=sys.stderr)
167-
print(
216+
output.print_stderr_line(notice)
217+
output.record_stdout(
168218
format_compile_json(
169219
graph=analysis.graph,
170220
written=write_result.written,
@@ -177,9 +227,9 @@ def _run_compile_with_status(
177227
sql_validation_enabled=not request.no_sql_validation,
178228
)
179229
)
180-
return exit_code
230+
return result
181231

182-
print(
232+
output.record_stdout(
183233
format_compile_text(
184234
graph=analysis.graph,
185235
written=write_result.written,
@@ -192,4 +242,24 @@ def _run_compile_with_status(
192242
sql_validation_enabled=not request.no_sql_validation,
193243
)
194244
)
195-
return exit_code
245+
return result
246+
247+
248+
def _command_result(
249+
*,
250+
request: CompileCommandRequest,
251+
project_dir: Path,
252+
exit_code: int,
253+
rules_failed: bool,
254+
analysis: CompileAnalysis,
255+
) -> CompileCommandResult:
256+
return CompileCommandResult(
257+
exit_code=exit_code,
258+
compile_cache_enabled=analysis.graph.project.compile_cache_dir is not None,
259+
artifacts_written=not (rules_failed or request.profile_flags.skip_write),
260+
dag_artifact_path=(
261+
None
262+
if rules_failed or request.dag_path is None
263+
else resolve_compile_dag_path(project_dir=project_dir, dag_path=request.dag_path)
264+
),
265+
)

0 commit comments

Comments
 (0)