Repository navigation
SQLRDD: fast sequential reads (keyset paging, read-ahead, no constraint indexes on loaded pages) - #2121
Merged
RobertvanderHulst merged 4 commits intoOct 27, 2026
Conversation
Reading forward through an order fetched every next page with ORDER BY ... OFFSET n ROWS FETCH NEXT PageSize ROWS. The server has to step over all n rows for every page, so a sequential read is quadratic: DbEval() over a 52,000 row table took 521 queries with an OFFSET growing up to 52,000 (36 s in natural order, 55 s in a tag order, against 5 s with ADS). _FetchForward() now asks for the rows that follow the last buffered row in the current order (keyset paging): the ORDER BY items plus the recno column are compared lexicographically with their values for that row, read by subqueries on its recno, so the comparison uses the same expressions and collation as the ORDER BY, also for keys with functions and for descending orders. A leading "k1 >= v1" bound lets the server seek in the index. The fetched rows are the same as with OFFSET, so the buffer coordinates do not change. OFFSET remains for a non-contiguous buffer, tables without recno column and when the last row has NULL in a key column. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A sequential read (DbEval(), a count, a report) fetched PageSize rows per query. With the keyset paging the queries are cheap, but for an index order every page still costs a seek plus key lookups. _FetchForward() now doubles the number of rows it asks for with every full page that follows the previous one, up to BufferSize pages and never more than the buffer holds without evicting the current row. Any other repositioning (open, seek, backward page, reload at an offset) starts again with one page, so single seeks and browsers read as before. ADRESSDB (52,000 rows, 154 columns), DbEval() in tag order: 9.4 s -> 3.9 s. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SqlDbCommand.GetDataTable() filled each page with DataTable.Load(reader). Load takes NOT NULL, unique etc. from the reader's schema and validates the constraints when it is done, which creates a sort index for every column that does not allow NULL. The indexes stay attached to the table: the RDD keeps the first page table as its row buffer and adds/removes every following row there, so each of these indexes was maintained per row. For a table with 154 NOT NULL columns this index maintenance was most of the time of a sequential read (profiled: ~80% in DataTable index insert/compare). The table is now loaded inside a DataSet with EnforceConstraints = FALSE, so the validation and its indexes are skipped. The RDD already switched the constraints off on its buffer after loading (via reflection) and resets AllowDBNull, it just came too late to avoid the indexes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
After a seek the buffer holds the records with the sought key (often one), and the next Skip() fetched a full page behind it. Code that seeks and then skips while the key matches mostly reads one or two more records; on a 150 column table that page cost 35-40 ms each time (twice per paging step in the order entry form of our ERP). The read-ahead now counts rows instead of pages. After a seek the first forward page is PageSize/10 rows, and it doubles with every full page that follows, up to BufferSize pages as before; everything else starts with PageSize. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.
Reading a table front to back was slow: for example
DbEval(), a count with a condition in a browse window, or a report. Counting ADRESSDB (52,000 rows, 154 columns) took about 45 s on SQL Server against 5 s with ADS. There were two causes, one commit each, plus a read-ahead.1. OFFSET paging was quadratic
Every next page was fetched with
ORDER BY ... OFFSET n ROWS FETCH NEXT PageSize ROWS, so the server stepped over all n rows for every page. For ADRESSDB that was 521 queries with OFFSET up to 52,000._FetchForward()now uses keyset paging. It asks for the rows that follow the last buffered row in the current order:k1 >= v1bound lets the server seek in the index,OFFSET remains for a non-contiguous buffer, for tables without a recno column, and when the last row has NULL in a key column.
2. Read ahead
When pages are read one after the other, every next fetch asks for twice as many rows as the previous one. This goes up to
BufferSizepages and never beyond what the buffer holds without evicting the current row. Opening, paging backward or reloading at an offset starts again with one page, so browsers read as before.After a hard seek the read-ahead starts small (
PageSize / 10rows) and doubles from there. Code that seeks and then skips while the key matches mostly reads only a few records, and a full page of a wide table costs about 10 ms to load. A seek that is followed by a long scan still reaches full pages after a few fetches.3. Constraint indexes on every loaded page
SqlDbCommand.GetDataTable()filled pages withDataTable.Load(reader). Load takes NOT NULL from the reader's schema and validates the constraints when it finishes, which builds a sort index for every non-nullable column. Those indexes stay attached to the table, and the RDD keeps the first page table as its row buffer, so with 154 NOT NULL columns every row added to or removed from the buffer maintained 154 indexes. Profiling showed about 80 % of the time in DataTable index inserts and string compares. Pages are now loaded inside a DataSet withEnforceConstraints = FALSE. The RDD already switched the constraints off on its buffer afterwards (via reflection), but too late to avoid the indexes.Measurements
DbEval()over ADRESSDB, 52,025 rows, SQL Server 2022 Express:REF_NRFORTYP == 'K'TYP+SUBSTR(PLZ,1,1)+KURZNAMEThe last one stays slow because the server has to sort the computed key for every page. No index can serve that order without an additional computed column.
Testing
🤖 Generated with Claude Code