constable-rails 1.1.0 → 1.3.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: c4612ebf00dac193c1557bbd887beccfbd7e7864f157dd88a77386e476897a6e
4
- data.tar.gz: f208f84658349658dccef504a21f6a62833d27b9b772d1d91ef5f26216b60ae5
3
+ metadata.gz: '092c12ca7debd9d093f51e3ce6d90332aef25dcb9c16a5cf79b67f716e15a6a5'
4
+ data.tar.gz: 0b82a9db3e6d66228103161d46635e394591223956aa67a4bc5f929075701954
5
5
  SHA512:
6
- metadata.gz: 296f8609681632e8427d481ecc94a15773b581c90dd7a88fa28c66eb584cd492c1706eec3faebc56aec385709ff0e72320db299c3901cec4659b333d8b94f97a
7
- data.tar.gz: aeb4696fc4029685e7ffcab4c4b7d3e0287b6a153b86f6c331864353e56546d6b9a9eb0aef640a59dce748ed08e27f6f5e80251b7e521143f1490ed18356d67f
6
+ metadata.gz: 9bbf3170f8fd728513e0384d36477d4281535276446c86d8e97b8d49b32d956101d1fe4fe30d19688b9a37f36d8159f8ee3061ebc1b2801a6ad55f718d721d27
7
+ data.tar.gz: 300f8c3ecbfb7075492638c355aceb83fc242808e8296ec21798bea71e23bee98ec6b28ffeaad84a11206fd0e6e65b9771c56a441f4e1d12cc730a969a4340ba
data/CHANGELOG.md CHANGED
@@ -5,6 +5,110 @@ All notable changes to this project are documented here. This project adheres to
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.3.0]
9
+
10
+ Adoption at scale. Everything here came from installing 1.2.0 into a 1,277-spec-file
11
+ Postgres app.
12
+
13
+ ### `worker_databases: reuse`
14
+
15
+ Per-worker databases have been rebuilt from schema on every run since 1.0.0, which is
16
+ what Rails does for `rails test`. It is correct by construction — no drift is possible —
17
+ and it is useless for an app whose schema **cannot** rebuild the database by itself. Any
18
+ app with Postgres custom types is in that position: `CREATE TYPE` has no `schema.rb`
19
+ representation, so a from-scratch load fails on a schema that references a type it never
20
+ defines.
21
+
22
+ ```yaml
23
+ worker_databases: schema # schema (default) | reuse | off
24
+ ```
25
+
26
+ `reuse` connects to `<database>_<index>` when it is already there and builds it from
27
+ schema only when it is not — checking each database separately, which matters for a
28
+ multi-database app where one may be prepared and another not. "Already there" means
29
+ present *and* holding tables: an empty database is not a prepared one, and running a
30
+ suite against no tables is the worst available outcome.
31
+
32
+ It is also just faster for everyone. A 3,000-line schema is no longer reloaded once per
33
+ worker per run.
34
+
35
+ `off` skips sharding entirely — an explicit serial run, no attempt and no warning.
36
+
37
+ New command, for the one-time setup `reuse` needs:
38
+
39
+ ```console
40
+ $ constable prepare # build <database>_0 .. _<N-1>, once
41
+ $ constable prepare --workers 8
42
+ ```
43
+
44
+ ### Hundreds of cold cases no longer bury the summary
45
+
46
+ Every cold-case file warns, once per run, so that "12 tests not yet under native rules"
47
+ is never something the suite quietly forgets to mention. At 1,277 files that is four
48
+ thousand lines of the same sentence, and the warnings that actually need a decision — an
49
+ `unsafe` block, a jailed test — are lost inside it.
50
+
51
+ Past ten files they collapse into one line that keeps the numbers, which are the part
52
+ that is supposed to shrink:
53
+
54
+ ```
55
+ ⚠ 1277 files running as cold cases, 18432 tests — not yet under native rules.
56
+ `constable test --unsafe` runs just these.
57
+ ```
58
+
59
+ Fewer than ten are still listed individually, and nothing else is ever collapsed.
60
+
61
+
62
+ ## [1.2.0]
63
+
64
+ Two bugs found installing 1.1.0 into a large real Postgres app (~3,000-line schema,
65
+ custom types, a factory directory). Both are the same shape as the ones 1.0.0 fixed:
66
+ Constable was confidently wrong and said nothing useful about it.
67
+
68
+ ### A factory named `*_case.rb` was loaded as a test
69
+
70
+ `spec/**/*_case.rb` is a generous net, and a real app has things in it that merely share
71
+ the suffix. Caseflow has a FactoryBot factory at `spec/factories/distributed_case.rb`.
72
+ Constable loaded it as a case file, FactoryBot raised `DuplicateDefinitionError` because
73
+ the factory was already registered, and the run reported a failing test in a file that
74
+ contains no tests:
75
+
76
+ ```
77
+ ✗ spec/factories/distributed_case.rb
78
+ "could not be loaded"
79
+ FactoryBot::DuplicateDefinitionError: Factory already registered: distributed_case
80
+ ```
81
+
82
+ A filename is not evidence. A file outside the conventional `test/cases/` and
83
+ `spec/cases/` directories now has to look like a case before it is loaded — a class
84
+ declaration, or the DSL. Files under those directories are still taken at their word,
85
+ since that is what they are for and an empty one there is a case somebody is part-way
86
+ through writing.
87
+
88
+ ### An app that cannot be sharded now runs anyway
89
+
90
+ Per-worker databases (1.0.0) are built by loading `schema.rb` into `<database>_<index>`.
91
+ Not every app can do that: one with Postgres custom types, functions or triggers cannot
92
+ rebuild itself from `schema.rb` at all, which is exactly why such apps keep a
93
+ `structure.sql`. Rails' own `parallelize` fails the same way.
94
+
95
+ Constable handled it about as badly as possible. Each worker raised, printing a full
96
+ stack trace — four workers, four traces, several hundred lines — and the parent then
97
+ reported a run that had never happened:
98
+
99
+ ```
100
+ CONSTABLE 1 test · 1 case · 10.8s
101
+ ✓ 0 passed ✗ 1 failed
102
+ ```
103
+
104
+ A worker that cannot build its database now reports that home rather than raising. If no
105
+ worker got started, nothing has run yet, so the parent simply runs the suite serially and
106
+ says why in one sentence — including the real error and how to skip the attempt
107
+ (`parallel_workers: 1`). The rule from 1.0.0 is unchanged: a worker never falls back to
108
+ sharing the parent's database, because that is the corruption this whole mechanism
109
+ exists to prevent.
110
+
111
+
8
112
  ## [1.1.0]
9
113
 
10
114
  Three things that were documented and did not work, plus the command for a docket
@@ -358,7 +462,9 @@ Initial release.
358
462
  - Diff-based coverage gate — only lines changed in the current diff are held to the
359
463
  threshold. `constable beat` for the full picture, `--html` for a browsable report.
360
464
 
361
- [Unreleased]: https://github.com/Ray-Hughes/constable/compare/v1.1.0...HEAD
465
+ [Unreleased]: https://github.com/Ray-Hughes/constable/compare/v1.3.0...HEAD
466
+ [1.3.0]: https://github.com/Ray-Hughes/constable/compare/v1.2.0...v1.3.0
467
+ [1.2.0]: https://github.com/Ray-Hughes/constable/compare/v1.1.0...v1.2.0
362
468
  [1.1.0]: https://github.com/Ray-Hughes/constable/compare/v1.0.0...v1.1.0
363
469
  [1.0.0]: https://github.com/Ray-Hughes/constable/compare/v0.1.0...v1.0.0
364
470
  [0.1.0]: https://github.com/Ray-Hughes/constable/releases/tag/v0.1.0
data/README.md CHANGED
@@ -399,6 +399,7 @@ worse than one that resets.
399
399
  | `constable status` | How the suite is doing over time |
400
400
  | `constable beat [--html]` | Coverage: overall %, per-file, the unpatrolled list |
401
401
  | `constable history relink OLD NEW` | Carry history across a real body change |
402
+ | `constable prepare [--workers N]` | Build the per-worker test databases `worker_databases: reuse` needs |
402
403
  | `constable prune [--dry-run]` | Forget docket rows and warrants for tests that no longer exist |
403
404
  | `constable import --from=rspec` | Adopt an existing suite as cold cases |
404
405
  | `constable modernize PATH` | Opt-in AST rewrite into the native DSL |
@@ -409,7 +410,11 @@ Order is randomized every run for native cases, with the seed printed and replay
409
410
  `--seed`. Cold cases keep their own engine's order. Workers run in parallel by default,
410
411
  load-balanced by a cached per-test duration index.
411
412
 
412
- Each worker gets **its own database**, built from schema the way `rails test` does it.
413
+ Each worker gets **its own database**, built from schema the way `rails test` does it —
414
+ or kept between runs, with `worker_databases: reuse`, which is both faster and the only
415
+ thing that works for an app whose schema cannot rebuild the database by itself (any app
416
+ with Postgres custom types: `CREATE TYPE` has no `schema.rb` representation). Prepare
417
+ those once with `constable prepare`.
413
418
  Sharing one would not be a speed/safety trade but a correctness bug: on SQLite the run
414
419
  dissolves into `database is locked`, and on a client/server database tests quietly see
415
420
  each other's rows. If your app has ActiveRecord but cannot shard, Constable runs serially
data/lib/constable/cli.rb CHANGED
@@ -399,6 +399,40 @@ module Constable
399
399
  end
400
400
  end
401
401
 
402
+ desc "prepare", "Build the per-worker test databases parallel runs need"
403
+ long_desc <<~DESC
404
+ For `worker_databases: reuse`. Creates `<database>_0` .. `<database>_<N-1>` and
405
+ loads the schema into each, once, so later runs can connect straight to them.
406
+
407
+ Only useful for an app that has opted into reuse -- the default `schema` mode
408
+ rebuilds them on every run and needs no preparation. It is the answer for an app
409
+ whose schema cannot rebuild the database by itself: prepare these once, by whatever
410
+ means already works for you, and Constable will use them from then on.
411
+ DESC
412
+ option :workers, type: :numeric, desc: "How many to prepare (default: the configured worker count)"
413
+ def prepare
414
+ config = load_config
415
+ unless WorkerDatabases.shardable?
416
+ warn "This app has no ActiveRecord test databases to prepare."
417
+ exit(EXIT_USAGE)
418
+ end
419
+
420
+ count = (options[:workers] || config.parallel_workers).to_i.clamp(1, 64)
421
+ say "Preparing #{count} worker #{count == 1 ? "database" : "databases"}..."
422
+
423
+ count.times do |index|
424
+ built = WorkerDatabases.prepare!(index)
425
+ say " worker #{index}: #{built.empty? ? "already prepared" : "built #{built.join(", ")}"}"
426
+ end
427
+
428
+ say "\nDone. Set `worker_databases: reuse` so runs connect to these instead of " \
429
+ "rebuilding them."
430
+ 0
431
+ rescue Constable::Error => e
432
+ warn e.message
433
+ exit(EXIT_FAILED)
434
+ end
435
+
402
436
  desc "prune", "Forget docket rows and warrants for tests that no longer exist"
403
437
  long_desc <<~DESC
404
438
  A test's key is a content hash of its body, so editing a jailed test gives it a new
@@ -195,7 +195,11 @@ module Constable
195
195
  "running as a cold case (#{base_class_name}) — " \
196
196
  "#{count} #{count == 1 ? "test" : "tests"} not yet under native rules",
197
197
  location: location,
198
- kind: :cold_case
198
+ kind: :cold_case,
199
+ # Carried so the reporter can total them when there are too many to list. A
200
+ # suite mid-adoption has hundreds of these, and printing every one buries the
201
+ # things that actually need a decision.
202
+ tests: count
199
203
  )
200
204
  end
201
205
 
@@ -21,6 +21,7 @@ module Constable
21
21
  "fail_on_warnings" => false,
22
22
  "output" => "concise",
23
23
  "parallel_workers" => "auto",
24
+ "worker_databases" => "schema",
24
25
  "tiers" => {
25
26
  "unit" => "test/cases/models/**/*",
26
27
  "integration" => "test/cases/controllers/**/*",
@@ -103,6 +104,24 @@ module Constable
103
104
  # but you can see which test is hanging without waiting for the summary.
104
105
  #
105
106
  # The summary itself is identical either way. This only affects the live stream.
107
+ # How a parallel worker gets a database of its own.
108
+ #
109
+ # schema rebuild `<database>_<index>` from schema on every run. What Rails does for
110
+ # `rails test`, and correct by construction: no drift is possible.
111
+ # reuse connect to `<database>_<index>` when it already exists, and build it from
112
+ # schema only when it does not. Faster -- a large schema is not reloaded on
113
+ # every run -- and the only option that works for an app whose schema cannot
114
+ # rebuild the database by itself, which is any app with Postgres custom
115
+ # types. The cost is that keeping those databases current is now yours.
116
+ # off do not shard, so do not fork. An explicit serial run, with no attempt and
117
+ # no warning.
118
+ WORKER_DATABASE_MODES = %i[schema reuse off].freeze
119
+
120
+ def worker_databases
121
+ mode = @raw["worker_databases"].to_s.strip.downcase.to_sym
122
+ WORKER_DATABASE_MODES.include?(mode) ? mode : :schema
123
+ end
124
+
106
125
  OUTPUT_MODES = %i[concise expanded].freeze
107
126
 
108
127
  def output_mode
@@ -44,6 +44,12 @@ module Constable
44
44
  # without a limit a quiet case could hold the buffer forever.
45
45
  STREAM_FLUSH_THRESHOLD = 12
46
46
 
47
+ # How many cold-case files are listed one by one before they are summarised instead.
48
+ # A suite part-way through adoption has hundreds -- one real app had 1,277 -- and
49
+ # printing every one buries the warnings that actually need a decision under four
50
+ # thousand lines that say the same thing.
51
+ COLD_CASE_LIST_LIMIT = 10
52
+
47
53
  DEFAULT_SLOWEST = 5
48
54
 
49
55
  # Column the expanded stream right-aligns durations into. Descriptions are never
@@ -609,6 +615,7 @@ module Constable
609
615
  end
610
616
 
611
617
  def section_warnings(warnings)
618
+ warnings = collapse_cold_cases(warnings)
612
619
  return if warnings.empty?
613
620
 
614
621
  section("WARNINGS")
@@ -624,6 +631,25 @@ module Constable
624
631
  end
625
632
  end
626
633
 
634
+ # Cold cases are a fact about the suite, not a list of problems: every one says the
635
+ # same sentence about a different file. Past the limit they become one line that
636
+ # still carries the number -- which is the part that is supposed to shrink over time,
637
+ # and the reason the warning exists at all.
638
+ def collapse_cold_cases(warnings)
639
+ cold, rest = warnings.partition { |w| w[:kind] == :cold_case }
640
+ return warnings if cold.size <= COLD_CASE_LIST_LIMIT
641
+
642
+ tests = cold.sum { |w| w[:tests].to_i }
643
+ total = tests.positive? ? ", #{tests} #{pluralize(tests, "test")}" : ""
644
+
645
+ [{
646
+ kind: :cold_case,
647
+ location: nil,
648
+ message: "#{cold.size} files running as cold cases#{total} — not yet under " \
649
+ "native rules. `constable test --unsafe` runs just these."
650
+ }] + rest
651
+ end
652
+
627
653
  # A warning carries the author's own words -- an unsafe block's reason, a cold case's
628
654
  # count -- and those are easily ninety columns. Wrapped to the frame, but respecting
629
655
  # any line breaks the message already chose.
@@ -707,7 +733,11 @@ module Constable
707
733
  w = warning.to_h.transform_keys(&:to_sym)
708
734
  return nil if w[:message].nil?
709
735
 
710
- { message: w[:message].to_s, location: w[:location], kind: (w[:kind] || :unsafe).to_sym }
736
+ # `tests` rides along so cold cases can be totalled when there are too many to list.
737
+ # Normalizing is about the three fields the reporter needs, not about discarding
738
+ # everything a warning chose to carry.
739
+ { message: w[:message].to_s, location: w[:location], kind: (w[:kind] || :unsafe).to_sym,
740
+ tests: w[:tests] }.compact
711
741
  end
712
742
 
713
743
  def normalize_coverage(coverage)
@@ -283,6 +283,9 @@ module Constable
283
283
  #
284
284
  # When we cannot shard, we run serially and say why. Slow is a trade-off; wrong is not.
285
285
  def parallel_safe?
286
+ # An explicit opt-out. No attempt, and no warning about one -- the user has already
287
+ # told us they know.
288
+ return false if @config.worker_databases == :off
286
289
  return true unless WorkerDatabases.active_record?
287
290
  return true if WorkerDatabases.shardable?
288
291
 
@@ -337,9 +340,17 @@ module Constable
337
340
  reader.close
338
341
 
339
342
  # Before a single test runs: build this worker's own database and point the
340
- # process at it. Raises rather than falling back to the shared one, because a
341
- # silent fallback is the bug we are here to prevent.
342
- WorkerDatabases.after_fork!(worker_index)
343
+ # process at it. Never falls back to the shared one -- that is the bug this
344
+ # exists to prevent -- but the failure is reported home rather than raised.
345
+ # A raise here dumps a full stack trace per worker and leaves the parent
346
+ # reporting a run that never happened.
347
+ begin
348
+ WorkerDatabases.after_fork!(worker_index, mode: @config.worker_databases)
349
+ rescue Constable::Error => e
350
+ write_message(writer, :worker_error, e.message)
351
+ writer.close
352
+ exit!(0)
353
+ end
343
354
 
344
355
  bucket.each do |item|
345
356
  run_item(item).each { |result| write_message(writer, :result, result.to_h) }
@@ -365,6 +376,17 @@ module Constable
365
376
  collected = drain(readers)
366
377
  pids.each { |pid| Process.waitpid(pid) rescue nil } # rubocop:disable Style/RescueModifier
367
378
 
379
+ # No worker could build itself a database, so no test ran. Not every app can be
380
+ # sharded: an app whose schema.rb cannot rebuild the database on its own -- Postgres
381
+ # custom types, functions and triggers are the usual reason, and are exactly why
382
+ # such apps use structure.sql -- will fail here every time. Rails' own `parallelize`
383
+ # fails the same way; the difference is that this is not the user's fault and they
384
+ # should not have to read four stack traces to find that out.
385
+ #
386
+ # Nothing has run yet, so falling back to a serial run costs a restart, not
387
+ # correctness.
388
+ return run_serially_after_worker_failure(items) if collected.empty? && worker_errors.any?
389
+
368
390
  # A warning raised inside a worker only ever reached that worker's memory, so the
369
391
  # results carry them home. Nothing that bends the rules is allowed to go missing
370
392
  # just because it happened in a subprocess.
@@ -372,6 +394,26 @@ module Constable
372
394
  collected
373
395
  end
374
396
 
397
+ def worker_errors = (@worker_errors ||= [])
398
+
399
+ def run_serially_after_worker_failure(items)
400
+ reason = worker_errors.first.to_s
401
+
402
+ Constable.warn!(
403
+ "no parallel worker could build its own test database, so the suite ran serially " \
404
+ "instead. This usually means the app's schema cannot rebuild the database by " \
405
+ "itself -- Postgres custom types, functions and triggers are the common reason, " \
406
+ "and `rails test` parallelization fails the same way. Set " \
407
+ "`worker_databases: reuse` to keep prepared databases between runs instead, or " \
408
+ "`worker_databases: off` to stop trying. The first worker said: #{reason}",
409
+ kind: :parallel
410
+ )
411
+
412
+ # The blotter handle was closed before forking, and the pool was cleared. Both come
413
+ # back on their next use, so there is nothing to reopen by hand.
414
+ run_serial(items)
415
+ end
416
+
375
417
  # Every message on the pipe is tagged, because results are not the only thing a worker
376
418
  # has to send home.
377
419
  def write_message(writer, kind, body)
@@ -416,6 +458,10 @@ module Constable
416
458
  @reporter.record(result)
417
459
  when :coverage
418
460
  @worker_coverage = Constable::Coverage.merge_raw(@worker_coverage, body)
461
+ when :worker_error
462
+ # A worker that could not start. Collected rather than raised, so the parent
463
+ # decides what to do once it knows whether any worker got going at all.
464
+ worker_errors << body
419
465
  end
420
466
  end
421
467
  end
@@ -17,6 +17,22 @@ module Constable
17
17
  def to_s = line ? "#{path}:#{line}" : path.to_s
18
18
  end
19
19
 
20
+ # A filename is not evidence. `spec/**/*_case.rb` is a generous net, and in a real app
21
+ # it catches things that merely share the suffix -- a FactoryBot factory named
22
+ # spec/factories/distributed_case.rb, say. Loading one of those runs somebody's code
23
+ # twice and reports the resulting explosion as a failing test in a file that contains
24
+ # no tests.
25
+ #
26
+ # So the file has to look like a case before we load it: a class declaration, or the
27
+ # DSL. Deliberately a cheap read rather than a parse -- this runs over every candidate
28
+ # on every run, and anything a parse would catch that this misses would also have to
29
+ # be a file that defines a case without mentioning one.
30
+ NATIVE_MARKERS = /
31
+ <\s*(?:Constable::Case|\w*Case)\b # class FooCase < UnitCase
32
+ | ^\s*investigate\s*[("] # or the DSL, for a reopened class
33
+ | ^\s*tier\s+:
34
+ /x
35
+
20
36
  NATIVE_GLOBS = [
21
37
  "test/cases/**/*.rb",
22
38
  "spec/cases/**/*.rb",
@@ -183,6 +199,17 @@ module Constable
183
199
 
184
200
  def native_files
185
201
  @native_files ||= glob(NATIVE_GLOBS).reject { |f| @config.cold_case?(f) }
202
+ .select { |f| native_by_content?(f) }
203
+ end
204
+
205
+ def native_by_content?(path)
206
+ # Files under the conventional case directories are taken at their word: that is
207
+ # what the directory is for, and an empty one there is a case file being written.
208
+ return true if path.match?(%r{/(?:test|spec)/cases/})
209
+
210
+ File.read(path).match?(NATIVE_MARKERS)
211
+ rescue StandardError
212
+ false
186
213
  end
187
214
 
188
215
  def cold_files
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Constable
4
- VERSION = "1.1.0"
4
+ VERSION = "1.3.0"
5
5
  end
@@ -46,24 +46,115 @@ module Constable
46
46
  false
47
47
  end
48
48
 
49
- # Child side, immediately after the fork and before any test runs. Builds
50
- # `<database>_<index>` from schema and points this process at it.
49
+ # Child side, immediately after the fork and before any test runs. Gives this process
50
+ # a database of its own, named `<database>_<index>`.
51
51
  #
52
- # ENV["VERBOSE"] is silenced the way Rails silences it: schema loading is chatty, and
53
- # stdout belongs to the reporter.
54
- def after_fork!(index)
52
+ # `mode` is the `worker_databases` setting:
53
+ #
54
+ # :schema rebuild it from schema every run, which is what Rails does. Correct by
55
+ # construction -- no drift is possible -- and the right default.
56
+ # :reuse connect to it when it is already there, build it from schema when it is
57
+ # not. Skips reloading a large schema on every run, and is the only thing
58
+ # that works when the schema cannot rebuild the database by itself.
59
+ def after_fork!(index, mode: :schema)
55
60
  return false unless shardable?
61
+ return reuse!(index) if mode.to_sym == :reuse
56
62
 
57
63
  ::ActiveRecord::TestDatabases.create_and_load_schema(index, env_name: env_name)
58
64
  true
59
65
  rescue StandardError => e
60
- # A worker that cannot build its own database would otherwise silently fall back to
61
- # sharing the parent's, which is the bug this module exists to prevent. Say so, and
62
- # let the failure be a real one.
66
+ # A worker that cannot build its own database must never fall back to sharing the
67
+ # parent's -- that is the corruption this module exists to prevent. The runner turns
68
+ # this into a serial run rather than letting it kill the process.
63
69
  raise Constable::Error, "worker #{index} could not create its own test database: " \
64
70
  "#{e.class}: #{e.message}"
65
71
  end
66
72
 
73
+ # `constable prepare`. Builds worker `index`'s databases from the parent process --
74
+ # the same work :reuse would do lazily inside a fork, done once, deliberately, where
75
+ # the output is visible and a failure is not four stack traces at once.
76
+ def prepare!(index)
77
+ raise Constable::Error, "this app has no ActiveRecord databases to prepare" unless shardable?
78
+
79
+ # Renaming is destructive, and here it happens in the parent rather than in a fork
80
+ # that is about to die. Without restoring the names afterwards this command would
81
+ # leave the process -- and, worse, anything else in it -- pointed at
82
+ # `<database>_<index>` instead of the real test database.
83
+ original = database_names
84
+ begin
85
+ reuse!(index)
86
+ ensure
87
+ restore_database_names(original)
88
+ ::ActiveRecord::Base.establish_connection
89
+ end
90
+ rescue Constable::Error
91
+ raise
92
+ rescue StandardError => e
93
+ raise Constable::Error, "could not prepare worker #{index}: #{e.class}: #{e.message}"
94
+ end
95
+
96
+ def database_names
97
+ ::ActiveRecord::Base.configurations
98
+ .configs_for(env_name: env_name, include_hidden: true)
99
+ .map(&:database)
100
+ end
101
+
102
+ def restore_database_names(names)
103
+ ::ActiveRecord::Base.configurations
104
+ .configs_for(env_name: env_name, include_hidden: true)
105
+ .zip(names).each { |config, name| config._database = name if name }
106
+ end
107
+
108
+ # The :reuse half. Points every database this environment declares at its `_<index>`
109
+ # sibling, and only builds the ones that are not there yet.
110
+ #
111
+ # "There" means present *and* populated: an empty database is not a prepared one, and
112
+ # connecting to it would hand the worker a suite with no tables. Deciding that per
113
+ # database rather than per worker matters for a multi-database app -- Caseflow has a
114
+ # primary and an ETL database -- where one may be prepared and the other not.
115
+ #
116
+ # Keeping these current is the user's job once they opt in, which is the trade the
117
+ # setting exists to let them make.
118
+ def reuse!(index)
119
+ built = []
120
+
121
+ each_worker_config(index) do |db_config|
122
+ next if populated?(db_config)
123
+
124
+ ::ActiveRecord::Tasks::DatabaseTasks.reconstruct_from_schema(db_config, nil)
125
+ built << db_config.database
126
+ end
127
+
128
+ built
129
+ ensure
130
+ # Rails does this after its own schema load: the pool has to be re-established
131
+ # against the renamed configuration before any test asks for a connection.
132
+ ::ActiveRecord::Base.establish_connection
133
+ end
134
+
135
+ # Every database config for this environment, renamed to its per-worker sibling.
136
+ # `_database=` is exactly how Rails' own TestDatabases does the renaming, and this
137
+ # runs in a forked child, so the mutation dies with the worker.
138
+ def each_worker_config(index)
139
+ configs = ::ActiveRecord::Base.configurations.configs_for(env_name: env_name,
140
+ include_hidden: true)
141
+ configs.each do |db_config|
142
+ db_config._database = "#{db_config.database}_#{index}"
143
+ next unless db_config.database_tasks?
144
+
145
+ yield db_config
146
+ end
147
+ end
148
+
149
+ # Present and holding tables. A database that exists but is empty is not prepared, and
150
+ # silently running a suite against no tables is the worst of the available outcomes.
151
+ def populated?(db_config)
152
+ ::ActiveRecord::Base.establish_connection(db_config)
153
+ ::ActiveRecord::Base.connection.tables.any?
154
+ rescue StandardError
155
+ false
156
+ end
157
+
67
158
  def env_name
68
159
  if defined?(::ActiveRecord::ConnectionHandling::DEFAULT_ENV)
69
160
  ::ActiveRecord::ConnectionHandling::DEFAULT_ENV.call
data/lib/constable.rb CHANGED
@@ -97,8 +97,8 @@ module Constable
97
97
 
98
98
  # Warnings are never silent and never fatal by default. They accumulate through a run
99
99
  # and always get their own section in the summary.
100
- def warn!(message, location: nil, kind: :unsafe)
101
- warnings << { message: message, location: location, kind: kind }
100
+ def warn!(message, location: nil, kind: :unsafe, **extra)
101
+ warnings << { message: message, location: location, kind: kind, **extra }
102
102
  end
103
103
 
104
104
  def warnings
@@ -149,7 +149,7 @@ module Constable
149
149
  SETTINGS = %i[
150
150
  cold_cases warrants warrant_retries auto_relink parole_period
151
151
  coverage coverage_threshold coverage_html fail_on_warnings parallel_workers
152
- output tiers
152
+ worker_databases output tiers
153
153
  ].freeze
154
154
 
155
155
  # `storage` is the one setting that cannot live here, and the reason is ordering, not
@@ -156,6 +156,7 @@ end
156
156
  #
157
157
  # c.cold_cases = [] # RSpec/Minitest globs to run as cold cases
158
158
  # c.parallel_workers = "auto" # or an integer. Each worker gets its own database
159
+ # c.worker_databases = :schema # or :reuse (keep them between runs) / :off
159
160
  # c.output = :concise # or :expanded -- a line per test, with timings
160
161
  # c.fail_on_warnings = false # CI: fail when the warning count is not trending down
161
162
  # c.warrants = false # rerun a failure in isolation before believing it
@@ -73,6 +73,21 @@ fail_on_warnings: false # CI: fail the build when the warning count doesn't tre
73
73
 
74
74
  parallel_workers: auto # or an explicit integer
75
75
 
76
+ # How a parallel worker gets a database of its own. Sharing one is not a speed/safety
77
+ # trade but a correctness bug: on SQLite the run dissolves into "database is locked", and
78
+ # on a client/server database tests quietly see each other's rows.
79
+ #
80
+ # schema rebuild <database>_<index> from schema on every run. What Rails does for
81
+ # `rails test`, and correct by construction -- no drift is possible.
82
+ # reuse connect to <database>_<index> when it is already there, and build it from
83
+ # schema only when it is not. Faster, because a large schema is not reloaded
84
+ # every run -- and the only option that works at all when the schema cannot
85
+ # rebuild the database by itself, which is true of any app with Postgres custom
86
+ # types (`CREATE TYPE` has no schema.rb representation, so a from-scratch load
87
+ # fails). Keeping those databases current becomes your job.
88
+ # off do not shard, so do not fork. An explicit serial run: no attempt, no warning.
89
+ worker_databases: schema # schema | reuse | off
90
+
76
91
  # How much the live stream says while the suite is running. The summary is identical
77
92
  # either way -- this only changes what you watch on the way there.
78
93
  #
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: 1.1.0
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ray Hughes