Skip to content

feat(rfc): row-range partitioning and a fetch-size knob for sap_read_table - #133

Merged
jrosskopf merged 14 commits into
masterfrom
claude/rfc-row-range-partitioning
Sep 2, 2026
Merged

feat(rfc): row-range partitioning and a fetch-size knob for sap_read_table#133
jrosskopf merged 14 commits into
masterfrom
claude/rfc-row-range-partitioning

Conversation

@jrosskopf

@jrosskopf jrosskopf commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

What this adds

sap_read_table gains row-range partitioning: PARTITIONS := N splits a scan
into N disjoint ROWSKIPS/ROWCOUNT windows read concurrently, and
FETCH_SIZE := N exposes the per-batch row budget that until now was only
reachable through erpl_rfc_read_table_batch_budget.

This closes the structural gap that column-parallelism left open: a narrow table
got no parallelism at all, because the existing scan parallelises across columns.

Correctness

Row order is not part of the contract, so every partitioned path is checked
against its serial reference with the symmetric EXCEPT ALL multiset oracle —
order-insensitive, multiplicity-preserving, so it catches drops, duplicates and
value corruption in one assertion. count(*) would pass a drop-one/duplicate-one
bug; it is not used as the oracle anywhere here.

rfc/test/sql/sap_read_table_partitions.test runs that oracle across partition
counts, and rfc/test/cpp/test_row_window_scheduler.cpp covers the scheduler
offline, including an 8-thread no-gap/no-overlap case.

Fixes found while building it

Each of these was caught by measurement or by a review round, not by the tests I
wrote first:

  • Partitioning was initially 2x slower. I had pinned the batch to
    STANDARD_VECTOR_SIZE instead of the effective batch budget. Only the
    benchmark caught it.
  • ROWSKIPS could wrap. It is an ABAP INT4; a large offset overflowed.
  • An absurd partition window handed out one offset foreverMAX_ROWS
    combined with partitions left a state machine with work pending that it could
    never complete, hanging the scan.
  • The runtime RFC_READ_TABLE fallback raced under concurrent partitions and
    left stale metadata behind. Now serialised behind a lock; the first attempt at
    this fix was itself incomplete (it updated the member but the retry used a
    path captured before the loop).

Measurement, and what it is not

Partitioning helps, but the honest framing is in API_REFERENCE.md: the gain
depends entirely on how much concurrency the target system sustains. The numbers
I could take are from a single-container trial and are not a statement about
what a production SAP system does — nothing here caps a default at a
trial-measured value.

Also

Pins erpl-proto to v2026.8.29.1. That is deliberately not the newest release:
v2026.9.1 carries two defects that break ODP and table deltas
(DataZooDE/erpl-proto#53, #54 — fixes now open as #55 and #56). Re-pin once those
land.

The piece that decides which rows each worker of a partitioned sap_read_table scan
reads -- and therefore the piece that loses or duplicates them if it is wrong.

Deliberately free of any RFC dependency. The equivalent logic in erpl_odp can only be
exercised through a live connection, which is why an observed threads>1 under-count
there went unexplained; this one is covered by an 8-thread test asserting that the
claimed windows tile the row space with no gap and no overlap.

Two SAP-side rules shape it, both verified against the trial rather than assumed:

  * RFC_READ_TABLE rejects ROWSKIPS % ROWCOUNT != 0 outright -- the server answers
    "RFC_READ_TABLE (ROWSKIPS MOD ROWCOUNT <> 0)". Every offset handed out is
    therefore a multiple of the batch size, and the window size is rounded up to a
    whole number of batches so that stays true forever.
  * A read at or past the end returns zero rows rather than erroring, so a short read
    is how a worker discovers the table is exhausted.

MAX_ROWS is applied at claim time against raw offsets, not per worker: a per-worker
limit would return workers x MAX_ROWS rows. The final window's LOGICAL count shrinks
while the caller still requests whole batches and clips locally, because a
non-aligned ROWCOUNT is rejected server-side.

ReportExhausted prevents NEW claims only. A worker already holding a window must
always be allowed to finish it -- that is precisely the invariant erpl_odp broke, and
there is a test named for it.

Also verified live before writing any of this: two disjoint ROWSKIPS windows tile a
table exactly (1000 + 1000 rows against a 2000-row read: zero overlap, zero missing,
zero extra).
GetProjectedColumnName() scanned column_state_machines to map a projected column
index back to an RFC column index, and AddOptionsFromFilters() depends on it -- so
filter pushdown was coupled to the state machines living on the bind data.

A partitioned scan owns them per worker, so ActivateColumns() now also records the
mapping as plain data and GetProjectedColumnName() reads that instead. Built once,
read-only afterwards.

No behaviour change: filter pushdown suite green, 47 C++ cases green.
TrySelectFallbackReadTableFunction reassigns read_table_function -- a std::string --
from execute time, when a string column read fails with TABLE_WITHOUT_DATA. It is the
one place the bind data is written after the scan has started.

Column-parallel reads already make it reachable from two tasks at once; a partitioned
scan would multiply that by the worker count. A caller arriving second now sees the
switch already made and reports the outcome rather than probing and writing again.

Found by an external review of the partitioning design, which correctly contradicted
my claim that the bind data is read-only during a scan. The other site it flagged,
read_table_supports_et_data_switch, is already resolved eagerly at bind whenever any
column needs string support -- which is exactly when the lazy path could be reached --
so it is left alone.
sap_read_table parallelises per COLUMN: one concurrent RFC_READ_TABLE call per
projected column, all reading the same row window in lock-step. That suits a wide
extract and does nothing at all for a narrow one -- a single-column scan issues one
call and `threads` has nothing to spread.

`partitions` splits the row space instead. Workers claim disjoint windows from a
shared scheduler and read them with ROWSKIPS/ROWCOUNT, so a narrow extract finally has
something to parallelise.

Opt-in, and not because the implementation is doubted: a partitioned scan returns rows
in worker-completion order, whereas RFC_READ_TABLE is called with GET_SORTED='X' and an
unpartitioned scan returns them sorted. That is observable, so it is a choice.

Three SAP-side facts were established against the trial before any of this was written,
rather than assumed:

  * Disjoint ROWSKIPS windows tile a table exactly -- 1000 + 1000 rows against a
    2000-row read gave zero overlap, zero missing, zero extra.
  * ROWSKIPS % ROWCOUNT != 0 is rejected outright by the server, so misalignment fails
    loudly rather than returning wrong rows.
  * A read at or past the end returns zero rows rather than erroring, which is how a
    worker learns the table is exhausted.

Columns are read one after another inside a worker rather than fanned out. A
partitioned scan already has one call in flight per worker; fanning out per column
would make concurrent calls workers x columns with nothing bounding the product. Row
parallelism and column parallelism are alternatives, not a product.

One real bug surfaced during testing, and only with partitioning on: MAX_ROWS hung.
The unpartitioned path trims ROWCOUNT to land exactly on the limit, but a partitioned
window cannot -- ROWCOUNT has to stay batch-aligned -- so it over-fetches the final
batch. LoadNextBatchToDuckDBColumn then clips at the limit and returns 0 for every
later call, leaving pending_records permanently above zero and the state machine
spinning in LOAD_TO_DUCKDB. Reaching the limit now ends the read.

Covered by test/sql/sap_read_table_partitions.test: the same table read serially and
at 3 and 8 partitions, compared by symmetric EXCEPT ALL; a window size small enough
to force repeated hand-offs; more workers than windows; MAX_ROWS as a scan-wide limit;
filters combined with partitioning; and partitions=0/1 behaving as unpartitioned.
Five repeated runs of the 3-way comparison were identical.
Two fixes, one found by measuring and one by an external review of the diff.

**It was 2x SLOWER than not partitioning.** Measured on a release build against a
164,664-row single-column extract: 2.23s unpartitioned, 4.1s at every partition count
from 2 to 16. The cause was mine: a partitioned window cannot let the batch size
double -- that would break ROWSKIPS alignment -- so I pinned it at
STANDARD_VECTOR_SIZE, which made a window cost 64 RFC round-trips where the
unpartitioned path's geometric warm-up reaches 32768 and needs a handful.

The batch size is now the same one the unpartitioned path warms up to, resolved once
before any worker starts. Windows default to one batch each, which is the finest
granularity that still issues full-size calls, so workers share the table evenly
instead of one taking a huge window while the rest idle.

  partitions   1      2      4      8      16
  wall       2.23s  1.45s  1.11s  0.83s  0.92s

2.7x at eight workers, and the knee is visible.

**ROWSKIPS could wrap.** It is an ABAP INT4 and was cast to int32_t unchecked. An
unpartitioned scan reaches a large offset only by reading its way there, but a
partitioned worker can START at one, so a big table or a large window setting could
wrap the cast and silently re-read an earlier range -- duplicating rows, which is the
worst failure this code can have. It now refuses with a message naming the limit.

Also releases the cached connection and SDK result buffer on the new
limit-reached branch, matching what FINAL_LOAD_TO_DUCKDB already does, so a
MAX_ROWS read does not hold a SAP work process until query teardown.

Suites: 47 C++ cases, RFC SQL 31/31 on nwrfc and 31/31 on proto.
Documents that threads parallelises across columns and therefore does nothing for a
narrow extract -- the thing that sends people to raise threads and watch it change
nothing -- and that partitions splits rows instead.

States the measured curve rather than implying the worker count: 2.23s -> 0.83s at
eight partitions on a 164,664-row single-column extract, with the knee visible so
nobody sets it to 64 and expects more.

Both caveats are stated plainly: rows come back unordered, which is why this is
opt-in; and ROWSKIPS is a 32-bit integer, so a window cannot start past row 2.1
billion -- erpl refuses rather than wrapping, because a wrap would duplicate rows.
erpl_rfc_partition_window_rows is a UBIGINT the user sets, and the scheduler rounded
it UP to a whole number of batches. Rounding a near-max value wraps, and a window_size
of 0 makes Claim()'s fetch_add return the SAME offset to every worker forever -- every
worker reads the same rows, so the scan returns duplicates rather than an error.

Found by a third adversarial review round, after two earlier rounds had reviewed the
design and the first implementation. The test that pins it fails with "0 > 0" on the
old code.

Now clamped to INT32_MAX before rounding -- nothing above that is reachable anyway,
since ROWSKIPS is an ABAP INT4 -- and rounded DOWN, which cannot overflow while
keeping every offset a multiple of the batch size.

Also divides erpl_rfc_fetch_size across partition workers. The budget bounds
(columns x batch) for ONE reader, and each worker owns its own state machines and SDK
result buffers, so leaving it undivided would have made peak memory scale with the
partition count. Measured on a 2.87M-row extract: 215 MB at one partition, 374 MB at
eight -- 1.7x memory for 4.2x speed, rather than the 8x a per-worker budget would give.
Narrow scans are unaffected: the batch hits MAX_BATCH_SIZE long before the divided
budget binds.
TrySelectFallbackReadTableFunction switches read_table_function when a string column
read fails with TABLE_WITHOUT_DATA, but read_table_result_path and
read_table_import_params were probed against the PREVIOUS function at bind time and do
not carry over.

Left stale, the retry asks /SAPDS/RFC_READ_TABLE2 for "/DATA", ResolveResultTable
finds nothing and returns 0 rows. On the unpartitioned path that is an empty result; on
a partitioned scan a worker reads 0 rows as end-of-table and stops the whole scan.
Either way it is silent truncation, which is the worst thing this code can do.

Both are now recomputed for the newly selected function, inside the lock, before any
other worker observes the switch.

Found by a fourth review round. The first three had looked at the fallback for
thread-safety and stopped there, having concluded the mutex settled it.

Also makes RfcRowWindowScheduler total for any batch size rather than relying on its
only caller staying well behaved -- a batch above INT32_MAX would previously have
produced a window that could wrap the ROWSKIPS arithmetic. Not reachable through
sap_read_table today, since the batch comes from MaxBatchSizeForColumnCount and is
capped, but the class should not depend on that.

50 C++ cases, 3018 assertions.
A fifth review round took the round-4 fix as its subject and found three things.

**The retry still used the old result path.** `data_path` was computed once before the
retry loop, so after the fallback switched read_table_function and reprobed the path,
the retry asked /SAPDS/RFC_READ_TABLE2 for RFC_READ_TABLE's "/DATA" anyway. Fixing the
member without fixing the local left the original symptom exactly where it was: zero
rows, which a partitioned worker reads as end-of-table. It is now re-read every
attempt.

**The readers raced the writer.** read_table_function, read_table_result_path and
read_table_import_params were written under the fallback mutex and read without it.
read_table_import_params is a std::set -- reading one while another thread clears and
refills it is undefined behaviour, not a stale answer. A partitioned scan makes this
reachable: several workers build RFC calls while one of them falls back. The accessors
now take the same lock. The two Resolve* functions are safe to call while holding it
because they read read_table_function directly rather than through the accessors.

**Claim() could wrap.** next_offset.fetch_add was unchecked, so with enough claims the
offset wraps and starts handing out ranges that have already been read. It now stops at
the ROWSKIPS ceiling, which is where a window becomes unusable anyway.

The test asserting two successive claims from an INT32_MAX-sized window was wrong and
has been corrected: exactly one claim is possible there, and the second must be
REFUSED rather than advancing past what ROWSKIPS can address. What the test really
guards -- that no offset is ever handed out twice -- is now asserted directly.

50 C++ cases, 3003 assertions.
The gitlink pointed at 983ffd33 -- a commit with no tag ancestor, roughly twenty
commits behind the current release, recorded back in August. Anything testing "the
proto backend" was testing that, not a release.

v2026.9.1 brings, among others:

  beca45e  basXML selected and implemented end to end
  0f7f1d3  two divergences a side-by-side against PyRFC 3.3.1 exposed
  04c541b  the 17 module-level names pyrfc was missing
  2d82178  throughput: count what a client can honestly count

basXML matters most here: the ODP path parses it directly.

Pinning to a tag rather than a branch commit also stops the submodule drifting. A
local checkout had wandered to v2026.9.1-5-g088a7e7 -- five commits PAST the release,
on a branch -- and the build quietly reset it back to the recorded gitlink mid-run, so
measurements taken either side of a build were against different implementations
without saying so.
The newest release, v2026.9.1, breaks ODP replication: RODPS_REPL_ODP_FETCH_XML makes
ABAP short-dump with RUNT_INTERNAL_ERROR in SAPLRODPS_REPL, and 14 of erpl's 24 ODP
tests fail on it. Reported as DataZooDE/erpl-proto#53.

Bisected across release tags to a single commit:

  v2026.8.29    6d8dfe1  ok
  v2026.8.29.1  0f7f1d3  ok        <- pinned here
  v2026.8.30    beca45e  BROKEN    <- "basXML: ... implement it end to end"
  v2026.8.31    125b594  BROKEN
  v2026.9.1     4e50ef1  BROKEN

RODPS_REPL_ODP_OPEN still succeeds on the broken releases -- the ODQ cursor is
registered -- and sap_odp_preview still works, because it goes through a call that does
not negotiate an XML format. It is format selection specifically that ABAP rejects.

This is still a move forward: the previous gitlink was 983ffd33, sixteen commits BEFORE
v2026.8.29 and carrying no tag at all. v2026.8.29.1 is a real release, is newer, and
keeps ODP working. Move the pin to v2026.9.1 or later once #53 is fixed -- nothing else
in that range is a problem for erpl, and the RFC suite is green on it.
They are unrelated to row-range partitioning and were swept in by an
overbroad add. They stay on disk, untracked, as they are on master.
@jrosskopf jrosskopf changed the title feat(rfc): row-range partitioning for sap_read_table feat(rfc): row-range partitioning and a fetch-size knob for sap_read_table Sep 1, 2026
v2026.9.2 fixes the table-delta padding gap (#54) but regresses BICS from
45/45 to 23/45 on a nested-TTYP basXML defect (erpl-proto#60). One RFC test
is the cheaper cost, so the pin stays and the gap stays recorded.
@jrosskopf

Copy link
Copy Markdown
Collaborator Author

Merged master in to resolve the CHANGELOG.md conflict with #137, and bumped the odp gitlink to 0d66415 (DataZooDE/erpl-odp#9, now merged — the delta-cursor close fix for #134/#135).

So this PR now carries the last piece of the three-bug batch as well as row-range partitioning. Re-running CI before merge.

@jrosskopf
jrosskopf merged commit 517c375 into master Sep 2, 2026
72 of 79 checks passed
@jrosskopf
jrosskopf deleted the claude/rfc-row-range-partitioning branch September 2, 2026 05:59
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