ros-apartment 4.0.0.alpha13 → 4.0.0.alpha15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b8e5931794be153df0c2443c215f142fa9f07c8e0a0830f5efac616e0c306e58
4
- data.tar.gz: e97bbef33052e8bae6a0ce036afc61aeb19a9b1a04895ddcdfb138b5bf89f031
3
+ metadata.gz: 894a4e62959833cd9dba2797321b9199ed8269eab4d51fe6744c95d0aef68076
4
+ data.tar.gz: 1e2dedc54edd54b7058ac7c86c96df47b90d2d4fb3dd62c9aab0293dbb22590a
5
5
  SHA512:
6
- metadata.gz: 362d681d9a275c070521d0f7dff72d8562e3395262df60ea1d61d8a6865956cc700047b5e12a99b132c6a51c41bdaf813592c434f0ca8f10dcdaef65bc876dbb
7
- data.tar.gz: 56ab684ddbdc3db9ec820a5b833538fedf3e10ae6358edc0283020cbd352578870d9277620d4fe230b34f13308f2c7d0382c810cba66f2ac8a5d60947ea277a2
6
+ metadata.gz: f31685d04741cef165f6bcffcf67db498c520be551ba31c03a8967d74b4c2992163dddd09047240771347cdb6f1ef0beec6311f1779f14f114f365e96600980d
7
+ data.tar.gz: 841a51484b694620fa04433f0ce8b2fb63afcb9f003b7750c9b9c138703d9193501adc2fe15be3d67f15b72a7bf52e383d00767719ff7014afdd47344db8e44e
data/README.md CHANGED
@@ -38,7 +38,7 @@ This gem is a maintained fork of the original [Apartment gem](https://github.com
38
38
  ### Requirements
39
39
 
40
40
  - Ruby 3.3+
41
- - Rails 7.2+
41
+ - Rails 8.1+
42
42
  - PostgreSQL 14+, MySQL 8.4+, or SQLite3
43
43
 
44
44
  ### Ruby version manager
@@ -438,18 +438,20 @@ routed-vs-pinned model and the two-store recipe.
438
438
 
439
439
  ## Iterating across tenants
440
440
 
441
- v4 keys a connection pool per `"tenant:role"`, so *switching* into a tenant creates a pool. Pick the lightest primitive for the work — the question is **does the block touch per-tenant-schema data?**
441
+ v4 keys a connection pool per `"tenant:role"`, and the pool is created when a query resolves a connection — **not** when `Tenant.switch` sets the tenant. So a switch whose block never touches the tenant database costs nothing, and the question for picking a primitive is **does the block query per-tenant-schema data?**
442
442
 
443
443
  | Need | Use | v4 cost |
444
444
  |---|---|---|
445
445
  | Names only (enqueue a job, build a list) | `Apartment.tenant_names.each { ... }` | No switch, no pool created |
446
- | Per-tenant-schema work (read/write tenant tables) | `Apartment::Tenant.each(release_connection: true) { ... }` | One pool per tenant; released between iterations |
446
+ | Per-tenant-schema work, every tenant | `Apartment::Tenant.each(release_connection: true) { ... }` | One pool per tenant; released between iterations |
447
+ | Per-tenant-schema work, a **subset** | `Apartment::Tenant.each(names, release_connection: true) { ... }` | Same, over the list you pass — no need to hand-roll the `ensure` |
447
448
  | Global/pinned data only | Don't switch — read it in the default context | Under shared pinned connections (PG schema, MySQL default), a switch routes pinned/global models *through* the tenant pool |
448
449
 
449
450
  Rules of thumb:
450
451
 
451
- - **Enqueueing jobs?** Don't switch — pass the tenant as a job argument (`Job.perform_async(tenant: name)`) and let your worker middleware switch when the job runs. Switching only to enqueue spins up a pool for nothing.
452
+ - **Enqueueing jobs?** Pass the tenant as a job argument (`Job.perform_async(tenant: name)`) and let your worker middleware switch when the job runs — the worker needs the name anyway, and the enqueue side stays independent of tenant routing. A switch that only enqueues is *not* itself expensive: the block touches the queue, never the tenant database, so no pool is created.
452
453
  - **Only need global/pinned data?** Don't switch. Under shared pinned connections a `switch` resolves pinned and excluded models through the *current tenant's* pool, so reading global data inside a switch still creates a tenant pool.
454
+ - **Iterating some tenants, not all?** `Tenant.each` takes the list as its first argument — `Tenant.each(Account.active.pluck(:tenant_name)) { ... }`. Reach for this instead of writing `names.each { |n| Tenant.switch(n) { ... } }`: it is the gem-native shape, and it puts `release_connection:` within reach for when the next bullet says you need it. Add that flag deliberately rather than by default — it is not a free no-op inside an outer transaction. A hand-rolled loop over such a block leaves one leased connection per visited tenant, which keeps every pool it touches un-evictable and can drive the pool count far past `max_tenant_pools` (see [Pool admission control](docs/designs/pool-admission-control.md)). A block using only modern query methods creates a pool per tenant but no lasting lease, so those pools stay idle and the reaper can trim them. The subset form and `release_connection:` are **v4 API** — v3's `Tenant.each` forwards to the adapter and accepts neither. `Tenant.with_tenants(*names) { ... }` scopes the provider instead, if you want nested calls to see the same subset.
453
455
  - **Large fan-out doing real per-tenant work?** Pass `release_connection: true` to `Tenant.each` — it releases connections after each tenant so the reaper can evict finished tenants' pools mid-run. It matters for blocks that hold a connection (raw `ActiveRecord::Base.connection`, an open transaction, a long operation); modern query methods (`create!`, `where`, …) check the connection back in themselves (Rails 7.2+), so a fan-out of only those needs no release. **It releases *every* connection leased to the current thread (`clear_active_connections!(:all)`), so don't use it inside an outer transaction or while holding a connection for non-tenant work.**
454
456
 
455
457
  ## Convenience Methods
@@ -22,7 +22,8 @@ lib/apartment/
22
22
  ├── patches/ # ActiveRecord patches for tenant-aware connections
23
23
  │ ├── connection_handling.rb # Prepends on AR::Base — tenant-aware connection_pool
24
24
  │ ├── connection_registry.rb # Prepends on AR's PoolManager + ConnectionHandler — serializes the pool registry
25
- │ └── postgresql_sequence_name.rb # Prepends on the PG adapter — schema-agnostic Model.sequence_name memoization
25
+ │ ├── postgresql_sequence_name.rb # Prepends on the PG adapter — schema-agnostic Model.sequence_name memoization
26
+ │ └── postgresql_type_map.rb # Prepends on the PG adapter — one OID type map per database, shared across tenant pools
26
27
  ├── privileges/ # Tenant privilege policy support
27
28
  │ └── context.rb # Privileges::Context: what a tenant_privilege_policy receives, one per phase
28
29
  ├── tasks/ # Rake task utilities; v4.rake for apartment:create/drop/migrate/seed/rollback
@@ -77,10 +78,22 @@ The readers are Rails' own, all reaching the registry through `ConnectionHandler
77
78
 
78
79
  `serialize!` **fails closed**: it resolves each guarded method with `method_defined?` / `private_method_defined?` (matching what `super` actually needs, so an upstream method merely *moved* to a superclass or module still counts) and raises `Apartment::ConfigurationError` at `activate!` when one is genuinely gone. It only *warns* when upstream has grown a public accessor we do not guard — a hole beats a boot failure. See `docs/designs/ar-connection-registry-thread-safety.md`.
79
80
 
81
+ ### patches/postgresql_type_map.rb — Shared PostgreSQL Type Map
82
+
83
+ Rails builds the PostgreSQL OID type map **per connection**: `configure_connection` ends in `reload_type_map`, which clears `@type_map` and runs `initialize_type_map`, and that issues three `pg_type` queries. Two of them have no usable index and sequential-scan `pg_type`, which grows by two rows per table per tenant schema (a composite type and its array type). Pool-per-tenant pays that on every cold tenant pool, so a nightly sweep over hundreds of tenants rebuilds the same map hundreds of times against a catalog hundreds of times larger than a single-schema app's: measured 3.4 ms of type-map queries per connect at 5 schemas, 65 ms at 500 (design: `docs/designs/postgresql-type-map-sharing.md`).
84
+
85
+ The patch shares one map per **database** (OIDs are database-wide; `search_path`, role and tenant play no part) across every adapter in the process, through two public `:nodoc:` seams identical on Rails 8.1 and main. `clear_cache!(new_connection: true)` is Rails' own "the socket is being replaced" signal, called from `reconnect!`, `disconnect!` and `reset!`; the override nils `@type_map` there, so every path to a new physical connection reaches `reload_type_map` with a nil map. `reload_type_map` with a nil map **adopts** the shared instance from `REGISTRY`, building and publishing with `put_if_absent` if this is the first connection to that database; with a live map, which only the enum DDL helpers reach, it **rebuilds** into a fresh instance and republishes.
86
+
87
+ The key is `[[host, port, database], [database_oid, postmaster_start_time], default_timezone]` and everything but the timezone comes off the **live connection, never `@config`** — libpq resolves defaults the config cannot show (`dbname` from the user name, a service file, `PGDATABASE`, `hostaddr` without `host`), so two identical-looking configs can reach different databases. The catalog identity costs one query (`pg_database.oid` plus the *epoch* of `pg_postmaster_start_time()`, both world-readable, 0.096 ms; the epoch because a timestamptz renders through the session's `TimeZone`/`DateStyle` and would split one catalog's key) and exists because a name is not a catalog: the patch deliberately survives `clear_cache!`, which is what heals a replaced catalog today, so a different cluster behind an unchanged endpoint (blue/green on logical replication, a restore into the same name) would otherwise be adopted — and fresh catalogs restart type OIDs at 16384, so a stale entry describes a *different* type rather than going unused. The OID alone will not do — `postgres` is OID 5 on every cluster ever initdb'd, so an app on the RDS default database would have a constant check — which is why the start time is in the same SELECT; publishing then supersedes same-endpoint entries carrying a different identity, so restarts do not strand one map each. Within one cluster the OID counter is global and monotonic, so a drop and recreate leaves dead entries, not wrong ones; a promoted physical replica rebuilds once, the accepted cost of fail-closed identity. A failed probe warns, **detaches `@type_map` first** and falls back to upstream — detaching is load-bearing, since upstream's `reload_type_map` clears a live map in place and would blank the shared instance for every other holder. The timezone is in the key because `initialize_type_map` bakes it into the `time`/`timestamp` registrations. A published map is never cleared in place: holders keep resolving against it and adopt the rebuilt one on their next physical reconnect, learning any new OID lazily through `get_oid_type` meanwhile, exactly as a stale per-connection map does today.
88
+
89
+ Three details are load-bearing. The map is a `SharedTypeMap < HashLookupTypeMap` whose `@mapping` is a `Concurrent::Map`, so lazy OID registrations from one thread are serialized against reads from every other by the same primitive Rails uses for the other half of that object; on MRI a plain Hash would already be safe here, but the argument should not be implicit. No registry lock is held across I/O: the catalog queries run before `put_if_absent`, whose **return value is load-bearing** — the loser of a publish race adopts the winner, so every adapter converges on one instance. And the build writes into the adapter's own `@type_map` before publishing, because `initialize_type_map` loads through the private `type_map` reader rather than its argument; `apply!` **fails closed** (`ConfigurationError`) if that reader or either public seam is gone. Retention is measured rather than bounded: 88.5 KB per map, one key for the whole process under schema-per-tenant, ~49 MB for 570 tenant databases — no eviction ships because a cap tight enough to matter would thrash the nightly sweep it exists to speed up, and eviction stays free to add later. Applied from the gem-load `on_load(:active_record_postgresqladapter)` hook alongside the sequence-name patch, not from `activate!`, because boot opens connections before `activate!` runs and the map they build is the one tenant pools should adopt. `add_pg_decoders` still runs its one index-driven `pg_type` lookup per connection; deliberately untouched. **Rails main** (rails/rails#57013, not in 8.1.x) already ships the built-in OIDs statically and defers the one remaining scan to the first unknown OID per adapter; both seams survive there, `HashLookupTypeMap#initialize` lost its `parent` argument (so `SharedTypeMap` forwards `...`), and the patch's value shrinks to making that deferred scan per process. The integration spec feature-detects `OID::WellKnown` and pends its two connect-time examples on main.
90
+
80
91
  ### pool_reaper.rb — Pool Eviction + Admission
81
92
 
82
93
  Background `Concurrent::TimerTask` that evicts idle and excess tenant pools, and also serves as the pool manager's synchronous admission controller (`admit!`) when a cap is configured — evicting the LRU idle pool inline before a new one is established, applying `pool_overflow_policy` (`:evict_idle` / `:raise`) when no idle pool can be freed. A single `evict_tenant` primitive backs the timer (idle/LRU) and admission paths. Reap cadence (`interval`/`reaper_interval`) is decoupled from the idle window (`idle_timeout`/`pool_idle_timeout`). Created by `Apartment.configure`, stored as `Apartment.pool_reaper`. Deregisters evicted pools from AR's ConnectionHandler. Default tenant is never evicted.
83
94
 
95
+ **Admission is the quadratic path, so it does not instrument per candidate.** The scan walks every pool on every cold create; the timer paths walk every pool once per reap cycle. So `protected_pool?` (per-candidate `:skip_evict`, timer only) and `protection_reason` (bare predicate, admission) are split, and admission reports one `skipped: { pinned:, in_use:, default: }` tally on `:cap_unmet` instead — three buckets, because the default tenant's own pools are refused before the protection check and would otherwise vanish from the accounting; `pinned + in_use + default` equals `current` for an *uninterrupted* scan — a candidate whose protection check raises is rescued without being tallied, and the reaper does not take `@create_mutex`, so it can evict between the scan and the count. It also reads `PoolManager#total_pools` rather than `#stats`, which materializes the full key array to answer a count. A measured breach reaching 477 pools emitted ~218k `:skip_evict` events before this. Note that `in_use` means *leased*, not *executing* — a fan-out that never releases reads as saturation while nothing is running; `docs/observability.md` documents the tell.
96
+
84
97
  ### adapters/abstract_adapter.rb — Base Adapter
85
98
 
86
99
  Lifecycle ops (`create`, `drop`, `migrate`, `seed`), `ActiveSupport::Callbacks` on `:create`/`:switch`, `resolve_connection_config` (abstract — subclasses override), `process_excluded_models`, `environmentify`, `base_config` (stringified `connection_config`), `rails_env` (guarded `Rails.env` access). `process_pinned_model` / `qualify_pinned_table_name` call **`Apartment::Model` class methods** (`apartment_explicit_table_name?`, `apartment_mark_processed!`, etc.); no `instance_variable_*` on arbitrary model classes. Constructor takes `connection_config` (raw AR hash, not `Apartment::Config`).
@@ -136,7 +149,7 @@ Three hooks in Rails boot order:
136
149
 
137
150
  `Apartment::MigrationRole.wrap` runs a block inside `connected_to(role: config.ddl_role)`, or yields when no role is configured. It exists as its own module because both `Migrator` and the adapters need it and an adapter cannot depend on `Migrator`; `Migrator.with_migration_role` stays as the documented entry point and delegates here. All tenant DDL goes through it — migrations, `Tenant.create`, and `Tenant.drop`'s engine call — because PostgreSQL scopes an `ALTER DEFAULT PRIVILEGES` rule with no `FOR ROLE` to the role that executed it, and because `DROP SCHEMA` needs ownership of a container `ddl_role` owns. See `docs/designs/v4-rbac-contract.md`.
138
151
 
139
- An unresolvable role is translated here: `ActiveRecord::ConnectionNotEstablished` becomes an `Apartment::ConfigurationError` naming `ddl_role` and the symbol given. `connected_to` resolves no pool — it pushes onto `connected_to_stack` and yields — so the failure arrives from *inside* the block and cannot be detected before it; a wrap whose block never touches the database stays silent by design. What discriminates is not the error class but a probe: `retrieve_connection_pool` for our role, nil meaning the failure is ours to explain and a pool meaning it belongs to the caller's block and re-raises untouched. The rescue names `ConnectionNotEstablished` and **only** that class, because `ConnectionNotDefined` does not exist before Rails 8.0 and Ruby resolves rescue constants at raise time, so naming the subclass raised `NameError` on the Rails floor and destroyed the error it was classifying. No wrapped error still needs translating: `Patches::ConnectionHandling#connection_pool` scopes its relabelling rescue to the tenant-resolution path, so errors do still arrive wrapped from inside it — but those are genuine tenant-pool failures that belong to the tenant rather than to `ddl_role`, and translating them would misattribute. A ddl_role failure is not one of them: it surfaces from the default-path lookup outside that boundary, and the tenant path establishes a pool for the role itself so it never fails on an unregistered one. The `ApartmentError` clause and one-layer unwrap this rescue once carried belonged to the old method-level rescue and went with it. Checked at first use rather than at `activate!`, which runs in `after_initialize`, after the eager-load initializer: under lazy loading no model has run `connects_to` yet and a boot-time check would fail on every boot.
152
+ An unresolvable role is translated here: `ActiveRecord::ConnectionNotEstablished` becomes an `Apartment::ConfigurationError` naming `ddl_role` and the symbol given. `connected_to` resolves no pool — it pushes onto `connected_to_stack` and yields — so the failure arrives from *inside* the block and cannot be detected before it; a wrap whose block never touches the database stays silent by design. What discriminates is not the error class but a probe: `retrieve_connection_pool` for our role, nil meaning the failure is ours to explain and a pool meaning it belongs to the caller's block and re-raises untouched. The rescue names `ConnectionNotEstablished` and **only** that class, even though every supported Rails now raises the `ConnectionNotDefined` subclass here. Narrowing to the subclass would buy nothing — the probe, not the class, decides — and would cost the case the method exists for: a bare `ConnectionNotEstablished` raised inside the caller's block while `ddl_role` is genuinely unregistered would stop getting the message that names `ddl_role`. (History, no longer binding: the subclass did not exist before Rails 8.0, and Ruby resolves rescue constants at raise time, so while 7.2 was supported naming it raised `NameError` and destroyed the error it was classifying.) No wrapped error still needs translating: `Patches::ConnectionHandling#connection_pool` scopes its relabelling rescue to the tenant-resolution path, so errors do still arrive wrapped from inside it — but those are genuine tenant-pool failures that belong to the tenant rather than to `ddl_role`, and translating them would misattribute. A ddl_role failure is not one of them: it surfaces from the default-path lookup outside that boundary, and the tenant path establishes a pool for the role itself so it never fails on an unregistered one. The `ApartmentError` clause and one-layer unwrap this rescue once carried belonged to the old method-level rescue and went with it. Checked at first use rather than at `activate!`, which runs in `after_initialize`, after the eager-load initializer: under lazy loading no model has run `connects_to` yet and a boot-time check would fail on every boot.
140
153
 
141
154
  ### privileges.rb / privileges/context.rb — Adopter-Owned Privilege Policy
142
155
 
@@ -38,13 +38,20 @@ module Apartment
38
38
  # explain. Non-nil means the role is fine and the error belongs to something the
39
39
  # caller did inside the block, so it re-raises untouched.
40
40
  #
41
- # The probe is the discriminator; the class is only a pre-filter, and on the Rails
42
- # floor it cannot filter at all. +ActiveRecord::ConnectionNotDefined+ does not exist
43
- # before Rails 8.0 (absent on 7.2.3.1, present on 8.0.5 and 8.1.3), and Ruby
44
- # resolves a rescue clause's constants at raise time — so naming it here raised
45
- # NameError on 7.2 and destroyed the error it was meant to classify. 7.2 raises
46
- # +ConnectionNotEstablished+ for an unregistered role and 8.0+ raises the subclass,
47
- # so the superclass alone covers the whole matrix. Do not narrow it back.
41
+ # The probe is the discriminator; the class is only a pre-filter. The clause names
42
+ # the +ConnectionNotEstablished+ superclass even though every supported Rails now
43
+ # raises the +ActiveRecord::ConnectionNotDefined+ subclass here, and that is
44
+ # deliberate rather than leftover. Narrowing to the subclass would buy nothing —
45
+ # the probe, not the class, decides — and would cost the case below: a bare
46
+ # +ConnectionNotEstablished+ raised inside the caller's block while +ddl_role+ is
47
+ # genuinely unregistered would stop getting the message that names +ddl_role+,
48
+ # which is the one situation this method exists to explain. Do not narrow it.
49
+ #
50
+ # (History, no longer binding: the subclass did not exist before Rails 8.0 — absent
51
+ # on 7.2.3.1, present on 8.0.5 and 8.1.3 — and Ruby resolves a rescue clause's
52
+ # constants at raise time, so while 7.2 was supported naming it raised NameError
53
+ # and destroyed the error it was meant to classify. The 8.1 floor removed that
54
+ # constraint; it did not change the conclusion.)
48
55
  #
49
56
  # The cost of the wider clause is that a bare ConnectionNotEstablished from the
50
57
  # caller's own code now reaches the probe instead of passing straight through. That
@@ -264,10 +264,13 @@ module Apartment
264
264
  # silently taking any shard already registered in it with it. Serializing
265
265
  # the whole method makes the upsert atomic.
266
266
  #
267
- # Wrapped with argument forwarding rather than a reimplementation because
268
- # the signature is version-dependent (AR >= 8.0 passes a ConnectionDescriptor
269
- # where 7.2 passed the connection-name String) and the key derivation is
270
- # upstream's business, not ours.
267
+ # Wrapped with argument forwarding rather than a reimplementation because the
268
+ # key derivation is upstream's business, not ours. Across the supported matrix
269
+ # the signature is currently stable — 8.1 and main both take a
270
+ # ConnectionDescriptor (verified) — so forwarding buys nothing today. It is
271
+ # kept because upstream has already changed this argument once: 7.2 passed the
272
+ # connection-name String and 8.0 replaced it with the descriptor. Forwarding
273
+ # costs a token and survives the next one.
271
274
  #
272
275
  # Declared private to match upstream. A prepended method is public by
273
276
  # default, and leaving it so would widen a Rails internal into public API
@@ -7,7 +7,7 @@ module Apartment
7
7
  # Backports rails/rails#56902 ("Pass IsolatedExecutionState.context to
8
8
  # share_with") to Rails versions where the fix has not landed.
9
9
  #
10
- # On Rails 7.2 / 8.0 / 8.1.x stable, ActionController::Live#process calls
10
+ # On Rails 8.1.x stable, ActionController::Live#process calls
11
11
  # ActiveSupport::IsolatedExecutionState.share_with(Thread.current, ...)
12
12
  # which reads from Thread.current.active_support_execution_state. Under
13
13
  # :fiber isolation that's empty — the data lives on Fiber.current's
@@ -0,0 +1,321 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Referenced in a class body below. Rails requires it from the PostgreSQL
4
+ # adapter file, which an eager-loading host app may not have loaded yet.
5
+ require 'active_record/type/hash_lookup_type_map'
6
+
7
+ module Apartment
8
+ module Patches
9
+ # Shares one PostgreSQL OID type map per database across every adapter in
10
+ # the process.
11
+ #
12
+ # Rails builds the type map per connection: configure_connection ends in
13
+ # reload_type_map, which clears @type_map and runs initialize_type_map, and
14
+ # that issues three pg_type queries (load_types_queries). Two of them have
15
+ # no usable index and sequential-scan pg_type, which grows by two rows per
16
+ # table per tenant schema. Pool-per-tenant pays that on every cold tenant
17
+ # pool, so a nightly sweep over hundreds of tenants rebuilds the same map
18
+ # hundreds of times against a catalog hundreds of times larger than a
19
+ # single-schema app's. Measured at 65 ms per connect for 500 schemas of
20
+ # 220 tables. The map is database-scoped (OIDs are), so one instance can
21
+ # serve every tenant pool. Design: docs/designs/postgresql-type-map-sharing.md.
22
+ #
23
+ # Two public :nodoc: seams, identical on Rails 8.1 and main:
24
+ #
25
+ # * clear_cache!(new_connection: true) is Rails' own "the socket is being
26
+ # replaced, drop connection-derived caches" signal, called from reconnect!,
27
+ # disconnect! and reset!. We drop @type_map there, so every path to a new
28
+ # physical connection reaches reload_type_map with a nil map.
29
+ # * reload_type_map with a nil map ADOPTS the shared instance (building and
30
+ # publishing it if this is the first connection to that database); with a
31
+ # live map it REBUILDS into a fresh instance and republishes. The live-map
32
+ # callers upstream are the enum DDL helpers and disable_extension. A
33
+ # published map is
34
+ # never cleared in place: other holders keep resolving against it and pick
35
+ # up the rebuilt one on their next physical reconnect, learning any new
36
+ # OID lazily through get_oid_type meanwhile, exactly as a stale
37
+ # per-connection map does today.
38
+ #
39
+ # The build writes into this adapter's own @type_map before publishing
40
+ # because initialize_type_map loads through the private +type_map+ reader,
41
+ # not its argument (load_additional_types constructs
42
+ # TypeMapInitializer.new(type_map)). Both that reader and
43
+ # initialize_type_map are checked by apply!, which refuses to boot without
44
+ # either.
45
+ #
46
+ # RETENTION, measured rather than bounded. An entry is removed only when a
47
+ # later publish supersedes it (see #apartment_supersede_stale_identities),
48
+ # so a process retains one map per KEY it has built against, and the key is
49
+ # [[host, port, database], identity, timezone] -- not the database alone. A
50
+ # :reading role on a replica is a different endpoint and carries its own
51
+ # map for the same catalog, so multiply by distinct endpoints, usually the
52
+ # number of roles. (Timezone splits nothing in practice: tenant configs
53
+ # derive from one base config.) One built map holds ~138 registrations and
54
+ # 88.5 KB of RSS (measured against PostgreSQL 18 on Rails 8.1) against a
55
+ # catalog carrying only built-in enums, domains and ranges -- a BASELINE,
56
+ # not a constant. The second load query has no namespace filter, so a map
57
+ # holds the built-ins plus every such type in the catalog it was built
58
+ # against, and pg_type is per-DATABASE, so the strategy decides only how
59
+ # tenant-defined types distribute. Schema-per-tenant -- the common case,
60
+ # and the one this patch was written for -- has one key per endpoint
61
+ # however many tenants there are, and that single map carries every
62
+ # tenant's types (138 + tenants x types-per-schema). Database-per-tenant
63
+ # splits the same types across maps: each carries 138 + its own tenant's,
64
+ # so COUNT grows with tenants, cumulatively -- supersede fires only for one
65
+ # endpoint under a different identity, so a dropped database leaves its
66
+ # entry for the life of the process. 570 tenant databases is ~49 MB of
67
+ # baseline per endpoint, plus whatever those tenants define. See docs/designs/postgresql-type-map-sharing.md. No
68
+ # eviction policy ships because a cap tight enough to bound that
69
+ # meaningfully is also tight enough to thrash the nightly sweep it exists to
70
+ # speed up, which is the deployment that has the problem in the first place.
71
+ # Eviction is semantically free if that ever changes -- dropping an entry
72
+ # only costs the next cold connection one rebuild, which is the unpatched
73
+ # behaviour -- so a bound can land later with no correctness migration.
74
+ # +reset!+ is the escape hatch in the meantime.
75
+ module PostgresqlTypeMap
76
+ # HashLookupTypeMap whose mapping is a Concurrent::Map.
77
+ #
78
+ # Upstream keeps @mapping in a plain Hash because one connection owns it.
79
+ # Shared, it sees lock-free reads from every thread and occasional writes
80
+ # from lazy OID registration. On MRI a plain Hash would already be safe
81
+ # here (single C calls, no Ruby-level iteration in this class, Integer and
82
+ # String keys), but the argument should not be implicit: Concurrent::Map
83
+ # is the primitive Rails already uses for the other half of this object,
84
+ # and every operation the adapter and TypeMapInitializer perform on the
85
+ # store ([]=, fetch(key, default), key?, keys, clear) exists on it with the
86
+ # same semantics.
87
+ class SharedTypeMap < ActiveRecord::Type::HashLookupTypeMap
88
+ # Forwards whatever it is given: 8.1 takes an optional parent, Rails main
89
+ # takes nothing, and the adapter passes nothing on either. The forwarding is
90
+ # what spans the two SUPPORTED lanes -- not a shim for a dropped Rails.
91
+ def initialize(...)
92
+ super
93
+ @mapping = Concurrent::Map.new
94
+ end
95
+ end
96
+
97
+ # [[host, port, database], [database_oid, postmaster_start_time],
98
+ # default_timezone] => SharedTypeMap. Nested so the endpoint and the
99
+ # catalog incarnation stay separable: #apartment_supersede_stale_identities
100
+ # needs exactly that distinction. Everything but the timezone is read off
101
+ # the live connection rather than @config -- see #apartment_type_map_key.
102
+ # Timezone is part of the key because initialize_type_map bakes
103
+ # @default_timezone into the time and timestamp registrations (verified on
104
+ # 8.1 and main).
105
+ REGISTRY = Concurrent::Map.new
106
+
107
+ # Identity of the catalog behind the endpoint name, in one round trip.
108
+ #
109
+ # The database OID alone is NOT enough, which a probe settles rather than
110
+ # an argument: template1, template0 and postgres hold OIDs 1, 4 and 5 on
111
+ # every cluster ever initdb'd, so an app whose database is `postgres` --
112
+ # the RDS default -- would have an identity check that evaluates to a
113
+ # constant. Freshly provisioned clusters also hand the first user database
114
+ # the same OID, 16384, so identical provisioning collides too. Neither
115
+ # needs a pooler or any exotic topology; a plain endpoint swap is enough.
116
+ # pg_postmaster_start_time() closes both: two live clusters disagree on it
117
+ # with near-certainty.
118
+ #
119
+ # Both are world-readable and need no privilege, unlike
120
+ # pg_control_system() and pg_control_checkpoint(), which are superuser-only
121
+ # by default. Everything is schema-qualified because an unqualified name
122
+ # can resolve to a temporary relation or an earlier entry in search_path.
123
+ # Measured at 0.049 ms of execution, 0.096 ms including the round trip,
124
+ # against 1.9 ms for a full type-map load on a 647-row pg_type and ~24 ms
125
+ # on a 281,927-row one.
126
+ # EXTRACT(EPOCH FROM ...) rather than the timestamptz itself, because the
127
+ # value is read as text and a timestamptz renders through the session's
128
+ # TimeZone and DateStyle. Two connection classes to the SAME catalog that
129
+ # differ in those (a per-role `ALTER ROLE ... SET TimeZone` with no
130
+ # explicit `variables` entry, say) would produce different identity
131
+ # strings, split the key, and set the supersede rule below ping-ponging --
132
+ # sharing quietly switching itself off, with no wrong data and no warning
133
+ # to explain it. A numeric epoch depends on neither GUC.
134
+ DATABASE_IDENTITY_SQL = <<~SQL.squish
135
+ SELECT d.oid, EXTRACT(EPOCH FROM pg_catalog.pg_postmaster_start_time())
136
+ FROM pg_catalog.pg_database d
137
+ WHERE d.datname = pg_catalog.current_database()
138
+ SQL
139
+
140
+ PUBLIC_SEAMS = %i[reload_type_map clear_cache!].freeze
141
+ PRIVATE_SEAMS = %i[initialize_type_map type_map].freeze
142
+
143
+ class << self
144
+ # FAILS CLOSED on a shape we cannot patch, for the same reason
145
+ # ConnectionRegistry does: the failure this patch prevents is database
146
+ # saturation during the nightly sweep, which is far harder to attribute
147
+ # than a boot error naming the ActiveRecord version. Resolution uses
148
+ # method_defined? so a seam that merely moved to a superclass still
149
+ # counts. Prepend is idempotent, so apply! may run more than once.
150
+ def apply!(adapter_class)
151
+ missing = PUBLIC_SEAMS.reject { |name| adapter_class.method_defined?(name) } +
152
+ PRIVATE_SEAMS.reject { |name| adapter_class.private_method_defined?(name) }
153
+
154
+ unless missing.empty?
155
+ raise(Apartment::ConfigurationError,
156
+ 'Apartment cannot share the PostgreSQL type map on ActiveRecord ' \
157
+ "#{ActiveRecord::VERSION::STRING}: expected method(s) #{missing.join(', ')} " \
158
+ 'are gone. Without them every cold tenant pool reloads the OID type map ' \
159
+ 'from pg_type. Upgrade ros-apartment to a version that supports this ' \
160
+ 'ActiveRecord release.')
161
+ end
162
+
163
+ adapter_class.prepend(self)
164
+ nil
165
+ end
166
+
167
+ # Forget every shared map. The next connection to each database rebuilds.
168
+ # A test hook for suites that need a cold start, and the escape hatch for
169
+ # an adopter who wants the retention described above reclaimed. Not a
170
+ # configuration knob.
171
+ def reset!
172
+ REGISTRY.clear
173
+ end
174
+ end
175
+
176
+ def clear_cache!(new_connection: false)
177
+ super
178
+ @type_map = nil if new_connection
179
+ end
180
+
181
+ def reload_type_map
182
+ @lock.synchronize do
183
+ key = apartment_type_map_key
184
+
185
+ # No identity we can trust, so fall back to upstream's per-connection
186
+ # map: sharing under an identity we could not confirm is the one thing
187
+ # this must never do. Detaching first is load-bearing rather than
188
+ # tidy -- upstream's reload_type_map CLEARS a live @type_map in place,
189
+ # and if this adapter had already adopted the shared instance that
190
+ # would blank the map every other holder is resolving against, which
191
+ # is the invariant the whole design rests on.
192
+ if key.nil?
193
+ @type_map = nil
194
+ return super
195
+ end
196
+
197
+ if @type_map.nil?
198
+ @type_map = REGISTRY[key] || apartment_publish_type_map(key)
199
+ else
200
+ REGISTRY[key] = apartment_build_type_map
201
+ end
202
+ end
203
+ end
204
+
205
+ private
206
+
207
+ # Ask the live connection, never @config, and ask the server which catalog
208
+ # it actually is.
209
+ #
210
+ # Two different failures make @config alone the wrong source. First, libpq
211
+ # fills in defaults the configuration hash cannot show -- dbname
212
+ # defaulting to the user name, a service file, PGHOST/PGPORT/PGDATABASE,
213
+ # hostaddr given without host -- so two adapters whose configs look
214
+ # identical can land on different databases. PQhost, PQport and PQdb are
215
+ # client-side reads of the RESOLVED parameters (measured ~0.06 us each)
216
+ # and close all of that; PQhost reports hostaddr when only hostaddr was
217
+ # given.
218
+ #
219
+ # Second, and the reason for the query: a name is not a catalog. This
220
+ # patch deliberately survives +clear_cache!(new_connection: true)+, which
221
+ # is Rails' "the socket is being replaced, drop what you cached" signal,
222
+ # and that signal is exactly what heals a replaced catalog today. Put a
223
+ # different cluster behind an unchanged endpoint -- an RDS blue/green
224
+ # cutover on logical replication, a restore into the same name -- and
225
+ # every connection drops, every adapter reconnects, and without this each
226
+ # one would adopt the old catalog's map. Type OIDs in a fresh catalog
227
+ # restart at 16384, so a stale registration does not merely go unused: it
228
+ # can describe a DIFFERENT type that now holds its OID, and an enum gets
229
+ # cast as a domain with nothing raised. (A promoted physical replica is
230
+ # not affected either way -- its catalog is byte-identical, so the OIDs
231
+ # still agree.) Within one cluster the OID counter is global and
232
+ # monotonic, verified by probe, so a same-cluster drop and recreate leaves
233
+ # dead entries rather than wrong ones; it is the cross-cluster case this
234
+ # closes.
235
+ #
236
+ # The identity read runs on the raw connection rather than through the
237
+ # adapter: it must not recurse into the type map it is about to resolve,
238
+ # and PQexec's arity does not drift across the Rails versions this gem
239
+ # supports. Any PG error means we could not establish identity, so we do
240
+ # not share.
241
+ #
242
+ # A pooler whose single alias fans out across several databases, or across
243
+ # clusters, IS caught: every resolved libpq parameter names the pooler, but
244
+ # the identity query travels to the backend. What no connect-time check of
245
+ # any kind can see is a pooler that moves a live client connection onto a
246
+ # different cluster without configure_connection running again.
247
+ def apartment_type_map_key
248
+ raw = @raw_connection
249
+ return nil unless raw
250
+
251
+ [[raw.host, raw.port, raw.db], apartment_database_identity(raw), @default_timezone]
252
+ rescue PG::Error => e
253
+ # Sharing silently switching itself off has no symptom beyond "the
254
+ # nightly got slow again", so leave evidence. Rescuing rather than
255
+ # raising keeps a probe failure from breaking a connection that is
256
+ # otherwise fine; the cost is one rebuilt map.
257
+ warn('[Apartment] could not identify the database for type-map sharing ' \
258
+ "(#{e.class}: #{e.message.lines.first&.strip}); this connection builds its own map.")
259
+ nil
260
+ end
261
+
262
+ # Block form so the PG::Result is freed at once instead of waiting for GC.
263
+ def apartment_database_identity(raw)
264
+ raw.exec(DATABASE_IDENTITY_SQL) { |result| [result.getvalue(0, 0), result.getvalue(0, 1)] }
265
+ end
266
+
267
+ # put_if_absent rather than compute_if_absent: the build runs catalog
268
+ # queries on this connection, and no registry lock is held across that
269
+ # I/O. A lost race costs one redundant build, which is today's cost once.
270
+ # Returning the WINNER is load-bearing -- the loser adopts it and drops its
271
+ # own build, so every adapter converges on one instance.
272
+ def apartment_publish_type_map(key)
273
+ built = apartment_build_type_map
274
+ published = REGISTRY.put_if_absent(key, built) || built
275
+ apartment_supersede_stale_identities(key)
276
+ published
277
+ end
278
+
279
+ # Drop entries for the same endpoint under a DIFFERENT catalog incarnation.
280
+ #
281
+ # Without this, putting the postmaster's start time in the key would trade
282
+ # one unbounded growth for another: every database restart would strand an
283
+ # entry per database, forever, which is growth in TIME rather than in
284
+ # tenants. A superseded entry can never be adopted again -- its identity
285
+ # will not recur -- so removing it costs nothing, and the worst case if an
286
+ # endpoint is flipped back is one rebuild, which is the unpatched
287
+ # behaviour. Timezone variants of the same live catalog share an endpoint
288
+ # AND an identity, so they survive; only the identity slice discriminates.
289
+ #
290
+ # Keys are collected before deleting rather than deleted mid-iteration.
291
+ #
292
+ # Two publishers for the same endpoint under DIFFERENT identities can each
293
+ # delete the other's entry, leaving the registry empty for that endpoint.
294
+ # Harmless, and deliberately uncoordinated: it takes both incarnations
295
+ # reachable at once (a cutover window), every live adapter holds its map by
296
+ # reference, adoption is keyed by the identity the adopting connection
297
+ # measured on its own socket, and losing costs one rebuild -- the unpatched
298
+ # behaviour. Superseding before the publish has the symmetric race, so
299
+ # there is nothing to buy.
300
+ def apartment_supersede_stale_identities(key)
301
+ endpoint, identity = key
302
+ stale = REGISTRY.keys.select { |other| other[0] == endpoint && other[1] != identity }
303
+ stale.each { |other| REGISTRY.delete(other) }
304
+ end
305
+
306
+ def apartment_build_type_map
307
+ @type_map = SharedTypeMap.new
308
+ # Parity with upstream's own reload, which clears this before
309
+ # reinitialising. It exists only on Rails main, where it records whether
310
+ # this adapter has run the deferred bulk pg_type query; the assignment is
311
+ # inert on 8.1, where nothing reads it. Without it a rebuilt
312
+ # map would skip the bulk half of the next deferred load -- still
313
+ # correct, since the specific OID is always requested, but less than
314
+ # upstream promises.
315
+ @type_map_queried = false
316
+ initialize_type_map
317
+ @type_map
318
+ end
319
+ end
320
+ end
321
+ end
@@ -124,11 +124,19 @@ module Apartment
124
124
  .map(&:first)
125
125
  end
126
126
 
127
+ # O(1) pool count. Separate from #stats because the admission path asks for
128
+ # the count on every cold create, and #stats answers by materializing the
129
+ # full key array — garbage proportional to the pool count, at exactly the
130
+ # moment the pool count is the problem.
131
+ def total_pools
132
+ @pools.size
133
+ end
134
+
127
135
  # Basic stats. Full observability (per-tenant breakdown, connection
128
136
  # counts, eviction counters) deferred to Phase 3.
129
137
  def stats
130
138
  {
131
- total_pools: @pools.size,
139
+ total_pools: total_pools,
132
140
  tenants: @pools.keys,
133
141
  }
134
142
  end
@@ -61,13 +61,18 @@ module Apartment
61
61
  def admit!(incoming_tenant_key)
62
62
  return unless @max_total
63
63
 
64
+ # Reset per scan: the tally that matters is the one from the pass that
65
+ # gave up, not a sum over passes that each evicted something.
66
+ skipped = nil
64
67
  loop do
65
- break if @pool_manager.stats[:total_pools] < @max_total
66
- break unless evict_one_for_admission(incoming_tenant_key)
68
+ break if @pool_manager.total_pools < @max_total
69
+
70
+ skipped = { pinned: 0, in_use: 0, default: 0 }
71
+ break unless evict_one_for_admission(incoming_tenant_key, skipped)
67
72
  end
68
- return if @pool_manager.stats[:total_pools] < @max_total
73
+ return if @pool_manager.total_pools < @max_total
69
74
 
70
- apply_overflow_policy
75
+ apply_overflow_policy(skipped)
71
76
  end
72
77
 
73
78
  def start
@@ -156,11 +161,32 @@ module Apartment
156
161
  # Evict the single LRU evictable pool to make room for an incoming one,
157
162
  # skipping the incoming key, the default tenant, and pinned/in-use pools.
158
163
  # Returns the evicted tenant key, or nil if nothing is evictable.
159
- def evict_one_for_admission(incoming_tenant_key)
160
- @pool_manager.lru_tenants(count: @pool_manager.stats[:total_pools]).each do |tenant|
164
+ #
165
+ # Tallies protected candidates into +skipped+ instead of emitting a
166
+ # per-candidate :skip_evict. This scan walks every pool and runs on every
167
+ # cold create, so a per-candidate event is quadratic in the pool count: a
168
+ # measured breach that reached 477 pools emitted ~218k of them, each one
169
+ # eagerly walking that pool's connection array twice to build a payload.
170
+ # The aggregate lands on :cap_unmet once per admission instead. The timer
171
+ # paths keep their per-candidate events — there the scan is once per reap
172
+ # cycle, and "this tenant has been skipped for N cycles" is the signal.
173
+ def evict_one_for_admission(incoming_tenant_key, skipped)
174
+ @pool_manager.lru_tenants(count: @pool_manager.total_pools).each do |tenant|
161
175
  next if tenant == incoming_tenant_key
162
- next if default_tenant_pool?(tenant)
163
- next if protected_pool?(tenant, eviction_reason: :admission)
176
+
177
+ # Tallied, not silently skipped: a breach caused entirely by default
178
+ # pools would otherwise report "nothing protected" beside a cap that
179
+ # could not be met. The three buckets plus the incoming key account
180
+ # for every candidate the scan saw.
181
+ if default_tenant_pool?(tenant)
182
+ skipped[:default] += 1
183
+ next
184
+ end
185
+
186
+ if (reason = protection_reason(@pool_manager.peek(tenant)))
187
+ skipped[reason] += 1
188
+ next
189
+ end
164
190
 
165
191
  evict_tenant(tenant, reason: :admission)
166
192
  return tenant
@@ -174,18 +200,20 @@ module Apartment
174
200
  # or in use). :evict_idle degrades to a soft cap — allow the new pool, surface
175
201
  # the breach via :cap_unmet. :raise fails the admission so the caller sheds
176
202
  # load. See docs/designs/pool-admission-control.md.
177
- def apply_overflow_policy
178
- current = @pool_manager.stats[:total_pools]
203
+ def apply_overflow_policy(skipped = nil)
204
+ current = @pool_manager.total_pools
179
205
  case @overflow_policy
180
206
  when :raise
181
207
  raise(Apartment::PoolCapacityReached.new(max_total: @max_total, current: current))
182
208
  else
183
- Instrumentation.instrument(:cap_unmet, max_total: @max_total, current: current, unevicted: 1)
209
+ payload = { max_total: @max_total, current: current, unevicted: 1 }
210
+ payload[:skipped] = skipped if skipped
211
+ Instrumentation.instrument(:cap_unmet, payload)
184
212
  end
185
213
  end
186
214
 
187
215
  def evict_lru
188
- total = @pool_manager.stats[:total_pools]
216
+ total = @pool_manager.total_pools
189
217
  excess = total - @max_total
190
218
  return 0 if excess <= 0
191
219
 
@@ -231,15 +259,20 @@ module Apartment
231
259
  # protected. Used as a single guard for both eviction paths.
232
260
  def protected_pool?(tenant, eviction_reason:)
233
261
  pool = @pool_manager.peek(tenant)
234
- if pool_pinned?(pool)
235
- instrument_skip(reason: :pinned, tenant: tenant, eviction_reason: eviction_reason, pool: pool)
236
- return true
237
- end
238
- if pool_in_use?(pool)
239
- instrument_skip(reason: :in_use, tenant: tenant, eviction_reason: eviction_reason, pool: pool)
240
- return true
241
- end
242
- false
262
+ return false unless (reason = protection_reason(pool))
263
+
264
+ instrument_skip(reason: reason, tenant: tenant, eviction_reason: eviction_reason, pool: pool)
265
+ true
266
+ end
267
+
268
+ # Why this pool cannot be evicted right now (:pinned / :in_use), or nil when
269
+ # it can. Split out of protected_pool? so the admission scan can classify a
270
+ # candidate without paying for instrumentation — see evict_one_for_admission.
271
+ def protection_reason(pool)
272
+ return :pinned if pool_pinned?(pool)
273
+ return :in_use if pool_in_use?(pool)
274
+
275
+ nil
243
276
  end
244
277
 
245
278
  # Instance-side wrapper around the class predicate; kept for callers
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Apartment
4
- VERSION = '4.0.0.alpha13'
4
+ VERSION = '4.0.0.alpha15'
5
5
  end
data/lib/apartment.rb CHANGED
@@ -545,14 +545,17 @@ module Apartment # rubocop:disable Metrics/ModuleLength
545
545
  end
546
546
  end
547
547
 
548
- # Prepend the sequence-name patch whenever the PostgreSQL adapter loads
548
+ # Prepend the PostgreSQL adapter patches whenever the adapter loads
549
549
  # (immediately, if it already has). Registered at gem load rather than in
550
- # activate! because ActiveRecord memoizes Model.sequence_name at first touch,
551
- # which can happen during boot before Apartment.activate! runs. No-op for apps
552
- # that never load the PostgreSQL adapter, so MySQL/SQLite consumers never pull
553
- # in pg. See the patch file for the full rationale.
550
+ # activate! because both fire before activate! can: ActiveRecord memoizes
551
+ # Model.sequence_name at first touch, and boot opens connections (schema cache,
552
+ # pending-migration check) whose type map the tenant pools should then adopt.
553
+ # No-op for apps that never load the PostgreSQL adapter, so MySQL/SQLite
554
+ # consumers never pull in pg. See each patch file for the full rationale.
554
555
  ActiveSupport.on_load(:active_record_postgresqladapter) do
555
556
  prepend(Apartment::Patches::PostgresqlSequenceName)
557
+
558
+ Apartment::Patches::PostgresqlTypeMap.apply!(self)
556
559
  end
557
560
 
558
561
  # Load Railtie when Rails is present (standard gem convention).
@@ -29,7 +29,7 @@ Gem::Specification.new do |s|
29
29
 
30
30
  s.required_ruby_version = '>= 3.3'
31
31
 
32
- s.add_dependency('activerecord', '>= 7.2.0', '< 8.2')
32
+ s.add_dependency('activerecord', '>= 8.1.0', '< 8.2')
33
33
  s.add_dependency('activesupport', '>= 7.2.0', '< 8.2')
34
34
  s.add_dependency('concurrent-ruby', '>= 1.3.0')
35
35
  s.add_dependency('parallel', '>= 1.26.0')
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ros-apartment
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.0.0.alpha13
4
+ version: 4.0.0.alpha15
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ryan Brunner
@@ -18,7 +18,7 @@ dependencies:
18
18
  requirements:
19
19
  - - ">="
20
20
  - !ruby/object:Gem::Version
21
- version: 7.2.0
21
+ version: 8.1.0
22
22
  - - "<"
23
23
  - !ruby/object:Gem::Version
24
24
  version: '8.2'
@@ -28,7 +28,7 @@ dependencies:
28
28
  requirements:
29
29
  - - ">="
30
30
  - !ruby/object:Gem::Version
31
- version: 7.2.0
31
+ version: 8.1.0
32
32
  - - "<"
33
33
  - !ruby/object:Gem::Version
34
34
  version: '8.2'
@@ -199,6 +199,7 @@ files:
199
199
  - lib/apartment/patches/connection_registry.rb
200
200
  - lib/apartment/patches/live_tenant_propagation.rb
201
201
  - lib/apartment/patches/postgresql_sequence_name.rb
202
+ - lib/apartment/patches/postgresql_type_map.rb
202
203
  - lib/apartment/pool_manager.rb
203
204
  - lib/apartment/pool_observer.rb
204
205
  - lib/apartment/pool_reaper.rb