ros-apartment 4.0.0.alpha3 → 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 +25 -1
- data/lib/apartment/CLAUDE.md +4 -0
- data/lib/apartment/config.rb +7 -1
- data/lib/apartment/pool_observer.rb +120 -0
- data/lib/apartment/railtie.rb +5 -0
- data/lib/apartment/tenant.rb +24 -2
- data/lib/apartment/version.rb +1 -1
- 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
|
|
|
@@ -109,6 +109,14 @@ All options are set in `config/initializers/apartment.rb` inside an `Apartment.c
|
|
|
109
109
|
|
|
110
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
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).
|
|
119
|
+
|
|
112
120
|
### Elevator (Request Tenant Detection)
|
|
113
121
|
|
|
114
122
|
```ruby
|
|
@@ -363,6 +371,22 @@ tenant's keyspace. Guard routed work with `Apartment::Tenant.require_tenant!`
|
|
|
363
371
|
`require_default_tenant!`. See [Tenant-Aware Caching](docs/caching.md) for the
|
|
364
372
|
routed-vs-pinned model and the two-store recipe.
|
|
365
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
|
+
|
|
366
390
|
## Convenience Methods
|
|
367
391
|
|
|
368
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
|
@@ -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:
|
data/lib/apartment/config.rb
CHANGED
|
@@ -26,7 +26,7 @@ module Apartment
|
|
|
26
26
|
:active_record_log, :sql_query_tags,
|
|
27
27
|
:shard_key_prefix,
|
|
28
28
|
:migration_role, :app_role, :schema_cache_per_tenant, :check_pending_migrations,
|
|
29
|
-
:force_separate_pinned_pool, :test_fixture_cleanup
|
|
29
|
+
:force_separate_pinned_pool, :test_fixture_cleanup, :reap_in_test
|
|
30
30
|
|
|
31
31
|
def initialize # rubocop:disable Metrics/AbcSize
|
|
32
32
|
@tenant_strategy = nil
|
|
@@ -59,6 +59,7 @@ module Apartment
|
|
|
59
59
|
@schema_cache_per_tenant = false
|
|
60
60
|
@check_pending_migrations = true
|
|
61
61
|
@force_separate_pinned_pool = false
|
|
62
|
+
@reap_in_test = false
|
|
62
63
|
@test_fixture_cleanup = true
|
|
63
64
|
end
|
|
64
65
|
|
|
@@ -209,6 +210,11 @@ module Apartment
|
|
|
209
210
|
"test_fixture_cleanup must be true or false, got: #{@test_fixture_cleanup.inspect}")
|
|
210
211
|
end
|
|
211
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
|
+
|
|
212
218
|
unless @tenant_validator.nil? || @tenant_validator == false || @tenant_validator.respond_to?(:call)
|
|
213
219
|
raise(ConfigurationError,
|
|
214
220
|
'tenant_validator must be nil, false, or a callable, ' \
|
|
@@ -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
|
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
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
|