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 +4 -4
- data/README.md +5 -3
- data/lib/apartment/CLAUDE.md +2 -0
- data/lib/apartment/patches/postgresql_type_map.rb +20 -7
- data/lib/apartment/pool_manager.rb +9 -1
- data/lib/apartment/pool_reaper.rb +54 -21
- data/lib/apartment/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 894a4e62959833cd9dba2797321b9199ed8269eab4d51fe6744c95d0aef68076
|
|
4
|
+
data.tar.gz: 1e2dedc54edd54b7058ac7c86c96df47b90d2d4fb3dd62c9aab0293dbb22590a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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"`,
|
|
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
|
|
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?**
|
|
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
|
data/lib/apartment/CLAUDE.md
CHANGED
|
@@ -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
|
|
49
|
-
# timezone
|
|
50
|
-
#
|
|
51
|
-
#
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
#
|
|
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:
|
|
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.
|
|
66
|
-
|
|
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.
|
|
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
|
-
|
|
160
|
-
|
|
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
|
-
|
|
163
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
data/lib/apartment/version.rb
CHANGED