mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-30 08:16:49 +00:00
* feat(pipelines): auto-derive cascade trigger edges from ducklake/s3 reads Within a `// pipeline`, a read of a ducklake table or s3 object now auto-wires its cascade trigger edge straight from the FROM clause, so `// on <asset>` is only needed for edges inference can't see (dynamic SQL) or to carry per-edge opts. Two opt-outs: `// mute <asset>` suppresses a single derived edge (a lookup / SCD input read every run but not cascaded on), and `// mute all` opts the script out of derivation entirely (back to explicit-`// on`-only). Explicit `// on` still wins the dedup. Scoped to ducklake + s3 reads; resource/datatable/volume stay explicit. Read-write (RW) and write inputs are excluded so a self-referential merge can't loop-trigger itself; ambiguous (None) access is skipped. - parser: `mute` / `mute_all` in PipelineAnnotations (Rust + TS mirror) - deploy: derive_pipeline_asset_trigger_refs → script_trigger rows - frontend: resolveGraph mirrors derivation for the live edit-mode canvas - tests: shared parity corpus + derive-helper units + resolveGraph overlays Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(pipelines): mark auto-derived cascade edges with a persisted derived flag + "auto" badge Persist script_trigger.derived (deploy: true for ducklake/s3-read derivation, false for explicit // on) and return it from the asset-graph endpoint so the canvas renders a Sparkles "auto" badge on auto-wired edges — the inference is now visible on both the deployed graph and the live edit canvas, not just implied. Dispatch (fetch_subscribers) ignores the flag, so a derived edge fires identically to an explicit // on. Also copy derived in the workspace-clone trigger copy, and backfill muteAssets/muteAll into two empty PipelineAnnotations literals the base commit left stale (check:fast). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(pipelines): derive cascade edge from effective (alt-fallback) asset access derive_pipeline_asset_trigger_refs gated on the raw parser access_type, but the persisted asset.usage_access_type and the frontend canvas both use access_type.or(alt_access_type). An ambiguous parse with a manual read override was persisted/drawn as a read yet derived no edge, so the auto edge silently vanished on deploy. Gate on the effective access type for parity. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(pipelines): badge muted reads instead of auto-derived edges Auto-derivation is the default now, so badging every derived cascade edge is noise. Drop the "auto" badge and the persisted `script_trigger.derived` flag (migration + insert param + graph field + clone copy) that only powered it, and instead badge the exception: a ducklake/s3 asset a script reads but does NOT cascade — `// mute <asset>` / `// mute all`. `computeMutedReadKeys` marks a read-only ('r') supported read with no cascade trigger and no self-write; the canvas renders a bell-off "muted" badge on that read edge. Also fixes two review parity nits: - TS `// on` parser now strips trailing `key=value` opts (e.g. `debounce=60s`) like the Rust `split_trailing_kv_opts`, so the ref dedups against inference. - A `// materialize` producer reading its own target is upgraded to `rw` (deploy) / excluded via the materialize write refs (canvas), so it neither self-cascades nor shows as a muted read. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(pipelines): drop redundant // on for auto-derived reads; gate muted badge to pipeline scripts - Templates no longer scaffold `// on <asset>` for a ducklake/s3 input the body reads — the read auto-wires the cascade now that derivation is the default. Kept for datatable/resource (not auto-derived) and native triggers. The discoverability hint now mentions `// mute` (the newly relevant annotation). - computeMutedReadKeys only badges reads by `// pipeline` scripts. A plain script or flow reading a ducklake/s3 asset never had an auto trigger to suppress, so it must render as ordinary lineage, not "muted" (Codex review). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(pipelines): only drop template // on when the body actually reads the input The redundant-`// on` removal assumed the generated body reads the ducklake/s3 input, but postgres/bash/generic bodies (and `data_upload`, which reads the picker file) ignore `input` — dropping `// on` there left the asset-created script with no cascade at all. Gate the drop on READS_INPUT_LANGS (bun/deno/python/duckdb) so non-reading templates keep the explicit trigger. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
214 lines
7.6 KiB
TypeScript
214 lines
7.6 KiB
TypeScript
import { readFileSync } from 'node:fs'
|
|
import { resolve, dirname } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { describe, expect, it } from 'vitest'
|
|
import { parsePipelineAnnotations, type PipelineAnnotations } from './parsePipelineAnnotations'
|
|
|
|
// Every field the TS parser produces, each with an assertion in the per-fixture
|
|
// test below. Typed as `Record<keyof PipelineAnnotations, true>` so adding a
|
|
// field to `PipelineAnnotations` is a compile error here until it's listed —
|
|
// forcing the author to also wire up its parity assertion. (Deploy-only Rust
|
|
// fields like join_mode / debounce_default are not part of this type, so they
|
|
// correctly never appear here.)
|
|
const ASSERTED_TS_FIELDS: Record<keyof PipelineAnnotations, true> = {
|
|
inPipeline: true,
|
|
triggerAssets: true,
|
|
nativeTriggers: true,
|
|
partition: true,
|
|
freshness: true,
|
|
tag: true,
|
|
retry: true,
|
|
materialize: true,
|
|
dataTests: true,
|
|
columnLineage: true,
|
|
macros: true,
|
|
useLibs: true,
|
|
muteAssets: true,
|
|
muteAll: true
|
|
}
|
|
|
|
// Parser-parity guard: this TS parser (drives the live graph preview) and
|
|
// the Rust `parse_pipeline_annotations` (drives deploy) must stay
|
|
// behaviorally identical, or the graph the user previews is not the graph
|
|
// that deploys. Both run the SAME fixture corpus, owned by the Rust crate:
|
|
//
|
|
// backend/parsers/windmill-parser/tests/fixtures/pipeline_annotations.json
|
|
//
|
|
// Rust counterpart: backend/parsers/windmill-parser/tests/
|
|
// pipeline_annotations_parity.rs. Extend the corpus when the grammar
|
|
// changes; a fixture passing on one side and failing on the other is
|
|
// exactly the drift this exists to catch.
|
|
//
|
|
// Intentional divergence — the Rust parser is a superset. It also parses
|
|
// `join_mode` and `debounce_default`, which are DEPLOY-ONLY: they affect how
|
|
// the backend schedules cascade runs, never the rendered graph, so the TS
|
|
// preview parser deliberately doesn't produce them and they are not compared
|
|
// here. Every field the TS parser DOES produce is compared, and the
|
|
// `ASSERTED_TS_FIELDS` exhaustiveness check above fails the suite if a new TS
|
|
// field is added without a matching assertion — so a field can't be parsed on
|
|
// the TS side yet silently skipped by this guard.
|
|
|
|
type Fixture = {
|
|
name: string
|
|
code: string
|
|
expected: {
|
|
in_pipeline: boolean
|
|
asset_triggers: string[]
|
|
native_triggers: string[]
|
|
partition: {
|
|
kind: string
|
|
key?: string
|
|
tz: string | null
|
|
format: string | null
|
|
start: string | null
|
|
} | null
|
|
freshness: string | null
|
|
tag: string | null
|
|
retry: { count: number; delay: string | null } | null
|
|
materialize?: {
|
|
target_kind: string
|
|
target_path: string
|
|
manual?: boolean
|
|
append?: boolean
|
|
unique_key?: string | null
|
|
scd2?: boolean
|
|
track?: string[]
|
|
close_deleted?: boolean
|
|
// "warn" | "ignore"; absent === "warn" (the default)
|
|
on_schema_change?: string
|
|
} | null
|
|
// Snake_case form matching the Rust `DataTest` serde output, so the one
|
|
// corpus drives both sides. The TS parser emits this shape verbatim
|
|
// (snake_case fields), so the comparison is 1:1. Absent === [].
|
|
data_tests?: Array<Record<string, unknown>>
|
|
// Snake_case `ColumnLineage` serde shape — TS parser emits it verbatim,
|
|
// so the comparison is 1:1. Absent === [].
|
|
column_lineage?: Array<Record<string, unknown>>
|
|
// `// macros` marker. Absent === false.
|
|
macros?: boolean
|
|
// `// use <lib_path>` accumulation, declaration order, deduped. Absent === [].
|
|
use_libs?: string[]
|
|
// `// mute <asset>` accumulation as `kind:path`, declaration order, deduped.
|
|
// Absent === [].
|
|
mute?: string[]
|
|
// `// mute all` marker. Absent === false.
|
|
mute_all?: boolean
|
|
}
|
|
}
|
|
|
|
const fixturesPath = resolve(
|
|
dirname(fileURLToPath(import.meta.url)),
|
|
'../../../../../../backend/parsers/windmill-parser/tests/fixtures/pipeline_annotations.json'
|
|
)
|
|
const fixtures: Fixture[] = JSON.parse(readFileSync(fixturesPath, 'utf-8'))
|
|
|
|
describe('parsePipelineAnnotations matches the shared Rust fixture corpus', () => {
|
|
it('corpus is non-empty', () => {
|
|
expect(fixtures.length).toBeGreaterThan(0)
|
|
})
|
|
|
|
it('every field the parser emits across the corpus has a parity assertion', () => {
|
|
const asserted = new Set(Object.keys(ASSERTED_TS_FIELDS))
|
|
const emitted = new Set<string>()
|
|
for (const f of fixtures) {
|
|
for (const k of Object.keys(parsePipelineAnnotations(f.code))) emitted.add(k)
|
|
}
|
|
expect(
|
|
[...emitted].filter((k) => !asserted.has(k)),
|
|
'unasserted parser fields'
|
|
).toEqual([])
|
|
})
|
|
|
|
for (const f of fixtures) {
|
|
it(f.name, () => {
|
|
const got = parsePipelineAnnotations(f.code)
|
|
|
|
expect(got.inPipeline, 'in_pipeline').toBe(f.expected.in_pipeline)
|
|
|
|
expect(
|
|
got.triggerAssets.map((a) => `${a.kind}:${a.path}`),
|
|
'asset triggers'
|
|
).toEqual(f.expected.asset_triggers)
|
|
|
|
expect(
|
|
got.nativeTriggers.map((n) => n.kind),
|
|
'native triggers'
|
|
).toEqual(f.expected.native_triggers)
|
|
|
|
if (f.expected.partition === null) {
|
|
expect(got.partition, 'partition').toBeUndefined()
|
|
} else {
|
|
expect(got.partition?.kind, 'partition kind').toBe(f.expected.partition.kind)
|
|
expect(
|
|
got.partition && 'key' in got.partition ? got.partition.key : undefined,
|
|
'partition key'
|
|
).toEqual(f.expected.partition.key ?? undefined)
|
|
expect(got.partition?.tz, 'partition tz').toEqual(f.expected.partition.tz ?? undefined)
|
|
expect(got.partition?.format, 'partition format').toEqual(
|
|
f.expected.partition.format ?? undefined
|
|
)
|
|
expect(got.partition?.start, 'partition start').toEqual(
|
|
f.expected.partition.start ?? undefined
|
|
)
|
|
}
|
|
|
|
expect(got.freshness?.duration, 'freshness').toEqual(f.expected.freshness ?? undefined)
|
|
expect(got.tag, 'tag').toEqual(f.expected.tag ?? undefined)
|
|
|
|
if (f.expected.retry === null) {
|
|
expect(got.retry, 'retry').toBeUndefined()
|
|
} else {
|
|
expect(got.retry?.count, 'retry count').toBe(f.expected.retry.count)
|
|
expect(got.retry?.delay, 'retry delay').toEqual(f.expected.retry.delay ?? undefined)
|
|
}
|
|
|
|
if (f.expected.materialize == null) {
|
|
expect(got.materialize, 'materialize').toBeUndefined()
|
|
} else {
|
|
expect(got.materialize?.targetKind, 'materialize target kind').toBe(
|
|
f.expected.materialize.target_kind
|
|
)
|
|
expect(got.materialize?.targetPath, 'materialize target path').toBe(
|
|
f.expected.materialize.target_path
|
|
)
|
|
expect(got.materialize?.manual ?? false, 'materialize manual').toBe(
|
|
f.expected.materialize.manual ?? false
|
|
)
|
|
expect(got.materialize?.append ?? false, 'materialize append').toBe(
|
|
f.expected.materialize.append ?? false
|
|
)
|
|
expect(got.materialize?.uniqueKey, 'materialize key').toEqual(
|
|
f.expected.materialize.unique_key ?? undefined
|
|
)
|
|
expect(got.materialize?.scd2 ?? false, 'materialize scd2').toBe(
|
|
f.expected.materialize.scd2 ?? false
|
|
)
|
|
expect(got.materialize?.track ?? [], 'materialize track').toEqual(
|
|
f.expected.materialize.track ?? []
|
|
)
|
|
expect(got.materialize?.closeDeleted ?? false, 'materialize close_deleted').toBe(
|
|
f.expected.materialize.close_deleted ?? false
|
|
)
|
|
expect(got.materialize?.onSchemaChange ?? 'warn', 'materialize on_schema_change').toBe(
|
|
f.expected.materialize.on_schema_change ?? 'warn'
|
|
)
|
|
}
|
|
|
|
expect(got.dataTests, 'data tests').toEqual(f.expected.data_tests ?? [])
|
|
|
|
expect(got.columnLineage, 'column lineage').toEqual(f.expected.column_lineage ?? [])
|
|
|
|
expect(got.macros, 'macros').toBe(f.expected.macros ?? false)
|
|
|
|
expect(got.useLibs, 'use_libs').toEqual(f.expected.use_libs ?? [])
|
|
|
|
expect(
|
|
got.muteAssets.map((a) => `${a.kind}:${a.path}`),
|
|
'mute'
|
|
).toEqual(f.expected.mute ?? [])
|
|
|
|
expect(got.muteAll, 'mute_all').toBe(f.expected.mute_all ?? false)
|
|
})
|
|
}
|
|
})
|