feat(materialization): incremental persist (refresh="incremental") - #913
girishjeswani wants to merge 4 commits into
Conversation
Adds a publisher-side incremental materialization path for persisted
Malloy sources, realizing the `refresh` knob's "incremental" value.
- refresh="incremental" seeds the target with a CTAS on the first run and
MERGEs the source rows in place on the source's primary_key thereafter;
idempotent under retry (unlike a raw INSERT).
- Compile-safe self-reference CTE (__malloy_incremental_keys): empty at
compile/first-run, hydrated to the target on incremental runs, so a
source filters to new rows before an expensive step (e.g.
ML.GENERATE_TEXT). dbt is_incremental()/{{ this }} analog, no compiler
change.
- forceFullRebuild run flag (dbt --full-refresh analog) routes an
incremental source through the full staging+rename path; kept distinct
from forceRefresh, which the scheduler sets on every run.
- Publish-gate validation: refresh must be full|incremental; incremental
+ freshness fallback "live" is rejected (a live serve returns the delta,
not the dataset).
- Portable MERGE: the SET target column is unqualified (BigQuery and
Postgres 15+ reject a qualified one; Snowflake accepts unqualified).
Backward compatible: refresh unset dispatches to the unchanged
full-rebuild path (extracted verbatim as buildFullSource); getSQL() and
content-addressing are unaffected; the reuse-skip exemption and the new
validation fire only for incremental sources.
Tests: new materialization_incremental.spec.ts plus policy, controller,
and run-metadata coverage. Docs: docs/materialization-incremental.md.
Signed-off-by: Girish Jeswani <girish@credibledata.com>
- hydrateIncrementalKeysCte: match the reserved CTE's AS keyword case-insensitively and anchored to the CTE name, so a lowercase `as` no longer silently skips hydration (which would leave the filter inert and reprocess every row on each incremental run). - Rule 6: reject freshness fallback "live" when it resolves from the incremental source's own freshness block, not only the package level. - docs: recommend a NOT EXISTS anti-join over the NULL-fragile NOT IN, note the canonical CTE form, and document the stable-target-name assumption (the target name must be stable across runs, else every run re-seeds). Tests: case-insensitive + AS-substring hydration, and source-level "live" fallback rejection. Signed-off-by: Girish Jeswani <girish@credibledata.com>
|
Nice feature -- the compile-safe 1. The MERGE path has no dialect gate (blocking).
The result is a raw warehouse syntax error surfaced late in the build (potentially after a first-run CTAS has already created a table), rather than a clear rejection. The doc's dialect table is documentation-only -- nothing enforces it, and Trino/MySQL/Databricks aren't even listed. Could you add a publish-time rule that rejects 2. Non-atomic (nested/struct/array) columns are silently dropped on subsequent runs (blocking). 3. The A few smaller things worth a look, non-blocking:
|
Addresses the code review on malloydata#913. - Dialect gate (blocking): reject refresh="incremental" at publish when the source dialect is not MERGE-capable (allow BigQuery, Snowflake, Postgres), turning a late raw warehouse syntax error into a clear publish rejection. Postgres < 15 stays a documented caveat (version isn't visible from the dialect name). - Non-atomic columns (blocking): derive the MERGE column set from the target table's actual schema (fetchSchemaForTables) instead of the atomic-filtered deriveColumns, so struct/array/record columns the first-run CTAS materialized are carried through the MERGE rather than silently dropped. One schema fetch now both decides seed-vs-MERGE and yields the column set, replacing the SELECT 1 LIMIT 0 probe (a connection failure now fails the run instead of being misread as "table absent"). - api-doc: reword forceFullRebuild to a clean public contract (no dbt/staging internals); the rationale stays a code comment. - hydrateIncrementalKeysCte: \b-bound the CTE-name anchor and document that the reserved name must appear only as the CTE. - docs: dialect-gate table, Snowflake USING-WITH caveat, NOT EXISTS guidance; unit tests use postgres (a supported dialect) rather than duckdb. Tests: non-atomic column carried into the MERGE; dialect-gate rejection; fetchSchemaForTables-driven seed and MERGE paths. Signed-off-by: Girish Jeswani <girish@credibledata.com>
|
Nice feature — the A few things not in that review. They hang off one observation about the shape of the change:
Four consequences worth a decision before this ships: 1. Schema/definition drift is guarded only by docs (the general case of sagar's #2). 2. The MERGE never deletes — rows dropped from the source (or newly access-restricted) linger. 3. Cost model: incremental bounds compute-per-row, not scan — and the doc oversells it. Two things would help:
4. The stable-target-name requirement is an unenforced contract on the orchestrating caller. One more, lower-confidence and rollout-dependent: during a rolling deploy, can a scheduled materialization run land on a pre-#913 replica? If so it treats the source as full-rebuild, never hydrates the CTE, and re-pays the entire per-row cost the feature exists to avoid — correct data, but the expensive full recompute, replacing the accumulated table. Bounded and self-healing if the deploy is effectively atomic for the scheduler; just want to confirm which. |
…n pass - MERGE primary key casing: resolve the declared primary_key to the target table's actual column spelling, so the ON key and the column list (both now sourced from the target schema) cannot disagree in case on a case-normalizing engine such as Snowflake. Throw if the key is not among the target's columns. - Correct the fetchTargetColumns / docs characterization: fetchSchemaForTables collapses a missing table AND a transient metadata error into its errors map (it does not throw), so "no schema" is treated as first run. A transient error against a table that DOES exist therefore surfaces as a spurious "table already exists" failure from the seed CTAS (a failed run, never data loss) — not the loud connection rejection the earlier comment claimed. Tests: primary_key resolved to the target's column casing; primary_key absent from the target columns is rejected. Signed-off-by: Girish Jeswani <girish@credibledata.com>
Summary
Realizes the
refresh="incremental"value of the persistrefreshknob on the publisher buildpath. Today a persisted source is always fully rebuilt (staging + atomic rename) on every run — fine
for most sources, but prohibitive when a per-row cost dominates the build (an
ML.GENERATE_TEXTclassification, an embedding call, an expensive UDF): every refresh re-pays that cost for rows that
have not changed.
refresh="incremental"makes such a source build once, then update in place.Behavior
refresh="incremental"(on#@ persist) selects the incremental path. Unset /"full"keepsthe default full-rebuild path, unchanged.
CREATE TABLE <target> AS (<source SQL>)— the warehouse infers theschema; DDL is never derived from Malloy intrinsic types.
MERGE INTO <target> USING (<source SQL>) ON <primary_key>,WHEN MATCHED THEN UPDATE+WHEN NOT MATCHED THEN INSERT. In place (no staging), and idempotent under retry(a re-run is a no-op on already-merged rows, unlike a raw
INSERT).primary_key— no new persist-levelunique_keyknob.An incremental source with no primary key is a
BadRequestErrorat build time.Compile-safe self-reference construct
The reserved CTE
__malloy_incremental_keyslets a source refer to "what I have alreadymaterialized" without a bootstrap problem — the dbt
is_incremental()+{{ this }}analog, with nocompiler change. The author writes it with an empty body (matches nothing → compiles, and seeds
everything on the first run); on an incremental run the publisher rewrites the body to
SELECT <primary_key> FROM <target>. Because the filter sits inside the source SQL, an expensive stepruns only for new rows. The construct is optional (omit it → upsert every returned row). Write it in
the canonical
WITH __malloy_incremental_keys AS ( ... )form; theASkeyword is matchedcase-insensitively and anchored to the CTE name (a lowercase
asstill hydrates).Force full rebuild (
forceFullRebuild)A new run flag (dbt
--full-refreshanalog) rebuilds every incremental source through the fullstaging + rename path this run. Deliberately distinct from
forceRefresh: the scheduler setsforceRefreshon every run, so overloading it would silently turn scheduled incremental refreshesback into full rebuilds.
Backward compatibility
Additive and off by default. The current full-rebuild body is extracted verbatim as
buildFullSource;refreshunset dispatches straight to it.getSQL()is unaffected by theannotation, so content-addressing and reuse-rebind are identical for full sources. The reuse-skip
exemption and the new validation rules fire only for incremental sources.
Validation & dialects
refreshmust befull|incremental; an incremental source that resolves tofreshness
fallback: "live"is rejected — whether the fallback is declared on the source orinherited from the package (a live serve returns the delta, not the dataset).
SETtarget column (SET col = S.col) forportability: BigQuery and Postgres 15+ reject a target-alias-qualified column there; Snowflake
accepts unqualified. Supported on BigQuery / Snowflake / Postgres 15+; DuckDB persistence is
unsupported generally, and Postgres < 15 has no
MERGE.Testing
materialization_incremental.spec.ts:refreshMode, the SQL builders(
hydrateIncrementalKeysCteincl. nested-paren balancing, case-insensitiveAS, and theAS-substring guard;buildMergeSQL), and thebuildOneSourcedispatch (full unchanged /first-run CTAS seed / MERGE /
forceFullRebuild/ missing-pk error / CTE hydration).refreshand incremental+live(package-level and source-level) inpersistence_policy.spec.ts(also proves the compiler carriesrefreshthrough to the wire plan).forceFullRebuildvalidation in the controller spec; run-metadata shape updated.tsc --noEmitclean; all affected suites green.Docs
docs/materialization-incremental.md— the annotation surface, the self-reference construct(incl. a
NOT EXISTSanti-join recommendation over the NULL-fragileNOT IN), build behavior, theescape hatch, validation, dialects, the stable-target-name assumption, and back-compat.
Notes
ASmatching,source-level
live-fallback rejection, and doc hardening —NOT EXISTSguidance + thestable-target-name assumption).
mainand independent of the CLI schedule work (feat(cli): materialization schedule + listing CLI, tuning skill, and docs #909) — it needs only the schedulerservice already merged in feat(materialization): standalone scheduler, environment-scoped listing, and schedule management UI #888. For a hosted refresh cadence, pair with
scope: package+ hostedfreshness(not the version-scoped cron).primary_key. A richer strategy (aseparate
unique_key, an event-time watermark predicate, or building into a cloned generationrather than in place) is possible later and nothing here forecloses it.