constable-rails 1.2.0 → 1.3.1

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: 65a31ecd5db11b141e18762227af70518749d77042d45fad1626cc1713e06289
4
- data.tar.gz: 6fe6e92175d4d306e47626a4d66e6e1ed029817c80af644f6c70f88f4c15c9df
3
+ metadata.gz: e3b589fed13c38522dab7443ab1bb967bedeaec7abb34beaecd5c4f2c9a4298a
4
+ data.tar.gz: b646c7160830f81bc2c6c5af9fe60728a8c7e1f40236e594fb64a51eed04080f
5
5
  SHA512:
6
- metadata.gz: 717a3d9604ef554b4216a0fc497626b7bbff207767904c7c73d66805d70b7e3e60c932d030c728123e414823a346040817ddeb715fe84260ff46c8038bb02181
7
- data.tar.gz: 4f22e2278306e93af27767a71ae63931ea4b1752dc403ef5b33d604cb6ca8b4c136267f3c3d95f78e74bfdaa581eab2d8f343a65bc49bd8e1180a434bbf1ebeb
6
+ metadata.gz: ee0579e80cb81345601ce2c40090db63b87b1498698a77fbc8c34d0df478d36f3210295b3909bd8290835a1f245f2522952f491caca6776d7ac28981dcae7b74
7
+ data.tar.gz: aea8a0a67d5bdc8868706db9820af271ded921871da905c9e09fe83ac74a359c8c5e9ef8649d737367075f8faa9fabc32fff13156a8f2a9f6e9f69516464441a
data/CHANGELOG.md CHANGED
@@ -5,6 +5,126 @@ All notable changes to this project are documented here. This project adheres to
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.3.1]
9
+
10
+ ### Cold cases now read `.rspec`
11
+
12
+ The bug that mattered. `.rspec` is where an RSpec suite says what to load before any spec
13
+ file:
14
+
15
+ ```
16
+ --require spec_helper
17
+ --require rails_helper
18
+ ```
19
+
20
+ That is what `rspec --init` generates, and it is why a real spec file usually has no
21
+ `require` line of its own — there is nothing for it to repeat. RSpec's own runner reads
22
+ those files. Constable's cold-case driver did not.
23
+
24
+ So a cold case ran with no `rails_helper` at all: no FactoryBot, no shoulda-matchers, no
25
+ `spec/support/**`. On a 1,277-file suite that is **entirely green under
26
+ `bundle exec rspec`**, `constable test spec/models --full` reported:
27
+
28
+ ```
29
+ ✓ 774 passed ✗ 3589 failed
30
+ ```
31
+
32
+ 2,637 of them `undefined method 'create'`, the rest `belong_to`,
33
+ `validate_presence_of`, missing support constants. Every one of them Constable's fault,
34
+ and every one of them looking like the user's.
35
+
36
+ Cold cases now apply the `--require` directives from `.rspec`, `~/.rspec`, `.rspec-local`
37
+ and `SPEC_OPTS`, parsed by RSpec itself so the precedence is its own rather than a guess.
38
+ Only `--require` is taken: formatters, colour and output streams belong to Constable's
39
+ reporter, ordering is Constable's job, and a `--tag` filter meant for a different run
40
+ should not silently drop tests from this one.
41
+
42
+ The same file is now 14 passed, 0 failed — matching `bundle exec rspec` exactly.
43
+
44
+ This never showed up before because the app it was developed against wrote
45
+ `require "rails_helper"` at the top of every spec, which is the one arrangement that
46
+ hides it.
47
+
48
+ ### `constable import` says what it actually did
49
+
50
+ It reported `did reopen 1277 file(s)`. "Reopen" is this code's internal word and means
51
+ nothing to a reader, and "import" on its own suggests the files were copied somewhere —
52
+ which is the opposite of what happened. A real user read it exactly that way and asked
53
+ why their specs had not moved into `test/`.
54
+
55
+ ```
56
+ Adopted 1277 rspec files as cold cases.
57
+
58
+ Your rspec files stay exactly where they are and are not changed.
59
+ One line was added to .constable/config.yml:
60
+
61
+ cold_cases:
62
+ - spec/**/*_spec.rb
63
+
64
+ Constable runs them from there, through real RSpec, and folds
65
+ the results into its own reporting, flake history and CI gate.
66
+
67
+ Next: constable test --full run everything, cold and native
68
+ constable test --unsafe run only these
69
+ constable modernize PATH see what one file would look like
70
+ as a native case (writes nothing)
71
+ ```
72
+
73
+
74
+ ## [1.3.0]
75
+
76
+ Adoption at scale. Everything here came from installing 1.2.0 into a 1,277-spec-file
77
+ Postgres app.
78
+
79
+ ### `worker_databases: reuse`
80
+
81
+ Per-worker databases have been rebuilt from schema on every run since 1.0.0, which is
82
+ what Rails does for `rails test`. It is correct by construction — no drift is possible —
83
+ and it is useless for an app whose schema **cannot** rebuild the database by itself. Any
84
+ app with Postgres custom types is in that position: `CREATE TYPE` has no `schema.rb`
85
+ representation, so a from-scratch load fails on a schema that references a type it never
86
+ defines.
87
+
88
+ ```yaml
89
+ worker_databases: schema # schema (default) | reuse | off
90
+ ```
91
+
92
+ `reuse` connects to `<database>_<index>` when it is already there and builds it from
93
+ schema only when it is not — checking each database separately, which matters for a
94
+ multi-database app where one may be prepared and another not. "Already there" means
95
+ present *and* holding tables: an empty database is not a prepared one, and running a
96
+ suite against no tables is the worst available outcome.
97
+
98
+ It is also just faster for everyone. A 3,000-line schema is no longer reloaded once per
99
+ worker per run.
100
+
101
+ `off` skips sharding entirely — an explicit serial run, no attempt and no warning.
102
+
103
+ New command, for the one-time setup `reuse` needs:
104
+
105
+ ```console
106
+ $ constable prepare # build <database>_0 .. _<N-1>, once
107
+ $ constable prepare --workers 8
108
+ ```
109
+
110
+ ### Hundreds of cold cases no longer bury the summary
111
+
112
+ Every cold-case file warns, once per run, so that "12 tests not yet under native rules"
113
+ is never something the suite quietly forgets to mention. At 1,277 files that is four
114
+ thousand lines of the same sentence, and the warnings that actually need a decision — an
115
+ `unsafe` block, a jailed test — are lost inside it.
116
+
117
+ Past ten files they collapse into one line that keeps the numbers, which are the part
118
+ that is supposed to shrink:
119
+
120
+ ```
121
+ ⚠ 1277 files running as cold cases, 18432 tests — not yet under native rules.
122
+ `constable test --unsafe` runs just these.
123
+ ```
124
+
125
+ Fewer than ten are still listed individually, and nothing else is ever collapsed.
126
+
127
+
8
128
  ## [1.2.0]
9
129
 
10
130
  Two bugs found installing 1.1.0 into a large real Postgres app (~3,000-line schema,
@@ -408,7 +528,9 @@ Initial release.
408
528
  - Diff-based coverage gate — only lines changed in the current diff are held to the
409
529
  threshold. `constable beat` for the full picture, `--html` for a browsable report.
410
530
 
411
- [Unreleased]: https://github.com/Ray-Hughes/constable/compare/v1.2.0...HEAD
531
+ [Unreleased]: https://github.com/Ray-Hughes/constable/compare/v1.3.1...HEAD
532
+ [1.3.1]: https://github.com/Ray-Hughes/constable/compare/v1.3.0...v1.3.1
533
+ [1.3.0]: https://github.com/Ray-Hughes/constable/compare/v1.2.0...v1.3.0
412
534
  [1.2.0]: https://github.com/Ray-Hughes/constable/compare/v1.1.0...v1.2.0
413
535
  [1.1.0]: https://github.com/Ray-Hughes/constable/compare/v1.0.0...v1.1.0
414
536
  [1.0.0]: https://github.com/Ray-Hughes/constable/compare/v0.1.0...v1.0.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
@@ -168,8 +168,8 @@ module Constable
168
168
  strategy: options[:strategy].to_sym
169
169
  )
170
170
 
171
+ # The summary says this in full now, in the reader's own terms.
171
172
  say result.summary
172
- say "\nNothing was rewritten -- cold cases run through their own engine, unchanged." if result.any_changes?
173
173
  exit(EXIT_CLEAN)
174
174
  end
175
175
 
@@ -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
@@ -278,9 +278,53 @@ module Constable
278
278
  # rspec-core's own default; restated because rspec-rails turns it off and a
279
279
  # verbatim legacy file may well open with a bare `describe`.
280
280
  configuration.expose_dsl_globally = true if configuration.respond_to?(:expose_dsl_globally=)
281
+ apply_rspec_options(configuration)
281
282
  @session_prepared = true
282
283
  end
283
284
 
285
+ # `.rspec` is where a suite says what to load before any spec file.
286
+ #
287
+ # --require spec_helper
288
+ # --require rails_helper
289
+ #
290
+ # That is what `rspec --init` generates, and it is why a real spec file usually
291
+ # has no `require` line of its own -- there is nothing for it to repeat. RSpec's
292
+ # own runner reads those files; driving example groups directly does not, so
293
+ # without this a cold case runs with no rails_helper at all: no FactoryBot, no
294
+ # shoulda-matchers, no spec/support. In one real suite that was 2,637 tests
295
+ # failing on `undefined method 'create'` -- a suite that is entirely green under
296
+ # `bundle exec rspec`.
297
+ #
298
+ # Only `--require` is taken. Formatters, colour and output streams belong to
299
+ # Constable's reporter, ordering is Constable's job, and a `--tag` filter from a
300
+ # file RSpec would apply to its own run should not silently drop tests from this
301
+ # one. Requires are the part that decides whether the suite can run at all.
302
+ def apply_rspec_options(configuration)
303
+ requires = rspec_option_requires
304
+ return if requires.empty?
305
+
306
+ # `requires=` rather than plain Kernel#require: it is RSpec's own accessor, and
307
+ # it puts `lib` and the default path (`spec`) on the load path first, which is
308
+ # what makes a bare `require "rails_helper"` resolve.
309
+ configuration.requires = requires
310
+ rescue StandardError => e
311
+ # A helper that will not load is the suite's problem to fix, and it will say so
312
+ # loudly on the first file. Constable's job here is not to disappear.
313
+ Constable.warn!("could not load what .rspec requires (#{e.class}: #{e.message}). " \
314
+ "Cold cases will run without it.", kind: :cold_case)
315
+ end
316
+
317
+ # Parsed by RSpec itself, so `.rspec`, `~/.rspec`, `.rspec-local` and SPEC_OPTS are
318
+ # all read with its precedence rather than a guess at the format.
319
+ def rspec_option_requires
320
+ return [] unless defined?(::RSpec::Core::ConfigurationOptions)
321
+
322
+ options = ::RSpec::Core::ConfigurationOptions.new([]).options
323
+ Array(options[:requires])
324
+ rescue StandardError
325
+ []
326
+ end
327
+
284
328
  def clear_examples
285
329
  world = ::RSpec.instance_variable_get(:@world)
286
330
  world.reset if world.respond_to?(:reset)
@@ -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
@@ -114,12 +114,26 @@ module Constable
114
114
  }
115
115
  end
116
116
 
117
+ # Written for somebody who has just typed `constable import` and does not yet know
118
+ # what a cold case is. "reopen" is the word this code uses internally and it means
119
+ # nothing to a reader; "import" itself suggests files were copied somewhere, which
120
+ # is the opposite of what happened. So: say where the files are, say what changed,
121
+ # and say what to do next.
117
122
  def summary
118
- verb = dry_run? ? "would" : "did"
119
- lines = ["#{@from} import (#{@strategy}) -- #{verb} reopen #{imported_count} file(s)"]
123
+ verb = dry_run? ? "Would adopt" : "Adopted"
124
+ noun = imported_count == 1 ? "file" : "files"
125
+ lines = ["#{verb} #{imported_count} #{@from} #{noun} as cold cases."]
126
+
120
127
  unless @globs_added.empty?
121
- lines << " config path match: added #{@globs_added.size} glob(s) to #{@config_path}"
122
- @globs_added.each { |glob| lines << " - #{glob} (#{covered_by(glob).size} files, 0 file changes)" }
128
+ lines << ""
129
+ lines << " Your #{@from} files stay exactly where they are and are not changed."
130
+ lines << " #{dry_run? ? "One line would be added to" : "One line was added to"} " \
131
+ "#{@config_path}:"
132
+ lines << ""
133
+ @globs_added.each { |glob| lines << " cold_cases:\n - #{glob}" }
134
+ lines << ""
135
+ lines << " Constable runs them from there, through real #{engine_label}, and folds"
136
+ lines << " the results into its own reporting, flake history and CI gate."
123
137
  unless comments_preserved?
124
138
  lines << " note: config.yml was rewritten from parsed YAML; comments were not preserved"
125
139
  end
@@ -133,9 +147,20 @@ module Constable
133
147
  @skipped.each { |s| lines << " - #{s[:path]} (#{s[:reason]})" }
134
148
  end
135
149
  @errors.each { |e| lines << " error: #{e[:path]} -- #{e[:message]}" }
150
+
151
+ unless dry_run? || imported_count.zero?
152
+ lines << ""
153
+ lines << " Next: constable test --full run everything, cold and native"
154
+ lines << " constable test --unsafe run only these"
155
+ lines << " constable modernize PATH see what one file would look like"
156
+ lines << " as a native case (writes nothing)"
157
+ end
158
+
136
159
  lines.join("\n")
137
160
  end
138
161
 
162
+ def engine_label = @from.to_s == "rspec" ? "RSpec" : "Minitest"
163
+
139
164
  def superclass_name = ENGINES.fetch(@from)[:superclass]
140
165
 
141
166
  def covered_by(glob)
@@ -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
 
@@ -342,7 +345,7 @@ module Constable
342
345
  # A raise here dumps a full stack trace per worker and leaves the parent
343
346
  # reporting a run that never happened.
344
347
  begin
345
- WorkerDatabases.after_fork!(worker_index)
348
+ WorkerDatabases.after_fork!(worker_index, mode: @config.worker_databases)
346
349
  rescue Constable::Error => e
347
350
  write_message(writer, :worker_error, e.message)
348
351
  writer.close
@@ -400,8 +403,9 @@ module Constable
400
403
  "no parallel worker could build its own test database, so the suite ran serially " \
401
404
  "instead. This usually means the app's schema cannot rebuild the database by " \
402
405
  "itself -- Postgres custom types, functions and triggers are the common reason, " \
403
- "and `rails test` parallelization fails the same way. Set `parallel_workers: 1` " \
404
- "to skip the attempt. The first worker said: #{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}",
405
409
  kind: :parallel
406
410
  )
407
411
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Constable
4
- VERSION = "1.2.0"
4
+ VERSION = "1.3.1"
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.2.0
4
+ version: 1.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ray Hughes