Files
windmill/backend/windmill-common/src/materialization.rs
T
Ruben FiszelandClaude Opus 4.8 ade74b297f feat: capture managed-materialize output schema as asset metadata (#2a) (#9812)
* feat: capture managed-materialize output schema as asset metadata (#2a)

After a managed `// materialize` run, capture the producer's output schema
via a DESCRIBE folded into the existing one-row summary read (no extra
round-trip) and persist it in a new versioned `materialized_asset_schema`
sidecar table. This is the producer-side capture that pipeline parity gap
#2b (save-time consumer-ref contract enforcement) will read back.

- materialized_asset_schema sidecar (asset-level grain), versioned: a new
  version row is inserted only when the captured column set changes.
- output_schema column added to the materialize summary codegen.
- worker extracts + records the schema on a successful materialize.
- /assets/asset_schemas read endpoint exposing the evolution history.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: address CI review on schema capture (partition col, order, status gate)

- exclude the synthetic `_wm_partition` column from the captured schema for
  partitioned assets, so the recorded contract is the producer's logical
  output, not Windmill's storage detail (claude/cubic P1).
- make the captured column list explicitly ordered (`row_number()` over the
  DESCRIBE + `list(... ORDER BY)`), so the `list()` aggregate can't reorder
  columns and spuriously bump the schema version (cubic P2).
- gate the API `record_materialization` schema upsert on a `Materialized`
  status, so a failed/running write (or a client attaching a schema to one)
  can't advance the schema history (cubic P2).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: address Codex review (manual-mode schema gate + auth contract docs)

- gate output_schema extraction on the managed (`Some((Some(_), _))`) path so a
  `// materialize manual` run — whose result is the user's own query output —
  can't persist a caller-shaped `output_schema` into materialized_asset_schema
  (Codex P2). Verified e2e: a manual run returning a fabricated
  `output_schema:[{injected,EVIL}]` records the partition but writes no schema
  version, while the managed path still captures normally.
- document the authorization contract on the new public `record_asset_schema`
  and `list_asset_schemas` helpers: they perform no access control (mirroring
  the materialized_partition siblings) and require callers to pass a
  workspace-authorized executor (Codex P1).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(frontend): schema-history tab on the ducklake asset node (#2a)

Adds a "Schema" tab to DucklakeAssetPanel surfacing the captured output-schema
versions persisted by the materialize run. Master-detail (mirrors the History
tab): the version list (newest first, newest auto-selected) shows column count +
snapshot + capture time; selecting a version renders its column/type table.
Reads the GET /assets/asset_schemas endpoint via raw fetch, matching the sibling
PartitionStatusGrid convention (these materialization endpoints are not in the
generated client).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat: schema tab is strategy-aware (history vs fixed schema)

Only a whole-table `replace` producer (CREATE OR REPLACE) can change columns
run-to-run; `append`/`merge`/partitioned writes INSERT into a fixed-schema
table, so their schema is pinned at first materialize and the "history" framing
is degenerate (always one version).

- backend: surface the managed `materialize_strategy` (`replace`/`append`/
  `merge`) on the asset-graph runnable node, alongside the existing
  `partition_kind` (same parse-from-annotation path).
- frontend: the pipeline page derives `schemaCanEvolve` for the selected asset
  from its write-producer (`replace` && not partitioned) and threads it to the
  Schema tab. Evolvable → master-detail version history; fixed → a single
  current-schema table with a short "schema is fixed" note. Unknown defaults to
  evolvable so real history is never hidden.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: schemaCanEvolve fails open on unknown producer strategy

Previously a producer present but missing `materialize_strategy` (e.g. a
draft-overlay runnable, synthesized without the field) fell through to
canEvolve=false, hiding captured history behind the fixed-schema view —
contradicting the "unknown defaults to evolvable" intent.

Now the fixed view shows only when *every* producer is a known insert-style
write (append/merge, or partitioned replace); any producer with unknown
(missing) strategy is treated as evolvable, so real history is never hidden.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 19:46:48 +02:00

285 lines
11 KiB
Rust

//! CE materialization state — the per-partition status recorded by the managed
//! `// materialize` write (in windmill-worker), read by the partition-status
//! grid and by the EE backfill worklist.
//!
//! The write engine and this state are CE; only automatic partition
//! *resolution* (`partition_ee`) and *backfill* orchestration
//! (`pipeline_advanced_ee`) are enterprise. This module is the shared seam:
//! the EE backfill enumerates the partitions in a range, diffs them against
//! these rows to find the missing/failed set, and pushes one CE materialization
//! job per gap (with an explicit `partition` arg — which runs idempotently and
//! upserts the row here). Nothing about that orchestration lives in this file;
//! it only needs the rows to exist, which is why recording is CE.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::types::Json;
use sqlx::{PgExecutor, Postgres, Transaction};
use uuid::Uuid;
use crate::assets::AssetKind;
use crate::error::Result;
/// Sentinel `partition` value for an unpartitioned (whole-table)
/// materialization — partition is part of the primary key and cannot be NULL.
pub const UNPARTITIONED: &str = "";
/// Mirrors the `MATERIALIZATION_STATUS` pg enum (see migration
/// `20260619170118_add_materialized_partition`).
#[derive(sqlx::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[sqlx(type_name = "MATERIALIZATION_STATUS", rename_all = "lowercase")]
#[serde(rename_all = "lowercase")]
pub enum MaterializationStatus {
Running,
Materialized,
Failed,
}
/// One column of a captured asset output schema: its name and substrate type
/// (e.g. `{"name": "order_id", "type": "BIGINT"}`). `type` is the substrate's
/// own type spelling (DuckDB for ducklake) — kept verbatim so #2b can compare
/// declared vs. captured without a lossy normalization step.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SchemaColumn {
pub name: String,
#[serde(rename = "type")]
pub data_type: String,
}
/// The materialization outcome an agent worker (`Connection::Http`, no direct
/// DB) sends to the API to be recorded. Mirrors the `record_materialization`
/// args; the API handler unpacks it and calls that function with its own DB.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecordMaterializationRequest {
pub asset_kind: AssetKind,
pub asset_path: String,
pub partition: String,
pub status: MaterializationStatus,
pub snapshot_id: Option<i64>,
pub row_count: Option<i64>,
pub job_id: Option<Uuid>,
pub error: Option<String>,
/// Captured output schema of the materialized asset (`None` when the
/// substrate/run produced no schema, e.g. a failed run or a polyglot helper
/// that doesn't DESCRIBE). When present, the recorder also upserts a
/// `materialized_asset_schema` version. Defaults to `None` so older agents
/// stay wire-compatible.
#[serde(default)]
pub schema: Option<Vec<SchemaColumn>>,
}
/// Upsert the latest materialization state for one (asset, partition) slice.
/// The worker records the terminal outcome once the write finishes:
/// `Materialized` (with the DuckLake `snapshot_id` + `row_count`) or `Failed`
/// (with `error`). `Running` mirrors the pg enum but has no writer in this flow.
/// Idempotent: re-running the same partition overwrites the row — exactly the
/// backfill / failure-recovery contract.
#[allow(clippy::too_many_arguments)]
pub async fn record_materialization<'e>(
executor: impl PgExecutor<'e>,
workspace_id: &str,
asset_kind: AssetKind,
asset_path: &str,
partition: &str,
status: MaterializationStatus,
snapshot_id: Option<i64>,
row_count: Option<i64>,
job_id: Option<Uuid>,
error: Option<&str>,
) -> Result<()> {
sqlx::query!(
"INSERT INTO materialized_partition
(workspace_id, asset_kind, asset_path, partition, status,
snapshot_id, row_count, job_id, materialized_at, error)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, now(), $9)
ON CONFLICT (workspace_id, asset_kind, asset_path, partition)
DO UPDATE SET status = EXCLUDED.status,
snapshot_id = EXCLUDED.snapshot_id,
row_count = EXCLUDED.row_count,
job_id = EXCLUDED.job_id,
materialized_at = now(),
error = EXCLUDED.error",
workspace_id,
asset_kind as AssetKind,
asset_path,
partition,
status as MaterializationStatus,
snapshot_id,
row_count,
job_id,
error,
)
.execute(executor)
.await?;
Ok(())
}
/// One materialized-partition row, for the status grid / backfill diff.
#[derive(sqlx::FromRow, Debug, Clone, Serialize)]
pub struct MaterializedPartition {
pub asset_kind: AssetKind,
pub asset_path: String,
pub partition: String,
pub status: MaterializationStatus,
pub snapshot_id: Option<i64>,
pub row_count: Option<i64>,
pub job_id: Option<Uuid>,
pub materialized_at: DateTime<Utc>,
pub error: Option<String>,
}
/// All recorded partitions for one asset, newest first — the grid's data and
/// the backfill worklist's "what already exists" set.
pub async fn list_materialized_partitions<'e>(
executor: impl PgExecutor<'e>,
workspace_id: &str,
asset_kind: AssetKind,
asset_path: &str,
) -> Result<Vec<MaterializedPartition>> {
let rows = sqlx::query_as!(
MaterializedPartition,
r#"SELECT asset_kind AS "asset_kind: AssetKind", asset_path, partition,
status AS "status: MaterializationStatus", snapshot_id,
row_count, job_id, materialized_at, error
FROM materialized_partition
WHERE workspace_id = $1 AND asset_kind = $2 AND asset_path = $3
ORDER BY partition DESC"#,
workspace_id,
asset_kind as AssetKind,
asset_path,
)
.fetch_all(executor)
.await?;
Ok(rows)
}
/// One captured schema version of an asset, newest first — the schema-evolution
/// history surfaced on the asset node and read by #2b contract enforcement.
#[derive(sqlx::FromRow, Debug, Clone, Serialize)]
pub struct AssetSchemaVersion {
pub version: i64,
pub columns: Json<Vec<SchemaColumn>>,
pub snapshot_id: Option<i64>,
pub job_id: Option<Uuid>,
pub captured_at: DateTime<Utc>,
}
/// Record the captured output schema of a freshly-materialized asset.
///
/// **Authorization:** like the sibling `record_materialization`, this performs
/// no access control of its own — it writes the row for whatever `workspace_id`
/// it is given. Callers MUST pass a workspace-authorized executor and a
/// `workspace_id` the caller is allowed to write: an RLS-scoped `user_db`
/// transaction for API / agent-worker entry points, or the trusted worker DB
/// pool for the in-worker recorder. Do not expose it to an unauthenticated path.
///
/// Versioning across re-materializations: a new `version` row is inserted only
/// when `columns` differs from the latest stored version; an unchanged
/// re-materialize re-affirms the latest row in place (updates its
/// `snapshot_id`/`job_id`/`captured_at`). The result is a compact
/// schema-evolution history where `MAX(version)` is the current contract.
///
/// Runs in a transaction guarded by a per-asset advisory lock so two concurrent
/// materializations of the same asset can't both insert the same next version
/// or interleave a stale comparison. Returns `true` if a new version was
/// inserted (the schema changed), `false` if the latest was re-affirmed.
pub async fn record_asset_schema(
tx: &mut Transaction<'_, Postgres>,
workspace_id: &str,
asset_kind: AssetKind,
asset_path: &str,
columns: &[SchemaColumn],
snapshot_id: Option<i64>,
job_id: Option<Uuid>,
) -> Result<bool> {
// Serialize concurrent captures of the *same* asset; the lock auto-releases
// at tx end. Hash the identity into the bigint advisory-lock key space.
sqlx::query!(
"SELECT pg_advisory_xact_lock(hashtextextended($1, 0::int8))",
format!("materialized_asset_schema:{workspace_id}:{asset_kind:?}:{asset_path}"),
)
.fetch_one(&mut **tx)
.await?;
let latest = sqlx::query!(
r#"SELECT version, columns AS "columns: Json<Vec<SchemaColumn>>"
FROM materialized_asset_schema
WHERE workspace_id = $1 AND asset_kind = $2 AND asset_path = $3
ORDER BY version DESC
LIMIT 1"#,
workspace_id,
asset_kind as AssetKind,
asset_path,
)
.fetch_optional(&mut **tx)
.await?;
let columns_json = Json(columns.to_vec());
let next_version = match latest {
Some(latest) if latest.columns.0.as_slice() == columns => {
// Unchanged schema — re-affirm the latest version in place.
sqlx::query!(
"UPDATE materialized_asset_schema
SET snapshot_id = $5, job_id = $6, captured_at = now()
WHERE workspace_id = $1 AND asset_kind = $2 AND asset_path = $3
AND version = $4",
workspace_id,
asset_kind as AssetKind,
asset_path,
latest.version,
snapshot_id,
job_id,
)
.execute(&mut **tx)
.await?;
return Ok(false);
}
Some(latest) => latest.version + 1,
None => 1,
};
sqlx::query!(
"INSERT INTO materialized_asset_schema
(workspace_id, asset_kind, asset_path, version, columns,
snapshot_id, job_id, captured_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, now())",
workspace_id,
asset_kind as AssetKind,
asset_path,
next_version,
columns_json as Json<Vec<SchemaColumn>>,
snapshot_id,
job_id,
)
.execute(&mut **tx)
.await?;
Ok(true)
}
/// All captured schema versions for one asset, newest version first.
///
/// **Authorization:** performs no access control (mirrors
/// `list_materialized_partitions`); the caller must pass a workspace-authorized
/// executor (an RLS-scoped `user_db` transaction on the API read path) and a
/// `workspace_id` it is allowed to read.
pub async fn list_asset_schemas<'e>(
executor: impl PgExecutor<'e>,
workspace_id: &str,
asset_kind: AssetKind,
asset_path: &str,
) -> Result<Vec<AssetSchemaVersion>> {
let rows = sqlx::query_as!(
AssetSchemaVersion,
r#"SELECT version, columns AS "columns: Json<Vec<SchemaColumn>>",
snapshot_id, job_id, captured_at
FROM materialized_asset_schema
WHERE workspace_id = $1 AND asset_kind = $2 AND asset_path = $3
ORDER BY version DESC"#,
workspace_id,
asset_kind as AssetKind,
asset_path,
)
.fetch_all(executor)
.await?;
Ok(rows)
}