Persist pushdown hardening - #38110
Merged
Merged
Conversation
This was referenced Aug 7, 2026
def-
marked this pull request as ready for review
August 7, 2026 11:10
def-
force-pushed
the
persist-pushdown-hardening
branch
from
August 12, 2026 22:52
2cf0894 to
e12ec91
Compare
def-
force-pushed
the
persist-pushdown-hardening
branch
from
August 13, 2026 06:52
e12ec91 to
049f147
Compare
def-
force-pushed
the
persist-pushdown-hardening
branch
from
August 13, 2026 06:55
049f147 to
61bfe82
Compare
DAlperin
reviewed
Aug 13, 2026
DAlperin
left a comment
Member
There was a problem hiding this comment.
One Note, but otherwise looking good. Thanks for the proptests and fuzz target
def-
force-pushed
the
persist-pushdown-hardening
branch
3 times, most recently
from
August 13, 2026 21:45
1394c7d to
55aa3ec
Compare
def-
force-pushed
the
persist-pushdown-hardening
branch
2 times, most recently
from
August 16, 2026 05:51
2aaf64b to
8436652
Compare
DAlperin
approved these changes
Aug 18, 2026
The Time, Timestamp, TimestampTz, Interval, and Uuid arms of col_values matched any FixedSize stats. from_bytes validates length only, and PackedNaiveDateTime, PackedInterval, and Uuid are all 16 bytes, so wrong-kind bytes decoded silently into garbage bounds. Not reachable today because a column's type cannot change under a reused name, but one schema evolution feature away from wrong bounds on old parts. Mismatched kinds now fall through to the catch-all arm and degrade to no stats. Also soften the Timestamp arm's two hard expects on the roundtrip through CheckedTimestamp to match the TimestampTz arm: malformed bytes should degrade, not panic the replica. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q5GiWioZM6CWrwWgfBADDR
The legacy Atomic arm of col_values used four hard expects on ProtoDatum decode: malformed V0 stats bytes panicked the replica at stats read time. It also never validated the decoded datum against the column type, and the V0 encoding carries no type tag, so a wrong-typed bound produced a range that excludes every value of the column's actual type, i.e. wrong results from corrupt legacy stats. Decode failures and type mismatches now degrade to no stats via soft errors. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q5GiWioZM6CWrwWgfBADDR
may_match_mfp treated an absent err column in the part stats as "no errors", while the storage read path's filter_result treats it as "may error". The err column is on the default force-keep list for stats trimming, but that list is configurable, so a trimmed part would have let the peek path (StatsCursor uses may_match_mfp) skip a part whose error rows must surface regardless of any filter. Align with filter_result and keep the part. The regression test builds a part with an error row, strips the err column's stats, and applies a filter no Ok row matches: the old code skipped the part. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q5GiWioZM6CWrwWgfBADDR
The shard_source filter and the stats accessors in fetch called LazyPartStats::decode, which panics on undecodable bytes, on stats that other processes wrote. Stats from a newer version can use a proto variant an older reader does not know, so a reader inside the upgrade window panicked in the pushdown filter instead of falling back to fetching the part, the fail-open behavior every other missing-stats case on this path already has. Decode failures now degrade to "no stats". Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q5GiWioZM6CWrwWgfBADDR
ok_count had a hard expect on the err column's stats shape: corrupt or version-skewed durable stats panicked the replica at pushdown time. An unknown err count already fails open (callers keep the part), so degrade to that instead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q5GiWioZM6CWrwWgfBADDR
apply_demand filters metadata by raw ColumnIndex but types by position, which only agree on dense descs. Assert that, so a future dropped-column desc fails loudly instead of attaching statistics and filter specs to the wrong columns. Document the two other invariants the residual-risk review found load-bearing but unwritten: name-keyed part stats, and diffs_sum substitution requiring registered batch bounds to match the blob. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q5GiWioZM6CWrwWgfBADDR
The pushdown audit fired only when the MFP produced output on an Ok row. Error rows from an audited part were emitted with no check, so a part discarded because its err stats undercount, the one violation class the err-count guard exists for, passed the audit silently. Mirror the audit into the error arm. No deterministic test exists for this: the audit only fires on an actual violation, which requires a live stats bug to construct through the runtime operator. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q5GiWioZM6CWrwWgfBADDR
Corpus: add -NaN and -0.0 floats, jsonb maps and lists, truncation-edge strings, and a leap-second time to interesting_datums. Containment: check that stats-derived specs contain every datum for every scalar type, over datum pairs and full sets, after trim and trim_to_budget at every budget. End to end: wide-schema and multi-part audit proptests over real part stats with mz_now bounds and error rows, plus zero-column ReplaceWith and schema-drift cases, and regression tests for both PER-53 variants in pushdown.slt. Interpreter: grow the equivalence proptest vocabulary to 18 scalar types and ~150 declared-monotone functions, validating their monotonicity claims continuously. PROPTEST_CASES now overrides the built-in case counts for long runs, and a new coverage-guided pushdown_soundness cargo-fuzz target explores raw bit patterns beyond the corpus with the same soundness oracle. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q5GiWioZM6CWrwWgfBADDR
range_lower maps empty and unbounded-lower ranges to NULL, which the interpreter's endpoint box cannot represent. The claim is sound only because those inputs form a downward-closed prefix of the range ordering, so valued endpoints imply no NULL-yielding interior. Record that argument at the declaration and pin the SQL results, verified empirically (range columns also collect no statistics today, so pushdown cannot prune on them). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q5GiWioZM6CWrwWgfBADDR
err_count subtracted the ok count from the part length unchecked: err stats whose none count exceeds the part length (corrupt or version-skewed durable state, the same class the previous hardening covers) underflowed and panicked the replica. Report the err count as unknown instead, which callers treat as may-error. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q5GiWioZM6CWrwWgfBADDR
Finish the "fail open on undecodable part stats" change. `LazyPartStats` loses its panicking `decode`, so no read path can reintroduce the panic: the fast-path peek filter keeps the part, `EXPLAIN FILTER PUSHDOWN` reports it as selected, and inspect-state serializes the undecodable marker its `Debug` impl already renders. All three ran on stats straight off durable state, so version skew or corruption panicked clusterd and environmentd respectively. Extend that to the 'ok' column shape checks in `RelationPartStats`, which panicked on a wrong-shaped column on the very call stack the peek filter runs. A mismatch now counts in `mismatched_count` and reports the column range as unknown. Use the whole-second leap time as the interesting `TIME` datum. `from_hms_micro_opt(23, 59, 59, 1_999_999)` is a fractional leap second encoding to 86_400_999_999 microseconds, outside PostgreSQL's [0, 86_400_000_000] `time` domain, where a `postgres-types` client wraps it to 00:00:00.999999. Since `interesting_datums` also feeds the DATUMS load generator, that value reached a user-visible table. It is unreachable from SQL besides, which falsified the premise for keeping the leap representation in `TIME` at all. `TIME '23:59:60'` exercises the same `frac >= 1e9` path and encodes to exactly PostgreSQL's bound. Redact the `DataflowError` in the new error-row audit arm: those events go to Sentry, and `DecodeError` carries the raw source record bytes. Also drop the duplicated `value_between` doc summary line, document that the fuzz runner's interrupted classification treats a kernel OOM SIGKILL as interrupted too, relying on libFuzzer's rss limit to catch memory blowups as artifact-producing OOMs first, and replace the clippy-disallowed `prop_oneof!` and `Iterator::zip` in the new proptest strategies with `Union` and `zip_eq`. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
def-
force-pushed
the
persist-pushdown-hardening
branch
from
August 18, 2026 20:27
8436652 to
906adc7
Compare
def-
enabled auto-merge (squash)
August 18, 2026 20:27
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Motivation
Filter pushdown lets Materialize skip persist parts whose statistics prove no row can match a query's filter. A wrong skip is a silently wrong result, and a stats shape the decoder does not expect is a replica panic. This PR hardens the pipeline at every layer, stats collection, stats-to-range conversion, the interpreter's monotonicity contracts, and the runtime audit, and adds the test coverage that found the bugs.
Description
apply_demandrelies on, and document the name-keyed-stats and diffs_sum invariants along with whyrange_lower's monotone claim survives its NULLs.mz_nowbounds and error rows, interpreter monotonicity vocabulary grown to ~150 declared-monotone functions, a coverage-guidedpushdown_soundnesscargo-fuzz target, and slt regressions for every SQL-reachable fix.--.Verification
Every fix has a deterministic test verified red before the fix, unit or slt, except the audit error-row arm, where a test would require a live stats bug (explained in its commit message). Soaks: 16k-case interpreter equivalence runs over the full vocabulary, 131k-case wide e2e proptest, and a 160M-execution coverage-guided fuzz run, all clean. An analytic per-function proof pass of every
is_monotonedeclaration backs the vocabulary.Nightly run: https://buildkite.com/materialize/nightly/builds/18081