constable-rails 2.0.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 +4 -4
- data/CHANGELOG.md +166 -0
- data/README.md +44 -1
- data/lib/constable/cold_case/rspec.rb +25 -4
- data/lib/constable/runner.rb +119 -8
- data/lib/constable/version.rb +1 -1
- data/lib/constable/worker_databases.rb +167 -2
- data/lib/generators/constable/templates/case_helper.rb.tt +2 -2
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '008b9cb5b6a691c81f154c127f924118626c653ddfdf8c5d711503a0fe4531ef'
|
|
4
|
+
data.tar.gz: 6854d47a266792d2805ad06aae373f3dcfb83150f875d3822c28857ff32fc400
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 55b9a3384a28d83fe163e60b853e7e3286d3c03ee6b63cb89b0fc4760c079294ff4d85ba7f6191da8170dea8f13fb4788f071ea6058cbcc64c37adc320dc1fca
|
|
7
|
+
data.tar.gz: 16e8d3a9e1da126f106cbaff3af7ea34e73025a687fa1c5786fbfc699c6c0b6f4eaa7da1642935beb4607d5b8ea10202216b58a3e135fb426ecaa5b40d89f9f3
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,172 @@ 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
|
+
|
|
83
|
+
## [2.1.0]
|
|
84
|
+
|
|
85
|
+
Both changes here come from one afternoon on a real suite: nineteen files, eight forked
|
|
86
|
+
workers, and a run that reported 83 passed and 1 failed on a unique index for a row no
|
|
87
|
+
test had made. The failing test was not the broken one. Nothing was wrong with it.
|
|
88
|
+
|
|
89
|
+
### A helper named in `.rspec` that will not load is now fatal
|
|
90
|
+
|
|
91
|
+
It used to be a warning, and the cold cases ran anyway. The reasoning written in the
|
|
92
|
+
source was that Constable's job is not to disappear. That was wrong, and it is worth being
|
|
93
|
+
precise about why, because the failure it produced is the one this project exists to
|
|
94
|
+
prevent.
|
|
95
|
+
|
|
96
|
+
`rails_helper` is not a convenience. It is where a suite registers DatabaseCleaner's
|
|
97
|
+
per-test transaction, FactoryBot, WebMock, and every `spec/support` hook. A worker that
|
|
98
|
+
fails to finish loading it does not run a slightly degraded suite — it runs an
|
|
99
|
+
**unisolated** one, against a real database, with nothing wrapping any test. The writes
|
|
100
|
+
commit. Some tests pass anyway. Later files die on unique indexes naming rows nobody can
|
|
101
|
+
account for, in a different file, with a stack trace pointing at innocent code.
|
|
102
|
+
|
|
103
|
+
What produced it: eight workers loading a `spec/support` file that did
|
|
104
|
+
|
|
105
|
+
```ruby
|
|
106
|
+
Dir.mkdir(cache_directory) unless File.directory?(cache_directory)
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Two workers check, both see nothing, both create, and the loser raises `Errno::EEXIST` —
|
|
110
|
+
while `rails_helper` is loading. Six workers were isolated. Two were not, and left
|
|
111
|
+
committed rows behind in their databases.
|
|
112
|
+
|
|
113
|
+
A green run that was never isolated is worse than no run at all, so this now raises:
|
|
114
|
+
|
|
115
|
+
```
|
|
116
|
+
could not load what .rspec requires (Errno::EEXIST: File exists @ dir_s_mkdir - tmp/browser_cache_all).
|
|
117
|
+
Those files set up the suite -- database cleaning, factories, spec/support hooks.
|
|
118
|
+
Running cold cases without them would not isolate them, so this is fatal rather than a warning.
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
This is a behaviour change, not a new check: the condition was always detected. If a run
|
|
122
|
+
starts failing here, it was already producing results that did not mean what they said.
|
|
123
|
+
|
|
124
|
+
### Workers are told which worker they are
|
|
125
|
+
|
|
126
|
+
`CONSTABLE_WORKER` (the index) and `CONSTABLE_WORKERS` (the count) are set in each forked
|
|
127
|
+
worker, and unset in the parent.
|
|
128
|
+
|
|
129
|
+
Anything a suite keeps on disk per process needs a name that differs per worker — a
|
|
130
|
+
browser cache, a download directory, a screenshot path, a scratch file — and until now
|
|
131
|
+
there was no way to ask, so every worker computed the same path and raced for it. That is
|
|
132
|
+
the root cause of the bug above rather than an unrelated nicety.
|
|
133
|
+
|
|
134
|
+
```ruby
|
|
135
|
+
worker = ENV["CONSTABLE_WORKER"] ? "_w#{ENV['CONSTABLE_WORKER']}" : ""
|
|
136
|
+
cache = Rails.root.join("tmp/browser_cache#{worker}")
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Deliberately not `TEST_ENV_NUMBER` or `TEST_SUBCATEGORY`: both are already interpolated
|
|
140
|
+
into some apps' `database.yml`, and setting either here would rename databases behind
|
|
141
|
+
`WorkerDatabases`' back.
|
|
142
|
+
|
|
143
|
+
### A stale worker database drops the run to serial instead of running wrong
|
|
144
|
+
|
|
145
|
+
`worker_databases: reuse` keeps the per-worker databases between runs, which is the point
|
|
146
|
+
of it — and it means they do not follow migrations on their own. Run a migration, run the
|
|
147
|
+
suite, and every worker is testing yesterday's schema.
|
|
148
|
+
|
|
149
|
+
That does not fail cleanly. It fails as a missing column in whichever file happened to
|
|
150
|
+
land on a stale worker, three files from anything you changed, on a different file each
|
|
151
|
+
run because the scheduling moved. Constable now compares what each worker database has
|
|
152
|
+
migrated against the real test database before it forks, and when they disagree:
|
|
153
|
+
|
|
154
|
+
```
|
|
155
|
+
⚠ worker database 3 has not run the migrations the test database has. `worker_databases:
|
|
156
|
+
reuse` keeps these between runs, which means they do not follow a migration on their
|
|
157
|
+
own. Running serially instead -- `constable prepare` rebuilds them.
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Serial rather than a refusal, deliberately: the database a serial run uses is the real
|
|
161
|
+
test database, and that one *is* current. The suite still runs, correctly, and says what
|
|
162
|
+
to do to get its speed back. Two integers per database, in the parent, before anything
|
|
163
|
+
forks; a question that cannot be answered (no `schema_migrations`, an adapter that will
|
|
164
|
+
not connect) never blocks a run that would have worked.
|
|
165
|
+
|
|
166
|
+
### Tests
|
|
167
|
+
|
|
168
|
+
The fork path had no end-to-end coverage at all, which is where most of this project's
|
|
169
|
+
serious bugs have lived. It has some now: worker identity arriving in the child and not
|
|
170
|
+
leaking into the parent, and both halves of `.rspec` handling — a helper that raises stops
|
|
171
|
+
the run, and a helper that loads is actually loaded before the file.
|
|
172
|
+
|
|
173
|
+
|
|
8
174
|
## [2.0.0]
|
|
9
175
|
|
|
10
176
|
A tidy-up release. The API you write — `investigate`, `witness`, `briefing`, `docket`,
|
data/README.md
CHANGED
|
@@ -414,12 +414,55 @@ 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
|
+
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
|
+
|
|
460
|
+
`CONSTABLE_WORKER` is the index and `CONSTABLE_WORKERS` the count; both are unset in the
|
|
461
|
+
parent, so serial runs keep whatever name they had. Use `FileUtils.mkdir_p` rather than
|
|
462
|
+
`Dir.mkdir ... unless File.directory?` while you are there — the second is a race, and if
|
|
463
|
+
it runs inside `spec/support` it takes `rails_helper` down with it, which costs the loser
|
|
464
|
+
its database cleaning rather than just its cache directory.
|
|
465
|
+
|
|
423
466
|
### Output
|
|
424
467
|
|
|
425
468
|
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
|
-
#
|
|
328
|
-
#
|
|
329
|
-
|
|
330
|
-
|
|
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
|
data/lib/constable/runner.rb
CHANGED
|
@@ -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,67 @@ 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
|
-
|
|
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
|
+
warn_about_shared_databases!
|
|
322
|
+
worker_databases_current?(count)
|
|
323
|
+
end
|
|
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
|
+
|
|
346
|
+
# `:reuse` keeps the per-worker databases between runs, so they do not follow
|
|
347
|
+
# migrations by themselves. A run against stale copies does not fail cleanly -- it
|
|
348
|
+
# fails as a missing column in whichever file happened to touch it, on a different
|
|
349
|
+
# worker each run.
|
|
350
|
+
#
|
|
351
|
+
# Serial is the right fallback rather than a refusal, because the database a serial
|
|
352
|
+
# run uses is the real test database, and that one *is* current. So the suite still
|
|
353
|
+
# runs, correctly, and says exactly what to do to get its speed back.
|
|
354
|
+
def worker_databases_current?(count)
|
|
355
|
+
return true unless @config.worker_databases.to_s == "reuse"
|
|
356
|
+
|
|
357
|
+
stale = WorkerDatabases.stale_workers(count)
|
|
358
|
+
return true if stale.empty?
|
|
311
359
|
|
|
312
360
|
Constable.warn!(
|
|
313
|
-
"
|
|
314
|
-
"
|
|
315
|
-
"
|
|
361
|
+
"worker database#{"s" if stale.length > 1} #{stale.join(", ")} " \
|
|
362
|
+
"#{stale.length > 1 ? "have" : "has"} not run the migrations the test database " \
|
|
363
|
+
"has. `worker_databases: reuse` keeps these between runs, which means they do " \
|
|
364
|
+
"not follow a migration on their own. Running serially instead -- " \
|
|
365
|
+
"`constable prepare` rebuilds them.",
|
|
316
366
|
kind: :parallel
|
|
317
367
|
)
|
|
318
368
|
false
|
|
@@ -359,6 +409,22 @@ module Constable
|
|
|
359
409
|
pid = fork do
|
|
360
410
|
reader.close
|
|
361
411
|
|
|
412
|
+
# Tell the app which worker it is.
|
|
413
|
+
#
|
|
414
|
+
# Anything a suite keeps on disk per process needs this: a browser cache, a
|
|
415
|
+
# download directory, a screenshot path, a scratch file. Without it every worker
|
|
416
|
+
# computes the same path and they race -- and the way that surfaces is not a
|
|
417
|
+
# tidy error. Observed on a real suite: eight workers running
|
|
418
|
+
# `Dir.mkdir(dir) unless File.directory?(dir)` in a spec/support file, the losers
|
|
419
|
+
# raising Errno::EEXIST *while loading rails_helper*, so those workers ran their
|
|
420
|
+
# files with no database cleaning at all.
|
|
421
|
+
#
|
|
422
|
+
# Deliberately its own name rather than TEST_ENV_NUMBER or parallel_tests'
|
|
423
|
+
# TEST_SUBCATEGORY: those are already wired into some apps' database.yml, and
|
|
424
|
+
# setting one here would rename databases behind WorkerDatabases' back.
|
|
425
|
+
ENV["CONSTABLE_WORKER"] = worker_index.to_s
|
|
426
|
+
ENV["CONSTABLE_WORKERS"] = buckets.length.to_s
|
|
427
|
+
|
|
362
428
|
# Before a single test runs: build this worker's own database and point the
|
|
363
429
|
# process at it. Never falls back to the shared one -- that is the bug this
|
|
364
430
|
# exists to prevent -- but the failure is reported home rather than raised.
|
|
@@ -377,8 +443,14 @@ module Constable
|
|
|
377
443
|
# reason, and a run that scheduled nineteen files reports zero tests and exits
|
|
378
444
|
# 0. A worker that dies has to say so.
|
|
379
445
|
begin
|
|
380
|
-
|
|
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|
|
|
381
452
|
run_item(item).each { |result| write_message(writer, :result, result.to_h) }
|
|
453
|
+
write_message(writer, :progress, position + 1)
|
|
382
454
|
end
|
|
383
455
|
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
384
456
|
write_message(writer, :worker_error, "#{e.class}: #{e.message}")
|
|
@@ -427,9 +499,45 @@ module Constable
|
|
|
427
499
|
# results carry them home. Nothing that bends the rules is allowed to go missing
|
|
428
500
|
# just because it happened in a subprocess.
|
|
429
501
|
collected.each { |result| Constable.warnings.concat(Array(result.warnings)) }
|
|
430
|
-
collected
|
|
502
|
+
collected + finish_abandoned_work(buckets)
|
|
431
503
|
end
|
|
432
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
|
+
|
|
433
541
|
def worker_errors = (@worker_errors ||= [])
|
|
434
542
|
|
|
435
543
|
# A worker can die below Ruby: a segfault, an OOM kill, a signal. No `rescue` reaches
|
|
@@ -530,6 +638,9 @@ module Constable
|
|
|
530
638
|
@reporter.record(result)
|
|
531
639
|
when :coverage
|
|
532
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
|
|
533
644
|
when :worker_error
|
|
534
645
|
# A worker that could not start. Collected rather than raised, so the parent
|
|
535
646
|
# decides what to do once it knows whether any worker got going at all.
|
data/lib/constable/version.rb
CHANGED
|
@@ -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
|
|
|
@@ -93,6 +100,124 @@ module Constable
|
|
|
93
100
|
raise Constable::Error, "could not prepare worker #{index}: #{e.class}: #{e.message}"
|
|
94
101
|
end
|
|
95
102
|
|
|
103
|
+
# The one mistake `:reuse` invites, caught before the fork rather than after.
|
|
104
|
+
#
|
|
105
|
+
# `:reuse` keeps the per-worker databases between runs, which is the whole point --
|
|
106
|
+
# and it means they do not follow migrations by themselves. Run a migration, run the
|
|
107
|
+
# suite, and every worker is now testing yesterday's schema. That does not fail
|
|
108
|
+
# cleanly: it fails as a missing column in whichever file happened to touch it, three
|
|
109
|
+
# files away from anything you changed, differently on each run because the file went
|
|
110
|
+
# to a different worker. Exactly the shape of bug that costs an afternoon.
|
|
111
|
+
#
|
|
112
|
+
# So compare what each worker database has migrated against what the real test
|
|
113
|
+
# database has, and refuse rather than guess. Two integers per database, in the parent,
|
|
114
|
+
# before anything forks.
|
|
115
|
+
#
|
|
116
|
+
# Returns the worker indexes that are out of date, empty when they are all current or
|
|
117
|
+
# when the question cannot be answered (no schema_migrations table, an adapter that
|
|
118
|
+
# will not connect) -- an unanswerable check must not block a run that would have
|
|
119
|
+
# worked.
|
|
120
|
+
def stale_workers(count)
|
|
121
|
+
return [] unless shardable?
|
|
122
|
+
|
|
123
|
+
expected = {}
|
|
124
|
+
original = database_names
|
|
125
|
+
begin
|
|
126
|
+
each_source_config do |db_config|
|
|
127
|
+
fingerprint = schema_fingerprint(db_config)
|
|
128
|
+
expected[db_config.database.to_s] = fingerprint if fingerprint
|
|
129
|
+
end
|
|
130
|
+
return [] if expected.empty?
|
|
131
|
+
|
|
132
|
+
stale = []
|
|
133
|
+
(0...count).each do |index|
|
|
134
|
+
names = database_names
|
|
135
|
+
begin
|
|
136
|
+
each_worker_config(index) do |db_config|
|
|
137
|
+
source = db_config.database.to_s.sub(/_#{index}\z/, "")
|
|
138
|
+
next unless expected.key?(source)
|
|
139
|
+
|
|
140
|
+
actual = schema_fingerprint(db_config)
|
|
141
|
+
stale << index if actual && actual != expected[source]
|
|
142
|
+
end
|
|
143
|
+
ensure
|
|
144
|
+
restore_database_names(names)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
stale.uniq
|
|
148
|
+
ensure
|
|
149
|
+
# Names only. Nothing here ever repointed ActiveRecord::Base, so there is no
|
|
150
|
+
# connection to put back -- which is the point.
|
|
151
|
+
restore_database_names(original)
|
|
152
|
+
end
|
|
153
|
+
rescue StandardError
|
|
154
|
+
[]
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# What a database has migrated: how many migrations it has run and the latest one.
|
|
158
|
+
# Cheaper than diffing every version, and a worker that missed a migration differs in
|
|
159
|
+
# both. nil when the question does not apply.
|
|
160
|
+
def schema_fingerprint(db_config)
|
|
161
|
+
with_probe_connection(db_config) do |connection|
|
|
162
|
+
next nil unless connection.table_exists?("schema_migrations")
|
|
163
|
+
|
|
164
|
+
connection.select_rows("SELECT COUNT(*), MAX(version) FROM schema_migrations").first
|
|
165
|
+
end
|
|
166
|
+
rescue StandardError
|
|
167
|
+
nil
|
|
168
|
+
end
|
|
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
|
+
|
|
209
|
+
# The same configs `each_worker_config` renames, left under their real names.
|
|
210
|
+
def each_source_config
|
|
211
|
+
::ActiveRecord::Base.configurations
|
|
212
|
+
.configs_for(env_name: env_name, include_hidden: true)
|
|
213
|
+
.each do |db_config|
|
|
214
|
+
next unless db_config.database_tasks?
|
|
215
|
+
next unless shardable_adapter?(db_config)
|
|
216
|
+
|
|
217
|
+
yield db_config
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
96
221
|
def database_names
|
|
97
222
|
::ActiveRecord::Base.configurations
|
|
98
223
|
.configs_for(env_name: env_name, include_hidden: true)
|
|
@@ -269,12 +394,52 @@ module Constable
|
|
|
269
394
|
# Present and holding tables. A database that exists but is empty is not prepared, and
|
|
270
395
|
# silently running a suite against no tables is the worst of the available outcomes.
|
|
271
396
|
def populated?(db_config)
|
|
272
|
-
|
|
273
|
-
::ActiveRecord::Base.connection.tables.any?
|
|
397
|
+
with_probe_connection(db_config) { |connection| connection.tables.any? }
|
|
274
398
|
rescue StandardError
|
|
275
399
|
false
|
|
276
400
|
end
|
|
277
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
|
+
|
|
278
443
|
def env_name
|
|
279
444
|
if defined?(::ActiveRecord::ConnectionHandling::DEFAULT_ENV)
|
|
280
445
|
::ActiveRecord::ConnectionHandling::DEFAULT_ENV.call
|
|
@@ -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 =>
|
|
38
|
-
abort("#{
|
|
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
|
|