Skip to content

Optimize task dispatch queries and bound listing payloads - #1260

Merged
anfimovdm merged 2 commits into
masterfrom
optimize-task-dispatch-and-listings
Oct 1, 2026
Merged

anfimovdm merged 2 commits into
masterfrom
optimize-task-dispatch-and-listings

Conversation

@anfimovdm

Copy link
Copy Markdown
Contributor

Summary

Part of AlmaLinux/build-system#553: two slow paths fixed. The task dispatch queries locked and loaded far more rows than they hand out, and two listing endpoints return more data than their callers read.

Claim build, test and sign tasks as queue pops

  • get_available_build_task(): LIMIT 1 + FOR UPDATE OF build_tasks SKIP LOCKED. Before, one poll locked every eligible task and ran its 7 eager loaders over all of them, and concurrent build nodes blocked on each other's locks.
  • get_available_test_tasks(): adds SKIP LOCKED (it already had LIMIT 10), so concurrent schedulers take disjoint batches.
  • get_available_sign_task(): before, it read idle tasks without a lock and then ran an unconditional UPDATE, so two sign nodes could claim the same task. It now uses the same locked queue-pop shape.

Bound errata and product listing payloads

  • GET /errata/query/: pageNumber defaults to 1. The response shape is unchanged (ErrataListResponse) and total_records is still counted over the full filter, which is what the "advisory ID taken" check in CreateErrata.vue reads. /errata/all/ (OVAL) is unaffected.
  • get_products(): Product.builds is loaded id-only. ProductBuild only serializes id, so it no longer loads mock_options JSONB for every build.
  • GET /releases/: contract unchanged. Omitting pageNumber still returns the bare list.
  • Errata and releases: pageNumber < 1 is clamped to page 1. Before, 0 fell into the unpaginated branch and negative values built a negative OFFSET.

Testing

  • New tests/test_crud/test_dispatch_locking.py uses two independent DB connections with timeouts. All 5 tests fail on master and pass on this branch.
  • New pagination/projection tests in test_errata.py, test_products.py, test_releases.py.
  • test_releases.py, test_errata.py, test_products.py, test_dispatch_locking.py, test_crud/test_test.py: 38 passed against Postgres 13.
  • Checked the query plans with EXPLAIN ANALYZE on tables shaped like production (2M build tasks / 400k sign tasks). The build dispatch query uses idx_build_tasks_status_arch_ts and does not walk the primary key under ORDER BY id LIMIT 1.

Follow-ups

  • sign_tasks.status has no index, so the sign dispatch lookup is still a sequential scan. A partial index on idle tasks would fix that.
  • GET /products/ still returns every build id per product. Replacing it with a count needs ProductFeed.vue to stop reading product.builds.length first.

🤖 Generated with Claude Code

get_available_build_task() selected every eligible task FOR UPDATE and
kept the first one in Python, so a single poll locked the whole set and
ran its seven eager loaders over all of it. A second build node polling
at the same time blocked on those locks until the first transaction
ended, which capped dispatch on lock contention rather than on work.

The build query now takes LIMIT 1 with FOR UPDATE OF build_tasks SKIP
LOCKED, so it locks and loads exactly the task it hands out and a
concurrent poller moves on to the next free one. The test task batch
already had LIMIT 10 and only gains SKIP LOCKED, so two schedulers take
disjoint batches instead of serializing.

get_available_sign_task() read idle tasks without any lock and then
updated the first one unconditionally, so two sign nodes polling at
once could claim the same task. It now uses the same ORDER BY id,
LIMIT 1, FOR UPDATE SKIP LOCKED shape, which makes the claim exclusive.
sign_tasks.status has no index, so the lookup is still a sequential
scan; that is left for a follow-up.

The new tests drive two independent connections, since row locks are
not observable from one session, and bound every call with a timeout
so a regression that drops SKIP LOCKED fails instead of hanging. All
of them fail against the previous queries.

Related to AlmaLinux/build-system#553
GET /errata/query/ is unauthenticated, and without a pageNumber it
returned every matching record with packages, albs_packages,
build_artifacts and build_tasks eager-loaded for each of them. The
frontend's "advisory ID already taken" check calls it exactly that way
and only reads total_records. pageNumber now defaults to the first page.
The response is an ErrataListResponse either way, so only the length of
`records` changes, and total_records is still counted over the full
filter. GET /errata/all/ keeps calling the CRUD with page=None for the
compact OVAL projection.

get_products() loaded every column of every build attached to a
product, mock_options JSONB included, although ProductBuild only
serializes the id. Product.builds is now loaded id-only. The number of
rows is still unbounded; replacing `builds` with a count needs the
frontend to stop reading product.builds.length first.

GET /releases/ keeps its unpaginated contract: omitting pageNumber
still returns the bare list of releases, which external API consumers
rely on.

In both list_errata_records() and get_releases(), a page below 1 is now
clamped to the first page. Page 0 fell into the unpaginated branch with
a different response shape, and negative values built a negative
OFFSET that Postgres rejects. Page sizes are named constants.

Related to AlmaLinux/build-system#553
@anfimovdm anfimovdm self-assigned this Oct 1, 2026
@anfimovdm
anfimovdm merged commit d5e4be9 into master Oct 1, 2026
5 of 7 checks passed
@anfimovdm
anfimovdm deleted the optimize-task-dispatch-and-listings branch October 1, 2026 08:44
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.

1 participant