Reuse loaded records when expiring pipeline ETag caches
What does this MR do and why?
Ci::ExpirePipelineCacheService runs synchronously during atomic pipeline processing (and from ExpirePipelineCacheWorker). When building ETag paths for the pipeline hierarchy, upstream_and_all_downstreams.includes(project: [:route, { namespace: :route }]) reloaded the pipeline's project, namespace, and both routes from scratch — fresh instances of records the caller already holds.
This MR preloads the association explicitly with ActiveRecord::Associations::Preloader and available_records: [project], so the in-memory project (and any associations already loaded on it) is shared instead. Measured on the jobRetry GraphQL mutation with CI variables, this removes 4 SQL queries (~84 → ~80 in the progression measured on the proof of concept, see References).
The N+1 spec now seeds an upstream and downstream pipeline before capturing the control: with record reuse, a control captured with no relatives no longer performs the batched preload queries that runs with relatives still need, so the old control undercounted.
Feature flag
The service runs on every pipeline status transition, so the change is guarded by the ci_expire_pipeline_cache_record_reuse derisk feature flag (disabled by default, actor: project). When disabled, the code falls back to the existing includes-based preload unchanged. Both states are covered by specs.
References
MR acceptance checklist
Evaluated against the MR acceptance checklist.