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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0b2254c1f3f95622ba9fa45147fcc2f5b2036db4c5c75a2d68c3718772df6149
4
- data.tar.gz: efcc9149b0aaaa6c7b614cc981848f02e999259eaa0826120b4df16b555f6caf
3
+ metadata.gz: 86ded64305e44aac057b97264c079d5090545b7d1740693ea1cf7c881b69fe19
4
+ data.tar.gz: 99e78891808ff4cdb1e0fef82f7c49a344a4c088408c9c143af465c70a150a3b
5
5
  SHA512:
6
- metadata.gz: 35b3bdeb392ed750be9a20868082f1708e2ae3c4c8449cef5bda163d3e2e911409274abf9a1f8161811678192c8aaaa21c2f4d6a177a5932a9567942172bc234
7
- data.tar.gz: 3d0909cc593dcbd014f21626a2565e0e92d373cb74aa768f308b1033f689fbdea1aa48a2f886294e581d4d3954b1d719b15ab3277990a8fde216449eed40485f
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.
@@ -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,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
@@ -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.alpha3'
4
+ VERSION = '4.0.0.alpha4'
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.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