constable-rails 1.3.3 → 1.4.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: fb4658a48f23de1dcb97417de5bab1deda4548a334e2a198835f93b7cac656c3
4
- data.tar.gz: c844e9f7ac1c8d797d60349b7b15759bbcbbb0f1b56c914d43e2511e17f75489
3
+ metadata.gz: 5fd3b3f867d958ed8f1306a51fb6a001145d88bd4c79839dd6d603f0b565248b
4
+ data.tar.gz: 1dee6cf2bece623c102744cb44e271461907d386715ed435fcc871ee131dd92c
5
5
  SHA512:
6
- metadata.gz: c3e78d5c2d2ab5314ee501795a92a1017fbfe0eb4cab6889c7ab6883d1ff898b02a595a693c3bb774c8e8e26caed2132c4f0c033723e7389735386f7b6c43668
7
- data.tar.gz: 9f60ad3894558087d8747a5308e39558ce03d87c1518608dff21c9ca341fc776f11ceea45b7251435f052654ead1bbf348c489086b5857cea8e1b0a845781ff8
6
+ metadata.gz: 1f6c418f0dd90a2e6e45733b237e8aa3c8e2e0d7c62b55d145dc7596bf047569983f243ba92558050612328f78834971cf5fef19e52efe4eb4deee0f5a751506
7
+ data.tar.gz: 74e6fd7f76215036de95d36ccdd84a068a71b999368e97f998e5e832c2230d3624eaf6aba4aafe0ff5edf2aa0245b15a050e85fb5784347c6407a01d72842711
data/CHANGELOG.md CHANGED
@@ -5,6 +5,51 @@ All notable changes to this project are documented here. This project adheres to
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.4.0]
9
+
10
+ Two problems from the same 1,277-file suite: a docket nobody asked for, and 110 minutes.
11
+
12
+ ### A flaky test is no longer jailed by itself
13
+
14
+ **This is the important one, because jailed means skipped.** A first `constable test` on a
15
+ real suite put **29 tests on a docket the user had never asked for** — each one recorded
16
+ as *"passed, then failed with no code change"* — and every one of them was silently
17
+ skipped from then on. A suite with order-dependent tests, which is most large suites and
18
+ exactly what Constable is pitched at, trips that constantly.
19
+
20
+ Automatic flake-jailing is now off by default:
21
+
22
+ ```yaml
23
+ jail_flakes: false # was: always on
24
+ ```
25
+
26
+ `constable test --jail` still jails failures, because that is a thing you asked for.
27
+ Turn the automatic route back on when you want it. Nothing stops being *reported* — a
28
+ flaky test still fails, still shows up, still gets a warrant if warrants are on. It just
29
+ does not remove itself from the suite.
30
+
31
+ ### Per-worker databases without a loadable schema
32
+
33
+ The same suite ran **serially for 110 minutes on a 12-core machine**, because its schema
34
+ cannot be loaded from `schema.rb` (Postgres custom types), so `worker_databases: off` was
35
+ the only setting that worked.
36
+
37
+ Postgres can copy a whole database in one statement, and that needs no schema at all:
38
+
39
+ ```sql
40
+ CREATE DATABASE "caseflow_test_3" TEMPLATE "caseflow_test"
41
+ ```
42
+
43
+ `worker_databases: reuse` now clones from the test database you already have, dropping a
44
+ stale copy and disconnecting the template first. It falls back to loading the schema when
45
+ there is nothing to clone, and to Postgres only — anything unexpected takes the old path
46
+ rather than failing. It is also simply faster than replaying a large schema once per
47
+ worker, so it is worth having on any Postgres app.
48
+
49
+ For a suite that could only run serially, this is the difference between one core and all
50
+ of them.
51
+
52
+
8
53
  ## [1.3.3]
9
54
 
10
55
  **1.3.2 was tagged twice.** The console fix in it was rebuilt after the gem had already
@@ -619,7 +664,8 @@ Initial release.
619
664
  - Diff-based coverage gate — only lines changed in the current diff are held to the
620
665
  threshold. `constable beat` for the full picture, `--html` for a browsable report.
621
666
 
622
- [Unreleased]: https://github.com/Ray-Hughes/constable/compare/v1.3.3...HEAD
667
+ [Unreleased]: https://github.com/Ray-Hughes/constable/compare/v1.4.0...HEAD
668
+ [1.4.0]: https://github.com/Ray-Hughes/constable/compare/v1.3.3...v1.4.0
623
669
  [1.3.3]: https://github.com/Ray-Hughes/constable/compare/v1.3.2...v1.3.3
624
670
  [1.3.2]: https://github.com/Ray-Hughes/constable/compare/v1.3.1...v1.3.2
625
671
  [1.3.1]: https://github.com/Ray-Hughes/constable/compare/v1.3.0...v1.3.1
data/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  **A strict Rails testing framework where fast and non-flaky are structural, not disciplinary.**
9
9
 
10
- [![Gem Version](https://img.shields.io/gem/v/constable-rails?color=1f6feb&label=constable-rails)](https://rubygems.org/gems/constable-rails)
10
+ [![Gem Version](https://badge.fury.io/rb/constable-rails.svg)](https://badge.fury.io/rb/constable-rails)
11
11
  [![CI](https://github.com/Ray-Hughes/constable/actions/workflows/ci.yml/badge.svg)](https://github.com/Ray-Hughes/constable/actions/workflows/ci.yml)
12
12
  [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.1-CC342D)](https://www.ruby-lang.org)
13
13
  [![Rails](https://img.shields.io/badge/rails-%3E%3D%207.0-D30001)](https://rubyonrails.org)
@@ -22,6 +22,7 @@ module Constable
22
22
  "output" => "concise",
23
23
  "parallel_workers" => "auto",
24
24
  "worker_databases" => "schema",
25
+ "jail_flakes" => false,
25
26
  "tiers" => {
26
27
  "unit" => "test/cases/models/**/*",
27
28
  "integration" => "test/cases/controllers/**/*",
@@ -76,6 +77,19 @@ module Constable
76
77
 
77
78
  def cold_cases = Array(@raw["cold_cases"])
78
79
  def warrants? = truthy(@raw["warrants"])
80
+
81
+ # Should a test that passed last run and failed this one be put on the docket by
82
+ # itself? Off by default, and the reason is what jailing *does*: a jailed test is
83
+ # skipped on every later run. Turning that on automatically means a suite quietly
84
+ # stops running tests nobody chose to stop running.
85
+ #
86
+ # Observed on a real suite: a first `constable test` put 29 tests on a docket the user
87
+ # had never asked for, and every one of them was skipped from then on. A suite with
88
+ # order-dependent tests -- which is most large suites, and exactly the kind Constable
89
+ # is pitched at -- trips this constantly.
90
+ #
91
+ # `constable test --jail` still jails failures, because that is a thing you asked for.
92
+ def jail_flakes? = truthy(@raw["jail_flakes"])
79
93
  # Negative retries are a typo for "off", not an instruction to count backwards.
80
94
  def warrant_retries = [@raw["warrant_retries"].to_i, 0].max
81
95
  def auto_relink? = truthy(@raw["auto_relink"])
@@ -123,6 +123,13 @@ module Constable
123
123
 
124
124
  # Consecutive clean runs that earn an automatic release. Floors at 1 -- a period of
125
125
  # zero would mean "release on sight", which is not parole.
126
+ # Opt-in. See Config#jail_flakes? for why the default is off: jailing skips the test on
127
+ # every later run, and doing that to a test nobody nominated is how a suite quietly
128
+ # stops testing things.
129
+ def jail_flakes?
130
+ @config.respond_to?(:jail_flakes?) ? @config.jail_flakes? : false
131
+ end
132
+
126
133
  def parole_period
127
134
  period = @config.respond_to?(:parole_period) ? @config.parole_period.to_i : 0
128
135
  period.positive? ? period : 10
@@ -290,7 +297,7 @@ module Constable
290
297
 
291
298
  if jail_mode
292
299
  jail_failure(result)
293
- elsif flake_flip?(result)
300
+ elsif flake_flip?(result) && jail_flakes?
294
301
  jail_for_flake(result)
295
302
  else
296
303
  result
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Constable
4
- VERSION = "1.3.3"
4
+ VERSION = "1.4.0"
5
5
  end
@@ -105,6 +105,73 @@ module Constable
105
105
  .zip(names).each { |config, name| config._database = name if name }
106
106
  end
107
107
 
108
+ # Postgres can copy a whole database in one statement:
109
+ #
110
+ # CREATE DATABASE "caseflow_test_3" TEMPLATE "caseflow_test"
111
+ #
112
+ # That matters because it needs no schema.rb at all. An app whose schema cannot
113
+ # rebuild the database by itself -- custom types, functions, triggers -- can still get
114
+ # per-worker databases this way, cloned from the test database it already has. It is
115
+ # also far faster than replaying a large schema once per worker.
116
+ #
117
+ # Returns true when it cloned, false when this is not Postgres or the source is not
118
+ # there, so the caller can fall back to loading the schema.
119
+ def clone_database(db_config, index)
120
+ return false unless postgres?(db_config)
121
+
122
+ source = db_config.database.to_s.sub(/_#{index}\z/, "")
123
+ target = db_config.database.to_s
124
+ return false if source.empty? || source == target
125
+
126
+ maintenance_connection(db_config) do |connection|
127
+ return false unless database_exists?(connection, source)
128
+
129
+ # A template cannot be copied while anything is connected to it.
130
+ disconnect_everyone_from!(connection, source)
131
+ connection.execute(%(DROP DATABASE IF EXISTS "#{target}"))
132
+ connection.execute(%(CREATE DATABASE "#{target}" TEMPLATE "#{source}"))
133
+ end
134
+
135
+ true
136
+ rescue StandardError
137
+ # Cloning is the fast path, never the only one. Anything unexpected -- a permission,
138
+ # a Postgres version, a connection that will not drop -- falls back to the schema.
139
+ false
140
+ end
141
+
142
+ def postgres?(db_config)
143
+ db_config.respond_to?(:adapter) && db_config.adapter.to_s.include?("postgre")
144
+ end
145
+
146
+ # Postgres will not let you create a database while connected to the one you are
147
+ # copying, so the statements run against the cluster's own maintenance database.
148
+ def maintenance_connection(db_config)
149
+ previous = ::ActiveRecord::Base.connection_db_config
150
+ ::ActiveRecord::Base.establish_connection(db_config.configuration_hash.merge(database: "postgres"))
151
+ yield ::ActiveRecord::Base.connection
152
+ ensure
153
+ ::ActiveRecord::Base.establish_connection(previous)
154
+ end
155
+
156
+ def database_exists?(connection, name)
157
+ # Plain Ruby, not #present?: this runs inside a forked worker in somebody else's app,
158
+ # and quietly depending on ActiveSupport being loaded is how a fast path silently
159
+ # turns itself off.
160
+ value = connection.select_value("SELECT 1 FROM pg_database WHERE datname = #{connection.quote(name)}")
161
+ !value.nil?
162
+ rescue StandardError
163
+ false
164
+ end
165
+
166
+ def disconnect_everyone_from!(connection, name)
167
+ connection.execute(
168
+ "SELECT pg_terminate_backend(pid) FROM pg_stat_activity " \
169
+ "WHERE datname = #{connection.quote(name)} AND pid <> pg_backend_pid()"
170
+ )
171
+ rescue StandardError
172
+ nil
173
+ end
174
+
108
175
  # The :reuse half. Points every database this environment declares at its `_<index>`
109
176
  # sibling, and only builds the ones that are not there yet.
110
177
  #
@@ -121,6 +188,10 @@ module Constable
121
188
  each_worker_config(index) do |db_config|
122
189
  next if populated?(db_config)
123
190
 
191
+ # Clone first: it needs no schema.rb, which is the only thing that works for an
192
+ # app whose schema cannot rebuild the database, and it is faster besides.
193
+ next built << "#{db_config.database} (cloned)" if clone_database(db_config, index)
194
+
124
195
  ::ActiveRecord::Tasks::DatabaseTasks.reconstruct_from_schema(db_config, nil)
125
196
  built << db_config.database
126
197
  end
data/lib/constable.rb CHANGED
@@ -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
- worker_databases output tiers
152
+ worker_databases jail_flakes output tiers
153
153
  ].freeze
154
154
 
155
155
  # `storage` is the one setting that cannot live here, and the reason is ordering, not
@@ -159,6 +159,7 @@ end
159
159
  # c.worker_databases = :schema # or :reuse (keep them between runs) / :off
160
160
  # c.output = :concise # or :expanded -- a line per test, with timings
161
161
  # c.fail_on_warnings = false # CI: fail when the warning count is not trending down
162
+ # c.jail_flakes = false # auto-jail a pass->fail flip (jailed = skipped)
162
163
  # c.warrants = false # rerun a failure in isolation before believing it
163
164
  # c.warrant_retries = 5
164
165
  # c.parole_period = 10 # clean runs before a paroled test releases itself
@@ -59,6 +59,14 @@ storage:
59
59
  url: # postgres/mysql only, e.g.
60
60
  # postgres://user:pass@host/constable_metadata
61
61
 
62
+ # Put a test on the docket by itself when it passed last run and failed this one, with no
63
+ # code change in between. Off by default, and the reason is what jailing *does*: a jailed
64
+ # test is skipped on every later run. Turning that on automatically means a suite quietly
65
+ # stops running tests nobody chose to stop running -- and a suite with order-dependent
66
+ # tests, which is most large ones, trips it constantly. `constable test --jail` still
67
+ # jails failures, because that is a thing you asked for.
68
+ jail_flakes: false
69
+
62
70
  warrants: false # opt-in flaky detector -- rerun a failure in isolation before believing it
63
71
  warrant_retries: 5 # reruns before declaring a warrant (flaky) or a genuine failure
64
72
  auto_relink: false # auto-confirm high-confidence rename detection instead of suggesting it
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: constable-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.3
4
+ version: 1.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ray Hughes
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-09-08 00:00:00.000000000 Z
11
+ date: 2026-09-09 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport