Skip to content

Commit 4e4c5b8

Browse files
authored
Merge pull request #137 from basecamp/flavorjones/path-based-tenanting
Improve testing framework support for parallel testing
2 parents 500f772 + d499397 commit 4e4c5b8

9 files changed

Lines changed: 184 additions & 38 deletions

File tree

GUIDE.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ Documentation outline:
2424
- support and documentation for Solid Cache, Solid Queue, Solid Cable, and Turbo Rails
2525
- a Tenant is just a string that is used for:
2626
- the sqlite database filename (or perhaps the pg/mysql database name in the future)
27-
- the subdomain (or path element)
27+
- configuring either tenant-by-subdomain or a tenant-by-root-path-element
2828
- fragment cache disambiguation
2929
- global id disambiguation
3030
- invalid characters in a tenant name
@@ -73,9 +73,12 @@ Documentation outline:
7373
- `tenant_resolver`
7474
- `tenanted_rails_records`
7575
- `log_tenant_tag`
76-
- demonstrate how to configure an app for subdomain tenants
76+
- demonstrate how to configure an app with subdomain tenanting
7777
- app.config.hosts
78-
- example TenantSelector
78+
- example TenantSelector config
79+
- demonstrate how to configure an app with root path tenanting
80+
- app.config.hosts
81+
- example TenantSelector config
7982

8083
- migrations
8184
- create_tenant migrates the new database
@@ -251,7 +254,7 @@ TODO:
251254
- [x] tenanted_rails_records
252255

253256
- additional configuration
254-
- [ ] default_tenant (development only)
257+
- [x] default_tenant (local only)
255258

256259

257260
### Tenanting in your application

bin/test-unit

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,5 @@ ENV["DEFAULT_TEST"] ||= "test/unit/**_test.rb"
55

66
require "bundler/setup"
77
require "rails/plugin/test"
8+
9+
Rails::TestUnitReporter.executable = __FILE__

lib/active_record/tenanted/database_configurations.rb

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@ module ActiveRecord
66
module Tenanted
77
module DatabaseConfigurations
88
class RootConfig < ActiveRecord::DatabaseConfigurations::HashConfig
9+
attr_accessor :test_worker_id
10+
11+
def initialize(...)
12+
super
13+
@test_worker_id = nil
14+
end
15+
916
def database_tasks?
1017
false
1118
end
@@ -16,16 +23,27 @@ def database_for(tenant_name)
1623
raise BadTenantNameError, "Tenant name contains an invalid character: #{tenant_name.inspect}"
1724
end
1825

19-
sprintf(database, tenant: tenant_name)
26+
path = sprintf(database, tenant: tenant_name)
27+
28+
if test_worker_id
29+
test_worker_suffix = "_#{test_worker_id}"
30+
if path.start_with?("file:") && path.include?("?")
31+
path.sub!(/(\?.*)$/, "#{test_worker_suffix}\\1")
32+
else
33+
path += test_worker_suffix
34+
end
35+
end
36+
37+
path
2038
end
2139

2240
def database_path_for(tenant_name)
2341
coerce_path(database_for(tenant_name))
2442
end
2543

2644
def tenants
27-
glob = coerce_path(sprintf(database, tenant: "*"))
28-
scanner = Regexp.new(coerce_path(sprintf(database, tenant: "(.+)")))
45+
glob = database_path_for("*")
46+
scanner = Regexp.new(database_path_for("(.+)"))
2947

3048
Dir.glob(glob).map do |path|
3149
result = path.scan(scanner).flatten.first

lib/active_record/tenanted/database_tasks.rb

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,17 +45,13 @@ def root_database_config
4545
db_configs.detect { |c| c.configuration_hash[:tenanted] }
4646
end
4747

48-
def default_tenant
49-
"#{Rails.env}-tenant"
50-
end
51-
5248
def get_current_tenant
5349
tenant = ENV["ARTENANT"]
5450

5551
if tenant.present?
5652
$stdout.puts "Setting current tenant to #{tenant.inspect}" if verbose?
5753
elsif Rails.env.local?
58-
tenant = default_tenant
54+
tenant = Rails.application.config.active_record_tenanted.default_tenant
5955
$stdout.puts "Defaulting current tenant to #{tenant.inspect}" if verbose?
6056
else
6157
tenant = nil

lib/active_record/tenanted/railtie.rb

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,17 @@ class Railtie < ::Rails::Railtie
4646
# Defaults to false in development and test environments, and true in all other environments.
4747
config.active_record_tenanted.log_tenant_tag = !Rails.env.local?
4848

49+
# Set this to override the default tenant name used in development and test environments.
50+
#
51+
# This is the default tenant name used by database tasks and in the Rails console. In both
52+
# cases, this can be overridden at runtime by setting the `ARTENANT` environment variable.
53+
#
54+
# Notably, it's also the tenant name used by the testing frameworks, so you may need to set
55+
# this if you have application-specific constraints on tenant names.
56+
#
57+
# Defaults to "development-tenant" in development and "test-tenant" in test environments.
58+
config.active_record_tenanted.default_tenant = Rails.env.local? ? "#{Rails.env}-tenant" : nil
59+
4960
config.before_initialize do
5061
Rails.application.configure do
5162
if config.active_record_tenanted.connection_class.present?

lib/active_record/tenanted/tenant.rb

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -123,19 +123,18 @@ def create_tenant(tenant_name, if_not_exists: false, &block)
123123
end
124124

125125
def destroy_tenant(tenant_name)
126-
return unless tenant_exist?(tenant_name)
126+
ActiveRecord::Base.logger.info " DESTROY [tenant=#{tenant_name}] Destroying tenant database"
127127

128-
with_tenant(tenant_name) do
129-
connection_pool(schema_version_check: false)
130-
lease_connection.send(:log, "/* destroying tenant database */", "DESTROY [tenant=#{tenant_name}]")
131-
ensure
132-
remove_connection
128+
with_tenant(tenant_name, prohibit_shard_swapping: false) do
129+
if retrieve_connection_pool(strict: false)
130+
remove_connection
131+
end
133132
end
134133

135134
# NOTE: This is obviously a sqlite-specific implementation.
136135
# TODO: Create a `drop_database` method upstream in the sqlite3 adapter, and call it.
137136
# Then this would delegate to the adapter and become adapter-agnostic.
138-
FileUtils.rm(tenanted_root_config.database_path_for(tenant_name))
137+
FileUtils.rm_f(tenanted_root_config.database_path_for(tenant_name))
139138
end
140139

141140
def tenants

lib/active_record/tenanted/testing.rb

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,22 +8,31 @@ module ActiveSupportTestCase
88

99
prepended do
1010
if klass = ActiveRecord::Tenanted.connection_class
11-
klass.current_tenant = "#{Rails.env}-tenant"
12-
klass.destroy_tenant(klass.current_tenant)
13-
klass.create_tenant(klass.current_tenant)
11+
klass.current_tenant = Rails.application.config.active_record_tenanted.default_tenant
12+
tenant_genesis(klass)
1413

1514
parallelize_setup do |worker|
16-
klass.current_tenant = "#{Rails.env}-tenant-#{worker}"
15+
# free up the connection pool for the tenant name
1716
klass.destroy_tenant(klass.current_tenant)
18-
klass.create_tenant(klass.current_tenant)
19-
end
2017

21-
# clean up any non-default tenants left over from the last test run
22-
klass.tenants.each do |tenant|
23-
klass.destroy_tenant(tenant) unless tenant.start_with?("#{Rails.env}-tenant")
18+
# destroy and create tenant databases for this worker's unique id
19+
klass.tenanted_root_config.test_worker_id = worker
20+
tenant_genesis(klass)
2421
end
2522
end
2623
end
24+
25+
class_methods do
26+
private
27+
# Destroy any existing tenants from the last test run, and create a fresh tenant
28+
# database for the current tenant.
29+
#
30+
# Yes, this is a Star Trek reference.
31+
def tenant_genesis(klass)
32+
klass.tenants.each { |tenant| klass.destroy_tenant(tenant) }
33+
klass.create_tenant(klass.current_tenant)
34+
end
35+
end
2736
end
2837

2938
module ActionDispatchIntegrationTest
@@ -77,7 +86,7 @@ def transactional_tests_for_pool?(pool)
7786
# 2. having an open transaction will prevent the test from being able to destroy the tenant.
7887
is_non_default_tenant = (
7988
config.instance_of?(Tenanted::DatabaseConfigurations::TenantConfig) &&
80-
!config.tenant.start_with?("#{Rails.env}-tenant")
89+
config.tenant != Rails.application.config.active_record_tenanted.default_tenant.to_s
8190
)
8291

8392
return false if is_root_config || is_non_default_tenant

test/unit/database_configurations_test.rb

Lines changed: 116 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,12 @@
3737
assert_equal("storage/db/tenanted/foo/main.sqlite3", config.database_path_for("foo"))
3838
end
3939

40+
test "parallel test workers have unique files" do
41+
config.test_worker_id = 99
42+
43+
assert_equal("storage/db/tenanted/foo/main.sqlite3_99", config.database_path_for("foo"))
44+
end
45+
4046
test "raises if the tenant name contains a path separator" do
4147
assert_raises(ActiveRecord::Tenanted::BadTenantNameError) { config.database_path_for("foo/bar") }
4248
end
@@ -50,8 +56,23 @@
5056
test "returns all tenants" do
5157
Dir.chdir(dir) do
5258
[ "foo", "bar", "baz" ].each do |tenant|
53-
FileUtils.mkdir_p("storage/db/tenanted/#{tenant}")
54-
FileUtils.touch("storage/db/tenanted/#{tenant}/main.sqlite3")
59+
path = config.database_path_for(tenant)
60+
FileUtils.mkdir_p(File.dirname(path))
61+
FileUtils.touch(path)
62+
end
63+
64+
assert_equal(Set.new(config.tenants), Set.new([ "foo", "bar", "baz" ]))
65+
end
66+
end
67+
68+
test "parallel test worker returns all tenants" do
69+
config.test_worker_id = 99
70+
71+
Dir.chdir(dir) do
72+
[ "foo", "bar", "baz" ].each do |tenant|
73+
path = config.database_path_for(tenant)
74+
FileUtils.mkdir_p(File.dirname(path))
75+
FileUtils.touch(path)
5576
end
5677

5778
assert_equal(Set.new(config.tenants), Set.new([ "foo", "bar", "baz" ]))
@@ -68,16 +89,38 @@
6889
assert_equal("#{dir}/storage/db/tenanted/foo/main.sqlite3", config.database_path_for("foo"))
6990
end
7091

92+
test "parallel test workers have unique files" do
93+
config.test_worker_id = 99
94+
95+
assert_equal("file:#{dir}/storage/db/tenanted/foo/main.sqlite3_99", config.database_for("foo"))
96+
assert_equal("#{dir}/storage/db/tenanted/foo/main.sqlite3_99", config.database_path_for("foo"))
97+
end
98+
7199
test "returns all tenants" do
72100
Dir.chdir(dir) do
73101
[ "foo", "bar", "baz" ].each do |tenant|
74-
FileUtils.mkdir_p("storage/db/tenanted/#{tenant}")
75-
FileUtils.touch("storage/db/tenanted/#{tenant}/main.sqlite3")
102+
path = config.database_path_for(tenant)
103+
FileUtils.mkdir_p(File.dirname(path))
104+
FileUtils.touch(path)
76105
end
77106
end
78107

79108
assert_equal(Set.new(config.tenants), Set.new([ "foo", "bar", "baz" ]))
80109
end
110+
111+
test "parallel test worker returns all tenants" do
112+
config.test_worker_id = 99
113+
114+
Dir.chdir(dir) do
115+
[ "foo", "bar", "baz" ].each do |tenant|
116+
path = config.database_path_for(tenant)
117+
FileUtils.mkdir_p(File.dirname(path))
118+
FileUtils.touch(path)
119+
end
120+
121+
assert_equal(Set.new(config.tenants), Set.new([ "foo", "bar", "baz" ]))
122+
end
123+
end
81124
end
82125

83126
describe "absolute URI with query params" do
@@ -89,16 +132,38 @@
89132
assert_equal("#{dir}/storage/db/tenanted/foo/main.sqlite3", config.database_path_for("foo"))
90133
end
91134

135+
test "parallel test workers have unique files" do
136+
config.test_worker_id = 99
137+
138+
assert_equal("file:#{dir}/storage/db/tenanted/foo/main.sqlite3_99?vfs=unix-dotfile", config.database_for("foo"))
139+
assert_equal("#{dir}/storage/db/tenanted/foo/main.sqlite3_99", config.database_path_for("foo"))
140+
end
141+
92142
test "returns all tenants" do
93143
Dir.chdir(dir) do
94144
[ "foo", "bar", "baz" ].each do |tenant|
95-
FileUtils.mkdir_p("storage/db/tenanted/#{tenant}")
96-
FileUtils.touch("storage/db/tenanted/#{tenant}/main.sqlite3")
145+
path = config.database_path_for(tenant)
146+
FileUtils.mkdir_p(File.dirname(path))
147+
FileUtils.touch(path)
97148
end
98149
end
99150

100151
assert_equal(Set.new(config.tenants), Set.new([ "foo", "bar", "baz" ]))
101152
end
153+
154+
test "parallel test worker returns all tenants" do
155+
config.test_worker_id = 99
156+
157+
Dir.chdir(dir) do
158+
[ "foo", "bar", "baz" ].each do |tenant|
159+
path = config.database_path_for(tenant)
160+
FileUtils.mkdir_p(File.dirname(path))
161+
FileUtils.touch(path)
162+
end
163+
164+
assert_equal(Set.new(config.tenants), Set.new([ "foo", "bar", "baz" ]))
165+
end
166+
end
102167
end
103168

104169
describe "relative URI" do
@@ -110,11 +175,33 @@
110175
assert_equal("storage/db/tenanted/foo/main.sqlite3", config.database_path_for("foo"))
111176
end
112177

178+
test "parallel test workers have unique files" do
179+
config.test_worker_id = 99
180+
181+
assert_equal("file:storage/db/tenanted/foo/main.sqlite3_99", config.database_for("foo"))
182+
assert_equal("storage/db/tenanted/foo/main.sqlite3_99", config.database_path_for("foo"))
183+
end
184+
113185
test "returns all tenants" do
114186
Dir.chdir(dir) do
115187
[ "foo", "bar", "baz" ].each do |tenant|
116-
FileUtils.mkdir_p("storage/db/tenanted/#{tenant}")
117-
FileUtils.touch("storage/db/tenanted/#{tenant}/main.sqlite3")
188+
path = config.database_path_for(tenant)
189+
FileUtils.mkdir_p(File.dirname(path))
190+
FileUtils.touch(path)
191+
end
192+
193+
assert_equal(Set.new(config.tenants), Set.new([ "foo", "bar", "baz" ]))
194+
end
195+
end
196+
197+
test "parallel test worker returns all tenants" do
198+
config.test_worker_id = 99
199+
200+
Dir.chdir(dir) do
201+
[ "foo", "bar", "baz" ].each do |tenant|
202+
path = config.database_path_for(tenant)
203+
FileUtils.mkdir_p(File.dirname(path))
204+
FileUtils.touch(path)
118205
end
119206

120207
assert_equal(Set.new(config.tenants), Set.new([ "foo", "bar", "baz" ]))
@@ -131,6 +218,13 @@
131218
assert_equal("storage/db/tenanted/foo/main.sqlite3", config.database_path_for("foo"))
132219
end
133220

221+
test "parallel test workers have unique files" do
222+
config.test_worker_id = 99
223+
224+
assert_equal("file:storage/db/tenanted/foo/main.sqlite3_99?vfs=unix-dotfile", config.database_for("foo"))
225+
assert_equal("storage/db/tenanted/foo/main.sqlite3_99", config.database_path_for("foo"))
226+
end
227+
134228
test "returns all tenants" do
135229
Dir.chdir(dir) do
136230
[ "foo", "bar", "baz" ].each do |tenant|
@@ -141,6 +235,20 @@
141235
assert_equal(Set.new(config.tenants), Set.new([ "foo", "bar", "baz" ]))
142236
end
143237
end
238+
239+
test "parallel test worker returns all tenants" do
240+
config.test_worker_id = 99
241+
242+
Dir.chdir(dir) do
243+
[ "foo", "bar", "baz" ].each do |tenant|
244+
path = config.database_path_for(tenant)
245+
FileUtils.mkdir_p(File.dirname(path))
246+
FileUtils.touch(path)
247+
end
248+
249+
assert_equal(Set.new(config.tenants), Set.new([ "foo", "bar", "baz" ]))
250+
end
251+
end
144252
end
145253
end
146254

test/unit/tenant_test.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -620,7 +620,7 @@
620620
log = capture_log do
621621
TenantedApplicationRecord.destroy_tenant("foo")
622622
end
623-
assert_includes(log.string, "destroying tenant database")
623+
assert_includes(log.string, "Destroying tenant database")
624624
assert_includes(log.string, "DESTROY [tenant=foo]")
625625
end
626626

0 commit comments

Comments
 (0)