Files
windmill/frontend/src/lib/components/dbt/DbtModelGraph.svelte
T

542 lines
22 KiB
Svelte

<script lang="ts">
// The dbt editor's model graph, refreshed on demand.
//
// dbt's own DAG, drawn from a manifest dbt itself produced — at deploy, or by
// a `parse` of the buffer being edited. Never from a `ref()` scan of our own:
// that yields names without relations (a node is
// `dbt://<warehouse>/<schema>/<table>`, and schema, database and alias come
// from configs layered with the profile) and disagrees with dbt about
// `enabled`, macro-built refs, loops and packages.
//
// Explicit, because a refresh costs a worker job: engine provisioning is
// cached per (version, adapter) and `dbt deps` per lock digest, so a warm
// refresh is seconds and a cold one is a download.
import { onDestroy, untrack } from 'svelte'
import { JobService, OpenAPI, type ScriptModule } from '$lib/gen'
import { Button } from '$lib/components/common'
import { Loader2, RefreshCw } from 'lucide-svelte'
import { displayDate } from '$lib/utils'
import AssetGraphCanvas from '$lib/components/assets/AssetGraph/AssetGraphCanvas.svelte'
import type { DbtPreviewBuffer } from './previewRows'
import type {
AssetGraphNodeData,
AssetGraphResponse,
DbtAssetProvenance
} from '$lib/components/assets/AssetGraph/types'
import { useDbtRunStatus } from './runStatus.svelte'
import type { DbtGraphPin } from '$lib/components/assets/AssetGraph/dbtColumnLineage.svelte'
import {
buildColumnGraph,
EMPTY_COLUMN_GRAPH,
type ColumnLineageGraph
} from '$lib/components/assets/AssetGraph/columnLineageGraph'
let {
workspace,
scriptPath,
/** The descriptor buffer. It is the script's content, and what a parse of
* it runs with. */
descriptor,
/** The project. It travels inline as `_MODULES`, the same way a Test does:
* the bundle IS the project, and dbt cannot parse a subset of it. */
modules,
/** The run form's arguments. A descriptor interpolating `{{ day }}` takes
* one, and a parse uses whatever has been filled in — it tolerates the
* rest being absent, as the deploy's own parse does. */
args,
/** The script's worker tag and timeout. A parse is a real dbt invocation:
* a project reaching a private network parses only on the worker that can
* reach it, and a slow one must get the same budget a run would. */
tag,
timeout,
/** The deployed version, when there is one. Its graph is what the panel
* shows before anything has been parsed from the buffer. */
deployedHash,
/** The build the editor is running, so the models it touches colour while
* it walks the DAG — the reason this is one pane and not a tab beside the
* run. `dbt-core-1x` reports per node live; the other engines settle every
* relation from the result at the end. */
testJobId,
testRunning = false,
testResult,
/** The node picked on the canvas, owned by the parent: it decides what the
* bottom section shows, and closing that has to clear it. Reported rather
* than bound — the provenance travels with it, since resolving that needs
* the graph response, which is this component's. */
selection,
onSelect
}: {
workspace: string | undefined
scriptPath: string
descriptor: string
modules: Record<string, ScriptModule> | undefined
args: Record<string, unknown> | undefined
tag?: string
timeout?: number
deployedHash?: string | number
testJobId?: string
testRunning?: boolean
testResult?: unknown
selection?: AssetGraphNodeData | undefined
onSelect?: (
selection: AssetGraphNodeData | undefined,
dbt: DbtAssetProvenance | undefined,
/** The project this node was parsed from, when that was the editor's
* 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,
/** Which graph this node was taken from, so anything else fetched
* about it describes the same project: the editor's own parse job
* when the panel is pinned to one, else the deployed version. Sent
* with the selection for the same reason the buffer is — it must not
* be able to disagree with the node on screen. */
pin: DbtGraphPin,
/** Column lineage the CONSUMERS of this project declare — a script
* reading a model's column and writing a ducklake one. It comes off
* the same graph response, and the details pane merges it with the
* project's own so a trace crosses that boundary instead of ending
* at it. */
producerColumns: ColumnLineageGraph
) => void
} = $props()
/** The last parse of the buffer. Component state on purpose: it describes a
* buffer, and a reload has a different one. */
let refreshJob = $state<string | undefined>(undefined)
let refreshing = $state(false)
// The project the pinned graph was parsed FROM, kept as submitted. Every node
// on screen describes this snapshot, so anything run against those nodes has
// to run against it too — reading the live buffer instead would preview one
// project under another one's SQL from the next keystroke onward.
let parsedBuffer = $state<DbtPreviewBuffer | undefined>(undefined)
let refreshError = $state<{ message: string; job?: string } | undefined>(undefined)
/** A parse still running well past the point one normally takes, so its job is
* reachable while it waits. Not an error — it may still land. */
let refreshPending = $state<string | undefined>(undefined)
let raw = $state<AssetGraphResponse | undefined>(undefined)
let loading = $state(true)
let failed = $state(false)
// The graph endpoint is folder-scoped; a script outside `f/` has no folder and
// falls back to the whole workspace, which the scoping below narrows anyway.
let folder = $derived(scriptPath.startsWith('f/') ? scriptPath.split('/')[1] : undefined)
let graphKey = $derived(
`${workspace ?? ''}|${folder ?? ''}|${deployedHash ?? ''}|${refreshJob ?? ''}`
)
// Two loads can overlap — a refresh landing while the deployed one is still in
// flight — and nothing orders two requests to one endpoint. The older answers
// with the deployed graph, so landing second it would replace the parse the
// user just asked for.
let graphSeq = 0
let destroyed = false
onDestroy(() => (destroyed = true))
async function load() {
if (!workspace) return
const seq = ++graphSeq
const current = () => seq === graphSeq && !destroyed
try {
const params = new URLSearchParams({ asset_kinds: 'dbt' })
if (folder) params.set('folder', folder)
// Through the parse JOB when there is one: its graph belongs to that job
// and is reachable no other way. Otherwise the deployed version's, by
// hash, so an editor open on an older version draws that one.
const path = refreshJob
? `/w/${workspace}/jobs/dbt_graph/${refreshJob}`
: `/w/${workspace}/assets/graph`
if (!refreshJob && deployedHash != undefined) {
params.set('dbt_script_hash', String(deployedHash))
}
const res = await fetch(`${OpenAPI.BASE ?? ''}${path}?${params}`, {
credentials: 'include'
})
if (!current()) return
if (!res.ok) {
failed = true
return
}
const body = (await res.json()) as AssetGraphResponse
if (!current()) return
raw = body
failed = false
} catch {
if (current()) failed = true
} finally {
if (current()) loading = false
}
}
$effect(() => {
void graphKey
loading = true
failed = false
raw = undefined
select(undefined)
void load()
})
/** Run a parse-only job over the buffer and pin the panel to its graph.
*
* Its own submission rather than the editor's job loader: a refresh and a
* build are independent, and routing both through one would have each evict
* the other's job. */
export async function refresh() {
if (refreshing || !workspace) return
refreshError = undefined
refreshPending = undefined
let id: string | undefined
// Read once, and before the button is disabled: the editor keeps changing
// underneath, and everything the resulting graph is used for has to name the
// project it actually described.
const submitted: DbtPreviewBuffer = {
content: descriptor,
// The whole bundle, always: dbt resolves `ref()` project-wide and cannot
// parse one file of it.
modules: modules ? { ...modules } : undefined,
tag,
timeout,
// The form's `vars` come along, and only those: they steer `enabled`,
// schemas, aliases and materializations, so a parse without them reports
// a different project than the run would build. `select` and the rest
// belong to a command that builds. Deep-copied, because the form keeps
// being edited and this has to stay the parse's own inputs.
args: JSON.parse(
JSON.stringify({
...(args ?? {}),
command: { label: 'parse', vars: (args?.command as any)?.vars ?? {} }
})
)
}
refreshing = true
try {
id = await JobService.runScriptPreview({
workspace,
timeout: submitted.timeout,
requestBody: {
path: scriptPath,
content: submitted.content,
language: 'dbt',
tag: submitted.tag,
modules: submitted.modules,
args: submitted.args ?? {}
}
})
// Polled until the JOB ends, with no window of its own: a cold worker
// provisions an engine and runs `dbt deps` before dbt starts, which
// outlasts any bound worth hard-coding, and giving up early strands a
// parse that then succeeds where nothing can pin to it.
//
// A job's own timeout only starts once a worker takes it, so this does
// NOT terminate on its own for one nothing serves — `tag` is forwarded
// precisely so a project can name a worker pool, and a pool with no
// worker leaves it queued. Hence the notice below rather than a bound:
// waiting stays correct, and the job stays reachable while it waits.
//
// Backed off, because most of that wait is not the parse; and tolerant
// of a failed poll, because one lost request must not abandon a job
// that is still running — but only of a few in a row, since an expired
// session answers the same way forever.
let delay = 1000
let failures = 0
const slowAt = Date.now() + 60_000
while (!destroyed) {
await new Promise((r) => setTimeout(r, delay))
if (destroyed) return
let done: Awaited<ReturnType<typeof JobService.getCompletedJobResultMaybe>>
try {
done = await JobService.getCompletedJobResultMaybe({ workspace, id })
failures = 0
} catch (e) {
if (++failures >= 10) {
refreshError = {
message: `Lost track of the parse: ${e instanceof Error ? e.message : String(e)}`,
job: id
}
return
}
delay = Math.min(delay * 1.5, 5000)
continue
}
if (!done.completed) {
// Non-terminal, and the only thing that puts the job on screen
// while it runs: the header shows a disabled spinner and nothing
// else, so a parse that never starts would otherwise leave no link,
// no reason and nothing to do but reload — which discards the pin.
if (Date.now() > slowAt) refreshPending = id
delay = Math.min(delay * 1.5, 5000)
continue
}
if (!done.success) {
// A parse renders `profiles.yml` before it runs, so a project whose
// warehouse does not resolve fails here exactly as a run would. The
// job carries the reason; the panel says where to read it.
refreshError = { message: parseFailure(done.result), job: id }
return
}
refreshJob = id
parsedBuffer = submitted
return
}
} catch (e) {
refreshError = { message: e instanceof Error ? e.message : String(e), job: id }
} finally {
refreshing = false
refreshPending = undefined
}
}
/** dbt's own message out of the failed job's result, which is where the
* useful half is — "Could not find profile named 'x'" rather than "the job
* failed". */
function parseFailure(result: unknown): string {
const err = (result as { error?: { message?: string } } | undefined)?.error?.message
if (typeof err !== 'string' || !err.trim()) return 'The parse job failed.'
// dbt is verbose on failure and the panel is narrow; the head of it names
// the problem and the job page has the rest.
const trimmed = err.trim()
return trimmed.length > 400 ? `${trimmed.slice(0, 400)}…` : trimmed
}
// This project's models, and whatever else in the workspace reads them. The
// second half is why the editor draws from the shared asset graph rather than
// from a manifest of its own: a native script consuming one of these marts
// belongs on the same canvas as the model that writes it.
let graph = $derived.by(() => {
if (!raw) return undefined
// Pinned to this editor's own parse, every dbt node in the answer is this
// project's — that graph is nothing else. Unpinned the answer is
// FOLDER-wide, so the script's own usage rows are what say which relations
// are its; without that a new project draws a neighbouring one's models
// under a "never parsed" label.
const ids =
refreshJob && raw.dbt_snapshot_job === refreshJob
? new Set(raw.assets.filter((a) => a.dbt).map((a) => `${a.kind}:${a.path}`))
: new Set(
raw.edges
.filter((e) => e.runnable_path === scriptPath)
.map((e) => `${e.asset_kind}:${e.asset_path}`)
)
const assets = raw.assets.filter((a) => a.dbt && ids.has(`${a.kind}:${a.path}`))
if (assets.length === 0) return undefined
const edges = raw.edges.filter(
(e) => ids.has(`${e.asset_kind}:${e.asset_path}`) && e.runnable_path !== scriptPath
)
const readers = new Set(edges.map((e) => `${e.runnable_kind}:${e.runnable_path}`))
return {
...raw,
assets,
// The script's own node is dropped: the whole graph already IS this
// project, so a node for it only adds a hub every model hangs off.
runnables: raw.runnables.filter((r) => readers.has(`${r.usage_kind}:${r.path}`)),
edges,
triggers: [],
dbt_edges: (raw.dbt_edges ?? []).filter(
(e) => ids.has(`dbt:${e.from_asset_path}`) && ids.has(`dbt:${e.to_asset_path}`)
)
} as AssetGraphResponse
})
// The build the editor is running, drawn onto the same graph. One pane rather
// than a tab beside it: watching the models light up IS what you want while a
// build runs, and a tab makes that a choice between the two.
// Deliberately NOT `$state`: the effect below increments it, and `+= 1` reads
// it — a reactive read there would make the effect depend on its own write and
// loop. It exists only to invalidate responses issued for an older job.
let runGen = 0
const runStatus = useDbtRunStatus({
workspace: () => workspace,
jobId: () => testJobId,
running: () => testRunning,
result: () => testResult,
graph: () => graph,
generation: () => runGen,
destroyed: () => destroyed
})
// A new job's colours are not the previous one's, and a finished run settles
// itself from its own result.
$effect(() => {
void testJobId
runGen += 1
runStatus.reset()
})
$effect(() => {
if (!testRunning || !testJobId) return
void runStatus.load()
const t = setInterval(() => void runStatus.load(), 2000)
return () => clearInterval(t)
})
// Once, on the way out: the last events can land after the run ends, and a
// run that produced no result at all is coloured from these alone.
$effect(() => {
if (testRunning || !testJobId) return
void runStatus.load()
})
// Whether what is drawn is the buffer's graph. Not "a refresh has run": a pin
// that did not resolve falls back to the deployed graph, and everything keyed
// on this — the label, and which project a row preview runs — must follow the
// graph that actually came back.
let editorParsed = $derived(refreshJob != undefined && raw?.dbt_snapshot_job === refreshJob)
// Which stored graph is on screen. Anything the details pane fetches about a
// selected node asks for this one, so it cannot describe a node parsed from
// the buffer with the deployed version's answer.
let pin = $derived<DbtGraphPin>(
editorParsed && refreshJob ? { jobId: refreshJob } : { scriptHash: deployedHash }
)
// What the scripts around this project declare about its columns. Empty for
// the ordinary project nothing downstream annotates.
//
// A consumer is anchored here only by its `// materialize` target: this graph
// is fetched `asset_kinds=dbt` so the canvas is the project and nothing else,
// and `buildColumnGraph`'s other anchor is a ducklake WRITE EDGE, which that
// filter drops. So a script consuming a model and writing a ducklake table it
// never declared contributes no hop in this editor, while it does on the
// pipeline page, whose graph spans both kinds. Widening the request would put
// ducklake nodes on the dbt canvas, which is the opposite of what it is for.
let producerColumns = $derived(graph ? buildColumnGraph(graph) : EMPTY_COLUMN_GRAPH)
// `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.
function select(sel: AssetGraphNodeData | undefined) {
untrack(() =>
onSelect?.(
sel,
sel?.kind === 'asset'
? graph?.assets.find((a) => a.kind === sel.asset_kind && a.path === sel.path)?.dbt
: undefined,
editorParsed ? parsedBuffer : undefined,
pin,
producerColumns
)
)
}
let modelCount = $derived(
graph?.assets.filter((a) => a.dbt && a.dbt.resource_type !== 'source').length ?? 0
)
// Which graph is on screen, said plainly. The buffer's and the deploy's are
// drawn identically, so leaving it unlabelled would move the ambiguity the
// explicit refresh removes straight into the editor.
let provenance = $derived.by(() => {
if (editorParsed) {
// Time alone: a refresh is something you did minutes ago in this
// session, so the date is noise and the seconds are worse than noise.
const at = raw?.dbt_graph_ingested_at
return at
? `parsed from the editor at ${displayDate(at, false, false)}`
: 'parsed from the editor'
}
// Nothing has come back yet, so there is no provenance to state. Said
// plainly rather than left to the branches below, which would read the
// absent graph as a parse that failed to load.
if (loading) return 'loading…'
// A refresh landed but its graph is not what came back — the pin did not
// resolve to it. Saying which graph IS on screen beats guessing why.
if (refreshJob) return 'last parse could not be loaded — showing the deployed graph'
if (deployedHash != undefined) return 'as of last deploy'
return 'never parsed'
})
</script>
<div class="flex flex-col h-full min-h-0">
<div class="shrink-0 flex items-center gap-2 px-2 py-1 border-b bg-surface-secondary text-2xs">
<span class="text-secondary truncate" title="Where the models on screen came from">
{provenance}
</span>
{#if modelCount > 0}
<span class="text-tertiary shrink-0">· {modelCount} models</span>
{/if}
<div class="ml-auto shrink-0">
<Button
unifiedSize="2xs"
variant="subtle"
disabled={refreshing}
startIcon={{
icon: refreshing ? Loader2 : RefreshCw,
classes: refreshing ? 'animate-spin' : undefined
}}
on:click={refresh}
title="Run `dbt parse` over the project as it is here and redraw the graph"
>
{refreshing ? 'Parsing' : 'Refresh models'}
</Button>
</div>
</div>
{#if refreshPending}
<div class="shrink-0 px-2 py-1.5 border-b text-2xs text-secondary">
Still parsing. A cold worker provisions the dbt engine before it starts; a project pinned to a
worker tag nothing serves waits here indefinitely.
<a
class="text-blue-500 hover:underline"
href="/run/{refreshPending}?workspace={workspace}"
target="_blank"
rel="noreferrer">Open the parse job</a
>
</div>
{/if}
{#if refreshError}
<div class="shrink-0 px-2 py-1.5 border-b text-2xs text-secondary">
<div class="font-semibold text-primary mb-0.5">The parse failed</div>
<pre class="whitespace-pre-wrap font-mono text-3xs">{refreshError.message}</pre>
{#if refreshError.job}
<a
class="text-blue-500 hover:underline"
href="/run/{refreshError.job}?workspace={workspace}"
target="_blank"
rel="noreferrer">Open the parse job</a
>
{/if}
</div>
{/if}
{#if loading}
<div class="flex items-center gap-2 text-xs text-secondary p-3">
<Loader2 class="animate-spin" size={14} /> Loading the model graph
</div>
{:else if failed}
<div class="text-xs text-secondary p-3">Could not load the model graph.</div>
{:else if !graph}
<div class="text-xs text-secondary p-3 flex flex-col gap-2">
{#if refreshJob || deployedHash != undefined}
<span>
This project has no models in the asset graph. A project that brings its own
<span class="font-mono">profiles.yml</span> without naming a
<span class="font-mono">profile.warehouse</span> has no warehouse identity to key
<span class="font-mono">dbt://</span> assets on, so its relations cannot be drawn.
</span>
{:else}
<span>
Nothing has parsed this project yet. <span class="font-mono">Refresh models</span> runs
<span class="font-mono">dbt parse</span> over the files as they are here and draws what dbt
reports — deploying does the same.
</span>
<span class="text-tertiary">
It needs a warehouse it can reach: the profile is rendered before dbt runs, so a project
whose <span class="font-mono">profile.warehouse</span> is not configured under Settings → dbt
fails the parse the way it would fail a run.
</span>
{/if}
</div>
{:else}
<div class="flex-1 min-h-0">
<AssetGraphCanvas
{graph}
{selection}
assetRunStatus={runStatus.status}
onselect={select}
showMinimap={false}
scrollZoom={false}
/>
</div>
{/if}
</div>