fix(pipelines): order data_test relationships refs before the tested script in a cascade (#9934)

* fix(pipelines): order data_test relationships refs before the tested script in a cascade

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(pipelines): key custom-test reads by (usage_kind, path) to avoid same-path flow collisions

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-07-05 22:34:58 +02:00
committed by GitHub
parent 0ad174fa49
commit 46be39dfb7
7 changed files with 311 additions and 2 deletions
+118
View File
@@ -797,6 +797,25 @@ enum TriggerEdge {
},
}
// Ordering-only "must-run-after" edge: `runnable_path`'s data test reads
// `asset` (a `// data_test relationships` ref, or a custom test whose body
// reads a known pipeline asset), so the asset's in-pipeline producer must
// materialize before `runnable_path` runs. NOT a data-consumption edge — the
// tested script doesn't ingest the asset's rows, it only needs the table to
// exist at test time. Rendered dashed (like macro edges) and fed into the
// cascade topo-sort so a cold cascade orders the referenced dimension first.
// Only emitted when the referenced asset has a producer in the graph; an
// external table (no producer) adds no edge — the runtime error stands.
#[derive(Serialize, Debug)]
struct TestEdge {
producer_kind: AssetUsageKind,
producer_path: String,
runnable_kind: AssetUsageKind,
runnable_path: String,
asset_kind: AssetKind,
asset_path: String,
}
#[derive(Serialize, Debug)]
struct AssetGraphResponse {
assets: Vec<GraphAssetNode>,
@@ -805,6 +824,8 @@ struct AssetGraphResponse {
triggers: Vec<TriggerEdge>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
macro_edges: Vec<MacroEdge>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
test_edges: Vec<TestEdge>,
}
async fn asset_graph(
@@ -1257,6 +1278,102 @@ async fn asset_graph(
runnable_set.insert((AssetUsageKind::Script, e.consumer_path.clone()));
}
// Data-test ordering edges. A `// data_test relationships <col> -> <asset>`
// (and, best-effort, a custom `// data_test <script>` whose body reads a
// pipeline asset) needs the referenced asset materialized before the tested
// script runs — but that dependency is otherwise absent from the execution
// DAG, so a cold cascade can run the tested script first and hard-fail
// ("Catalog Error: Table … does not exist"). Resolve the referenced asset's
// producer(s) from the write edges built above and add an ordering-only
// edge producer → testing_script. No producer (external table) ⇒ no edge.
let mut producers_by_asset: std::collections::HashMap<
(AssetKind, String),
Vec<(AssetUsageKind, String)>,
> = Default::default();
for e in &edges {
if matches!(e.access_type.as_deref(), Some("w") | Some("rw")) {
producers_by_asset
.entry((e.asset_kind, e.asset_path.clone()))
.or_default()
.push((e.runnable_kind, e.runnable_path.clone()));
}
}
// Assets a runnable reads, keyed by (usage_kind, path) — used to order a
// custom-test *script*'s producers before whichever member declares
// `// data_test <path>`. Keyed on the kind too because a flow can share a
// script's path; `// data_test <path>` resolves a deployed script body, so
// the lookup below pins `Script` and never pulls a same-path flow's reads.
let mut reads_by_runnable: std::collections::HashMap<
(AssetUsageKind, String),
Vec<(AssetKind, String)>,
> = Default::default();
for e in &edges {
if matches!(e.access_type.as_deref(), None | Some("r") | Some("rw")) {
reads_by_runnable
.entry((e.runnable_kind, e.runnable_path.clone()))
.or_default()
.push((e.asset_kind, e.asset_path.clone()));
}
}
let mut test_edges: Vec<TestEdge> = Vec::new();
// Dedup on (producer, tested_script, asset) so several tests referencing the
// same asset, or a repeated producer, yield one edge.
let mut seen_test_edges: std::collections::HashSet<(String, String, AssetKind, String)> =
Default::default();
for (member_path, ann) in &annotations_by_path {
for dt in &ann.data_tests {
use windmill_common::assets::DataTest;
let referenced: Vec<(AssetKind, String)> = match dt {
DataTest::Relationships { to_kind, to_path, .. } => {
vec![(
windmill_common::assets::asset_kind_from_parser(*to_kind),
to_path.clone(),
)]
}
// Best-effort: the custom test *script*'s parsed reads. Reading
// the member's OWN output resolves to the member as producer and
// is dropped by the self-edge guard below.
DataTest::Custom { path } => reads_by_runnable
.get(&(AssetUsageKind::Script, path.clone()))
.cloned()
.unwrap_or_default(),
_ => vec![],
};
for (asset_kind, asset_path) in referenced {
let Some(producers) = producers_by_asset.get(&(asset_kind, asset_path.clone()))
else {
continue;
};
for (producer_kind, producer_path) in producers {
// Skip a self-edge: the tested script produces the asset it
// tests (a materialize + relationships/custom on its own output).
if *producer_kind == AssetUsageKind::Script && producer_path == member_path {
continue;
}
if seen_test_edges.insert((
producer_path.clone(),
member_path.clone(),
asset_kind,
asset_path.clone(),
)) {
test_edges.push(TestEdge {
producer_kind: *producer_kind,
producer_path: producer_path.clone(),
runnable_kind: AssetUsageKind::Script,
runnable_path: member_path.clone(),
asset_kind,
asset_path: asset_path.clone(),
});
}
}
}
}
}
for e in &test_edges {
runnable_set.insert((e.producer_kind, e.producer_path.clone()));
runnable_set.insert((e.runnable_kind, e.runnable_path.clone()));
}
// Fork data-environment state per ducklake asset. The parent's rows are read on the plain
// pool: fork membership does not imply parent membership, and defer already exposes the
// parent's data to fork jobs — surfacing its materialization status is strictly less.
@@ -1421,6 +1538,7 @@ async fn asset_graph(
edges,
triggers,
macro_edges,
test_edges,
}))
}
+32
View File
@@ -21595,6 +21595,38 @@ paths:
type: string
via_use:
type: boolean
test_edges:
type: array
description: >-
Ordering-only "must-run-after" edges — a `// data_test relationships`
(or custom test reading a pipeline asset) requires the referenced
asset's producer to run before the tested script. Not a data-consumption
edge; fed into the cascade topo-sort so cold runs order correctly.
Omitted when empty.
items:
type: object
required:
[
producer_kind,
producer_path,
runnable_kind,
runnable_path,
asset_kind,
asset_path,
]
properties:
producer_kind:
$ref: "#/components/schemas/AssetUsageKind"
producer_path:
type: string
runnable_kind:
$ref: "#/components/schemas/AssetUsageKind"
runnable_path:
type: string
asset_kind:
$ref: "#/components/schemas/AssetKind"
asset_path:
type: string
/w/{workspace}/assets/macros:
get:
+34
View File
@@ -412,6 +412,40 @@ seams: add a parsed variant, emit its check/reader SQL into the summary, read
it back in the worker. Nothing about the closed set of *today's* keywords is
load-bearing.
### Cascade ordering (`test_edges`)
A `relationships` test — and, best-effort, a custom test whose body reads a
pipeline asset — needs the *referenced* asset to exist at test time, but it is
**not** a data-consumption `// on`/read of that asset, so it contributes no
lineage edge. Without an ordering constraint, a cold cascade can run the tested
script before the referenced dimension is materialized and hard-fail
(`Catalog Error: Table … does not exist`).
`asset_graph` (`windmill-api-assets/src/lib.rs`) closes this by resolving the
referenced asset's in-pipeline **producer** (from the write edges it already
builds) and emitting an ordering-only `test_edges` entry
`producer → testing_script`. It is:
- **Only emitted when a producer exists in the graph.** An external table (no
in-pipeline producer) adds no edge — the missing dependency is real and the
runtime error is the correct signal. Self-edges (a script relationship-testing
its own output) are dropped.
- **Rendered distinctly** — amber dashed "test needs" link on the canvas, apart
from lineage (blue/gray solid) and triggers (gray) — because the tested script
does not ingest the asset's rows.
- **Fed into the cascade topo-sort** via `buildLineageDag`
(`AssetGraph/boundedCascade.ts`), routed *through* the referenced asset node
(`asset → testing_script`) so the existing `producer → asset` write edge
extends into `producer → asset → testing_script` and the two-hop
`buildLineageDownstreamMap` invariant holds. Bounded and full-pipeline runs
(`computeInducedSchedule`) then order the producer first.
Scope caveat: this orders *within a single client-driven cascade* (dev run /
bounded run / full-pipeline run). It does not change the production reactive
asset-dispatch of two independently-scheduled roots — there, `relationships`
targets on a disjoint root should still be co-scheduled or bound with an
explicit `// on <dim>` if a hard ordering is required.
### Scoping decisions (v1)
- **Partition scope.** When `// partitioned`, built-in checks are scoped to the
@@ -222,6 +222,7 @@
| 'add-anchor'
| 'data-test'
| 'macro'
| 'test-dependency'
unsaved?: boolean
// Edge from a missing-trigger placeholder — styled red dashed to
// signal "this script declared `// on kafka` but no trigger row
@@ -523,6 +524,22 @@
})
}
// Data-test ordering edges (producer → tested script): the referenced
// asset must be materialized before the test runs, so the cascade orders
// the producer first. Rendered as a dashed "must run after" link, distinct
// from data flow — the tested script doesn't consume the asset's rows.
for (const te of g.test_edges ?? []) {
const producerId = `${te.producer_kind}:${te.producer_path}`
const testedId = `${te.runnable_kind}:${te.runnable_path}`
if (!runnableNodeIds.has(producerId) || !runnableNodeIds.has(testedId)) continue
edges.push({
id: `testdep:${producerId}->${testedId}`,
source: producerId,
target: testedId,
kind: 'test-dependency'
})
}
// Non-asset triggers (schedule + native) are rendered as source nodes
// above the pipeline script. Real (non-missing) nodes are
// deduplicated per (kind, ref) tuple so a single schedule shared
@@ -951,6 +968,16 @@
label = e.via_use ? 'uses lib' : 'macros'
labelStyle = 'fill: rgb(139 92 246); font-size: 10px; font-weight: 600;'
break
case 'test-dependency':
// Producer → tested script: amber dashed ordering link. Not
// data flow (blue/gray) nor execution trigger (gray "triggers")
// — it only says "the test needs this asset to exist first".
style = 'stroke: rgb(217 119 6); stroke-width: 1.25px;'
strokeDasharray = '5 3'
markerColor = 'rgb(217 119 6)'
label = 'test needs'
labelStyle = 'fill: rgb(217 119 6); font-size: 10px; font-weight: 600;'
break
default:
style = ''
}
@@ -16,15 +16,17 @@ import { computeInducedSchedule } from './graphTraversal'
type W = [script: string, asset: string] // producer write edge (datatable)
type R = [script: string, asset: string] // pure-read edge (datatable)
type S = [script: string, asset: string] // `// on <asset>` subscription
type T = [producer: string, tested: string, asset: string] // `// data_test` ordering edge
function graph(opts: {
scripts?: string[]
writes?: W[]
reads?: R[]
subs?: S[]
tests?: T[]
native?: Array<[kind: NativeTriggerKind, script: string]>
}): AssetGraphResponse {
const { scripts = [], writes = [], reads = [], subs = [], native = [] } = opts
const { scripts = [], writes = [], reads = [], subs = [], tests = [], native = [] } = opts
const triggers: AssetGraphTrigger[] = [
...subs.map(
([s, a]) =>
@@ -60,7 +62,15 @@ function graph(opts: {
access_type: 'r' as const
}))
],
triggers
triggers,
test_edges: tests.map(([producer, tested, a]) => ({
producer_kind: 'script' as const,
producer_path: producer,
runnable_kind: 'script' as const,
runnable_path: tested,
asset_kind: 'datatable' as const,
asset_path: a
}))
}
}
@@ -95,6 +105,22 @@ describe('buildLineageDag', () => {
expect([...(dag.down.get(sn('u')) ?? [])]).toEqual([asset('x')])
expect(dag.up.get(sn('u'))).toBeUndefined() // asset is not upstream of its own writer
})
it('routes a data_test ordering edge through the referenced asset', () => {
// prod writes x; tested has a `// data_test` against x (prod → tested edge).
// The DAG must place x (and thus prod) upstream of tested so a cascade
// materializes x first.
const g = graph({
scripts: ['prod', 'tested'],
writes: [['prod', 'x']],
tests: [['prod', 'tested', 'x']]
})
const dag = buildLineageDag(g)
// asset x → tested (routed through the asset, not a direct prod → tested hop)
expect([...(dag.down.get(asset('x')) ?? [])]).toEqual([sn('tested')])
// prod → x → tested makes prod an ancestor of tested.
expect(ancestors(dag, sn('tested'))).toEqual(new Set([asset('x'), sn('prod')]))
})
})
describe('ancestors / descendants', () => {
@@ -271,6 +297,47 @@ describe('buildLineageDownstreamMap (read-aware scheduling)', () => {
expect(readAware.indegree.get('c')).toBe(1)
expect(readAware.nodes).toEqual(['a', 'c'])
})
it('orders a disjoint-root producer before a data_test that references it', () => {
// Two disjoint roots (the HD-1 repro): `dim` produces the dimension
// `dimc`; `fct` produces `fcto` and has a `// data_test relationships`
// against `dimc`. Without the test edge they are unordered and a cold
// cascade can run `fct` first ("table dimc does not exist"). The test
// edge must place `dim` strictly before `fct`.
const g = graph({
scripts: ['dim', 'fct'],
writes: [
['dim', 'dimc'],
['fct', 'fcto']
],
tests: [['dim', 'fct', 'dimc']]
})
const selected = new Set(['dim', 'fct'])
const map = buildLineageDownstreamMap(g)
expect([...(map.get('dim') ?? [])]).toEqual(['fct'])
const schedule = computeInducedSchedule(g, selected, map)
expect(schedule.roots).toEqual(['dim'])
expect(schedule.indegree.get('fct')).toBe(1)
expect(schedule.nodes).toEqual(['dim', 'fct'])
expect(schedule.cyclic).toEqual([])
})
it('adds no ordering when the referenced asset has no in-pipeline producer', () => {
// `fct` tests against an external `ext` table nothing produces — the
// backend emits no test edge, so the frontend sees none and `fct` stays
// an independent root (the runtime error stands, as designed).
const g = graph({
scripts: ['dim', 'fct'],
writes: [['fct', 'fcto']],
tests: [] // no producer for `ext` ⇒ backend omitted the edge
})
const schedule = computeInducedSchedule(
g,
new Set(['dim', 'fct']),
buildLineageDownstreamMap(g)
)
expect(schedule.roots.sort()).toEqual(['dim', 'fct'])
})
})
describe('assetUriToNodeId', () => {
@@ -70,10 +70,17 @@ export type LineageDag = {
* - producer script → asset (write / rw edges)
* - asset → reader script (pure-read edges — a data dependency)
* - asset → subscriber script (`// on <asset>` triggers)
* - asset → testing script (`// data_test` ordering edges)
*
* An `rw` edge is treated as production only (script → asset); emitting the
* reverse asset → script too would make every upsert a 2-cycle through its own
* asset.
*
* `test_edges` are modeled through the referenced asset (asset → testing
* script), NOT as a direct producer → testing-script hop: the producer already
* has a write edge to that asset, so this yields producer → asset → testing
* script and keeps the two-hop (script → asset → script) invariant that
* `buildLineageDownstreamMap` relies on.
*/
export function buildLineageDag(g: AssetGraphResponse): LineageDag {
const down = new Map<string, Set<string>>()
@@ -107,6 +114,13 @@ export function buildLineageDag(g: AssetGraphResponse): LineageDag {
if (t.trigger_kind !== 'asset' || t.runnable_kind !== 'script') continue
addEdge(assetKey(t), scriptNodeId(t.runnable_path))
}
// Data-test ordering edges: the referenced asset must exist before the
// tested script runs. Routed through the asset node so the existing
// producer → asset write edge extends into producer → asset → testing script.
for (const t of g.test_edges ?? []) {
if (t.runnable_kind !== 'script') continue
addEdge(assetKey(t), scriptNodeId(t.runnable_path))
}
return { down, up, nodes }
}
@@ -147,12 +147,29 @@ export interface AssetGraphMacroEdge {
unsaved?: boolean
}
// Ordering-only "must-run-after" edge: `runnable_path`'s `// data_test`
// (a `relationships` ref, or a custom test reading a pipeline asset) needs
// `asset` materialized before the tested script runs — but the tested script
// doesn't consume the asset's rows, so this is NOT a lineage edge. Resolved
// server-side to the referenced asset's in-pipeline producer; fed into the
// cascade topo-sort (buildLineageDag) so a cold cascade orders the referenced
// dimension first, and rendered dashed on the canvas (like macro edges).
export interface AssetGraphTestEdge {
producer_kind: GraphUsageKind
producer_path: string
runnable_kind: GraphUsageKind
runnable_path: string
asset_kind: AssetKind
asset_path: string
}
export interface AssetGraphResponse {
assets: AssetGraphAssetNode[]
runnables: AssetGraphRunnableNode[]
edges: AssetGraphEdge[]
triggers: AssetGraphTrigger[]
macro_edges?: AssetGraphMacroEdge[]
test_edges?: AssetGraphTestEdge[]
}
export type AssetGraphNodeData =