compute: stop error collections multiplying their cardinality - #38196
Conversation
ggevay
left a comment
There was a problem hiding this comment.
Thank you for the quick fix! Wrote some comments.
def-
left a comment
There was a problem hiding this comment.
Some strange stuff happens with multiple replicas when an MV is in error state:
diff --git a/test/cluster/mzcompose.py b/test/cluster/mzcompose.py
index 73c6dbfa93..4730f90851 100644
--- a/test/cluster/mzcompose.py
+++ b/test/cluster/mzcompose.py
@@ -6507,6 +6507,137 @@ def workflow_test_constant_sink(c: Composition) -> None:
"""))
+def workflow_test_mv_error_multiplicity_divergence(c: Composition) -> None:
+ """
+ Test that two replicas of one cluster do not fight over an errored
+ materialized view's persist shard.
+
+ `enable_compute_error_distinct` decides whether a shared binding keeps the
+ error multiplicity its fan-out gives it, it is replica-scoped, and rendering
+ reads it once, when a dataflow is built. Two replicas that rendered on
+ either side of a flip therefore disagree about that multiplicity, and
+ neither is re-rendered. The MV sink writes `desired - persist` into a shard
+ both replicas share, so a disagreement that reaches the shard has each
+ replica correcting the other's writes at every batch description, forever,
+ with no input activity at all.
+ """
+
+ def replica_def(name: str, host: str) -> str:
+ return f"""
+ {name} (
+ STORAGECTL ADDRESSES ['{host}:2100'],
+ STORAGE ADDRESSES ['{host}:2103'],
+ COMPUTECTL ADDRESSES ['{host}:2101'],
+ COMPUTE ADDRESSES ['{host}:2102'],
+ WORKERS 2
+ )"""
+
+ def assert_mv_errors() -> None:
+ # Reading the MV waits for its shard's write frontier to pass the read
+ # timestamp, so this also waits for the sink to have written the error.
+ try:
+ c.sql_query("SELECT * FROM mv")
+ except DatabaseError as e:
+ assert "division by zero" in str(e), e
+ else:
+ raise RuntimeError("materialized view unexpectedly did not error")
+
+ def distinct_error_operators(replica: str) -> int:
+ with c.sql_cursor(
+ startup_params={"cluster": "cluster1", "cluster_replica": replica}
+ ) as cursor:
+ cursor.execute(
+ b"SELECT count(*) FROM mz_introspection.mz_dataflow_operators "
+ b"WHERE name LIKE 'Distinct errors%'"
+ )
+ return int(cursor.fetchall()[0][0])
+
+ def correction_insertions() -> float:
+ """Cumulative persist sink correction insertions, over both replicas."""
+ return sum(
+ Metrics(
+ c.exec(s, "curl", "localhost:6878/metrics", capture=True).stdout
+ ).get_summed_value("mz_persist_sink_correction_insertions_total")
+ for s in ("clusterd1", "clusterd2")
+ )
+
+ c.up("materialized", "clusterd1", "clusterd2")
+
+ c.sql(
+ """
+ ALTER SYSTEM SET unsafe_enable_unorchestrated_cluster_replicas = true;
+ ALTER SYSTEM SET enable_compute_error_distinct = false;
+ """,
+ port=6877,
+ user="mz_system",
+ )
+
+ c.sql(f"""
+ CREATE CLUSTER cluster1 REPLICAS ({replica_def("replica1", "clusterd1")});
+
+ CREATE TABLE t (a int, b int);
+ INSERT INTO t VALUES (1, 0);
+
+ -- Diamond-shaped bindings over a division by zero. Every level reads the
+ -- level below it twice, so the error reaches the sink with multiplicity
+ -- 8 when the collapse is off and 2 when it is on.
+ CREATE MATERIALIZED VIEW mv IN CLUSTER cluster1 AS
+ WITH
+ c0 AS (SELECT a, a / b AS q FROM t),
+ c1 AS (SELECT a, q FROM c0 WHERE a = 1 UNION ALL SELECT q, a FROM c0 WHERE a = 2),
+ c2 AS (SELECT a, q FROM c1 WHERE a = 1 UNION ALL SELECT q, a FROM c1 WHERE a = 2),
+ c3 AS (SELECT a, q FROM c2 WHERE a = 1 UNION ALL SELECT q, a FROM c2 WHERE a = 2)
+ SELECT * FROM c3;
+ """)
+
+ assert_mv_errors()
+
+ # Flip the flag and add a replica that renders the MV with the collapse on,
+ # while replica1 keeps running its uncollapsed dataflow.
+ c.sql(
+ "ALTER SYSTEM SET enable_compute_error_distinct = true",
+ port=6877,
+ user="mz_system",
+ )
+ c.sql(f"CREATE CLUSTER REPLICA cluster1.{replica_def('replica2', 'clusterd2')}")
+
+ for _ in range(120):
+ hydrated = c.sql_query("""
+ SELECT count(*)
+ FROM mz_internal.mz_compute_hydration_times h
+ JOIN mz_cluster_replicas r ON r.id = h.replica_id
+ JOIN mz_materialized_views v ON v.id = h.object_id
+ WHERE r.name = 'replica2' AND h.time_ns IS NOT NULL
+ """)[0][0]
+ if hydrated:
+ break
+ time.sleep(0.5)
+ else:
+ raise AssertionError("replica2 did not hydrate the materialized view")
+
+ # Without this the rest of the test could pass for the wrong reason, e.g.
+ # because the optimizer stopped sharing the view's bindings.
+ assert distinct_error_operators("replica1") == 0, "replica1 collapsed errors"
+ assert distinct_error_operators("replica2") > 0, "replica2 did not collapse errors"
+
+ # Replicas that agree stop touching the shard once they are hydrated and
+ # never insert into their correction buffers again. Replicas that disagree
+ # keep correcting each other in bursts, at least a dozen insertions a
+ # minute, separated by pauses of up to half a minute.
+ time.sleep(5)
+ before = correction_insertions()
+ time.sleep(60)
+ insertions = correction_insertions() - before
+
+ assert insertions <= 3, (
+ "the replicas keep correcting the materialized view's persist shard: "
+ f"{insertions} correction buffer insertions in a minute, with no input "
+ "activity at all"
+ )
+
+ assert_mv_errors()
+
+
def workflow_test_memory_limiter(c: Composition) -> None:
"""
Test that the memory limiter functions as expected.Running bin/mzcompose --find cluster down && bin/mzcompose --find cluster run test-mv-error-multiplicity-divergence fails with: AssertionError: the replicas keep correcting the materialized view's persist shard: 22.0 correction buffer insertions in a minute, with no input activity at all
|
@def- this is a blocker and the diagnosis holds. Thank you for the runnable repro — that's a failure mode I would not have found by reading. Confirmed the mechanism: The root cause is broader than the flag, and I'd rather fix that than the trigger. Error multiplicity in an MV's shard is a function of plan shape: it is whatever the binding fan-out happens to be, 8 in your repro. Nothing anywhere normalizes it before the write. The flag makes divergence trivially reachable, but any difference in how two replicas of a cluster render the same MV's error graph has the same consequence, and the multiplicity is meaningless in the first place — nothing reads it, which is the premise this whole PR rests on. So the fix I'd propose is to collapse errors at the persist sink boundary, unconditionally and independent of Two notes on alternatives I considered and rejected:
I also owe a correction on my earlier reply to @antiguru: I sold per-replica enablement as a benefit of the replica scoping, "turn it on for one unbilled replica against a real erroring dataflow". On an MV that is precisely your bug, deliberately induced. That advice was wrong and I withdraw it — until the sink normalizes, the flag must not differ between replicas of one cluster, and validating on a single replica is only safe for index-backed objects. Would you mind if I take your workflow into the PR as-is? It asserts on the mechanism rather than the symptom, and the Generated by Claude Code |
You can always take my tests directly! Sometimes the comments are a bit inappropriate for a permanent test since it's my local reproducer, and sometimes references stuff that isn't even in the repo. |
Error collections carry a multiplicity nothing reads. A dataflow is in an error state if its error collection is non-empty, and the error surfaced to the user is chosen arbitrarily, so an error's diff conveys nothing. Two rendering patterns nonetheless grow that diff multiplicatively, and because every error in a dataflow consolidates onto one of a handful of distinct error values, the diffs pile onto single records and reach `Diff` overflow on plans of quite ordinary size. A delta join propagated each input's pre-existing errors once per delta path. With N inputs that is N copies of every input's errors, assembled over N^2 concat edges, and since a join's output is another join's input the factors compound through a nested plan. Collect each input's error collections once, outside the path loop, and leave `build_update_stream` and `build_halfjoin` returning only the errors they themselves produce. Plan-level sharing multiplies the same way and more steeply: every reader of a binding propagates that binding's errors independently, so a binding read f times contributes its errors f times, and a chain of diamond-shaped CTEs multiplies those factors instead of adding them. Collapse a binding's error multiplicities to one where more than one `Get` reads it, which holds a dataflow's error multiplicity to the fan-out of a single level. A binding one `Get` reads cannot duplicate its own errors and is left alone. The collapse has to read the accumulated collection, which is why it is a reduce over an arrangement rather than arithmetic on the diffs: no pointwise function of an update's diff (a saturating add, a sign) can bound multiplicity and still cancel when the errors retract. `LetRec` already collapses its error variable for the same reason. Gated on `enable_compute_error_distinct`, off in production and pinned on for sqllogictest and mzcompose. Adds a diamond-chain regression test to test/sqllogictest/error_semantics.slt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GbtepuqcxmAffaM2qXZ4uA
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GbtepuqcxmAffaM2qXZ4uA
Lets the collapse be enabled on a single replica, so it can be validated against a real erroring dataflow before it applies environment-wide. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GbtepuqcxmAffaM2qXZ4uA
…stinct The `SqlLogicTest` mzcompose service and `bin/sqllogictest` both pass `get_default_system_parameters()`, which includes the minimal parameters the flag is already listed in, so pinning it in the binary added a crate dependency without changing what any run sees. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GbtepuqcxmAffaM2qXZ4uA
The collapse changes how new dataflows render their error streams but not what any consumer observes, since an error's multiplicity is not visible, so the flag is safe to flip mid-run rather than uninteresting. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GbtepuqcxmAffaM2qXZ4uA
The names reached introspection as `Distinct errors for l0`, putting an optimizer-assigned identifier into arrangement goldens that would churn every time locals are renumbered. Name the key instead, matching the existing `ArrangeBy[[...]]` convention. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GbtepuqcxmAffaM2qXZ4uA
Collapsing a shared binding's error multiplicities adds two arrangements per binding read as a raw collection (an arrange plus the reduce) and one per binding read as an arrangement (the reduce alone, over the arrangement that already exists). The `Arrange bundle err` rows are not new arrangements: the reduce imports that trace, which raises its sharing count from zero and makes an arrangement that already existed visible to `mz_arrangement_sharing`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GbtepuqcxmAffaM2qXZ4uA
A rec binding's `insert_id` stored the bundle uncollapsed, so only reads rendered before it, which resolve to the feedback `Variable`, saw collapsed errors. Later rec bindings in the same block, the body, and everything downstream resolved to the stored bundle and compounded level over level. That is the shape the collapse already prevented for non-recursive bindings, and generated recursive queries reach it. Also corrects two doc claims that were stronger than the guarantee: `bundle_errs` bounds an input's errors to one copy per retained form, not one outright, and imported errors arrive bounded by the exporting dataflow's last level of sharing rather than collapsed to one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GbtepuqcxmAffaM2qXZ4uA
An errored materialized view's error multiplicity is durable state. The sink writes `desired - persist` into a shard every replica of the cluster shares, and rendering reads this config once, when a dataflow is built, so two replicas that rendered under different values each see the other's writes as an error to correct and correct each other forever, with no input activity. Per-cluster scoping does not express this: a cluster-scoped override is stored durably but never reaches a compute worker, since the only path to a worker's `ConfigSet` is the controller's per-replica dyncfg push, which reads the replica-scoped overrides alone. It would look like a working knob and silently render from the environment-wide value instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GbtepuqcxmAffaM2qXZ4uA
`union all` duplicates the error records physically, so the chain exhausted memory without ever overflowing and the test was red for the wrong reason. `union` arranges the error collection, letting duplicates consolidate into one record whose diff doubles per level, which is the accumulation the fix targets. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GbtepuqcxmAffaM2qXZ4uA
The per-binding collapse bounded sharing within a dataflow but not across objects. A level of sharing expressed as an indexed view reads `Get(Global)`, which a binding collapse never sees, so an index import handed the importing dataflow the trace's diffs and its first consolidation accumulated them. A chain of indexed views therefore doubled per level and still overflowed. Normalizing at every boundary another dataflow can read closes that: both index exports, and the persist sink whose shard is read back verbatim. Subscribes and one-shot copies are read once by a client and never re-imported, so they do not pay for the arrangement. The sink normalizes after the null assertions, whose errors follow the ok row's multiplicity. This also makes error multiplicity in a materialized view's shard independent of plan shape, so two replicas of one cluster cannot write conflicting values and correct each other indefinitely. Drops the reference-count gate on the per-binding collapse. It was a pure optimization guarding roughly 1% of collapses, since `NormalizeLets` inlines single-use bindings before rendering sees them, and it cost a public API on `RenderPlan` threaded through three call sites. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GbtepuqcxmAffaM2qXZ4uA
Boundary normalization means this flag governs sharing within a dataflow only, so what it decides never reaches durable state and per-replica or per-cluster overrides are no longer a divergence hazard. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GbtepuqcxmAffaM2qXZ4uA
e997486 to
d8a1cbf
Compare
Error multiplicity has one reader after all: error-count introspection reports it as the number of failing rows. Collapsing before `log_dataflow_errors` made an index report a single error however many rows failed, which `unified-compute-introspection` caught. The exported trace still leaves normalized, so importers cannot accumulate this dataflow's fan-out; only the count now sees the collection first. The materialized view sink already normalized after its logging, so its counts were never affected. Also names both export collapses `Distinct errors`, matching the per-binding collapse: an operator name identifies an implementation, not an instantiation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GbtepuqcxmAffaM2qXZ4uA
The divergence that argued for shipping dark is closed: boundary normalization makes error multiplicity independent of this flag in everything durable, so replicas rendering under different values write the same thing. What remains is that a runtime flip, not a default change, is the path that leaves two live replicas rendering differently, and a default arrives with a new binary that every replica is recreated on. Allowlists the compiled-on / LaunchDarkly-off divergence until production is flipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GbtepuqcxmAffaM2qXZ4uA
…ions Adds the `Distinct errors` operator and its channels. The `LogDataflowErrorsStream -> Distinct errors` edge is the ordering that keeps error counts reporting the number of failing rows: the collapse reads the logged stream, never the reverse. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GbtepuqcxmAffaM2qXZ4uA
|
There are some scary errors in CI:
|
Putting a reduce between an arrangement and its index export crashes system dataflows with `cursor_through`: `upper` straddles batch. The backtrace reads `batches_through` through two trace wrappers, so the reader is an imported `TraceEnter<TraceFrontier<..>>`, and the crashing process is a `mz_catalog_server` replica: a linear join in a system dataflow reading an imported index. An exported `TraceBundle`'s two traces are consumed as if they progress together, and `output_probe` watches the ok stream alone, so an extra operator on the error path leaves the pair structurally asymmetric. The collapse also cost memory without buying anything where errors carry distinct payloads. `introspection-sources` indexes a view casting 10000 negative values to `uint2`, every row failing with the value in its message, so the reduce rewrote 10000 distinct errors into a second arrangement of 10000: records 10000 -> 20000, size 0 -> 1 MiB. Sharing across objects is unbounded again as a result, so the indexed-view chain that covered it goes too. Bounding it has to happen where a dataflow imports a trace, not where one exports it. The persist sink keeps its collapse: it normalizes a collection on its way into a shard, with no trace and no importer reading batches through it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GbtepuqcxmAffaM2qXZ4uA
Two families, both measured by running these files rather than inferred from a CI excerpt. The per-binding collapse adds `Arrange errors` and `Distinct errors` to any dataflow with a shared binding, so `materializations`, `monotonic`, `kafka-avro-upsert-sinks` gain those rows. `monotonic` also gains `m1`, a dataflow the old expectation did not list at all, and `materializations`' test7 gains `Arrange bundle err` and `Distinct errors[[]]`. `idx2_div_by_zero` indexes the materialized view rather than the view, so its dataflow reads the shard the sink collapse normalized and reports one error however many rows failed. Every block moves to 1, not only the ones CI reached before bailing. The sibling indexes on the plain view keep their row counts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GbtepuqcxmAffaM2qXZ4uA
The note claimed `output_probe` watching only `oks.stream` was the reason, which the measurements contradict: probing `errs.stream` too still panicked, and so did building the reduce and throwing its output away while exporting the original trace. `cursor_through` is also never reached from the peek path in compute, only from `mz_join_core` in an importing dataflow. What the code supports instead is trace-handle sharing. `reduce` holds `set_physical_compaction` at its own lagging progress, that pins the shared spine's physical frontier, `ArrangementManager::maintenance` can no longer advance it, and batches accumulate in `Spine::pending`, where `cursor_through` applies a straddle check it does not apply to batches already merged. A stream-level reader never registers a handle, which is why `as_collection` passes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
@ggevay The cross-object half you found is unfixed again. I had it working via a collapse at both index exports, but that collapse crashed 19 CI jobs, so it is reverted in 528abd9. Filed as CPU-209, because the blocker turned out to be a general hazard rather than anything error-specific. Summary here so the PR carries it. Symptom, in dataflows importing an index and unrelated to the erroring plan: Four variants, each a full local
Row 3 is the one that decides it: merely constructing the reader panics, even when its output goes nowhere and the trace that gets exported is the original. So this is not about the collapse, the error values, or the ok/error frontier desynchronization I first assumed (row 2 rules the probe out). Mechanism, read off the code rather than instrumented. So the invariant is: an arrangement registered with What that leaves: multiplicity crossing an index boundary is unbounded, materialized views are covered by the sink normalization, and the 64-level indexed-view test came out with the revert. Keeping it in proportion, object-graph path counts for the reported incidents top out at 8 against roughly 7e30 within one dataflow, so I would rather land the intra-dataflow fix and do the index side properly under CPU-209 than hold this. The safe shape looks like collapsing in stream form and re-arranging, given that row 4 passes, at the cost of an extra arrangement. Say the word if you would rather it were one PR. |
|
Thank you, LGTM, seems ok to me to postpone https://linear.app/materializeinc/issue/CPU-209/a-second-trace-reader-on-an-exported-arrangement-panics-importers-in Edit: From Claude: You could reference CPU-209 in the export_index NOTE so future readers find the tracker, and attach the removed 64-level indexed-view test to CPU-209 so the eventual fix lands with its regression test. |
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Both done.
Your indexed-view file is on CPU-209, credited, with a link back to the review comment holding the verbatim version. I put it there as a generator rather than 413 pasted lines, since the levels are mechanical, and noted the one rename it needs ( One more thing surfaced while checking whether this PR covers neighbouring issues: it does not fix CPU-74, self-linear-joins double-counting errors. Measured on a build of this branch, Waiting on CI for the two comment-only commits, then this is ready from my side. |
|
Thanks for the thorough reviews! Much appreciated |
|
@antiguru Can you check if this makes sense? I'll be out in a few minutes, but this looks serious enough to potentially flag: 1. MEDIUM -- Error collapse rewrites negative accumulations to
|
|
@ggevay The delta-join test I owed you is tracked as CPU-212, so it does not disappear now that this is merged. It records that the no-gate decision rests on a test that does not exist yet, and carries what is needed to write it: the three read shapes (raw source, local arrangement, imported trace), the two error origins (errors in the collection versus errors from forming the key, which live on different streams), your |
Based on https://buildkite.com/materialize/nightly/builds/18140 Test run: https://buildkite.com/materialize/nightly/builds/18141 Follow-ups to #38196 and #38018 I haven't checked why the benchmarks regressed. --------- Co-authored-by: Moritz Hoffmann <antiguru@gmail.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Motivation
Closes: CPU-204
Follow-up: CPU-209
An error collection carries a multiplicity almost nothing reads. A dataflow is in
an error state if its error collection is non-empty, and the error surfaced to the
user is chosen arbitrarily, so an error's diff conveys nothing about the query's
answer. Several rendering patterns nonetheless grow that diff multiplicatively
rather than additively, and because every error in a dataflow consolidates onto
one of a handful of distinct error values, those diffs pile onto single records
and reach
Diffoverflow on plans of quite ordinary size.The reported shape is plan-level sharing: a chain of diamond-shaped CTEs over a
dataflow that has any
EvalError. The asymmetry that makes this an error-onlyproblem is worth stating plainly, since it explains why the ok side of the same
plan is fine: the branches of a diamond compute different ok rows, so ok
multiplicity does not compound, but they propagate the identical upstream
error collection, so error multiplicity doubles at every reconvergence.
Description
Three multipliers. Two are fixed here, the third only for materialized views.
Delta join, N copies per join.
render_delta_joinpropagated each input'spre-existing errors once per delta path:
build_update_streamconcatenated thesource's errors and every stage's
build_halfjoinconcatenated a lookuparrangement's errors, inside the per-path region. With N inputs that is N copies
of every input's errors assembled over N² concat edges, and since a join's
output is another join's input those factors compound through a nested plan.
Input error collections are now gathered once, outside the path loop, and the
two helpers return only the errors they themselves produce. No operator added,
no semantic change: the same union of streams, formed once instead of N times.
Sharing within a dataflow, f copies per level. Every reader of a binding
propagates that binding's errors independently, so a binding read f times
contributes its errors f times, and the factors apply again at each further
level of sharing.
CollectionBundle::distinct_errscollapses a binding's errormultiplicities to one, applied at every
Letand rec binding definition. This isthe multiplier the reported incidents were made of, and the one the regression
test drives to overflow.
Sharing across objects, f copies per level. A level of sharing expressed as
an indexed view rather than a CTE reads
Get(Global), which a binding collapsenever sees, so the importing dataflow accumulates the exported diffs. Only half
of this is closed here, at the persist sink, whose shard another dataflow reads
back verbatim and whose contents are durable. Subscribes and one-shot copies are
read once by a client and never re-imported, so they do not pay for the
arrangement. The sink normalizes after the null assertions, whose errors follow
the ok row's multiplicity rather than the error collection's.
The index half is not fixed, and CPU-209 records why: collapsing at the
export gives the exported arrangement a second trace reader, which pins the
shared spine's physical frontier and makes importing dataflows panic in
cursor_throughwithupperstraddles batch. That was measured four ways,including a variant that builds the reduce, discards its output, and exports the
original trace, which panics too. So the fault is the reader's existence, not
its output or the frontier probe. Cross-object multiplicity through indexes
therefore stays unbounded. In proportion: object-graph path counts for the
reported incidents top out at 8, against roughly 7×10^30 within one dataflow, so
this half is reachable synthetically but is not what those incidents were.
Two decisions the diff cannot explain:
Why a reduce and not arithmetic on the diffs. Saturating the add, or
collapsing to a sign, looks cheaper and does not work: neither is additive, so
neither cancels when the errors retract, and a partially-retracted error would
persist forever as a phantom. Bounding multiplicity requires reading the
accumulated collection, which means a reduce over an arrangement.
LetRecalready collapses its error variable this way and for this reason.
Why every representation in a bundle is collapsed, not just one. A bundle's
raw collection and each of its arrangements carry their own independent error
stream, and they are not even the same content: an arrangement's errors include
the key-formation errors that the raw collection's do not. Which form a consumer
reads is the consumer's choice, and a delta join reads both in one operator, so
the binding definition cannot know that choice.
Behavior and rollout
enable_compute_error_distinctgates the per-binding collapse, and now defaultson. The divergence that argued for shipping dark is closed: the sink
normalization is ungated, so nothing this flag decides reaches durable state and
two replicas rendering under different values write the same thing. A default
change also arrives with a new binary that every replica is recreated on, whereas
a runtime flip is what leaves two live replicas rendering differently. Production
LaunchDarkly still serves off, so the flag is allowlisted in
launchdarkly-flag-consistencyuntil that is reconciled.Two parts are deliberately not gated, so "behavior is unchanged with the flag
off" would be wrong:
set of errors and strictly reduces copies, and the failure mode worth guarding
against is
bundle_errsmissing a form and silently dropping a dataflow'serrors, which a test catches and a flag does not.
durable content of a materialized view's shard dependent on the flag.
Error counts change, in two places.
mz_compute_error_counts_raw_unifiedsumserror diffs, so a diamond over one failing row previously reported 8 and now
reports 1; unshared plans are unaffected. And anything reading a materialized
view's shard now sees one error per distinct error rather than one per failing
row, which is what the
idx2_div_by_zerogolden moves record. Neither number ispromised: an object is tainted by having any error at all, and that is the part
that is preserved. The Console does surface these counts, so the change is
visible.
Verification
test/sqllogictest/error_semantics.sltgains a 70-level diamond CTE chain overan erroring expression, asserting the error still comes back, where an overflow
is a panic CI fails on. Measured both directions:
Overflow: 4611686018427387904 + 4611686018427387904Full file with the flag on: 153/153. With the flag off it panics in
consolidate_updates_slice_slowoverDataflowErrorSerand the replica dies.unionrather thanunion allis what makes the chain reach the overflow: thedistinct arranges the error collection so duplicates consolidate into one record
with a doubling diff. Under
union allthe records duplicate physically insteadand the query exhausts memory, 34 GB with no completion, without ever
overflowing.
A cross-object chain of indexed views is deliberately not in the suite, since
nothing here bounds it. It belongs with CPU-209.
Thanks to @ggevay for measuring that the first version of that test did not
reproduce the bug at all, for the
union alltouniondiagnosis, for findingthat the cross-object half was still unfixed, and for the reference-count
measurement that removed a gate this PR did not need; and to @def- for the
mzcompose workflow showing two replicas of one cluster correcting each other's
writes to an errored materialized view's shard indefinitely, which the sink
normalization closes.
Operator cost, measured on
mz_catalog_serverin an empty environment:+1,014 dataflow operators (+6.4%) with the flag on, measured before the sink
normalization was added.
🤖 Generated with Claude Code
https://claude.ai/code/session_01GbtepuqcxmAffaM2qXZ4uA