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>
This commit is contained in:
Ruben Fiszel
2026-07-02 12:50:55 +02:00
committed by GitHub
parent d65f58c388
commit 53bbb92953
12 changed files with 699 additions and 82 deletions
@@ -0,0 +1,30 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT DISTINCT ON (path) path AS \"path!\", content AS \"content!\"\n FROM script\n WHERE workspace_id = $1\n AND auto_kind = 'pipeline'\n AND archived = false\n AND deleted = false\n AND (content LIKE '%' || $2 || '%' OR path = ANY($3))\n ORDER BY path, created_at DESC\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "path!",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "content!",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"TextArray"
]
},
"nullable": [
false,
false
]
},
"hash": "4add520575717d449a3758326777fee03012a3fa0fb943239e004cc4a7067501"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT DISTINCT usage_path FROM asset\n WHERE workspace_id = $1\n AND kind = 'ducklake'\n AND path = $2\n AND usage_kind = 'script'\n AND usage_access_type IN ('w', 'rw')\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "usage_path",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false
]
},
"hash": "d4f047b0a672d5bfe9fd7db60f11563ef05db7cc54a958a72384b08a69eb784e"
}
+1 -1
View File
@@ -1 +1 @@
bdd4ba4dfd05c8ca7db365e530812dc914517d7f
c3852ecb36bd0be1a74c63169e513888f3347850
+3
View File
@@ -8,6 +8,9 @@ edition.workspace = true
name = "windmill_api_assets"
path = "src/lib.rs"
[features]
private = ["windmill-common/private"]
[dependencies]
windmill-api-auth.workspace = true
windmill-common = { workspace = true, default-features = false }
@@ -0,0 +1,18 @@
//! OSS fallback: backfilling a range of partitions is an enterprise
//! feature (the resolution/enumeration logic lives in `windmill-ee-private`,
//! `windmill-api-assets/src/backfill_ee.rs`). Single-partition runs with an
//! explicit `partition` arg remain available in OSS.
use windmill_common::error::{Error, Result};
use crate::{PartitionsInRangeQuery, PartitionsInRangeResponse};
pub(crate) async fn partitions_in_range(
_tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
_w_id: &str,
_q: &PartitionsInRangeQuery,
) -> Result<PartitionsInRangeResponse> {
Err(Error::BadRequest(
"Backfilling a range of partitions is an enterprise feature".to_string(),
))
}
+62
View File
@@ -15,6 +15,18 @@ use windmill_common::{
use windmill_api_auth::ApiAuthed;
// Partition-range backfill preview. The logic (producer resolution, range
// enumeration, status join) is enterprise: the `private` build compiles the
// EE module, the public build a stub that errors.
#[cfg(feature = "private")]
mod backfill_ee;
#[cfg(feature = "private")]
use backfill_ee as backfill;
#[cfg(not(feature = "private"))]
mod backfill_oss;
#[cfg(not(feature = "private"))]
use backfill_oss as backfill;
pub fn workspaced_service() -> Router {
Router::new()
.route("/list", get(list_assets))
@@ -23,6 +35,7 @@ pub fn workspaced_service() -> Router {
.route("/graph", get(asset_graph))
.route("/pipelines", get(list_pipeline_folders))
.route("/partitions", get(list_partitions))
.route("/partitions_in_range", get(list_partitions_in_range))
.route("/asset_schemas", get(list_asset_schemas))
.route("/record_materialization", post(record_materialization))
}
@@ -54,6 +67,55 @@ async fn list_partitions(
Ok(Json(rows))
}
// Only the EE `backfill` module reads the fields; the OSS stub errors without
// touching them.
#[cfg_attr(not(feature = "private"), allow(dead_code))]
#[derive(Deserialize)]
struct PartitionsInRangeQuery {
// The materialized ducklake asset path (`<ducklake>/<table>`).
path: String,
// Inclusive calendar-day range (YYYY-MM-DD), local to the producer's
// partition tz.
from: chrono::NaiveDate,
to: chrono::NaiveDate,
}
#[derive(Serialize)]
struct PartitionInRange {
partition: String,
// `missing` | `running` | `materialized` | `failed` — `missing` means no
// materialization was ever recorded for the slice.
status: &'static str,
}
#[derive(Serialize)]
struct PartitionsInRangeResponse {
// The pipeline script that materializes the asset (managed `// materialize`
// target, or a partitioned writer using the SDK helpers) — the runnable a
// backfill launches (with an explicit `partition` arg per slice).
producer_path: String,
partition_kind: String,
partitions: Vec<PartitionInRange>,
}
// Backfill range preview: every partition the producer's `// partitioned` spec
// expects in `[from, to]`, joined with what `materialized_partition` records —
// the missing/failed subset is the backfill worklist. The logic is in the
// `backfill` module pair: EE resolves and enumerates, the OSS stub errors
// (single-partition runs stay available everywhere; fanning out over a range
// is enterprise).
async fn list_partitions_in_range(
authed: ApiAuthed,
Path(w_id): Path<String>,
Extension(user_db): Extension<UserDB>,
Query(q): Query<PartitionsInRangeQuery>,
) -> JsonResult<PartitionsInRangeResponse> {
let mut tx = user_db.begin(&authed).await?;
let res = backfill::partitions_in_range(&mut tx, &w_id, &q).await?;
tx.commit().await?;
Ok(Json(res))
}
// Per-asset captured output schema versions for a ducklake asset (gap #2a) —
// the schema-evolution history persisted after each managed `// materialize`.
// Newest version first; materialization targets are ducklake-only in v1, so the
+1 -1
View File
@@ -10,7 +10,7 @@ path = "src/lib.rs"
[features]
default = []
private = ["windmill-audit/private", "windmill-common/private", "windmill-api-auth/private", "windmill-store/private", "windmill-api-users/private", "windmill-api-workspaces/private", "windmill-api-groups/private", "windmill-api-configs/private", "windmill-api-settings/private", "windmill-api-agent-workers?/private", "windmill-trigger-kafka?/private", "windmill-trigger-postgres?/private", "windmill-trigger-mqtt?/private", "windmill-trigger-websocket?/private", "windmill-trigger-nats?/private", "windmill-trigger-sqs?/private", "windmill-trigger-gcp?/private", "windmill-trigger-azure?/private", "windmill-trigger-email?/private", "windmill-git-sync/private", "windmill-autoscaling?/private"]
private = ["windmill-audit/private", "windmill-common/private", "windmill-api-auth/private", "windmill-store/private", "windmill-api-users/private", "windmill-api-workspaces/private", "windmill-api-groups/private", "windmill-api-configs/private", "windmill-api-settings/private", "windmill-api-assets/private", "windmill-api-agent-workers?/private", "windmill-trigger-kafka?/private", "windmill-trigger-postgres?/private", "windmill-trigger-mqtt?/private", "windmill-trigger-websocket?/private", "windmill-trigger-nats?/private", "windmill-trigger-sqs?/private", "windmill-trigger-gcp?/private", "windmill-trigger-azure?/private", "windmill-trigger-email?/private", "windmill-git-sync/private", "windmill-autoscaling?/private"]
enterprise = ["windmill-queue/enterprise", "windmill-audit/enterprise", "windmill-git-sync/enterprise", "windmill-common/enterprise", "windmill-worker?/enterprise", "windmill-api-auth/enterprise", "windmill-store/enterprise", "windmill-api-jobs/enterprise", "windmill-api-scripts/enterprise", "windmill-api-flows/enterprise", "windmill-api-users/enterprise", "windmill-api-workspaces/enterprise", "windmill-api-groups/enterprise", "windmill-api-configs/enterprise", "windmill-api-settings/enterprise", "windmill-api-schedule/enterprise", "windmill-api-agent-workers?/enterprise", "windmill-trigger/enterprise", "windmill-trigger-kafka?/enterprise", "windmill-trigger-postgres?/enterprise", "windmill-trigger-mqtt?/enterprise", "windmill-trigger-websocket?/enterprise", "windmill-trigger-email?/enterprise", "windmill-trigger-nats?/enterprise", "windmill-trigger-sqs?/enterprise", "windmill-trigger-gcp?/enterprise", "windmill-trigger-azure?/enterprise", "windmill-trigger-http?/enterprise", "windmill-native-triggers?/enterprise", "dep:windmill-autoscaling", "windmill-autoscaling/enterprise", "license"]
stripe = []
run_inline = ["dep:windmill-worker", "windmill-api-configs/run_inline"]
+57
View File
@@ -21498,6 +21498,42 @@ paths:
items:
$ref: "#/components/schemas/MaterializedPartition"
/w/{workspace}/assets/partitions_in_range:
get:
summary: List expected partitions of a ducklake asset in a date range with their materialization status (enterprise)
operationId: listAssetPartitionsInRange
tags:
- asset
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: path
in: query
required: true
description: The materialized ducklake asset path (`<ducklake>/<table>`)
schema:
type: string
- name: from
in: query
required: true
description: Inclusive range start (YYYY-MM-DD), local to the producer's partition tz
schema:
type: string
format: date
- name: to
in: query
required: true
description: Inclusive range end (YYYY-MM-DD), local to the producer's partition tz
schema:
type: string
format: date
responses:
"200":
description: expected partitions in range with per-slice status — the missing/failed subset is the backfill worklist
content:
application/json:
schema:
$ref: "#/components/schemas/PartitionsInRange"
/w/{workspace}/assets/asset_schemas:
get:
summary: List captured output-schema versions for a ducklake asset
@@ -29692,6 +29728,27 @@ components:
error:
type: string
nullable: true
PartitionsInRange:
type: object
required: [producer_path, partition_kind, partitions]
properties:
producer_path:
type: string
description: the pipeline script that materializes the asset (managed `// materialize` target, or a partitioned writer using the SDK helpers) — the runnable a backfill launches
partition_kind:
type: string
enum: [daily, hourly, weekly, monthly, dynamic]
partitions:
type: array
items:
type: object
required: [partition, status]
properties:
partition:
type: string
status:
type: string
enum: [missing, running, materialized, failed]
AssetSchemaVersion:
type: object
required: [version, columns, captured_at]
@@ -2,78 +2,217 @@
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'
// Backfill re-runs the CE materialization once per partition in [from, to].
// It is an enterprise feature (orchestration over a range); the dialog is
// only reachable when licensed, but we guard here too so the action can
// never fire in CE.
// 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
// Invoked with the inclusive ISO date range; the parent performs the
// actual fan-out (EE backfill endpoint).
onBackfill: (from: string, to: string) => Promise<void>
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, onBackfill }: Props = $props()
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 loading = $state(false)
let error = $state<string | undefined>(undefined)
let onlyMissing = $state(true)
let canSubmit = $derived(!!$enterpriseLicense && !!fromDate && !!toDate && !loading)
async function submit() {
if (!fromDate || !toDate) return
loading = true
error = undefined
try {
await onBackfill(fromDate, toDate)
open = false
} catch (e) {
error = e instanceof Error ? e.message : String(e)
} finally {
loading = false
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}`}>
<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 materialization for each partition in the range. Re-running a partition is
idempotent, so this is safe to repeat.
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">
<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} />
<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} />
<DateInput bind:value={toDate} dateFormat="yyyy-MM-dd" />
</div>
</div>
{#if error}
<p class="text-sm text-red-600">{error}</p>
{#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()}
<Button variant="subtle" onclick={() => (open = false)}>Cancel</Button>
<Button variant="accent" disabled={!canSubmit} {loading} onclick={submit}>
Start backfill
</Button>
{#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>
@@ -1,10 +1,12 @@
<script lang="ts">
import { resource } from 'runed'
import { OpenAPI, AssetService, type MaterializedPartition } from '$lib/gen'
import { AssetService, JobService, type MaterializedPartition } from '$lib/gen'
import { Button } from '$lib/components/common'
import { Loader2, RefreshCw, History } from 'lucide-svelte'
import { sendUserToast } from '$lib/utils'
import { enterpriseLicense } from '$lib/stores'
import BackfillRangeDialog from './BackfillRangeDialog.svelte'
import { runBackfill, type BackfillSliceState } from './backfillRun'
import { makeWaitJobTerminal } from './cascadeRun'
interface Props {
// The materialized ducklake asset path (`<ducklake>/<table>`).
@@ -19,59 +21,131 @@
})
let backfillOpen = $state(false)
// Slice states of the in-flight (or last finished) backfill. Owned here —
// not in the dialog — so progress keeps streaming in the header when the
// dialog is closed mid-run.
let backfillSlices = $state<BackfillSliceState[] | undefined>(undefined)
let backfillRunning = $state(false)
let backfillCancelRequested = $state(false)
let backfillDone = $derived(
backfillSlices?.filter((s) => s.status === 'success' || s.status === 'failure').length ?? 0
)
let backfillFailed = $derived(backfillSlices?.filter((s) => s.status === 'failure').length ?? 0)
// Never throws: the job may already be terminal, and the sequential loop
// stops on the cancel flag regardless.
async function cancelJobById(jobId: string) {
try {
await JobService.cancelQueuedJob({
workspace,
id: jobId,
requestBody: { reason: 'backfill cancelled' }
})
} catch {}
}
// Stop scheduling further slices and cancel the in-flight run (its slice
// then reports failure); already-materialized slices are unaffected. A
// cancel that lands while a launch is in flight (no job id yet) is
// finished by the runner via its `cancelJob` hook.
async function cancelBackfill() {
backfillCancelRequested = true
const running = backfillSlices?.find((s) => s.status === 'running')
if (running?.jobId) {
await cancelJobById(running.jobId)
}
}
async function startBackfill(producerPath: string, worklist: string[]) {
if (backfillRunning || worklist.length === 0) return
backfillRunning = true
backfillCancelRequested = false
try {
await runBackfill({
partitions: worklist,
// Backend asset dispatch stays ON (unlike client-orchestrated dev
// cascades, which pass `_wmill_skip_asset_dispatch`): a backfilled
// slice refreshes its deployed consumers like any deployed run,
// carrying its partition down the chain.
launch: async (partition) =>
await JobService.runScriptByPath({
workspace,
path: producerPath,
requestBody: { partition }
}),
waitTerminal: async (jobId) => {
const term = await makeWaitJobTerminal(workspace)(jobId)
// The run just recorded (or failed to record) its
// materialized_partition row — stream it into the grid.
partitions.refetch()
return term
},
onUpdate: (s) => (backfillSlices = s),
isCancelled: () => backfillCancelRequested,
cancelJob: cancelJobById
})
} finally {
backfillRunning = false
partitions.refetch()
}
}
const statusClass: Record<MaterializedPartition['status'], string> = {
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',
failed: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300'
}
async function onBackfill(from: string, to: string) {
// The fan-out runner is an enterprise feature; the dialog only submits
// when licensed. This posts the intent to the (EE) backfill endpoint — a raw
// fetch since it's not in the OSS OpenAPI, so carry the bearer token
// explicitly (matches how `/pipeline_dev` authenticates: no session cookie).
const token = OpenAPI.TOKEN
const authHeader: Record<string, string> =
typeof token === 'string' && token ? { Authorization: `Bearer ${token}` } : {}
const res = await fetch(`${OpenAPI.BASE ?? ''}/w/${workspace}/assets/backfill`, {
method: 'POST',
credentials: 'include',
headers: { 'content-type': 'application/json', ...authHeader },
body: JSON.stringify({ path, from, to })
})
if (!res.ok) throw new Error(`backfill → ${res.status}`)
sendUserToast(`Backfill queued for ${path} (${from}${to})`)
await partitions.refetch()
}
</script>
<div class="flex flex-col h-full">
<div class="flex items-center justify-between gap-2 px-3 py-2 border-b shrink-0">
<span class="text-xs font-semibold text-secondary">Materialized partitions</span>
<div class="flex items-center gap-1">
<Button
variant="subtle"
unifiedSize="sm"
startIcon={{ icon: RefreshCw }}
iconOnly
onclick={() => partitions.refetch()}
title="Refresh"
/>
<!-- The backfill range runner (POST /assets/backfill) is a planned
enterprise follow-up and not yet implemented on the backend, so the
button stays disabled — enabling it would 404. Re-enable when the
endpoint lands. -->
<Button
variant="default"
unifiedSize="sm"
startIcon={{ icon: History }}
disabled
onclick={() => (backfillOpen = true)}
title="Backfill a range of partitions coming soon (enterprise)"
>
Backfill
</Button>
<div class="flex items-center gap-2">
{#if backfillRunning}
<button
class="flex items-center gap-1 text-3xs text-tertiary hover:text-primary"
onclick={() => (backfillOpen = true)}
title="Show backfill progress"
>
<Loader2 size={12} class="animate-spin" />
Backfilling {backfillDone}/{backfillSlices?.length ?? 0}
</button>
{:else if backfillSlices?.length}
<button
class="text-3xs {backfillFailed > 0
? 'text-red-600'
: 'text-tertiary'} hover:text-primary"
onclick={() => (backfillOpen = true)}
title="Show backfill result"
>
Backfill: {backfillDone - backfillFailed}/{backfillSlices.length} ok{backfillFailed > 0
? `, ${backfillFailed} failed`
: ''}
</button>
{/if}
<div class="flex items-center gap-1">
<Button
variant="subtle"
unifiedSize="sm"
startIcon={{ icon: RefreshCw }}
iconOnly
onclick={() => partitions.refetch()}
title="Refresh"
/>
<Button
variant="default"
unifiedSize="sm"
startIcon={{ icon: History }}
disabled={!$enterpriseLicense}
onclick={() => (backfillOpen = true)}
title={$enterpriseLicense
? 'Backfill a range of partitions'
: 'Backfill a range of partitions (enterprise feature)'}
>
Backfill
</Button>
</div>
</div>
</div>
@@ -122,4 +196,14 @@
</div>
</div>
<BackfillRangeDialog bind:open={backfillOpen} assetPath={path} {onBackfill} />
<BackfillRangeDialog
bind:open={backfillOpen}
assetPath={path}
{workspace}
slices={backfillSlices}
running={backfillRunning}
cancelRequested={backfillCancelRequested}
onStart={startBackfill}
onCancel={cancelBackfill}
onReset={() => (backfillSlices = undefined)}
/>
@@ -0,0 +1,118 @@
import { describe, expect, it } from 'vitest'
import { runBackfill, type BackfillSliceState } from './backfillRun'
// Deterministic fake backend: launch resolves with `job:<partition>`,
// waitTerminal resolves per the `results` table (default success), recording
// launch order.
function fakeRunner(results: Record<string, 'success' | 'failure'> = {}) {
const launched: string[] = []
return {
launched,
launch: async (partition: string) => {
launched.push(partition)
return `job:${partition}`
},
waitTerminal: async (jobId: string) => results[jobId.slice(4)] ?? ('success' as const)
}
}
describe('runBackfill', () => {
it('runs slices sequentially in order and reports ok', async () => {
const r = fakeRunner()
const res = await runBackfill({
partitions: ['2026-06-26', '2026-06-27', '2026-06-29'],
launch: r.launch,
waitTerminal: r.waitTerminal
})
expect(r.launched).toEqual(['2026-06-26', '2026-06-27', '2026-06-29'])
expect(res.ok).toBe(true)
expect(res.cancelled).toBe(false)
expect(res.slices.map((s) => s.status)).toEqual(['success', 'success', 'success'])
expect(res.slices.map((s) => s.jobId)).toEqual([
'job:2026-06-26',
'job:2026-06-27',
'job:2026-06-29'
])
})
it('continues past a failed slice — each slice is independent', async () => {
const r = fakeRunner({ '2026-06-27': 'failure' })
const res = await runBackfill({
partitions: ['2026-06-26', '2026-06-27', '2026-06-29'],
launch: r.launch,
waitTerminal: r.waitTerminal
})
expect(r.launched).toEqual(['2026-06-26', '2026-06-27', '2026-06-29'])
expect(res.ok).toBe(false)
expect(res.slices.map((s) => s.status)).toEqual(['success', 'failure', 'success'])
})
it('records a launch error as slice failure and keeps going', async () => {
const r = fakeRunner()
const res = await runBackfill({
partitions: ['a', 'b'],
launch: async (p) => {
if (p === 'a') throw new Error('boom')
return r.launch(p)
},
waitTerminal: r.waitTerminal
})
expect(res.slices[0]).toMatchObject({ status: 'failure', error: 'boom' })
expect(res.slices[1].status).toBe('success')
})
it('stops before the next launch when cancelled, leaving the rest pending', async () => {
const r = fakeRunner()
let done = 0
const res = await runBackfill({
partitions: ['a', 'b', 'c'],
launch: r.launch,
waitTerminal: async (id) => {
done++
return r.waitTerminal(id)
},
isCancelled: () => done >= 1
})
expect(r.launched).toEqual(['a'])
expect(res.cancelled).toBe(true)
expect(res.ok).toBe(false)
expect(res.slices.map((s) => s.status)).toEqual(['success', 'pending', 'pending'])
})
it('cancels a job whose launch raced the cancellation', async () => {
let cancelled = false
const cancelledJobs: string[] = []
const res = await runBackfill({
partitions: ['a', 'b'],
launch: async (p) => {
// The user clicks cancel while the launch request is in flight —
// there is no job id to cancel yet.
cancelled = true
return `job:${p}`
},
waitTerminal: async () => 'failure',
isCancelled: () => cancelled,
cancelJob: async (id) => {
cancelledJobs.push(id)
}
})
expect(cancelledJobs).toEqual(['job:a'])
expect(res.cancelled).toBe(true)
expect(res.slices.map((s) => s.status)).toEqual(['failure', 'pending'])
})
it('emits a snapshot per transition, never mutating earlier snapshots', async () => {
const r = fakeRunner()
const snapshots: BackfillSliceState[][] = []
await runBackfill({
partitions: ['a'],
launch: r.launch,
waitTerminal: r.waitTerminal,
onUpdate: (s) => snapshots.push(s)
})
// initial pending, running, running+jobId, terminal
expect(snapshots.map((s) => s[0].status)).toEqual(['pending', 'running', 'running', 'success'])
expect(snapshots[1][0].jobId).toBeUndefined()
expect(snapshots[2][0].jobId).toBe('job:a')
})
})
@@ -0,0 +1,83 @@
// Client-side orchestration of a partition-range backfill (enterprise): one
// deployed run of the producing script per slice, launched with an explicit
// `partition` arg (the worker only resolves a partition when the arg is
// absent, so the caller-provided value wins). Slices run sequentially —
// concurrent materializations of the same ducklake table would contend on
// the catalog commit — and a failed slice does not stop the rest: each slice
// is independent, and the missing/failed set is simply the next worklist.
//
// Pure module (no Svelte runes) so the loop is unit-testable; reactive
// progress is delivered via `onUpdate` snapshots, mirroring
// `cascadeOrchestrator.ts`.
export type BackfillSliceStatus = 'pending' | 'running' | 'success' | 'failure'
export type BackfillSliceState = {
partition: string
status: BackfillSliceStatus
jobId?: string
error?: string
}
export type BackfillRunOptions = {
/** Partition values to materialize, in run order. */
partitions: string[]
/** Launch one run of the producer with the given partition arg; returns the job id. */
launch: (partition: string) => Promise<string>
/** Resolve once the job reaches a terminal state. */
waitTerminal: (jobId: string) => Promise<'success' | 'failure'>
/** Snapshot of all slice states, emitted on every transition. */
onUpdate?: (slices: BackfillSliceState[]) => void
/** Checked before each launch; a true stop leaves the remaining slices 'pending'. */
isCancelled?: () => boolean
/**
* Cancel a job whose launch raced the cancellation — the cancel click had
* no job id to act on yet, so the loop cancels it as soon as the id
* arrives. Must not throw (the job may already be terminal).
*/
cancelJob?: (jobId: string) => Promise<void>
}
export type BackfillRunResult = {
/** True when every slice ran and succeeded. */
ok: boolean
/** True when the loop stopped early on `isCancelled`. */
cancelled: boolean
slices: BackfillSliceState[]
}
export async function runBackfill(opts: BackfillRunOptions): Promise<BackfillRunResult> {
const { partitions, launch, waitTerminal, onUpdate, isCancelled, cancelJob } = opts
const slices: BackfillSliceState[] = partitions.map((partition) => ({
partition,
status: 'pending'
}))
const emit = () => onUpdate?.(slices.map((s) => ({ ...s })))
emit()
let cancelled = false
for (const slice of slices) {
if (isCancelled?.()) {
cancelled = true
break
}
slice.status = 'running'
emit()
try {
slice.jobId = await launch(slice.partition)
emit()
if (isCancelled?.() && cancelJob) {
await cancelJob(slice.jobId)
}
slice.status = await waitTerminal(slice.jobId)
} catch (e) {
slice.status = 'failure'
slice.error = e instanceof Error ? e.message : String(e)
}
emit()
}
return {
ok: !cancelled && slices.every((s) => s.status === 'success'),
cancelled,
slices
}
}