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 +4 -4
- data/README.md +32 -4
- data/lib/apartment/CLAUDE.md +7 -3
- data/lib/apartment/adapters/abstract_adapter.rb +15 -1
- data/lib/apartment/config.rb +30 -5
- data/lib/apartment/errors.rb +20 -0
- data/lib/apartment/patches/connection_handling.rb +22 -4
- data/lib/apartment/pool_manager.rb +37 -3
- data/lib/apartment/pool_observer.rb +120 -0
- data/lib/apartment/pool_reaper.rb +78 -12
- data/lib/apartment/railtie.rb +5 -0
- data/lib/apartment/tenant.rb +24 -2
- data/lib/apartment/version.rb +1 -1
- data/lib/apartment.rb +20 -10
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 86ded64305e44aac057b97264c079d5090545b7d1740693ea1cf7c881b69fe19
|
|
4
|
+
data.tar.gz: 99e78891808ff4cdb1e0fef82f7c49a344a4c088408c9c143af465c70a150a3b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 (
|
|
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
|
|
104
|
+
`pool_idle_timeout`: seconds an idle tenant pool must exceed before it is eligible for reaping (default: 300).
|
|
105
105
|
|
|
106
|
-
`
|
|
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.
|
data/lib/apartment/CLAUDE.md
CHANGED
|
@@ -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`
|
|
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
|
data/lib/apartment/config.rb
CHANGED
|
@@ -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, :
|
|
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 =
|
|
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
|
-
|
|
148
|
-
raise(ConfigurationError,
|
|
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, ' \
|
data/lib/apartment/errors.rb
CHANGED
|
@@ -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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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?
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/apartment/railtie.rb
CHANGED
|
@@ -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
|
data/lib/apartment/tenant.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
data/lib/apartment/version.rb
CHANGED
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
|
-
|
|
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.
|
|
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
|