Skip to content

Commit b44b9d3

Browse files
fresioASclaude
andauthored
Perf: add opt-in project-index loading for lint (#5913)
Signed-off-by: Andreas Fredhøi <andreas.fredhoi@fresio.no> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent b4c2d29 commit b44b9d3

11 files changed

Lines changed: 616 additions & 42 deletions

File tree

docs/guides/configuration.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,16 @@ By default, the SQLMesh cache is stored in a `.cache` directory within your proj
329329

330330
The cache directory is automatically created if it doesn't exist. You can clear the cache using the `sqlmesh clean` command.
331331

332+
#### Project index
333+
334+
The `--use-project-index` option on supported commands maintains a persistent model dependency index in the cache directory. Each project writes a file named `<project>_<hash>_model_index.json`.
335+
336+
A full project load with the option enabled creates or refreshes the index. SQLMesh invalidates it when relevant configuration, gateway, macro, audit, or signal metadata changes, or when the set of model files changes. If the index is missing, invalid, or stale, SQLMesh safely falls back to a full project load and rebuilds it.
337+
338+
For operations targeting selected models, the index allows SQLMesh to load only those models and their upstream dependencies.
339+
340+
In multi-repository projects, dependencies that cross project boundaries may not be represented by an individual project's index. SQLMesh detects incomplete scoped loads and falls back to loading the full configured project set.
341+
332342
### Table/view storage locations
333343

334344
SQLMesh creates schemas, physical tables, and views in the data warehouse/engine. Learn more about why and how SQLMesh creates schema in the ["Why does SQLMesh create schemas?" FAQ](../faq/faq.md#schema-question).

docs/guides/linter.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,21 @@ $ sqlmesh lint --local
135135

136136
This can make linting faster in repositories where all referenced models are loaded from local files. In multi-repository setups, or when linting only a subset of projects, `--local` may cause additional linting errors because SQLMesh will not resolve references or schemas from models that exist only in remote state.
137137

138+
For faster targeted linting, enable the persistent project index with `--use-project-index`. When
139+
models are selected with `--model`, SQLMesh loads, resolves, and validates only those models and
140+
their transitive upstream dependencies. The same behavior can be enabled by default for the
141+
Python API and CLI with the `linter.use_project_index` configuration option:
142+
143+
```yaml
144+
linter:
145+
enabled: true
146+
use_project_index: true
147+
```
148+
149+
`Context.lint_models` uses this configuration value when `use_project_index` is omitted. Passing
150+
`use_project_index=False` explicitly disables it for that call. If a context was already loaded,
151+
an indexed lint of selected models reloads the context so the requested scope is applied.
152+
138153

139154
## Applying linting rules
140155

docs/reference/cli.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -650,9 +650,12 @@ Usage: sqlmesh lint [OPTIONS]
650650
651651
Options:
652652
--model TEXT A model to lint. Multiple models can be linted. If no models are specified, every model will be linted.
653+
--use-project-index Use the persistent project index. With --model, only the selected models and their upstream dependencies
654+
are loaded, resolved, and validated, so errors in unrelated models are not reported. Without --model,
655+
every model is still loaded and linted.
653656
--local Lint using only locally loaded project files without loading state. In multi-repository setups, or when
654657
linting only a subset of projects, this may cause additional linting errors because SQLMesh will not resolve
655658
references or schemas from models that exist only in remote state.
656659
--help Show this message and exit.
657660
658-
```
661+
```

docs/reference/configuration.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,13 @@ The `model_defaults` key is **required** and must contain a value for the `diale
6060

6161
See all the keys allowed in `model_defaults` at the [model configuration reference page](./model_configuration.md#model-defaults).
6262

63+
### Linter
64+
65+
| Option | Description | Type | Required |
66+
|---------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------|---------|----------|
67+
| `linter.enabled` | Whether linting is enabled (Default: `False`) | boolean | N |
68+
| `linter.use_project_index` | Whether to use the persistent project index for linting. Targeted linting loads selected models and their upstream dependencies. (Default: `False`) | boolean | N |
69+
6370
### Variables
6471

6572
The `variables` key can be used to provide values for user-defined variables, accessed using the [`@VAR` macro function](../concepts/macros/sqlmesh_macros.md#global-variables) in SQL model definitions, [`context.var` method](../concepts/models/python_models.md#global-variables) in Python model definitions, and [`evaluator.var` method](../concepts/macros/sqlmesh_macros.md#accessing-global-variable-values) in Python macro functions.

sqlmesh/cli/main.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,10 @@ def cli(
141141
if ctx.invoked_subcommand in SKIP_LOAD_COMMANDS:
142142
load = False
143143

144+
# Unlike the other commands above, lint can scope its own load for multi-project contexts.
145+
if ctx.invoked_subcommand == "lint":
146+
load = False
147+
144148
configs = load_configs(config, Context.CONFIG_TYPE, paths, dotenv_path=dotenv)
145149
log_limit = list(configs.values())[0].log_limit
146150

@@ -1209,6 +1213,12 @@ def environments(obj: Context) -> None:
12091213
multiple=True,
12101214
help="A model to lint. Multiple models can be linted. If no models are specified, every model will be linted.",
12111215
)
1216+
@click.option(
1217+
"--use-project-index",
1218+
is_flag=True,
1219+
default=None,
1220+
help="Use the persistent project index. With --model, only the selected models and their upstream dependencies are loaded, resolved, and validated, so errors in unrelated models are not reported. Without --model, every model is still loaded and linted. Can also be enabled with linter.use_project_index.",
1221+
)
12121222
@click.option(
12131223
"--local",
12141224
is_flag=True,
@@ -1221,9 +1231,18 @@ def environments(obj: Context) -> None:
12211231
def lint(
12221232
obj: Context,
12231233
models: t.Iterator[str],
1234+
use_project_index: t.Optional[bool],
12241235
) -> None:
12251236
"""Run the linter for the target model(s)."""
1226-
obj.lint_models(models)
1237+
obj.lint_models(
1238+
models,
1239+
use_project_index=use_project_index,
1240+
)
1241+
1242+
if not obj.models:
1243+
raise click.ClickException(
1244+
f"`{obj.path}` doesn't seem to have any models... cd into the proper directory or specify the path(s) with -p."
1245+
)
12271246

12281247

12291248
@cli.group(no_args_is_help=True)

sqlmesh/core/config/linter.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,16 @@ class LinterConfig(BaseConfig):
1616
Args:
1717
enabled: Flag indicating whether the linter should run
1818
19+
use_project_index: Whether to use the persistent project index when linting.
20+
1921
rules: A list of error rules to be applied on model
2022
warn_rules: A list of rules to be applied on models but produce warnings instead of raising errors.
2123
ignored_rules: A list of rules to be excluded/ignored
2224
2325
"""
2426

2527
enabled: bool = False
28+
use_project_index: bool = False
2629

2730
rules: t.Set[str] = set()
2831
warn_rules: t.Set[str] = set()

sqlmesh/core/context.py

Lines changed: 130 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,7 @@ def __init__(
418418
self._linters: t.Dict[str, Linter] = {}
419419
self._loaded: bool = False
420420
self._load_state: bool = load_state
421+
self._uncached_model_names: t.Set[str] = set()
421422
self._selector_cls = selector or NativeSelector
422423

423424
self.path, self.config = t.cast(t.Tuple[Path, C], next(iter(self.configs.items())))
@@ -641,11 +642,31 @@ def refresh(self) -> None:
641642
if any(loader.reload_needed() for loader in self._loaders):
642643
self.load()
643644

644-
def load(self, update_schemas: bool = True) -> GenericContext[C]:
645-
"""Load all files in the context's path."""
645+
def load(
646+
self,
647+
update_schemas: bool = True,
648+
model_fqns: t.Optional[t.Set[str]] = None,
649+
use_project_index: bool = False,
650+
) -> GenericContext[C]:
651+
"""Load files in the context's path, optionally scoped to specific models.
652+
653+
Args:
654+
update_schemas: Whether to update model schemas and validate model definitions.
655+
model_fqns: If provided with ``use_project_index=True``, only the selected models
656+
and their transitive upstream dependencies are loaded.
657+
use_project_index: Whether to use and maintain the persistent project model index.
658+
When ``model_fqns`` is not provided, all models are loaded and the index is
659+
refreshed for future scoped loads.
660+
"""
646661
load_start_ts = time.perf_counter()
647662

648-
loaded_projects = [loader.load() for loader in self._loaders]
663+
loaded_projects = [
664+
loader.load(
665+
model_fqns=model_fqns,
666+
use_project_index=use_project_index,
667+
)
668+
for loader in self._loaders
669+
]
649670

650671
self.dag = DAG()
651672
self._standalone_audits.clear()
@@ -688,6 +709,27 @@ def load(self, update_schemas: bool = True) -> GenericContext[C]:
688709
BUILTIN_RULES.union(project.user_rules), config.linter
689710
)
690711

712+
indexed_model_fqns = {
713+
fqn for project in loaded_projects for fqn in (project.indexed_model_fqns or set())
714+
}
715+
if model_fqns and (
716+
not model_fqns <= self._models.keys()
717+
or any(
718+
dependency in indexed_model_fqns and dependency not in self._models
719+
for model in self._models.values()
720+
for dependency in model.depends_on
721+
)
722+
):
723+
# A missing or stale index, a new model, or a dependency crossing project
724+
# boundaries requires a full load to preserve existing behavior.
725+
self.load(
726+
update_schemas=False,
727+
use_project_index=use_project_index,
728+
)
729+
if update_schemas:
730+
self._update_model_schemas_and_validate(model_fqns)
731+
return self
732+
691733
# Load environment statements from state for projects not in current load
692734
if self._load_state and any(self._projects):
693735
prod = self.state_reader.get_environment(c.PROD)
@@ -713,34 +755,13 @@ def load(self, update_schemas: bool = True) -> GenericContext[C]:
713755
else:
714756
local_store[snapshot.name] = snapshot.node # type: ignore
715757

758+
self._uncached_model_names = uncached
759+
716760
for model in self._models.values():
717761
self.dag.add(model.fqn, model.depends_on)
718762

719763
if update_schemas:
720-
for fqn in self.dag:
721-
model = self._models.get(fqn) # type: ignore
722-
723-
if not model or fqn in uncached:
724-
continue
725-
726-
# make a copy of remote models that depend on local models or in the downstream chain
727-
# without this, a SELECT * FROM local will not propogate properly because the downstream
728-
# model will get mutated (schema changes) but the object is the same as the remote cache
729-
if any(dep in uncached for dep in model.depends_on):
730-
uncached.add(fqn)
731-
self._models.update({fqn: model.copy(update={"mapping_schema": {}})})
732-
continue
733-
734-
update_model_schemas(
735-
self.dag,
736-
models=self._models,
737-
cache_dir=self.cache_dir,
738-
)
739-
740-
models = self.models.values()
741-
for model in models:
742-
# The model definition can be validated correctly only after the schema is set.
743-
model.validate_definition()
764+
self._update_model_schemas_and_validate(model_fqns or None)
744765

745766
duplicates = set(self._models) & set(self._standalone_audits)
746767
if duplicates:
@@ -767,6 +788,53 @@ def load(self, update_schemas: bool = True) -> GenericContext[C]:
767788
self._loaded = True
768789
return self
769790

791+
def _update_model_schemas_and_validate(self, model_fqns: t.Optional[t.Set[str]] = None) -> None:
792+
"""Updates the mapping schemas of the given models (all models by default) and validates their definitions.
793+
794+
Args:
795+
model_fqns: If provided, only these models and their transitive upstream
796+
dependencies are processed.
797+
"""
798+
if model_fqns is not None:
799+
model_fqns = {
800+
fqn for target in model_fqns for fqn in (target, *self.dag.upstream(target))
801+
}
802+
803+
uncached = set(self._uncached_model_names)
804+
805+
for fqn in self.dag:
806+
if model_fqns is not None and fqn not in model_fqns:
807+
continue
808+
809+
model = self._models.get(fqn)
810+
811+
if not model or fqn in uncached:
812+
continue
813+
814+
# make a copy of remote models that depend on local models or in the downstream chain
815+
# without this, a SELECT * FROM local will not propogate properly because the downstream
816+
# model will get mutated (schema changes) but the object is the same as the remote cache
817+
if any(dep in uncached for dep in model.depends_on):
818+
uncached.add(fqn)
819+
self._models.update({fqn: model.copy(update={"mapping_schema": {}})})
820+
continue
821+
822+
models = self._models
823+
if model_fqns is not None:
824+
models = UniqueKeyDict(
825+
"models", {fqn: model for fqn, model in self._models.items() if fqn in model_fqns}
826+
)
827+
828+
update_model_schemas(
829+
self.dag,
830+
models=models,
831+
cache_dir=self.cache_dir,
832+
)
833+
834+
for model in models.values():
835+
# The model definition can be validated correctly only after the schema is set.
836+
model.validate_definition()
837+
770838
@python_api_analytics
771839
def run(
772840
self,
@@ -3435,7 +3503,42 @@ def lint_models(
34353503
self,
34363504
models: t.Optional[t.Iterable[t.Union[str, Model]]] = None,
34373505
raise_on_error: bool = True,
3506+
use_project_index: t.Optional[bool] = None,
34383507
) -> t.List[AnnotatedRuleViolation]:
3508+
"""Lint the selected models.
3509+
3510+
Args:
3511+
models: Models to lint. If omitted, all loaded models are linted.
3512+
raise_on_error: Whether to raise when an error-level violation is found.
3513+
use_project_index: Whether to use the persistent project index. If omitted, the
3514+
value of ``linter.use_project_index`` is used. Indexed linting of selected
3515+
models reloads an already-loaded context so the requested scope is applied.
3516+
"""
3517+
models = list(models) if models is not None else []
3518+
use_project_index = (
3519+
self.config.linter.use_project_index if use_project_index is None else use_project_index
3520+
)
3521+
3522+
target_fqns = (
3523+
{
3524+
normalize_model_name(
3525+
model,
3526+
default_catalog=self.default_catalog,
3527+
dialect=self.default_dialect,
3528+
)
3529+
if isinstance(model, str)
3530+
else model.fqn
3531+
for model in models
3532+
}
3533+
if models and use_project_index
3534+
else None
3535+
)
3536+
3537+
# An already-loaded context does not otherwise enter the loading path. Reload when
3538+
# indexed linting is requested for specific models so the scope is actually applied.
3539+
if not self._loaded or target_fqns is not None:
3540+
self.load(model_fqns=target_fqns, use_project_index=use_project_index)
3541+
34393542
found_error = False
34403543

34413544
model_list = (

0 commit comments

Comments
 (0)