feat: column-level lineage for dbt from the engine's parquet index

`manifest.json` carries no column-to-column edges, which is why decision 14
recorded column lineage as unavailable. The edges live in a different artifact:
`dbt compile --static-analysis strict --write-index` writes `target/index/`,
whose `dbt.column_lineage.parquet` holds them and whose
`dbt.node_columns.parquet` holds every column of every node, typed and ordered
rather than only the ones an author documented.

Strict analysis rejects SQL the default accepts, so this is a separate compile
with its own `--target-path`, opt-in per project via `column_lineage: true`, and
best-effort throughout: a project it cannot analyze keeps exactly the graph it
had, with the engine's own diagnostics in the job log.

Storage mirrors `dbt_edge`: `dbt_column_edge` keyed by (path, version, job) with
the same composite FK to `script` and the same sweeps. The typed column list
lands in `dbt_node.column_schema`, beside `columns` rather than merged into it,
so `columns` stays what the author declared.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PRtsPQ3Ck69Fu9DNr7bMcJ
This commit is contained in:
Ruben Fiszel
2026-09-04 17:06:54 +02:00
co-authored by Claude Opus 5
parent 11138284ac
commit 39fa2b10d6
29 changed files with 1311 additions and 60 deletions
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM dbt_column_edge\n WHERE workspace_id = $1 AND script_path = $2 AND script_hash = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Int8"
]
},
"nullable": []
},
"hash": "178cb9cd8dfda66e878cd924f64a58ec742d7079703d417c886c8cf0a0e767a6"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT count(*) FROM dbt_column_edge WHERE workspace_id = $1 AND script_hash = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text",
"Int8"
]
},
"nullable": [
null
]
},
"hash": "97fd4f9939d96176f4cfc4a07e69466279aad62f33c57f392835e8265ed40f26"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM dbt_column_edge\n WHERE job_id <> '00000000-0000-0000-0000-000000000000'\n AND ingested_at < now() - make_interval(days => $1)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int4"
]
},
"nullable": []
},
"hash": "e5417b36319dafd5fcb2e42d812f778b2d3cf8b0f13cf4620192604cc3824a77"
}
@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM dbt_column_edge WHERE workspace_id = $1 AND script_path = $2\n AND script_hash = $3 AND job_id = $4",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Int8",
"Uuid"
]
},
"nullable": []
},
"hash": "f49cd9b5ea1e94d7cd10dcd1706cad62b5c1eff191f06905a3a78eb600e3bb4b"
}
@@ -0,0 +1,50 @@
{
"db_name": "PostgreSQL",
"query": "WITH live AS (\n SELECT * FROM (\n SELECT DISTINCT ON (s.path) s.path, s.hash\n FROM script s\n WHERE $5::text IS NULL AND s.workspace_id = $1 AND s.language = 'dbt'\n AND ($3::bigint IS NULL OR s.hash = $3)\n AND ($3::bigint IS NOT NULL OR (s.deleted = false AND s.archived = false))\n ORDER BY s.path, s.created_at DESC\n ) cur\n UNION ALL\n SELECT $5::text, $3::bigint WHERE $5::text IS NOT NULL\n ),\n chosen AS (\n SELECT CASE WHEN $4::uuid IS NOT NULL AND EXISTS (\n SELECT 1 FROM dbt_graph_snapshot g\n WHERE g.workspace_id = $1 AND g.job_id = $4)\n THEN $4::uuid\n ELSE '00000000-0000-0000-0000-000000000000'::uuid END AS job_id\n )\n SELECT p.asset_path AS \"from_path!\", e.parent_column AS \"from_column!\",\n c.asset_path AS \"to_path!\", e.child_column AS \"to_column!\",\n e.lineage_kind AS \"lineage_kind!\"\n FROM dbt_column_edge e\n JOIN live l ON l.path = e.script_path\n AND (e.script_hash = l.hash\n OR ($5::text IS NOT NULL AND l.hash IS NULL\n AND e.script_hash IS NULL))\n JOIN chosen ch ON ch.job_id = e.job_id\n JOIN dbt_node p ON p.workspace_id = e.workspace_id\n AND p.script_path = e.script_path\n AND (p.script_hash = e.script_hash\n OR ($5::text IS NOT NULL AND e.script_hash IS NULL\n AND p.script_hash IS NULL))\n AND p.job_id = ch.job_id\n AND p.unique_id = e.parent_unique_id\n JOIN dbt_node c ON c.workspace_id = e.workspace_id\n AND c.script_path = e.script_path\n AND (c.script_hash = e.script_hash\n OR ($5::text IS NOT NULL AND e.script_hash IS NULL\n AND c.script_hash IS NULL))\n AND c.job_id = ch.job_id\n AND c.unique_id = e.child_unique_id\n WHERE e.workspace_id = $1\n AND p.asset_path IS NOT NULL AND c.asset_path IS NOT NULL\n AND ($3::bigint IS NOT NULL OR $5::text IS NOT NULL OR EXISTS (\n SELECT 1 FROM asset a\n WHERE a.workspace_id = $1 AND a.kind = 'dbt'\n AND a.path = c.asset_path\n AND ($2::text IS NULL OR a.usage_path LIKE $2)))",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "from_path!",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "from_column!",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "to_path!",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "to_column!",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "lineage_kind!",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Int8",
"Uuid",
"Text"
]
},
"nullable": [
true,
false,
true,
false,
false
]
},
"hash": "f73676fe0138004a73cbf16eea680a0434da121dc2c81f59cdb63d651273c463"
}
+1
View File
@@ -16694,6 +16694,7 @@ dependencies = [
"opentelemetry 0.30.0",
"opentelemetry-proto 0.30.0",
"oracle",
"parquet",
"pem 3.0.6",
"pep440_rs",
"postgres-native-tls 0.5.3",
+6
View File
@@ -660,6 +660,12 @@ process-wrap = { version = "8.2.1", features = ["tokio1"] }
systemstat = "0.2.4"
datafusion = "47.0.0"
# The row API only: a dbt engine's parquet index is six string columns, so this
# needs no arrow and no writer. `parquet` is already in the tree with `arrow` for
# every shipped edition (`oss_core`), and cargo unifies the features there; this
# set is what a build WITHOUT object storage compiles. ZSTD is what the engine
# writes today, snap what parquet writers most often default to.
parquet = { version = "55.2.0", default-features = false, features = ["snap", "zstd"] }
object_store = { git = "https://github.com/apache/arrow-rs-object-store", rev = "36752c975d4f29e20b57c91f81a10872dcd48ae7", features = ["aws", "azure", "gcp"] }
openidconnect = { version = "4.0.0-rc.1" }
aws-config = "^1"
@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS dbt_column_edge;
ALTER TABLE dbt_node DROP COLUMN IF EXISTS column_schema;
@@ -0,0 +1,62 @@
-- Column-level lineage, from the engine's own static analysis.
--
-- `manifest.json` carries none, which is why decision 14 recorded the feature as
-- unavailable. The edges exist in a different artifact: an engine that does
-- static analysis writes `target/index/dbt.column_lineage.parquet` under
-- `dbt compile --static-analysis strict --write-index`. That pass is opt-in per
-- project (`column_lineage: true`), because strict analysis rejects SQL the
-- default accepts and must never become a silent requirement of running a build.
-- One column-to-column edge, keyed exactly like `dbt_edge`: a version's graph
-- dies with its version through the composite foreign key, a run's snapshot is
-- keyed by `job_id` with the zero UUID meaning "the version's own graph", and an
-- editor buffer's parse carries a NULL `script_hash` keyed to its preview job.
CREATE TABLE IF NOT EXISTS dbt_column_edge (
workspace_id VARCHAR(50) NOT NULL REFERENCES workspace(id) ON DELETE CASCADE ON UPDATE CASCADE,
script_path VARCHAR(255) NOT NULL,
script_hash BIGINT,
job_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000',
parent_unique_id TEXT NOT NULL,
parent_column TEXT NOT NULL,
child_unique_id TEXT NOT NULL,
child_column TEXT NOT NULL,
-- dbt's own word for how the value travelled: `copy` (passthrough), `mod`
-- (transformed), `scan` (the column was read to produce the ROW rather than
-- the value -- a join key, a `where` predicate, a `group by`). TEXT rather
-- than an enum because the engine treats the set as open: its own reader maps
-- those three and returns anything else verbatim.
lineage_kind TEXT NOT NULL,
ingested_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- Two partial unique indexes rather than a primary key, for the reason
-- 20260801121717 gives: a versioned graph is keyed by its version, a buffer
-- parse by its job alone.
CONSTRAINT dbt_column_edge_script_fkey FOREIGN KEY (workspace_id, script_hash)
REFERENCES script (workspace_id, hash) ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS dbt_column_edge_versioned_key
ON dbt_column_edge (workspace_id, script_path, script_hash, job_id,
parent_unique_id, parent_column, child_unique_id, child_column)
WHERE script_hash IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS dbt_column_edge_editor_key
ON dbt_column_edge (workspace_id, job_id,
parent_unique_id, parent_column, child_unique_id, child_column)
WHERE script_hash IS NULL;
-- Same age sweep as the other per-run rows, and the same reason there is no
-- foreign key to `v2_job`.
CREATE INDEX IF NOT EXISTS idx_dbt_column_edge_run_age ON dbt_column_edge (ingested_at)
WHERE job_id <> '00000000-0000-0000-0000-000000000000';
-- The real column schema of a node, which only static analysis knows: an
-- ordered `[{"name": …, "type": …}]`, from `dbt.node_columns.parquet`.
--
-- Beside `columns` rather than folded into it. `columns` is the DECLARED
-- metadata `manifest.json` carries -- the names an author wrote in `schema.yml`
-- and the prose against them -- and stays exactly that, so a project that
-- documents two of forty columns keeps saying so. This is the other forty,
-- typed, in the order the model produces them.
ALTER TABLE dbt_node ADD COLUMN IF NOT EXISTS column_schema JSONB;
GRANT ALL ON dbt_column_edge TO windmill_user;
GRANT ALL ON dbt_column_edge TO windmill_admin;
@@ -58,6 +58,19 @@ impl DbtEngine {
pub fn emits_node_events(&self) -> bool {
matches!(self, DbtEngine::DbtCore1x)
}
/// Whether the engine's CLI has `--write-index`, the flag that writes the
/// parquet index column lineage lives in. False for 1.x, whose Python CLI
/// has no such option.
///
/// True is not a promise that the artifact appears: `dbt-core` 2.0.0-alpha.5
/// accepts the flag, declares the views over `dbt.column_lineage` in its own
/// `views.sql`, and writes neither that parquet nor `dbt.node_columns`. Only
/// Fusion does today. Attempting the pass on both is what lets a later 2.x
/// release pick the feature up with no change here.
pub fn writes_column_index(&self) -> bool {
!matches!(self, DbtEngine::DbtCore1x)
}
}
/// How the warehouse connection is supplied. Both paths are supported
@@ -127,6 +140,18 @@ pub struct DbtDescriptor {
pub selector: Option<String>,
#[serde(default)]
pub test_behavior: DbtTestBehavior,
/// Ingest column-to-column lineage and the real column schemas, from the
/// engine's static analysis.
///
/// Opt-in, and it has to be: the artifact only appears under
/// `--static-analysis strict`, which rejects SQL the default accepts (an
/// unresolvable identifier is an error there and compiles fine otherwise).
/// Turning it on for everyone would make a stricter dialect the price of
/// deploying a dbt project. It is a separate `dbt compile` pass, so nothing
/// it decides can change what a build does; a project it cannot analyze
/// keeps the graph it has today.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub column_lineage: bool,
/// `--vars`. dbt vars are typed — numbers, booleans, lists and objects are
/// all normal — so values keep their YAML type; only string leaves carry
/// `{{ arg }}` placeholders the worker substitutes from job args. Coercing
+112 -3
View File
@@ -663,10 +663,18 @@ struct DbtAssetProvenance {
description: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
data_tests: Vec<DbtDataTest>,
/// Declared column metadata (name -> description). NOT column lineage —
/// `manifest.json` carries none (docs/dbt-runtime.md, decision 14).
/// Declared column metadata (name -> description): what `manifest.json`
/// carries, which is only the columns an author wrote down.
#[serde(skip_serializing_if = "Option::is_none")]
columns: Option<serde_json::Value>,
/// Every column of the relation, typed and in order —
/// `[{"name": …, "type": …}]` — from the engine's static analysis. Present
/// only for a project that opted into it. Not gated on `script_visible`,
/// like the column edges and the `ref()` graph: the SHAPE of a relation is
/// what an in-scope consumer needs in order to explain what it reads, and
/// the prose about it is what `columns` above gates.
#[serde(skip_serializing_if = "Option::is_none")]
column_schema: Option<serde_json::Value>,
/// A source's declared freshness policy, for the staleness chip.
#[serde(skip_serializing_if = "Option::is_none")]
freshness: Option<serde_json::Value>,
@@ -921,6 +929,11 @@ pub struct AssetGraphResponse {
/// instead of the project's actual shape.
#[serde(skip_serializing_if = "Vec::is_empty", default)]
dbt_edges: Vec<DbtLineageEdge>,
/// Column-to-column lineage between two dbt models, from the engine's static
/// analysis. Empty unless the project opted into it — `manifest.json`
/// carries none.
#[serde(skip_serializing_if = "Vec::is_empty", default)]
dbt_column_edges: Vec<DbtColumnLineageEdge>,
/// The job whose snapshot the dbt half was resolved from, when one was
/// asked for and found. A run page polls the graph while its job runs
/// because a dynamic descriptor's snapshot is written mid-run, and this is
@@ -946,6 +959,22 @@ struct DbtLineageEdge {
to_asset_path: String,
}
/// One column-to-column edge, in the same terms: the two relations and the two
/// columns, never dbt's node ids.
#[derive(Serialize, Debug, PartialEq, Eq, PartialOrd, Ord)]
struct DbtColumnLineageEdge {
from_asset_path: String,
from_column: String,
to_asset_path: String,
to_column: String,
/// dbt's own word for how the value travelled: `copy` (passthrough), `mod`
/// (transformed), `scan` (read to produce the ROW rather than the value — a
/// join key, a predicate, a `group by`). Sent verbatim, including a kind
/// this engine version invented, because the renderer decides what a kind
/// means and the set is the engine's.
kind: String,
}
async fn asset_graph(
authed: ApiAuthed,
Path(w_id): Path<String>,
@@ -1323,7 +1352,7 @@ pub async fn asset_graph_for(
n.resource_type AS "resource_type!", n.name AS "name!", n.asset_path,
n.materialized, n.materialize_strategy, n.tags AS "tags!", n.description,
n.test_kind, n.test_column, n.test_args, n.severity, n.attached_node,
n.columns, n.freshness,
n.columns, n.column_schema, n.freshness,
n.raw_code, n.original_file_path,
-- Whether the caller may read the project this row describes.
-- The query deliberately reaches outside the requested folder
@@ -1461,6 +1490,69 @@ pub async fn asset_graph_for(
.fetch_all(&mut *tx)
.await?;
// Column-to-column lineage, resolved to the relations the two nodes produce,
// like the `ref()` edges above and scoped exactly the same way. Present only
// for a project that opted into the analysis pass, so the usual graph pays
// one indexed lookup that finds nothing.
let dbt_column_edge_rows = sqlx::query!(
r#"WITH live AS (
SELECT * FROM (
SELECT DISTINCT ON (s.path) s.path, s.hash
FROM script s
WHERE $5::text IS NULL AND s.workspace_id = $1 AND s.language = 'dbt'
AND ($3::bigint IS NULL OR s.hash = $3)
AND ($3::bigint IS NOT NULL OR (s.deleted = false AND s.archived = false))
ORDER BY s.path, s.created_at DESC
) cur
UNION ALL
SELECT $5::text, $3::bigint WHERE $5::text IS NOT NULL
),
chosen AS (
SELECT CASE WHEN $4::uuid IS NOT NULL AND EXISTS (
SELECT 1 FROM dbt_graph_snapshot g
WHERE g.workspace_id = $1 AND g.job_id = $4)
THEN $4::uuid
ELSE '00000000-0000-0000-0000-000000000000'::uuid END AS job_id
)
SELECT p.asset_path AS "from_path!", e.parent_column AS "from_column!",
c.asset_path AS "to_path!", e.child_column AS "to_column!",
e.lineage_kind AS "lineage_kind!"
FROM dbt_column_edge e
JOIN live l ON l.path = e.script_path
AND (e.script_hash = l.hash
OR ($5::text IS NOT NULL AND l.hash IS NULL
AND e.script_hash IS NULL))
JOIN chosen ch ON ch.job_id = e.job_id
JOIN dbt_node p ON p.workspace_id = e.workspace_id
AND p.script_path = e.script_path
AND (p.script_hash = e.script_hash
OR ($5::text IS NOT NULL AND e.script_hash IS NULL
AND p.script_hash IS NULL))
AND p.job_id = ch.job_id
AND p.unique_id = e.parent_unique_id
JOIN dbt_node c ON c.workspace_id = e.workspace_id
AND c.script_path = e.script_path
AND (c.script_hash = e.script_hash
OR ($5::text IS NOT NULL AND e.script_hash IS NULL
AND c.script_hash IS NULL))
AND c.job_id = ch.job_id
AND c.unique_id = e.child_unique_id
WHERE e.workspace_id = $1
AND p.asset_path IS NOT NULL AND c.asset_path IS NOT NULL
AND ($3::bigint IS NOT NULL OR $5::text IS NOT NULL OR EXISTS (
SELECT 1 FROM asset a
WHERE a.workspace_id = $1 AND a.kind = 'dbt'
AND a.path = c.asset_path
AND ($2::text IS NULL OR a.usage_path LIKE $2)))"#,
&w_id,
folder_filter.as_deref(),
dbt_script_hash,
dbt_job_id,
pinned_path,
)
.fetch_all(&mut *tx)
.await?;
// The marker `chosen` resolved to, answered once for the caller: its
// EXISTENCE is what lets a run page stop polling, and its timestamp is what
// an editor labels the graph's provenance with — "parsed from the editor at
@@ -1604,6 +1696,7 @@ pub async fn asset_graph_for(
description: r.description.clone().filter(|_| source_allowed),
data_tests: vec![],
columns: r.columns.clone().filter(|_| source_allowed),
column_schema: r.column_schema.clone(),
freshness: r.freshness.clone().filter(|_| source_allowed),
};
// One relation can carry rows from several projects — typically a model
@@ -2166,6 +2259,21 @@ pub async fn asset_graph_for(
.collect();
dbt_edges.sort();
dbt_edges.dedup();
let mut dbt_column_edges: Vec<DbtColumnLineageEdge> = dbt_column_edge_rows
.into_iter()
.filter(|r| {
rendered.contains(r.from_path.as_str()) && rendered.contains(r.to_path.as_str())
})
.map(|r| DbtColumnLineageEdge {
from_asset_path: r.from_path,
from_column: r.from_column,
to_asset_path: r.to_path,
to_column: r.to_column,
kind: r.lineage_kind,
})
.collect();
dbt_column_edges.sort();
dbt_column_edges.dedup();
Ok(Json(AssetGraphResponse {
assets,
@@ -2175,6 +2283,7 @@ pub async fn asset_graph_for(
macro_edges,
test_edges,
dbt_edges,
dbt_column_edges,
dbt_snapshot_job,
dbt_graph_ingested_at,
}))
+44 -1
View File
@@ -25952,6 +25952,33 @@ components:
type: string
to_asset_path:
type: string
dbt_column_edges:
type: array
description: >-
Column-to-column lineage between two dbt models, from the engine's
own static analysis: `manifest.json` carries none, and the artifact
that does is only written when the project opts into the analysis
pass. Omitted when empty, which is the usual case.
items:
type: object
required: [from_asset_path, from_column, to_asset_path, to_column, kind]
properties:
from_asset_path:
type: string
from_column:
type: string
to_asset_path:
type: string
to_column:
type: string
kind:
type: string
description: >-
dbt's own word for how the value travelled — `copy`
(passthrough), `mod` (transformed), `scan` (read to produce the
ROW rather than the value: a join key, a predicate, a `group
by`). Not an enum: the engine's own reader maps those three and
passes anything else through.
dbt_snapshot_job:
type: string
format: uuid
@@ -26018,7 +26045,23 @@ components:
columns:
type: object
additionalProperties: true
description: Declared column metadata (name -> description). NOT column lineage — `manifest.json` carries none.
description: Declared column metadata (name -> description) — what `manifest.json` carries, which is only the columns an author wrote down. Omitted when the caller cannot read the script.
column_schema:
type: array
description: >-
Every column of the relation, typed and in the order the model
produces them, from the engine's static analysis. Present only for a
project that opted into it. Unlike `columns` this is the relation's
SHAPE, so it is not gated on being able to read the project.
items:
type: object
required: [name]
properties:
name:
type: string
type:
type: string
description: The declared type where `schema.yml` gives one, else the inferred one. Omitted when neither is known.
freshness:
type: object
additionalProperties: true
+229 -5
View File
@@ -188,6 +188,12 @@ fn graph_digest(ingested: &IngestedManifest, relation_root: &str) -> String {
.unwrap_or_default()
.as_bytes(),
);
h.update(b"\0");
h.update(
serde_json::to_string(&ingested.column_edges)
.unwrap_or_default()
.as_bytes(),
);
format!("{:x}", h.finalize())
}
@@ -297,6 +303,14 @@ pub async fn prune_dbt_run_graphs(
)
.execute(db)
.await?;
sqlx::query!(
"DELETE FROM dbt_column_edge
WHERE job_id <> '00000000-0000-0000-0000-000000000000'
AND ingested_at < now() - make_interval(days => $1)",
RUN_GRAPH_RETENTION_DAYS,
)
.execute(db)
.await?;
// In ONE transaction with the orphan sweep: a restart in the gap leaves graph
// rows whose marker is gone, and since the sweep runs only when a marker went,
// every later call computes `retired == 0` and skips them for good.
@@ -325,7 +339,7 @@ pub async fn prune_dbt_run_graphs(
// partial index here — all of them `WHERE job_id <> DEPLOYED` — and past the
// keep-count is rare, so the ordinary run should pay for neither.
if retired > 0 {
for table in ["dbt_node", "dbt_edge"] {
for table in ["dbt_node", "dbt_edge", "dbt_column_edge"] {
sqlx::query(&format!(
"DELETE FROM {table} t
WHERE t.workspace_id = $1 AND t.script_path = $2
@@ -381,6 +395,11 @@ pub struct IngestedNode {
pub severity: Option<String>,
pub attached_node: Option<String>,
pub columns: Option<serde_json::Value>,
/// The node's real columns, typed and ordered — `[{"name": …, "type": …}]`,
/// from the engine's static analysis. `None` when the project did not ask
/// for it or the engine wrote none. Beside `columns` rather than merged into
/// it: that one is what the author DECLARED, and stays that.
pub column_schema: Option<serde_json::Value>,
pub freshness: Option<serde_json::Value>,
/// The transform itself, for the graph to render. The copy taken at
/// deploy: the file itself is in the script's module bundle.
@@ -388,6 +407,40 @@ pub struct IngestedNode {
pub original_file_path: Option<String>,
}
/// One column-to-column edge of the ingested graph.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Default)]
#[serde(default)]
pub struct IngestedColumnEdge {
pub parent_unique_id: String,
pub parent_column: String,
pub child_unique_id: String,
pub child_column: String,
/// dbt's own word: `copy`, `mod` or `scan`. Kept verbatim — the engine's own
/// reader maps those three and passes anything else through, so the set is
/// open.
pub lineage_kind: String,
}
/// One column of a node, as the engine's static analysis resolved it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IndexedColumn {
pub name: String,
/// The declared type where `schema.yml` gives one, else the inferred one.
/// Empty when neither is known; the column still belongs to the relation, so
/// only the type is left out.
pub column_type: String,
/// Position in the relation, which is the order the panel lists them in.
pub index: i64,
}
/// What one `--write-index` pass produced: the column edges of the whole
/// project and the real column schema per node.
#[derive(Debug, Default)]
pub struct ColumnIndex {
pub edges: Vec<IngestedColumnEdge>,
pub columns: HashMap<String, Vec<IndexedColumn>>,
}
// Serde: an agent worker cannot write these tables directly, so it posts the
// whole manifest to the server, which stores it with the same function the SQL
// path uses.
@@ -398,6 +451,9 @@ pub struct IngestedNode {
pub struct IngestedManifest {
pub nodes: Vec<IngestedNode>,
pub edges: Vec<(String, String)>,
/// Column-to-column lineage, when the project asked for it and the engine
/// produced it. Empty is the normal case — see `attach_column_index`.
pub column_edges: Vec<IngestedColumnEdge>,
/// The `asset` rows the owning script produces (models) and consumes
/// (sources) — what the lineage graph is drawn from.
pub assets: Vec<AssetWithAltAccessType>,
@@ -405,6 +461,66 @@ pub struct IngestedManifest {
pub adapter_type: String,
}
/// The most column edges one graph stores.
///
/// A `scan` edge — the column was read to produce the row, not the value — is
/// emitted from every join key and every predicate column to every output
/// column, so one wide model over a multi-column join contributes columns times
/// predicates edges on its own. The cap is what keeps a project shaped like that
/// from turning one deploy into a multi-million-row insert; past it the lineage
/// is truncated and the rest of the graph is unaffected.
pub const MAX_COLUMN_EDGES: usize = 200_000;
impl IngestedManifest {
/// Fold one `--write-index` pass into the graph.
///
/// Both halves are scoped to the nodes this graph already kept: the index
/// describes the whole project, while the graph describes what this script's
/// selection builds plus the parents anchoring its edges, and an edge whose
/// endpoint is absent has nothing to draw.
pub fn attach_column_index(&mut self, index: ColumnIndex) {
let kept: std::collections::HashSet<&str> =
self.nodes.iter().map(|n| n.unique_id.as_str()).collect();
let mut edges: Vec<IngestedColumnEdge> = index
.edges
.into_iter()
.filter(|e| {
kept.contains(e.parent_unique_id.as_str())
&& kept.contains(e.child_unique_id.as_str())
})
.collect();
// Sorted and deduplicated for the digest, which decides whether a run
// stores a snapshot at all: parquet row order is the engine's and two
// passes over one project must not read as two different graphs.
edges.sort();
edges.dedup();
edges.truncate(MAX_COLUMN_EDGES);
self.column_edges = edges;
let mut columns = index.columns;
for node in self.nodes.iter_mut() {
let Some(mut cols) = columns.remove(&node.unique_id) else {
continue;
};
if cols.is_empty() {
continue;
}
cols.sort_by_key(|c| c.index);
node.column_schema = Some(serde_json::Value::Array(
cols.into_iter()
// A column the analysis typed as nothing still belongs in
// the list — that it exists is the half `manifest.json`
// could not answer.
.map(|c| match c.column_type.is_empty() {
true => serde_json::json!({ "name": c.name }),
false => serde_json::json!({ "name": c.name, "type": c.column_type }),
})
.collect(),
));
}
}
}
/// dbt's `materialized` mapped onto Windmill's write strategy.
///
/// The mapping is exact for the four strategies Windmill has, and deliberately
@@ -655,6 +771,9 @@ pub fn ingest_manifest(
.map(|(k, v)| (k.clone(), v.description.clone().unwrap_or_default()))
.collect::<BTreeMap<_, _>>())
}),
// Filled by `attach_column_index` when the project asked for it:
// the manifest carries declared columns only.
column_schema: None,
freshness: node.freshness.clone(),
// The transform the graph renders. Capped: a project can hold
// thousands of models and this is duplicated per deploy, so a
@@ -826,6 +945,16 @@ pub async fn replace_dbt_manifest(
)
.execute(&mut **tx)
.await?;
sqlx::query!(
"DELETE FROM dbt_column_edge WHERE workspace_id = $1 AND script_path = $2
AND script_hash = $3 AND job_id = $4",
workspace_id,
script_path,
script_hash,
job_id
)
.execute(&mut **tx)
.await?;
// The marker, before the rows: a graph with no nodes at all is a legitimate
// answer for a dynamic run that disabled every model, and the reader must be
// able to tell it from a run that stored nothing.
@@ -881,7 +1010,7 @@ async fn insert_graph_rows(
"INSERT INTO dbt_node (workspace_id, script_path, script_hash, job_id, unique_id, \
resource_type, name, asset_path, materialized, materialize_strategy, unique_key, \
tags, description, test_kind, test_column, test_args, severity, attached_node, \
columns, freshness, raw_code, original_file_path) ",
columns, column_schema, freshness, raw_code, original_file_path) ",
);
q.push_values(chunk, |mut b, n| {
b.push_bind(workspace_id)
@@ -903,6 +1032,7 @@ async fn insert_graph_rows(
.push_bind(&n.severity)
.push_bind(&n.attached_node)
.push_bind(&n.columns)
.push_bind(&n.column_schema)
.push_bind(&n.freshness)
.push_bind(&n.raw_code)
.push_bind(&n.original_file_path);
@@ -926,6 +1056,26 @@ async fn insert_graph_rows(
q.push(" ON CONFLICT DO NOTHING");
q.build().execute(&mut **tx).await?;
}
for chunk in ingested.column_edges.chunks(COLUMN_EDGE_INSERT_CHUNK) {
let mut q = sqlx::QueryBuilder::new(
"INSERT INTO dbt_column_edge (workspace_id, script_path, script_hash, job_id, \
parent_unique_id, parent_column, child_unique_id, child_column, lineage_kind) ",
);
q.push_values(chunk, |mut b, e| {
b.push_bind(workspace_id)
.push_bind(script_path)
.push_bind(script_hash)
.push_bind(job_id)
.push_bind(&e.parent_unique_id)
.push_bind(&e.parent_column)
.push_bind(&e.child_unique_id)
.push_bind(&e.child_column)
.push_bind(&e.lineage_kind);
});
q.push(" ON CONFLICT DO NOTHING");
q.build().execute(&mut **tx).await?;
}
Ok(())
}
@@ -966,7 +1116,7 @@ pub async fn replace_dbt_editor_graph(
) -> Result<()> {
// By job alone, so re-executing one — a zombie recovered onto another
// worker — replaces its rows rather than colliding with them.
for table in ["dbt_node", "dbt_edge", "dbt_graph_snapshot"] {
for table in ["dbt_node", "dbt_edge", "dbt_column_edge", "dbt_graph_snapshot"] {
sqlx::query(&format!(
"DELETE FROM {table} WHERE workspace_id = $1 AND job_id = $2 AND script_hash IS NULL"
))
@@ -1016,7 +1166,7 @@ pub async fn replace_dbt_editor_graph(
.fetch_all(&mut **tx)
.await?;
if !retired.is_empty() {
for table in ["dbt_node", "dbt_edge"] {
for table in ["dbt_node", "dbt_edge", "dbt_column_edge"] {
sqlx::query(&format!(
"DELETE FROM {table} WHERE workspace_id = $1 AND job_id = ANY($2) \
AND script_hash IS NULL"
@@ -1035,6 +1185,8 @@ pub async fn replace_dbt_editor_graph(
const NODE_INSERT_CHUNK: usize = 2000;
/// Six columns, so the same ceiling allows far more.
const EDGE_INSERT_CHUNK: usize = 8000;
/// Nine columns, and by far the most numerous rows of the three.
const COLUMN_EDGE_INSERT_CHUNK: usize = 6000;
/// Clear one VERSION's graph: the delete-by-hash route, which only soft-deletes
/// its `script` row and so fires no cascade, and the ingest that finds no
@@ -1070,6 +1222,15 @@ pub async fn clear_dbt_manifest_version(
)
.execute(&mut **tx)
.await?;
sqlx::query!(
"DELETE FROM dbt_column_edge
WHERE workspace_id = $1 AND script_path = $2 AND script_hash = $3",
workspace_id,
script_path,
script_hash
)
.execute(&mut **tx)
.await?;
// The marker too, and every job's: a marker left standing for rows that are
// gone is read as a snapshot, and its digest still answers the suppression
// check — so an identical run would write nothing and then render an empty
@@ -1105,7 +1266,7 @@ pub async fn clear_dbt_editor_graphs(
workspace_id: &str,
script_path: &str,
) -> Result<()> {
for table in ["dbt_node", "dbt_edge", "dbt_graph_snapshot"] {
for table in ["dbt_node", "dbt_edge", "dbt_column_edge", "dbt_graph_snapshot"] {
sqlx::query(&format!(
"DELETE FROM {table}
WHERE workspace_id = $1 AND script_path = $2 AND script_hash IS NULL"
@@ -1765,6 +1926,69 @@ mod tests {
assert_eq!(back.assets.len(), ingested.assets.len());
assert_eq!(back.assets[0].path, ingested.assets[0].path);
}
// The index describes the whole PROJECT while the graph describes what this
// script's selection builds, so an edge whose endpoint the graph does not
// hold has nothing to draw and must not be stored.
#[test]
fn column_lineage_is_scoped_to_the_nodes_the_graph_kept() {
let mut i = ingested();
let kept = "model.jaffle_shop.customers";
let dropped = "model.other_project.elsewhere";
i.attach_column_index(ColumnIndex {
edges: vec![
edge("model.jaffle_shop.orders_daily", "id", kept, "id", "copy"),
edge(dropped, "id", kept, "id", "copy"),
edge(kept, "id", dropped, "id", "copy"),
],
columns: [
(
kept.to_string(),
vec![
col("total", "Float64", 1),
col("id", "Int32", 0),
col("untyped", "", 2),
],
),
(dropped.to_string(), vec![col("id", "Int32", 0)]),
]
.into(),
});
assert_eq!(
i.column_edges
.iter()
.map(|e| (e.parent_unique_id.as_str(), e.child_unique_id.as_str()))
.collect::<Vec<_>>(),
vec![("model.jaffle_shop.orders_daily", kept)]
);
// In `column_index` order, and a column the analysis could not type still
// belongs to the relation.
assert_eq!(
node(&i, kept).column_schema,
Some(serde_json::json!([
{"name": "id", "type": "Int32"},
{"name": "total", "type": "Float64"},
{"name": "untyped"},
]))
);
assert!(node(&i, "model.jaffle_shop.orders_daily")
.column_schema
.is_none());
}
fn edge(from: &str, from_col: &str, to: &str, to_col: &str, kind: &str) -> IngestedColumnEdge {
IngestedColumnEdge {
parent_unique_id: from.into(),
parent_column: from_col.into(),
child_unique_id: to.into(),
child_column: to_col.into(),
lineage_kind: kind.into(),
}
}
fn col(name: &str, column_type: &str, index: i64) -> IndexedColumn {
IndexedColumn { name: name.into(), column_type: column_type.into(), index }
}
}
/// Record one model's state for THIS RUN.
@@ -8,8 +8,8 @@
use sqlx::{Pool, Postgres};
use windmill_common::dbt_manifest::{
clear_dbt_editor_graphs, clear_dbt_manifest_version, prune_dbt_run_graphs,
replace_dbt_editor_graph, replace_dbt_manifest, IngestedManifest, IngestedNode,
DBT_EDITOR_GRAPHS_KEPT, DEPLOYED_GRAPH, DEPLOYED_GRAPH_VERSIONS_KEPT,
replace_dbt_editor_graph, replace_dbt_manifest, IngestedColumnEdge, IngestedManifest,
IngestedNode, DBT_EDITOR_GRAPHS_KEPT, DEPLOYED_GRAPH, DEPLOYED_GRAPH_VERSIONS_KEPT,
};
const WS: &str = "test-workspace";
@@ -52,10 +52,36 @@ fn manifest(names: &[&str]) -> IngestedManifest {
.windows(2)
.map(|w| (format!("model.p.{}", w[0]), format!("model.p.{}", w[1])))
.collect(),
// Same reason: a project that opted into the analysis pass has these, and
// a fixture without them leaves every column-edge insert and sweep in
// this file unexecuted.
column_edges: names
.windows(2)
.map(|w| IngestedColumnEdge {
parent_unique_id: format!("model.p.{}", w[0]),
parent_column: w[0].to_string(),
child_unique_id: format!("model.p.{}", w[1]),
child_column: w[1].to_string(),
lineage_kind: "copy".to_string(),
})
.collect(),
..Default::default()
}
}
/// Column edges of one version, so the sweeps can be shown to reach them.
async fn column_edges_for(db: &Pool<Postgres>, hash: i64) -> i64 {
sqlx::query_scalar!(
"SELECT count(*) FROM dbt_column_edge WHERE workspace_id = $1 AND script_hash = $2",
WS,
hash
)
.fetch_one(db)
.await
.unwrap()
.unwrap_or(0)
}
/// Edges for one version, so a test can assert the batched insert ran at all.
async fn edges_for(db: &Pool<Postgres>, hash: i64) -> i64 {
sqlx::query_scalar!(
@@ -275,6 +301,8 @@ async fn deleting_the_script_cascades_to_every_sidecar(db: Pool<Postgres>) {
assert_eq!(nodes_for(&db, 2, DEPLOYED_GRAPH).await, 0);
assert_eq!(edges_for(&db, 1).await, 0);
assert_eq!(edges_for(&db, 2).await, 0);
assert_eq!(column_edges_for(&db, 1).await, 0);
assert_eq!(column_edges_for(&db, 2).await, 0);
assert_eq!(markers_for_path(&db).await, 0);
}
@@ -315,7 +343,7 @@ async fn the_sweep_takes_old_snapshots_and_spares_the_version(db: Pool<Postgres>
tx.commit().await.unwrap();
// Age one snapshot past the window, rows and marker together.
for t in ["dbt_node", "dbt_edge", "dbt_graph_snapshot"] {
for t in ["dbt_node", "dbt_edge", "dbt_column_edge", "dbt_graph_snapshot"] {
sqlx::query(&format!(
"UPDATE {t} SET ingested_at = now() - interval '400 days' WHERE job_id = $1"
))
+4
View File
@@ -114,6 +114,10 @@ rust_decimal.workspace = true
jsonwebtoken.workspace = true
sha2.workspace = true
hmac.workspace = true
# Reads the dbt engine's `target/index/*.parquet`, which is where column-level
# lineage lives. Unconditional rather than behind the `parquet` FEATURE: that one
# is object storage, and a build without it still runs dbt jobs.
parquet.workspace = true
pem = { workspace = true, optional = true }
rsa = { workspace = true, optional = true }
urlencoding.workspace = true
@@ -0,0 +1,283 @@
//! Column-level lineage and real column schemas, from the engine's own static
//! analysis.
//!
//! `manifest.json` carries neither. What does is the parquet index an engine
//! writes under `dbt compile --static-analysis strict --write-index`:
//! `dbt.column_lineage.parquet` (column-to-column edges, each labelled `copy`,
//! `mod` or `scan`) and `dbt.node_columns.parquet` (every column of every node,
//! typed and ordered, rather than only the ones an author documented).
//!
//! Three properties shape everything here, all of them measured against the real
//! engines rather than assumed:
//!
//! - **Strict analysis rejects SQL the default accepts.** An unresolvable
//! identifier is an error under `strict` and compiles fine otherwise, so this
//! is a SEPARATE pass with its own `--target-path`, never a flag on the build,
//! and it is opt-in per project.
//! - **A failed pass still writes the index**, holding every edge of the models
//! that did analyze. So the artifact is read whatever the exit status.
//! - **The flag is not the capability.** `dbt-core` 2.0.0-alpha.5 accepts
//! `--write-index`, declares the views over these two tables in its own
//! `views.sql`, and writes neither file; only Fusion does today. Nothing here
//! asks which engine it is beyond "has the flag" — a release that starts
//! writing them is picked up with no change.
use std::path::Path;
use parquet::file::reader::{FileReader, SerializedFileReader};
use parquet::record::{Field, Row};
use uuid::Uuid;
use windmill_common::dbt_manifest::{ColumnIndex, IndexedColumn, IngestedColumnEdge};
use windmill_common::error;
use windmill_common::worker::Connection;
use windmill_parser_yaml::dbt::DbtDescriptor;
use windmill_queue::append_logs;
use crate::dbt_executor::{dbt_command, Invocation, PreparedProject};
use crate::handle_child::JobCtx;
/// Where the lineage pass writes, relative to the project directory.
///
/// Its own tree, not the runtime's `wm_target`: a `dbt compile` writes
/// `manifest.json` and `run_results.json` like any other invocation, and after a
/// build those two are what the graph ingest and `dbt retry` read.
const CLL_ARTIFACTS_DIR: &str = "wm_target_cll";
const COLUMN_LINEAGE_PARQUET: &str = "dbt.column_lineage.parquet";
const NODE_COLUMNS_PARQUET: &str = "dbt.node_columns.parquet";
/// Run the lineage pass and read what it produced.
///
/// Best-effort throughout: every failure returns `None` with a line in the job
/// log saying which one, because the graph without column lineage is exactly the
/// graph this project had before it asked for any.
pub(crate) async fn collect(
p: &PreparedProject,
descriptor: &DbtDescriptor,
inv: &Invocation,
ctx: &mut JobCtx<'_>,
job_id: &Uuid,
w_id: &str,
conn: &Connection,
) -> Option<ColumnIndex> {
if !descriptor.column_lineage {
return None;
}
if !p.engine.engine.writes_column_index() {
append_logs(
job_id,
w_id,
format!(
"\n`column_lineage` is set, but the {} engine has no `--write-index`: column \
lineage needs an engine that does static analysis. The rest of the graph is \
unaffected.\n",
p.engine.engine.as_str()
),
conn,
)
.await;
return None;
}
let index_dir = p.project_dir.join(CLL_ARTIFACTS_DIR).join("index");
// A previous pass in the same job directory — a retry's second attempt —
// would otherwise be read back as this one's answer.
tokio::fs::remove_dir_all(p.project_dir.join(CLL_ARTIFACTS_DIR))
.await
.ok();
let mut cmd = dbt_command(
p,
&[
"compile",
"--static-analysis",
"strict",
"--write-index",
// Documented as what builds the CLL graph, and `--write-index` alone
// happens to imply it on the engine probed. Passed explicitly so the
// pass does not depend on which of the two is doing the work.
"--write-lineage",
"--target-path",
CLL_ARTIFACTS_DIR,
],
);
if let Err(e) = crate::dbt_executor::add_vars(&mut cmd, descriptor, inv) {
tracing::warn!("dbt column lineage: {e:#}");
return None;
}
// Captured rather than streamed: a strict-analysis failure is a wall of
// diagnostics about SQL the build itself accepts, and this pass decides
// nothing about whether that build runs.
let outcome = crate::dbt_executor::run_capturing(
cmd,
"dbt compile (column lineage)",
ctx,
job_id,
w_id,
conn,
CLL_MAX_OUTPUT_BYTES,
)
.await;
let index = read_index(&index_dir).await;
// What the caller cannot say for itself. The COUNTS are logged where the
// index is folded into the graph, since the graph is what decides how much
// of it is kept; this is the part only the pass knows.
match (&index, &outcome) {
// The ordinary partial outcome: some models did not analyze, the rest
// did, and their lineage is real.
(Some(_), Err(e)) => {
let note = "Column lineage: `--static-analysis strict` rejected part of the project, \
so the lineage covers only the models it could analyze.";
append_logs(job_id, w_id, format!("\n{note}\n{}", diagnostics(&e.to_string())), conn)
.await;
}
(Some(_), Ok(_)) => {}
(None, outcome) => {
let note = format!(
"No column lineage: the analysis pass wrote no `{COLUMN_LINEAGE_PARQUET}`. Only \
an engine that computes it does, and only for the warehouses it analyzes \
natively — the flag alone is not the capability."
);
// The engine's own diagnostics. They are how a reader learns that
// this adapter turned static analysis off, which it reports as a
// warning on a SUCCESSFUL compile that nothing else would show.
let detail = match outcome {
Ok(c) => diagnostics(&c.stderr),
Err(e) => diagnostics(&e.to_string()),
};
append_logs(job_id, w_id, format!("\n{note}\n{detail}"), conn).await;
}
}
index
}
/// stdout the pass may produce. It is a compile, so this is diagnostics rather
/// than data.
const CLL_MAX_OUTPUT_BYTES: usize = 1 << 20;
/// The tail of what the engine said, bounded. The whole of it is every rendered
/// model on a large project, which is not what a job log is for.
const DIAGNOSTIC_LINES: usize = 40;
fn diagnostics(out: &str) -> String {
let lines: Vec<&str> = out.lines().filter(|l| !l.trim().is_empty()).collect();
let tail = &lines[lines.len().saturating_sub(DIAGNOSTIC_LINES)..];
match tail.is_empty() {
true => String::new(),
false => format!("{}\n", tail.join("\n")),
}
}
/// Read both parquets, if the lineage one is there.
///
/// The column schemas alone are not worth a graph: they arrive with the lineage
/// or not at all, and a node's declared columns already answer for the case
/// where the pass never ran.
async fn read_index(index_dir: &Path) -> Option<ColumnIndex> {
let lineage = index_dir.join(COLUMN_LINEAGE_PARQUET);
if !tokio::fs::try_exists(&lineage).await.unwrap_or(false) {
return None;
}
let columns = index_dir.join(NODE_COLUMNS_PARQUET);
// Decompressing and decoding a parquet is CPU work on a file the engine just
// wrote, so it does not belong on the runtime's poll thread.
tokio::task::spawn_blocking(move || read_index_blocking(&lineage, &columns))
.await
.map_err(|e| tracing::warn!("reading the dbt column index: {e:#}"))
.ok()?
.map_err(|e| tracing::warn!("reading the dbt column index: {e:#}"))
.ok()
}
fn read_index_blocking(lineage: &Path, columns: &Path) -> error::Result<ColumnIndex> {
let mut out = ColumnIndex::default();
for row in rows(lineage)? {
let parent_unique_id = string(&row, "from_node_unique_id");
let child_unique_id = string(&row, "to_node_unique_id");
let parent_column = string(&row, "from_column_name");
let child_column = string(&row, "to_column_name");
// A column of a node the analysis could not name is not an endpoint the
// graph can draw.
if parent_unique_id.is_empty()
|| child_unique_id.is_empty()
|| parent_column.is_empty()
|| child_column.is_empty()
{
continue;
}
out.edges.push(IngestedColumnEdge {
parent_unique_id,
parent_column,
child_unique_id,
child_column,
lineage_kind: string(&row, "lineage_kind"),
});
}
// Absent is normal — an engine can write the lineage table and not this one —
// and unreadable is not worth losing the lineage over.
if let Ok(rows) = rows(columns) {
for row in rows {
let unique_id = string(&row, "unique_id");
let name = string(&row, "column_name");
if unique_id.is_empty() || name.is_empty() {
continue;
}
// The author's `data_type` where `schema.yml` gives one, since that
// is what the project calls the column; the analysis's own inference
// otherwise.
let column_type = match string(&row, "declared_type") {
t if !t.is_empty() => t,
_ => string(&row, "inferred_type"),
};
out.columns
.entry(unique_id)
.or_default()
.push(IndexedColumn {
name,
column_type,
index: int(&row, "column_index").unwrap_or(i64::MAX),
});
}
}
Ok(out)
}
fn rows(path: &Path) -> error::Result<Vec<Row>> {
let file = std::fs::File::open(path)
.map_err(|e| error::Error::internal_err(format!("opening {}: {e}", path.display())))?;
let reader = SerializedFileReader::new(file)
.map_err(|e| error::Error::internal_err(format!("reading {}: {e}", path.display())))?;
reader
.get_row_iter(None)
.map_err(|e| error::Error::internal_err(format!("reading {}: {e}", path.display())))?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| error::Error::internal_err(format!("reading {}: {e}", path.display())))
}
/// By NAME, not by position: these tables are the engine's own schema and it
/// adds columns to them between releases.
fn field<'a>(row: &'a Row, name: &str) -> Option<&'a Field> {
row.get_column_iter()
.find(|(k, _)| k.as_str() == name)
.map(|(_, v)| v)
}
fn string(row: &Row, name: &str) -> String {
match field(row, name) {
Some(Field::Str(s)) => s.clone(),
Some(Field::Bytes(b)) => String::from_utf8_lossy(b.data()).into_owned(),
_ => String::new(),
}
}
fn int(row: &Row, name: &str) -> Option<i64> {
match field(row, name) {
Some(Field::Long(v)) => Some(*v),
Some(Field::Int(v)) => Some(*v as i64),
Some(Field::Short(v)) => Some(*v as i64),
Some(Field::UInt(v)) => Some(*v as i64),
Some(Field::ULong(v)) => i64::try_from(*v).ok(),
_ => None,
}
}
+105 -9
View File
@@ -653,12 +653,23 @@ pub(crate) async fn dbt_dep(
None => GraphPublisher::Unversioned,
};
let superseded = if let Some(warehouse) = prepared.warehouse.as_deref() {
let ingested = windmill_common::dbt_manifest::ingest_manifest(
let mut ingested = windmill_common::dbt_manifest::ingest_manifest(
&manifest,
warehouse,
prepared.default_database.as_deref(),
selected.as_ref(),
);
attach_column_index(
&mut ingested,
&prepared,
&descriptor,
&inv,
&mut ctx,
job_id,
w_id,
&conn,
)
.await;
let published = persist_ingest(
db,
w_id,
@@ -2882,7 +2893,8 @@ async fn run_show(
conn,
SHOW_MAX_OUTPUT_BYTES,
)
.await?;
.await?
.stdout;
// dbt frames the rows as `{"node": …, "show": [ … ]}`, pretty-printed, with a
// banner before and a deprecation summary after — so neither "the line starting
// with `{`" nor "first `{` to the end" parses. A streaming deserializer stops at
@@ -3064,12 +3076,23 @@ async fn run_parse_only(
// manifest and the selection while the warehouse only keys them — so a project
// with no warehouse identity still reports what dbt found. The placeholder
// reaches no row: the guard below returns before anything is written.
let ingested = windmill_common::dbt_manifest::ingest_manifest(
let mut ingested = windmill_common::dbt_manifest::ingest_manifest(
&manifest,
p.warehouse.as_deref().unwrap_or("unkeyed"),
p.default_database.as_deref(),
selected.as_ref(),
);
attach_column_index(
&mut ingested,
p,
descriptor,
inv,
ctx,
&job.id,
&job.workspace_id,
conn,
)
.await;
result.nodes = ingested.nodes.len();
result.edges = ingested.edges.len();
for n in &ingested.nodes {
@@ -3143,6 +3166,51 @@ async fn run_parse_only(
Ok(to_raw_value(&result))
}
/// Fold this project's column lineage into the graph about to be stored, when
/// the descriptor asked for it.
///
/// One helper for all three ingests — deploy, editor parse, per-run refresh —
/// because a graph that carries column lineage in one provenance and not another
/// reads as the lineage having disappeared.
async fn attach_column_index(
ingested: &mut windmill_common::dbt_manifest::IngestedManifest,
p: &PreparedProject,
descriptor: &DbtDescriptor,
inv: &Invocation,
ctx: &mut JobCtx<'_>,
job_id: &Uuid,
w_id: &str,
conn: &Connection,
) {
let Some(index) =
crate::dbt_column_index::collect(p, descriptor, inv, ctx, job_id, w_id, conn).await
else {
return;
};
let found = index.edges.len();
ingested.attach_column_index(index);
let kept = ingested.column_edges.len();
let typed: usize = ingested
.nodes
.iter()
.filter(|n| n.column_schema.is_some())
.count();
// Counted here rather than at the pass: the index describes the whole
// project and this graph describes one selection of it, so `found` is what
// dbt produced and `kept` is what the graph can draw.
let dropped = match found.saturating_sub(kept) {
0 => String::new(),
n => format!(" ({n} outside this graph or past the cap)"),
};
append_logs(
job_id,
w_id,
format!("\nIngested {kept} column lineage edges{dropped} and typed {typed} nodes\n"),
conn,
)
.await;
}
/// Refresh the stored graph from the manifest this run produced.
async fn ingest_from_run(
p: &PreparedProject,
@@ -3164,12 +3232,23 @@ async fn ingest_from_run(
// filter this run's manifest by a different node set than it built.
let selected =
resolve_selection(p, descriptor, inv, ctx, &job.id, &job.workspace_id, conn).await?;
let ingested = windmill_common::dbt_manifest::ingest_manifest(
let mut ingested = windmill_common::dbt_manifest::ingest_manifest(
&manifest,
warehouse,
p.default_database.as_deref(),
selected.as_ref(),
);
attach_column_index(
&mut ingested,
p,
descriptor,
inv,
ctx,
&job.id,
&job.workspace_id,
conn,
)
.await;
// Only a run whose models are its own snapshots per run. A static
// descriptor at a moved profile re-ingests the VERSION's graph, since the
// move outlives the run; one that neither drifted nor overrode anything
@@ -3428,7 +3507,9 @@ async fn resolve_selection(
// through the job-log writer, which `NO_LOGS_AT_ALL` discards — the selection
// would resolve to the empty set and the ingest would wipe the script's assets
// while dbt went on building the descriptor's models.
let stdout = run_capturing(cmd, "dbt ls", ctx, job_id, w_id, conn, LS_MAX_OUTPUT_BYTES).await?;
let stdout = run_capturing(cmd, "dbt ls", ctx, job_id, w_id, conn, LS_MAX_OUTPUT_BYTES)
.await?
.stdout;
let mut set = std::collections::HashSet::new();
for line in stdout.lines() {
let line = line.trim();
@@ -3471,7 +3552,15 @@ const CAPTURE_MAX_STDERR_BYTES: usize = 64 * 1024;
/// never holds more than it, so it has to be enforced while reading. Both pipes
/// are drained concurrently because a child that fills the one nobody reads
/// blocks forever.
async fn run_capturing(
/// What a captured invocation produced. `stderr` is where dbt writes its
/// diagnostics — the errors and warnings block — so a caller that has to explain
/// a SUCCESSFUL run needs it as much as a failing one does.
pub(crate) struct Captured {
pub stdout: String,
pub stderr: String,
}
pub(crate) async fn run_capturing(
mut cmd: Command,
name: &str,
ctx: &mut JobCtx<'_>,
@@ -3479,7 +3568,7 @@ async fn run_capturing(
w_id: &str,
conn: &Connection,
max_stdout_bytes: usize,
) -> error::Result<String> {
) -> error::Result<Captured> {
use tokio::io::AsyncReadExt;
let mut child = cmd
@@ -3568,7 +3657,10 @@ async fn run_capturing(
String::from_utf8_lossy(&stderr)
)));
}
Ok(String::from_utf8_lossy(&stdout).to_string())
Ok(Captured {
stdout: String::from_utf8_lossy(&stdout).to_string(),
stderr: String::from_utf8_lossy(&stderr).to_string(),
})
}
/// Run a preparation command through the same child handler the build uses, so
@@ -4518,7 +4610,11 @@ fn has_retryable_node(run_results: &str) -> bool {
}
/// Append `--vars` if the descriptor (or the run) declares any.
fn add_vars(cmd: &mut Command, descriptor: &DbtDescriptor, inv: &Invocation) -> error::Result<()> {
pub(crate) fn add_vars(
cmd: &mut Command,
descriptor: &DbtDescriptor,
inv: &Invocation,
) -> error::Result<()> {
let vars = resolved_vars(descriptor, &inv.args, inv.strict)?;
if !vars.is_empty() {
cmd.args(["--vars", &serde_json::to_string(&vars).unwrap_or_default()]);
+1
View File
@@ -33,6 +33,7 @@ pub mod common;
mod config;
mod csharp_executor;
mod dbt_column_index;
mod dbt_engine;
mod dbt_executor;
mod dbt_profiles;
+57 -7
View File
@@ -36,7 +36,7 @@ the dominant way dbt is orchestrated today.
| 11 | Asset kind | `dbt://<warehouse>/<schema>/<name>` — keyed on the relation, not on dbt's node id. See below |
| 12 | Graph refresh | Deploy-time, re-ingested per run only when the descriptor is dynamic, plus an explicit `parse` of the editor's buffer. See below |
| 13 | Manifest storage | Sidecar table for nodes/edges. Full manifest **not** stored — see below |
| 14 | Metadata depth | Tests, strategy, tags, freshness, column descriptions. Column **lineage** is not in the manifest — see below |
| 14 | Metadata depth | Tests, strategy, tags, freshness, column descriptions. Column **lineage** and real column schemas come from the engine's parquet index, opt-in per project — see below |
| 15 | Node rendering | Asset nodes per model plus one runnable node for the script |
| 16 | Progress | Live, from the JSON event stream |
| 17 | Test failures | Honor dbt's own `severity` |
@@ -857,6 +857,8 @@ profile:
select: ["tag:nightly+"]
exclude: []
test_behavior: build # build | after_all | none
column_lineage: false # opt in to the static-analysis pass that
# produces column-level lineage (decision 14)
vars: # typed: numbers/bools/lists keep their type,
run_date: "{{ run_date }}" # and string leaves take job arguments
strict: false
@@ -1151,11 +1153,57 @@ would be an unread copy of data that is already reproducible by redeploying (or,
for a dynamic descriptor, by the next run). Worth adding the day something needs the
parts the sidecar drops — compiled SQL, macro definitions — and not before.
**Decision 14 — column lineage is not available.** The decision assumed
`manifest.json` carries column-to-column edges; it does not, in either core
engine. What it does carry is declared column *descriptions*, which are
ingested. Real column lineage would need Fusion (which does static analysis) or
a SQL-AST pass of our own, so `columnLineageGraph.ts` is not wired up for dbt.
**Decision 14 — column lineage comes from the parquet index, not the manifest.**
`manifest.json` carries no column-to-column edges, in any engine, and its
`columns` are the ones an author declared in `schema.yml`. Both halves exist in a
different artifact: `dbt compile --static-analysis strict --write-index` writes
`target/index/`, and two of its tables are `dbt.column_lineage.parquet`
(`from_node_unique_id`, `from_column_name`, `to_node_unique_id`,
`to_column_name`, `lineage_kind`) and `dbt.node_columns.parquet` (every column of
every node, with its declared type, its inferred type and its description).
Three measured properties decide the shape of the ingest.
**Strict analysis is a stricter dialect.** `select no_such_column from
ref(...)` is `UnresolvedIdentifier (dbt0227)` and exit 1 under `strict`, and
compiles fine under `baseline` (the default). So this is a separate `dbt compile`
with its own `--target-path`, never a flag on the build, and it is opt-in per
project: `column_lineage: true` in the descriptor. Off, nothing changes. On, a
project that cannot be analyzed keeps exactly the graph it had. The pass never
fails a deploy or a run.
**A failed pass still writes the index**, holding every edge of the models that
did analyze, so the artifact is read whatever the exit status and partial lineage
is a normal outcome rather than an error. An unreachable *source* is milder
still: `RemoteError (dbt1014)` downgrades that model to `static_analysis: off`
and the compile succeeds. (Strict analysis queries the warehouse catalog for
source schemas; a `ref()`ed model is inferred statically and needs no built
table.)
**The flag is not the capability.** `dbt-core` 2.0.0-alpha.5 — the version
`DBT_CORE_2X_VERSION` pins — accepts `--write-index` and `--write-lineage`, and
its own `views.sql` declares views over both tables, but it writes neither
parquet; only Fusion does today. The ADAPTER decides too: an experimental one
(postgres under `DBT_ALLOW_EXPERIMENTAL_ADAPTERS`) turns static analysis off and
says so only in a warning on an otherwise successful compile. The gate is
therefore "the engine has the flag" (everything but 1.x, whose Python CLI has no
such option) plus "the file appeared", so a later release picking the feature up
needs no change here — and the job log carries the engine's own stderr whenever
no index appears, since without it "no column lineage" has no explanation.
`lineage_kind` is stored as TEXT, not an enum. Three values exist — `copy`
(passthrough), `mod` (transformed) and `scan` (the column was read to produce the
ROW rather than the value: a join key, a `where` predicate, a `group by`) — and
the engine's own reader maps those three and passes anything else through, so the
set is the engine's to extend. All three are stored and sent; the column-trace
diagram draws `copy` and `mod`, because a `scan` edge reaches every output column
of its model and would render as a complete bipartite graph.
Storage mirrors `dbt_edge` exactly: `dbt_column_edge`, keyed by (path, version,
job) with the same composite foreign key to `script`, so a version's column
lineage dies with the version and a run's snapshot with the sweep. The typed
column list lands in `dbt_node.column_schema`, beside `columns` rather than
merged into it — `columns` stays what the author *declared*.
## Concept mapping
@@ -1167,7 +1215,9 @@ a SQL-AST pass of our own, so `columnLineageGraph.ts` is not wired up for dbt.
| `materialized: incremental` | `append` or `merge` (by `unique_key`) | same |
| `{% snapshot %}` | `scd2` | same, incl. `<dim>_current` handling |
| `unique`/`not_null`/`accepted_values`/`relationships` | `data_tests` | exact 1:1 with the four `// data_test` kinds |
| declared column metadata | `columns` on the asset node | descriptions only; see the note below |
| declared column metadata | `columns` on the asset node | descriptions only, from the manifest |
| analyzed column schema | `column_schema` on the asset node | `dbt.node_columns.parquet`, opt-in |
| column-to-column lineage | column-lineage trace | `dbt.column_lineage.parquet`, opt-in |
| model `tags` | node badge | `tag` |
| source freshness | `freshness` | `last_success_at` chip |
| `run_results.json` | materialization records | `record_materialization` |
@@ -446,6 +446,15 @@
return scripts.length === 1 ? `${scripts[0].path}__dbt/${file}` : file
})
// The selected relation's own column nodes, which is what decides whether
// there is a trace to draw at all: a producer that declares no column lineage
// — or a dbt project that never asked for the analysis pass — has none.
let selectionColumnNodes = $derived(
selection?.kind === 'asset' && selectionColumnGraph
? assetColumnNodes(selectionColumnGraph, selection.asset_kind, selection.path)
: []
)
// Bound from ScriptEditor — populated by inferAssets on every code
// change. Forwarded to the page so the canvas can re-derive write
// edges as the user edits the body (e.g. renaming a CREATE TABLE
@@ -1221,7 +1230,7 @@
</span>
</div>
{/if}
{#if selectionColumnGraph && assetColumnNodes(selectionColumnGraph, selection.asset_kind, selection.path).length > 0}
{#if selectionColumnGraph && selectionColumnNodes.length > 0}
<div class="border-b shrink-0">
<ColumnLineageTrace
graph={selectionColumnGraph}
@@ -1236,7 +1245,7 @@
</div>
</div>
{/key}
{:else if selectionDbt?.raw_code}
{:else if selectionDbt && (selectionDbt.raw_code || selectionColumnNodes.length > 0)}
<!-- The transform behind the node. Read-only on purpose: dbt
development is a local loop (`dbt run --select`, `dbt test`
against a dev target), and a browser textarea over one file
@@ -1249,11 +1258,29 @@
<DbtIcon width={11} height={11} />
<span class="font-mono truncate">{dbtBundlePath ?? selectionDbt.unique_id}</span
>
<span class="ml-auto shrink-0 opacity-70">read-only · edit locally</span>
</div>
<div class="flex-1 min-h-0 overflow-auto">
<HighlightCode language="sql" code={selectionDbt.raw_code} />
{#if selectionDbt.raw_code}
<span class="ml-auto shrink-0 opacity-70">read-only · edit locally</span>
{/if}
</div>
<!-- Above the SQL rather than beside it: the columns are what
the SQL below produces, so reading them in that order is
the model's own shape. Same trace component the ducklake
assets use — the graph is one graph across both. -->
{#if selectionColumnGraph && selectionColumnNodes.length > 0}
<div class="border-b shrink-0 overflow-auto max-h-64">
<ColumnLineageTrace
graph={selectionColumnGraph}
assetKind={selection.asset_kind}
assetPath={selection.path}
targetLabel={selectionDbt.unique_id}
/>
</div>
{/if}
{#if selectionDbt.raw_code}
<div class="flex-1 min-h-0 overflow-auto">
<HighlightCode language="sql" code={selectionDbt.raw_code} />
</div>
{/if}
</div>
{:else}
<div class="p-3 text-xs text-secondary">
@@ -109,6 +109,48 @@ describe('buildColumnGraph', () => {
expect(colNodeId('ducklake', 'a:b', 'c')).not.toBe(colNodeId('ducklake', 'a', 'b:c'))
})
it('takes dbt column edges but not the indirect `scan` ones', () => {
// dbt arrives already resolved to two relations rather than anchored to a
// producer. `scan` means the column was read to produce the ROW — a join
// key, a predicate, a `group by` — so it reaches every output column of
// its model and is not the lineage a column trace means.
const g = buildColumnGraph({
assets: [],
runnables: [],
edges: [],
triggers: [],
dbt_column_edges: [
{
from_asset_path: 'main/s/stg',
from_column: 'raw_name',
to_asset_path: 'main/s/mart',
to_column: 'clean_name',
kind: 'mod'
},
{
from_asset_path: 'main/s/stg',
from_column: 'id',
to_asset_path: 'main/s/mart',
to_column: 'id',
kind: 'copy'
},
{
from_asset_path: 'main/s/stg',
from_column: 'id',
to_asset_path: 'main/s/mart',
to_column: 'clean_name',
kind: 'scan'
}
]
})
expect(g.up.get(colNodeId('dbt', 'main/s/mart', 'clean_name'))).toEqual(
new Set([colNodeId('dbt', 'main/s/stg', 'raw_name')])
)
expect(g.up.get(colNodeId('dbt', 'main/s/mart', 'id'))).toEqual(
new Set([colNodeId('dbt', 'main/s/stg', 'id')])
)
})
it('skips producers with no ducklake output asset (columns unanchorable)', () => {
const graph = chainGraph()
graph.edges = graph.edges.filter((e) => e.runnable_path !== 's1') // s1 loses its output edge
@@ -23,11 +23,24 @@ export type ColumnLineageGraph = {
down: Map<ColumnNodeId, Set<ColumnNodeId>>
}
// Direct value flow, as dbt's static analysis labels it: `copy` passes a column
// through, `mod` transforms it. A `scan` edge is the third kind and means the
// column was read to produce the ROW rather than the value — a join key, a
// `where` predicate, a `group by` — so it reaches EVERY output column of the
// model and would draw the diagram as a complete bipartite graph. Kept on the
// wire (the API sends all three) so a later "show indirect" control needs no
// backend change; kept out of the lineage a column trace means.
const DIRECT_DBT_LINEAGE = new Set(['copy', 'mod'])
// Build the column graph from a resolved asset graph. A producer's
// `column_lineage` describes the columns of the asset it materializes; that
// output asset is the ducklake target it writes (v1 materialize target), found
// from its write-edge. Producers without a known ducklake output are skipped
// (their columns can't be anchored to an asset node).
//
// dbt contributes the same edges from the other side: a dbt project is one
// runnable with many output assets, so its lineage arrives already resolved to
// the two relations rather than anchored to a producer.
export function buildColumnGraph(graph: AssetGraphResponse): ColumnLineageGraph {
const nodes = new Map<ColumnNodeId, ColumnNode>()
const up = new Map<ColumnNodeId, Set<ColumnNodeId>>()
@@ -86,6 +99,14 @@ export function buildColumnGraph(graph: AssetGraphResponse): ColumnLineageGraph
}
}
for (const e of graph.dbt_column_edges ?? []) {
if (!DIRECT_DBT_LINEAGE.has(e.kind)) continue
addEdge(
addNode({ kind: 'dbt', path: e.from_asset_path, column: e.from_column }),
addNode({ kind: 'dbt', path: e.to_asset_path, column: e.to_column })
)
}
return { nodes, up, down }
}
@@ -36,9 +36,14 @@ export interface DbtAssetProvenance {
tags?: string[]
description?: string
data_tests?: DbtDataTest[]
/** Declared column metadata (name -> description). NOT column lineage:
* `manifest.json` carries none (docs/dbt-runtime.md, decision 14). */
/** Declared column metadata (name -> description): what `manifest.json`
* carries, which is only the columns an author wrote down. */
columns?: Record<string, string>
/** Every column of the relation, typed and in the order the model produces
* them, from the engine's static analysis. Present only for a project that
* opted into it (`column_lineage: true`); `manifest.json` has no such
* thing. Lockstep with Rust `DbtAssetProvenance.column_schema`. */
column_schema?: { name: string; type?: string }[]
/** A source's declared freshness policy. */
freshness?: unknown
/** The model's SQL as written — the transform behind the node. Read-only:
@@ -225,6 +230,7 @@ export interface AssetGraphResponse {
macro_edges?: AssetGraphMacroEdge[]
test_edges?: AssetGraphTestEdge[]
dbt_edges?: AssetGraphDbtEdge[]
dbt_column_edges?: AssetGraphDbtColumnEdge[]
/** The job whose snapshot the dbt half was resolved from, when one was
* asked for and found. A run page polls the graph until this is its own
* job, which is how it knows a dynamic descriptor's ingest has landed. */
@@ -243,6 +249,21 @@ export interface AssetGraphDbtEdge {
to_asset_path: string
}
// Column-to-column lineage between two dbt models, from the engine's own static
// analysis. Empty unless the project opted into it — `manifest.json` carries no
// column edges at all.
export interface AssetGraphDbtColumnEdge {
from_asset_path: string
from_column: string
to_asset_path: string
to_column: string
// dbt's own word: `copy` (passthrough), `mod` (transformed), `scan` (read to
// produce the ROW rather than the value — a join key, a predicate, a
// `group by`). Left open: the engine's own reader maps those three and
// passes anything else through.
kind: string
}
export type AssetGraphNodeData =
| {
kind: 'asset'
@@ -31,6 +31,7 @@
AssetGraphNodeData,
DbtAssetProvenance
} from '$lib/components/assets/AssetGraph/types'
import type { ColumnLineageGraph } from '$lib/components/assets/AssetGraph/columnLineageGraph'
import {
DBT_DESCRIPTOR,
DBT_MODULE_EXTENSIONS,
@@ -207,6 +208,7 @@
let graphSelection = $state<AssetGraphNodeData | undefined>(undefined)
let selectedAsset = $derived(graphSelection?.kind === 'asset' ? graphSelection : undefined)
let selectedDbt = $state<DbtAssetProvenance | undefined>(undefined)
let selectedColumnGraph = $state<ColumnLineageGraph | undefined>(undefined)
// Set when the selected node came from a buffer parse: the project that parse
// ran on, which is the one its rows must come from. Undefined for a node off
// the deployed graph, which previews by version instead. Either way the rows
@@ -514,10 +516,11 @@
testRunning={testIsLoading}
testResult={testJob?.result}
selection={graphSelection}
onSelect={(sel, dbt, buffer) => {
onSelect={(sel, dbt, buffer, columnGraph) => {
graphSelection = sel
selectedDbt = dbt
selectedBuffer = buffer
selectedColumnGraph = columnGraph
}}
/>
</Pane>
@@ -539,6 +542,7 @@
{args}
fileInBundle={!!selectedDbt.original_file_path &&
!!modules?.[selectedDbt.original_file_path]}
columnGraph={selectedColumnGraph}
onOpenFile={open}
onClose={() => (graphSelection = undefined)}
/>
@@ -12,6 +12,11 @@
import { ClipboardCopy, Code2, FileCode2, Loader2, TableProperties, X } from 'lucide-svelte'
import { copyToClipboard } from '$lib/utils'
import type { DbtAssetProvenance } from '$lib/components/assets/AssetGraph/types'
import ColumnLineageTrace from '$lib/components/assets/AssetGraph/ColumnLineageTrace.svelte'
import {
assetColumnNodes,
type ColumnLineageGraph
} from '$lib/components/assets/AssetGraph/columnLineageGraph'
import { previewDbtRows, type DbtPreview, type DbtPreviewBuffer } from './previewRows'
import { nodeSelector } from './parseDbtRun'
@@ -34,6 +39,10 @@
args,
/** Whether this model's file is in the bundle being edited. */
fileInBundle = false,
/** The project's column-level lineage, when the descriptor asked for it.
* Resolved from the same graph response the canvas draws, so the trace
* and the nodes above it describe one parse. */
columnGraph,
onOpenFile,
onClose
}: {
@@ -45,6 +54,7 @@
buffer?: DbtPreviewBuffer
args?: Record<string, unknown>
fileInBundle?: boolean
columnGraph?: ColumnLineageGraph
onOpenFile?: (path: string) => void
onClose?: () => void
} = $props()
@@ -111,7 +121,29 @@
return typeof v === 'object' ? JSON.stringify(v) : String(v)
}
let columns = $derived(Object.entries(dbt.columns ?? {}))
// The real columns where the analysis pass produced them — typed and in the
// order the model emits them — and the declared ones otherwise. The
// description comes from `columns` either way: that is the only place an
// author's prose lives, and a project documents a handful of forty.
let columns = $derived(
dbt.column_schema?.length
? dbt.column_schema.map((c) => ({
name: c.name,
type: c.type,
description: dbt.columns?.[c.name] ?? ''
}))
: Object.entries(dbt.columns ?? {}).map(([name, description]) => ({
name,
type: undefined,
description
}))
)
let columnsAreAnalyzed = $derived(!!dbt.column_schema?.length)
// The selected relation's own column nodes: empty for a project that never
// asked for the analysis pass, which is the ordinary case.
let columnNodes = $derived(
columnGraph ? assetColumnNodes(columnGraph, 'dbt', assetPath) : []
)
// `dbt show` SELECTs from the node's own relation and the worker intersects
// the selector with `resource_type:model`, so offering it on a seed, snapshot
// or source only ever produces a failed job.
@@ -238,20 +270,29 @@
<div class="px-2 py-1.5 border-b flex flex-col gap-1.5">
{#if columns.length > 0}
<div class="text-2xs">
<div class="text-tertiary mb-0.5">columns declared</div>
<div class="text-tertiary mb-0.5">
{columnsAreAnalyzed ? 'columns' : 'columns declared'}
</div>
<div class="flex flex-col gap-0.5">
{#each columns as [name, desc] (name)}
{#each columns as col (col.name)}
<div class="flex gap-2">
<span class="font-mono text-primary shrink-0">{name}</span>
<span class="text-secondary truncate">{desc}</span>
<span class="font-mono text-primary shrink-0">{col.name}</span>
{#if col.type}
<span class="font-mono text-tertiary shrink-0">{col.type}</span>
{/if}
<span class="text-secondary truncate">{col.description}</span>
</div>
{/each}
</div>
<!-- dbt's manifest carries no column-to-column edges, so this is a
declared column SET rather than lineage. -->
<div class="text-tertiary mt-0.5">
Declared metadata — dbt reports no column-level lineage.
</div>
<!-- `manifest.json` carries declared columns only, so without the
analysis pass this list is what an author wrote down rather than
what the model produces. -->
{#if !columnsAreAnalyzed}
<div class="text-tertiary mt-0.5">
Declared metadata. Set `column_lineage: true` in the descriptor for the real
column schema and column-level lineage.
</div>
{/if}
</div>
{/if}
{#if (dbt.data_tests?.length ?? 0) > 0}
@@ -269,6 +310,17 @@
</div>
{/if}
{#if columnGraph && columnNodes.length > 0}
<div class="border-b overflow-auto max-h-64">
<ColumnLineageTrace
graph={columnGraph}
assetKind="dbt"
assetPath={assetPath}
targetLabel={dbt.unique_id}
/>
</div>
{/if}
{#if showRows && preview}
{#if 'error' in preview}
<div class="p-2 text-2xs text-secondary">{preview.error}</div>
@@ -25,6 +25,10 @@
DbtAssetProvenance
} from '$lib/components/assets/AssetGraph/types'
import { useDbtRunStatus } from './runStatus.svelte'
import {
buildColumnGraph,
type ColumnLineageGraph
} from '$lib/components/assets/AssetGraph/columnLineageGraph'
let {
workspace,
@@ -80,7 +84,12 @@
* buffer rather than a deployed version — as submitted, not as the
* editor holds it now. Sent with the selection rather than exposed on
* its own so it can never disagree with the SQL the parent shows. */
buffer: DbtPreviewBuffer | undefined
buffer: DbtPreviewBuffer | undefined,
/** The project's column-level lineage, when the descriptor asked for
* it. Travels with the selection for the same reason the provenance
* does: it is resolved from the graph response, which is this
* component's. */
columnGraph: ColumnLineageGraph | undefined
) => void
} = $props()
@@ -364,6 +373,13 @@
// graph that actually came back.
let editorParsed = $derived(refreshJob != undefined && raw?.dbt_snapshot_job === refreshJob)
// Built lazily off the graph on screen, so the trace in the details pane
// describes the same parse the canvas does. Empty for a project that never
// asked for the analysis pass, which is the ordinary case.
let columnGraph = $derived(
graph?.dbt_column_edges?.length ? buildColumnGraph(graph) : undefined
)
// `untrack`, because the effect that reloads the graph clears the selection
// through here: reading the graph to describe a selection would subscribe that
// effect to the very state its own fetch writes, and it would reload forever.
@@ -374,7 +390,8 @@
sel?.kind === 'asset'
? graph?.assets.find((a) => a.kind === sel.asset_kind && a.path === sel.path)?.dbt
: undefined,
editorParsed ? parsedBuffer : undefined
editorParsed ? parsedBuffer : undefined,
sel?.kind === 'asset' ? columnGraph : undefined
)
)
}
+5
View File
@@ -1381,6 +1381,11 @@ full_refresh: false
# resolved to that Windmill variable, so secrets stay out of this file.
# env:
# DBT_PASSWORD: $var:u/user/my_warehouse_password
# Column-level lineage and real column schemas, from the engine's static
# analysis. Opt-in because it runs a separate dbt compile under
# --static-analysis strict, which rejects SQL the default accepts; a project it
# cannot analyze keeps the graph it has. Needs an engine that computes it.
# column_lineage: true
`
// for related places search: ADD_NEW_LANG
export const INITIAL_CODE = {
@@ -1989,14 +1989,16 @@
down: new Map()
}
// Pipeline-wide column-lineage graph, stitched across every producer's
// (inferred + annotated) `column_lineage` and the asset write-edges. Drives
// the transitive column trace in the details pane. Built from `displayGraph`
// (inferred + annotated) `column_lineage`, the asset write-edges and the
// column edges a dbt project's static analysis produced. Drives the
// transitive column trace in the details pane. Built from `displayGraph`
// — the exact graph the canvas renders — so the trace matches it: draft
// overlays in edit / show-drafts, deployed-only in plain View. Gated to a
// ducklake-asset selection so it isn't rebuilt on every editor keystroke when
// the trace UI isn't even shown.
// overlays in edit / show-drafts, deployed-only in plain View. Gated to the
// two asset kinds that can carry column lineage so it isn't rebuilt on every
// editor keystroke when the trace UI isn't even shown.
let columnGraph = $derived(
pe.selection?.kind === 'asset' && pe.selection.asset_kind === 'ducklake'
pe.selection?.kind === 'asset' &&
(pe.selection.asset_kind === 'ducklake' || pe.selection.asset_kind === 'dbt')
? buildColumnGraph(displayGraph)
: EMPTY_COLUMN_GRAPH
)