Skip to content

Commit 8bc5325

Browse files
authored
feat(native): batch and cache macro calls behind the preview engine (#1013)
* feat(native): add a render crate that scans, splices and memoizes macro calls The sqlbuild-render crate ports the Python macro call scanner byte for byte (quotes, comments, dollar quotes, the identifier/whitespace quirks, Python Unicode classes), splices rendered calls with code-point spans, and holds the in-compile memo of recorded calls and their events. Anything the scanner cannot reproduce exactly defers to Python. * feat(native): expose macro call scanning, splicing and the memo to Python A macro_bridge module in the PyO3 crate registers scan_macro_call_sites and splice_macro_calls (run with the GIL released) and the MacroCallMemo class, whose recorded events cross as plain (tag, text, text) rows. * feat(native): batch macro calls through a native render bridge With the native engine, the compile-input render runs every expand_sql_macros_result call through the macro bridge: Rust scans the top-level call sites, Python resolves their call class and executes memo misses in order with the existing _evaluate_macro_call, recorded calls replay their ordered events (macro uses, declaration reads, generated-reference candidates, typed argument references) for each consumer, and Rust splices the results. Relation placeholders render exactly as in the serial path. Any native render failure re-runs the Python stage, which raises the exact error; a re-run that succeeds is reported as a divergence. The Python engine is unchanged. * feat(native): add a shared native store and carry macro calls in it The sqlbuild-cache crate is the one store and fingerprinting scheme for native caches: a content digest, a project file fingerprint that skips the folders project reuse skips, and one checksummed, atomically replaced file per cache kind whose entries are immutable and dropped after 64 saves unused. A damaged, foreign or older file opens empty. The macro call memo can attach a store: calls of a persistent class are looked up by a digest of their class text and call text, and recorded results are stored. The Python extension exposes the digest, the project fingerprint and the memo's store methods. * feat(native): cache macro call results across compiles The native macro bridge now loads stored call results when the compile cache is enabled and saves the results it used or recorded after the render stage succeeds. A stored result is valid only in the same environment: native build, interpreter, import search path, SQLBUILD_* and SQB_* variables, and the content of every project file except model SQL files; loaded module files are stamped and rechecked before use. Each call is keyed by its text, the name, file, declaration identity and source of every macro in the call, the relations passed so far, and for context macros the whole context by value (vars, target, adapter, visible constants and enums). Values without stable text make the call uncached. An edit-sequence e2e test compiles each edit with the store and with --no-cache, with and without project reuse, and a broken store key must make it fail. The compiler differential compiles every authored and generated project a second time with a warm store. * feat(native): run the macro bridge and call store only in the preview tier The macro bridge and the macro call store are registered as preview-tier native stages, so the default native engine and the Python engine never batch macro calls or open the store. The bindings move under the grouped bindings layout (render and cache domains). An e2e test compiles twice under each engine and checks macro executions and store files; the edit sequence also changes an @@env value passed to a macro. * fix(native): scan nested macro calls in one pass without recursion Collecting the names of nested macro calls recursed once per nesting level and rescanned the arguments at each level, so deep nesting was quadratic and very deep nesting overflowed the native stack. One pass with an explicit stack now covers any depth in linear time. * docs: state that macros must be deterministic Compile reuse, the per-compile macro call memo and the macro call cache all reuse a macro's result for the same call and context. Document the contract, which inputs are compared, and that environment variables read directly are compared only for SQLBUILD_ and SQB_ names. * fix(native): trust stored macro calls only for code identified by content Module stamps were read when the store was saved, so a module imported before its file changed could be stored under the new file's stamps and replayed by later processes; unreadable modules (zip members, moved files) were skipped, and timestamps can be preserved or coarse. Only the first compile of a process now uses and saves the store, so code loaded by earlier compiles can no longer differ from disk. Loaded modules outside the interpreter and SQLBuild are identified by a BLAKE3 digest of their backing file, at attach, after recorded calls and at save; a module whose code cannot be read from a plain file disables the store for that compile. Installed compiled extensions are covered by their distributions' RECORD files, and the project fingerprint must be unchanged between attach and save. The process start time is no longer read, so the store also works without /proc. * test(native): replay macro-returned reference call errors for every consumer * refactor(native): raise the shared native stage mismatch error from the macro bridge
1 parent b5a2453 commit 8bc5325

115 files changed

Lines changed: 7027 additions & 151 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.

‎Cargo.lock‎

Lines changed: 86 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Cargo.toml‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
[workspace]
22
members = [
33
"crates/sqlbuild-core",
4+
"crates/sqlbuild-cache",
45
"crates/sqlbuild-sqltext",
56
"crates/sqlbuild-config",
67
"crates/sqlbuild-discovery",
78
"crates/sqlbuild-scopes",
89
"crates/sqlbuild-model-config",
10+
"crates/sqlbuild-render",
911
"crates/sqlbuild-analysis",
1012
"crates/sqlbuild-rules",
1113
"crates/sqlbuild-python",
@@ -18,11 +20,13 @@ resolver = "2"
1820
[workspace.metadata.native-layers]
1921
order = [
2022
"sqlbuild-core",
23+
"sqlbuild-cache",
2124
"sqlbuild-sqltext",
2225
"sqlbuild-config",
2326
"sqlbuild-discovery",
2427
"sqlbuild-scopes",
2528
"sqlbuild-model-config",
29+
"sqlbuild-render",
2630
"sqlbuild-analysis",
2731
"sqlbuild-rules",
2832
"sqlbuild-python",
@@ -61,6 +65,7 @@ polyglot-sql = { git = "https://github.com/chio-labs/polyglot.git", rev = "9ff0a
6165
polyglot-sql-function-catalogs = { git = "https://github.com/chio-labs/polyglot.git", rev = "9ff0af282829d92efeeb36b6a820be2271e1da49", default-features = false, features = [
6266
"dialect-duckdb",
6367
] }
68+
blake3 = { version = "1", features = ["rayon"] }
6469
quote = "1"
6570
rayon = "1"
6671
saphyr-parser = "0.1"
@@ -70,9 +75,11 @@ serde = { version = "1", features = ["derive"] }
7075
serde_json = { version = "1", features = ["preserve_order"] }
7176
sha2 = "0.10"
7277
sqlbuild-analysis = { path = "crates/sqlbuild-analysis" }
78+
sqlbuild-cache = { path = "crates/sqlbuild-cache" }
7379
sqlbuild-config = { path = "crates/sqlbuild-config" }
7480
sqlbuild-core = { path = "crates/sqlbuild-core" }
7581
sqlbuild-discovery = { path = "crates/sqlbuild-discovery" }
82+
sqlbuild-render = { path = "crates/sqlbuild-render" }
7683
sqlbuild-rules = { path = "crates/sqlbuild-rules" }
7784
sqlbuild-model-config = { path = "crates/sqlbuild-model-config" }
7885
sqlbuild-scopes = { path = "crates/sqlbuild-scopes" }

‎crates/README.md‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,13 @@ that can be tested and benchmarked with `cargo test` alone.
77
| Crate | Responsibility |
88
|---|---|
99
| `sqlbuild-core` | Constants shared by several crates, the panic boundary that turns an unwinding native failure into a named compiler error, passive identity, diagnostic and SQL value types, text positions, and a JSON emitter whose output matches Python's `json.dumps` and `orjson` byte for byte. |
10+
| `sqlbuild-cache` | The shared native store every native cache uses (one atomically replaced, checksummed file per cache kind, keyed by content digests) and the one fingerprinting scheme for cache keys: the content digest and the project file fingerprint. |
1011
| `sqlbuild-sqltext` | Lexical SQL text without polyglot: comment, quote and parenthesis scanning, the rules quote policy, model header tokenization and matching, static variable substitution, static reference extraction, and the general reference scan with table-function call arguments under each adapter's lexical rules. |
1112
| `sqlbuild-config` | Configuration file reading without Python: `tomllib`-compatible TOML, PyYAML `safe_load`-compatible YAML 1.1, and typed project and local config readers for the fields discovery needs. Errors carry their kind and position; YAML outside the supported subset is an `Unsupported` error. |
1213
| `sqlbuild-discovery` | Native project discovery and the only implementation of the model, SQL test, scenario, source and schema file collections and the declaration layout: the shared directory walk with Python's glob and sort semantics, file reading with Python's newline handling and read errors, and parsing of authored files with Python's exact discovery messages. Results are plain data the Python discovery facade materialises. |
1314
| `sqlbuild-scopes` | Declaration scopes: the scope index with Python's record orders and diagnostics, declaration visibility, relationship grants, the scope lookup groups, and the dialect-aware scan of SQL tests and scenarios for their expected models. Anything it cannot reproduce exactly defers to Python. |
1415
| `sqlbuild-model-config` | Model configuration: MODEL header columns and audits, `${...}` template expansion with its environment and context reads, and the template and macro presence scans over authored config values, all read through a trait over the caller's values. Anything it cannot reproduce exactly defers to Python, which also raises every model config error. |
16+
| `sqlbuild-render` | Native rendering: the macro call scanner (a byte-for-byte port of Python's), splicing of rendered calls with code-point spans, and the in-compile memo of recorded macro calls and their replayable events, which can carry results across compiles through the shared native store. Anything it cannot reproduce exactly defers to Python. |
1517
| `sqlbuild-analysis` | SQL analysis over polyglot: SQL tokens, query analysis, the binding catalog, semantic validation and usage, column references, and SQL-test extraction, planning and rendering. |
1618
| `sqlbuild-rules` | Built-in rules and the rules engine, the custom-rule host, SQL lint, quality checks and formatting, rules configuration and the request models. It also owns the build identity script. |
1719
| `sqlbuild-python` | The only PyO3 crate: the `_native` module, its Python classes and functions, conversions from Python objects, and the process allocator. |
@@ -22,8 +24,9 @@ Dependencies point one way, from the top of this graph to the bottom:
2224

2325
```text
2426
sqlbuild-python
25-
-> sqlbuild-rules -> sqlbuild-analysis -> sqlbuild-model-config -> sqlbuild-scopes
26-
-> sqlbuild-discovery -> sqlbuild-config -> sqlbuild-sqltext -> sqlbuild-core
27+
-> sqlbuild-rules -> sqlbuild-analysis -> sqlbuild-model-config -> sqlbuild-render
28+
-> sqlbuild-scopes -> sqlbuild-discovery -> sqlbuild-config -> sqlbuild-sqltext -> sqlbuild-cache
29+
-> sqlbuild-core
2730
```
2831

2932
`sqlbuild-config` and `sqlbuild-model-config` do not depend on the crates below them today, and

‎crates/sqlbuild-cache/Cargo.toml‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
[package]
2+
name = "sqlbuild-cache"
3+
version.workspace = true
4+
description = "The shared native store and fingerprinting scheme for SQLBuild's compiler caches."
5+
edition.workspace = true
6+
license.workspace = true
7+
publish.workspace = true
8+
rust-version.workspace = true
9+
10+
[dependencies]
11+
blake3.workspace = true
12+
rayon.workspace = true
13+
sha2.workspace = true
14+
tempfile.workspace = true
15+
walkdir.workspace = true
16+
17+
[dev-dependencies]
18+
tempfile.workspace = true
19+
20+
[lints]
21+
workspace = true
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
pub(crate) mod walk;
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
//! List the project files a fingerprint covers, in a stable order.
2+
3+
use std::collections::HashSet;
4+
use std::path::{Path, PathBuf};
5+
use walkdir::{DirEntry, WalkDir};
6+
7+
use crate::digest::constants::{
8+
EXCLUDED_DIRECTORIES, EXCLUDED_ROOT_DIRECTORIES, PRESENCE_ONLY_FILE_SUFFIXES,
9+
};
10+
use crate::digest::errors::FingerprintError;
11+
12+
/// One covered file: its project-relative path, location and whether only presence counts.
13+
pub(crate) struct CoveredFile {
14+
pub(crate) relative_path: String,
15+
pub(crate) path: PathBuf,
16+
pub(crate) presence_only: bool,
17+
}
18+
19+
/// Every file below `root` outside the excluded folders and `excluded_files`, links followed.
20+
pub(crate) fn covered_files(
21+
root: &Path,
22+
excluded_files: &HashSet<String>,
23+
) -> Result<Vec<CoveredFile>, FingerprintError> {
24+
let mut files: Vec<CoveredFile> = Vec::new();
25+
let walk = WalkDir::new(root)
26+
.follow_links(true)
27+
.sort_by_file_name()
28+
.into_iter()
29+
.filter_entry(|entry| !excluded_directory(entry));
30+
for entry in walk {
31+
let entry: DirEntry = entry.map_err(FingerprintError::Walk)?;
32+
if !entry.file_type().is_file() {
33+
continue;
34+
}
35+
let relative_path: String = relative_text(root, entry.path())?;
36+
if excluded_files.contains(&relative_path) {
37+
continue;
38+
}
39+
let presence_only: bool = PRESENCE_ONLY_FILE_SUFFIXES
40+
.iter()
41+
.any(|suffix| relative_path.ends_with(suffix));
42+
files.push(CoveredFile {
43+
relative_path,
44+
path: entry.into_path(),
45+
presence_only,
46+
});
47+
}
48+
Ok(files)
49+
}
50+
51+
fn excluded_directory(entry: &DirEntry) -> bool {
52+
if !entry.file_type().is_dir() || entry.depth() == 0 {
53+
return false;
54+
}
55+
let Some(name) = entry.file_name().to_str() else {
56+
return false;
57+
};
58+
EXCLUDED_DIRECTORIES.contains(&name)
59+
|| (entry.depth() == 1 && EXCLUDED_ROOT_DIRECTORIES.contains(&name))
60+
}
61+
62+
fn relative_text(root: &Path, path: &Path) -> Result<String, FingerprintError> {
63+
let relative: &Path = path
64+
.strip_prefix(root)
65+
.map_err(|_| FingerprintError::OutsideRoot(path.to_path_buf()))?;
66+
let non_utf8 = || FingerprintError::NonUtf8Path(path.to_path_buf());
67+
let parts: Vec<&str> = relative
68+
.components()
69+
.map(|component| component.as_os_str().to_str().ok_or_else(non_utf8))
70+
.collect::<Result<Vec<&str>, FingerprintError>>()?;
71+
Ok(parts.join("/"))
72+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
//! What the project fingerprint walk skips; the same folders project reuse leaves out.
2+
3+
/// Folders skipped only directly below the project root.
4+
pub const EXCLUDED_ROOT_DIRECTORIES: &[&str] = &[
5+
"logs",
6+
"target",
7+
"venv",
8+
".cache",
9+
".fensu",
10+
".hg",
11+
".idea",
12+
".mypy_cache",
13+
".nox",
14+
".pytest_cache",
15+
".ruff_cache",
16+
".sqlbuild",
17+
".svn",
18+
".tox",
19+
".venv",
20+
".vscode",
21+
];
22+
23+
/// Folders skipped at any depth.
24+
pub const EXCLUDED_DIRECTORIES: &[&str] = &[".git", "__pycache__"];
25+
26+
/// Files that count only by presence because every build rewrites them.
27+
pub const PRESENCE_ONLY_FILE_SUFFIXES: &[&str] = &[".duckdb", ".duckdb.wal"];
28+
29+
/// Digest tag of a file whose bytes count.
30+
pub const CONTENT_TAG: &[u8] = b"content";
31+
/// Digest tag of a file that counts only by presence.
32+
pub const PRESENCE_TAG: &[u8] = b"presence";
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
//! Why a project fingerprint could not cover every file.
2+
3+
use std::fmt;
4+
5+
/// A file the fingerprint must cover could not be listed or read.
6+
#[derive(Debug)]
7+
pub enum FingerprintError {
8+
Walk(walkdir::Error),
9+
NonUtf8Path(std::path::PathBuf),
10+
OutsideRoot(std::path::PathBuf),
11+
Read(std::path::PathBuf, std::io::Error),
12+
}
13+
14+
impl fmt::Display for FingerprintError {
15+
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
16+
match self {
17+
Self::Walk(error) => write!(formatter, "cannot list project files: {error}"),
18+
Self::NonUtf8Path(path) => write!(formatter, "path is not UTF-8: {}", path.display()),
19+
Self::OutsideRoot(path) => {
20+
write!(formatter, "path is outside the project: {}", path.display())
21+
}
22+
Self::Read(path, error) => write!(formatter, "cannot read {}: {error}", path.display()),
23+
}
24+
}
25+
}

0 commit comments

Comments
 (0)