ros-apartment 4.0.0.alpha14 → 4.0.0.alpha15

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: 2bce6584d418fcb45322b27efd6b816c8e33b603866310d9ffabf96e1078a776
4
- data.tar.gz: 77d334fc612ff87fa615b2079f2263b8505c84652e966b08b94386061621fcaa
3
+ metadata.gz: 894a4e62959833cd9dba2797321b9199ed8269eab4d51fe6744c95d0aef68076
4
+ data.tar.gz: 1e2dedc54edd54b7058ac7c86c96df47b90d2d4fb3dd62c9aab0293dbb22590a
5
5
  SHA512:
6
- metadata.gz: 3f73a95f17f83e8034f2a1db98938018d5d1b5e83ec7508720305269b61cdb847c356616d0aa39ea7ad65e42716838c8f243753a14eaf47539ca20fc043ec4c5
7
- data.tar.gz: c7626e6a4dfad74516f56be92ee2703e9a19e0cc1b11c525d0803dcb36f918f7bce72d9c68b62c226a712d138dd18ee6e0746ac2333b6b8a05a57a6984e3ab60
6
+ metadata.gz: f31685d04741cef165f6bcffcf67db498c520be551ba31c03a8967d74b4c2992163dddd09047240771347cdb6f1ef0beec6311f1779f14f114f365e96600980d
7
+ data.tar.gz: 841a51484b694620fa04433f0ce8b2fb63afcb9f003b7750c9b9c138703d9193501adc2fe15be3d67f15b72a7bf52e383d00767719ff7014afdd47344db8e44e
data/README.md CHANGED
@@ -438,18 +438,20 @@ routed-vs-pinned model and the two-store recipe.
438
438
 
439
439
  ## Iterating across tenants
440
440
 
441
- 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?**
441
+ v4 keys a connection pool per `"tenant:role"`, and the pool is created when a query resolves a connection — **not** when `Tenant.switch` sets the tenant. So a switch whose block never touches the tenant database costs nothing, and the question for picking a primitive is **does the block query per-tenant-schema data?**
442
442
 
443
443
  | Need | Use | v4 cost |
444
444
  |---|---|---|
445
445
  | Names only (enqueue a job, build a list) | `Apartment.tenant_names.each { ... }` | No switch, no pool created |
446
- | Per-tenant-schema work (read/write tenant tables) | `Apartment::Tenant.each(release_connection: true) { ... }` | One pool per tenant; released between iterations |
446
+ | Per-tenant-schema work, every tenant | `Apartment::Tenant.each(release_connection: true) { ... }` | One pool per tenant; released between iterations |
447
+ | Per-tenant-schema work, a **subset** | `Apartment::Tenant.each(names, release_connection: true) { ... }` | Same, over the list you pass — no need to hand-roll the `ensure` |
447
448
  | 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 |
448
449
 
449
450
  Rules of thumb:
450
451
 
451
- - **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.
452
+ - **Enqueueing jobs?** Pass the tenant as a job argument (`Job.perform_async(tenant: name)`) and let your worker middleware switch when the job runs — the worker needs the name anyway, and the enqueue side stays independent of tenant routing. A switch that only enqueues is *not* itself expensive: the block touches the queue, never the tenant database, so no pool is created.
452
453
  - **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.
454
+ - **Iterating some tenants, not all?** `Tenant.each` takes the list as its first argument — `Tenant.each(Account.active.pluck(:tenant_name)) { ... }`. Reach for this instead of writing `names.each { |n| Tenant.switch(n) { ... } }`: it is the gem-native shape, and it puts `release_connection:` within reach for when the next bullet says you need it. Add that flag deliberately rather than by default — it is not a free no-op inside an outer transaction. A hand-rolled loop over such a block leaves one leased connection per visited tenant, which keeps every pool it touches un-evictable and can drive the pool count far past `max_tenant_pools` (see [Pool admission control](docs/designs/pool-admission-control.md)). A block using only modern query methods creates a pool per tenant but no lasting lease, so those pools stay idle and the reaper can trim them. The subset form and `release_connection:` are **v4 API** — v3's `Tenant.each` forwards to the adapter and accepts neither. `Tenant.with_tenants(*names) { ... }` scopes the provider instead, if you want nested calls to see the same subset.
453
455
  - **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.**
454
456
 
455
457
  ## Convenience Methods
@@ -92,6 +92,8 @@ Three details are load-bearing. The map is a `SharedTypeMap < HashLookupTypeMap`
92
92
 
93
93
  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.
94
94
 
95
+ **Admission is the quadratic path, so it does not instrument per candidate.** The scan walks every pool on every cold create; the timer paths walk every pool once per reap cycle. So `protected_pool?` (per-candidate `:skip_evict`, timer only) and `protection_reason` (bare predicate, admission) are split, and admission reports one `skipped: { pinned:, in_use:, default: }` tally on `:cap_unmet` instead — three buckets, because the default tenant's own pools are refused before the protection check and would otherwise vanish from the accounting; `pinned + in_use + default` equals `current` for an *uninterrupted* scan — a candidate whose protection check raises is rescued without being tallied, and the reaper does not take `@create_mutex`, so it can evict between the scan and the count. It also reads `PoolManager#total_pools` rather than `#stats`, which materializes the full key array to answer a count. A measured breach reaching 477 pools emitted ~218k `:skip_evict` events before this. Note that `in_use` means *leased*, not *executing* — a fan-out that never releases reads as saturation while nothing is running; `docs/observability.md` documents the tell.
96
+
95
97
  ### adapters/abstract_adapter.rb — Base Adapter
96
98
 
97
99
  Lifecycle ops (`create`, `drop`, `migrate`, `seed`), `ActiveSupport::Callbacks` on `:create`/`:switch`, `resolve_connection_config` (abstract — subclasses override), `process_excluded_models`, `environmentify`, `base_config` (stringified `connection_config`), `rails_env` (guarded `Rails.env` access). `process_pinned_model` / `qualify_pinned_table_name` call **`Apartment::Model` class methods** (`apartment_explicit_table_name?`, `apartment_mark_processed!`, etc.); no `instance_variable_*` on arbitrary model classes. Constructor takes `connection_config` (raw AR hash, not `Apartment::Config`).
@@ -45,13 +45,26 @@ module Apartment
45
45
  #
46
46
  # RETENTION, measured rather than bounded. An entry is removed only when a
47
47
  # later publish supersedes it (see #apartment_supersede_stale_identities),
48
- # so a process retains one map per database it has connected to, times the
49
- # timezone variants live against it. One built map
50
- # holds ~138 registrations and 88.5 KB of RSS (measured against PostgreSQL
51
- # 18 on Rails 8.1). Schema-per-tenant -- the common case, and the one this
52
- # patch was written for -- has exactly ONE key for the whole process. Only
53
- # database-per-tenant grows, and it grows with the tenants a process
54
- # actually serves rather than with time: 570 tenant databases is ~49 MB. No
48
+ # so a process retains one map per KEY it has built against, and the key is
49
+ # [[host, port, database], identity, timezone] -- not the database alone. A
50
+ # :reading role on a replica is a different endpoint and carries its own
51
+ # map for the same catalog, so multiply by distinct endpoints, usually the
52
+ # number of roles. (Timezone splits nothing in practice: tenant configs
53
+ # derive from one base config.) One built map holds ~138 registrations and
54
+ # 88.5 KB of RSS (measured against PostgreSQL 18 on Rails 8.1) against a
55
+ # catalog carrying only built-in enums, domains and ranges -- a BASELINE,
56
+ # not a constant. The second load query has no namespace filter, so a map
57
+ # holds the built-ins plus every such type in the catalog it was built
58
+ # against, and pg_type is per-DATABASE, so the strategy decides only how
59
+ # tenant-defined types distribute. Schema-per-tenant -- the common case,
60
+ # and the one this patch was written for -- has one key per endpoint
61
+ # however many tenants there are, and that single map carries every
62
+ # tenant's types (138 + tenants x types-per-schema). Database-per-tenant
63
+ # splits the same types across maps: each carries 138 + its own tenant's,
64
+ # so COUNT grows with tenants, cumulatively -- supersede fires only for one
65
+ # endpoint under a different identity, so a dropped database leaves its
66
+ # entry for the life of the process. 570 tenant databases is ~49 MB of
67
+ # baseline per endpoint, plus whatever those tenants define. See docs/designs/postgresql-type-map-sharing.md. No
55
68
  # eviction policy ships because a cap tight enough to bound that
56
69
  # meaningfully is also tight enough to thrash the nightly sweep it exists to
57
70
  # speed up, which is the deployment that has the problem in the first place.
@@ -124,11 +124,19 @@ module Apartment
124
124
  .map(&:first)
125
125
  end
126
126
 
127
+ # O(1) pool count. Separate from #stats because the admission path asks for
128
+ # the count on every cold create, and #stats answers by materializing the
129
+ # full key array — garbage proportional to the pool count, at exactly the
130
+ # moment the pool count is the problem.
131
+ def total_pools
132
+ @pools.size
133
+ end
134
+
127
135
  # Basic stats. Full observability (per-tenant breakdown, connection
128
136
  # counts, eviction counters) deferred to Phase 3.
129
137
  def stats
130
138
  {
131
- total_pools: @pools.size,
139
+ total_pools: total_pools,
132
140
  tenants: @pools.keys,
133
141
  }
134
142
  end
@@ -61,13 +61,18 @@ module Apartment
61
61
  def admit!(incoming_tenant_key)
62
62
  return unless @max_total
63
63
 
64
+ # Reset per scan: the tally that matters is the one from the pass that
65
+ # gave up, not a sum over passes that each evicted something.
66
+ skipped = nil
64
67
  loop do
65
- break if @pool_manager.stats[:total_pools] < @max_total
66
- break unless evict_one_for_admission(incoming_tenant_key)
68
+ break if @pool_manager.total_pools < @max_total
69
+
70
+ skipped = { pinned: 0, in_use: 0, default: 0 }
71
+ break unless evict_one_for_admission(incoming_tenant_key, skipped)
67
72
  end
68
- return if @pool_manager.stats[:total_pools] < @max_total
73
+ return if @pool_manager.total_pools < @max_total
69
74
 
70
- apply_overflow_policy
75
+ apply_overflow_policy(skipped)
71
76
  end
72
77
 
73
78
  def start
@@ -156,11 +161,32 @@ module Apartment
156
161
  # Evict the single LRU evictable pool to make room for an incoming one,
157
162
  # skipping the incoming key, the default tenant, and pinned/in-use pools.
158
163
  # Returns the evicted tenant key, or nil if nothing is evictable.
159
- def evict_one_for_admission(incoming_tenant_key)
160
- @pool_manager.lru_tenants(count: @pool_manager.stats[:total_pools]).each do |tenant|
164
+ #
165
+ # Tallies protected candidates into +skipped+ instead of emitting a
166
+ # per-candidate :skip_evict. This scan walks every pool and runs on every
167
+ # cold create, so a per-candidate event is quadratic in the pool count: a
168
+ # measured breach that reached 477 pools emitted ~218k of them, each one
169
+ # eagerly walking that pool's connection array twice to build a payload.
170
+ # The aggregate lands on :cap_unmet once per admission instead. The timer
171
+ # paths keep their per-candidate events — there the scan is once per reap
172
+ # cycle, and "this tenant has been skipped for N cycles" is the signal.
173
+ def evict_one_for_admission(incoming_tenant_key, skipped)
174
+ @pool_manager.lru_tenants(count: @pool_manager.total_pools).each do |tenant|
161
175
  next if tenant == incoming_tenant_key
162
- next if default_tenant_pool?(tenant)
163
- next if protected_pool?(tenant, eviction_reason: :admission)
176
+
177
+ # Tallied, not silently skipped: a breach caused entirely by default
178
+ # pools would otherwise report "nothing protected" beside a cap that
179
+ # could not be met. The three buckets plus the incoming key account
180
+ # for every candidate the scan saw.
181
+ if default_tenant_pool?(tenant)
182
+ skipped[:default] += 1
183
+ next
184
+ end
185
+
186
+ if (reason = protection_reason(@pool_manager.peek(tenant)))
187
+ skipped[reason] += 1
188
+ next
189
+ end
164
190
 
165
191
  evict_tenant(tenant, reason: :admission)
166
192
  return tenant
@@ -174,18 +200,20 @@ module Apartment
174
200
  # or in use). :evict_idle degrades to a soft cap — allow the new pool, surface
175
201
  # the breach via :cap_unmet. :raise fails the admission so the caller sheds
176
202
  # load. See docs/designs/pool-admission-control.md.
177
- def apply_overflow_policy
178
- current = @pool_manager.stats[:total_pools]
203
+ def apply_overflow_policy(skipped = nil)
204
+ current = @pool_manager.total_pools
179
205
  case @overflow_policy
180
206
  when :raise
181
207
  raise(Apartment::PoolCapacityReached.new(max_total: @max_total, current: current))
182
208
  else
183
- Instrumentation.instrument(:cap_unmet, max_total: @max_total, current: current, unevicted: 1)
209
+ payload = { max_total: @max_total, current: current, unevicted: 1 }
210
+ payload[:skipped] = skipped if skipped
211
+ Instrumentation.instrument(:cap_unmet, payload)
184
212
  end
185
213
  end
186
214
 
187
215
  def evict_lru
188
- total = @pool_manager.stats[:total_pools]
216
+ total = @pool_manager.total_pools
189
217
  excess = total - @max_total
190
218
  return 0 if excess <= 0
191
219
 
@@ -231,15 +259,20 @@ module Apartment
231
259
  # protected. Used as a single guard for both eviction paths.
232
260
  def protected_pool?(tenant, eviction_reason:)
233
261
  pool = @pool_manager.peek(tenant)
234
- if pool_pinned?(pool)
235
- instrument_skip(reason: :pinned, tenant: tenant, eviction_reason: eviction_reason, pool: pool)
236
- return true
237
- end
238
- if pool_in_use?(pool)
239
- instrument_skip(reason: :in_use, tenant: tenant, eviction_reason: eviction_reason, pool: pool)
240
- return true
241
- end
242
- false
262
+ return false unless (reason = protection_reason(pool))
263
+
264
+ instrument_skip(reason: reason, tenant: tenant, eviction_reason: eviction_reason, pool: pool)
265
+ true
266
+ end
267
+
268
+ # Why this pool cannot be evicted right now (:pinned / :in_use), or nil when
269
+ # it can. Split out of protected_pool? so the admission scan can classify a
270
+ # candidate without paying for instrumentation — see evict_one_for_admission.
271
+ def protection_reason(pool)
272
+ return :pinned if pool_pinned?(pool)
273
+ return :in_use if pool_in_use?(pool)
274
+
275
+ nil
243
276
  end
244
277
 
245
278
  # Instance-side wrapper around the class predicate; kept for callers
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Apartment
4
- VERSION = '4.0.0.alpha14'
4
+ VERSION = '4.0.0.alpha15'
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.alpha14
4
+ version: 4.0.0.alpha15
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ryan Brunner