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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c4e597c986618a8043acbecc8081ef0399493c545fd3720d8f57653da90025da
4
- data.tar.gz: 9fb0d04fe5abb04e93a4ef654edc14744dfc8d86d35f6d6ef8b685d7eb6ddb29
3
+ metadata.gz: 7ce0a7124b9df22be74f2899b8a38046f52fcd7e1e0b068b3ebd778e16f57969
4
+ data.tar.gz: 0f40a39d073ae13bd59dce2e72e3e2aaa3759f2ffc270491f6dbbfcc7ef623fe
5
5
  SHA512:
6
- metadata.gz: 747e6efe4c8c073368684944bbee84cccfd0b063f389d7b4a699debd97662496db9ea220e6be6f119986fa76e3586d180bb3f61ff61c4c44bc3acc37b7784f0c
7
- data.tar.gz: 9ceef5927c8169dc2db2041fbbfba99b81c7106a9cf02814812550c1775cd2566398dd8383e78bd0d24e7a82a845d2c4418bc974c43cc5e0169cd25a6072eba5
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
- environmentify(tenant),
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 say('Cancelled.') if !force? && !yes?("Drop tenant '#{tenant}'? This cannot be undone. [y/N]")
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
@@ -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
- original = conn.instance_variable_get(:@advisory_locks_enabled)
219
- conn.instance_variable_set(:@advisory_locks_enabled, false)
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&.instance_variable_set(:@advisory_locks_enabled, original)
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.schema_cache.load!(cache_path)
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
@@ -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.new.invoke(:drop, [args[:tenant]], force: true)
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])'
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Apartment
4
- VERSION = '4.0.0.alpha5'
4
+ VERSION = '4.0.0.alpha6'
5
5
  end
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/.
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.alpha5
4
+ version: 4.0.0.alpha6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ryan Brunner