Skip to content

SQLRDD: cheaper interactive navigation (lazy GoTo, order switches, keyset windows and backward reads) - #2123

Merged
RobertvanderHulst merged 7 commits into
X-Sharp:devfrom
ecosSystem:homebase/sqlrdd-navigation
Oct 27, 2026
Merged

RobertvanderHulst merged 7 commits into
X-Sharp:devfrom
ecosSystem:homebase/sqlrdd-navigation

Conversation

@ecosSystem

@ecosSystem ecosSystem commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

Rebased onto the current dev after #2121 was squash-merged: the 7 commits are unchanged, only their parents and hashes differ. #2128 builds on this branch.

Interactive work in an ERP (an order entry form that pages from one order to the next) is a stream of small operations: seeks into related tables, GoTo() back to a saved record, order switches around a lookup, one Skip(). Under DBF/ADS these are local and nearly free. Under SQLRDD each one was a round trip, and many of them read far more than needed. In our order entry form one paging step sent 183-300 statements; it now sends about 100.

What changed

  1. Load query results into the buffer's structure (e147489333). Every query built a new DataTable from the reader's schema. For one or a few rows of a wide table that was most of the client side cost (1.5 ms per query for 178 columns, 0.5 ms when loaded into an existing structure). The work area keeps an empty clone of its first buffer, and SqlDbCommand.GetDataTable(cTable, oTemplate) loads the rows into a copy of it with GetValues()/LoadDataRow(). If the columns do not match, the rows are loaded as before.
  2. GoTo() loads only the record (758712232a, a91d9ae087). GoTo() to a record that is not buffered used to look up its position right away, with a ROW_NUMBER() over the order plus a page around the record. It now loads just the record by its recno. The position is looked up when Skip(), OrderKeyNo etc. need it. A second GoTo() in a row still positions fully, so a browse that calls GoTo() for every row it paints gets one page instead of one query per row; a record loaded by an order change does not count for that. DBI_GETLOCKARRAY/DBI_LOCKCOUNT no longer query the lock table when UseLocks is off.
  3. Order changes (88083310d0, fea0681d12).
    • At EOF an order change no longer loads and counts the first page of the new order. An empty buffer is built from the structure of the previous one, and EOF is marked as pending as with GoTo(0).
    • Otherwise the current record, which was in the buffer a moment before, is kept instead of being read again.
    • LastRec() stays valid across the switch.
    • A seek that finds nothing no longer counts the order to compute BOF.
  4. Keyset conditions with literals (b9d4aabfae). The keyset condition from SQLRDD: fast sequential reads (keyset paging, read-ahead, no constraint indexes on loaded pages) #2121 read every key value of the last row with a subquery. Optimizing those took the server 30-45 ms per statement, against 3 ms to execute it, and the plan was never reused because the text changes with every page. Plain key columns with ASCII string or integer/decimal values are now written as literals. Other values (non-ASCII strings, dates, expressions) still use the subquery.
  5. Small first reads (b9d4aabfae). GoBottom() and looking up the position of a record read PageSize / 10 rows instead of a full page, as a seek already does in SQLRDD: fast sequential reads (keyset paging, read-ahead, no constraint indexes on loaded pages) #2121. Code that goes to a record mostly reads only it and its neighbour. Reading on makes the pages grow.
  6. Window after GoTo and backward reads by key (eb225987cb).
    • Window after GoTo: The page loaded when a lazily positioned record is moved from now starts at the record and is found by its key, instead of OFFSET n around it. With OFFSET the server reads through all rows before the record: 13 ms for 10 rows at offset 502 of a 1,025 row table, 2 ms by key. When the position is not known yet, the buffer is anchored at the record (like a seek) and the position is only looked up when needed.
    • Backward reads: Reading backwards finds the preceding rows by the key of the first buffered row, sorted in reverse (BuildSqlStatement(..., lReverse)), also on a seek-anchored buffer, which is then anchored at its new first row.
    • Limits: Both are only used when all key values can be written as literals. An order on a key expression keeps using offsets, because there the keyset subquery costs more to compile than the OFFSET.

Measurements

SQL Server 2022 Express on the same machine, with the database's default PARAMETERIZATION SIMPLE. AUFHDRDB has 1,025 rows and 178 columns, ADRESSDB has 52,000 rows and 154 columns.

test before after
SetOrder(0) / GoTo() / SetOrder(n), AUFHDRDB 11.1 ms 1.4 ms
DbEval() over ADRESSDB, tag REF_NR FOR TYP == 'K' (#2121) 3.9 s 2.3 s
GoTo/Skip pattern, ADRESSDB tag 1 26 s 3.3 s
GoTo/Skip pattern, AUFHDRDB tag 1 (descending) 5.4 s 4.5 s
GoTo/Skip pattern, PARADB (338 rows) 0.6 s 0.7 s

The GoTo/Skip pattern covers 40 records per table. For each one it does GoTo(), Skip(+1, +1, -3, -10, +25), an order switch with GoTo() and Skip(-1), Skip(+150) and Skip(-1), and Seek() of the record's key followed by Skip(-1, -15, +20) and a soft seek with Skip(-2). It ends with GoBottom(), Skip(-1) three times, Skip(-30) and Skip(+1)/Skip(-1) at EOF.

On small tables the keyset statements are slightly slower than OFFSET, which costs nearly nothing there: a few tenths of a millisecond per statement.

Deployment note: with ALTER DATABASE ... SET PARAMETERIZATION FORCED, the server reuses plans for seeks with new values instead of compiling every statement. In our measurements this saved another third of the SQL time per paging step: replaying the statements of one step took 110-145 ms with SIMPLE and 73-95 ms with FORCED.

Testing

  • Full reads: The recno sequence of a full read is identical to the OFFSET version, for natural order, tags with FOR conditions, function keys and descending tags.
  • DTOS keys: For 115 tags with DTOS keys of our ERP, the sequence and a seek check per sampled key prefix are unchanged. The seek check covers hard seek, the number of matching records, soft seek and SeekLast.
  • Navigation: The GoTo/Skip pattern above returns the same positions (recno, EOF, BOF) as before on 8 table/tag combinations: ascending, descending, FOR condition, key expression and natural order.

🤖 Generated with Claude Code

@RobertvanderHulst

Copy link
Copy Markdown
Member

@copilot resolve the merge conflicts in this pull request

ecosSystem and others added 7 commits October 27, 2026 12:31
Every query built a new DataTable from the reader's schema (DataTable.Load()).
For a query that returns one or a few rows of a wide table that is most of the
client side cost: measured 1.5 ms per query for 178 columns against 0.5 ms
when the rows are loaded into an existing structure. Interactive work (seeks,
relations, GoTo()) is mostly such queries - an order entry form of our ERP runs
about 140 of them per paging step.

The work area keeps an empty clone of its first buffer (all its queries use
the same column list) and SqlDbCommand.GetDataTable(cTable, oTemplate) loads
the rows into a copy of it with GetValues()/LoadDataRow(). If the reader's
columns do not match the template, the rows are loaded as before.

Seek on a 178 column table: 4.2 -> 3.3 ms, SetOrder/GoTo/SetOrder: 11.1 ->
8.3 ms per round (including the server round trips).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…needed

GoTo() to a record that is not buffered looked up its position in the current
order right away: a ROW_NUMBER() over the order plus a whole page around the
record. Code that saves the record number, seeks elsewhere and goes back, or
that follows relations, mostly just reads the record's fields afterwards; in an
ERP order entry form paging from one order to the next did this 4-5 times per
step on the order header table alone (each time ROW_NUMBER() and two pages of
a 150 column table).

GoTo() now loads just the record by its recno, the way an order change already
does (_LoadSingleRecord()), and the position is looked up by
_EnsurePositioned()/_AbsolutePosition() when Skip(), OrderKeyNo etc. need it.
When the buffer already holds such a single record, GoTo() positions fully as
before, so a browse that calls GoTo() for each row it paints still gets one
page with the neighbouring rows instead of one query per row.

DBI_GETLOCKARRAY/DBI_LOCKCOUNT no longer query the lock table when UseLocks is
off: no lock entries are written then, and code that checks the lock list after
every movement (RLockList, IsLocked()) paid a round trip each time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When the work area was at EOF at an order change (typically switching back to
the previous order after a seek that found nothing, before going back to the
saved record), the reposition did GoTo(0). After the cursor reset that first
loaded the first page of the new order and counted it. The order entry form of
our ERP did this 4 times per paging step on a 150 column table (~66 ms).

An empty result now provides the structure for the phantom record; EOF is
marked as pending as with GoTo(0), so Skip(1) stays at EOF and Skip(-1) goes to
the bottom.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The protection for browses (a second GoTo() in a row positions fully) also
fired when the single buffered record came from an order change. SetOrder()
followed by GoTo(saved record) is the usual way to restore a position, and it
then paid ROW_NUMBER() plus a page again. Only a single record loaded by the
previous GoTo() itself now counts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Switching orders around a lookup (SetOrder/Seek/SetOrder) read the current
record again after every switch, recounted the table and, at EOF, sent a
"WHERE 1=0" query only to get an empty buffer. The record was in the buffer a
moment before, so it is now kept (its position in the new order is still
looked up only when needed), LastRec() stays valid across the switch and the
empty buffer is built from the structure of the previous one.

A seek that finds nothing sets BOF itself, so it no longer counts the order
to compute it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The keyset condition read every key value of the last buffered row with a
subquery. Optimizing those took the server 30-45 ms per statement (executing
it 3 ms), and since the text changes with every page the plan was never
reused. Plain key columns with ASCII string or integer/decimal values are
now written as literals; other values still use the subquery. A sequential
read of ADRESSDB (52,000 rows, two column key) went from 3.9 to 2.4 s.

GoBottom() and looking up the position of a record now read a small page
(PageSize/10) instead of a full one, like a seek already does: code that
goes to a record mostly reads only it and its neighbour, and a full page of
a wide table costs about 10 ms. Reading on makes the pages grow, and a
read-ahead that started small still reaches a skip target in one page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Positioning on a record (after a lazy GoTo, Skip() from it) loaded a page
around it by OFFSET, which makes the server read through all rows before it:
10 ms for a 10 row page at offset 500 of a 1000 row table, 2 ms when the
rows are found by key. Reading backwards had the same cost on every page.

The page now starts at the record and is found by its key (keyset condition
including the record). When its position is not known yet, the buffer is
anchored at the record and the position is only looked up when needed.
Reading backwards finds the preceding rows by the key of the first buffered
row, sorted in reverse, also on a seek-anchored buffer (which is anchored at
its new first row), and starts small after a GoTo or GoBottom.

Both only use literal key values: an order on a key expression keeps using
offsets, because a keyset subquery costs more to compile than the OFFSET.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ecosSystem
ecosSystem force-pushed the homebase/sqlrdd-navigation branch from 2a5a79f to eb22598 Compare October 27, 2026 11:37
@RobertvanderHulst
RobertvanderHulst merged commit e1f6ef8 into X-Sharp:dev Oct 27, 2026
2 checks passed
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.

2 participants