Sign in or sign up before continuing. Don't have an account yet? Register now to get started.
Register now

Preloads on instance dependent associations for CI partitions

Rails associations accept a block in their definition to pass arguments from one record to the other:

  has_one :metadata, -> (build) { where(partition_id: build.partition_id) },
                   class_name: 'Ci::BuildMetadata',
                   foreign_key: :build_id,
                   inverse_of: :build,
                   autosave: true

This works for creating records, accessing them one by one, but it doesn't work for preloading them:

ArgumentError: The association scope 'metadata' is instance dependent (the
scope block takes an argument). Preloading instance dependent scopes is not
supported.

In Rails 7 it is possible to preload associations that use scopes:

require "bundler/inline"

gemfile(true) do
  source "https://rubygems.org"

  git_source(:github) { |repo| "https://github.com/#{repo}.git" }

  gem 'rails', '~> 7.0', '>= 7.0.4'
  gem "sqlite3"
end

require "active_record"
require "logger"

ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
ActiveRecord::Base.logger = Logger.new(STDOUT)

ActiveRecord::Schema.define do
  create_table :ci_builds, force: true do |t|
    t.integer :partition_id, default: 100
  end

  create_table :ci_builds_metadata, force: true do |t|
    t.integer :build_id
    t.integer :partition_id
  end
end

class Build < ActiveRecord::Base
  self.table_name = :ci_builds

  has_one :metadata, -> (metadatable) { where(partition_id: metadatable.partition_id) },
                   class_name: 'BuildMetadata',
                   foreign_key: :build_id,
                   inverse_of: :build,
                   autosave: true
end

class BuildMetadata < ActiveRecord::Base
  self.table_name = :ci_builds_metadata

  belongs_to :build, class_name: 'Build'
end


[100, 200, 300].each do |partition_id|
  5.times do
    job = Build.create!(partition_id: partition_id)
    metadata = BuildMetadata.create!(build: job, partition_id: partition_id)
  end
end

Build.preload(:metadata).to_a

This ends up creating one query for each partition_id value:

D, [2022-10-11T15:31:09.176755 #68489] DEBUG -- :   Build Load (0.0ms)  SELECT "ci_builds".* FROM "ci_builds"
D, [2022-10-11T15:31:09.182337 #68489] DEBUG -- :   BuildMetadata Load (0.4ms)  SELECT "ci_builds_metadata".* FROM "ci_builds_metadata" WHERE "ci_builds_metadata"."partition_id" = ? AND "ci_builds_metadata"."build_id" IN (?, ?, ?, ?, ?)  [["partition_id", 100], ["build_id", 1], ["build_id", 2], ["build_id", 3], ["build_id", 4], ["build_id", 5]]
D, [2022-10-11T15:31:09.182846 #68489] DEBUG -- :   BuildMetadata Load (0.1ms)  SELECT "ci_builds_metadata".* FROM "ci_builds_metadata" WHERE "ci_builds_metadata"."partition_id" = ? AND "ci_builds_metadata"."build_id" IN (?, ?, ?, ?, ?)  [["partition_id", 200], ["build_id", 6], ["build_id", 7], ["build_id", 8], ["build_id", 9], ["build_id", 10]]
D, [2022-10-11T15:31:09.183113 #68489] DEBUG -- :   BuildMetadata Load (0.0ms)  SELECT "ci_builds_metadata".* FROM "ci_builds_metadata" WHERE "ci_builds_metadata"."partition_id" = ? AND "ci_builds_metadata"."build_id" IN (?, ?, ?, ?, ?)  [["partition_id", 300], ["build_id", 11], ["build_id", 12], ["build_id", 13], ["build_id", 14], ["build_id", 15]]

Should we backport this feature into Rails 6?

Edited Oct 11, 2022 by Marius Bobin
Assignee Loading
Time tracking Loading