ros-apartment 4.0.0.alpha4 → 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: 86ded64305e44aac057b97264c079d5090545b7d1740693ea1cf7c881b69fe19
4
- data.tar.gz: 99e78891808ff4cdb1e0fef82f7c49a344a4c088408c9c143af465c70a150a3b
3
+ metadata.gz: 7ce0a7124b9df22be74f2899b8a38046f52fcd7e1e0b068b3ebd778e16f57969
4
+ data.tar.gz: 0f40a39d073ae13bd59dce2e72e3e2aaa3759f2ffc270491f6dbbfcc7ef623fe
5
5
  SHA512:
6
- metadata.gz: 6a80e29d57f2d4d4de7cd41666b3cef737720cceb782c3d6a34f5522dec0a6ec3fc04e41ba1a45761eabfee5bee5f5b0d2d80d2ae79d1c43f8d905906d7dc1d3
7
- data.tar.gz: a4c460e9ec3c2214e1a52fd399bf0038a6da1d43c66a20fbd97a31ceb6ac44b4595592eec41f0310cfc51f99e5b6699420332fa816a177865479af1772dea928
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
@@ -24,7 +24,18 @@ module Apartment
24
24
  def self.install!(sink:, sample_interval: nil, backend_count: nil)
25
25
  observer = new(sink: sink, backend_count: backend_count)
26
26
  observer.subscribe!
27
- observer.start_sampler!(interval: sample_interval) if sample_interval&.positive?
27
+ if sample_interval.nil?
28
+ # nil is the intentional "no gauge sampler" signal; counters still flow.
29
+ elsif sample_interval.positive?
30
+ observer.start_sampler!(interval: sample_interval)
31
+ else
32
+ # A non-nil, non-positive interval is almost always a misconfig (e.g. an
33
+ # empty APARTMENT_POOL_SAMPLE_INTERVAL coerced to 0). Silently skipping
34
+ # the sampler ships an observer whose gauges never emit; surface it.
35
+ warn "[Apartment::PoolObserver] sample_interval=#{sample_interval.inspect} is not " \
36
+ 'positive; gauge sampler not started (tenant_pools_live/backend_connections ' \
37
+ 'will not emit). Pass a positive interval, or nil to disable the sampler.'
38
+ end
28
39
  observer
29
40
  rescue StandardError
30
41
  # Don't leak subscriptions if a later step (e.g. a bad sample_interval)
@@ -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])'
@@ -200,17 +200,29 @@ module Apartment
200
200
  # it drops EVERY connection leased to the current execution context, across
201
201
  # all pools and roles, not just the tenant pool. Do NOT use it inside an
202
202
  # outer ActiveRecord::Base.transaction, or while holding a non-tenant
203
- # connection you mean to keep. Fail-fast: if the block raises, the failing
204
- # tenant is not released (the fan-out aborts).
203
+ # connection you mean to keep. Fail-fast: a raising block still aborts the
204
+ # fan-out, but the failing tenant's connection is released first the
205
+ # release is best-effort cleanup (in an ensure), not iteration semantics.
205
206
  def each(tenants = nil, release_connection: false)
206
207
  raise(ArgumentError, 'Apartment::Tenant.each requires a block') unless block_given?
207
208
 
208
209
  tenants ||= Apartment.tenant_names
209
210
  tenants.each do |tenant|
210
211
  switch(tenant) { yield(tenant) }
212
+ ensure
211
213
  # Handler-wide (:all) covers writing + reading roles — the gem's
212
- # established release call (see memory_stability_spec).
213
- ActiveRecord::Base.connection_handler.clear_active_connections!(:all) if release_connection
214
+ # established release call (see memory_stability_spec). In an ensure so
215
+ # a raising tenant is still released; the exception then propagates and
216
+ # halts the fan-out (fail-fast preserved). Best-effort: a release
217
+ # failure must never mask the block's exception, so it is rescued and
218
+ # warned rather than raised out of the ensure.
219
+ if release_connection
220
+ begin
221
+ ActiveRecord::Base.connection_handler.clear_active_connections!(:all)
222
+ rescue StandardError => e
223
+ warn("[Apartment::Tenant.each] connection release failed: #{e.class}: #{e.message}")
224
+ end
225
+ end
214
226
  end
215
227
  end
216
228
 
@@ -281,8 +293,56 @@ module Apartment
281
293
  Apartment.pool_manager&.stats || {}
282
294
  end
283
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
+
284
331
  private
285
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
+
286
346
  # Raise when default_tenant_switch_allowed is false and the caller is
287
347
  # block-switching into the default tenant. switch! and reset are exempt:
288
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.alpha4'
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.alpha4
4
+ version: 4.0.0.alpha6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ryan Brunner