Skip to content

sql: add WITH (IGNORE ERRORS) to SELECT and SUBSCRIBE - #38916

Open
antiguru wants to merge 6 commits into
MaterializeInc:mainfrom
antiguru:ignore-errors-design
Open

antiguru wants to merge 6 commits into
MaterializeInc:mainfrom
antiguru:ignore-errors-design

Conversation

@antiguru

Copy link
Copy Markdown
Member

A source can write a definite error into its persist shard, and nothing ever writes the compensating retraction. Every later read returns that error instead of data, so the collection is poisoned permanently. Kafka does this when the topic does not exist, when compaction has advanced the low watermark past the resume upper, and when the topic is recreated or deleted underneath a running source. The only escape today is to drop the source and recreate it, paying a full re-ingest, even when the user only wants to look at the rows that did land before deciding whether to rebuild.

Storage already names the intended repair and provides no way to take it: a definite error passing through the ingestion pipeline carries the health hint retracting the errored value may resume the source, but no user-facing mechanism performs that retraction.

This adds a statement-level option that discards the statement's error collection and answers from the ok rows instead:

SELECT a FROM t ORDER BY a LIMIT 10 WITH (IGNORE ERRORS);
SUBSCRIBE t WITH (IGNORE ERRORS);

It is gated behind the enable_ignore_errors feature flag, which defaults off in production and on in the test configuration.

Scope

The option is confined to ad-hoc reads. It attaches to SelectStatement rather than to Query, so CREATE VIEW, CREATE MATERIALIZED VIEW, CREATE INDEX and INSERT ... SELECT have no position in which it could appear, and no durable object can be defined in terms of the weakened guarantees. This mirrors the existing restriction on AS OF. A WITH (...) list rather than a trailing keyword keeps IGNORE usable as a bare table alias, which a trailing form would break because IGNORE is not reserved in table-alias position.

Statement-level scope means the option requires nothing of the optimizer, which is the trade this design makes. The 2024 error-handling design proposed a per-relation form and restricted it to sources and subsources precisely to keep a guarantee that a decode error omits only the record that failed. That form needs the optimizer to treat the annotated Get as a barrier. This design drops the guarantee instead. The design document records why, along with the alternatives that were rejected, including filtering by error variant, which would make the variant assignment a compatibility surface because query results would then depend on how an error is classified.

Reporting

Reporting is required rather than optional, because a statement that silently returns a degraded answer is a worse failure than the error it replaces: the caller cannot distinguish it from a clean one. The server emits a warning carrying one discarded error.

The notice carries no count. Every read path stops at the first error it meets, so counting would turn a bounded probe into a full walk of the error trace on every execution, and the resulting number would not mean affected rows in any case: compute collapses error multiplicities per binding during rendering, and a single source error poisons a whole collection with no row correspondence at all.

Protocol

The change is asymmetric. Outbound is a boolean on Peek and SubscribeSinkConnection. The return direction needs a new shape, because both response types encode rows exclusive-or an error while this option produces rows together with an error. PeekResponse::Rows becomes a struct variant carrying the retained error and StashedPeekResponse gains the same field; merging across workers keeps any one of them, since a retained error is a sample rather than a tally. For subscribe, the Err arm of SubscribeBatch.updates is the poison channel this option removes, so the batch gains a separate field. None of these structures is durable.

Notice delivery

A peek learns what it discarded only as its rows stream, which is after the connection loop has already drained pending notices for that statement. The notice therefore arrived with the next statement, or was lost entirely when the session ended after a single query, which is the interactive case the feature exists for. ready() now drains pending notices immediately before ReadyForQuery.

That commit changes behavior shared by every notice, not only this feature's: a notice queued during row streaming now arrives with the statement that raised it instead of the next one. It is worth a careful look, and is kept as its own commit for that reason.

Tests

Adds test/sqllogictest/ignore_errors.slt, covering the feature gate, a clean query under the option, an error raised by the statement's own expressions, per-row errors where the rows that did not error are still returned, rejection on every statement that defines a catalog item and on a subquery, and that IGNORE still parses as a bare table alias. Extends the parser datadriven tests in src/sql-parser/tests/testdata/select and ddl with the new syntax, including its interaction with ORDER BY, LIMIT, AS OF and a leading CTE.

Release note

This release adds a WITH (IGNORE ERRORS) option to SELECT and SUBSCRIBE, which returns the rows of a collection whose errors would otherwise fail the query, together with a warning naming one discarded error. The rows carry no correctness guarantee while errors are present, so the option is intended for inspecting a collection that a definite error has made unreadable, not for routine querying. The option cannot be used in a view, materialized view, index, or INSERT ... SELECT.

🤖 Generated with Claude Code

antiguru and others added 6 commits September 17, 2026 18:06
A source that writes a definite error into its persist shard poisons the
collection permanently, because nothing ever writes the compensating
retraction. The only escape today is to drop the source and recreate it,
paying a full re-ingest. Storage already names the intended repair in a
health hint, but no user-facing mechanism performs it.

This design adds a statement-level `WITH (IGNORE ERRORS)` option to
`SELECT` and `SUBSCRIBE` that discards the statement's error collection
and reports one discarded error as a notice. Scope is limited to ad-hoc
reads so that no durable object can be defined in terms of the weakened
guarantees, and the option requires nothing of the optimizer.

The document revisits the per-relation form proposed and rejected in
20240609_error_handling.md, and records why statement-level scope,
drop-all semantics, and mandatory reporting were chosen over per-relation
scope, filtering by error variant, and write-time retraction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Add a statement-level `WITH (IGNORE ERRORS)` option to `SELECT` and
`SUBSCRIBE`, gated behind the `enable_ignore_errors` feature flag. The
option is carried to `SelectPlan` and `SubscribePlan` and is not yet
honored during execution.

The option attaches to `SelectStatement` rather than to `Query`, so the
statements that define a catalog item cannot carry it: `CREATE VIEW`,
`CREATE MATERIALIZED VIEW`, `CREATE INDEX` and `INSERT ... SELECT` all
parse a `Query` and have no position in which the option could appear.
This mirrors the existing restriction on `AS OF`.

A `WITH (...)` list rather than a trailing keyword keeps `IGNORE` usable
as a bare table alias, which a trailing form would break because `IGNORE`
is not reserved in table-alias position.

A side-effecting function is planned outside the normal query path, which
has no error collection to discard, so the option is rejected there
rather than silently dropped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Carry the option to the replica on `Peek`, skip the errors a peek meets
instead of answering with them, and report one skipped error to the
client as a warning.

Each read path drops errors where it previously surfaced them. The index
walk continues past an error rather than returning it, the persist fast
path skips the row, and constant folding returns an empty result. A
row-iteration limit still fails the peek, because it is a resource limit
rather than an error in the data.

The return direction needs a shape change, because `PeekResponse`
separates `Rows` from `Error` while this option produces rows together
with an error. `Rows` becomes a struct variant carrying the retained
error, and `StashedPeekResponse` gains the same field. Merging across
workers keeps any one of them: a retained error is a sample rather than a
tally, since collapsing multiplicities and collection-wide errors both
leave a count meaning something other than affected rows.

The notice reaches the session through the notice channel, which the peek
response stream holds because it runs without a session of its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Carry the option to the sink, which discards errors and keeps streaming
rows instead of poisoning the subscribe, and report the first discarded
error to the client as a warning.

The poison channel is `SubscribeBatch.updates`, whose `Err` arm is what
this option removes, so a discarded error has no slot to travel in and
the batch gains a field for it. Compute sets it on the one batch that
discarded the error and leaves it empty afterwards, so a stream that
errors continuously reports once rather than on every batch. The
controller keeps whichever worker's error arrives first when merging.

Also fix the side-effecting function guard, which rejected the option
before deciding whether the statement was such a call at all, so
`SELECT 1 / 0 WITH (IGNORE ERRORS)` was refused instead of planned. The
check now runs once the call is identified. The frontend peek path has a
constant-folding branch of its own, which needed the same handling as the
one in the coordinator.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A statement can queue a notice while its rows stream, which happens after
the drain at the top of the connection loop has already run for that
statement. Such a notice was delivered with the next statement instead,
and a session that ended after a single query dropped it entirely.

Draining immediately before `ReadyForQuery` keeps the notice with the
statement that raised it. `SELECT ... WITH (IGNORE ERRORS)` is the first
caller to depend on this: it learns what it discarded only as its rows
stream, and the notice is the only signal that the answer is degraded.

Also record in the design document what the implementation settled: the
option's position relative to `ORDER BY` and `AS OF`, that a per-row
error omits only the rows that raised it, that both peek paths fold
constants and each needs the branch, and the notice ordering above.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four independent failures, all consequences of the new statement option.

The sqllogictest file is exempt from `--auto-index-selects`. That mode
wraps a successful `SELECT` in an indexed view and compares outcomes, but
the option is rejected on a view by design, so every query carrying it
reports an inconsistent outcome. The file is added to `tests_without_views`
and `tests_no_auto_index_selects`.

The pretty printer dropped the option, so a statement carrying it did not
survive the parse, print, reparse round trip that `mz-sql-pretty` asserts.
It now renders the option list in the same position `AstDisplay` does.

`ComputeResponse` grew from 112 to 120 bytes. The subscribe variant is the
largest, so the error a subscribe discards lands on the whole enum. Boxing
the field does not avoid this, because the variant pads to the same width
either way, so the assertion is updated and the cause recorded next to it.

The doc comment on `SelectStatementOptionName` referred to
`SelectOptionName`, which lives in another module and did not resolve as an
intra-doc link.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@antiguru
antiguru marked this pull request as ready for review September 17, 2026 20:02
@antiguru
antiguru requested review from a team and ggevay as code owners September 17, 2026 20:02
@def-

def- commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

QA LLM Review

1. MEDIUM -- WITH (IGNORE ERRORS) is not honored on the persist fast path, so adding a LIMIT turns a working query into a failing one

src/compute/src/compute_state.rs:1847

PersistPeek::do_peek gained an ignore_errors guard on the shard's SourceData::Err entries (line 1816) but not on the two other ways it can produce an error: the MapFilterProject evaluation at line 1847 and the negative-multiplicity check at line 1832. A SELECT that routes to FastPathPlan::PeekPersist therefore still fails on an expression error even under the option, while the same query planned as a dataflow discards it. The design states the option "discards every error the statement would otherwise return" and that dropping "never depends on ... where it arose in the plan"; this makes it depend on the plan shape.

Details

Failure scenario, with default settings (persist_fast_path_order = false, persist_fast_path_limit = 25) and t containing a row with a = 0:

SELECT 1 / a FROM t WITH (IGNORE ERRORS);            -- returns the non-erroring rows
SELECT 1 / a FROM t LIMIT 10 WITH (IGNORE ERRORS);   -- ERROR: division by zero

The second form satisfies filters.is_empty() && finish_ok in create_fast_path_plan (src/adapter/src/coord/peek.rs:645: no WHERE, no ORDER BY, limit + offset < persist_fast_path_limit), so it becomes a PeekPersist whose MFP — including the fallible 1 / a — is evaluated inside do_peek. mfp_to_safe_plan only rejects temporal MFPs, not fallible ones, so this is a routine shape. test/sqllogictest/ignore_errors.slt covers exactly this behavior for the no-LIMIT case (SELECT 1 / a FROM t ORDER BY 1 WITH (IGNORE ERRORS) expecting 0, 1), and would not catch the fast-path divergence.

LIMIT is a natural thing to add when inspecting a poisoned collection, which is the case the feature exists for. The pure SELECT * FROM src LIMIT 10 WITH (IGNORE ERRORS) shape is fine (no fallible MFP), so the gap is confined to statements whose projection or filter can raise, but those are precisely what the sqllogictest advertises as supported.

Fix: give the two remaining error sites in do_peek the same treatment as line 1816 — on ignore_errors, record the error into ignored_error via get_or_insert_with and continue to the next entry instead of returning. For the negative-multiplicity case that also matches the index path, which under ignore_errors logs and steps past it (src/compute/src/compute_state/error_scan.rs:146) rather than failing.

Note while you are there: that error_scan.rs:146 arm skips the key without populating ignored_error, so an error trace holding only net-negative multiplicities yields rows with no warning at all. That contradicts the design's mandatory-reporting rule, though it takes corrupt trace data to reach, so it is LOW on its own.

@mgree
mgree self-requested a review September 17, 2026 21:20
@mgree

mgree commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

My agent's summary is below, but the main feedback is:

  • We should have testdrive tests for this, making sure to exercise SUBSCRIBE and the persist fast path (as @def-'s 🤖 suggested).
  • We should have some AWS test exercise the COPY TO S3 path.
Details Blocking
  1. The feature flag is not on in CI. The PR body and design doc both say the CI default is set in get_default_system_parameters, but misc/python/materialize/mzcompose/init.py is untouched and the slt test has to ALTER SYSTEM SET enable_ignore_errors = true itself. CLAUDE.md requires new flags default on in the test config so the path is exercised by sqllogictest, testdrive, and the parallel workload. Add it to the defaults dict.
  2. COPY (SELECT ... WITH (IGNORE ERRORS)) TO 's3://...' silently drops the option. plan_copy routes the S3 branch through plan_select_inner (src/sql/src/plan/statement/dml.rs:2247), so the flag check passes and CopyToPlan.select_plan.ignore_errors is true, but sequence_copy_to and the copy-to optimizer never read it. The sink dataflow will still fail on errors while the user believes they opted out. Reject it there the way side_effecting_func.rs does, or wire it through the copy-to sink. COPY ... TO STDOUT is fine since it takes the peek path.
  3. No test exercises SUBSCRIBE, the persist fast path, or the warning itself. The slt covers the index/dataflow peek path only. Untested: sink/subscribe.rs, the PendingSubscribe merge in service.rs, ActiveSubscribe::process_response, PersistPeek skipping error rows, and every merge_ignored_errors arm. Nothing asserts a NoticeResponse is ever emitted, which the design calls "required, not optional". Suggested minimum:
    • slt DECLARE c CURSOR FOR SUBSCRIBE t WITH (IGNORE ERRORS) + FETCH over a table with a per-row error.
    • A pgtest asserting the warning arrives before ReadyForQuery for a SELECT. This also gives the pgwire commit its test.
    • A testdrive case on the motivating scenario. The design doc itself points at test/testdrive/kafka-recreate-topic.td as a ready fixture for the poisoned-source persist path.
    • Unit tests for ErrorScan with ignore_errors = true (the helper in error_scan/tests.rs hardcodes false).

Strong suggestions

  1. ErrorScan does a full walk under ignore_errors when it only needs one sample. error_scan.rs:143-158 keeps stepping after recording the first error, charging fuel and the row-iteration budget per key. A large error trace can now fail with RowIterationLimitExceeded, which the option cannot suppress. Since the retained error is explicitly a sample, break with Finished(Ok(..)) right after get_or_insert_with. That also makes the PR body's "every read path stops at the first error it meets" true again. The design doc contradicts itself here: the Reporting section says paths stop at the first error, the Implementation section admits the index walk becomes a full walk.
  2. The subscribe notice can fire once per worker, not once. SubscribeProtocol.reported_ignored_error is per worker. PendingSubscribe.ignored_error keeps the first and take()s it on emit, so a later worker's report refills it and ships on the next batch, and ActiveSubscribe::process_response sends a notice each time. Comments in service.rs:522 and response.rs:400 claim "once". Either add a reported flag on ActiveSubscribe or on PendingSubscribe, or soften the comments.
  3. Negative multiplicities are swallowed with no sample. In error_scan.rs:143, an ignore_errors walk that meets only retraction-without-insertion keys logs at error! and returns rows with ignored_error: None, so the client gets no warning for a corrupt trace. Record it into ignored_error too.
  4. Split the pgwire commit into its own PR. It changes delivery timing for every notice queued during row streaming, has no test, and is independently mergeable. The PR body already flags it as needing a careful look.

Nits

  • SubscribeBatch.ignored_error doc says "updates is an Ok when this is set", but service.rs:325 emits Err(text) (result-size overflow) with ignored_error: Some.
  • notice_tx: tokio::sync::mpsc::UnboundedSendercrate::AdapterNotice is spelled fully qualified in command.rs, peek.rs, peek_client.rs. Import it.
  • The constant-fold Err(e) if ignore_errors arm is duplicated in coord/peek.rs:729 and peek_client.rs:404. Existing duplication pattern, but a small helper on AdapterNotice would keep the two in sync.
  • PersistPeek::do_peek now returns Result<(Vec<_>, Option), PeekError>. A small named struct would read better than the tuple.
  • Design doc: fix the CI-default claim and the stop-at-first-error claim per items 1 and 4. Otherwise well argued, and the rejected alternatives are worth keeping.

Verdict

The parser, planner, and protocol shape are clean, the statement-level scoping is well justified, and the comments are of the right density. Blocked on the CI default, the COPY TO S3 silent drop, and the missing SUBSCRIBE/notice coverage. With those addressed this improves codebase health and I would approve.

@mgree mgree left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The problem with this approach is that it ignores errors in the source (good, in moderation) but also errors in the computation (bad).

I think we want something with a slightly different shape: make it possible to push errors from a source into the data plane (CREATE TABLE FROM SOURCE ... WITH ERROR TABLE ...), where the "error table" is essentially a dead-letter queue.

We'll need tools to manage the error table/DLQ, and in the fullness of time we'll need tools for managing the error stream of any dataflow. We might get away at the beginning by only allowing ad-hoc selects into the error table/DLQ. Management may be as simple as allowing DELETEs in an error table.

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.

3 participants