Skip to content

SQLRDD: fast sequential reads (keyset paging, read-ahead, no constraint indexes on loaded pages) - #2121

Merged
RobertvanderHulst merged 4 commits into
X-Sharp:devfrom
ecosSystem:homebase/sqlrdd-sequential-read
Oct 27, 2026
Merged

RobertvanderHulst merged 4 commits into
X-Sharp:devfrom
ecosSystem:homebase/sqlrdd-sequential-read

Conversation

@ecosSystem

@ecosSystem ecosSystem commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

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:

  • the ORDER BY items plus the recno column are compared lexicographically with their values for that row,
  • the values are read by subqueries on the row's 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 exactly the ones OFFSET would return, so the buffer coordinates do not change.

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 BufferSize pages 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 / 10 rows) 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 with DataTable.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 with EnforceConstraints = 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:

order before after
natural (recno) 36.3 s 2.0 s
tag REF_NR FOR TYP == 'K' 54.8 s 3.9 s
tag TYP+SUBSTR(PLZ,1,1)+KURZNAME 205 s 34 s

The 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

  • The recno sequence of the full read is identical to the OFFSET version for natural order, tags with FOR conditions, function keys and descending tags.
  • Row and key counts, GoTop/Skip/GoBottom/Skip(-n) over all 297 tables and about 1,065 tags of our ERP are unchanged.

🤖 Generated with Claude Code

ecosSystem and others added 4 commits October 26, 2026 10:52
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>
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