Skip to content

Propagate upstream changes into downstream cubes - #2424

Open
shangyian wants to merge 9 commits into
mainfrom
propagate-upstream-changes-to-cubes
Open

Propagate upstream changes into downstream cubes#2424
shangyian wants to merge 9 commits into
mainfrom
propagate-upstream-changes-to-cubes

Conversation

@shangyian

@shangyian shangyian commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator

Summary

Changing a node in a material sense doesn't bump the version of the cubes downstream from it. The cube keeps serving a materialized table built against the old upstream, and the only way to fix it is pushing a no-op PR against the cube to force a recompile.

This is because _propagate_update_downstream calls get_downstream_nodes(..., include_cubes=False), so cubes are filtered out of the downstream list before the loop runs. And even if they weren't, the loop only calls revalidate_node, whose cube branch sets status and returns, with no bump logic like that in update_cube_node.

Changes:

  • Propagation now includes cubes, and a cube inherits the upstream's change tier (e.g., a major change upstream is a major bump on the cube)
  • update_cube_node is extracted into save_new_cube_revision, so the update path and propagation build a cube revision the same way rather than drifting.
  • Adding a column to a node is now a minor bump instead of major. Without this, an additive change upstream rebuilds every cube beneath it.

Test Plan

  • PR has an associated issue: #
  • make check passes
  • make test shows 100% unit test coverage

Deployment Plan

An upstream edit can now trigger rematerialization of downstream cubes, which didn't happen before. Cube bumps also stop the superseded revision's workflows via the existing swap.

A cube was filtered out of downstream propagation, and even reached it would
only have had its status revalidated -- nothing computed it a version. So an
upstream edit left the cube pointing at a materialized table built against a
definition that no longer existed, and the only way to recompile it was a no-op
edit to the cube itself.

Propagation now includes cubes and gives each one a new revision resolved from
its own current metrics and dimensions, so its columns, elements and parents
recompile against what the upstream became. The revision is built by the same
code the cube update endpoint uses, extracted as `save_new_cube_revision`, so
the two writers cannot produce differently-shaped revisions, and it swaps
materializations the same way so the superseded revision's workflows are
stopped rather than left pointing at a dead revision.

The cube inherits the upstream's change tier rather than being classified on
its own shape. Widening a transform's WHERE clause changes no metric, no
dimension and no metric component identity -- `is_non_trivial_cube_change`
returns False for it -- yet every row in the cube's table was computed under
the old filter. Over-rebuilding costs compute; under-rebuilding serves wrong
numbers.

A cube that cannot be rebuilt is logged and skipped rather than costing the
remaining downstreams their propagation.
`revalidate_node` bumped a node's major version for any difference between its
stored columns and what the validator recomputed. A column that only just
appeared cannot be referenced by anything written before it existed, so an
additive change breaks nobody, and now that downstream cubes inherit their
upstream's tier a major bump there rebuilds every cube's materialized table for
it. A column whose type moved stays major: everything reading it is reading a
different type than it was written against.

`order_fixed` -- DJ backfilling a missing `order` onto stored columns to match
the query's projection -- is filed with the additive cases. It changes no name,
no type and no value, so it arguably deserves no revision at all; minor is the
conservative reading, since a revision is what the existing audit-trail test
expects it to produce.

The decision is a `ChangeTier` handed to `bump_version` rather than a direct
call to `next_major_version`, so it is a value propagation can pass on to
downstream cubes.
@netlify

netlify Bot commented Aug 16, 2026

Copy link
Copy Markdown

Deploy Preview for thriving-cassata-78ae72 canceled.

Name Link
🔨 Latest commit 8fbf0ef
🔍 Latest deploy log https://app.netlify.com/projects/thriving-cassata-78ae72/deploys/6a844fe47cc4d40008b7f058

`order_fixed` fires when a stored column has no `order` and DJ fills in the
projection index -- its own bookkeeping on a row written before the field
existed. No query changed and no name, type or value moved, so it earned a
version bump that described nothing, and since downstream cubes inherit their
upstream's tier, it would now rebuild every cube's materialized table for a
metadata fix.

It is `ChangeTier.NONE`, and the fix is applied to the current revision in
place. Leaving it unset is not free: readers sort columns by `order` with unset
last, so an unordered column drifts to the end of the projection and every
`to_spec` logs the node as unordered. Writing the projection index onto the
stored row misrepresents nothing, since the rows were inserted in that order to
begin with -- and where a revision is only partly ordered, it puts a column back
where the query always projected it.

The audit trail survives the loss of the bump. A backfill still writes a history
event naming the columns it filled and the version the node kept, because
nothing else would record that DJ rewrote the row.

Three existing tests used a cleared column order as a cheap way to force
revalidation to fork a revision, which it no longer does; they now disagree with
a stored column type instead, so they keep testing the fork path rather than
passing vacuously.

Also documents, next to the cube rebuild check, why the churn there is
deliberate: any query edit is a major bump, so every upstream query edit rebuilds
every cube below it. Narrowing that by diffing resolved output columns was
considered and rejected -- a query edit can move a filter, a join or a CASE
threshold while leaving every column and type identical, and each changes every
row served.
The merge loop walked the validator's columns and looked each up in the stored
ones, so it could only ever find what the validator produced. A column the
revision still stores but the query no longer selects was never visited: no
bump, and it rode onto the next revision advertising a value the node cannot
supply. describe_column_changes already compares both directions, so use it.

A removal is major -- anything referencing the column is broken. That
occasionally rebuilds a downstream cube that never used it, which is affordable
because removals are rare and additions are not.
An order-only change no longer earns a revision, so the in-loop backfill and the
history detail that reports it are reachable only when a revision is created for
another reason and a legacy column also has no order. The suite exercised those
separately and never together.
Dropping a column a metric aggregates leaves the transform valid -- its own query
is fine -- while the metric can no longer infer a type and the cube built on it
follows. Each bumps, so nothing is left serving a definition that no longer
resolves. Worth pinning because the failure is only visible downstream: the edit
that causes it looks entirely successful at the node being edited.
previous_table_usable was derived from is_non_trivial_cube_change on every path,
including propagation -- so a filter change upstream, the case that motivated not
using that predicate to decide rebuilds, recorded the old table as reusable
exactly when its data is what went stale. Propagation now passes False, since
identical shapes say nothing about the rows.

Also: correct the claim that both cube-revision writers route through here (deploy
builds and swaps on its own path), say why node survives the recovery rollback,
and cover a cube failing after one already succeeded.
The sibling of test_patch_and_deployment_agree_on_version, which only covers edits
to the cube itself. Here the cube's spec is byte-identical across two deploys and
only the metric under it changes, which filter_nodes_to_deploy cannot see -- an
unchanged spec is skipped, so the cube keeps a revision compiled against the old
definition while the PATCH path propagates into it.

Fails on the cube assertion (v2.0 on PATCH, v1.0 on deploy); the metric assertion
above it passes, so the divergence is the cube alone. Red until the deploy path
shares the propagation bump.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant