Repository navigation
dialect/sql/sqlpage: a page after a cursor starts reading at it - #22
Merged
Merged
Conversation
A field named `field` generated a mutation that did not compile (#18). Its own methods would be AddField, AddedField, ClearField and FieldCleared, which every mutation already has, and the two halves of the template disagreed about what to do about that. The namers renamed Add and Added -- AddFieldField, AddedFieldField -- but the dispatchers that take a field by its name called the field's methods by the plain name, which reached the generic ones with the wrong arguments; and Clear and Cleared were never renamed, so they were declared a second time. `fields` (AddedFields), `edge` (ClearEdge, EdgeCleared) and `ids` (AddedIds) broke the same way. The namers decide now, and everything calls through them. MutationClear and MutationCleared ask mutMethods as the others do: ClearFieldField, and FieldFieldCleared, which puts the word where an edge's EdgeEdgeCleared puts its own. The dispatchers, the update builders' Clear, sqlSave and update defaults, and an update's check of a required unique edge call the namer's name instead of spelling one. mutMethods was ent.Mutation's methods and four others. It lists every method a generated mutation has beside the interface's now. One it missed was Ids, upstream's IDs spelled by this fork's rule: that is also the getter of a field named `ids`, which the ent package's mutation shadowed. Nothing that compiled is named differently. Every method renamed is one whose old name did not compile, and the generated trees here are unchanged. entc/integration/fieldnames has an entity with a numeric, optional field of each of those names, and one with an update default on `field` and a required unique edge named `edge`. It compiles, which it did not, and on every engine each field is written, filtered and ordered by, added to and cleared -- by the builders, and by a hook through the methods that take a field's name. In entc/gen, TestField_MutationNames names a field after every method in mutMethods, and after what is left of one without a namer's prefix or suffix, and fails a namer that answers a method every mutation has; TestMutationMethods reads the methods of a generated mutation and fails one mutMethods does not list. Both fail without this change. Upstream has the same code, but for Ids. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KxtnpRW533bdErnW8QPhsk
After narrowed a page to the rows past the cursor with the order
written out,
c1 > a1 OR (c1 = a1 AND c2 > a2)
which is the right rows and, on PostgreSQL, no range to start an index
at (#19). PostgreSQL used the index for the rest of the query and
applied the OR to every row from the first, so each page read every row
before it. On a million rows, the page of 500 after the middle of one
tenant's 500,000 read and threw away 250,001 of them: 802 ms cold and
30 ms warm, where it now takes 0.19 and 0.13 ms. A walk through that
tenant in pages of 500 took 65.6 s and takes 0.75 s. Every generated
List and Watch that pages by a cursor goes through here, so every one
paid it deep into a large table. lesomnus/payday#154 found the same
shape in payday's own trail paging and fixed it there.
"After" is said now in the shape each engine starts reading at:
- On PostgreSQL and SQLite, when the order runs one way, the row value
`(c1, c2) > (a1, a2)`, or `<` when it descends. PostgreSQL's Index
Cond is then the tenant and the pair, in a custom plan and in a
generic one, and SQLite's search is constrained by the pair.
- Otherwise the OR, behind an inclusive bound on the columns that lead
the order one way: `c1 >= a1`, or a row value of them where the
engine takes one. A row value is wrong across a change of direction.
The bound is implied by the OR and is where the index starts, and
what the read passes over is the rows tied with the cursor in those
columns. The page of a mixed order, (alias DESC, id), after the
middle went from 697 ms and 250,001 rows thrown away to 1.4 ms and
one.
- MySQL and MariaDB get the OR, behind the same bound. They read the OR
as ranges of the index already, the first starting at the cursor,
and the bound as the same range. A row value they do not seek on:
on 200,000 rows MySQL 8.0 and 8.4 and MariaDB 10.11 and 11.4 each
looked up the tenant's rows and filtered 26,301 of them to answer 50.
One column stays one comparison. The bound alone in front of the OR,
for every order, was the other way to a seek on PostgreSQL, and
payday#154 found what it costs: it reads a run of rows sharing one c1
again from its first on every page, and a one-way order need not pay
that.
entc/integration/keyset pages a tenant's rows in four orders -- up,
down, mixed, and mixed after two columns one way -- across pages that
end inside runs of one c1, one run longer than a page, with another
tenant's rows between, and holds every engine to every row once, in
the order asked. On PostgreSQL it runs each page again under EXPLAIN
ANALYZE, with sequential and bitmap scans off, and fails one whose
index scan threw away more rows than are tied with the cursor in the
columns its bound covers. On SQLite it reads the plan for a search
constrained by the order's first column. With the OR alone back, every
order fails on both from its second page; with the bound alone in
front of the OR, PostgreSQL fails the one-way orders and the one that
leads with two columns one way.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KxtnpRW533bdErnW8QPhsk
lesomnus
added a commit
to lesomnus/payday
that referenced
this pull request
Oct 24, 2026
The same code as 4c7bfb2, which this pinned while protobuf-orm/ent#22 was open: the merge commit is master's, and runtime.go carries its stamp. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KxtnpRW533bdErnW8QPhsk
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.
Fixes #19.
On top of #21. This branch's first commit is #21's; merge #21 first, and this PR's diff is then its own commit, 4c7bfb2. lesomnus/payday#173 pins 4c7bfb2, which a
--mergekeeps in master's history.What was wrong
sqlpage.Afternarrowed a page to the rows past the cursor with the order written out:That is the right rows on every engine, and on PostgreSQL it is no range to start an index at. PostgreSQL uses the index for the rest of the query and applies the OR to every row from the first, so each page reads every row before it, and paging through a table costs the square of its size. Every generated
ListandWatchthat pages by a cursor goes throughAfter. lesomnus/payday#154 found the same shape in payday's own trail paging and fixed it there with a row value.What changed
Aftersays "after" in the shape each engine starts reading at:(c1, c2) > (a1, a2), or<when it descends. It is the whole bound.c1 >= a1, or(c1, c2) >= (a1, a2)where the engine takes a row value. A row value is wrong across a change of direction. The bound is implied by the OR and is where the index starts; what the read passes over are the rows tied with the cursor in those columns.The bound alone in front of the OR, for every order, would also seek on PostgreSQL. payday#154 found what it costs: it reads a run of rows sharing one
c1again from its first on every page, and an order that runs one way need not pay that.UPSTREAM.md's Paging row says whatAfterdoes now.Measured
PostgreSQL 17, a table of 1,000,000 rows in two tenants of 500,000,
(tenant_id, date_created, id)and(tenant_id, alias, id)indexed,date_createdthree rows to an instant. The page of 500 after the middle of one tenant's rows, asAfterwrites it (EXPLAIN (ANALYZE, BUFFERS)of a prepared statement, first run and second):date_created, idtenant_id = …tenant_id = … AND ROW(date_created, id) > ROW(…)alias DESC, idtenant_id = …tenant_id = … AND alias <= …The generic plan (
plan_cache_mode = force_generic_plan) keeps it:Index Cond: ((tenant_id = $1) AND (ROW(date_created, id) > ROW($2, $3))), 0.21 ms.A walk through one tenant's 500,000 rows in pages of 500, through
Afteritself: 65.6 s with the OR, 0.75 s now.MySQL and MariaDB, 200,000 rows,
(tenant, c1, c2)indexed, the page of 50 after the middle of one tenant's (EXPLAIN ANALYZEon MySQL,ANALYZEon MariaDB):(tenant = 1 AND c1 = a AND b < c2) OR (tenant = 1 AND a < c1), 50 rows readrange, key_len 12, r_rows 50c1 >= atenant = 1, 26,301 rows read and filteredref, key_len 4, r_rows 26,301SQLite (3.53.4, as
ncruces/go-sqlite3bundles it): the OR was searched by(tenant=?)alone, and the row value by(tenant=? AND (c1,id)>(?,?)).Tests
entc/integration/keysetpages a tenant's rows in four orders -- up, down, mixed, and mixed after two columns one way -- across pages that end inside runs of onec1, one run longer than a page, with another tenant's rows between them.EXPLAIN ANALYZE, with sequential and bitmap scans off so that a small table is planned as a large one is, and fails if its index scan threw away more rows than are tied with the cursor in the columns its bound covers.Afterback, every order fails on PostgreSQL and SQLite from its second page. With the bound alone in front of the OR, PostgreSQL fails the one-way orders and the one that leads with two columns one way ("a page read 55 rows by Index Scan keyset_c1 (((tenant = 1) AND (c1 >= 8))) and threw 5 of them away").dialect/sql/sqlpagerenders the predicate on each dialect.go test -race ./...indialect;.github/lint.shover the files changed;entc/integration/keyseton all six engines; the wholeentc/integrationwith-race -count=2on all six engines. Twenty packages passed together; the root package,enttimeandmigrateran intogo test's ten minutes on this machine, which was loaded throughout (load average 14 to 20 on 16 cores), and passed run one at a time (-p 1 -timeout 60m). CI ran the same in about four minutes.Anything else with the OR
Nothing else in this repository pages by an expanded OR:
sqlpageis the one keyset. In lesomnus/payday, ent#154 took the trail's four reads off it, and the generatedListandWatchare this.🤖 Generated with Claude Code
https://claude.ai/code/session_01KxtnpRW533bdErnW8QPhsk