Repository navigation
SQLRDD: large tables - date and non-ASCII keyset literals, direct SeekLast and GoBottom, no deep OFFSET, OPTION (RECOMPILE) for keysets - #2133
Merged
RobertvanderHulst merged 11 commits intoOct 28, 2026
Conversation
The keyset condition of a page used a subquery for every value that is not a string or a number, such as a date. SQL Server could then not seek on the index of the order: paging through an order on BEL_ART + DTOS(DAT_HINZU) + BEL_NR of a table with 3.8 million rows read the whole table for every page (665,762 logical reads against 1,941 with a literal). A provider can now return a literal for a date without a time of day (SqlDbProvider.DateLiteral(), default NULL = subquery as before). SQL Server returns 'yyyyMMdd', which is read the same way for all date types, whatever the language and DATEFORMAT of the session. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…field A single equals compares like a prefix in xBase, so field = 'A' became field LIKE 'A%'. When the literal is as long as the character field, the prefix is the whole field and the compare is an equality. For the server a LIKE is a range, though: with TYP = 'L' (TYP C(1)) as the FOR condition of an order on ART_NR + REF_NR, SQL Server sorted all 477,757 rows of type L for the first page (95,321 logical reads, 8 s) instead of reading them in the order of the index (319 reads, 0.19 s). Such literals are now compared with =. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Seek() with Last found the first record of the key and skipped from there to the last one, reading every record of the key page by page. Our ERP opens its order entry with a SeekLast for the newest order of a type on BEL_ART + DTOS(DAT_HINZU) + BEL_NR: with 3.8 million orders it had not arrived after 80 seconds and 310,000 records. The last record of the key is now read with the order reversed (one row) and positioned on like after GoTo(): its position in the order is looked up when something moves from it. With a filter, without a recno column, or when the key has no record, Seek() continues as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A keyset condition k1 >= v1 AND (k1 > v1 OR (k1 = v1 AND k2 > v2) OR ...) only becomes seeks on the index when SQL Server knows the values. With PARAMETERIZATION FORCED, or with a plan from the cache, it seeks on k1 alone and reads every row of v1 before the position. Paging the lines of an order near the end of a table with 5.4 million rows took 675 ms per page (6,641 logical reads for 10 rows); with OPTION (RECOMPILE) 4 ms. On small tables the read is cheap and compiling every page costs more (navigation tests took 1.5 to 2 times as long), so the hint is only added for tables with at least 100,000 rows, taken once per connection from the statistics (sys.partitions), without counting. New provider members: OrQueryHint, OrQueryHintMinRows and TableRowsStatement (all empty by default, so other databases are not affected). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GoBottom() reads the last page in reverse, but counted the order before that, only to know the position of the page. The count is invalidated by every Seek(), so a list of the lines of an order in our ERP, which calls GoBottom() on every move to another order, counted the order of a table with 5.4 million rows each time (46 ms, 72 times while paging through orders). When no valid count is cached, the last page is now anchored at its first row, as after GoTo(), and its position is looked up when something needs it (OrderKeyNo, a far skip back). With a valid count, a page shorter than asked for, or a key that cannot be expressed, the position is set as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ips back - GoBottom() read the last page in reverse only for ascending orders. On a descending order it counted the order and read the page with OFFSET: OFFSET 3768900 in an order of 3.8 million orders did not finish within 30 seconds (the selection list of orders in our ERP). The reverse of a descending order is now built item by item, like the backward keyset reads do. - A skip back by more than a page read the rows by position: 150 rows back from the end of the same order needed OFFSET 3768805 and took two minutes. Up to what the buffer holds, the rows before the buffer are now read by key, also for an anchored buffer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A soft seek or a scope on a concatenated key compares the column where the value ends as a prefix: SUBSTRING(c, 1, n) >= v. With the function the server cannot seek on the index: a soft seek in a descending order of 3.8 million orders took 0.7 to 1.7 seconds. The same conditions without it: SUBSTRING(c, 1, n) >= v is c >= v SUBSTRING(c, 1, n) <= v is c < v OR c LIKE 'v%' They also compare like the ORDER BY. With an accent-sensitive collation, SUBSTRING(NAME, 1, 3) >= 'Zäh' is FALSE for 'Zahlungseingang', which the order sorts after 'Zählprotokoll' (a and ä have the same primary weight): the record was left out of the result of the seek, and skipping on from the last 'Zäh...' record ended at EOF instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Keyset conditions only wrote ASCII strings as literals: a literal is converted to the code page of the database, and a character outside it would be changed. Keys with umlauts therefore fell back to the position of the record: a soft seek on 'GÜN' in the customers by short name read the page at OFFSET 323,815 of 1.1 million addresses, which took more than a minute. The connection now reads the code page of the database once (new provider member CodePageStatement, SQL Server: the code page of the database collation), and a string is written as a literal when all its characters survive a round trip through that code page. Without a code page it stays ASCII only, and a backslash still falls back to the subquery. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A key like ART_NR+LISTE+STR(MENGE,5,0) has a function in it, so it was sorted, sought and paged as the whole concatenated text. The server had to compute that text for every row: a price lookup in a table with 7 million rows read 118,000 pages, and the page before the found record (keyset condition with subqueries on the text) 212,000 pages in 16.7 seconds. STR(column, n, d) is now a key part of width n, like DTOS() since X-Sharp#2122: the order is ART_NR, LISTE, STR(MENGE,5,0), so an index on the leading columns gives the order, and seeks are conditions per column. Unlike DTOS(), the text of STR() does not sort like the number (negative values), so the order keeps the STR() expression for that part. Its keyset literal is the right aligned text of the value, only when the value has no more decimals than d and fits into n characters (otherwise the subquery as before). The same skip back now takes 1.1 instead of 39.9 seconds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e tables Not only a keyset condition with OR depends on the values: with forced parameterization a range shares one plan for all values as well. A plan from the cache for TYP = @0 AND KURZNAME >= @1 ORDER BY KURZNAME, made for the 1.1 million customers (TYP 'K'), read the short name index in order and looked up TYP for every row. For a soft seek among the 488 suppliers (TYP 'L') near the end of the names it read 720,398 pages in 107 seconds to find nothing; recompiled, 418 pages in 6 ms. The hint now applies when the whole condition of the query (with the condition of the order and the scope) has OR, LIKE or range terms: keyset conditions, soft seeks, scopes, prefix seeks. A condition of equalities only, such as the many relation seeks, keeps the plan from the cache; it is a seek for any value. The provider members are renamed to RangeQueryHint and RangeQueryHintMinRows accordingly, and GoBottom() of an ascending order gets the hint as well. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The STR() part of a key like ART_NR+LISTE+STR(MENGE,5,0) was sorted by its
text, to keep the order of DBF exactly. An index on (ART_NR, LISTE, MENGE)
then only gives the order of the leading columns: for the rows behind a
position (the next page after a seek) the server sorted all rows of the
table behind it first. Opening the purchase orders in our ERP looks up
prices this way for every line: 118,278 reads and 6.5 s CPU per lookup,
against 43 reads sorted by the number.
The STR() part is now sorted by the column, and a seek value that covers the
part compares the number ([MENGE] = 1 instead of STR([MENGE],5,0) =
' 1'). The text sorts like the number for values from 0 up to the width.
Unlike DBF, negative values now come before 0 and values wider than the
part ('*****') at the end. The keyset literal is the number, so the helper
for the text literal is gone again.
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.
These came out of moving a real customer dataset of our ERP to SQL Server: 3.8 million orders, 5.4 million order lines, 1.1 million addresses, 72 GB. With the small test database everything was fine. With this one, the program start took almost a minute, the order entry did not open within two minutes, and selection lists (F8) and the article master hung, a search in the suppliers did not finish within two minutes, and the purchase orders took 15 seconds to open.
The branch is based on the current
dev(after #2128). Each commit is one fix. Measurements are on SQL Server 2025 Developer with the database set toPARAMETERIZATION FORCED(which saves about a third of the SQL time for the many small statements of the ERP).What changed
9fc4cee490). The keyset condition of a page wrote a subquery for every value that is not a string or a number. SQL Server could then not seek on the index: paging through BEL_ART + DTOS(DAT_HINZU) + BEL_NR of 3.8 million orders read the whole table for every page (665,762 logical reads, against 1,941 with a literal). New provider methodDateLiteral()(default NULL = subquery as before); SQL Server returns'yyyyMMdd'for a date without time, which is read the same way for all date types, whatever the language and DATEFORMAT.field = 'literal'as an equality when the literal covers the field (492e94ca46). A single equals is a prefix compare in xBase, soTYP = 'L'becameTYP LIKE 'L%'. For a C(1) field that is an equality, but for the server a LIKE is a range: withTYP = 'L'as the FOR condition of ART_NR+REF_NR it sorted all 477,757 rows of type L for the first page (8.1 s); with=0.19 s. The column length comes from the table definition, because the fields of the work area do not exist yet when the production index is parsed.8270cb620b). Seek() with Last found the first record of the key and skipped to the last one. Our order entry opens with a SeekLast for the newest order of a type; it read every order of that type page by page and had not arrived after 80 seconds. The last record is now read in reverse (one row) and positioned on like after GoTo(). With a filter, without a recno column, or when the key has no record, Seek() continues as before.37c15bb6cd). A keyset conditionk1 >= v1 AND (k1 > v1 OR (k1 = v1 AND k2 > v2) OR ...)only becomes seeks when the server knows the values. With forced parameterization (or a cached plan) it seeks on k1 alone and reads all rows of v1 before the position: paging the lines of an order near the end of 5.4 million rows took 675 ms per page, with the hint 4 ms. On small tables compiling every page costs more than it saves (navigation tests took 1.5 to 2 times as long), so the hint is only added for tables with at least 100,000 rows, read once per connection fromsys.partitions. New provider members (renamed in commit 10)RangeQueryHint,RangeQueryHintMinRows,TableRowsStatement, empty by default.0264e53a1e). GoBottom() read the last page in reverse, but counted the order before, only to know the position of the page. The count is invalidated by every Seek(), and a list of order lines calls GoBottom() on every move to another order: 72 counts of 5.4 million rows (46 ms each) while paging through orders. Without a valid count the last page is now anchored at its first row, as after GoTo(), and the position is looked up when something needs it.a0b95477b2). On a descending order GoBottom() still counted and read the page withOFFSET 3768900, which did not finish within 30 seconds (the order selection list). The reverse of a descending order is now built item by item, like the backward keyset reads do. A skip back by more than a page also used the position (OFFSET 3768805, two minutes for 150 rows back from the end); up to what the buffer holds, the rows before it are now read by key.a1d9307e49). The column where a soft-seek value ends was compared asSUBSTRING(c, 1, n) >= v, which keeps the server from using the index. Nowc >= v, and for the upper boundc < v OR c LIKE 'v%'. The lower bound is fast now (610 instead of about 17,000 reads); the upper bound still is not, because SQL Server does not seek on a LIKE inside an OR, and under a collation like Latin1_General_CI_AS there is no exact "next value" to use instead. Both now compare like the ORDER BY: with an accent-sensitive collationSUBSTRING(NAME,1,3) >= 'Zäh'was FALSE for 'Zahlungseingang', which sorts after 'Zählprotokoll', so that record was left out of the seek result and skipping on ended at EOF.ae71f77367). Keyset literals were ASCII only, because a literal is converted to the code page of the database. A soft seek on 'GÜN' in the customers by short name therefore fell back to the position of the record:OFFSET 323815of 1.1 million addresses, more than a minute. The connection now reads the code page of the database once (new provider memberCodePageStatement, SQL Server: the code page of the database collation), and a string is written as a literal when all its characters survive a round trip through that code page.cf36f8b65e). A key like ART_NR+LISTE+STR(MENGE,5,0) has a function in it, so it was sorted, sought and paged as the whole concatenated text, which the server computes for every row: a price lookup in 7 million rows read 118,000 pages, the page before the found record (keyset condition with subqueries on the text) 212,000 pages in 16.7 s. STR() is now a key part of width n, like DTOS() since SQLRDD: sort keys like REF_NR+DTOS(DATUM) by their columns #2122, and seeks are conditions per column. In this commit the STR() part was still sorted by its text; commit 11 sorts it by the number. The same skip back takes 1.1 s instead of 39.9 s.55e632a88a). Not only a keyset condition with OR depends on the values: with forced parameterization a range shares one plan for all values as well. A plan from the cache forTYP = @0 AND KURZNAME >= @1 ORDER BY KURZNAME, made for the 1.1 million customers (TYP 'K'), read the short name index in order and looked up TYP for every row. For a soft seek among the 488 suppliers (TYP 'L') near the end of the names it read 720,398 pages in 107 s to find nothing; recompiled, 418 pages in 6 ms. The hint now applies when the whole condition of the query (with the condition of the order and the scope) has OR, LIKE or range terms: keyset conditions, soft seeks, scopes, prefix seeks. A condition of equalities only, such as the many relation seeks, keeps the plan from the cache; it is a seek for any value. The provider members of commit 4 are renamed toRangeQueryHintandRangeQueryHintMinRows.9daffc4311). Sorted by its text, the STR() part kept an index on (ART_NR, LISTE, MENGE) from giving the order: for the rows behind a position (the next page after a seek) the server sorted all rows behind it first. Opening the purchase orders looks up prices this way for every line: 118,278 reads and 6.5 s CPU per lookup, against 43 reads sorted by the number. The STR() part is now sorted by the column, and a seek value that covers the part compares the number ([MENGE] = 1instead ofSTR([MENGE],5,0) = ' 1'). Behaviour change: the text sorts like the number for values from 0 up to the width; unlike DBF, negative values now come before 0, and values wider than the part ('*****') at the end. Neither occurs in the price table of the dataset (7 million rows).Result
Program start plus order entry, measured on the SQL Server side:
The remaining time is mostly client side (round trips: a list page of orders runs about 100 relation seeks) and, on this 16 GB test machine, physical reads.
Testing
ResultCacheMs: navigation patterns on 9 table/order combinations, EOF/BOF sequences, sequential reads, and full-order sequences plus Seek/SoftSeek/SeekLast checks for the 115 DTOS tags and 23 tags withfield = 'x'conditions. New in this round: a GoBottom test (far skips, OrderKeyNo, Seek/GoBottom/SeekLast, on 10 orders including descending ones) and Skip(-1)/Skip(2) after SeekLast plus a soft SeekLast behind the key. 602 result files (with ARTPRSDB orders that have a STR() part added for commit 9; the test database has no table above 100,000 rows, so commit 10 is tested on the large one), no differences, except the two of the 'Zäh' case in commit 7, where the new result is the correct one.Not in this PR: in our ERP the SQL indexes for tags with a FOR condition now include the columns of the condition (equality columns first, the others as INCLUDE). Without them the server has to read every row of the table to find those of the tag (571,427 instead of 343 reads for a tag with 81 of 1.7 million rows). We create those indexes in the application;
SqlDbTableCommandBuilder.CreateIndex()might want the same.🤖 Generated with Claude Code