Fix job list performance and SQLite pagination - #1374
Open
bgentry wants to merge 2 commits into
Open
Conversation
bgentry
force-pushed
the
bg/job-list-finalized-index
branch
7 times, most recently
from
September 10, 2026 01:14
e8e6290 to
05cde7f
Compare
Listing one finalized state by time can scan and sort the entire job table. PostgreSQL cannot use the partial finalized-time index without an explicit non-null predicate, and singleton ANY prevents it from using the index's time ordering. Build state equality with the list API's existing condition mechanism when custom SQL cannot depend on the existing array argument. Add `finalized_at IS NOT NULL` only for one known finalized state ordered by finalized time. Keep the shared builder and drivers unchanged. Preserve custom SQL, multi-state filters, and deletion queries. Build cursor predicates locally so conversion preserves the caller's conditions. Cover finalized states, both ordering directions, tied timestamps, PostgreSQL pagination, and combined filters across supported drivers. Check custom OR expressions, contradictory conditions, argument binding, and unchanged delete-many query generation.
bgentry
force-pushed
the
bg/job-list-finalized-index
branch
from
September 10, 2026 01:19
05cde7f to
e50368a
Compare
SQLite stores job timestamps as formatted text, while JobList passes cursor values directly to database/sql. Different encodings can skip or repeat jobs when a page boundary shares a timestamp. Format `time.Time` and `*time.Time` list arguments with SQLite's existing timestamp helpers. Copy the argument map so conversion leaves reusable parameters intact, including custom conditions and nullable values. Enable finalized-job pagination coverage for SQLite, libSQL, and Turso. Cover scheduled-job pagination, millisecond precision, time zones, nullable arguments, and repeated use of driver parameters.
bgentry
marked this pull request as ready for review
September 10, 2026 01:28
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.
Listing completed jobs can scan the entire job table even though an index exists for this query. Earlier work explicitly aimed to use that index (#304). The
finalized_at IS NULL/IS NOT NULLconditions may have been lost along the way when tweaking the job list API or moving this logic out of its original home in riverui.When filtering by one state, use
state = ...instead ofstate = ANY(...). For completed, cancelled, or discarded jobs sorted by finalized time, also addfinalized_at IS NOT NULL. Both changes are needed to make the measured query fast. River UI benefits without any changes to its calls. This optimization skips queries with custom SQL, multiple states, and job deletion.Why use = instead of ANY when filtering by one state?
A one-element
ANYarray selects the same rows as=, but PostgreSQL does not necessarily plan them the same way. There is also a distinction betweenstate IN ('completed'), which PostgreSQL simplifies tostate = 'completed', andstate = ANY(ARRAY['completed']), which does not receive the same simplification in the measured PostgreSQL 17.11 plans.Tom Lane explains the distinction in his response to PostgreSQL bug #17922. Equality lets the planner recognize that a column has one fixed value, making sorting by that column unnecessary. About applying that reasoning to
ANY, he writes:His example involves a join and an
ORDER BY, but the fixed-column reasoning also explains the behavior observed in River's query. River's existing index is:The index orders entries by state first, then finalized time. With
state = $2, the planner knows every matching row has the same state, so the remaining index order is useful forORDER BY finalized_at DESC, id DESC. It can scan the index backward, sort jobs with equal timestamps by ID, and stop after enough jobs have been found. The measured plan calls this anIncremental Sort. Withstate = ANY($2), the measured plans instead scan the table and sort the matching jobs before applying the limit.The explicit
finalized_at IS NOT NULLcondition solves a separate problem. PostgreSQL must recognize that the query only requests rows covered by the partial index. Although River's supported schemas enforce non-null finalized timestamps for finalized states, the measured planner does not infer the index condition from the state filter. Adding it explicitly makes the index eligible. This follows PostgreSQL's partial-index requirements.Both changes are necessary for the measured query. Using the normal job-column projection, a parameterized limit of 100, and 888,000 jobs on local PostgreSQL 17.11:
state = ANY($2)state = ANY($2)state = $2state = $2These were forced custom and generic prepared plans. A custom plan uses the supplied parameter values; a generic plan is reusable without depending on those values. In a generic plan, an array parameter could contain several states, whereas
state = $2still restricts the query to one state. The customANYplan was slow too, even though its array contained onlycompleted. See PostgreSQL's prepared-plan documentation.The successful plans read 103 rows through the existing index and perform a small incremental sort; the other three forms scan all 888,000 jobs. These are individual measurements, not averages. This does not mean
ANYcannot use indexes or that replacing it always improves performance: the benefit here depends on one state, the index's column order, and the query's ordering and limit. Queries selecting multiple states retainANY.A separate commit fixes SQLite pagination skipping or repeating jobs. The SQLite driver now formats cursor timestamps consistently with stored timestamps, preventing incorrect comparisons.
Listing 100 completed jobs on a local PostgreSQL 17.11 database with 888,000 jobs:
The fixed query uses the existing index and reads 103 rows instead of scanning 888,000. These are individual measurements, not averages.