constable-rails 2.1.0 → 2.1.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: 64e582fc3aab8b2c70309a18f5807cc1f00468ba33afa6dd08f4c78f6abde71d
4
- data.tar.gz: b34caa421b4c301f5027e9d167c3e6a3ace738ef6668d5a5f81b2cf6711795b2
3
+ metadata.gz: '008b9cb5b6a691c81f154c127f924118626c653ddfdf8c5d711503a0fe4531ef'
4
+ data.tar.gz: 6854d47a266792d2805ad06aae373f3dcfb83150f875d3822c28857ff32fc400
5
5
  SHA512:
6
- metadata.gz: efd72821dbfff16019e0eeda25eae916753ea5e708d9addec79534002b84693f8d8e0498602b5c20f6793fbd92c5ed57e68dcef9a368026b8e54c5fd567d5af4
7
- data.tar.gz: bcae479351ac5a001dec12c0a5e81d337b373998bbd82d57c8588e91db47e94fda99c88abc8c581b860625cc91c44e1e69e38693fca6eb7d1671ee84bc984e1e
6
+ metadata.gz: 55b9a3384a28d83fe163e60b853e7e3286d3c03ee6b63cb89b0fc4760c079294ff4d85ba7f6191da8170dea8f13fb4788f071ea6058cbcc64c37adc320dc1fca
7
+ data.tar.gz: 16e8d3a9e1da126f106cbaff3af7ea34e73025a687fa1c5786fbfc699c6c0b6f4eaa7da1642935beb4607d5b8ea10202216b58a3e135fb426ecaa5b40d89f9f3
data/CHANGELOG.md CHANGED
@@ -5,6 +5,81 @@ All notable changes to this project are documented here. This project adheres to
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [2.1.1]
9
+
10
+ Two bugs found by using 2.1.0's own new feature on a real suite. The first is the most
11
+ serious defect this project has shipped.
12
+
13
+ ### A worker that died took its share of the suite with it, silently
14
+
15
+ A 192-test suite reporting **"99 passed, 0 failed"**, exit code 0. Ninety-three tests never
16
+ ran, and nothing said so.
17
+
18
+ One worker died partway through its bucket. The parent collected the results the other
19
+ worker sent, found them non-empty, and reported them as the whole run. `worker_errors` --
20
+ which had the reason, in full -- was only ever read on the path where *nothing* came back,
21
+ so a worker dying beside a healthy one was recorded and then never mentioned.
22
+
23
+ This is the exact failure mode the runner already had three separate defences against, all
24
+ of which assume total failure. Partial failure walked straight past them.
25
+
26
+ Each worker now reports its position after every item, so the parent knows what it
27
+ scheduled and how far each worker actually got. Whatever a dead worker abandoned is run in
28
+ the parent -- serially, in the one process that cannot also vanish unnoticed -- and the
29
+ reason comes with it:
30
+
31
+ ```
32
+ | 7 tests did not come back from a parallel worker, so they were run here instead --
33
+ everything ran, nothing was skipped. A worker died partway through its share:
34
+ SystemExit: Migrations are pending.
35
+ ```
36
+
37
+ Results for an item are only written once that item finishes, so the boundary is exact and
38
+ re-running from it cannot duplicate a result.
39
+
40
+ ### `establish_connection` was not changing database, and said nothing
41
+
42
+ `WorkerDatabases` renames a config in place -- `db_config._database = "app_test_3"` --
43
+ which is how Rails' own `TestDatabases` does it. But when the process already holds a pool
44
+ for that same config object, `establish_connection` hands back the pool it has instead of
45
+ opening the database the object now names. The query then succeeds and answers about the
46
+ *source* database.
47
+
48
+ Verified on a real app: `populated?` returned **true** for a SQLite worker database whose
49
+ file did not exist. That is `constable prepare` reporting "already prepared" for work it
50
+ never did -- and it is why 2.1.0's staleness check could not see a worker that was a
51
+ migration behind. Dropping the pool first is what makes a rename take effect.
52
+
53
+ A forked worker never hit this, because `before_fork!` clears every connection before the
54
+ fork. It only ever went wrong in the parent, which is why it survived this long.
55
+
56
+ Looking at a database also no longer borrows `ActiveRecord::Base`'s connection at all. It
57
+ gets a named subclass with a pool of its own, so `constable prepare` and the staleness
58
+ check leave every connection the app holds exactly where they were. Anonymous would not do:
59
+ a class with no name falls back to its superclass's connection specification name, which is
60
+ Base again.
61
+
62
+ ### Databases that cannot be sharded are named out loud
63
+
64
+ Oracle, and anything else with `database_tasks: false`, cannot be given to each worker.
65
+ Constable already skipped them — but *skipped* and *safe* are different claims, and only
66
+ the first was ever made. Every worker shares that database, and the failures that follow do
67
+ not look like a parallelism problem:
68
+
69
+ ```
70
+ | vacols (database_tasks: false) cannot be given to each worker, so all of them share it.
71
+ Tests that write to it will interfere with each other, and the failures will not look
72
+ like a parallelism problem -- they look like rows vanishing mid-test.
73
+ ```
74
+
75
+ Measured on a real app: 163 failures across four workers, every one of them passing
76
+ serially, 53 of them a bare `VacolsRecordNotFound` -- one shared Oracle database that each
77
+ worker's `before(:suite)` deleted from while the others were mid-test. The README has the
78
+ rest, including the harder limit that no warning can fix: an app holding OCI handles is not
79
+ reliably forkable, and aborts a good half of its parallel runs from inside the Oracle
80
+ client.
81
+
82
+
8
83
  ## [2.1.0]
9
84
 
10
85
  Both changes here come from one afternoon on a real suite: nineteen files, eight forked
data/README.md CHANGED
@@ -433,6 +433,30 @@ worker = ENV["CONSTABLE_WORKER"] ? "_w#{ENV['CONSTABLE_WORKER']}" : ""
433
433
  cache = Rails.root.join("tmp/browser_cache#{worker}")
434
434
  ```
435
435
 
436
+ Some databases cannot be given to each worker at all — Oracle and anything else Rails does
437
+ not manage (`database_tasks: false`). Constable does not try, and now says so at the start
438
+ of a parallel run, because *skipped* and *safe* are different claims:
439
+
440
+ ```
441
+ ⚠ vacols (database_tasks: false) cannot be given to each worker, so all of them share it.
442
+ Tests that write to it will interfere with each other, and the failures will not look
443
+ like a parallelism problem -- they look like rows vanishing mid-test.
444
+ ```
445
+
446
+ That warning is worth taking literally. On a real app with a legacy Oracle database, a
447
+ four-worker run produced 163 failures that all passed serially; 53 of them were a bare
448
+ `VacolsRecordNotFound`, because each worker's `before(:suite)` deleted from the one shared
449
+ database while the others were midway through tests that had just written to it.
450
+
451
+ **And a harder limit, if your app talks to one through a C driver: forking may not be
452
+ possible at all.** The same app aborts roughly half its parallel runs with SIGABRT — no
453
+ output on either stream, the crash report landing inside `libclntsh`, Oracle's client
454
+ library catching a SIGSEGV in its own signal handler. It is not a Constable failure and
455
+ there is nothing Constable can do about it: a process holding OCI handles is not reliably
456
+ forkable. If you see bare exit code 134 and no output, check
457
+ `~/Library/Logs/DiagnosticReports` (or your platform's equivalent) before assuming the test
458
+ runner ate your suite, and run those specs with `worker_databases: off`.
459
+
436
460
  `CONSTABLE_WORKER` is the index and `CONSTABLE_WORKERS` the count; both are unset in the
437
461
  parent, so serial runs keep whatever name they had. Use `FileUtils.mkdir_p` rather than
438
462
  `Dir.mkdir ... unless File.directory?` while you are there — the second is a race, and if
@@ -318,9 +318,31 @@ module Constable
318
318
  return false
319
319
  end
320
320
 
321
+ warn_about_shared_databases!
321
322
  worker_databases_current?(count)
322
323
  end
323
324
 
325
+ # A database Constable could not shard is shared by every worker, and that is worth
326
+ # saying out loud before the run rather than leaving it to be deduced from the wreckage.
327
+ #
328
+ # The failures it causes do not look like a parallelism problem. They look like records
329
+ # disappearing mid-test: worker 2's `before(:suite)` cleans the shared legacy database
330
+ # while worker 1 is halfway through a test that just created rows in it. Measured on a
331
+ # real suite -- 53 `VacolsRecordNotFound` failures across four workers, every one of
332
+ # them passing serially.
333
+ def warn_about_shared_databases!
334
+ shared = WorkerDatabases.unshardable_databases
335
+ return if shared.empty?
336
+
337
+ Constable.warn!(
338
+ "#{shared.join(", ")} cannot be given to each worker, so all of them share it. " \
339
+ "Tests that write to it will interfere with each other, and the failures will not " \
340
+ "look like a parallelism problem -- they look like rows vanishing mid-test. Run " \
341
+ "specs that touch it serially (`--workers 1`), or `worker_databases: off`.",
342
+ kind: :parallel
343
+ )
344
+ end
345
+
324
346
  # `:reuse` keeps the per-worker databases between runs, so they do not follow
325
347
  # migrations by themselves. A run against stale copies does not fail cleanly -- it
326
348
  # fails as a missing column in whichever file happened to touch it, on a different
@@ -421,8 +443,14 @@ module Constable
421
443
  # reason, and a run that scheduled nineteen files reports zero tests and exits
422
444
  # 0. A worker that dies has to say so.
423
445
  begin
424
- bucket.each do |item|
446
+ # The position report after each item is what lets the parent finish the work
447
+ # if this worker dies partway. Results for an item are all written once the
448
+ # item is done, so "position n reported" means items 0...n are home and
449
+ # nothing from item n was ever sent -- the boundary is exact, and re-running
450
+ # from it cannot duplicate a result.
451
+ bucket.each_with_index do |item, position|
425
452
  run_item(item).each { |result| write_message(writer, :result, result.to_h) }
453
+ write_message(writer, :progress, position + 1)
426
454
  end
427
455
  rescue Exception => e # rubocop:disable Lint/RescueException
428
456
  write_message(writer, :worker_error, "#{e.class}: #{e.message}")
@@ -471,9 +499,45 @@ module Constable
471
499
  # results carry them home. Nothing that bends the rules is allowed to go missing
472
500
  # just because it happened in a subprocess.
473
501
  collected.each { |result| Constable.warnings.concat(Array(result.warnings)) }
474
- collected
502
+ collected + finish_abandoned_work(buckets)
475
503
  end
476
504
 
505
+ # One worker dying used to cost its whole remaining bucket, silently.
506
+ #
507
+ # `worker_errors` was only ever read on the path where *nothing* came back, so a
508
+ # worker that died beside living ones was collected and never mentioned. The parent
509
+ # reported the results it happened to receive, called them the whole suite, and
510
+ # exited 0. Observed: a 192-test suite reporting "99 passed, 0 failed" -- green, with
511
+ # 93 tests that never ran.
512
+ #
513
+ # Now the parent knows what it scheduled and how far each worker actually got, so it
514
+ # can just run the rest itself. Serial, in this process, which is the one place that
515
+ # cannot also die without anyone noticing.
516
+ def finish_abandoned_work(buckets)
517
+ abandoned = buckets.each_with_index.flat_map do |bucket, index|
518
+ done = worker_progress[index]
519
+ done < bucket.size ? bucket[done..] : []
520
+ end
521
+ return [] if abandoned.empty?
522
+
523
+ Constable.warn!(
524
+ "#{abandoned.size} test#{"s" unless abandoned.size == 1} did not come back from " \
525
+ "a parallel worker, so #{abandoned.size == 1 ? "it was" : "they were"} run here " \
526
+ "instead -- everything ran, nothing was skipped. A worker died partway through " \
527
+ "its share#{worker_death_reason}.",
528
+ kind: :parallel
529
+ )
530
+ run_serial(abandoned)
531
+ end
532
+
533
+ def worker_death_reason
534
+ return "" if worker_errors.empty?
535
+
536
+ ": #{worker_errors.first}"
537
+ end
538
+
539
+ def worker_progress = (@worker_progress ||= Hash.new(0))
540
+
477
541
  def worker_errors = (@worker_errors ||= [])
478
542
 
479
543
  # A worker can die below Ruby: a segfault, an OOM kill, a signal. No `rescue` reaches
@@ -574,6 +638,9 @@ module Constable
574
638
  @reporter.record(result)
575
639
  when :coverage
576
640
  @worker_coverage = Constable::Coverage.merge_raw(@worker_coverage, body)
641
+ when :progress
642
+ index = readers.index(reader)
643
+ worker_progress[index] = body.to_i if index
577
644
  when :worker_error
578
645
  # A worker that could not start. Collected rather than raised, so the parent
579
646
  # decides what to do once it knows whether any worker got going at all.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Constable
4
- VERSION = "2.1.0"
4
+ VERSION = "2.1.1"
5
5
  end
@@ -37,6 +37,13 @@ module Constable
37
37
 
38
38
  # Parent side, before the fork. A child inheriting a live connection is a corruption
39
39
  # risk in exactly the way an inherited SQLite handle is.
40
+ #
41
+ # `clear_all_connections!` and not `disconnect!`. Closing the pools outright was tried
42
+ # here, on the theory that returning a connection to the pool leaves the socket and the
43
+ # driver's C-side state for `fork` to copy into every child. It changed nothing
44
+ # measurable, and the crash it was meant to prevent turned out to predate it -- see
45
+ # the note on native drivers in the README. Rails does the same thing before its own
46
+ # fork, and matching it is the conservative choice.
40
47
  def before_fork!
41
48
  return false unless active_record?
42
49
 
@@ -139,8 +146,9 @@ module Constable
139
146
  end
140
147
  stale.uniq
141
148
  ensure
149
+ # Names only. Nothing here ever repointed ActiveRecord::Base, so there is no
150
+ # connection to put back -- which is the point.
142
151
  restore_database_names(original)
143
- ::ActiveRecord::Base.establish_connection
144
152
  end
145
153
  rescue StandardError
146
154
  []
@@ -150,15 +158,54 @@ module Constable
150
158
  # Cheaper than diffing every version, and a worker that missed a migration differs in
151
159
  # both. nil when the question does not apply.
152
160
  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")
161
+ with_probe_connection(db_config) do |connection|
162
+ next nil unless connection.table_exists?("schema_migrations")
156
163
 
157
- connection.select_rows("SELECT COUNT(*), MAX(version) FROM schema_migrations").first
164
+ connection.select_rows("SELECT COUNT(*), MAX(version) FROM schema_migrations").first
165
+ end
158
166
  rescue StandardError
159
167
  nil
160
168
  end
161
169
 
170
+ # Databases this environment declares that cannot be given to each worker, so every
171
+ # worker shares the one copy.
172
+ #
173
+ # Constable skips them on purpose -- appending `_3` to an Oracle TNS service name names
174
+ # nothing -- but "skipped" and "safe" are different claims, and only the first was ever
175
+ # made. A legacy database that tests write to is shared mutable state across processes,
176
+ # and the failures it produces do not look like a parallelism problem: they look like
177
+ # records vanishing mid-test, in whichever spec happened to be running when another
178
+ # worker's suite hook cleaned the database they were both using.
179
+ #
180
+ # Measured on a real suite: 53 `VacolsRecordNotFound` failures across a four-worker
181
+ # run, every one of them passing serially, all from one shared Oracle database that
182
+ # each worker deleted from at startup.
183
+ def unshardable_databases
184
+ return [] unless active_record?
185
+
186
+ ::ActiveRecord::Base.configurations
187
+ .configs_for(env_name: env_name, include_hidden: true)
188
+ .filter_map { |db_config| share_reason(db_config) }
189
+ rescue StandardError
190
+ []
191
+ end
192
+
193
+ # Why a database stays shared, in the words of the setting that caused it. Both reasons
194
+ # matter and only one of them is about the adapter: `database_tasks: false` is how an
195
+ # app says "Rails does not manage this one", which is the usual way a legacy database is
196
+ # declared -- and it is exactly the database most likely to be shared, written to by
197
+ # tests, and cleaned by a suite hook in every worker at once.
198
+ #
199
+ # The config's *name* rather than its database, because a TNS descriptor is four lines
200
+ # of connection string and "vacols" is what anyone reading the warning calls it.
201
+ def share_reason(db_config)
202
+ name = db_config.respond_to?(:name) ? db_config.name : db_config.database
203
+ return "#{name} (database_tasks: false)" unless db_config.database_tasks?
204
+ return nil if shardable_adapter?(db_config)
205
+
206
+ "#{name} (#{db_config.adapter})"
207
+ end
208
+
162
209
  # The same configs `each_worker_config` renames, left under their real names.
163
210
  def each_source_config
164
211
  ::ActiveRecord::Base.configurations
@@ -347,12 +394,52 @@ module Constable
347
394
  # Present and holding tables. A database that exists but is empty is not prepared, and
348
395
  # silently running a suite against no tables is the worst of the available outcomes.
349
396
  def populated?(db_config)
350
- ::ActiveRecord::Base.establish_connection(db_config)
351
- ::ActiveRecord::Base.connection.tables.any?
397
+ with_probe_connection(db_config) { |connection| connection.tables.any? }
352
398
  rescue StandardError
353
399
  false
354
400
  end
355
401
 
402
+ # A connection class of its own, so looking at a database never disturbs the app's.
403
+ #
404
+ # Asking "is this worker database prepared, and has it run our migrations?" needs a
405
+ # connection, and the obvious way to get one is to point ActiveRecord::Base at it and
406
+ # then point it back. That works, right up until the app has a native driver attached.
407
+ #
408
+ # Measured on a real app with a legacy Oracle database: repointing Base in the parent
409
+ # before forking killed the whole run with SIGABRT, no output on either stream, the
410
+ # crash report landing inside libclntsh -- Oracle's client catching a SIGSEGV in its
411
+ # own handler and calling abort. Nothing about it says "your test runner opened a
412
+ # connection it did not need".
413
+ #
414
+ # A named subclass gets its own `connection_specification_name`, so its pool is its
415
+ # own: establishing and removing it leaves ActiveRecord::Base, and every other class
416
+ # with a connection, untouched. Anonymous would not do -- a class with no name falls
417
+ # back to its superclass's specification name, which is Base again.
418
+ def probe_class
419
+ base = ::ActiveRecord::Base
420
+ return @probe_class if defined?(@probe_class) && @probe_base.equal?(base)
421
+
422
+ klass = Class.new(base)
423
+ klass.abstract_class = true if klass.respond_to?(:abstract_class=)
424
+ # Naming it is not decoration: an unnamed class falls back to its superclass's
425
+ # connection specification name, which would put us right back on Base's pool.
426
+ remove_const(:ProbeConnection) if const_defined?(:ProbeConnection, false)
427
+ const_set(:ProbeConnection, klass)
428
+ @probe_base = base
429
+ @probe_class = klass
430
+ end
431
+
432
+ def with_probe_connection(db_config)
433
+ probe_class.establish_connection(db_config)
434
+ yield probe_class.connection
435
+ ensure
436
+ begin
437
+ probe_class.remove_connection
438
+ rescue StandardError
439
+ nil
440
+ end
441
+ end
442
+
356
443
  def env_name
357
444
  if defined?(::ActiveRecord::ConnectionHandling::DEFAULT_ENV)
358
445
  ::ActiveRecord::ConnectionHandling::DEFAULT_ENV.call
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.1.0
4
+ version: 2.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ray Hughes