Files
windmill/frontend/src/lib/components/assets/AssetGraph/BackfillRangeDialog.svelte
T
Ruben Fiszelandwindmill-internal-app[bot] 53bbb92953 feat(pipeline): backfill a range of partitions from the asset drawer (#9885)
* feat(pipeline): backfill a range of partitions from the asset drawer (ee)

* feat(pipeline): cancel in-flight backfill job and show cancelling state

* refactor(pipeline): move backfill range logic behind private feature

* fix(pipeline): close backfill cancel-launch race and record dispatch intent

* docs(openapi): producer_path also covers SDK write-edge producers

* chore: update ee-repo-ref to c3852ecb36bd0be1a74c63169e513888f3347850

This commit updates the EE repository reference after PR #641 was merged in windmill-ee-private.

Previous ee-repo-ref: 7c1450ef89fbc9e844a121b39cafe0d7235d704b

New ee-repo-ref: c3852ecb36bd0be1a74c63169e513888f3347850

Automated by sync-ee-ref workflow.

---------

Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-07-02 12:50:55 +02:00

219 lines
7.8 KiB
Svelte

<script lang="ts">
import { Button } from '$lib/components/common'
import Modal from '$lib/components/common/modal/Modal.svelte'
import DateInput from '$lib/components/DateInput.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import { Loader2 } from 'lucide-svelte'
import { resource } from 'runed'
import { AssetService } from '$lib/gen'
import { enterpriseLicense } from '$lib/stores'
import type { BackfillSliceState } from './backfillRun'
// Range backfill of a partitioned ducklake asset (enterprise). The dialog
// previews the expected partitions in [from, to] against what
// `materialized_partition` records (the missing/failed set is the
// worklist), then hands the worklist to the parent, which runs the
// producer once per slice (see `backfillRun.ts`). Progress state lives in
// the parent so a run survives closing the dialog.
interface Props {
// Controlled by the parent. `$bindable()` without a default per the
// AGENTS.md ban on `$bindable(default)` for optional props.
open?: boolean
assetPath: string
workspace: string
// Live per-slice states of the in-flight (or last finished) backfill.
slices?: BackfillSliceState[]
running?: boolean
// Cancellation was requested but the in-flight slice hasn't finished yet.
cancelRequested?: boolean
onStart: (producerPath: string, partitions: string[]) => void
onCancel: () => void
// Clear the finished run so the range picker shows again.
onReset: () => void
}
let {
open = $bindable(),
assetPath,
workspace,
slices,
running,
cancelRequested,
onStart,
onCancel,
onReset
}: Props = $props()
let fromDate = $state<string | undefined>(undefined)
let toDate = $state<string | undefined>(undefined)
let onlyMissing = $state(true)
let preview = resource(
[() => workspace, () => assetPath, () => fromDate, () => toDate, () => open ?? false],
async ([ws, path, from, to, isOpen]) => {
if (!isOpen || !ws || !path || !from || !to) return undefined
return await AssetService.listAssetPartitionsInRange({ workspace: ws, path, from, to })
}
)
let worklist = $derived.by(() => {
const parts = preview.current?.partitions ?? []
const picked = onlyMissing
? parts.filter((p) => p.status === 'missing' || p.status === 'failed')
: parts
return picked.map((p) => p.partition)
})
let counts = $derived.by(() => {
const c = { missing: 0, materialized: 0, failed: 0, running: 0 }
for (const p of preview.current?.partitions ?? []) c[p.status]++
return c
})
let canStart = $derived(
!!$enterpriseLicense && !running && !preview.loading && worklist.length > 0
)
// Backend errors come back as a plain-text body (windmill_common::error).
function errText(e: unknown): string {
const err = e as { body?: unknown; message?: string }
if (typeof err?.body === 'string' && err.body) return err.body
return err?.message ?? String(e)
}
const previewChipClass: Record<string, string> = {
missing: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300',
failed: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300',
materialized: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
running: 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300'
}
const sliceChipClass: Record<BackfillSliceState['status'], string> = {
pending: 'bg-surface-secondary text-tertiary',
running: 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
success: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
failure: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300'
}
</script>
<Modal
bind:open={() => open ?? false, (v) => (open = v)}
title={`Backfill ${assetPath}`}
cancelText="Close"
>
<div class="flex flex-col gap-4">
{#if !$enterpriseLicense}
<p class="text-sm text-secondary">
Partition backfill is an enterprise feature. Materializing a single partition is available
in the open-source edition; reprocessing a historical range requires an enterprise license.
</p>
{:else if slices?.length}
<!-- A run is in flight (or just finished): show per-slice progress. -->
<p class="text-sm text-secondary">
{#if running && cancelRequested}
Cancelling — waiting for the current partition to finish; the rest will not run.
{:else if running}
Materializing {slices.filter((s) => s.status === 'success' || s.status === 'failure')
.length}/{slices.length} partitions sequentially — each run gets its partition as an explicit
arg.
{:else}
Backfill finished: {slices.filter((s) => s.status === 'success').length} succeeded,
{slices.filter((s) => s.status === 'failure').length} failed,
{slices.filter((s) => s.status === 'pending').length} not run.
{/if}
</p>
<div class="flex flex-col gap-1 max-h-64 overflow-auto">
{#each slices as s (s.partition)}
<div class="flex items-center gap-2 text-xs">
<span class="px-1.5 py-0.5 rounded text-3xs font-medium {sliceChipClass[s.status]}">
{s.status}
</span>
<span class="font-mono">{s.partition}</span>
{#if s.status === 'running'}
<Loader2 size={12} class="animate-spin text-tertiary" />
{/if}
{#if s.error}
<span class="text-3xs text-red-600 truncate" title={s.error}>{s.error}</span>
{/if}
</div>
{/each}
</div>
{:else}
<p class="text-sm text-secondary">
Re-runs the producing script once per partition in the range, each with an explicit
<span class="font-mono">partition</span> arg. Re-running a partition is idempotent, so this is
safe to repeat.
</p>
<div class="flex gap-3 items-end">
<div class="flex flex-col gap-1">
<span class="text-xs font-semibold">From</span>
<DateInput bind:value={fromDate} dateFormat="yyyy-MM-dd" />
</div>
<div class="flex flex-col gap-1">
<span class="text-xs font-semibold">To</span>
<DateInput bind:value={toDate} dateFormat="yyyy-MM-dd" />
</div>
</div>
{#if preview.loading}
<div class="flex items-center gap-2 text-tertiary text-xs">
<Loader2 size={14} class="animate-spin" /> Computing partitions in range…
</div>
{:else if preview.error}
<p class="text-sm text-red-600">{errText(preview.error)}</p>
{:else if preview.current}
<div class="flex flex-col gap-2">
<p class="text-xs text-secondary">
{preview.current.partitions.length}
{preview.current.partition_kind} partitions in range — {counts.missing} missing,
{counts.failed} failed, {counts.materialized} materialized. Producer:
<span class="font-mono">{preview.current.producer_path}</span>
</p>
<div class="flex flex-wrap gap-1 max-h-40 overflow-auto">
{#each preview.current.partitions as p (p.partition)}
<span
class="px-1.5 py-0.5 rounded text-3xs font-mono {previewChipClass[p.status]}"
title={p.status}
>
{p.partition}
</span>
{/each}
</div>
<Toggle
bind:checked={onlyMissing}
size="xs"
options={{ right: 'Only run missing and failed partitions' }}
/>
</div>
{/if}
{/if}
</div>
{#snippet actions()}
{#if running}
<Button variant="default" disabled={cancelRequested} onclick={onCancel}>
{cancelRequested ? 'Cancelling…' : 'Cancel backfill'}
</Button>
{:else if slices?.length}
<!-- The finished run just changed partition statuses — refresh the
range preview along with clearing the run. -->
<Button
variant="default"
onclick={() => {
onReset()
preview.refetch()
}}
>
New backfill
</Button>
{:else}
<Button
variant="accent"
disabled={!canStart}
onclick={() => {
const producer = preview.current?.producer_path
if (producer) onStart(producer, worklist)
}}
>
Backfill {worklist.length} partition{worklist.length === 1 ? '' : 's'}
</Button>
{/if}
{/snippet}
</Modal>