ros-apartment 4.0.0.alpha7 → 4.0.0.alpha8

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: '093f09466c1e39d4344764096cec982394980fb08c8b72712c96bd8ad30fdcc7'
4
- data.tar.gz: 402f7ef009203bb95296a90d7c6ff3dd34eeebca2d70c7da86cdb398ecc6fa60
3
+ metadata.gz: 58a2c1795ee20c09d5ffa99ece8bb18d8697f981f6a6292750941775ee81738b
4
+ data.tar.gz: fb37448602b18043aee5f2d49d4f037c82b9b744917eeafb33de7e1099bdec6d
5
5
  SHA512:
6
- metadata.gz: 99343f0efe17d444cc23b244581c56135a293371c97ee3fc5d2d2931e51c688c50d9d3d93d8ae55489ec36d3a6a43a910cacdc85d22c43a6c7e80a1bad189faa
7
- data.tar.gz: 2ccd4c456cf1a5ce190d4b50dedfc131231cd1f02518c0444313987ef0a5fda8acc00c1376bfc5daac791768f1b13685d0bb47a08ae5ba755943a2cd6eb5d04b
6
+ metadata.gz: 9913b7e11919a0a2fb39398a67cab8a5dad0dda6d637df954bec0222bcc83c93b1e71dc36469316d2a50fbdf07996d665903b24f17c87cf99191fea009661ffa
7
+ data.tar.gz: f1128508d59269eec5ed3c5f2a684b0a8205049242c53b0729b461502ae2d7e2b8613519e4e0cb949896f10468011c6684d0ceca266961ced545fba5e3242885
data/README.md CHANGED
@@ -118,15 +118,19 @@ All options are set in `config/initializers/apartment.rb` inside an `Apartment.c
118
118
 
119
119
  ### Pool Settings
120
120
 
121
- `tenant_pool_size`: max connections per tenant pool. Default `nil` — each tenant pool inherits the app's base pool size (`DB_POOL_SIZE` / `pool:` in `database.yml`). Set it to size tenant pools independently of the app pool (e.g. to bound total connections across many schema-per-tenant pools).
121
+ `tenant_pool_size`: max connections per tenant pool. Default `nil` — each tenant pool inherits the app's base pool size (`DB_POOL_SIZE` / `pool:` in `database.yml`). **Set it to at least the peak number of threads/fibers in one process that can touch the _same_ tenant at once** (e.g. a Sidekiq role's concurrency for a same-tenant job fan-out); a smaller pool makes those threads block on connection checkout. It is a lazy ceiling — connections are created on demand and idle pools are reaped — so sizing for peak does not hold that many connections open at steady state.
122
122
 
123
123
  `pool_idle_timeout`: seconds an idle tenant pool must exceed before it is eligible for reaping (default: 300).
124
124
 
125
125
  `reaper_interval`: seconds between background reap passes. Default `nil` — derives from `pool_idle_timeout`. Set it lower to reap more often without shrinking the idle window.
126
126
 
127
- `max_total_connections`: ceiling on the number of live tenant pools; `nil` for unlimited (default: `nil`). Enforced synchronously at pool-creation time (see `pool_overflow_policy`) and trimmed continuously by the background reaper. Total backend connections ≈ `max_total_connections × tenant_pool_size`.
127
+ `max_tenant_pools`: ceiling on the number of live tenant pools per process; `nil` for unlimited (default: `nil`). Enforced synchronously at pool-creation time (see `pool_overflow_policy`) and trimmed continuously by the background reaper.
128
128
 
129
- `pool_overflow_policy`: behavior when a new pool would breach `max_total_connections` and every existing pool is pinned or in use (no idle pool to evict). `:evict_idle` (default) allow the new pool, emit a `cap_unmet` notification (soft cap, prioritizes availability). `:raise` raise `Apartment::PoolCapacityReached` (hard cap, sheds load). When an idle pool *is* available it is always evicted inline regardless of policy. See `docs/designs/pool-admission-control.md`.
129
+ `max_tenant_connections`: ceiling on total *tenant-pool* connections per process (default: `nil`); requires `tenant_pool_size`. The admission controller derives the pool budget as `floor(max_tenant_connections / tenant_pool_size)`. Per-process tenant-pool connections `effective_pool_budget × tenant_pool_size`, where `effective_pool_budget = min(max_tenant_pools, floor(max_tenant_connections / tenant_pool_size))`; the default pool and any separate pinned pool are additional. A tenant using multiple roles (e.g. `writing` + `reading`) holds one pool per role (`tenant:role` keys), counting once per role against the budget. For a hard external-pooler budget, pair this with `pool_overflow_policy: :raise` (the ceiling is soft under the default `:evict_idle`).
130
+
131
+ `max_total_connections`: **deprecated** (removed in v5) — it capped tenant-pool *count*, not connections. It now aliases `max_tenant_pools`; rename it. For a true connection ceiling, set `max_tenant_connections`.
132
+
133
+ `pool_overflow_policy`: behavior when a new pool would breach the pool budget (`max_tenant_pools` / `max_tenant_connections`) and every existing pool is pinned or in use (no idle pool to evict). `:evict_idle` (default) — allow the new pool, emit a `cap_unmet` notification (soft cap, prioritizes availability). `:raise` — raise `Apartment::PoolCapacityReached` (hard cap, sheds load). When an idle pool *is* available it is always evicted inline regardless of policy. See `docs/designs/pool-admission-control.md`.
130
134
 
131
135
  `reap_in_test`: keep the background reaper running under `Rails.env.test?` (default `false` — the Railtie stops it in test, where fixture transactions make mid-example eviction a liability). Set `true` if a deployed process can run under test-env semantics and must keep reaping — that's cleaner than guarding `RAILS_ENV` at boot to avoid silently leaking connections. It applies to *every* `Rails.env.test?` process, including CI, so enable it only when a real deployment needs it.
132
136
 
@@ -20,7 +20,8 @@ lib/apartment/
20
20
  │ └── mysql_config.rb # MysqlConfig: placeholder
21
21
  ├── elevators/ # Rack middleware for tenant detection (see CLAUDE.md); v4 uses constructor keyword args, no class-level state; Generic, Subdomain, FirstSubdomain, Domain, Host, HostHash, Header
22
22
  ├── patches/ # ActiveRecord patches for tenant-aware connections
23
- └── connection_handling.rb # Prepends on AR::Base — tenant-aware connection_pool
23
+ ├── connection_handling.rb # Prepends on AR::Base — tenant-aware connection_pool
24
+ │ └── postgresql_sequence_name.rb # Prepends on the PG adapter — schema-agnostic Model.sequence_name memoization
24
25
  ├── tasks/ # Rake task utilities; v4.rake for apartment:create/drop/migrate/seed/rollback
25
26
  ├── config.rb # Configuration with validate!/freeze!
26
27
  ├── current.rb # Fiber-safe tenant context (CurrentAttributes)
@@ -59,7 +60,7 @@ lib/apartment/
59
60
 
60
61
  ### pool_manager.rb — Pool Cache
61
62
 
62
- `Concurrent::Map` storing connection pools by tenant key. Monotonic clock timestamps for idle/LRU tracking. `stats_for` returns `{ seconds_idle: N }`. `clear` disconnects all pools before clearing. When `max_total_connections` is set, `Apartment.configure` wires an `admission_controller` (the reaper) so cold creates route through a serialized, capacity-bounded path; otherwise the lock-free `compute_if_absent` fast path is used. See `docs/designs/pool-admission-control.md`.
63
+ `Concurrent::Map` storing connection pools by tenant key. Monotonic clock timestamps for idle/LRU tracking. `stats_for` returns `{ seconds_idle: N }`. `clear` disconnects all pools before clearing. When a pool budget is configured (`max_tenant_pools` and/or `max_tenant_connections`, resolved by `Config#effective_pool_budget`; `max_total_connections` is the deprecated alias of `max_tenant_pools`), `Apartment.configure` wires an `admission_controller` (the reaper) so cold creates route through a serialized, capacity-bounded path; otherwise the lock-free `compute_if_absent` fast path is used. See `docs/designs/pool-connection-budget.md` and `docs/designs/pool-admission-control.md`.
63
64
 
64
65
  ### pool_reaper.rb — Pool Eviction + Admission
65
66
 
@@ -97,7 +98,7 @@ All inherit from `AbstractAdapter`. Override `resolve_connection_config`, `creat
97
98
 
98
99
  ### pool_observer.rb — Observability (opt-in)
99
100
 
100
- Sink-agnostic subscriber for the pool events (`create`/`evict`/`cap_unmet`/`skip_evict`/`reaper_stopped`) + an optional `Concurrent::TimerTask` gauge sampler (`tenant_pools_live`, optional adopter `backend_count`). Normalizes each to a `Sample` and forwards to a caller `sink`; ships no transport. Error-isolated — never raises into instrumentation. See `docs/observability.md`.
101
+ Sink-agnostic subscriber for the pool events (`create`/`evict`/`cap_unmet`/`skip_evict`/`reaper_stopped`) + an optional `Concurrent::TimerTask` gauge sampler. Gauges: `tenant_pools_live`, optional adopter `backend_count`, and per-pass checkout-pressure gauges from each tenant pool's AR `ConnectionPool#stat` — `pools_waiting` (blocked on checkout), `pools_saturated` (`busy >= size`), `max_checkout_waiting` (worst pool's `waiting`, tenant in payload not a dimension). Per-pool `#stat` is rescued so one tearing-down pool can't abort the pass. Normalizes each to a `Sample` via the shared `emit_gauge` helper and forwards to a caller `sink`; ships no transport. Error-isolated — never raises into instrumentation. See `docs/observability.md`.
101
102
 
102
103
  ### railtie.rb — v4 Rails Integration
103
104
 
@@ -17,7 +17,8 @@ module Apartment
17
17
  attr_accessor :tenants_provider, :default_tenant,
18
18
  :default_tenant_switch_allowed,
19
19
  :tenant_pool_size, :pool_idle_timeout, :reaper_interval,
20
- :max_total_connections, :pool_overflow_policy,
20
+ :max_total_connections, :max_tenant_pools, :max_tenant_connections,
21
+ :pool_overflow_policy,
21
22
  :seed_after_create, :seed_data_file,
22
23
  :schema_load_strategy, :schema_file,
23
24
  :parallel_migration_threads,
@@ -38,6 +39,8 @@ module Apartment
38
39
  @pool_idle_timeout = 300
39
40
  @reaper_interval = nil
40
41
  @max_total_connections = nil
42
+ @max_tenant_pools = nil
43
+ @max_tenant_connections = nil
41
44
  @pool_overflow_policy = :evict_idle
42
45
  @seed_after_create = false
43
46
  @seed_data_file = nil
@@ -124,6 +127,17 @@ module Apartment
124
127
  # Reap on the idle-timeout cadence unless an explicit interval decouples
125
128
  # the two (reap more often without shrinking the idle window).
126
129
  @reaper_interval = @pool_idle_timeout if @reaper_interval.nil?
130
+
131
+ # max_total_connections is deprecated: the name said "connections" but it
132
+ # always capped tenant-pool COUNT. Alias it to its true meaning without
133
+ # changing behavior. Only fill when max_tenant_pools was not set explicitly,
134
+ # so validate!'s both-set guard can still catch a genuine double-spec.
135
+ return unless @max_total_connections
136
+
137
+ warn '[Apartment] DEPRECATION: config.max_total_connections is deprecated and will be ' \
138
+ 'removed in v5. It caps tenant-pool COUNT, not connections; rename it to ' \
139
+ 'max_tenant_pools. For a true connection ceiling, set max_tenant_connections.'
140
+ @max_tenant_pools = @max_total_connections if @max_tenant_pools.nil?
127
141
  end
128
142
 
129
143
  # Validate configuration completeness and consistency.
@@ -171,6 +185,35 @@ module Apartment
171
185
  "max_total_connections must be a positive integer or nil, got: #{@max_total_connections.inspect}")
172
186
  end
173
187
 
188
+ if @max_tenant_pools && (!@max_tenant_pools.is_a?(Integer) || @max_tenant_pools < 1)
189
+ raise(ConfigurationError,
190
+ "max_tenant_pools must be a positive integer or nil, got: #{@max_tenant_pools.inspect}")
191
+ end
192
+
193
+ if @max_total_connections && @max_tenant_pools && @max_total_connections != @max_tenant_pools
194
+ raise(ConfigurationError,
195
+ 'max_total_connections and max_tenant_pools are the same setting under two names; ' \
196
+ "set only one. Got max_total_connections=#{@max_total_connections}, " \
197
+ "max_tenant_pools=#{@max_tenant_pools}")
198
+ end
199
+
200
+ if @max_tenant_connections && (!@max_tenant_connections.is_a?(Integer) || @max_tenant_connections < 1)
201
+ raise(ConfigurationError,
202
+ "max_tenant_connections must be a positive integer or nil, got: #{@max_tenant_connections.inspect}")
203
+ end
204
+
205
+ if @max_tenant_connections && @tenant_pool_size.nil?
206
+ raise(ConfigurationError,
207
+ 'max_tenant_connections requires tenant_pool_size to be set (the pool budget is ' \
208
+ 'derived as max_tenant_connections / tenant_pool_size)')
209
+ end
210
+
211
+ if @max_tenant_connections && @tenant_pool_size && @max_tenant_connections < @tenant_pool_size
212
+ raise(ConfigurationError,
213
+ "max_tenant_connections (#{@max_tenant_connections}) must be >= tenant_pool_size " \
214
+ "(#{@tenant_pool_size}); it cannot fit a single tenant pool")
215
+ end
216
+
174
217
  unless %i[evict_idle raise].include?(@pool_overflow_policy)
175
218
  raise(ConfigurationError,
176
219
  'pool_overflow_policy must be :evict_idle or :raise, ' \
@@ -238,5 +281,15 @@ module Apartment
238
281
  def rails_env_name
239
282
  (Rails.env if defined?(Rails.env)) || ENV['RAILS_ENV'] || ENV['RACK_ENV'] || 'default_env'
240
283
  end
284
+
285
+ # The pool-count bound the admission controller enforces: the stricter of the
286
+ # explicit pool cap (max_tenant_pools) and the pool budget derived from the
287
+ # connection ceiling (max_tenant_connections / tenant_pool_size, floored).
288
+ # Returns nil (uncapped) when neither knob is set. Relies on a global
289
+ # tenant_pool_size, so tenant-pool connections <= budget * tenant_pool_size.
290
+ def effective_pool_budget
291
+ derived = @max_tenant_connections && @tenant_pool_size ? @max_tenant_connections / @tenant_pool_size : nil
292
+ [@max_tenant_pools, derived].compact.min
293
+ end
241
294
  end
242
295
  end
@@ -33,22 +33,31 @@ module Apartment
33
33
  # Raised when the tenant connection pool is exhausted.
34
34
  class PoolExhausted < ApartmentError; end
35
35
 
36
- # Raised when admitting a new tenant pool would exceed max_total_connections
37
- # and no idle pool can be evicted to make room, under the :raise overflow
38
- # policy. Distinct from PoolExhausted (a single pool's connections) — this is
39
- # the process-wide pool-count ceiling. See docs/designs/pool-admission-control.md.
36
+ # Raised when admitting a new tenant pool would exceed the effective pool
37
+ # budget (Config#effective_pool_budget) and no idle pool can be evicted to make
38
+ # room, under the :raise overflow policy. Distinct from PoolExhausted (a single
39
+ # pool's connections) — this is the process-wide pool-COUNT ceiling. The message
40
+ # names the knobs that set it, never the deprecated max_total_connections: that
41
+ # name is what made the budget look like a connection count in the first place.
42
+ # See docs/designs/pool-connection-budget.md and pool-admission-control.md.
40
43
  class PoolCapacityReached < ApartmentError
44
+ # +max_total+ is the effective POOL budget, not a connection count. The
45
+ # reader keeps its name for compatibility with anything already rescuing this.
41
46
  attr_reader :max_total, :current
42
47
 
48
+ alias pool_budget max_total
49
+
43
50
  def initialize(max_total: nil, current: nil)
44
51
  @max_total = max_total
45
52
  @current = current
46
53
  super(
47
- "Tenant pool capacity reached: #{current.inspect} pools open, " \
48
- "max_total_connections is #{max_total.inspect}, and no idle pool could " \
49
- 'be evicted to admit another (all pinned or in use). Raise ' \
50
- 'max_total_connections, reduce concurrent tenants, or set ' \
51
- 'pool_overflow_policy to :evict_idle to allow soft overflow.'
54
+ "Tenant pool capacity reached: #{current.inspect} tenant pools open, " \
55
+ "the effective pool budget is #{max_total.inspect}, and no idle pool " \
56
+ 'could be evicted to admit another (all pinned or in use). Raise ' \
57
+ 'max_tenant_pools, or raise max_tenant_connections (which derives the ' \
58
+ 'pool budget as max_tenant_connections / tenant_pool_size), reduce ' \
59
+ 'concurrent tenants, or set pool_overflow_policy to :evict_idle to ' \
60
+ 'allow soft overflow.'
52
61
  )
53
62
  end
54
63
  end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Apartment
4
+ module Patches
5
+ # Keeps ActiveRecord's class-level Model.sequence_name memoization
6
+ # schema-agnostic under pool-per-tenant.
7
+ #
8
+ # Rails resolves default_sequence_name via pg_get_serial_sequence with an
9
+ # unqualified table name, so PostgreSQL answers through the current
10
+ # connection's search_path and returns a schema-QUALIFIED name — the schema
11
+ # of whichever tenant's pool happened to resolve it first. ActiveRecord then
12
+ # memoizes that value once per model class, process-wide. Consumers that
13
+ # render it into SQL (activerecord-import prefetches primary keys as a
14
+ # literal nextval(Model.sequence_name)) would draw ids from the
15
+ # first-resolver tenant's sequence in every tenant: wrong-tenant ids, silent
16
+ # sequence drift, and eventual PG::UniqueViolation.
17
+ #
18
+ # Stripping the connection's own current_schema prefix makes the memoized
19
+ # value schema-agnostic: nextval('widgets_id_seq') re-resolves through each
20
+ # pool's search_path, per tenant — the same invariant v4 relies on for table
21
+ # names (see docs/designs/v4-connection-model-rationale.md). A prefix naming
22
+ # any OTHER schema is preserved on purpose: persistent-schema tables and
23
+ # pinned models (whose table names are default_tenant-qualified and may
24
+ # execute on a shared tenant connection) are only correct BECAUSE they stay
25
+ # qualified.
26
+ #
27
+ # v3 shipped this guarantee as PostgreSqlAdapterPatch; it was lost in the
28
+ # Phase 2.5 v3 deletion (PR #356). Reset-on-switch is not an alternative:
29
+ # v4 serves tenants concurrently in one process, so only a schema-agnostic
30
+ # memoized value can be correct.
31
+ module PostgresqlSequenceName
32
+ def default_sequence_name(table_name, primary_key = 'id')
33
+ res = super
34
+ return res if res.nil?
35
+ # A schema-qualified table name is a PINNED model: Apartment qualifies
36
+ # those to the default tenant, and they resolve on whatever connection is
37
+ # current (the schema strategy shares the tenant's connection). Its
38
+ # sequence must stay qualified — stripping here is the mirror-image bug,
39
+ # because an unqualified name would later re-resolve against a *tenant's*
40
+ # search_path and draw ids from that tenant's stale copy of the pinned
41
+ # table's sequence. That order is the common one, not a corner case:
42
+ # pinned models are typically first touched at boot, on the default pool.
43
+ # v3 guarded the same case by force-adding the default_tenant prefix.
44
+ return res if table_name.to_s.include?('.')
45
+
46
+ # Routed table: drop whatever schema PostgreSQL qualified the sequence
47
+ # with — not just the connection's own. The two differ when the tenant
48
+ # schema is missing the table and search_path falls through to a schema
49
+ # that has it: the resolved sequence then names the FALLBACK schema, and
50
+ # preserving that would memoize it process-wide, so every tenant —
51
+ # including correctly-migrated ones — would draw from the fallback's
52
+ # sequence forever. Unqualified is the only value that lets the sequence
53
+ # re-resolve per pool exactly like the table name does.
54
+ #
55
+ # Parsed with AR's own splitter (rather than by hand) because it is what
56
+ # produced `res`, so it round-trips quoting and dotted identifiers.
57
+ ActiveRecord::ConnectionAdapters::PostgreSQL::Utils
58
+ .extract_schema_qualified_name(res).identifier
59
+ end
60
+ end
61
+ end
62
+ end
@@ -66,14 +66,15 @@ module Apartment
66
66
  # when supplied. Safe to call from start_sampler! or an external scheduler.
67
67
  def sample!
68
68
  total = Apartment.pool_manager&.stats&.fetch(:total_pools, 0) || 0
69
- emit(Sample.new(name: :tenant_pools_live, kind: :gauge, value: total, dimensions: {}, payload: {}))
69
+ emit_gauge(:tenant_pools_live, total)
70
+ emit_checkout_pressure!
70
71
 
71
72
  return unless @backend_count
72
73
 
73
74
  backends = @backend_count.call
74
75
  return if backends.nil?
75
76
 
76
- emit(Sample.new(name: :backend_connections, kind: :gauge, value: backends, dimensions: {}, payload: {}))
77
+ emit_gauge(:backend_connections, backends)
77
78
  rescue StandardError => e
78
79
  warn_failure('sample!', e)
79
80
  end
@@ -104,6 +105,43 @@ module Apartment
104
105
  @sampler = nil
105
106
  end
106
107
 
108
+ # Per-tenant-pool checkout pressure, aggregated to low cardinality. waiting>0
109
+ # means threads are blocked acquiring a connection (the same-tenant fan-out
110
+ # starvation signal). The worst tenant is carried in the Sample payload, NOT
111
+ # as a metric dimension, to avoid unbounded time-series churn. Per-pool #stat
112
+ # is rescued so a pool tearing down mid-sample can't abort the whole pass.
113
+ def emit_checkout_pressure!
114
+ manager = Apartment.pool_manager
115
+ return unless manager
116
+
117
+ stats = collect_pool_stats(manager)
118
+ worst = stats.max_by { |s| s[:pending] } || { tenant: nil, pending: 0 }
119
+ payload = worst[:pending].positive? ? { tenant: worst[:tenant] } : {}
120
+
121
+ emit_gauge(:pools_waiting, stats.count { |s| s[:pending].positive? })
122
+ emit_gauge(:pools_saturated, stats.count { |s| s[:saturated] })
123
+ emit_gauge(:max_checkout_waiting, worst[:pending], payload: payload)
124
+ end
125
+
126
+ def emit_gauge(name, value, payload: {})
127
+ emit(Sample.new(name: name, kind: :gauge, value: value, dimensions: {}, payload: payload))
128
+ end
129
+
130
+ # Read each tenant pool's checkout stats into [{ tenant:, pending:, saturated: }],
131
+ # skipping any pool whose #stat raises (mid-teardown) so one bad pool can't
132
+ # abort the whole pass.
133
+ def collect_pool_stats(manager)
134
+ stats = []
135
+ manager.each_pair do |tenant_key, pool|
136
+ stat = pool.stat
137
+ stats << { tenant: tenant_key, pending: stat[:waiting].to_i,
138
+ saturated: stat[:busy].to_i >= stat[:size].to_i }
139
+ rescue StandardError
140
+ next
141
+ end
142
+ stats
143
+ end
144
+
107
145
  def record_event(event, payload)
108
146
  # Copy the notification payload so a sink that mutates Sample#payload
109
147
  # can't corrupt it for other subscribers of the same event.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Apartment
4
- VERSION = '4.0.0.alpha7'
4
+ VERSION = '4.0.0.alpha8'
5
5
  end
data/lib/apartment.rb CHANGED
@@ -265,21 +265,23 @@ module Apartment # rubocop:disable Metrics/ModuleLength
265
265
  end
266
266
 
267
267
  # Build the pool manager + reaper for a freshly-validated config and start
268
- # the background reaper. When max_total_connections is set, wire the reaper
269
- # as the pool manager's admission controller so cold creates are bounded
270
- # synchronously; otherwise the manager keeps its lock-free create path.
268
+ # the background reaper. When a pool budget is configured (max_tenant_pools
269
+ # and/or max_tenant_connections, via Config#effective_pool_budget), wire the
270
+ # reaper as the pool manager's admission controller so cold creates are
271
+ # bounded synchronously; otherwise the manager keeps its lock-free create path.
271
272
  def setup_pools!(new_config)
273
+ budget = new_config.effective_pool_budget
272
274
  @pool_manager = PoolManager.new
273
275
  @pool_reaper = PoolReaper.new(
274
276
  pool_manager: @pool_manager,
275
277
  interval: new_config.reaper_interval,
276
278
  idle_timeout: new_config.pool_idle_timeout,
277
- max_total: new_config.max_total_connections,
279
+ max_total: budget,
278
280
  default_tenant: new_config.default_tenant,
279
281
  shard_key_prefix: new_config.shard_key_prefix,
280
282
  overflow_policy: new_config.pool_overflow_policy
281
283
  )
282
- @pool_manager.admission_controller = @pool_reaper if new_config.max_total_connections
284
+ @pool_manager.admission_controller = @pool_reaper if budget
283
285
  @pool_reaper.start
284
286
  end
285
287
 
@@ -370,6 +372,16 @@ module Apartment # rubocop:disable Metrics/ModuleLength
370
372
  end
371
373
  end
372
374
 
375
+ # Prepend the sequence-name patch whenever the PostgreSQL adapter loads
376
+ # (immediately, if it already has). Registered at gem load rather than in
377
+ # activate! because ActiveRecord memoizes Model.sequence_name at first touch,
378
+ # which can happen during boot before Apartment.activate! runs. No-op for apps
379
+ # that never load the PostgreSQL adapter, so MySQL/SQLite consumers never pull
380
+ # in pg. See the patch file for the full rationale.
381
+ ActiveSupport.on_load(:active_record_postgresqladapter) do
382
+ prepend(Apartment::Patches::PostgresqlSequenceName)
383
+ end
384
+
373
385
  # Load Railtie when Rails is present (standard gem convention).
374
386
  # Railtie is Zeitwerk-ignored — this explicit require is the only load path.
375
387
  require_relative 'apartment/railtie' if defined?(Rails::Railtie)
@@ -37,9 +37,20 @@ Apartment.configure do |config|
37
37
 
38
38
  # == Connection Pool =====================================================
39
39
 
40
- # config.tenant_pool_size = 5
41
- # config.pool_idle_timeout = 300
42
- # config.max_total_connections = nil
40
+ # tenant_pool_size MUST be >= the peak number of threads/fibers in one process
41
+ # that can touch the SAME tenant at once (e.g. a Sidekiq role's concurrency for a
42
+ # same-tenant job fan-out); otherwise those threads block on connection checkout.
43
+ # It is a lazy ceiling — connections are created on demand and idle pools reaped.
44
+ # config.tenant_pool_size = 5
45
+ # config.pool_idle_timeout = 300
46
+ #
47
+ # Bound how many tenant pools exist per process:
48
+ # config.max_tenant_pools = nil
49
+ # ...or bound total tenant-pool CONNECTIONS (requires tenant_pool_size); the
50
+ # admission controller derives the pool budget as floor(value / tenant_pool_size):
51
+ # config.max_tenant_connections = nil
52
+ #
53
+ # config.max_total_connections = nil # DEPRECATED: alias of max_tenant_pools; removed in v5
43
54
 
44
55
  # == Elevator (Request Tenant Detection) =================================
45
56
 
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.alpha7
4
+ version: 4.0.0.alpha8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ryan Brunner
@@ -194,6 +194,7 @@ files:
194
194
  - lib/apartment/migrator.rb
195
195
  - lib/apartment/patches/connection_handling.rb
196
196
  - lib/apartment/patches/live_tenant_propagation.rb
197
+ - lib/apartment/patches/postgresql_sequence_name.rb
197
198
  - lib/apartment/pool_manager.rb
198
199
  - lib/apartment/pool_observer.rb
199
200
  - lib/apartment/pool_reaper.rb