ros-apartment 4.0.0.alpha6 → 4.0.0.alpha8

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: 7ce0a7124b9df22be74f2899b8a38046f52fcd7e1e0b068b3ebd778e16f57969
4
- data.tar.gz: 0f40a39d073ae13bd59dce2e72e3e2aaa3759f2ffc270491f6dbbfcc7ef623fe
3
+ metadata.gz: 58a2c1795ee20c09d5ffa99ece8bb18d8697f981f6a6292750941775ee81738b
4
+ data.tar.gz: fb37448602b18043aee5f2d49d4f037c82b9b744917eeafb33de7e1099bdec6d
5
5
  SHA512:
6
- metadata.gz: ebbc9775707e60c7e7bd896e6f1efa02487017519770dda6558cd89552e53558f47a39df31b73f4c90f6c7039e86399536ce586ce1bd30ac263ad67071c7407a
7
- data.tar.gz: 8339cc7eb7b07adcb5df1f923c55f7e3aa96bad5faa47a13975550563af0d77191736b4afe0a8aa97f6a446adc59cc148a21739ff0c36224d3608371e6a0efe0
6
+ metadata.gz: 9913b7e11919a0a2fb39398a67cab8a5dad0dda6d637df954bec0222bcc83c93b1e71dc36469316d2a50fbdf07996d665903b24f17c87cf99191fea009661ffa
7
+ data.tar.gz: f1128508d59269eec5ed3c5f2a684b0a8205049242c53b0729b461502ae2d7e2b8613519e4e0cb949896f10468011c6684d0ceca266961ced545fba5e3242885
data/README.md CHANGED
@@ -40,6 +40,25 @@ This gem is a maintained fork of the original [Apartment gem](https://github.com
40
40
  - Rails 7.2+
41
41
  - PostgreSQL 14+, MySQL 8.4+, or SQLite3
42
42
 
43
+ ### Ruby version manager
44
+
45
+ `.ruby-version` is the single source of truth for the local Ruby version (the
46
+ same file `ruby/setup-ruby` reads in CI). Both [mise](https://mise.jdx.dev) and
47
+ [rbenv](https://github.com/rbenv/rbenv) honor it — use whichever you prefer:
48
+
49
+ ```bash
50
+ # mise (recommended)
51
+ brew install mise # then add `eval "$(mise activate zsh)"` to ~/.zshrc
52
+ mise trust && mise install # one-time trust per clone, then install the pinned Ruby
53
+
54
+ # rbenv (also works, unchanged)
55
+ rbenv install "$(cat .ruby-version)"
56
+ ```
57
+
58
+ The committed `mise.toml` carries settings only (never a version): it tells mise
59
+ to honor `.ruby-version` without any per-developer global config. `mise trust`
60
+ is required once per clone; `bin/dev/setup-worktree` handles it for new worktrees.
61
+
43
62
  ### Setup
44
63
 
45
64
  ```ruby
@@ -99,15 +118,19 @@ All options are set in `config/initializers/apartment.rb` inside an `Apartment.c
99
118
 
100
119
  ### Pool Settings
101
120
 
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).
121
+ `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 at least the peak number of threads/fibers in one process that can touch the _same_ tenant at once** (e.g. a Sidekiq role's concurrency for a same-tenant job fan-out); a smaller pool makes those threads block on connection checkout. It is a lazy ceiling — connections are created on demand and idle pools are reaped — so sizing for peak does not hold that many connections open at steady state.
103
122
 
104
123
  `pool_idle_timeout`: seconds an idle tenant pool must exceed before it is eligible for reaping (default: 300).
105
124
 
106
125
  `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
126
 
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`.
127
+ `max_tenant_pools`: ceiling on the number of live tenant pools per process; `nil` for unlimited (default: `nil`). Enforced synchronously at pool-creation time (see `pool_overflow_policy`) and trimmed continuously by the background reaper.
128
+
129
+ `max_tenant_connections`: ceiling on total *tenant-pool* connections per process (default: `nil`); requires `tenant_pool_size`. The admission controller derives the pool budget as `floor(max_tenant_connections / tenant_pool_size)`. Per-process tenant-pool connections ≈ `effective_pool_budget × tenant_pool_size`, where `effective_pool_budget = min(max_tenant_pools, floor(max_tenant_connections / tenant_pool_size))`; the default pool and any separate pinned pool are additional. A tenant using multiple roles (e.g. `writing` + `reading`) holds one pool per role (`tenant:role` keys), counting once per role against the budget. For a hard external-pooler budget, pair this with `pool_overflow_policy: :raise` (the ceiling is soft under the default `:evict_idle`).
130
+
131
+ `max_total_connections`: **deprecated** (removed in v5) — it capped tenant-pool *count*, not connections. It now aliases `max_tenant_pools`; rename it. For a true connection ceiling, set `max_tenant_connections`.
109
132
 
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`.
133
+ `pool_overflow_policy`: behavior when a new pool would breach the pool budget (`max_tenant_pools` / `max_tenant_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
134
 
112
135
  `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
136
 
@@ -20,7 +20,8 @@ lib/apartment/
20
20
  │ └── mysql_config.rb # MysqlConfig: placeholder
21
21
  ├── elevators/ # Rack middleware for tenant detection (see CLAUDE.md); v4 uses constructor keyword args, no class-level state; Generic, Subdomain, FirstSubdomain, Domain, Host, HostHash, Header
22
22
  ├── patches/ # ActiveRecord patches for tenant-aware connections
23
- └── connection_handling.rb # Prepends on AR::Base — tenant-aware connection_pool
23
+ ├── connection_handling.rb # Prepends on AR::Base — tenant-aware connection_pool
24
+ │ └── postgresql_sequence_name.rb # Prepends on the PG adapter — schema-agnostic Model.sequence_name memoization
24
25
  ├── tasks/ # Rake task utilities; v4.rake for apartment:create/drop/migrate/seed/rollback
25
26
  ├── config.rb # Configuration with validate!/freeze!
26
27
  ├── current.rb # Fiber-safe tenant context (CurrentAttributes)
@@ -59,7 +60,7 @@ lib/apartment/
59
60
 
60
61
  ### pool_manager.rb — Pool Cache
61
62
 
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
+ `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 a pool budget is configured (`max_tenant_pools` and/or `max_tenant_connections`, resolved by `Config#effective_pool_budget`; `max_total_connections` is the deprecated alias of `max_tenant_pools`), `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-connection-budget.md` and `docs/designs/pool-admission-control.md`.
63
64
 
64
65
  ### pool_reaper.rb — Pool Eviction + Admission
65
66
 
@@ -97,7 +98,7 @@ All inherit from `AbstractAdapter`. Override `resolve_connection_config`, `creat
97
98
 
98
99
  ### pool_observer.rb — Observability (opt-in)
99
100
 
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
+ Sink-agnostic subscriber for the pool events (`create`/`evict`/`cap_unmet`/`skip_evict`/`reaper_stopped`) + an optional `Concurrent::TimerTask` gauge sampler. Gauges: `tenant_pools_live`, optional adopter `backend_count`, and per-pass checkout-pressure gauges from each tenant pool's AR `ConnectionPool#stat` — `pools_waiting` (blocked on checkout), `pools_saturated` (`busy >= size`), `max_checkout_waiting` (worst pool's `waiting`, tenant in payload not a dimension). Per-pool `#stat` is rescued so one tearing-down pool can't abort the pass. Normalizes each to a `Sample` via the shared `emit_gauge` helper and forwards to a caller `sink`; ships no transport. Error-isolated — never raises into instrumentation. See `docs/observability.md`.
101
102
 
102
103
  ### railtie.rb — v4 Rails Integration
103
104
 
@@ -5,6 +5,13 @@ require 'thor'
5
5
  module Apartment
6
6
  class CLI < Thor
7
7
  class Migrations < Thor
8
+ # The raised Thor::Error message is the low-context surface a monitor or
9
+ # CI-log tail captures even when the stdout summary is dropped/unindexed.
10
+ # Name a few failed tenants there (capped, so a 600-tenant run does not
11
+ # produce a wall of text); the full per-tenant detail is in the summary
12
+ # and in the migrate_tenant_failed.apartment events.
13
+ FAILED_TENANTS_PREVIEW = 5
14
+
8
15
  def self.exit_on_failure? = true
9
16
 
10
17
  desc 'migrate [TENANT]', 'Run migrations for tenants'
@@ -62,7 +69,15 @@ module Apartment
62
69
  say(result.summary)
63
70
 
64
71
  trigger_schema_dump if result.success?
65
- raise(Thor::Error, "Migration failed for #{result.failed.size} tenant(s)") unless result.success?
72
+ raise(Thor::Error, migration_failure_message(result.failed)) unless result.success?
73
+ end
74
+
75
+ def migration_failure_message(failures)
76
+ names = failures.map(&:tenant)
77
+ shown = names.first(FAILED_TENANTS_PREVIEW)
78
+ more = names.size - shown.size
79
+ suffix = more.positive? ? ", and #{more} more" : ''
80
+ "Migration failed for #{names.size} tenant(s): #{shown.join(', ')}#{suffix} (see summary above)"
66
81
  end
67
82
 
68
83
  # Rollback bypasses the Migrator's parallelism and Result tracking but
@@ -17,7 +17,8 @@ module Apartment
17
17
  attr_accessor :tenants_provider, :default_tenant,
18
18
  :default_tenant_switch_allowed,
19
19
  :tenant_pool_size, :pool_idle_timeout, :reaper_interval,
20
- :max_total_connections, :pool_overflow_policy,
20
+ :max_total_connections, :max_tenant_pools, :max_tenant_connections,
21
+ :pool_overflow_policy,
21
22
  :seed_after_create, :seed_data_file,
22
23
  :schema_load_strategy, :schema_file,
23
24
  :parallel_migration_threads,
@@ -38,6 +39,8 @@ module Apartment
38
39
  @pool_idle_timeout = 300
39
40
  @reaper_interval = nil
40
41
  @max_total_connections = nil
42
+ @max_tenant_pools = nil
43
+ @max_tenant_connections = nil
41
44
  @pool_overflow_policy = :evict_idle
42
45
  @seed_after_create = false
43
46
  @seed_data_file = nil
@@ -124,6 +127,17 @@ module Apartment
124
127
  # Reap on the idle-timeout cadence unless an explicit interval decouples
125
128
  # the two (reap more often without shrinking the idle window).
126
129
  @reaper_interval = @pool_idle_timeout if @reaper_interval.nil?
130
+
131
+ # max_total_connections is deprecated: the name said "connections" but it
132
+ # always capped tenant-pool COUNT. Alias it to its true meaning without
133
+ # changing behavior. Only fill when max_tenant_pools was not set explicitly,
134
+ # so validate!'s both-set guard can still catch a genuine double-spec.
135
+ return unless @max_total_connections
136
+
137
+ warn '[Apartment] DEPRECATION: config.max_total_connections is deprecated and will be ' \
138
+ 'removed in v5. It caps tenant-pool COUNT, not connections; rename it to ' \
139
+ 'max_tenant_pools. For a true connection ceiling, set max_tenant_connections.'
140
+ @max_tenant_pools = @max_total_connections if @max_tenant_pools.nil?
127
141
  end
128
142
 
129
143
  # Validate configuration completeness and consistency.
@@ -171,6 +185,35 @@ module Apartment
171
185
  "max_total_connections must be a positive integer or nil, got: #{@max_total_connections.inspect}")
172
186
  end
173
187
 
188
+ if @max_tenant_pools && (!@max_tenant_pools.is_a?(Integer) || @max_tenant_pools < 1)
189
+ raise(ConfigurationError,
190
+ "max_tenant_pools must be a positive integer or nil, got: #{@max_tenant_pools.inspect}")
191
+ end
192
+
193
+ if @max_total_connections && @max_tenant_pools && @max_total_connections != @max_tenant_pools
194
+ raise(ConfigurationError,
195
+ 'max_total_connections and max_tenant_pools are the same setting under two names; ' \
196
+ "set only one. Got max_total_connections=#{@max_total_connections}, " \
197
+ "max_tenant_pools=#{@max_tenant_pools}")
198
+ end
199
+
200
+ if @max_tenant_connections && (!@max_tenant_connections.is_a?(Integer) || @max_tenant_connections < 1)
201
+ raise(ConfigurationError,
202
+ "max_tenant_connections must be a positive integer or nil, got: #{@max_tenant_connections.inspect}")
203
+ end
204
+
205
+ if @max_tenant_connections && @tenant_pool_size.nil?
206
+ raise(ConfigurationError,
207
+ 'max_tenant_connections requires tenant_pool_size to be set (the pool budget is ' \
208
+ 'derived as max_tenant_connections / tenant_pool_size)')
209
+ end
210
+
211
+ if @max_tenant_connections && @tenant_pool_size && @max_tenant_connections < @tenant_pool_size
212
+ raise(ConfigurationError,
213
+ "max_tenant_connections (#{@max_tenant_connections}) must be >= tenant_pool_size " \
214
+ "(#{@tenant_pool_size}); it cannot fit a single tenant pool")
215
+ end
216
+
174
217
  unless %i[evict_idle raise].include?(@pool_overflow_policy)
175
218
  raise(ConfigurationError,
176
219
  'pool_overflow_policy must be :evict_idle or :raise, ' \
@@ -238,5 +281,15 @@ module Apartment
238
281
  def rails_env_name
239
282
  (Rails.env if defined?(Rails.env)) || ENV['RAILS_ENV'] || ENV['RACK_ENV'] || 'default_env'
240
283
  end
284
+
285
+ # The pool-count bound the admission controller enforces: the stricter of the
286
+ # explicit pool cap (max_tenant_pools) and the pool budget derived from the
287
+ # connection ceiling (max_tenant_connections / tenant_pool_size, floored).
288
+ # Returns nil (uncapped) when neither knob is set. Relies on a global
289
+ # tenant_pool_size, so tenant-pool connections <= budget * tenant_pool_size.
290
+ def effective_pool_budget
291
+ derived = @max_tenant_connections && @tenant_pool_size ? @max_tenant_connections / @tenant_pool_size : nil
292
+ [@max_tenant_pools, derived].compact.min
293
+ end
241
294
  end
242
295
  end
@@ -33,22 +33,31 @@ 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.
36
+ # Raised when admitting a new tenant pool would exceed the effective pool
37
+ # budget (Config#effective_pool_budget) and no idle pool can be evicted to make
38
+ # room, under the :raise overflow policy. Distinct from PoolExhausted (a single
39
+ # pool's connections) — this is the process-wide pool-COUNT ceiling. The message
40
+ # names the knobs that set it, never the deprecated max_total_connections: that
41
+ # name is what made the budget look like a connection count in the first place.
42
+ # See docs/designs/pool-connection-budget.md and pool-admission-control.md.
40
43
  class PoolCapacityReached < ApartmentError
44
+ # +max_total+ is the effective POOL budget, not a connection count. The
45
+ # reader keeps its name for compatibility with anything already rescuing this.
41
46
  attr_reader :max_total, :current
42
47
 
48
+ alias pool_budget max_total
49
+
43
50
  def initialize(max_total: nil, current: nil)
44
51
  @max_total = max_total
45
52
  @current = current
46
53
  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.'
54
+ "Tenant pool capacity reached: #{current.inspect} tenant pools open, " \
55
+ "the effective pool budget is #{max_total.inspect}, and no idle pool " \
56
+ 'could be evicted to admit another (all pinned or in use). Raise ' \
57
+ 'max_tenant_pools, or raise max_tenant_connections (which derives the ' \
58
+ 'pool budget as max_tenant_connections / tenant_pool_size), reduce ' \
59
+ 'concurrent tenants, or set pool_overflow_policy to :evict_idle to ' \
60
+ 'allow soft overflow.'
52
61
  )
53
62
  end
54
63
  end
@@ -3,8 +3,8 @@
3
3
  require 'active_support/notifications'
4
4
 
5
5
  module Apartment
6
- # Thin wrapper around ActiveSupport::Notifications.
7
- # Known events: create, drop, evict (all namespaced as *.apartment).
6
+ # Thin wrapper around ActiveSupport::Notifications. All events are namespaced
7
+ # *.apartment; see docs/observability.md for the authoritative event catalog.
8
8
  module Instrumentation
9
9
  def self.instrument(event, payload = {}, &block)
10
10
  event_name = "#{event}.apartment"
@@ -121,9 +121,15 @@ module Apartment
121
121
  duration: monotonic_now - start, error: nil, versions_run: versions
122
122
  )
123
123
  rescue StandardError => e
124
+ duration = monotonic_now - start
125
+ # Symmetric with the :migrate_tenant success event above: the gem holds the
126
+ # full structured error here, so emit it for subscribers rather than
127
+ # stranding it in the returned Result. See migrate_tenant's rescue.
128
+ instrument_failure(tenant_name, e, duration)
129
+
124
130
  Result.new(
125
131
  tenant: tenant_name, status: :failed,
126
- duration: monotonic_now - start, error: e, versions_run: []
132
+ duration: duration, error: e, versions_run: []
127
133
  )
128
134
  end
129
135
 
@@ -166,9 +172,17 @@ module Apartment
166
172
  end
167
173
  end
168
174
  rescue StandardError => e
175
+ duration = monotonic_now - start
176
+ # Failure counterpart to the :migrate_tenant success event. On SUCCESS the
177
+ # gem instruments; on FAILURE it previously only returned a failed Result,
178
+ # leaving adopters no hook to observe the (structured) error. This closes
179
+ # that asymmetry — generic payload (tenant + error + duration); routing to
180
+ # an error tracker is the subscriber's job.
181
+ instrument_failure(tenant, e, duration)
182
+
169
183
  Result.new(
170
184
  tenant: tenant, status: :failed,
171
- duration: monotonic_now - start, error: e, versions_run: []
185
+ duration: duration, error: e, versions_run: []
172
186
  )
173
187
  ensure
174
188
  Apartment::Current.migrating = false
@@ -242,6 +256,34 @@ module Apartment
242
256
  conn.instance_variable_set(ADVISORY_LOCKS_IVAR, original) if conn&.instance_variable_defined?(ADVISORY_LOCKS_IVAR)
243
257
  end
244
258
 
259
+ # Emit the failure event without letting a raising subscriber break the
260
+ # Migrator's non-raising contract: the failed Result MUST still be returned.
261
+ # ActiveSupport::Notifications propagates subscriber exceptions through
262
+ # instrument (verified against AS 8.0), and this fires from inside a rescue
263
+ # block, so an un-isolated call would convert a captured per-tenant failure
264
+ # into an unhandled raise out of #run.
265
+ #
266
+ # Scope of the guarantee: a subscriber raising a StandardError is swallowed
267
+ # and warned. Process-control exceptions (SystemExit, SignalException /
268
+ # Interrupt) are deliberately NOT rescued — they must propagate so exit and
269
+ # Ctrl-C still work mid-migration; a subscriber raising a bare Exception
270
+ # subclass is itself a bug (Ruby errors should descend from StandardError).
271
+ # The warn is nested-rescued so a broken $stderr (IOError is a StandardError)
272
+ # cannot re-escape the handler. Success-path instrumentation is left
273
+ # un-isolated by design — only the failure path carries the hard no-raise
274
+ # guarantee, and swallowing there would mask real subscriber bugs.
275
+ def instrument_failure(tenant, error, duration)
276
+ Instrumentation.instrument(:migrate_tenant_failed, tenant: tenant, error: error, duration: duration)
277
+ rescue StandardError => e
278
+ # Nested rescue: a sibling `rescue` on this method would NOT catch an
279
+ # exception raised by warn (a broken $stderr), so the warn gets its own.
280
+ begin
281
+ warn "[Apartment::Migrator] migrate_tenant_failed subscriber raised #{e.class}: #{e.message}"
282
+ rescue StandardError
283
+ nil
284
+ end
285
+ end
286
+
245
287
  def monotonic_now
246
288
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
247
289
  end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Apartment
4
+ module Patches
5
+ # Keeps ActiveRecord's class-level Model.sequence_name memoization
6
+ # schema-agnostic under pool-per-tenant.
7
+ #
8
+ # Rails resolves default_sequence_name via pg_get_serial_sequence with an
9
+ # unqualified table name, so PostgreSQL answers through the current
10
+ # connection's search_path and returns a schema-QUALIFIED name — the schema
11
+ # of whichever tenant's pool happened to resolve it first. ActiveRecord then
12
+ # memoizes that value once per model class, process-wide. Consumers that
13
+ # render it into SQL (activerecord-import prefetches primary keys as a
14
+ # literal nextval(Model.sequence_name)) would draw ids from the
15
+ # first-resolver tenant's sequence in every tenant: wrong-tenant ids, silent
16
+ # sequence drift, and eventual PG::UniqueViolation.
17
+ #
18
+ # Stripping the connection's own current_schema prefix makes the memoized
19
+ # value schema-agnostic: nextval('widgets_id_seq') re-resolves through each
20
+ # pool's search_path, per tenant — the same invariant v4 relies on for table
21
+ # names (see docs/designs/v4-connection-model-rationale.md). A prefix naming
22
+ # any OTHER schema is preserved on purpose: persistent-schema tables and
23
+ # pinned models (whose table names are default_tenant-qualified and may
24
+ # execute on a shared tenant connection) are only correct BECAUSE they stay
25
+ # qualified.
26
+ #
27
+ # v3 shipped this guarantee as PostgreSqlAdapterPatch; it was lost in the
28
+ # Phase 2.5 v3 deletion (PR #356). Reset-on-switch is not an alternative:
29
+ # v4 serves tenants concurrently in one process, so only a schema-agnostic
30
+ # memoized value can be correct.
31
+ module PostgresqlSequenceName
32
+ def default_sequence_name(table_name, primary_key = 'id')
33
+ res = super
34
+ return res if res.nil?
35
+ # A schema-qualified table name is a PINNED model: Apartment qualifies
36
+ # those to the default tenant, and they resolve on whatever connection is
37
+ # current (the schema strategy shares the tenant's connection). Its
38
+ # sequence must stay qualified — stripping here is the mirror-image bug,
39
+ # because an unqualified name would later re-resolve against a *tenant's*
40
+ # search_path and draw ids from that tenant's stale copy of the pinned
41
+ # table's sequence. That order is the common one, not a corner case:
42
+ # pinned models are typically first touched at boot, on the default pool.
43
+ # v3 guarded the same case by force-adding the default_tenant prefix.
44
+ return res if table_name.to_s.include?('.')
45
+
46
+ # Routed table: drop whatever schema PostgreSQL qualified the sequence
47
+ # with — not just the connection's own. The two differ when the tenant
48
+ # schema is missing the table and search_path falls through to a schema
49
+ # that has it: the resolved sequence then names the FALLBACK schema, and
50
+ # preserving that would memoize it process-wide, so every tenant —
51
+ # including correctly-migrated ones — would draw from the fallback's
52
+ # sequence forever. Unqualified is the only value that lets the sequence
53
+ # re-resolve per pool exactly like the table name does.
54
+ #
55
+ # Parsed with AR's own splitter (rather than by hand) because it is what
56
+ # produced `res`, so it round-trips quoting and dotted identifiers.
57
+ ActiveRecord::ConnectionAdapters::PostgreSQL::Utils
58
+ .extract_schema_qualified_name(res).identifier
59
+ end
60
+ end
61
+ end
62
+ end
@@ -66,14 +66,15 @@ module Apartment
66
66
  # when supplied. Safe to call from start_sampler! or an external scheduler.
67
67
  def sample!
68
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: {}))
69
+ emit_gauge(:tenant_pools_live, total)
70
+ emit_checkout_pressure!
70
71
 
71
72
  return unless @backend_count
72
73
 
73
74
  backends = @backend_count.call
74
75
  return if backends.nil?
75
76
 
76
- emit(Sample.new(name: :backend_connections, kind: :gauge, value: backends, dimensions: {}, payload: {}))
77
+ emit_gauge(:backend_connections, backends)
77
78
  rescue StandardError => e
78
79
  warn_failure('sample!', e)
79
80
  end
@@ -104,6 +105,43 @@ module Apartment
104
105
  @sampler = nil
105
106
  end
106
107
 
108
+ # Per-tenant-pool checkout pressure, aggregated to low cardinality. waiting>0
109
+ # means threads are blocked acquiring a connection (the same-tenant fan-out
110
+ # starvation signal). The worst tenant is carried in the Sample payload, NOT
111
+ # as a metric dimension, to avoid unbounded time-series churn. Per-pool #stat
112
+ # is rescued so a pool tearing down mid-sample can't abort the whole pass.
113
+ def emit_checkout_pressure!
114
+ manager = Apartment.pool_manager
115
+ return unless manager
116
+
117
+ stats = collect_pool_stats(manager)
118
+ worst = stats.max_by { |s| s[:pending] } || { tenant: nil, pending: 0 }
119
+ payload = worst[:pending].positive? ? { tenant: worst[:tenant] } : {}
120
+
121
+ emit_gauge(:pools_waiting, stats.count { |s| s[:pending].positive? })
122
+ emit_gauge(:pools_saturated, stats.count { |s| s[:saturated] })
123
+ emit_gauge(:max_checkout_waiting, worst[:pending], payload: payload)
124
+ end
125
+
126
+ def emit_gauge(name, value, payload: {})
127
+ emit(Sample.new(name: name, kind: :gauge, value: value, dimensions: {}, payload: payload))
128
+ end
129
+
130
+ # Read each tenant pool's checkout stats into [{ tenant:, pending:, saturated: }],
131
+ # skipping any pool whose #stat raises (mid-teardown) so one bad pool can't
132
+ # abort the whole pass.
133
+ def collect_pool_stats(manager)
134
+ stats = []
135
+ manager.each_pair do |tenant_key, pool|
136
+ stat = pool.stat
137
+ stats << { tenant: tenant_key, pending: stat[:waiting].to_i,
138
+ saturated: stat[:busy].to_i >= stat[:size].to_i }
139
+ rescue StandardError
140
+ next
141
+ end
142
+ stats
143
+ end
144
+
107
145
  def record_event(event, payload)
108
146
  # Copy the notification payload so a sink that mutates Sample#payload
109
147
  # can't corrupt it for other subscribers of the same event.
@@ -34,6 +34,13 @@ namespace :apartment do
34
34
  else
35
35
  Apartment::CLI::Migrations.new.invoke(:migrate)
36
36
  end
37
+ # Parity with apartment:drop: `.new.invoke` bypasses Thor.start's
38
+ # exit_on_failure? handling, so an unrescued Thor::Error (raised on
39
+ # migration failure) would surface as a raw `rake aborted!` backtrace that
40
+ # buries the actionable message. abort re-emits it as a clean one-liner
41
+ # with a non-zero exit.
42
+ rescue Thor::Error => e
43
+ abort(e.message)
37
44
  end
38
45
 
39
46
  desc 'Seed all tenants'
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Apartment
4
- VERSION = '4.0.0.alpha6'
4
+ VERSION = '4.0.0.alpha8'
5
5
  end
data/lib/apartment.rb CHANGED
@@ -265,21 +265,23 @@ module Apartment # rubocop:disable Metrics/ModuleLength
265
265
  end
266
266
 
267
267
  # Build the pool manager + reaper for a freshly-validated config and start
268
- # the background reaper. When max_total_connections is set, wire the reaper
269
- # as the pool manager's admission controller so cold creates are bounded
270
- # synchronously; otherwise the manager keeps its lock-free create path.
268
+ # the background reaper. When a pool budget is configured (max_tenant_pools
269
+ # and/or max_tenant_connections, via Config#effective_pool_budget), wire the
270
+ # reaper as the pool manager's admission controller so cold creates are
271
+ # bounded synchronously; otherwise the manager keeps its lock-free create path.
271
272
  def setup_pools!(new_config)
273
+ budget = new_config.effective_pool_budget
272
274
  @pool_manager = PoolManager.new
273
275
  @pool_reaper = PoolReaper.new(
274
276
  pool_manager: @pool_manager,
275
277
  interval: new_config.reaper_interval,
276
278
  idle_timeout: new_config.pool_idle_timeout,
277
- max_total: new_config.max_total_connections,
279
+ max_total: budget,
278
280
  default_tenant: new_config.default_tenant,
279
281
  shard_key_prefix: new_config.shard_key_prefix,
280
282
  overflow_policy: new_config.pool_overflow_policy
281
283
  )
282
- @pool_manager.admission_controller = @pool_reaper if new_config.max_total_connections
284
+ @pool_manager.admission_controller = @pool_reaper if budget
283
285
  @pool_reaper.start
284
286
  end
285
287
 
@@ -370,6 +372,16 @@ module Apartment # rubocop:disable Metrics/ModuleLength
370
372
  end
371
373
  end
372
374
 
375
+ # Prepend the sequence-name patch whenever the PostgreSQL adapter loads
376
+ # (immediately, if it already has). Registered at gem load rather than in
377
+ # activate! because ActiveRecord memoizes Model.sequence_name at first touch,
378
+ # which can happen during boot before Apartment.activate! runs. No-op for apps
379
+ # that never load the PostgreSQL adapter, so MySQL/SQLite consumers never pull
380
+ # in pg. See the patch file for the full rationale.
381
+ ActiveSupport.on_load(:active_record_postgresqladapter) do
382
+ prepend(Apartment::Patches::PostgresqlSequenceName)
383
+ end
384
+
373
385
  # Load Railtie when Rails is present (standard gem convention).
374
386
  # Railtie is Zeitwerk-ignored — this explicit require is the only load path.
375
387
  require_relative 'apartment/railtie' if defined?(Rails::Railtie)
@@ -37,9 +37,20 @@ Apartment.configure do |config|
37
37
 
38
38
  # == Connection Pool =====================================================
39
39
 
40
- # config.tenant_pool_size = 5
41
- # config.pool_idle_timeout = 300
42
- # config.max_total_connections = nil
40
+ # tenant_pool_size MUST be >= the peak number of threads/fibers in one process
41
+ # that can touch the SAME tenant at once (e.g. a Sidekiq role's concurrency for a
42
+ # same-tenant job fan-out); otherwise those threads block on connection checkout.
43
+ # It is a lazy ceiling — connections are created on demand and idle pools reaped.
44
+ # config.tenant_pool_size = 5
45
+ # config.pool_idle_timeout = 300
46
+ #
47
+ # Bound how many tenant pools exist per process:
48
+ # config.max_tenant_pools = nil
49
+ # ...or bound total tenant-pool CONNECTIONS (requires tenant_pool_size); the
50
+ # admission controller derives the pool budget as floor(value / tenant_pool_size):
51
+ # config.max_tenant_connections = nil
52
+ #
53
+ # config.max_total_connections = nil # DEPRECATED: alias of max_tenant_pools; removed in v5
43
54
 
44
55
  # == Elevator (Request Tenant Detection) =================================
45
56
 
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.alpha6
4
+ version: 4.0.0.alpha8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ryan Brunner
@@ -194,6 +194,7 @@ files:
194
194
  - lib/apartment/migrator.rb
195
195
  - lib/apartment/patches/connection_handling.rb
196
196
  - lib/apartment/patches/live_tenant_propagation.rb
197
+ - lib/apartment/patches/postgresql_sequence_name.rb
197
198
  - lib/apartment/pool_manager.rb
198
199
  - lib/apartment/pool_observer.rb
199
200
  - lib/apartment/pool_reaper.rb