Repository navigation
SQLRDD: cheaper interactive navigation (lazy GoTo, order switches, keyset windows and backward reads) - #2123
Merged
RobertvanderHulst merged 7 commits intoOct 27, 2026
Conversation
Member
|
@copilot resolve the merge conflicts in this pull request |
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
force-pushed
the
homebase/sqlrdd-navigation
branch
from
October 27, 2026 11:37
2a5a79f to
eb22598
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Rebased onto the current
devafter #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
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, andSqlDbCommand.GetDataTable(cTable, oTemplate)loads the rows into a copy of it withGetValues()/LoadDataRow(). If the columns do not match, the rows are loaded as before.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_LOCKCOUNTno longer query the lock table whenUseLocksis off.88083310d0,fea0681d12).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.b9d4aabfae). GoBottom() and looking up the position of a record readPageSize / 10rows 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.eb225987cb).OFFSET naround 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.BuildSqlStatement(..., lReverse)), also on a seek-anchored buffer, which is then anchored at its new first row.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.REF_NRFORTYP == 'K'(#2121)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
🤖 Generated with Claude Code