ros-apartment 4.0.0.alpha5 → 4.0.0.alpha7
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 +19 -0
- data/lib/apartment/adapters/abstract_adapter.rb +36 -2
- data/lib/apartment/adapters/postgresql_schema_adapter.rb +6 -0
- data/lib/apartment/cli/migrations.rb +16 -1
- data/lib/apartment/cli/tenants.rb +23 -1
- data/lib/apartment/instrumentation.rb +2 -2
- data/lib/apartment/migrator.rb +67 -5
- data/lib/apartment/patches/connection_handling.rb +17 -1
- data/lib/apartment/tasks/v4.rake +17 -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: '093f09466c1e39d4344764096cec982394980fb08c8b72712c96bd8ad30fdcc7'
|
|
4
|
+
data.tar.gz: 402f7ef009203bb95296a90d7c6ff3dd34eeebca2d70c7da86cdb398ecc6fa60
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 99343f0efe17d444cc23b244581c56135a293371c97ee3fc5d2d2931e51c688c50d9d3d93d8ae55489ec36d3a6a43a910cacdc85d22c43a6c7e80a1bad189faa
|
|
7
|
+
data.tar.gz: 2ccd4c456cf1a5ce190d4b50dedfc131231cd1f02518c0444313987ef0a5fda8acc00c1376bfc5daac791768f1b13685d0bb47a08ae5ba755943a2cd6eb5d04b
|
data/README.md
CHANGED
|
@@ -40,6 +40,25 @@ This gem is a maintained fork of the original [Apartment gem](https://github.com
|
|
|
40
40
|
- Rails 7.2+
|
|
41
41
|
- PostgreSQL 14+, MySQL 8.4+, or SQLite3
|
|
42
42
|
|
|
43
|
+
### Ruby version manager
|
|
44
|
+
|
|
45
|
+
`.ruby-version` is the single source of truth for the local Ruby version (the
|
|
46
|
+
same file `ruby/setup-ruby` reads in CI). Both [mise](https://mise.jdx.dev) and
|
|
47
|
+
[rbenv](https://github.com/rbenv/rbenv) honor it — use whichever you prefer:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
# mise (recommended)
|
|
51
|
+
brew install mise # then add `eval "$(mise activate zsh)"` to ~/.zshrc
|
|
52
|
+
mise trust && mise install # one-time trust per clone, then install the pinned Ruby
|
|
53
|
+
|
|
54
|
+
# rbenv (also works, unchanged)
|
|
55
|
+
rbenv install "$(cat .ruby-version)"
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The committed `mise.toml` carries settings only (never a version): it tells mise
|
|
59
|
+
to honor `.ruby-version` without any per-developer global config. `mise trust`
|
|
60
|
+
is required once per clone; `bin/dev/setup-worktree` handles it for new worktrees.
|
|
61
|
+
|
|
43
62
|
### Setup
|
|
44
63
|
|
|
45
64
|
```ruby
|
|
@@ -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
|
|
@@ -5,6 +5,13 @@ require 'thor'
|
|
|
5
5
|
module Apartment
|
|
6
6
|
class CLI < Thor
|
|
7
7
|
class Migrations < Thor
|
|
8
|
+
# The raised Thor::Error message is the low-context surface a monitor or
|
|
9
|
+
# CI-log tail captures even when the stdout summary is dropped/unindexed.
|
|
10
|
+
# Name a few failed tenants there (capped, so a 600-tenant run does not
|
|
11
|
+
# produce a wall of text); the full per-tenant detail is in the summary
|
|
12
|
+
# and in the migrate_tenant_failed.apartment events.
|
|
13
|
+
FAILED_TENANTS_PREVIEW = 5
|
|
14
|
+
|
|
8
15
|
def self.exit_on_failure? = true
|
|
9
16
|
|
|
10
17
|
desc 'migrate [TENANT]', 'Run migrations for tenants'
|
|
@@ -62,7 +69,15 @@ module Apartment
|
|
|
62
69
|
say(result.summary)
|
|
63
70
|
|
|
64
71
|
trigger_schema_dump if result.success?
|
|
65
|
-
raise(Thor::Error,
|
|
72
|
+
raise(Thor::Error, migration_failure_message(result.failed)) unless result.success?
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def migration_failure_message(failures)
|
|
76
|
+
names = failures.map(&:tenant)
|
|
77
|
+
shown = names.first(FAILED_TENANTS_PREVIEW)
|
|
78
|
+
more = names.size - shown.size
|
|
79
|
+
suffix = more.positive? ? ", and #{more} more" : ''
|
|
80
|
+
"Migration failed for #{names.size} tenant(s): #{shown.join(', ')}#{suffix} (see summary above)"
|
|
66
81
|
end
|
|
67
82
|
|
|
68
83
|
# Rollback bypasses the Migrator's parallelism and Result tracking but
|
|
@@ -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
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
require 'active_support/notifications'
|
|
4
4
|
|
|
5
5
|
module Apartment
|
|
6
|
-
# Thin wrapper around ActiveSupport::Notifications.
|
|
7
|
-
#
|
|
6
|
+
# Thin wrapper around ActiveSupport::Notifications. All events are namespaced
|
|
7
|
+
# *.apartment; see docs/observability.md for the authoritative event catalog.
|
|
8
8
|
module Instrumentation
|
|
9
9
|
def self.instrument(event, payload = {}, &block)
|
|
10
10
|
event_name = "#{event}.apartment"
|
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,
|
|
@@ -116,9 +121,15 @@ module Apartment
|
|
|
116
121
|
duration: monotonic_now - start, error: nil, versions_run: versions
|
|
117
122
|
)
|
|
118
123
|
rescue StandardError => e
|
|
124
|
+
duration = monotonic_now - start
|
|
125
|
+
# Symmetric with the :migrate_tenant success event above: the gem holds the
|
|
126
|
+
# full structured error here, so emit it for subscribers rather than
|
|
127
|
+
# stranding it in the returned Result. See migrate_tenant's rescue.
|
|
128
|
+
instrument_failure(tenant_name, e, duration)
|
|
129
|
+
|
|
119
130
|
Result.new(
|
|
120
131
|
tenant: tenant_name, status: :failed,
|
|
121
|
-
duration:
|
|
132
|
+
duration: duration, error: e, versions_run: []
|
|
122
133
|
)
|
|
123
134
|
end
|
|
124
135
|
|
|
@@ -161,9 +172,17 @@ module Apartment
|
|
|
161
172
|
end
|
|
162
173
|
end
|
|
163
174
|
rescue StandardError => e
|
|
175
|
+
duration = monotonic_now - start
|
|
176
|
+
# Failure counterpart to the :migrate_tenant success event. On SUCCESS the
|
|
177
|
+
# gem instruments; on FAILURE it previously only returned a failed Result,
|
|
178
|
+
# leaving adopters no hook to observe the (structured) error. This closes
|
|
179
|
+
# that asymmetry — generic payload (tenant + error + duration); routing to
|
|
180
|
+
# an error tracker is the subscriber's job.
|
|
181
|
+
instrument_failure(tenant, e, duration)
|
|
182
|
+
|
|
164
183
|
Result.new(
|
|
165
184
|
tenant: tenant, status: :failed,
|
|
166
|
-
duration:
|
|
185
|
+
duration: duration, error: e, versions_run: []
|
|
167
186
|
)
|
|
168
187
|
ensure
|
|
169
188
|
Apartment::Current.migrating = false
|
|
@@ -213,13 +232,56 @@ module Apartment
|
|
|
213
232
|
# Disable advisory locks on the leased connection for the duration of the
|
|
214
233
|
# block, then restore the original value. lease_connection returns the same
|
|
215
234
|
# connection object for the current thread (fiber-local via IsolatedExecutionState).
|
|
235
|
+
#
|
|
236
|
+
# PG's advisory locks are database-wide and would serialize parallel tenant
|
|
237
|
+
# migrations (issue #298). Rails offers no public setter, so we poke the
|
|
238
|
+
# private @advisory_locks_enabled ivar. The instance_variable_defined? guard
|
|
239
|
+
# detects a future Rails *rename or removal* of the ivar (a name-presence
|
|
240
|
+
# check — it does NOT catch a semantics change where Rails keeps the ivar but
|
|
241
|
+
# stops honoring it on the lock path). On a detected rename we warn and
|
|
242
|
+
# proceed rather than silently creating an orphan ivar; the ivar contract is
|
|
243
|
+
# also unit-tested against a real connection so a rename breaks CI first.
|
|
216
244
|
def with_advisory_locks_disabled
|
|
217
245
|
conn = ActiveRecord::Base.lease_connection
|
|
218
|
-
|
|
219
|
-
|
|
246
|
+
unless conn.instance_variable_defined?(ADVISORY_LOCKS_IVAR)
|
|
247
|
+
warn "[Apartment::Migrator] ActiveRecord connection #{conn.class} does not define " \
|
|
248
|
+
"#{ADVISORY_LOCKS_IVAR}; cannot disable advisory locks for this Rails version. " \
|
|
249
|
+
'Parallel tenant migrations may serialize or fail on the database-wide advisory lock.'
|
|
250
|
+
return yield
|
|
251
|
+
end
|
|
252
|
+
original = conn.instance_variable_get(ADVISORY_LOCKS_IVAR)
|
|
253
|
+
conn.instance_variable_set(ADVISORY_LOCKS_IVAR, false)
|
|
220
254
|
yield
|
|
221
255
|
ensure
|
|
222
|
-
conn
|
|
256
|
+
conn.instance_variable_set(ADVISORY_LOCKS_IVAR, original) if conn&.instance_variable_defined?(ADVISORY_LOCKS_IVAR)
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Emit the failure event without letting a raising subscriber break the
|
|
260
|
+
# Migrator's non-raising contract: the failed Result MUST still be returned.
|
|
261
|
+
# ActiveSupport::Notifications propagates subscriber exceptions through
|
|
262
|
+
# instrument (verified against AS 8.0), and this fires from inside a rescue
|
|
263
|
+
# block, so an un-isolated call would convert a captured per-tenant failure
|
|
264
|
+
# into an unhandled raise out of #run.
|
|
265
|
+
#
|
|
266
|
+
# Scope of the guarantee: a subscriber raising a StandardError is swallowed
|
|
267
|
+
# and warned. Process-control exceptions (SystemExit, SignalException /
|
|
268
|
+
# Interrupt) are deliberately NOT rescued — they must propagate so exit and
|
|
269
|
+
# Ctrl-C still work mid-migration; a subscriber raising a bare Exception
|
|
270
|
+
# subclass is itself a bug (Ruby errors should descend from StandardError).
|
|
271
|
+
# The warn is nested-rescued so a broken $stderr (IOError is a StandardError)
|
|
272
|
+
# cannot re-escape the handler. Success-path instrumentation is left
|
|
273
|
+
# un-isolated by design — only the failure path carries the hard no-raise
|
|
274
|
+
# guarantee, and swallowing there would mask real subscriber bugs.
|
|
275
|
+
def instrument_failure(tenant, error, duration)
|
|
276
|
+
Instrumentation.instrument(:migrate_tenant_failed, tenant: tenant, error: error, duration: duration)
|
|
277
|
+
rescue StandardError => e
|
|
278
|
+
# Nested rescue: a sibling `rescue` on this method would NOT catch an
|
|
279
|
+
# exception raised by warn (a broken $stderr), so the warn gets its own.
|
|
280
|
+
begin
|
|
281
|
+
warn "[Apartment::Migrator] migrate_tenant_failed subscriber raised #{e.class}: #{e.message}"
|
|
282
|
+
rescue StandardError
|
|
283
|
+
nil
|
|
284
|
+
end
|
|
223
285
|
end
|
|
224
286
|
|
|
225
287
|
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])'
|
|
@@ -25,6 +34,13 @@ namespace :apartment do
|
|
|
25
34
|
else
|
|
26
35
|
Apartment::CLI::Migrations.new.invoke(:migrate)
|
|
27
36
|
end
|
|
37
|
+
# Parity with apartment:drop: `.new.invoke` bypasses Thor.start's
|
|
38
|
+
# exit_on_failure? handling, so an unrescued Thor::Error (raised on
|
|
39
|
+
# migration failure) would surface as a raw `rake aborted!` backtrace that
|
|
40
|
+
# buries the actionable message. abort re-emits it as a clean one-liner
|
|
41
|
+
# with a non-zero exit.
|
|
42
|
+
rescue Thor::Error => e
|
|
43
|
+
abort(e.message)
|
|
28
44
|
end
|
|
29
45
|
|
|
30
46
|
desc 'Seed all tenants'
|
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/.
|