Skip to content

fix(rfc): apply every predicate, and push far more of them to SAP - #128

Merged
jrosskopf merged 8 commits into
masterfrom
claude/rfc-filter-pushdown
Aug 31, 2026
Merged

fix(rfc): apply every predicate, and push far more of them to SAP#128
jrosskopf merged 8 commits into
masterfrom
claude/rfc-filter-pushdown

Conversation

@jrosskopf

@jrosskopf jrosskopf commented Aug 30, 2026

Copy link
Copy Markdown
Collaborator

Phase 2 of the large-dataset extraction work. Started as a performance change and
uncovered a correctness bug on the way.

The correctness bug (found by the on/off oracle)

sap_read_table sets filter_pushdown = true. DuckDB documents that as "if NOT
supported a filter will be added that applies the table filter directly"
— so
setting it true makes the scan solely responsible for every predicate it
receives. EXPLAIN confirms there is no FILTER operator above SAP_READ_TABLE.

erpl translated what RFC_READ_TABLE could express and silently ignored the rest.
Anything that did not translate was simply never applied:

SELECT count(*) FROM sap_read_table('/DMO/FLIGHT') WHERE SEATS_MAX > 350;
-- returned 40 (the whole table); ground truth is 14

Every range, conjunction and over-wide IN was affected — which is most of the
predicates that matter on a large table. Predicates that cannot reach the server
are now evaluated by erpl instead, so the result is identical either way.

The performance change

Only = and IN with at most five values reached SAP. Everything else crossed
the wire in full. Most of what was needed already existed and was switched off:

  • TransformComparision already mapped all six operators; a guard discarded
    everything but =.
  • CONJUNCTION_AND returned nothing — which is how DuckDB expresses BETWEEN, so
    a date window pushed down as nothing at all.
  • CreateExpression, written to join a column's filters, was never called.

Conjunctions translate all-or-nothing: dropping one arm of an AND merely widens
the predicate, but dropping one arm of an OR narrows it and would lose rows.
The IN cap is now a length budget, and an over-long list is dropped rather than
truncated, since a truncated IN is a different, narrower predicate.

Two SAP-side rules learned the hard way

  • Literals must match the DDIC type. A DATE pushed as 2020-01-01 gets
    "is not a valid value for D(8,0)". DATS wants YYYYMMDD, TIMS wants
    HHMMSS. Types with no unambiguous ABAP spelling (TIMESTAMP, BLOB,
    FLOAT/DOUBLE) are declined rather than guessed at.
  • The client field can never appear in OPTIONS. RFC_READ_TABLE rejects the
    whole clause. A join on MANDT produces exactly that, which is what the old
    "for joins, don't push down complex AND expressions" early return was really
    avoiding — removing it broke the SFLIGHT/SPFLI join. Client columns are now
    identified from the DDIC DATATYPE at bind time, the only place the distinction
    survives.

Also fixed: comparison literals were not escaped, so a value containing an
apostrophe closed the ABAP literal early; inequality was emitted as !=, which
ABAP does not accept; and the 70-char OPTIONS splitter broke inside quoted
literals containing spaces.

Testing

  • rfc/test/cpp/test_read_table_filters.cpp — 18 cases, no SAP system needed.
    Verified genuinely red against the pre-fix behaviour (7 of 13 cases failed
    before the type tests were added).
  • rfc/test/sql/sap_read_table_filter_pushdown.test — runs each predicate with
    erpl_rfc_pushdown_filters on and off and compares with symmetric EXCEPT ALL,
    which preserves multiplicity and so catches drops, duplicates and corrupted
    values in one assertion. COUNT(*) would pass a drop-one/duplicate-one bug.
    Order is deliberately not asserted — RFC_READ_TABLE guarantees none.
  • Full suites green: 38 C++ cases (2823 assertions), 29 RFC SQL tests.

erpl_rfc_pushdown_filters (default on) disables the translation. It changes only
where a predicate is evaluated, never which rows come back.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

…TABLE

Only `=` and `IN` with at most five values reached the SAP server. Everything
else was evaluated by DuckDB *after* the whole column had crossed the wire, so a
date-window predicate on a large fact table transferred every row and then threw
almost all of them away. On big extracts that transfer dominates every other cost
in the scan.

Most of what was needed already existed and was switched off:

- TransformComparision already mapped all six comparison operators; a guard in
  TransformFilter discarded everything but `=`.
- CONJUNCTION_AND returned nothing, which is how DuckDB expresses BETWEEN on a
  single column -- so a range window pushed down as nothing at all.
- CreateExpression, written to join a column's filters with an operator, was
  never called.

Conjunctions are now translated all-or-nothing. Dropping one arm of an AND merely
widens the predicate and DuckDB re-filters, but dropping one arm of an OR narrows
it and would silently lose rows; rather than carry two rules, neither is emitted
partially. The IN cap becomes a length budget instead of an arbitrary count, and
an over-long list is dropped rather than truncated, since a truncated IN list is a
different, narrower predicate.

Two correctness fixes found along the way:

- CONSTANT_COMPARISON built its literal with Format("'%s'"), skipping
  TransformLiteral and therefore not doubling embedded apostrophes. A value like
  O'Brien closed the literal early and changed what the predicate meant.
- TransformComparision emitted `!=`, which ABAP's dynamic WHERE does not accept.

The OPTIONS chunker is now quote-aware. Walking back to the nearest whitespace
lands inside a literal whenever one contains spaces, leaving an unbalanced
apostrophe on both lines; it now breaks only at whitespace outside a literal.
Literals too long for one 70-char line are declined at generation time, where
declining is still free.

Adds erpl_rfc_pushdown_filters (default on) as an escape hatch for SAP releases
that reject the generated syntax, and as the oracle for the live equality tests:
pushdown is a pure optimisation, so the same query with it on and off must return
the same rows.

Covered by rfc/test/cpp/test_read_table_filters.cpp, which needs no SAP system.
sap_read_table sets filter_pushdown = true, which DuckDB documents as "if NOT
supported a filter will be added that applies the table filter directly" -- so
setting it true makes the scan solely responsible for every predicate handed to
it. EXPLAIN confirms it: there is no FILTER operator above SAP_READ_TABLE.

erpl translated what RFC_READ_TABLE could express and silently ignored the rest,
so any predicate that did not translate returned unfiltered rows. Against the
trial, `WHERE SEATS_MAX > 350` on /DMO/FLIGHT returned all 40 rows instead of 14,
and a BETWEEN over FLIGHT_DATE returned 40 instead of 20. Every range, conjunction
and over-wide IN was affected, which is to say most predicates that matter on a
large table.

Widening the translation (previous commit) shrinks the exposed set but cannot
close it: literal types with no established ABAP spelling, over-long IN lists and
IS NULL still cannot go to the server. Those are now evaluated here instead, so
the contract holds whether or not a predicate reaches SAP.

An unrecognised filter kind keeps the row rather than dropping it. That
over-produces, which is what happened before this existed; dropping would be a new
way to be wrong.

This also makes erpl_rfc_pushdown_filters honest. It now changes only where the
predicate is evaluated, never which rows come back -- without this it silently
disabled filtering altogether, which is exactly how the bug was found.

Verified against the trial: ground truth, pushdown off and pushdown on now all
return 14 and 20 respectively.
Pushdown is only safe if it changes where a predicate is evaluated, never which
rows come back. erpl_rfc_pushdown_filters is the oracle: every predicate runs
twice and the two results are compared with symmetric EXCEPT ALL, which preserves
multiplicity and so catches dropped rows, duplicated rows and corrupted values in
one assertion. COUNT(*) would pass a drop-one/duplicate-one bug.

Row order is deliberately not asserted -- RFC_READ_TABLE guarantees none.

Each case also asserts the filter actually selects a strict subset, so that 'both
sides agree' cannot be satisfied by returning everything, which is precisely the
failure this was written to catch.
RFC_READ_TABLE's OPTIONS parser rejects the whole clause if it names the client
field -- 'The parser produced the error: The client field "M..."'. The client is
implicit in the RFC call, so it must not appear in a generated WHERE.

A join on MANDT produces exactly such a predicate, which is what the old
'for joins, don't push down complex AND expressions' early return was really
avoiding. Widening conjunction pushdown removed that accidental shield and broke
the SFLIGHT/SPFLI join in sap_read_table.test.

Client columns are identified from the DDIC DATATYPE at bind time, which is the
only place the distinction survives: GetRfcTypeForFieldMeta maps CLNT onto
RFCTYPE_CHAR, so by then it is indistinguishable from an ordinary CHAR field.

The filter is routed to the residual path rather than dropped, so the rows are
still filtered -- just on this side.
Records what now reaches SAP and what deliberately does not, that anything not
pushed is still applied, and how literals are rendered per DDIC type.

Also gitignores the local agent scratch directories, which is how they came to be
staged here in the first place.
@jrosskopf
jrosskopf force-pushed the claude/rfc-filter-pushdown branch from 4defa66 to aed11ca Compare August 30, 2026 16:40
A table function signals end-of-scan by returning a chunk with no rows. Residual
filtering can legitimately reject every row of a batch, and returning that empty
chunk terminated the scan and silently discarded everything still unread.

Only visible at scale: /DMO/FLIGHT is 40 rows and never fills a vector, so the
whole suite passed. On DD03L the scan stopped at the first fully-rejected batch
and returned 25,578 rows instead of 114,566.

The scan now keeps pulling until a batch has a surviving row or the table is
genuinely exhausted, resetting the chunk in between -- ApplyResidualFilters slices
it, leaving dictionary vectors that Step would otherwise write through as if they
were flat.

Verified on DD02L (164,664 rows, ~80 batches): ground truth, residual and pushed
all return exactly 40,786 rows with zero multiset difference in both directions.
Every other case in this file runs against /DMO/FLIGHT, which has 40 rows and
never fills a single vector -- so none of them touch the multi-batch path, and all
of them stayed green while the scan was truncating results.

DD02L is ~165k rows across ~80 batches and TABCLASS clusters by table name, so
whole batches contain no matching row. That is the shape that broke: residual
filtering emptied a batch, the scan returned a zero-row chunk, and DuckDB read it
as end-of-scan.

The row count is deliberately not written down -- it drifts with the trial's
repository contents. What is asserted is that ground truth, residual and pushed
agree as multisets, that the scan really does span many batches, and that the
filter rejects a large majority so empty batches actually occur.
SAP TIMS is second-precision; DuckDB TIME is not. Truncating the fraction changes
the predicate rather than approximating it: `t < TIME '09:05:03.500'` must keep a
row stored as 09:05:03, but the truncated clause `t < '090503'` rejects it. `>=`
fails the mirror-image way. Rounding correctly would have to depend on the
comparison operator, which TransformLiteral cannot see, so it declines and the
residual path applies the predicate exactly.

Found by an external review of the diff.

Also records why OPTIONAL_FILTER is enforced in residual evaluation rather than
skipped. DuckDB's own OptionalFilter::FilterSelection is a no-op, but that is a
performance choice, not permission to violate the predicate: CheckStatistics
delegates to the child for zone-map pruning and table_scan.cpp feeds it into
index-scan comparison extraction, both of which eliminate rows. DuckDB can skip
the exact check because something downstream re-applies it; here the filter was
removed from the plan, so nothing would.
@jrosskopf

Copy link
Copy Markdown
Collaborator Author

Verification pass: external review + testing at scale

The original testing was thinner than it looked — every live test ran against
/DMO/FLIGHT, which has 40 rows. For a change whose whole purpose is large
datasets, that is not representative, and it hid a second bug.

Second correctness bug, found only at scale

A table function signals end-of-scan by returning a chunk with no rows. Residual
filtering can legitimately reject every row of a batch, and returning that empty
chunk terminated the scan and silently discarded everything still unread.

On DD03L this returned 25,578 rows instead of 114,566. Every existing test
stayed green, because 40 rows never fill a single vector.

Fixed in 72bac9d, and pinned by a 165k-row regression test (DD02L, ~80 batches,
TABCLASS clusters so whole batches come back empty). Ground truth, residual and
pushed now agree exactly — 40,786 rows, zero multiset difference both directions.

External review findings

  • Fixed (high): a TIME literal with a fractional second was pushed truncated.
    SAP TIMS is second-precision, so t < TIME '09:05:03.500' became t < '090503'
    and wrongly rejected rows stored as 09:05:03. Correct rounding depends on the
    comparison operator, which the literal renderer cannot see, so it now declines and
    the residual path applies the predicate exactly (e539e3d).

  • Not changed (medium): the reviewer suggested OPTIONAL_FILTER should not be
    enforced in residual evaluation, since DuckDB's OptionalFilter::FilterSelection
    is a no-op. That no-op is a performance choice, not permission to violate the
    predicate: CheckStatistics delegates to the child for zone-map pruning, and
    table_scan.cpp:487 feeds the child into index-scan comparison extraction — both
    eliminate rows, so the predicate is implied by the query. DuckDB can skip the exact
    check because something downstream re-applies it; here the filter was removed from
    the plan, so nothing would. Reasoning recorded in the code.

Review also confirmed: the DataChunk::Slice interaction is safe (Reset() restores
vectors from cache, and writes go through Vector::SetValue), residual_filters is
read on the scan thread after WorkOnTasks() returns, and no ABAP injection path was
found — literals are escaped and field names validated against DDIC metadata.

Performance, now measured rather than asserted

165k-row table, debug/ASAN build (the ~1.8s floor is fixed startup cost):

Predicate Rows kept Pushdown off on Speedup
TABCLASS = 'VIEW' 14,461 / 164,664 6.46s 2.12s 3.0x
TABNAME = 'DD02L' 1 / 164,664 6.77s 1.80s 3.8x

Coverage now

  • 19 offline C++ cases; verified genuinely red against the pre-fix behaviour.
  • RFC SQL suite green on both backends: nwrfc 29/29, proto 29/29.
  • Multi-batch scale test at 165k rows.
  • SAP type edge cases against the live system: NUMC leading zeros ('0017'), packed
    CURR decimals, and a multi-byte UTF-8 literal ('Ägypten') through the
    byte-oriented 70-char chunker.
  • CI green across DuckDB 1.4.5 and 1.5.5, all five platforms, both backends.

@jrosskopf
jrosskopf merged commit 59cdcad into master Aug 31, 2026
53 checks passed
@jrosskopf
jrosskopf deleted the claude/rfc-filter-pushdown branch August 31, 2026 05:18
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