ros-apartment 4.0.0.alpha5 → 4.0.0.alpha6
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/lib/apartment/adapters/abstract_adapter.rb +36 -2
- data/lib/apartment/adapters/postgresql_schema_adapter.rb +6 -0
- data/lib/apartment/cli/tenants.rb +23 -1
- data/lib/apartment/migrator.rb +23 -3
- data/lib/apartment/patches/connection_handling.rb +17 -1
- data/lib/apartment/tasks/v4.rake +10 -1
- data/lib/apartment/tenant.rb +48 -0
- data/lib/apartment/version.rb +1 -1
- data/lib/apartment.rb +10 -0
- 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: 7ce0a7124b9df22be74f2899b8a38046f52fcd7e1e0b068b3ebd778e16f57969
|
|
4
|
+
data.tar.gz: 0f40a39d073ae13bd59dce2e72e3e2aaa3759f2ffc270491f6dbbfcc7ef623fe
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ebbc9775707e60c7e7bd896e6f1efa02487017519770dda6558cd89552e53558f47a39df31b73f4c90f6c7039e86399536ce586ce1bd30ac263ad67071c7407a
|
|
7
|
+
data.tar.gz: 8339cc7eb7b07adcb5df1f923c55f7e3aa96bad5faa47a13975550563af0d77191736b4afe0a8aa97f6a446adc59cc148a21739ff0c36224d3608371e6a0efe0
|
|
@@ -23,10 +23,17 @@ module Apartment
|
|
|
23
23
|
# Called by ConnectionHandling — subclasses should NOT override this.
|
|
24
24
|
# base_config_override: when supplied (e.g. a role-specific config from ConnectionHandling),
|
|
25
25
|
# the adapter builds the tenant config on top of it instead of its own base_config.
|
|
26
|
+
#
|
|
27
|
+
# This validates only the PHYSICAL identifier (engine rules). Raw pool-key
|
|
28
|
+
# safety (colon/whitespace/NUL that would corrupt "tenant:role") is enforced
|
|
29
|
+
# by the sole production caller, ConnectionHandling#connection_pool, before
|
|
30
|
+
# it builds the pool key — and independently by #create. A future caller
|
|
31
|
+
# that invokes this directly, bypassing connection_pool, must validate the
|
|
32
|
+
# raw tenant itself (TenantNameValidator.validate_common!).
|
|
26
33
|
def validated_connection_config(tenant, base_config_override: nil)
|
|
27
34
|
effective_base = base_config_override || base_config
|
|
28
35
|
TenantNameValidator.validate!(
|
|
29
|
-
tenant,
|
|
36
|
+
physical_tenant_name(tenant),
|
|
30
37
|
strategy: Apartment.config.tenant_strategy,
|
|
31
38
|
adapter_name: effective_base['adapter']
|
|
32
39
|
)
|
|
@@ -41,9 +48,13 @@ module Apartment
|
|
|
41
48
|
end
|
|
42
49
|
|
|
43
50
|
# Create a new tenant (schema or database).
|
|
51
|
+
# Validates the physical identifier create_tenant actually addresses
|
|
52
|
+
# (raw schema name for :schema, environmentified database name otherwise),
|
|
53
|
+
# so create and the pool-resolution path validate the same name.
|
|
44
54
|
def create(tenant)
|
|
55
|
+
validate_pool_key_safety!(tenant)
|
|
45
56
|
TenantNameValidator.validate!(
|
|
46
|
-
|
|
57
|
+
physical_tenant_name(tenant),
|
|
47
58
|
strategy: Apartment.config.tenant_strategy,
|
|
48
59
|
adapter_name: base_config['adapter']
|
|
49
60
|
)
|
|
@@ -199,11 +210,34 @@ module Apartment
|
|
|
199
210
|
end
|
|
200
211
|
end
|
|
201
212
|
|
|
213
|
+
# The physical identifier used to address this tenant at connection time:
|
|
214
|
+
# the database name for database-per-tenant strategies (environmentified).
|
|
215
|
+
# validated_connection_config validates THIS name so the pool-resolution
|
|
216
|
+
# path agrees with what the connection actually targets. Schema-per-tenant
|
|
217
|
+
# overrides this to the raw tenant (schemas are named directly).
|
|
218
|
+
def physical_tenant_name(tenant)
|
|
219
|
+
environmentify(tenant)
|
|
220
|
+
end
|
|
221
|
+
|
|
202
222
|
# Default tenant from config.
|
|
203
223
|
def default_tenant
|
|
204
224
|
Apartment.config.default_tenant
|
|
205
225
|
end
|
|
206
226
|
|
|
227
|
+
private
|
|
228
|
+
|
|
229
|
+
# Validate the raw tenant for pool-key safety: it becomes "#{tenant}:#{role}"
|
|
230
|
+
# in the pool key, so a colon / whitespace / NUL there breaks PoolManager's
|
|
231
|
+
# prefix and suffix matching and could evict the wrong tenant's pool.
|
|
232
|
+
# Validated in addition to physical_tenant_name (which carries the engine
|
|
233
|
+
# rules) because a callable environmentify_strategy could transform an
|
|
234
|
+
# unsafe character out of the physical name while the raw name still reaches
|
|
235
|
+
# the pool key. to_s first so a non-String tenant is stringified, not
|
|
236
|
+
# rejected (it is the value the pool key interpolates anyway).
|
|
237
|
+
def validate_pool_key_safety!(tenant)
|
|
238
|
+
TenantNameValidator.validate_common!(tenant.to_s)
|
|
239
|
+
end
|
|
240
|
+
|
|
207
241
|
protected
|
|
208
242
|
|
|
209
243
|
def create_tenant(tenant)
|
|
@@ -38,6 +38,12 @@ module Apartment
|
|
|
38
38
|
config.merge('schema_search_path' => search_path)
|
|
39
39
|
end
|
|
40
40
|
|
|
41
|
+
# Schemas are named directly (never environmentified), so the physical
|
|
42
|
+
# identifier validated at pool-resolution time is the raw tenant name.
|
|
43
|
+
def physical_tenant_name(tenant)
|
|
44
|
+
tenant.to_s
|
|
45
|
+
end
|
|
46
|
+
|
|
41
47
|
# The schema-strategy missing-tenant error: a dropped schema is not caught
|
|
42
48
|
# at switch time (search_path accepts a non-existent schema silently) — it
|
|
43
49
|
# surfaces on the first query as ActiveRecord::StatementInvalid
|
|
@@ -29,7 +29,7 @@ module Apartment
|
|
|
29
29
|
DESC
|
|
30
30
|
method_option :force, type: :boolean, desc: 'Skip confirmation prompt'
|
|
31
31
|
def drop(tenant)
|
|
32
|
-
return
|
|
32
|
+
return unless confirmed_destructive?(tenant)
|
|
33
33
|
|
|
34
34
|
Apartment::Tenant.drop(tenant)
|
|
35
35
|
say("Dropped tenant: #{tenant}") unless quiet?
|
|
@@ -77,6 +77,28 @@ module Apartment
|
|
|
77
77
|
options[:force] || ENV['APARTMENT_FORCE'] == '1'
|
|
78
78
|
end
|
|
79
79
|
|
|
80
|
+
# Whether the irreversible drop may proceed. --force / APARTMENT_FORCE=1
|
|
81
|
+
# is the explicit opt-in. On a TTY we prompt [y/N]. In a non-interactive
|
|
82
|
+
# context (deploy/cron/CI, binstub, rake) we cannot prompt: rather than
|
|
83
|
+
# let Thor's yes? read EOF as "no" — a silent cancel that still exits 0 —
|
|
84
|
+
# or block on $stdin.gets against an open-but-empty pipe, refuse loudly
|
|
85
|
+
# with a non-zero exit. Centralizing it here (not in the rake wrapper)
|
|
86
|
+
# covers every entry point uniformly. See issue #457.
|
|
87
|
+
def confirmed_destructive?(tenant)
|
|
88
|
+
return true if force?
|
|
89
|
+
|
|
90
|
+
unless $stdin.tty?
|
|
91
|
+
raise(Thor::Error,
|
|
92
|
+
'apartment tenants drop is destructive and cannot prompt in a ' \
|
|
93
|
+
'non-interactive context. Re-run with --force or APARTMENT_FORCE=1 to proceed.')
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
return true if yes?("Drop tenant '#{tenant}'? This cannot be undone. [y/N]")
|
|
97
|
+
|
|
98
|
+
say('Cancelled.')
|
|
99
|
+
false
|
|
100
|
+
end
|
|
101
|
+
|
|
80
102
|
def quiet?
|
|
81
103
|
options[:quiet] || ENV['APARTMENT_QUIET'] == '1'
|
|
82
104
|
end
|
data/lib/apartment/migrator.rb
CHANGED
|
@@ -6,6 +6,11 @@ require_relative 'errors'
|
|
|
6
6
|
|
|
7
7
|
module Apartment
|
|
8
8
|
class Migrator # rubocop:disable Metrics/ClassLength
|
|
9
|
+
# ActiveRecord exposes no public setter for advisory-lock state (only the
|
|
10
|
+
# advisory_locks_enabled? reader), so we toggle this private ivar directly.
|
|
11
|
+
# The guard in with_advisory_locks_disabled detects a future Rails rename.
|
|
12
|
+
ADVISORY_LOCKS_IVAR = :@advisory_locks_enabled
|
|
13
|
+
|
|
9
14
|
Result = Data.define(
|
|
10
15
|
:tenant,
|
|
11
16
|
:status,
|
|
@@ -213,13 +218,28 @@ module Apartment
|
|
|
213
218
|
# Disable advisory locks on the leased connection for the duration of the
|
|
214
219
|
# block, then restore the original value. lease_connection returns the same
|
|
215
220
|
# connection object for the current thread (fiber-local via IsolatedExecutionState).
|
|
221
|
+
#
|
|
222
|
+
# PG's advisory locks are database-wide and would serialize parallel tenant
|
|
223
|
+
# migrations (issue #298). Rails offers no public setter, so we poke the
|
|
224
|
+
# private @advisory_locks_enabled ivar. The instance_variable_defined? guard
|
|
225
|
+
# detects a future Rails *rename or removal* of the ivar (a name-presence
|
|
226
|
+
# check — it does NOT catch a semantics change where Rails keeps the ivar but
|
|
227
|
+
# stops honoring it on the lock path). On a detected rename we warn and
|
|
228
|
+
# proceed rather than silently creating an orphan ivar; the ivar contract is
|
|
229
|
+
# also unit-tested against a real connection so a rename breaks CI first.
|
|
216
230
|
def with_advisory_locks_disabled
|
|
217
231
|
conn = ActiveRecord::Base.lease_connection
|
|
218
|
-
|
|
219
|
-
|
|
232
|
+
unless conn.instance_variable_defined?(ADVISORY_LOCKS_IVAR)
|
|
233
|
+
warn "[Apartment::Migrator] ActiveRecord connection #{conn.class} does not define " \
|
|
234
|
+
"#{ADVISORY_LOCKS_IVAR}; cannot disable advisory locks for this Rails version. " \
|
|
235
|
+
'Parallel tenant migrations may serialize or fail on the database-wide advisory lock.'
|
|
236
|
+
return yield
|
|
237
|
+
end
|
|
238
|
+
original = conn.instance_variable_get(ADVISORY_LOCKS_IVAR)
|
|
239
|
+
conn.instance_variable_set(ADVISORY_LOCKS_IVAR, false)
|
|
220
240
|
yield
|
|
221
241
|
ensure
|
|
222
|
-
conn
|
|
242
|
+
conn.instance_variable_set(ADVISORY_LOCKS_IVAR, original) if conn&.instance_variable_defined?(ADVISORY_LOCKS_IVAR)
|
|
223
243
|
end
|
|
224
244
|
|
|
225
245
|
def monotonic_now
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'active_record'
|
|
4
|
+
require_relative '../tenant_name_validator'
|
|
4
5
|
|
|
5
6
|
module Apartment
|
|
6
7
|
module Patches
|
|
@@ -28,6 +29,15 @@ module Apartment
|
|
|
28
29
|
return super
|
|
29
30
|
end
|
|
30
31
|
|
|
32
|
+
# Reject pool-key-unsafe tenant names BEFORE building pool_key or entering
|
|
33
|
+
# fetch_or_create. In the capped path, fetch_or_admit runs admit! (which
|
|
34
|
+
# may LRU-evict an idle pool) before the adapter validates inside the
|
|
35
|
+
# block, so a colon / whitespace / NUL in the raw tenant — which would
|
|
36
|
+
# also corrupt the "#{tenant}:#{role}" key and PoolManager's prefix
|
|
37
|
+
# matching — must be caught here. ConfigurationError is an ApartmentError,
|
|
38
|
+
# so the rescue below re-raises it cleanly.
|
|
39
|
+
Apartment::TenantNameValidator.validate_common!(tenant.to_s)
|
|
40
|
+
|
|
31
41
|
role = ActiveRecord::Base.current_role
|
|
32
42
|
pool_key = "#{tenant}:#{role}"
|
|
33
43
|
|
|
@@ -109,7 +119,13 @@ module Apartment
|
|
|
109
119
|
cache_path = Apartment::SchemaCache.cache_path_for(tenant)
|
|
110
120
|
return unless File.exist?(cache_path)
|
|
111
121
|
|
|
112
|
-
pool.
|
|
122
|
+
# Bind the pool's reflection to the dump file (Rails 7.1+ API). The
|
|
123
|
+
# removed path-taking SchemaCache#load! raised ArgumentError here:
|
|
124
|
+
# pool.schema_cache returns a BoundSchemaReflection whose #load! takes
|
|
125
|
+
# no args. SchemaReflection.new(path) lazily loads the dump (and Rails
|
|
126
|
+
# version-checks it, ignoring a stale file with a warning).
|
|
127
|
+
pool.schema_reflection =
|
|
128
|
+
ActiveRecord::ConnectionAdapters::SchemaReflection.new(cache_path)
|
|
113
129
|
end
|
|
114
130
|
end
|
|
115
131
|
end
|
data/lib/apartment/tasks/v4.rake
CHANGED
|
@@ -15,7 +15,16 @@ namespace :apartment do
|
|
|
15
15
|
desc 'Drop a tenant schema/database'
|
|
16
16
|
task :drop, [:tenant] => :environment do |_t, args|
|
|
17
17
|
abort('Usage: rake apartment:drop[tenant_name]') unless args[:tenant]
|
|
18
|
-
Apartment::CLI::Tenants
|
|
18
|
+
# Plain delegate: Apartment::CLI::Tenants#drop owns the confirmation policy
|
|
19
|
+
# (TTY prompts [y/N]; non-interactive requires --force / APARTMENT_FORCE=1,
|
|
20
|
+
# otherwise raises Thor::Error). The rake wrapper adds no force handling of
|
|
21
|
+
# its own. It only reformats the guard error: `.new.invoke` bypasses
|
|
22
|
+
# Thor.start's exit_on_failure? handling, so an unrescued Thor::Error would
|
|
23
|
+
# surface as a raw `rake aborted!` backtrace that buries the actionable
|
|
24
|
+
# message — abort re-emits it as a clean one-liner with a non-zero exit.
|
|
25
|
+
Apartment::CLI::Tenants.new.invoke(:drop, [args[:tenant]])
|
|
26
|
+
rescue Thor::Error => e
|
|
27
|
+
abort(e.message)
|
|
19
28
|
end
|
|
20
29
|
|
|
21
30
|
desc 'Run migrations for all tenants (or one: rake apartment:migrate[tenant])'
|
data/lib/apartment/tenant.rb
CHANGED
|
@@ -293,8 +293,56 @@ module Apartment
|
|
|
293
293
|
Apartment.pool_manager&.stats || {}
|
|
294
294
|
end
|
|
295
295
|
|
|
296
|
+
# Clear the schema cache on warm tenant pools (and the default pool) in
|
|
297
|
+
# THIS PROCESS, so the next query re-reflects the database. Use after DDL
|
|
298
|
+
# on a pinned/shared table (which N warm tenant pools may have cached) or
|
|
299
|
+
# after manual DDL in a console. Lazy: clears now, repopulates from the DB
|
|
300
|
+
# on next access (not from any dump file).
|
|
301
|
+
#
|
|
302
|
+
# Current-process only — it cannot reach other workers' pools; fleet-wide
|
|
303
|
+
# DDL still needs a rolling restart. Clears schema reflection only, not
|
|
304
|
+
# prepared statements (AR self-heals those on PostgreSQL) and not model
|
|
305
|
+
# @columns_hash (call Model.reset_column_information or restart for that).
|
|
306
|
+
# Not a linearized barrier: an in-flight request may use metadata it
|
|
307
|
+
# already read. Intended for console / post-migrate / low-traffic use.
|
|
308
|
+
#
|
|
309
|
+
# tenant: nil clears all warm tenant pools + the default pool. A tenant
|
|
310
|
+
# name clears only that tenant's warm pools (+ the default pool when the
|
|
311
|
+
# name is the default tenant). Returns the count of pools cleared.
|
|
312
|
+
#
|
|
313
|
+
# Role scope: warm tenant pools are cleared across all roles (the pool key
|
|
314
|
+
# is "tenant:role"), but the default pool is cleared only for the CURRENT
|
|
315
|
+
# role. A multi-role app with a :reading default replica should call once
|
|
316
|
+
# per role (e.g. inside connected_to(role: :reading)) to clear the replica
|
|
317
|
+
# default pool too. For pinned/shared-table DDL, prefer the unscoped form
|
|
318
|
+
# (no tenant arg) — a tenant-scoped call clears only that tenant's pools.
|
|
319
|
+
def reload_schema_cache!(tenant = nil)
|
|
320
|
+
pools = []
|
|
321
|
+
Apartment.pool_manager&.each_pair do |key, pool|
|
|
322
|
+
pools << pool if tenant.nil? || key.start_with?("#{tenant}:")
|
|
323
|
+
end
|
|
324
|
+
default_pool = default_schema_cache_pool(tenant)
|
|
325
|
+
pools << default_pool if default_pool
|
|
326
|
+
|
|
327
|
+
pools.each { |pool| pool.schema_cache.clear! }
|
|
328
|
+
pools.size
|
|
329
|
+
end
|
|
330
|
+
|
|
296
331
|
private
|
|
297
332
|
|
|
333
|
+
# The default pool to clear for reload_schema_cache!, or nil when it should
|
|
334
|
+
# be excluded (a real-tenant scope, or no default tenant configured). The
|
|
335
|
+
# ConnectionHandling patch routes connection_pool by Current.tenant, so we
|
|
336
|
+
# enter default context to get the real default pool, not a tenant one;
|
|
337
|
+
# guarded on `default` because with_default_tenant raises with no default.
|
|
338
|
+
def default_schema_cache_pool(tenant)
|
|
339
|
+
default = default_tenant
|
|
340
|
+
return nil unless default
|
|
341
|
+
return nil unless tenant.nil? || tenant.to_s == default.to_s
|
|
342
|
+
|
|
343
|
+
with_default_tenant { ActiveRecord::Base.connection_pool }
|
|
344
|
+
end
|
|
345
|
+
|
|
298
346
|
# Raise when default_tenant_switch_allowed is false and the caller is
|
|
299
347
|
# block-switching into the default tenant. switch! and reset are exempt:
|
|
300
348
|
# neither enters this guard, so they remain the legitimate paths into
|
data/lib/apartment/version.rb
CHANGED
data/lib/apartment.rb
CHANGED
|
@@ -31,6 +31,16 @@ loader.ignore("#{__dir__}/apartment/cli")
|
|
|
31
31
|
# as the cli.rb / cli ignores above.
|
|
32
32
|
loader.ignore("#{__dir__}/rubocop")
|
|
33
33
|
|
|
34
|
+
# Rails generators live under lib/generators and are loaded on demand by Rails'
|
|
35
|
+
# generator machinery, never autoloaded. They follow Rails' generator naming
|
|
36
|
+
# (Apartment::InstallGenerator), not Zeitwerk's path-based inference
|
|
37
|
+
# (Generators::Apartment::Install::InstallGenerator), so leaving them managed
|
|
38
|
+
# makes `Zeitwerk::Loader.eager_load_all` — which Rails runs whenever a host app
|
|
39
|
+
# sets config.eager_load = true (production/CI) — raise Zeitwerk::NameError at
|
|
40
|
+
# boot. Ignoring the directory is the canonical for_gem pattern for gems that
|
|
41
|
+
# ship generators.
|
|
42
|
+
loader.ignore("#{__dir__}/generators")
|
|
43
|
+
|
|
34
44
|
# Collapse concerns/ so Zeitwerk maps lib/apartment/concerns/model.rb
|
|
35
45
|
# to Apartment::Model (not Apartment::Concerns::Model). Mirrors the
|
|
36
46
|
# Rails convention for app/models/concerns/.
|