ros-apartment 4.0.0.alpha2 → 4.0.0.alpha4

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: efc3d3a91e17846826ac5d5ca707cd17bdc5a5923b18fb4c0162ac87f24014d0
4
- data.tar.gz: 5c1787f4e9cee805842e6035fcc2769415dfccdf90c3c2b354315271315638a2
3
+ metadata.gz: 86ded64305e44aac057b97264c079d5090545b7d1740693ea1cf7c881b69fe19
4
+ data.tar.gz: 99e78891808ff4cdb1e0fef82f7c49a344a4c088408c9c143af465c70a150a3b
5
5
  SHA512:
6
- metadata.gz: 486bb0cd94225925ada99a497c89e314536300a261b898c3575a5008211e1e8f1632a9755dadae096f5814eaee00920e9652f70f8feb8676ff58897fc4262f2c
7
- data.tar.gz: e9b88d3c86be4e0891ff584e85c822979c6146ece01ce2bab17f442603e8add4aef3e826157157df3325722e39209a70bf94ffdd218504abdbf6be7dd4af29cf
6
+ metadata.gz: 6a80e29d57f2d4d4de7cd41666b3cef737720cceb782c3d6a34f5522dec0a6ec3fc04e41ba1a45761eabfee5bee5f5b0d2d80d2ae79d1c43f8d905906d7dc1d3
7
+ data.tar.gz: a4c460e9ec3c2214e1a52fd399bf0038a6da1d43c66a20fbd97a31ceb6ac44b4595592eec41f0310cfc51f99e5b6699420332fa816a177865479af1772dea928
data/README.md CHANGED
@@ -30,7 +30,7 @@ Apartment uses **schema-per-tenant** (PostgreSQL) or **database-per-tenant** (My
30
30
 
31
31
  ## About ros-apartment
32
32
 
33
- This gem is a maintained fork of the original [Apartment gem](https://github.com/influitive/apartment). Maintained by [CampusESP](https://www.campusesp.com) since 2024. Same `require 'apartment'`; v4 introduces a pool-per-tenant architecture that replaces the thread-local switching of v3. Tenant context is fiber-safe via `CurrentAttributes`, and connection pools are managed per tenant rather than swapping search paths on a shared connection. See the [upgrade guide](docs/upgrading-to-v4.md) for migration steps from v3.
33
+ This gem is a maintained fork of the original [Apartment gem](https://github.com/influitive/apartment). Maintained by [CampusESP](https://www.campusesp.com) since 2024. Same `require 'apartment'`; v4 introduces a pool-per-tenant architecture that replaces the thread-local switching of v3. Tenant context is fiber-safe via `CurrentAttributes`, and connection pools are managed per tenant rather than swapping search paths on a shared connection. For *why* v4 chose this model over the alternatives (including fully-qualified table names), see [`docs/designs/v4-connection-model-rationale.md`](docs/designs/v4-connection-model-rationale.md). See the [upgrade guide](docs/upgrading-to-v4.md) for migration steps from v3.
34
34
 
35
35
  ## Installation
36
36
 
@@ -99,11 +99,23 @@ All options are set in `config/initializers/apartment.rb` inside an `Apartment.c
99
99
 
100
100
  ### Pool Settings
101
101
 
102
- `tenant_pool_size`: connections per tenant pool (default: 5).
102
+ `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).
103
103
 
104
- `pool_idle_timeout`: seconds before an idle tenant pool is eligible for reaping (default: 300).
104
+ `pool_idle_timeout`: seconds an idle tenant pool must exceed before it is eligible for reaping (default: 300).
105
105
 
106
- `max_total_connections`: hard cap across all tenant pools; nil for unlimited (default: nil).
106
+ `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.
107
+
108
+ `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`.
109
+
110
+ `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`.
111
+
112
+ `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.
113
+
114
+ ### Observability
115
+
116
+ Apartment emits `ActiveSupport::Notifications` for the pool lifecycle and ships
117
+ `Apartment::PoolObserver`, a sink-agnostic subscriber + sampler. See
118
+ [docs/observability.md](docs/observability.md).
107
119
 
108
120
  ### Elevator (Request Tenant Detection)
109
121
 
@@ -359,6 +371,22 @@ tenant's keyspace. Guard routed work with `Apartment::Tenant.require_tenant!`
359
371
  `require_default_tenant!`. See [Tenant-Aware Caching](docs/caching.md) for the
360
372
  routed-vs-pinned model and the two-store recipe.
361
373
 
374
+ ## Iterating across tenants
375
+
376
+ 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?**
377
+
378
+ | Need | Use | v4 cost |
379
+ |---|---|---|
380
+ | Names only (enqueue a job, build a list) | `Apartment.tenant_names.each { ... }` | No switch, no pool created |
381
+ | Per-tenant-schema work (read/write tenant tables) | `Apartment::Tenant.each(release_connection: true) { ... }` | One pool per tenant; released between iterations |
382
+ | 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 |
383
+
384
+ Rules of thumb:
385
+
386
+ - **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.
387
+ - **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.
388
+ - **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.**
389
+
362
390
  ## Convenience Methods
363
391
 
364
392
  `Apartment.tenant_names` returns the current tenant list (delegates to `config.tenants_provider.call`). Preserves the v3 API so existing call sites work without changes.
@@ -59,11 +59,11 @@ lib/apartment/
59
59
 
60
60
  ### pool_manager.rb — Pool Cache
61
61
 
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.
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
63
 
64
- ### pool_reaper.rb — Pool Eviction
64
+ ### pool_reaper.rb — Pool Eviction + Admission
65
65
 
66
- Background `Concurrent::TimerTask` instance that evicts idle and excess tenant pools. Created by `Apartment.configure`, stored as `Apartment.pool_reaper`. Deregisters evicted pools from AR's ConnectionHandler. Default tenant is never evicted.
66
+ 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.
67
67
 
68
68
  ### adapters/abstract_adapter.rb — Base Adapter
69
69
 
@@ -95,6 +95,10 @@ All inherit from `AbstractAdapter`. Override `resolve_connection_config`, `creat
95
95
 
96
96
  **Shim compatibility:** `process_pinned_model` dynamically includes `Apartment::Model` on classes registered via the `excluded_models` shim that lack the concern. This is a runtime `include` on a partially-booted class — acceptable for the legacy shim path but new code should always use `include Apartment::Model` + `pin_tenant` explicitly.
97
97
 
98
+ ### pool_observer.rb — Observability (opt-in)
99
+
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
+
98
102
  ### railtie.rb — v4 Rails Integration
99
103
 
100
104
  Three hooks in Rails boot order:
@@ -30,7 +30,8 @@ module Apartment
30
30
  strategy: Apartment.config.tenant_strategy,
31
31
  adapter_name: effective_base['adapter']
32
32
  )
33
- resolve_connection_config(tenant, base_config: effective_base)
33
+ config = resolve_connection_config(tenant, base_config: effective_base)
34
+ apply_tenant_pool_size(config)
34
35
  end
35
36
 
36
37
  # Resolve a tenant-specific connection config hash.
@@ -259,6 +260,19 @@ module Apartment
259
260
  connection_config.transform_keys(&:to_s)
260
261
  end
261
262
 
263
+ # Cap the tenant pool's max checkout size to Apartment.config.tenant_pool_size
264
+ # when configured. Defaults to nil — the tenant pool inherits the base/default
265
+ # pool's `pool:` (the app's DB_POOL_SIZE), preserving pre-4.0.0.alpha3 behavior.
266
+ # Set tenant_pool_size to size each per-tenant pool independently of the app pool
267
+ # (e.g. to bound total connections across many schema-per-tenant pools). The config
268
+ # is string-keyed here; HashConfig symbolizes it and reads `:pool` for the size.
269
+ def apply_tenant_pool_size(config)
270
+ size = Apartment.config.tenant_pool_size
271
+ return config unless size
272
+
273
+ config.merge('pool' => size)
274
+ end
275
+
262
276
  # Connection config for pinned models on the separate-pool path.
263
277
  # For schema strategy, pins schema_search_path to the default tenant
264
278
  # (plus persistent schemas) so the connection resolves tables and FK
@@ -16,7 +16,8 @@ module Apartment
16
16
 
17
17
  attr_accessor :tenants_provider, :default_tenant,
18
18
  :default_tenant_switch_allowed,
19
- :tenant_pool_size, :pool_idle_timeout, :max_total_connections,
19
+ :tenant_pool_size, :pool_idle_timeout, :reaper_interval,
20
+ :max_total_connections, :pool_overflow_policy,
20
21
  :seed_after_create, :seed_data_file,
21
22
  :schema_load_strategy, :schema_file,
22
23
  :parallel_migration_threads,
@@ -25,7 +26,7 @@ module Apartment
25
26
  :active_record_log, :sql_query_tags,
26
27
  :shard_key_prefix,
27
28
  :migration_role, :app_role, :schema_cache_per_tenant, :check_pending_migrations,
28
- :force_separate_pinned_pool, :test_fixture_cleanup
29
+ :force_separate_pinned_pool, :test_fixture_cleanup, :reap_in_test
29
30
 
30
31
  def initialize # rubocop:disable Metrics/AbcSize
31
32
  @tenant_strategy = nil
@@ -33,9 +34,11 @@ module Apartment
33
34
  @default_tenant = nil
34
35
  @default_tenant_switch_allowed = true
35
36
  @excluded_models = []
36
- @tenant_pool_size = 5
37
+ @tenant_pool_size = nil
37
38
  @pool_idle_timeout = 300
39
+ @reaper_interval = nil
38
40
  @max_total_connections = nil
41
+ @pool_overflow_policy = :evict_idle
39
42
  @seed_after_create = false
40
43
  @seed_data_file = nil
41
44
  @schema_load_strategy = nil
@@ -56,6 +59,7 @@ module Apartment
56
59
  @schema_cache_per_tenant = false
57
60
  @check_pending_migrations = true
58
61
  @force_separate_pinned_pool = false
62
+ @reap_in_test = false
59
63
  @test_fixture_cleanup = true
60
64
  end
61
65
 
@@ -117,6 +121,9 @@ module Apartment
117
121
  def apply_defaults!
118
122
  # PostgreSQL's default schema is 'public'; avoid forcing every user to set it.
119
123
  @default_tenant ||= 'public' if @tenant_strategy == :schema
124
+ # Reap on the idle-timeout cadence unless an explicit interval decouples
125
+ # the two (reap more often without shrinking the idle window).
126
+ @reaper_interval = @pool_idle_timeout if @reaper_interval.nil?
120
127
  end
121
128
 
122
129
  # Validate configuration completeness and consistency.
@@ -144,19 +151,32 @@ module Apartment
144
151
 
145
152
  @postgres_config&.validate!
146
153
 
147
- unless @tenant_pool_size.is_a?(Integer) && @tenant_pool_size.positive?
148
- raise(ConfigurationError, "tenant_pool_size must be a positive integer, got: #{@tenant_pool_size.inspect}")
154
+ if @tenant_pool_size && (!@tenant_pool_size.is_a?(Integer) || @tenant_pool_size < 1)
155
+ raise(ConfigurationError,
156
+ "tenant_pool_size must be a positive integer or nil, got: #{@tenant_pool_size.inspect}")
149
157
  end
150
158
 
151
159
  unless @pool_idle_timeout.is_a?(Numeric) && @pool_idle_timeout.positive?
152
160
  raise(ConfigurationError, "pool_idle_timeout must be a positive number, got: #{@pool_idle_timeout.inspect}")
153
161
  end
154
162
 
163
+ # nil is valid pre-apply_defaults! (it derives from pool_idle_timeout); a
164
+ # set value must be a positive number.
165
+ if @reaper_interval && (!@reaper_interval.is_a?(Numeric) || !@reaper_interval.positive?)
166
+ raise(ConfigurationError, "reaper_interval must be a positive number or nil, got: #{@reaper_interval.inspect}")
167
+ end
168
+
155
169
  if @max_total_connections && (!@max_total_connections.is_a?(Integer) || @max_total_connections < 1)
156
170
  raise(ConfigurationError,
157
171
  "max_total_connections must be a positive integer or nil, got: #{@max_total_connections.inspect}")
158
172
  end
159
173
 
174
+ unless %i[evict_idle raise].include?(@pool_overflow_policy)
175
+ raise(ConfigurationError,
176
+ 'pool_overflow_policy must be :evict_idle or :raise, ' \
177
+ "got: #{@pool_overflow_policy.inspect}")
178
+ end
179
+
160
180
  unless [nil, :schema_rb, :sql].include?(@schema_load_strategy)
161
181
  raise(ConfigurationError, "Invalid schema_load_strategy: #{@schema_load_strategy.inspect}. " \
162
182
  'Must be nil, :schema_rb, or :sql')
@@ -190,6 +210,11 @@ module Apartment
190
210
  "test_fixture_cleanup must be true or false, got: #{@test_fixture_cleanup.inspect}")
191
211
  end
192
212
 
213
+ unless [true, false].include?(@reap_in_test)
214
+ raise(ConfigurationError,
215
+ "reap_in_test must be true or false, got: #{@reap_in_test.inspect}")
216
+ end
217
+
193
218
  unless @tenant_validator.nil? || @tenant_validator == false || @tenant_validator.respond_to?(:call)
194
219
  raise(ConfigurationError,
195
220
  'tenant_validator must be nil, false, or a callable, ' \
@@ -33,6 +33,26 @@ 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.
40
+ class PoolCapacityReached < ApartmentError
41
+ attr_reader :max_total, :current
42
+
43
+ def initialize(max_total: nil, current: nil)
44
+ @max_total = max_total
45
+ @current = current
46
+ 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.'
52
+ )
53
+ end
54
+ end
55
+
36
56
  # Raised when schema loading fails during tenant creation.
37
57
  class SchemaLoadError < ApartmentError; end
38
58
 
@@ -32,6 +32,13 @@ module Apartment
32
32
  pool_key = "#{tenant}:#{role}"
33
33
 
34
34
  Apartment.pool_manager.fetch_or_create(pool_key) do
35
+ # RE-ENTRANCY: when max_total_connections is set, this block runs under
36
+ # PoolManager's @create_mutex (non-reentrant). Nothing here may resolve
37
+ # ActiveRecord::Base.connection_pool for the current tenant — it would
38
+ # re-enter fetch_or_create and self-deadlock. `super` resolves the
39
+ # default pool (bypasses the patch), and check_pending_migrations? /
40
+ # schema-cache load operate on the explicit `pool`, so all are safe.
41
+ # Keep it that way if you add work to this block.
35
42
  # Resolve base config from the current role's default pool when available.
36
43
  # Falls back to nil (adapter uses its own base_config) when the default pool
37
44
  # is not accessible — e.g., in worker threads during parallel migration where
@@ -63,9 +70,20 @@ module Apartment
63
70
  shard: shard_key
64
71
  )
65
72
 
66
- raise(Apartment::PendingMigrationError, tenant) if check_pending_migrations?(pool)
67
-
68
- load_tenant_schema_cache(tenant, pool) if cfg.schema_cache_per_tenant
73
+ # establish_connection has registered the shard in AR's ConnectionHandler.
74
+ # If a post-establish check raises, the pool is returned to neither the
75
+ # caller nor PoolManager — it would be orphaned: live in AR but invisible
76
+ # to the reaper and to max_total accounting (a connection leak that also
77
+ # undercounts the cap). Deregister it before re-raising so AR and the
78
+ # manager stay consistent. The next request re-establishes cleanly.
79
+ begin
80
+ raise(Apartment::PendingMigrationError, tenant) if check_pending_migrations?(pool)
81
+
82
+ load_tenant_schema_cache(tenant, pool) if cfg.schema_cache_per_tenant
83
+ rescue StandardError
84
+ Apartment.deregister_shard(pool_key)
85
+ raise
86
+ end
69
87
 
70
88
  pool
71
89
  end
@@ -80,7 +98,7 @@ module Apartment
80
98
 
81
99
  def check_pending_migrations?(pool)
82
100
  return false unless Apartment.config.check_pending_migrations
83
- return false unless defined?(Rails) && Rails.env.local? # rubocop:disable Rails/UnknownEnv
101
+ return false unless defined?(Rails) && Rails.env.local?
84
102
  return false if Apartment::Current.migrating
85
103
 
86
104
  pool.migration_context.needs_migration?
@@ -4,17 +4,25 @@ require 'concurrent'
4
4
 
5
5
  module Apartment
6
6
  class PoolManager
7
+ # Set by Apartment.configure to the PoolReaper when max_total_connections is
8
+ # configured. nil (no cap) keeps the lock-free compute_if_absent fast path.
9
+ attr_accessor :admission_controller
10
+
7
11
  def initialize
8
12
  @pools = Concurrent::Map.new
9
13
  @timestamps = Concurrent::Map.new
14
+ @create_mutex = Mutex.new
15
+ @admission_controller = nil
10
16
  end
11
17
 
12
18
  # Fetch an existing pool or create one via the block.
13
19
  # Timestamp is updated after pool creation to avoid orphaned timestamps if the block raises.
20
+ # When an admission controller is wired (a cap is configured), cold creates
21
+ # go through the bounded path so the pool count cannot exceed max_total.
14
22
  def fetch_or_create(tenant_key, &)
15
- pool = @pools.compute_if_absent(tenant_key, &)
16
- touch(tenant_key)
17
- pool
23
+ return fetch_or_admit(tenant_key, &) if @admission_controller
24
+
25
+ touch_and_return(tenant_key, @pools.compute_if_absent(tenant_key, &))
18
26
  end
19
27
 
20
28
  def get(tenant_key)
@@ -119,6 +127,32 @@ module Apartment
119
127
 
120
128
  private
121
129
 
130
+ # Capacity-bounded creation path. Serializes cold creates so the admission
131
+ # controller's capacity check + eviction + insert is atomic across creators;
132
+ # the new pool is only inserted after admit! confirms (or makes) room. The
133
+ # hot path (existing pool) stays lock-free in fetch_or_create. Establishing
134
+ # the connection under the lock is deliberate: it serializes only cold
135
+ # creates (once per tenant per worker) — the price of a hard count bound.
136
+ def fetch_or_admit(tenant_key)
137
+ existing = @pools[tenant_key]
138
+ return touch_and_return(tenant_key, existing) if existing
139
+
140
+ @create_mutex.synchronize do
141
+ cached = @pools[tenant_key]
142
+ return touch_and_return(tenant_key, cached) if cached
143
+
144
+ @admission_controller.admit!(tenant_key)
145
+ pool = yield
146
+ @pools[tenant_key] = pool
147
+ touch_and_return(tenant_key, pool)
148
+ end
149
+ end
150
+
151
+ def touch_and_return(tenant_key, pool)
152
+ touch(tenant_key)
153
+ pool
154
+ end
155
+
122
156
  def touch(tenant_key)
123
157
  @timestamps[tenant_key] = monotonic_now
124
158
  end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'concurrent'
4
+
5
+ module Apartment
6
+ # Sink-agnostic observer for the v4 pool lifecycle. Subscribes to the gem's
7
+ # ActiveSupport::Notifications and forwards a normalized Sample to a caller-
8
+ # supplied sink; optionally samples pool gauges on an interval. Ships no
9
+ # transport — the adopter's sink maps Samples to CloudWatch/StatsD/logs/etc.
10
+ # All sink/sampler calls are error-isolated: telemetry must never raise into
11
+ # the gem's instrumentation or timer path. See docs/observability.md.
12
+ class PoolObserver
13
+ # name: Symbol (:evict, :tenant_pools_live, ...); kind: :counter | :gauge;
14
+ # value: Numeric; dimensions: Hash (curated, e.g. { reason: :idle });
15
+ # payload: the raw notification payload (counters) or {} (gauges).
16
+ Sample = Data.define(:name, :kind, :value, :dimensions, :payload)
17
+
18
+ # Pool-lifecycle events forwarded as counters (value 1 each).
19
+ COUNTER_EVENTS = %i[create evict cap_unmet skip_evict reaper_stopped].freeze
20
+
21
+ # Build, subscribe, and (optionally) start the gauge sampler. Returns the
22
+ # observer; call #stop! to tear it down. Idempotent subscription is NOT
23
+ # guaranteed — install once per process (e.g. an after_initialize hook).
24
+ def self.install!(sink:, sample_interval: nil, backend_count: nil)
25
+ observer = new(sink: sink, backend_count: backend_count)
26
+ observer.subscribe!
27
+ observer.start_sampler!(interval: sample_interval) if sample_interval&.positive?
28
+ observer
29
+ rescue StandardError
30
+ # Don't leak subscriptions if a later step (e.g. a bad sample_interval)
31
+ # raises after subscribe! has registered listeners.
32
+ observer&.stop!
33
+ raise
34
+ end
35
+
36
+ def initialize(sink:, backend_count: nil)
37
+ raise(ArgumentError, 'sink must be callable') unless sink.respond_to?(:call)
38
+
39
+ @sink = sink
40
+ @backend_count = backend_count
41
+ @subscribers = []
42
+ @sampler = nil
43
+ end
44
+
45
+ def subscribe!
46
+ COUNTER_EVENTS.each do |event|
47
+ @subscribers << ActiveSupport::Notifications.subscribe("#{event}.apartment") do |*, payload|
48
+ record_event(event, payload || {})
49
+ end
50
+ end
51
+ self
52
+ end
53
+
54
+ # One gauge pass: live tenant-pool count, plus the adopter's backend count
55
+ # when supplied. Safe to call from start_sampler! or an external scheduler.
56
+ def sample!
57
+ total = Apartment.pool_manager&.stats&.fetch(:total_pools, 0) || 0
58
+ emit(Sample.new(name: :tenant_pools_live, kind: :gauge, value: total, dimensions: {}, payload: {}))
59
+
60
+ return unless @backend_count
61
+
62
+ backends = @backend_count.call
63
+ return if backends.nil?
64
+
65
+ emit(Sample.new(name: :backend_connections, kind: :gauge, value: backends, dimensions: {}, payload: {}))
66
+ rescue StandardError => e
67
+ warn_failure('sample!', e)
68
+ end
69
+
70
+ def start_sampler!(interval:)
71
+ @sampler&.shutdown
72
+ @sampler = Concurrent::TimerTask.new(execution_interval: interval) { sample! }
73
+ @sampler.execute
74
+ @sampler
75
+ end
76
+
77
+ # Unsubscribe from all events and shut down the sampler. Safe to call twice.
78
+ def stop!
79
+ @subscribers.each { |subscriber| ActiveSupport::Notifications.unsubscribe(subscriber) }
80
+ @subscribers.clear
81
+ shutdown_sampler!
82
+ end
83
+
84
+ private
85
+
86
+ # shutdown stops future ticks; wait_for_termination ensures an in-flight
87
+ # sample! can't emit after stop! returns (mirrors PoolReaper#stop_internal).
88
+ def shutdown_sampler!
89
+ return unless @sampler
90
+
91
+ @sampler.shutdown
92
+ @sampler.wait_for_termination(5)
93
+ @sampler = nil
94
+ end
95
+
96
+ def record_event(event, payload)
97
+ # Copy the notification payload so a sink that mutates Sample#payload
98
+ # can't corrupt it for other subscribers of the same event.
99
+ payload = payload.dup
100
+ dimensions = payload[:reason] ? { reason: payload[:reason] } : {}
101
+ emit(Sample.new(name: event, kind: :counter, value: 1, dimensions: dimensions, payload: payload))
102
+ rescue StandardError => e
103
+ warn_failure("record_event(#{event})", e)
104
+ end
105
+
106
+ def emit(sample)
107
+ @sink.call(sample)
108
+ rescue StandardError => e
109
+ warn_failure("sink(#{sample.name})", e)
110
+ end
111
+
112
+ def warn_failure(context, error)
113
+ warn "[Apartment::PoolObserver] #{context} failed: #{error.class}: #{error.message}"
114
+ rescue StandardError
115
+ # The failure logger must never be the thing that raises into the gem's
116
+ # instrumentation path (e.g. a closed/replaced $stderr).
117
+ nil
118
+ end
119
+ end
120
+ end
@@ -7,7 +7,13 @@ module Apartment
7
7
  # Evicts idle and excess tenant pools on a background timer.
8
8
  # Complementary to ActiveRecord's ConnectionPool::Reaper which handles
9
9
  # intra-pool connection reaping — this handles inter-pool (tenant) eviction.
10
- class PoolReaper
10
+ class PoolReaper # rubocop:disable Metrics/ClassLength
11
+ # Reap cadence (seconds) and the idle window (seconds) a pool must exceed
12
+ # before it is eligible for idle eviction. Decoupled so a deployment can
13
+ # reap frequently without shrinking the idle window. Exposed for
14
+ # introspection and wiring assertions.
15
+ attr_reader :interval, :idle_timeout
16
+
11
17
  # True when Rails' transactional-fixture machinery has pinned the pool
12
18
  # (ConnectionPool#pin_connection!, Rails 7.1+). Evicting or discarding a
13
19
  # pinned pool strands the fixture transaction; teardown then errors or
@@ -24,7 +30,8 @@ module Apartment
24
30
  end
25
31
 
26
32
  def initialize(pool_manager:, interval:, idle_timeout:, max_total: nil,
27
- default_tenant: nil, shard_key_prefix: nil, on_evict: nil)
33
+ default_tenant: nil, shard_key_prefix: nil, on_evict: nil,
34
+ overflow_policy: :evict_idle)
28
35
  raise(ArgumentError, 'interval must be a positive number') unless interval.is_a?(Numeric) && interval.positive?
29
36
  unless idle_timeout.is_a?(Numeric) && idle_timeout.positive?
30
37
  raise(ArgumentError, 'idle_timeout must be a positive number')
@@ -40,10 +47,29 @@ module Apartment
40
47
  @default_tenant = default_tenant
41
48
  @shard_key_prefix = shard_key_prefix
42
49
  @on_evict = on_evict
50
+ @overflow_policy = overflow_policy
43
51
  @mutex = Mutex.new
44
52
  @timer = nil
45
53
  end
46
54
 
55
+ # Synchronously enforce max_total before a new tenant pool is admitted.
56
+ # Called by {PoolManager#fetch_or_create} under its creation lock (so the
57
+ # capacity check, eviction, and insert are atomic w.r.t. other creators).
58
+ # Evicts LRU idle (non-protected, non-default) pools until there is room for
59
+ # one more; if none can be freed, applies the overflow policy. A no-op when
60
+ # no cap is configured. See docs/designs/pool-admission-control.md.
61
+ def admit!(incoming_tenant_key)
62
+ return unless @max_total
63
+
64
+ loop do
65
+ break if @pool_manager.stats[:total_pools] < @max_total
66
+ break unless evict_one_for_admission(incoming_tenant_key)
67
+ end
68
+ return if @pool_manager.stats[:total_pools] < @max_total
69
+
70
+ apply_overflow_policy
71
+ end
72
+
47
73
  def start
48
74
  @mutex.synchronize do
49
75
  stop_internal
@@ -98,17 +124,61 @@ module Apartment
98
124
  next if default_tenant_pool?(tenant)
99
125
  next if protected_pool?(tenant, eviction_reason: :idle)
100
126
 
101
- pool = @pool_manager.remove(tenant)
102
- deregister_from_ar_handler(tenant)
103
- Instrumentation.instrument(:evict, tenant: tenant, reason: :idle)
104
- @on_evict&.call(tenant, pool)
105
- count += 1
127
+ count += 1 if evict_tenant(tenant, reason: :idle)
106
128
  rescue StandardError => e
107
129
  warn "[Apartment::PoolReaper] Failed to evict tenant #{tenant}: #{e.class}: #{e.message}"
108
130
  end
109
131
  count
110
132
  end
111
133
 
134
+ # Single eviction primitive shared by the timer paths (idle/LRU) and the
135
+ # synchronous admission path: drop from the manager, deregister from AR's
136
+ # ConnectionHandler (which disconnects the pool), instrument, and fire the
137
+ # on_evict hook. The +reason+ flows into the :evict event payload.
138
+ def evict_tenant(tenant, reason:)
139
+ # The timer (no lock) and admission (under @create_mutex) use different
140
+ # locks, so both can target the same idle tenant. The loser's remove
141
+ # returns nil — bail before firing a duplicate :evict event or calling
142
+ # on_evict with a nil pool.
143
+ return nil unless (pool = @pool_manager.remove(tenant))
144
+
145
+ deregister_from_ar_handler(tenant)
146
+ Instrumentation.instrument(:evict, tenant: tenant, reason: reason)
147
+ @on_evict&.call(tenant, pool)
148
+ pool
149
+ end
150
+
151
+ # Evict the single LRU evictable pool to make room for an incoming one,
152
+ # skipping the incoming key, the default tenant, and pinned/in-use pools.
153
+ # Returns the evicted tenant key, or nil if nothing is evictable.
154
+ def evict_one_for_admission(incoming_tenant_key)
155
+ @pool_manager.lru_tenants(count: @pool_manager.stats[:total_pools]).each do |tenant|
156
+ next if tenant == incoming_tenant_key
157
+ next if default_tenant_pool?(tenant)
158
+ next if protected_pool?(tenant, eviction_reason: :admission)
159
+
160
+ evict_tenant(tenant, reason: :admission)
161
+ return tenant
162
+ rescue StandardError => e
163
+ warn "[Apartment::PoolReaper] Failed to evict tenant #{tenant} for admission: #{e.class}: #{e.message}"
164
+ end
165
+ nil
166
+ end
167
+
168
+ # Applied when the cap can't be met by eviction (every other pool is pinned
169
+ # or in use). :evict_idle degrades to a soft cap — allow the new pool, surface
170
+ # the breach via :cap_unmet. :raise fails the admission so the caller sheds
171
+ # load. See docs/designs/pool-admission-control.md.
172
+ def apply_overflow_policy
173
+ current = @pool_manager.stats[:total_pools]
174
+ case @overflow_policy
175
+ when :raise
176
+ raise(Apartment::PoolCapacityReached.new(max_total: @max_total, current: current))
177
+ else
178
+ Instrumentation.instrument(:cap_unmet, max_total: @max_total, current: current, unevicted: 1)
179
+ end
180
+ end
181
+
112
182
  def evict_lru
113
183
  total = @pool_manager.stats[:total_pools]
114
184
  excess = total - @max_total
@@ -135,11 +205,7 @@ module Apartment
135
205
  next if default_tenant_pool?(tenant)
136
206
  next if protected_pool?(tenant, eviction_reason: :lru)
137
207
 
138
- pool = @pool_manager.remove(tenant)
139
- deregister_from_ar_handler(tenant)
140
- Instrumentation.instrument(:evict, tenant: tenant, reason: :lru)
141
- @on_evict&.call(tenant, pool)
142
- evicted += 1
208
+ evicted += 1 if evict_tenant(tenant, reason: :lru)
143
209
  rescue StandardError => e
144
210
  warn "[Apartment::PoolReaper] Failed to evict tenant #{tenant}: #{e.class}: #{e.message}"
145
211
  end
@@ -171,8 +171,13 @@ module Apartment
171
171
  # can call Apartment.pool_reaper.start explicitly. Emits :reaper_stopped
172
172
  # so an upgrading adopter notices the behavior change without combing
173
173
  # release notes.
174
+ #
175
+ # Opt out with `config.reap_in_test = true`: a deployment whose processes
176
+ # can run under Rails.env.test? semantics (and must keep reaping) then needs
177
+ # no boot guard around RAILS_ENV to avoid silently leaking connections.
174
178
  def self.deactivate_pool_reaper_in_test_env!
175
179
  return unless Rails.env.test?
180
+ return if Apartment.config&.reap_in_test
176
181
  return unless Apartment.pool_reaper
177
182
 
178
183
  Apartment.pool_reaper.stop
@@ -185,11 +185,33 @@ module Apartment
185
185
  # Accepts an optional tenant list; defaults to tenants_provider.
186
186
  # Fail-fast: raises immediately if a block raises for any tenant;
187
187
  # tenants after the failing one are not visited.
188
- def each(tenants = nil)
188
+ #
189
+ # release_connection: when true, release leased connections after each
190
+ # tenant so the reaper can evict finished tenants' pools mid-run. v4 keys a
191
+ # pool per "tenant:role"; a fan-out whose block leaves a connection checked
192
+ # out (raw ActiveRecord::Base.connection use, an open transaction, a long-
193
+ # held connection) would otherwise hold one warm connection per visited
194
+ # tenant. Modern query methods (create!, where, ...) check the connection
195
+ # back in themselves (Rails 7.2+), so a fan-out of only those needs no
196
+ # release — but when in doubt for a large fan-out, pass it; it is a cheap
197
+ # no-op when there is nothing to release.
198
+ #
199
+ # WARNING: the release is handler-wide (clear_active_connections!(:all)) —
200
+ # it drops EVERY connection leased to the current execution context, across
201
+ # all pools and roles, not just the tenant pool. Do NOT use it inside an
202
+ # outer ActiveRecord::Base.transaction, or while holding a non-tenant
203
+ # connection you mean to keep. Fail-fast: if the block raises, the failing
204
+ # tenant is not released (the fan-out aborts).
205
+ def each(tenants = nil, release_connection: false)
189
206
  raise(ArgumentError, 'Apartment::Tenant.each requires a block') unless block_given?
190
207
 
191
208
  tenants ||= Apartment.tenant_names
192
- tenants.each { |tenant| switch(tenant) { yield(tenant) } }
209
+ tenants.each do |tenant|
210
+ switch(tenant) { yield(tenant) }
211
+ # Handler-wide (:all) covers writing + reading roles — the gem's
212
+ # established release call (see memory_stability_spec).
213
+ ActiveRecord::Base.connection_handler.clear_active_connections!(:all) if release_connection
214
+ end
193
215
  end
194
216
 
195
217
  # Block-scoped override of the tenant resolver. For the duration of the
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Apartment
4
- VERSION = '4.0.0.alpha2'
4
+ VERSION = '4.0.0.alpha4'
5
5
  end
data/lib/apartment.rb CHANGED
@@ -158,16 +158,7 @@ module Apartment # rubocop:disable Metrics/ModuleLength
158
158
  @built_in_tenant_validator&.shutdown
159
159
  @built_in_tenant_validator = nil
160
160
  @config = new_config
161
- @pool_manager = PoolManager.new
162
- @pool_reaper = PoolReaper.new(
163
- pool_manager: @pool_manager,
164
- interval: new_config.pool_idle_timeout,
165
- idle_timeout: new_config.pool_idle_timeout,
166
- max_total: new_config.max_total_connections,
167
- default_tenant: new_config.default_tenant,
168
- shard_key_prefix: new_config.shard_key_prefix
169
- )
170
- @pool_reaper.start
161
+ setup_pools!(new_config)
171
162
  @config
172
163
  end
173
164
 
@@ -263,6 +254,25 @@ module Apartment # rubocop:disable Metrics/ModuleLength
263
254
  BUILT_IN_VALIDATOR_MUTEX.synchronize { @built_in_tenant_validator ||= TenantValidator.new }
264
255
  end
265
256
 
257
+ # Build the pool manager + reaper for a freshly-validated config and start
258
+ # the background reaper. When max_total_connections is set, wire the reaper
259
+ # as the pool manager's admission controller so cold creates are bounded
260
+ # synchronously; otherwise the manager keeps its lock-free create path.
261
+ def setup_pools!(new_config)
262
+ @pool_manager = PoolManager.new
263
+ @pool_reaper = PoolReaper.new(
264
+ pool_manager: @pool_manager,
265
+ interval: new_config.reaper_interval,
266
+ idle_timeout: new_config.pool_idle_timeout,
267
+ max_total: new_config.max_total_connections,
268
+ default_tenant: new_config.default_tenant,
269
+ shard_key_prefix: new_config.shard_key_prefix,
270
+ overflow_policy: new_config.pool_overflow_policy
271
+ )
272
+ @pool_manager.admission_controller = @pool_reaper if new_config.max_total_connections
273
+ @pool_reaper.start
274
+ end
275
+
266
276
  # Safely tear down old state. Stops the reaper first (so it doesn't
267
277
  # evict mid-cleanup), then deregisters tenant pools from AR's
268
278
  # ConnectionHandler, then clears the pool manager.
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.alpha2
4
+ version: 4.0.0.alpha4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ryan Brunner
@@ -195,6 +195,7 @@ files:
195
195
  - lib/apartment/patches/connection_handling.rb
196
196
  - lib/apartment/patches/live_tenant_propagation.rb
197
197
  - lib/apartment/pool_manager.rb
198
+ - lib/apartment/pool_observer.rb
198
199
  - lib/apartment/pool_reaper.rb
199
200
  - lib/apartment/railtie.rb
200
201
  - lib/apartment/schema_cache.rb