ros-apartment 4.0.0.alpha3 → 4.0.0.alpha5

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: 0b2254c1f3f95622ba9fa45147fcc2f5b2036db4c5c75a2d68c3718772df6149
4
- data.tar.gz: efcc9149b0aaaa6c7b614cc981848f02e999259eaa0826120b4df16b555f6caf
3
+ metadata.gz: c4e597c986618a8043acbecc8081ef0399493c545fd3720d8f57653da90025da
4
+ data.tar.gz: 9fb0d04fe5abb04e93a4ef654edc14744dfc8d86d35f6d6ef8b685d7eb6ddb29
5
5
  SHA512:
6
- metadata.gz: 35b3bdeb392ed750be9a20868082f1708e2ae3c4c8449cef5bda163d3e2e911409274abf9a1f8161811678192c8aaaa21c2f4d6a177a5932a9567942172bc234
7
- data.tar.gz: 3d0909cc593dcbd014f21626a2565e0e92d373cb74aa768f308b1033f689fbdea1aa48a2f886294e581d4d3954b1d719b15ab3277990a8fde216449eed40485f
6
+ metadata.gz: 747e6efe4c8c073368684944bbee84cccfd0b063f389d7b4a699debd97662496db9ea220e6be6f119986fa76e3586d180bb3f61ff61c4c44bc3acc37b7784f0c
7
+ data.tar.gz: 9ceef5927c8169dc2db2041fbbfba99b81c7106a9cf02814812550c1775cd2566398dd8383e78bd0d24e7a82a845d2c4418bc974c43cc5e0169cd25a6072eba5
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.
@@ -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:
@@ -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,131 @@
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
+ if sample_interval.nil?
28
+ # nil is the intentional "no gauge sampler" signal; counters still flow.
29
+ elsif sample_interval.positive?
30
+ observer.start_sampler!(interval: sample_interval)
31
+ else
32
+ # A non-nil, non-positive interval is almost always a misconfig (e.g. an
33
+ # empty APARTMENT_POOL_SAMPLE_INTERVAL coerced to 0). Silently skipping
34
+ # the sampler ships an observer whose gauges never emit; surface it.
35
+ warn "[Apartment::PoolObserver] sample_interval=#{sample_interval.inspect} is not " \
36
+ 'positive; gauge sampler not started (tenant_pools_live/backend_connections ' \
37
+ 'will not emit). Pass a positive interval, or nil to disable the sampler.'
38
+ end
39
+ observer
40
+ rescue StandardError
41
+ # Don't leak subscriptions if a later step (e.g. a bad sample_interval)
42
+ # raises after subscribe! has registered listeners.
43
+ observer&.stop!
44
+ raise
45
+ end
46
+
47
+ def initialize(sink:, backend_count: nil)
48
+ raise(ArgumentError, 'sink must be callable') unless sink.respond_to?(:call)
49
+
50
+ @sink = sink
51
+ @backend_count = backend_count
52
+ @subscribers = []
53
+ @sampler = nil
54
+ end
55
+
56
+ def subscribe!
57
+ COUNTER_EVENTS.each do |event|
58
+ @subscribers << ActiveSupport::Notifications.subscribe("#{event}.apartment") do |*, payload|
59
+ record_event(event, payload || {})
60
+ end
61
+ end
62
+ self
63
+ end
64
+
65
+ # One gauge pass: live tenant-pool count, plus the adopter's backend count
66
+ # when supplied. Safe to call from start_sampler! or an external scheduler.
67
+ def sample!
68
+ total = Apartment.pool_manager&.stats&.fetch(:total_pools, 0) || 0
69
+ emit(Sample.new(name: :tenant_pools_live, kind: :gauge, value: total, dimensions: {}, payload: {}))
70
+
71
+ return unless @backend_count
72
+
73
+ backends = @backend_count.call
74
+ return if backends.nil?
75
+
76
+ emit(Sample.new(name: :backend_connections, kind: :gauge, value: backends, dimensions: {}, payload: {}))
77
+ rescue StandardError => e
78
+ warn_failure('sample!', e)
79
+ end
80
+
81
+ def start_sampler!(interval:)
82
+ @sampler&.shutdown
83
+ @sampler = Concurrent::TimerTask.new(execution_interval: interval) { sample! }
84
+ @sampler.execute
85
+ @sampler
86
+ end
87
+
88
+ # Unsubscribe from all events and shut down the sampler. Safe to call twice.
89
+ def stop!
90
+ @subscribers.each { |subscriber| ActiveSupport::Notifications.unsubscribe(subscriber) }
91
+ @subscribers.clear
92
+ shutdown_sampler!
93
+ end
94
+
95
+ private
96
+
97
+ # shutdown stops future ticks; wait_for_termination ensures an in-flight
98
+ # sample! can't emit after stop! returns (mirrors PoolReaper#stop_internal).
99
+ def shutdown_sampler!
100
+ return unless @sampler
101
+
102
+ @sampler.shutdown
103
+ @sampler.wait_for_termination(5)
104
+ @sampler = nil
105
+ end
106
+
107
+ def record_event(event, payload)
108
+ # Copy the notification payload so a sink that mutates Sample#payload
109
+ # can't corrupt it for other subscribers of the same event.
110
+ payload = payload.dup
111
+ dimensions = payload[:reason] ? { reason: payload[:reason] } : {}
112
+ emit(Sample.new(name: event, kind: :counter, value: 1, dimensions: dimensions, payload: payload))
113
+ rescue StandardError => e
114
+ warn_failure("record_event(#{event})", e)
115
+ end
116
+
117
+ def emit(sample)
118
+ @sink.call(sample)
119
+ rescue StandardError => e
120
+ warn_failure("sink(#{sample.name})", e)
121
+ end
122
+
123
+ def warn_failure(context, error)
124
+ warn "[Apartment::PoolObserver] #{context} failed: #{error.class}: #{error.message}"
125
+ rescue StandardError
126
+ # The failure logger must never be the thing that raises into the gem's
127
+ # instrumentation path (e.g. a closed/replaced $stderr).
128
+ nil
129
+ end
130
+ end
131
+ 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,45 @@ 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: a raising block still aborts the
204
+ # fan-out, but the failing tenant's connection is released first — the
205
+ # release is best-effort cleanup (in an ensure), not iteration semantics.
206
+ def each(tenants = nil, release_connection: false)
189
207
  raise(ArgumentError, 'Apartment::Tenant.each requires a block') unless block_given?
190
208
 
191
209
  tenants ||= Apartment.tenant_names
192
- tenants.each { |tenant| switch(tenant) { yield(tenant) } }
210
+ tenants.each do |tenant|
211
+ switch(tenant) { yield(tenant) }
212
+ ensure
213
+ # Handler-wide (:all) covers writing + reading roles — the gem's
214
+ # established release call (see memory_stability_spec). In an ensure so
215
+ # a raising tenant is still released; the exception then propagates and
216
+ # halts the fan-out (fail-fast preserved). Best-effort: a release
217
+ # failure must never mask the block's exception, so it is rescued and
218
+ # warned rather than raised out of the ensure.
219
+ if release_connection
220
+ begin
221
+ ActiveRecord::Base.connection_handler.clear_active_connections!(:all)
222
+ rescue StandardError => e
223
+ warn("[Apartment::Tenant.each] connection release failed: #{e.class}: #{e.message}")
224
+ end
225
+ end
226
+ end
193
227
  end
194
228
 
195
229
  # 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.alpha3'
4
+ VERSION = '4.0.0.alpha5'
5
5
  end
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.alpha3
4
+ version: 4.0.0.alpha5
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