Add query_constraints to Ci::RunnerManager for partition pruning
What does this MR do and why?
ci_runner_machines is LIST-partitioned by runner_type (1 = instance, 2 = group, 3 = project) with composite primary key (id, runner_type). PostgreSQL can skip entire partitions when runner_type is part of the WHERE clause — but single-record write paths (reload, update, destroy) currently issue queries keyed only on id, forcing the planner to scan all three partitions.
This MR declares query_constraints :id, :runner_type on Ci::RunnerManager. With this in place, ActiveRecord includes runner_type in the WHERE clause for every instance-level reload, UPDATE, and DELETE (these use _in_memory_query_constraints_hash). The change is logically a no-op (a runner manager always has the same runner_type as its parent runner) but enables partition pruning at the query plan level.
Scope note: Class-level lookups via
Ci::RunnerManager.find(id)/find_by(id:)do not consultquery_constraints— Rails 7.2FinderMethods#find_onebuildswhere(primary_key => id)only and still scans all three partitions. The codebase has noRunnerManager.findcallers today, but read-path lookups (e.g. GraphQL/GlobalID resolution) would still scan all three partition withoutrunner_typecondition.
The two hot production paths that benefit immediately:
RunnerManager#update_columns— called on every runner heartbeat viaCi::RunnerManager#heartbeat.runner_manager.destroy!— called byCi::Runners::UnregisterRunnerManagerService.
This is the first MR in a series that implements the partition pruning work for runner-related models. Subsequent MRs (behind a feature flag) will add runner_type filters to Ci::Runner scopes (belonging_to_project, belonging_to_group, with_version_prefix, with_upgrade_status) and to the runner.runner_managers association reads.
Why :id, :runner_type and not :runner_id, :runner_type?
The linked issue's example patch suggests query_constraints :runner_id, :runner_type, matching the Ci::RunnerTagging declaration. That combination is unsafe for Ci::RunnerManager because (runner_id, runner_type) is not unique — a runner has many managers. Under Rails 7.2, query_constraints columns are used in the WHERE clause of UPDATE and DELETE (ActiveRecord docs), so runner_manager.destroy! would delete all managers belonging to that runner. I caught this with the Ci::Runners::UnregisterRunnerManagerService spec failing before switching to :id, :runner_type. RunnerTagging does not exhibit this bug because it is never destroyed individually — only in bulk via association replacement.
The composite primary key on the partitioned table is (id, runner_type), so declaring query_constraints :id, :runner_type aligns the ActiveRecord write semantics with the actual database PK.
Query Plans
| Operation | BEFORE query (id only) |
BEFORE plan | AFTER query (id + runner_type) |
AFTER plan |
|---|---|---|---|---|
SELECT (reload) |
SELECT * FROM ci_runner_machines WHERE id = 121405279 LIMIT 1; |
#154143 | SELECT * FROM ci_runner_machines WHERE id = 121405279 AND runner_type = 3 LIMIT 1; |
#154144 |
UPDATE (heartbeat) |
UPDATE ci_runner_machines SET contacted_at = NOW() WHERE id = 121405279; |
#154145 | UPDATE ci_runner_machines SET contacted_at = NOW() WHERE id = 121405279 AND runner_type = 3; |
#154146 |
DELETE (unregister) |
DELETE FROM ci_runner_machines WHERE id = 121405278; |
#154147 | DELETE FROM ci_runner_machines WHERE id = 121405277 AND runner_type = 3; |
#154148 |
See the full follow-up note for headline numbers (partitions touched, execution time, WAL bytes) and expanded plan output for each operation.
References
- Implements #594861 (closed)
- Pattern reference:
Ci::RunnerTagging(app/models/ci/runner_tagging.rb:13) uses the samequery_constraintsmechanism for partition pruning onci_runner_taggings. - Rails
query_constraintsdocumentation.
Screenshots or screen recordings
N/A — backend-only change with no user-facing surface.
How to set up and validate locally
# Verify the declared constraints
Ci::RunnerManager.query_constraints_list
# => ["id", "runner_type"]
# Set up a runner with two managers
runner = FactoryBot.create(:ci_runner, :group, groups: [FactoryBot.create(:group)])
manager1 = FactoryBot.create(:ci_runner_machine, runner: runner, system_xid: 'sys-1')
manager2 = FactoryBot.create(:ci_runner_machine, runner: runner, system_xid: 'sys-2')
# 1. Verify SELECT-by-PK (reload) includes runner_type
ActiveSupport::Notifications.subscribed(->(_, _, _, _, p) { puts p[:sql] if p[:sql].include?('ci_runner_machines') }, 'sql.active_record') do
manager1.reload
end
# Expect: SELECT ... FROM "ci_runner_machines" WHERE "ci_runner_machines"."id" = $1 AND "ci_runner_machines"."runner_type" = $2
# 2. Verify UPDATE includes runner_type
ActiveSupport::Notifications.subscribed(->(_, _, _, _, p) { puts p[:sql] if p[:sql].include?('UPDATE "ci_runner_machines"') }, 'sql.active_record') do
manager1.update_columns(architecture: 'amd64')
end
# Expect: UPDATE "ci_runner_machines" SET ... WHERE "ci_runner_machines"."id" = $1 AND "ci_runner_machines"."runner_type" = $2
# 3. Verify DELETE includes runner_type AND only the target row is removed
ActiveSupport::Notifications.subscribed(->(_, _, _, _, p) { puts p[:sql] if p[:sql].include?('DELETE FROM "ci_runner_machines"') }, 'sql.active_record') do
manager1.destroy!
end
# Expect: DELETE FROM "ci_runner_machines" WHERE "ci_runner_machines"."id" = $1 AND "ci_runner_machines"."runner_type" = $2
raise 'manager1 should be destroyed' if Ci::RunnerManager.exists?(manager1.id)
raise 'manager2 must NOT be destroyed' unless Ci::RunnerManager.exists?(manager2.id)
puts 'OK: destroy is scoped to a single row'
# 4. Confirm partition pruning at the query plan level
puts Ci::RunnerManager.where(id: manager2.id, runner_type: manager2.runner_type).explain
# Expect a single partition (e.g. group_type_ci_runner_machines) in the plan, not all three.Automated test coverage is added in spec/models/ci/runner_manager_spec.rb under the new describe 'query_constraints' block (5 examples covering the declaration, the SELECT/UPDATE/DELETE shapes, and the single-row-destroy safety property).
Full RSpec runs performed locally with all suites green:
spec/models/ci/runner_manager_spec.rb— 121 examples (+5 new), 0 failuresspec/models/ci/runner_spec.rb— 343 examples, 0 failuresspec/models/ci/build_spec.rb -e runner_manager— 11 examples, 0 failuresspec/finders/ci/runner_managers_finder_spec.rb,runner_jobs_finder_spec.rb,runners_finder_spec.rbspec/services/ci/runners/unregister_runner_manager_service_spec.rb,stale_managers_cleanup_service_spec.rb,reconcile_existing_runner_versions_service_spec.rbspec/requests/api/ci/runner/— 359 examples, 0 failures (coversregister,heartbeat,ensure_manager)
MR acceptance checklist
Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.