Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Active Record Connection Adaptersにおける長年の課題に再び取り組む

Sponsored · Your Podcast. Everywhere. Effortlessly. Share. Educate. Inspire. Entertain. You do you. We'll handle the rest. →

Active Record Connection Adaptersにおける長年の課題に再び取り組む

Avatar for Yasuo Honda

Yasuo Honda

October 16, 2026

More Decks by Yasuo Honda

Other Decks in Technology

Transcript

  1. Revisiting Long-Standing Issues in Active Record Connection Adapters Active Record

    Connection Adapters における長年の課題に再び取り組む Yasuo Honda Kaigi on Rails 2026 - Oct 16, 2026 1
  2. About Me | Yasuo Honda Rails Committer Maintainer of Active

    Record Oracle enhanced adapter Find me on: https://github.com/yahonda 2
  3. Asakusa.rb A Ruby community in Asakusa 大江戸Ruby会議11 - Nov 8,

    2026 https://github.com/ruby-nokai/official/issues/579 3
  4. 2024: where my time went From my Kaigi on Rails

    2024 talk: Keeping CI green, fixing warnings: by hand Issue reports with reproduction cases: by hand These took up most of my time 4
  5. 2026: coding agents freed up my time Since around April

    2026, I always develop with coding agents: Keeping CI green, fixing warnings: with coding agents Issue reports with reproduction cases: with coding agents These take far less of my time Time for "important but not urgent" work 5
  6. Two things I've long wanted to fix in Active Record

    1. Disabling foreign key constraints on PostgreSQL without superuser privileges (since 2017) 2. Migration compatibility for third-party adapters (since 2018) 6
  7. Background: foreign keys in Rails create_table :posts create_table :comments do

    |t| t.references :post, foreign_key: true end CREATE TABLE "posts" ("id" bigserial primary key) CREATE TABLE "comments" ("id" bigserial primary key, "post_id" bigint, CONSTRAINT "fk_rails_2fd19c0db7" FOREIGN KEY ("post_id") REFERENCES "posts" ("id")) foreign_key: true creates a database-level foreign key constraint 8
  8. Background: fixtures load in alphabetical order # test_fixtures.rb def fixtures(*fixture_set_names)

    # ... self.fixture_table_names = (fixture_table_names | fixture_set_names).sort # test/test_helper.rb, generated by rails new # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order. fixtures :all comments (child) loads before posts (parent) 9
  9. Background: without disabling foreign keys ActiveRecord::InvalidForeignKey PG::ForeignKeyViolation: ERROR: insert or

    update on table "comments" violates foreign key constraint "fk_rails_2fd19c0db7" DETAIL: Key (post_id)=(980190962) is not present in table "posts". So foreign keys are disabled while fixtures load 10
  10. Background: disable_referential_integrity on PostgreSQL # postgresql/referential_integrity.rb (Rails 8.1) execute(tables.collect {

    |name| "ALTER TABLE #{quote_table_name(name)} DISABLE TRIGGER ALL" }.join(";")) yield execute(tables.collect { |name| "ALTER TABLE #{quote_table_name(name)} ENABLE TRIGGER ALL" }.join(";")) Disables foreign keys, runs the block, then re-enables them Not an official way: it disables the internal triggers behind foreign keys 11
  11. Why: two problems with DISABLE / ENABLE TRIGGER ALL 1.

    Needs superuser privileges, even for the table owner ERROR: permission denied: "RI_ConstraintTrigger_c_11520776" is a system trigger Not available on some hosted databases, and far more than an app needs 2. Rows loaded while disabled are not validated ENABLE TRIGGER ALL does not validate them 12
  12. Earlier attempt (2017): deferring is not disabling rails/rails#27636 by @mtsmfm

    Make every foreign key DEFERRABLE , then SET CONSTRAINTS ALL DEFERRED Deferrable constraint: the check waits until COMMIT Table owner is enough: no superuser privileges needed Issue rails/rails#28888: fixtures fail with PG::ForeignKeyViolation t.belongs_to :connection, foreign_key: { on_delete: :restrict } Why: only the NO ACTION check is deferred; on_delete actions still act immediately Reverted before Rails 5.1.0 13
  13. Background: foreign key options in PostgreSQL DEFERRABLE / INITIALLY DEFERRED

    Normally checked per statement; this can wait until COMMIT NOT VALID / VALIDATE CONSTRAINT Whether existing rows have been checked NOT ENFORCED (new for foreign keys in PostgreSQL 18) If the constraint is NOT ENFORCED, the database system will not check the constraint. i.e. rows written by INSERT / UPDATE are not checked https://www.postgresql.org/docs/18/sql-createtable.html 14
  14. Solution: PostgreSQL 18 NOT ENFORCED ALTER TABLE comments ALTER CONSTRAINT

    fk_rails_... NOT ENFORCED; -- insert the fixture data ALTER TABLE comments ALTER CONSTRAINT fk_rails_... ENFORCED; Disables and re-enables the foreign key itself Solves both problems: Back to ENFORCED : existing rows are validated Table owner is enough: no superuser privileges needed 15
  15. Blocker: lost deferrability While adding NOT ENFORCED to Rails' disable_referential_integrity

    : Tests for DEFERRABLE foreign keys started failing deferred_constraints_test.rb on PostgreSQL 18.3: ActiveRecord::InvalidForeignKey: PG::ForeignKeyViolation: ERROR: insert or update on table "authors" violates foreign key constraint "fk_rails_94423a17a3" 16
  16. Blocker: lost deferrability on PostgreSQL 18.3 CREATE TABLE child (id

    INT PRIMARY KEY, parent_id INT, CONSTRAINT child_parent_fk FOREIGN KEY (parent_id) REFERENCES parent(id) DEFERRABLE INITIALLY DEFERRED); ALTER TABLE child ALTER CONSTRAINT child_parent_fk NOT ENFORCED; ALTER TABLE child ALTER CONSTRAINT child_parent_fk ENFORCED; -- pg_trigger tgname | RI_ConstraintTrigger_c_25894 tgdeferrable | f (false) ← should be t (true) tginitdeferred | f (false) ← should be t (true) 17
  17. Solution: a two-line PostgreSQL fix + + fkconstraint->fk_del_action = currcon->confdeltype;

    fkconstraint->deferrable = currcon->condeferrable; fkconstraint->initdeferred = currcon->condeferred; Recreated triggers keep their deferrability pgsql-hackers (Feb 27) → committed, backported (Mar 30): https://postgr.es/c/5db5e3396 Released in PostgreSQL 18.4 (May 14, 2026) Fix loss of deferrability of foreign-key triggers (Yasuo Honda) 18
  18. Solution: sharing it with the PostgreSQL community Talked at PostgreSQL

    Unconference #56 (Mar 24, 2026) Slides Shared the problem and the patch with people in the community Got a review of the patch 19
  19. Solution: disable_referential_integrity on PostgreSQL 18.4+ # postgresql_adapter.rb (rails/rails#57377) def supports_enforced_foreign_keys?

    database_version >= 18_00_04 end # postgresql/referential_integrity.rb (rails/rails#57378) def disable_referential_integrity(&block) if supports_enforced_foreign_keys? toggle_foreign_key_enforcement(current_schemas_only: true, &block) else # DISABLE TRIGGER ALL / ENABLE TRIGGER ALL, as before Older PostgreSQL: still DISABLE TRIGGER ALL , still needs superuser privileges 20
  20. Solution: in one transaction def toggle_foreign_key_enforcement(current_schemas_only: false) fks = enforced_foreign_keys(current_schemas_only:

    current_schemas_only) transaction(requires_new: true) do fks.each { |fk| set_foreign_key_enforcement(fk, enforced: false) } yield if block_given? fks.each { |fk| set_foreign_key_enforcement(fk, enforced: true) } end end A failure can't leave constraints NOT ENFORCED 21
  21. Solution: set_foreign_key_enforcement # postgresql/referential_integrity.rb (lines wrapped) def set_foreign_key_enforcement(fk, enforced:) table

    = "#{quote_column_name(fk["schema_name"])}." \ "#{quote_column_name(fk["table_name"])}" execute("ALTER TABLE #{table} " \ "ALTER CONSTRAINT #{quote_column_name(fk["constraint_name"])} " \ "#{enforced ? "ENFORCED" : "NOT ENFORCED"}") end One ALTER TABLE ... ALTER CONSTRAINT per foreign key The same SQL as NOT ENFORCED / ENFORCED shown earlier 22
  22. Solution: enforced: and change_foreign_key # For migrations class AddArticlesForeignKey <

    ActiveRecord::Migration[8.2] def change add_foreign_key :comments, :articles, enforced: false end end # For general use, e.g. application code connection.change_foreign_key :comments, :posts, enforced: false connection.change_foreign_key :comments, :posts, enforced: true Public API for applications too, not just for fixtures (rails/rails#57377) 23
  23. Background: migration version class CreateUsers < ActiveRecord::Migration[4.2] def change create_table

    :users do |t| t.string :name t.timestamps end end end : which Rails version's behavior this migration follows Not the Rails version running it [4.2] 25
  24. Background: migration version The same create_table :users on PostgreSQL: Migration[4.2]

    CREATE TABLE "users" ("id" serial NOT NULL PRIMARY KEY, "name" character varying, "created_at" timestamp, "updated_at" timestamp) Migration[8.1] CREATE TABLE "users" ("id" bigserial primary key, "name" character varying, "created_at" timestamp(6) NOT NULL, "updated_at" timestamp(6) NOT NULL) 26
  25. Background: migration compatibility class AddPublishedAtToPosts < ActiveRecord::Migration[6.1] def change add_column

    :posts, :published_at, :datetime end end -- Migration[6.1] ALTER TABLE "posts" ADD "published_at" timestamp -- Migration[7.0] ALTER TABLE "posts" ADD "published_at" timestamp(6) Rails 7.0 changed the default datetime precision to 6 Each migration keeps the behavior of its own version 27
  26. Background: adapter name checks in version classes # migration/compatibility.rb (Rails

    8.1) class V6_1 < V7_0 class PostgreSQLCompat def self.compatible_timestamp_type(type, connection) if connection.adapter_name == "PostgreSQL" type.to_sym == :datetime ? :timestamp : type else type end One class per migration version, inheriting the next newer one Older classes adjust options for the old behavior, then call super Adapter differences: adapter_name string checks in the same class 28
  27. Why: compatibility for third-party adapters 1. Oracle enhanced: identity columns

    Identity column: the SQL-standard counterpart of MySQL's AUTO_INCREMENT Migration[8.2] +: identity columns Older: sequences 2. Third-party adapters inheriting a bundled adapter e.g. the CockroachDB adapter, a separate gem for CockroachDB, a PostgreSQLcompatible database It should inherit the PostgreSQL adapter's migration compatibility 29
  28. Blocker: adapters matched by name : "CockroachDB" ≠ "PostgreSQL" Third-party

    adapters can only monkey-patch these classes: the CockroachDB adapter today adapter_name 30
  29. Earlier attempt (2018): rails/rails#33269 Idea: per-adapter version classes, e.g. PostgreSQL::V5_0

    < V5_0 This skips PostgreSQL::V5_1 Closed in 2019: needed a redesign 31
  30. Solution: rewriting about 7 times with coding agents (from May

    2026) First try: per-adapter modules included at runtime (rails/rails#57479) Modules got mixed across databases Along the way: @rafaelfranca suggested trying another pattern @kamipo simplified it: no extra state passed around in the options hash (rails/rails#59050) Not just coding agents: reviews from Rails core made it better too 32
  31. Solution: adapters own compatibility # postgresql/compatibility_behavior.rb module PostgreSQL module CompatibilityBehavior

    class V6_1 < V7_0 def add_column(table_name, column_name, type, **options) type = :timestamp if type.to_sym == :datetime super end No adapter_name check: the class lives in the PostgreSQL adapter 33
  32. Trade-off: a table_name_prefix edge case left as is # config.active_record.table_name_prefix

    = "p_" class AddIndexToPostsTitle < ActiveRecord::Migration[7.0] def change add_index :posts, :title end end Index name: index_p_posts_on_title on Rails 7.0, index_posts_on_title today Found by an agent; no user reported it since Rails 7.1 Fixing it breaks rollback of indexes created under the old name Left it out (rails/rails#58955 closed): what would fixing it break? 34
  33. Oracle enhanced adapter Built on rails/rails#58848 rsim/oracle-enhanced#2867, #2868, #2869 Merged

    on Oct 13, 2026, the same day as rails/rails#58848 Identity columns in Migration[8.2] +: now possible 36
  34. Oracle enhanced adapter: DDL by migration version The same create_table

    :users on Oracle: Migration[8.1] CREATE TABLE "USERS" ("ID" NUMBER(38) NOT NULL PRIMARY KEY, "NAME" VARCHAR2(255)) CREATE SEQUENCE "USERS_SEQ" START WITH 1 Migration[8.2] CREATE TABLE "USERS" ("ID" NUMBER(38) GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "NAME" VARCHAR2(255)) 37
  35. CockroachDB adapter: dropping the monkey patch Now inherits PostgreSQL's compatibility,

    no code needed compatibility.rb deleted: yahonda/activerecord-cockroachdb-adapter@6cf2eaa Green on Rails main Started on my fork in the last few days, since rails/rails#58848 landed on Oct 13 38
  36. Summary Coming in Rails 8.2: Disabling foreign keys without superuser

    privileges (PostgreSQL 18.4+) Per-adapter migration compatibility, tried with Oracle enhanced and CockroachDB Coding agents: rebuilt a design I couldn't crack in 2018 You decide what to do: try what you've always wanted to do 39