constable-rails 2.0.0 → 2.1.0

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: 4ee7e9d9303ba3b3e4977f529ab515059faa946e96209dae4e80dbef42d2930c
4
- data.tar.gz: feadad150285bf9d68b11cc671b7c1bc13188c39a0b6c76317aef1a7a6ca9c70
3
+ metadata.gz: 64e582fc3aab8b2c70309a18f5807cc1f00468ba33afa6dd08f4c78f6abde71d
4
+ data.tar.gz: b34caa421b4c301f5027e9d167c3e6a3ace738ef6668d5a5f81b2cf6711795b2
5
5
  SHA512:
6
- metadata.gz: 71adc2fab90c1ce4b1239159f7000c2b2200dc7f6becc3d365f6c935dc1702a9c012557b4602c909de880f2728433cfdfc9b4bd1524b8ec25581459c4a4f415a
7
- data.tar.gz: 70fd3761fed31d90e365e1b24940025ddd14dc6f80f0761d004cdfe6c69145b4f1806f4fe49753a61af4137677cc2313c9a3f7cd0ce2f284e121ada1e75d0fdf
6
+ metadata.gz: efd72821dbfff16019e0eeda25eae916753ea5e708d9addec79534002b84693f8d8e0498602b5c20f6793fbd92c5ed57e68dcef9a368026b8e54c5fd567d5af4
7
+ data.tar.gz: bcae479351ac5a001dec12c0a5e81d337b373998bbd82d57c8588e91db47e94fda99c88abc8c581b860625cc91c44e1e69e38693fca6eb7d1671ee84bc984e1e
data/CHANGELOG.md CHANGED
@@ -5,6 +5,97 @@ All notable changes to this project are documented here. This project adheres to
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [2.1.0]
9
+
10
+ Both changes here come from one afternoon on a real suite: nineteen files, eight forked
11
+ workers, and a run that reported 83 passed and 1 failed on a unique index for a row no
12
+ test had made. The failing test was not the broken one. Nothing was wrong with it.
13
+
14
+ ### A helper named in `.rspec` that will not load is now fatal
15
+
16
+ It used to be a warning, and the cold cases ran anyway. The reasoning written in the
17
+ source was that Constable's job is not to disappear. That was wrong, and it is worth being
18
+ precise about why, because the failure it produced is the one this project exists to
19
+ prevent.
20
+
21
+ `rails_helper` is not a convenience. It is where a suite registers DatabaseCleaner's
22
+ per-test transaction, FactoryBot, WebMock, and every `spec/support` hook. A worker that
23
+ fails to finish loading it does not run a slightly degraded suite — it runs an
24
+ **unisolated** one, against a real database, with nothing wrapping any test. The writes
25
+ commit. Some tests pass anyway. Later files die on unique indexes naming rows nobody can
26
+ account for, in a different file, with a stack trace pointing at innocent code.
27
+
28
+ What produced it: eight workers loading a `spec/support` file that did
29
+
30
+ ```ruby
31
+ Dir.mkdir(cache_directory) unless File.directory?(cache_directory)
32
+ ```
33
+
34
+ Two workers check, both see nothing, both create, and the loser raises `Errno::EEXIST` —
35
+ while `rails_helper` is loading. Six workers were isolated. Two were not, and left
36
+ committed rows behind in their databases.
37
+
38
+ A green run that was never isolated is worse than no run at all, so this now raises:
39
+
40
+ ```
41
+ could not load what .rspec requires (Errno::EEXIST: File exists @ dir_s_mkdir - tmp/browser_cache_all).
42
+ Those files set up the suite -- database cleaning, factories, spec/support hooks.
43
+ Running cold cases without them would not isolate them, so this is fatal rather than a warning.
44
+ ```
45
+
46
+ This is a behaviour change, not a new check: the condition was always detected. If a run
47
+ starts failing here, it was already producing results that did not mean what they said.
48
+
49
+ ### Workers are told which worker they are
50
+
51
+ `CONSTABLE_WORKER` (the index) and `CONSTABLE_WORKERS` (the count) are set in each forked
52
+ worker, and unset in the parent.
53
+
54
+ Anything a suite keeps on disk per process needs a name that differs per worker — a
55
+ browser cache, a download directory, a screenshot path, a scratch file — and until now
56
+ there was no way to ask, so every worker computed the same path and raced for it. That is
57
+ the root cause of the bug above rather than an unrelated nicety.
58
+
59
+ ```ruby
60
+ worker = ENV["CONSTABLE_WORKER"] ? "_w#{ENV['CONSTABLE_WORKER']}" : ""
61
+ cache = Rails.root.join("tmp/browser_cache#{worker}")
62
+ ```
63
+
64
+ Deliberately not `TEST_ENV_NUMBER` or `TEST_SUBCATEGORY`: both are already interpolated
65
+ into some apps' `database.yml`, and setting either here would rename databases behind
66
+ `WorkerDatabases`' back.
67
+
68
+ ### A stale worker database drops the run to serial instead of running wrong
69
+
70
+ `worker_databases: reuse` keeps the per-worker databases between runs, which is the point
71
+ of it — and it means they do not follow migrations on their own. Run a migration, run the
72
+ suite, and every worker is testing yesterday's schema.
73
+
74
+ That does not fail cleanly. It fails as a missing column in whichever file happened to
75
+ land on a stale worker, three files from anything you changed, on a different file each
76
+ run because the scheduling moved. Constable now compares what each worker database has
77
+ migrated against the real test database before it forks, and when they disagree:
78
+
79
+ ```
80
+ ⚠ worker database 3 has not run the migrations the test database has. `worker_databases:
81
+ reuse` keeps these between runs, which means they do not follow a migration on their
82
+ own. Running serially instead -- `constable prepare` rebuilds them.
83
+ ```
84
+
85
+ Serial rather than a refusal, deliberately: the database a serial run uses is the real
86
+ test database, and that one *is* current. The suite still runs, correctly, and says what
87
+ to do to get its speed back. Two integers per database, in the parent, before anything
88
+ forks; a question that cannot be answered (no `schema_migrations`, an adapter that will
89
+ not connect) never blocks a run that would have worked.
90
+
91
+ ### Tests
92
+
93
+ The fork path had no end-to-end coverage at all, which is where most of this project's
94
+ serious bugs have lived. It has some now: worker identity arriving in the child and not
95
+ leaking into the parent, and both halves of `.rspec` handling — a helper that raises stops
96
+ the run, and a helper that loads is actually loaded before the file.
97
+
98
+
8
99
  ## [2.0.0]
9
100
 
10
101
  A tidy-up release. The API you write — `investigate`, `witness`, `briefing`, `docket`,
data/README.md CHANGED
@@ -414,12 +414,31 @@ Each worker gets **its own database**, built from schema the way `rails test` do
414
414
  or kept between runs, with `worker_databases: reuse`, which is both faster and the only
415
415
  thing that works for an app whose schema cannot rebuild the database by itself (any app
416
416
  with Postgres custom types: `CREATE TYPE` has no `schema.rb` representation). Prepare
417
- those once with `constable prepare`.
417
+ those once with `constable prepare`, and again after a migration — kept databases do not
418
+ follow one on their own. Forgetting is caught rather than suffered: Constable compares
419
+ what each worker database has migrated against the real test database before it forks, and
420
+ runs serially (which uses the real one, so it is correct) rather than testing yesterday's
421
+ schema.
418
422
  Sharing one would not be a speed/safety trade but a correctness bug: on SQLite the run
419
423
  dissolves into `database is locked`, and on a client/server database tests quietly see
420
424
  each other's rows. If your app has ActiveRecord but cannot shard, Constable runs serially
421
425
  and says why — slow is a trade-off, wrong is not.
422
426
 
427
+ The database is not the only thing a worker needs to itself. Anything your suite keeps on
428
+ disk per process — a browser cache, a download directory, a screenshot path — needs a name
429
+ that differs per worker, or they race for it. Each worker is told which one it is:
430
+
431
+ ```ruby
432
+ worker = ENV["CONSTABLE_WORKER"] ? "_w#{ENV['CONSTABLE_WORKER']}" : ""
433
+ cache = Rails.root.join("tmp/browser_cache#{worker}")
434
+ ```
435
+
436
+ `CONSTABLE_WORKER` is the index and `CONSTABLE_WORKERS` the count; both are unset in the
437
+ parent, so serial runs keep whatever name they had. Use `FileUtils.mkdir_p` rather than
438
+ `Dir.mkdir ... unless File.directory?` while you are there — the second is a race, and if
439
+ it runs inside `spec/support` it takes `rails_helper` down with it, which costs the loser
440
+ its database cleaning rather than just its cache directory.
441
+
423
442
  ### Output
424
443
 
425
444
  stdout is reserved for results — not just Constable's own output, but the app's. Rails
@@ -324,10 +324,31 @@ module Constable
324
324
  rescue Constable::Error
325
325
  raise
326
326
  rescue StandardError => e
327
- # A helper that will not load is the suite's problem to fix, and it will say so
328
- # loudly on the first file. Constable's job here is not to disappear.
329
- Constable.warn!("could not load what .rspec requires (#{e.class}: #{e.message}). " \
330
- "Cold cases will run without it.", kind: :cold_case)
327
+ # Fatal, deliberately.
328
+ #
329
+ # This used to be a warning that ran the cold cases anyway, on the reasoning that
330
+ # Constable's job is not to disappear. That reasoning was wrong, and the way it
331
+ # was wrong is the exact failure this gem exists to prevent.
332
+ #
333
+ # A `rails_helper` that does not finish loading is not a suite missing one
334
+ # convenience. It is a suite missing its isolation: DatabaseCleaner's per-test
335
+ # transaction, FactoryBot, WebMock, every `spec/support` hook. The tests then run
336
+ # against a real database with nothing wrapping them, so their writes commit.
337
+ # Some pass. Others fail later on a unique index, in a different file, naming a
338
+ # row that a test three files ago was supposed to have rolled back.
339
+ #
340
+ # Observed exactly that: eight forked workers loading a helper that did
341
+ # `Dir.mkdir` on a shared tmp directory it had just checked for. The workers that
342
+ # lost the race raised Errno::EEXIST, took this branch, ran their files with no
343
+ # DatabaseCleaner at all, and left committed rows behind in their databases.
344
+ #
345
+ # A green run that was never isolated is worse than no run. Refuse it.
346
+ raise Constable::Error,
347
+ "could not load what .rspec requires (#{e.class}: #{e.message}).\n " \
348
+ "Those files set up the suite -- database cleaning, factories, " \
349
+ "spec/support hooks.\n " \
350
+ "Running cold cases without them would not isolate them, so this is " \
351
+ "fatal rather than a warning."
331
352
  end
332
353
 
333
354
  # What `configuration.requires=` does before requiring anything: puts `lib` and the
@@ -289,7 +289,7 @@ module Constable
289
289
  return [] if items.empty?
290
290
 
291
291
  count = worker_count(items)
292
- if count > 1 && forkable? && parallel_safe?
292
+ if count > 1 && forkable? && parallel_safe?(count)
293
293
  run_parallel(items, count)
294
294
  else
295
295
  run_serial(items)
@@ -302,17 +302,45 @@ module Constable
302
302
  # which is worse. An app with no ActiveRecord has nothing to shard and is always safe.
303
303
  #
304
304
  # When we cannot shard, we run serially and say why. Slow is a trade-off; wrong is not.
305
- def parallel_safe?
305
+ def parallel_safe?(count)
306
306
  # An explicit opt-out. No attempt, and no warning about one -- the user has already
307
307
  # told us they know.
308
308
  return false if @config.worker_databases == :off
309
309
  return true unless WorkerDatabases.active_record?
310
- return true if WorkerDatabases.shardable?
310
+
311
+ unless WorkerDatabases.shardable?
312
+ Constable.warn!(
313
+ "parallel workers need one database per worker, and this app's ActiveRecord " \
314
+ "cannot provide them (active_record/test_databases did not load). Running " \
315
+ "serially instead -- pass --workers N once that is available.",
316
+ kind: :parallel
317
+ )
318
+ return false
319
+ end
320
+
321
+ worker_databases_current?(count)
322
+ end
323
+
324
+ # `:reuse` keeps the per-worker databases between runs, so they do not follow
325
+ # migrations by themselves. A run against stale copies does not fail cleanly -- it
326
+ # fails as a missing column in whichever file happened to touch it, on a different
327
+ # worker each run.
328
+ #
329
+ # Serial is the right fallback rather than a refusal, because the database a serial
330
+ # run uses is the real test database, and that one *is* current. So the suite still
331
+ # runs, correctly, and says exactly what to do to get its speed back.
332
+ def worker_databases_current?(count)
333
+ return true unless @config.worker_databases.to_s == "reuse"
334
+
335
+ stale = WorkerDatabases.stale_workers(count)
336
+ return true if stale.empty?
311
337
 
312
338
  Constable.warn!(
313
- "parallel workers need one database per worker, and this app's ActiveRecord " \
314
- "cannot provide them (active_record/test_databases did not load). Running " \
315
- "serially instead -- pass --workers N once that is available.",
339
+ "worker database#{"s" if stale.length > 1} #{stale.join(", ")} " \
340
+ "#{stale.length > 1 ? "have" : "has"} not run the migrations the test database " \
341
+ "has. `worker_databases: reuse` keeps these between runs, which means they do " \
342
+ "not follow a migration on their own. Running serially instead -- " \
343
+ "`constable prepare` rebuilds them.",
316
344
  kind: :parallel
317
345
  )
318
346
  false
@@ -359,6 +387,22 @@ module Constable
359
387
  pid = fork do
360
388
  reader.close
361
389
 
390
+ # Tell the app which worker it is.
391
+ #
392
+ # Anything a suite keeps on disk per process needs this: a browser cache, a
393
+ # download directory, a screenshot path, a scratch file. Without it every worker
394
+ # computes the same path and they race -- and the way that surfaces is not a
395
+ # tidy error. Observed on a real suite: eight workers running
396
+ # `Dir.mkdir(dir) unless File.directory?(dir)` in a spec/support file, the losers
397
+ # raising Errno::EEXIST *while loading rails_helper*, so those workers ran their
398
+ # files with no database cleaning at all.
399
+ #
400
+ # Deliberately its own name rather than TEST_ENV_NUMBER or parallel_tests'
401
+ # TEST_SUBCATEGORY: those are already wired into some apps' database.yml, and
402
+ # setting one here would rename databases behind WorkerDatabases' back.
403
+ ENV["CONSTABLE_WORKER"] = worker_index.to_s
404
+ ENV["CONSTABLE_WORKERS"] = buckets.length.to_s
405
+
362
406
  # Before a single test runs: build this worker's own database and point the
363
407
  # process at it. Never falls back to the shared one -- that is the bug this
364
408
  # exists to prevent -- but the failure is reported home rather than raised.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Constable
4
- VERSION = "2.0.0"
4
+ VERSION = "2.1.0"
5
5
  end
@@ -93,6 +93,84 @@ module Constable
93
93
  raise Constable::Error, "could not prepare worker #{index}: #{e.class}: #{e.message}"
94
94
  end
95
95
 
96
+ # The one mistake `:reuse` invites, caught before the fork rather than after.
97
+ #
98
+ # `:reuse` keeps the per-worker databases between runs, which is the whole point --
99
+ # and it means they do not follow migrations by themselves. Run a migration, run the
100
+ # suite, and every worker is now testing yesterday's schema. That does not fail
101
+ # cleanly: it fails as a missing column in whichever file happened to touch it, three
102
+ # files away from anything you changed, differently on each run because the file went
103
+ # to a different worker. Exactly the shape of bug that costs an afternoon.
104
+ #
105
+ # So compare what each worker database has migrated against what the real test
106
+ # database has, and refuse rather than guess. Two integers per database, in the parent,
107
+ # before anything forks.
108
+ #
109
+ # Returns the worker indexes that are out of date, empty when they are all current or
110
+ # when the question cannot be answered (no schema_migrations table, an adapter that
111
+ # will not connect) -- an unanswerable check must not block a run that would have
112
+ # worked.
113
+ def stale_workers(count)
114
+ return [] unless shardable?
115
+
116
+ expected = {}
117
+ original = database_names
118
+ begin
119
+ each_source_config do |db_config|
120
+ fingerprint = schema_fingerprint(db_config)
121
+ expected[db_config.database.to_s] = fingerprint if fingerprint
122
+ end
123
+ return [] if expected.empty?
124
+
125
+ stale = []
126
+ (0...count).each do |index|
127
+ names = database_names
128
+ begin
129
+ each_worker_config(index) do |db_config|
130
+ source = db_config.database.to_s.sub(/_#{index}\z/, "")
131
+ next unless expected.key?(source)
132
+
133
+ actual = schema_fingerprint(db_config)
134
+ stale << index if actual && actual != expected[source]
135
+ end
136
+ ensure
137
+ restore_database_names(names)
138
+ end
139
+ end
140
+ stale.uniq
141
+ ensure
142
+ restore_database_names(original)
143
+ ::ActiveRecord::Base.establish_connection
144
+ end
145
+ rescue StandardError
146
+ []
147
+ end
148
+
149
+ # What a database has migrated: how many migrations it has run and the latest one.
150
+ # Cheaper than diffing every version, and a worker that missed a migration differs in
151
+ # both. nil when the question does not apply.
152
+ def schema_fingerprint(db_config)
153
+ ::ActiveRecord::Base.establish_connection(db_config)
154
+ connection = ::ActiveRecord::Base.connection
155
+ return nil unless connection.table_exists?("schema_migrations")
156
+
157
+ connection.select_rows("SELECT COUNT(*), MAX(version) FROM schema_migrations").first
158
+ rescue StandardError
159
+ nil
160
+ end
161
+
162
+ # The same configs `each_worker_config` renames, left under their real names.
163
+ def each_source_config
164
+ ::ActiveRecord::Base.configurations
165
+ .configs_for(env_name: env_name, include_hidden: true)
166
+ .each do |db_config|
167
+ next unless db_config.database_tasks?
168
+ next unless shardable_adapter?(db_config)
169
+
170
+ yield db_config
171
+ end
172
+ end
173
+
96
174
  def database_names
97
175
  ::ActiveRecord::Base.configurations
98
176
  .configs_for(env_name: env_name, include_hidden: true)
@@ -34,8 +34,8 @@ abort("The Rails environment is running in production mode!") if Rails.env.produ
34
34
  if defined?(ActiveRecord::Migration)
35
35
  begin
36
36
  ActiveRecord::Migration.maintain_test_schema!
37
- rescue ActiveRecord::PendingMigrationError => e
38
- abort("#{e.message}\n\nRun: bin/rails db:migrate RAILS_ENV=test")
37
+ rescue ActiveRecord::PendingMigrationError => error
38
+ abort("#{error.message}\n\nRun: bin/rails db:migrate RAILS_ENV=test")
39
39
  end
40
40
  end
41
41
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: constable-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.0
4
+ version: 2.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ray Hughes