Skip to content

feat(sql): run a Parquet table beside a signal table - #3107

Merged
pmoust merged 1 commit into
mainfrom
feat/2914-parquet-beside-signal
Oct 19, 2026
Merged

pmoust merged 1 commit into
mainfrom
feat/2914-parquet-beside-signal

Conversation

@pmoust

@pmoust pmoust commented Oct 19, 2026

Copy link
Copy Markdown
Member

T7 of epic #2904 (ADR-2904 decision 11, plus the paging rule from decision 9). A SQL statement that named a Parquet table beside one of samples, logs, spans, alerts and audit was refused with SqlError::SignalBesideParquet. It now runs on POST /api/v1/sql and Flight SQL alike. ADR-2904 rejected alternative G calls for one rule per statement text.

What changed

  • Resolve. A mixed statement resolves the Parquet tables first and then each signal lane, as before.
    • The cost estimate is the Parquet estimate plus each lane's.
    • Each lane's resolve-time max_s3_requests check counts the Parquet resolve reads.
    • Signal-only and Parquet-only statements take their old paths, with the same read counts.
    • SqlError::SignalBesideParquet is removed.
  • Session.
    • The named signal tables are registered beside the Parquet tables. The session takes the Parquet store registry, read limits and parquet_session_config.
    • A signal-only session still gets no registry. A Parquet-only session registers no signal table, on HTTP or on Flight.
    • A test pins that the Parquet settings leave a signal scan's physical plan as it is.
  • Budgets.
    • ReadLimits (ravel-parquet) gains a budget view. When the statement also reads a signal table, the Parquet reader checks each read against the statement's total requests and bytes, and still records the read on the Parquet handle.
    • Three checks see the statement total: the lanes' resolve-time request check, the Parquet reader's check before each read, and the samples scan's checkpoint.
    • The logs, spans, alerts and audit scans check neither budget while they read; the docs say so.
  • Tokens.
    • A min_commit_token bounds only the signal tables. A token qualified with a Parquet table's name is a 400.
    • Flight SQL now refuses a token on a Parquet-only statement at GetFlightInfo. It used to accept the token and ignore it; HTTP already refused it (decision 3).
  • Row window. Applies to the signal scans only. A Parquet-only statement with a row window is still refused.
  • Paging. page_target sees non-signal names, so a signal table beside any other table returns PagePlanError::CrossSignal. One signal row can join many rows of another table, so its row identity is not a total order over the join. Parquet-only statements keep the NoTable outcome.
  • Flight SQL. Tickets pin the Parquet manifests beside the signal snapshots, flat or grouped, in their existing layouts; there is no new version. Slice tickets still carry no Parquet table.
  • Known cost, not a correctness change. Beside a Parquet table, the samples lane resolves without __name__ postings pruning: the planning session that derives it cannot plan a Parquet table before its footers are read. A samples filter on __name__ reads every segment in its window, and the rows are the same. The docs state this.

Docs

  • A new ADR-2040 amendment lifts the interim refusal; the earlier amendment is not edited in place.
  • The ADR-0033, ADR-0045 and ADR-1101 status sentences are updated.
  • docs/query-engine.md, docs/guides/query.md and README.md are updated.
  • The docs/review/claims.yaml entries now bind the new tests.
  • changelog.d/2914.added.md is new. The unreleased 2910.added.md fragment drops its "returns a 400" sentence.

Tests

All tests are in crates/ravel-sql/tests/parquet_tables.rs unless noted.

  • Exact rows:
    • a_parquet_table_joined_with_logs_returns_exact_rows, with read counts for the Parquet resolve plus the logs lane;
    • samples_joined_with_a_parquet_table_returns_exact_rows;
    • two_signal_tables_joined_with_a_parquet_table_return_exact_rows.
  • Registry and registration: a_signal_only_statement_gets_no_parquet_store_registry, and its mirror for Parquet-only statements, on HTTP and Flight.
  • Budgets:
    • a_mixed_statement_is_held_to_one_request_budget and a_mixed_statement_is_held_to_one_byte_budget;
    • a_lanes_resolve_budget_check_counts_the_parquet_resolve_reads;
    • a_mixed_estimate_is_the_parquet_estimate_plus_the_lanes.
  • Tokens: tests on HTTP and Flight. The Flight test asserts the exact CommitTokenOnParquetStatement text.
  • Other: the row window; an end-to-end stats sum (stats.signals plus the Parquet share equals stats.phases); and Flight mixed statements, including a Parquet replace and a signal commit between the two calls that DoGet does not see.
  • Paging: page_plan::tests::a_signal_table_beside_a_parquet_table_is_not_paged and a_parquet_only_join_still_reports_no_table.
  • Landing check with feat(sql): probe KEY_IDX for logs exact-key equalities #3091: a_logs_key_equality_beside_a_parquet_table_returns_exact_rows runs on the combined tree. It returns the same rows as the statement over a VALUES list, for a join and for COUNT(*) and MIN, and asserts that the KEY_IDX range GETs fired.

Each of these wrong implementations was shown to fail a test:

Wrong implementation Fails
A mixed resolve that plans Parquet only the exact-row joins
The Parquet read budget reading the Parquet handle the request and byte budget tests
page_target counting signal names only the paging test
A Parquet-only statement registering an empty samples provider the registration test
Flight accepting a token on a Parquet-only statement the Flight token test
The Parquet resolve moved after the lanes the resolve budget test (RequestBudgetExceeded { requests: 10, max: 6 }, expected 7)
The Parquet term dropped from the estimate the estimate test
An empty key-index declaration the KEY_IDX landing test

Verification

  • Checkpoint review. An adversarial review of the first result found no code bug. It blocked on four documentation claims (budget coverage, unpinned byte claims, plan sameness, unpinned resolve order and estimate), all fixed in the fix round with the tests above.
  • Fix round gate on base 37e63ae, x86_64 with 16 vCPU, each lane EXIT=0:
    • closure nextest, 7240 passed;
    • sql, 1814; flight-sql, 3544; esql, 1835;
    • bench sql-latency,profiling,flight-lane, 692; bench saturation, 399;
    • bench stage-timing; ravel-logseg and ravel-ingest stage-timing, 1115;
    • cli sql-explain, 743;
    • all 33 guards.
  • Landing round on this tree (rebased onto main 1b4d298, with feat(sql): probe KEY_IDX for logs exact-key equalities #3091). Each step exited 0:
    • fmt;
    • clippy for ravel-sql, flight-sql and server sql;
    • nextest for ravel-sql (1499), flight-sql (3571) and server sql (1830);
    • the struct-literals guard and check-doc-claims.py.

Known gaps, tracked elsewhere

Fixes: #2914

A SQL statement may now name Parquet tables beside signal tables on both
POST /api/v1/sql and Flight SQL, as ADR-2904 decision 11 allows. Such a
statement was refused with SqlError::SignalBesideParquet, which is removed.

The Parquet tables resolve first and the signal lanes then resolve on top of
those reads, so each lane's resolve-time max_s3_requests check counts the
Parquet resolve reads, and the cost estimate is the Parquet estimate plus
each lane's. Tests pin both: a budget one below the Parquet and logs resolve
reads together is refused by the logs lane's resolve check with that sum,
and a mixed estimate equals the Parquet-only estimate plus the logs-only
one. The session registers the named signal tables beside the Parquet
tables and takes the Parquet store registry, read limits and
parquet_session_config. A signal-only session still gets no registry and a
Parquet-only session registers no signal table. A test pins that those
settings leave a signal scan's physical plan as it is without the Parquet
tables. The samples lane does not plan the same, though: beside a Parquet
table it resolves without the __name__ postings pruning, because the
throwaway planning session cannot plan a Parquet table before its footers
are read, so a samples filter on __name__ reads every segment in its window
where alone it reads only those whose postings name the metric. The rows
are the same.

The Parquet reader now checks each read against the statement's total
requests and bytes through a budget view on ReadLimits when the statement
also reads a signal table, and still records the read on the Parquet
handle. Three checks see the statement total: the lanes' resolve-time
request check, the Parquet reader's request and byte check before each
Parquet read, and the samples scan's checkpoint. The logs, spans, alerts
and audit scans make no scan-time request check and no byte check. Tests
refuse the last Parquet GET of a logs aggregate joined with a Parquet table
under a request budget and under a byte budget each one below the
statement total.

A commit token bounds the signal tables only, and Flight SQL now refuses a
token on a Parquet-only statement at GetFlightInfo as HTTP does. A row
window filters the signal scans only. page_plan refuses a signal table
beside any other table, since one signal row can join many rows of another
table. Flight tickets pin the Parquet manifests beside the signal snapshots
in their existing layouts; slice tickets still carry no Parquet table. A
test joins two signal tables with a Parquet table and pins its exact rows.

The docs, the ADR-2040 amendment that recorded the interim refusal, the
ADR-0033, ADR-0045 and ADR-1101 status sentences and the claims registry
are updated to match.

The test a_logs_key_equality_beside_a_parquet_table_returns_exact_rows pins
that a logs KEY_IDX key equality beside a Parquet table returns the rows of
the same statement over a VALUES list, for a join and for COUNT(*) and MIN
over it, and that the KEY_IDX probe runs.

Fixes: #2914
Signed-off-by: Panagiotis Moustafellos <pmoust@nofire.ai>
@pmoust

pmoust commented Oct 19, 2026

Copy link
Copy Markdown
Member Author

@claude-fleet review

@claude-fleet

claude-fleet Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

Review task queued.

Task: f7908461-e821-438c-b52e-c72ff38b48ea
Commit: 0b70fe4
Model: claude-opus-5 (effort medium)
Executor: rp2
Status: done. Review posted.

@pmoust pmoust mentioned this pull request Oct 19, 2026
7 of 10 tasks

@claude-fleet claude-fleet Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Review of #3107 at 0b70fe4 (merge base 1b4d298).

Verdict: comment

The core change is sound. I read every modified file against its diff, and the three things most likely to break here all hold up:

Verified:

  • The budget view is the statement total, and nothing is double-counted. ReadLimits::with_budget_view (crates/ravel-parquet/src/limits.rs) replaces only the source of the pooled snapshot in refusal; the in-flight bytes and the +1 request for the read being considered are still added on top, and admission still records on the Parquet handle. StatementAccounting::pooled_snapshot sums lanes + Parquet + statement, and the closure captures a clone that shares the underlying counters, so the view is live rather than a snapshot taken at plan time. The executor installs it only when reads_lanes is true, so a Parquet-only statement keeps reading the Parquet handle, which equals the total there.
  • reads_lanes distinguishes Parquet-only from mixed on every branch it gates (executor.rs:2663): target signal, several_tables, the budget view, the stats gate, lane registration, and the row-window fallback. A Parquet-only statement still registers no signal provider (a_parquet_only_statement_registers_no_signal_table), and logs_column_stats_eligible takes the conservative per-scan path for mixed.
  • Estimate arithmetic. The Parquet estimate is computed from resolve_snapshot().total_s3_requests() (actuals, Parquet-only at that point) and each lane adds estimated_catalog_requests, a static figure, so there is no overlap between the two terms. a_mixed_estimate_is_the_parquet_estimate_plus_the_lanes pins it.
  • Resolve ordering. Parquet resolves first and lanes resolve after, so check_lane_request_budget, which reads the statement-wide resolve snapshot, counts the Parquet manifest and grant reads. Pinned by a_lanes_resolve_budget_check_counts_the_parquet_resolve_reads.
  • Flight tickets. parquet_tables is encoded and decoded outside the version branch, so the flat (v8/9/11) shape a mixed single-lane statement actually mints carries the pins as well as the new grouped case the unit test covers; redemption_lanes takes the flat branch to tables.first_lane(), which is the one signal table, and TicketPinsOneSignal still guards the >1 case. The end-to-end a_flight_mixed_statement_pins_the_parquet_table_and_the_signal_lane exercises the flat shape with both sides changed between the RPCs, and is non-vacuous (it re-plans and sees both changes).
  • The Flight token refusal is equivalent to HTTP's. unqualified_tokens already rejects every qualified token on Flight, so the new CommitTokenOnParquetStatement check in resolve_pinned_tables covers exactly the remaining case, and the test asserts the same client_message as HTTP plus zero store reads.
  • Paging. page_target's new tables.len() == 1 guard uses the CTE-stripped referenced_base_tables set, so a signal table reached through a subquery or an IN list refuses too; the test covers all three shapes and both call levels.
  • Row window. Filtering stays per-TableScan on signal tables, and the parquet-only row_window refusal is unchanged (row_window && tables.signals.is_empty()).
  • SqlError::SignalBesideParquet is gone from code, docs and claims (the only remaining mentions are the ADR-2040 history).
  • Every test named in the new claims.yaml entries and in the doc prose exists in crates/ravel-sql/tests/parquet_tables.rs, and each new test carries a FLIP: line naming the wrong implementation it catches.

Findings (details in the JSON below):

  1. crates/ravel-sql/src/session.rs — the LIKE rewrite silently narrows to LogsColumnsOnly for a logs statement that gains a Parquet table. should_fix, documentation and test.
  2. docs/adrs/2040-parquet-tables-queried-in-place.md — the earlier amendment is edited in place and still reads, in the present tense, that the executor refuses with a variant this PR deletes. nit.
  3. crates/ravel-sql/src/executor.rs — several for the refusal's table name counts signal lanes only, so a mixed single-lane statement's RequestBudgetExceeded carries no table while the plan path treats the same statement as several tables. nit.

Checks run: git diff --stat then each modified file's diff separately; read the unchanged call sites that the changed branches depend on (validate.rs referenced_base_tables, like_udf.rs LikeScope, scan.rs samples checkpoint, provider.rs with_statement_accounting, flight/service.rs get_flight_info_statement, flight/stream.rs, flight_ticket.rs encode/decode/redemption_lanes, phase_accounting.rs); grepped for other copies of the lifted refusal and for stale user-facing text; checked the description's claims against the diff.

Smaller things: the README edit leaves a sentence break mid-line ("...bounds only the five. On\nPOST /api/v1/sql...") that reads oddly in the rendered paragraph; purely cosmetic. The estimate-based pre-check still covers only the Parquet term (the lanes are checked on actual resolve requests, not on the summed estimate), which is not a regression but means the early refusal for a mixed statement is weaker than the sum of its parts.

Not checked: nothing was compiled or executed here — no cargo test, no clippy, no feature lanes (sql, flight-sql), no scripts/gates.sh, and no guard scripts (check-doc-claims.py, the amendment-integrity guard over the new ADR markers); I did not run the revert check on any new test, so the FLIP: claims are read, not demonstrated. I did not verify against DataFusion's own source that the built-in LIKE refuses a custom escape — finding 1 rests on like_udf.rs's own doc comment saying so. I did not review the ES|QL or MCP surfaces beyond confirming they carry no Parquet table concept, did not examine the alert-rule evaluation path, and did not check services/ravel-server's rendering of stats.signals for a mixed statement.

Not checked:

  • Nothing was compiled or executed: no cargo build, test, clippy, doctest, or scripts/gates.sh run, and no sql/flight-sql feature lane.
  • No revert check on any new test, so each test's FLIP: claim was read rather than demonstrated failing without the change.
  • Guard scripts were not run: check-doc-claims.py over the rewritten docs/review/claims.yaml entries, and the amendment-integrity guard over the new amendment-applies / amendment-supersedes markers in ADR-2040.
  • Whether DataFusion's built-in LIKE really refuses a custom ESCAPE in the pinned version; finding 1 rests on crates/ravel-sql/src/like_udf.rs's own doc comment stating it.
  • The ES|QL and MCP surfaces beyond confirming neither carries a Parquet table concept, and the SQL alert-rule evaluation path for a mixed statement.
  • services/ravel-server's rendering of stats.signals and stats.phases for a mixed statement; only the in-crate accounting arithmetic was read.
  • The ravel-parquet reader internals beyond ReadLimits::refusal: concurrent read interleaving between signal scans and Parquet scans under a shared budget view was reasoned about from the saturating sums, not measured.
  • Performance of the samples lane without name postings pruning beside a Parquet table; the correctness claim (same rows) was read in the docs and not measured.

Task f7908461-e821-438c-b52e-c72ff38b48ea on rp2, model claude-opus-5, effort medium.

/// exactly one Parquet entry gets a store registry that answers anything.
/// two entries for one table name, and two Parquet entries. A Parquet entry
/// may sit beside signal tables (ADR-2904 decision 11). Only a set with a
/// Parquet entry gets a store registry that answers anything, and its session

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

should_fix: A logs statement that gains a Parquet table also changes its LIKE semantics, and nothing in the user-facing documentation says so.

like_scope is LikeScope::Every only when the session has exactly one table entry. Before this change, a logs-plus-Parquet set was refused, so a logs-only statement always got Every. Now the same statement with a Parquet table joined in builds a two-entry session and takes LogsColumnsOnly. By like_udf.rs's own doc, a LIKE on a logs column with a custom escape or a non-string operand then goes to the built-in operator, "which refuses it, while the same predicate runs in a logs-only session".

So SELECT ... FROM logs WHERE body LIKE 'a!_b' ESCAPE '!' answers today and becomes an error once the statement also names a Parquet table, and a plain logs LIKE loses the dictionary fast path. That is the same class of cost as the __name__ postings regression, which this PR documents carefully in changelog.d/2914.added.md, the ADR-2040 amendment and docs/query-engine.md, with a named test. This one appears in none of them, and no test pins it.

Suggest adding a sentence beside the __name__ note in the changelog entry and the amendment, and a test that asserts which evaluator a logs LIKE takes beside a Parquet table (or that the escape form is refused there), so the narrowing is a pinned decision rather than a side effect of the session's table count.

If the intent is instead that a mixed session keep Every for logs columns, that is a behavior change rather than a doc change, and the current tables.len() == 1 test is the thing to revisit.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Answered, with no change in this PR.

unchanged. Until ticket T7 of epic #2904 (#2914) lands, the executor refuses
such a statement with `SqlError::SignalBesideParquet`, a 400 that says it is
not supported yet, before any signal catalog listing.
unchanged. Until ticket T7 of epic #2904 (#2914) lands (it has; see the

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

nit: The earlier amendment is edited in place, and the edited sentence still asserts in the present tense that "the executor refuses such a statement with SqlError::SignalBesideParquet" for a variant this PR deletes. The inserted parenthetical "(it has; see the interim refusal amendment below)" satisfies the amendment-integrity pointer requirement but leaves the sentence self-contradicting for anyone who reads that paragraph alone.

Two things follow. First, the PR description says the earlier amendment is not edited in place; the diff does edit it. Second, the pointer requirement can be met with a sentence that is also true now, for example: "Until ticket T7 of epic #2904 (#2914) landed, the executor refused such a statement with SqlError::SignalBesideParquet, a 400 ...; see the interim refusal amendment below." Past tense keeps the superseded phrase traceable while removing the false present-tense claim.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Nit, carried to #2915 (the epic doc sweep), so the reviewed head stays as it is.

}
}
let lanes = tables.lanes();
let several = lanes.len() > 1;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

nit: let several = lanes.len() > 1; counts signal lanes only, so for a statement naming one signal table beside a Parquet table every RequestBudgetExceeded raised by check_lane_request_budget carries table: None, even though the statement does name more than one table and the refusal's cost includes the Parquet resolve reads. The plan path takes the opposite reading three hunks later: several_tables = lanes.len() > 1 || (parquet.is_some() && reads_lanes).

The Parquet-estimate refusal just above (table: None at the early return) is correct, since that one is attributable to the Parquet side alone. This is only the message, not the verdict, but the two flags being spelled differently for the same statement shape is the kind of thing that reads as a bug later. Either make this one match several_tables, or say in the check_lane_request_budget doc why the lane name is withheld when a Parquet table is the second table.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Nit, carried to #2915 (the epic doc sweep), so the reviewed head stays as it is.

@pmoust
pmoust added this pull request to the merge queue Oct 19, 2026
Merged via the queue into main with commit 50eed70 Oct 19, 2026
31 checks passed
@pmoust
pmoust deleted the feat/2914-parquet-beside-signal branch October 19, 2026 06:44
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.

T7: a Parquet table beside a signal table (epic #2904)

1 participant