constable-rails 0.1.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.
Files changed (76) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +88 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +515 -0
  5. data/exe/constable +7 -0
  6. data/lib/constable/case.rb +336 -0
  7. data/lib/constable/cli.rb +475 -0
  8. data/lib/constable/cold_case/minitest.rb +342 -0
  9. data/lib/constable/cold_case/rspec.rb +334 -0
  10. data/lib/constable/cold_case.rb +280 -0
  11. data/lib/constable/config.rb +125 -0
  12. data/lib/constable/coverage.rb +951 -0
  13. data/lib/constable/diff.rb +212 -0
  14. data/lib/constable/dsl.rb +833 -0
  15. data/lib/constable/identity.rb +121 -0
  16. data/lib/constable/importer/modernizer.rb +860 -0
  17. data/lib/constable/importer/reopener.rb +468 -0
  18. data/lib/constable/importer.rb +51 -0
  19. data/lib/constable/investigation.rb +67 -0
  20. data/lib/constable/isolation.rb +171 -0
  21. data/lib/constable/jail.rb +399 -0
  22. data/lib/constable/log_router.rb +197 -0
  23. data/lib/constable/matchers.rb +834 -0
  24. data/lib/constable/order_audit.rb +130 -0
  25. data/lib/constable/rails_support.rb +213 -0
  26. data/lib/constable/railtie.rb +36 -0
  27. data/lib/constable/registry.rb +57 -0
  28. data/lib/constable/reporter.rb +625 -0
  29. data/lib/constable/result.rb +149 -0
  30. data/lib/constable/runner.rb +697 -0
  31. data/lib/constable/selection.rb +205 -0
  32. data/lib/constable/storage/adapter.rb +91 -0
  33. data/lib/constable/storage/mysql_adapter.rb +125 -0
  34. data/lib/constable/storage/postgres_adapter.rb +125 -0
  35. data/lib/constable/storage/sqlite_adapter.rb +84 -0
  36. data/lib/constable/storage.rb +847 -0
  37. data/lib/constable/version.rb +5 -0
  38. data/lib/constable/warrants.rb +290 -0
  39. data/lib/constable-rails.rb +16 -0
  40. data/lib/constable.rb +151 -0
  41. data/lib/generators/constable/base.rb +99 -0
  42. data/lib/generators/constable/channel/channel_generator.rb +20 -0
  43. data/lib/generators/constable/channel/templates/channel_case.rb.tt +29 -0
  44. data/lib/generators/constable/controller/controller_generator.rb +25 -0
  45. data/lib/generators/constable/controller/templates/controller_case.rb.tt +32 -0
  46. data/lib/generators/constable/generator/generator_generator.rb +31 -0
  47. data/lib/generators/constable/generator/templates/generator_case.rb.tt +28 -0
  48. data/lib/generators/constable/helper/helper_generator.rb +23 -0
  49. data/lib/generators/constable/helper/templates/helper_case.rb.tt +19 -0
  50. data/lib/generators/constable/import_generator.rb +137 -0
  51. data/lib/generators/constable/install_generator.rb +188 -0
  52. data/lib/generators/constable/integration/integration_generator.rb +27 -0
  53. data/lib/generators/constable/integration/templates/request_case.rb.tt +22 -0
  54. data/lib/generators/constable/job/job_generator.rb +20 -0
  55. data/lib/generators/constable/job/templates/job_case.rb.tt +33 -0
  56. data/lib/generators/constable/mailbox/mailbox_generator.rb +20 -0
  57. data/lib/generators/constable/mailbox/templates/mailbox_case.rb.tt +26 -0
  58. data/lib/generators/constable/mailer/mailer_generator.rb +32 -0
  59. data/lib/generators/constable/mailer/templates/mailer_case.rb.tt +34 -0
  60. data/lib/generators/constable/mailer/templates/preview.rb.tt +14 -0
  61. data/lib/generators/constable/model/model_generator.rb +31 -0
  62. data/lib/generators/constable/model/templates/model_case.rb.tt +37 -0
  63. data/lib/generators/constable/resource/resource_generator.rb +27 -0
  64. data/lib/generators/constable/scaffold/scaffold_generator.rb +42 -0
  65. data/lib/generators/constable/scaffold/templates/api_controller_case.rb.tt +54 -0
  66. data/lib/generators/constable/scaffold/templates/controller_case.rb.tt +70 -0
  67. data/lib/generators/constable/scaffold/templates/system_case.rb.tt +53 -0
  68. data/lib/generators/constable/system/system_generator.rb +20 -0
  69. data/lib/generators/constable/system/templates/system_case.rb.tt +18 -0
  70. data/lib/generators/constable/templates/authenticatable.rb.tt +31 -0
  71. data/lib/generators/constable/templates/case_helper.rb.tt +179 -0
  72. data/lib/generators/constable/templates/config.yml.tt +67 -0
  73. data/lib/generators/constable/templates/example_case.rb.tt +56 -0
  74. data/lib/generators/constable/templates/matchers.rb.tt +36 -0
  75. data/lib/generators/constable/templates/rubocop.yml.tt +12 -0
  76. metadata +209 -0
@@ -0,0 +1,847 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "constable/storage/adapter"
5
+
6
+ module Constable
7
+ # The blotter -- everything Constable remembers between runs.
8
+ #
9
+ # This file is the entry point for the storage layer: it pulls in the abstract
10
+ # +Adapter+ interface, declares the three shipped adapters so +Adapter.build+ can
11
+ # resolve them, and defines the SQL implementation all three share.
12
+ #
13
+ # == Why one shared implementation
14
+ #
15
+ # SQLite, Postgres and MySQL differ in about six places -- how you open a connection,
16
+ # how a placeholder is spelled, what an auto-incrementing primary key is called, and
17
+ # how you read back an inserted id. Everything else (the schema, the queries, the jail
18
+ # state machine, the rolling duration average) is identical. +RelationalAdapter+ holds
19
+ # the identical part; each concrete adapter supplies the six differences. There is no
20
+ # third-party SQL abstraction in play here on purpose -- Constable must boot for a
21
+ # :unit-tier run without a Rails app present, so it carries no ActiveRecord dependency.
22
+ #
23
+ # == Conventions every caller can rely on
24
+ #
25
+ # * *Read methods return plain Ruby hashes with SYMBOL keys.* No adapter ever hands back
26
+ # a database-specific row object or a string-keyed hash. Other components (jail,
27
+ # warrants, the reporter, the CLI) consume these directly.
28
+ # * *Enumerable reads are newest-first.* +runs+, +history_for+, +jailed+, +paroled+,
29
+ # +warrants+ and +coverage_trend+ all return most-recent-first. A consumer plotting a
30
+ # trend line reverses; a consumer asking "what happened last?" reads element zero.
31
+ # * *Enum-ish values come back as Symbols* -- +:passed+, +:native+, +:unit+, +:jailed+,
32
+ # +:parole+ -- matching +Constable::Result::STATUSES+ and friends. Free text stays a
33
+ # String, and +nil+ stays +nil+ (never coerced into +:""+).
34
+ # * *Timestamps are ISO-8601 UTC Strings* ("2026-09-06T12:00:00.000Z"), stored in text
35
+ # columns. Portable across all three engines, sortable as text, no timezone surprises,
36
+ # and no driver-specific Time coercion to get wrong.
37
+ # * A read for something that does not exist returns +nil+ (single) or +[]+ (list).
38
+ #
39
+ # == Concurrency
40
+ #
41
+ # *Only the parent process writes to storage.* Workers ship their results back over a
42
+ # pipe as +Result#to_h+ and the parent persists them, so no adapter needs cross-process
43
+ # write locking and no upsert here needs to be atomic against a competing writer --
44
+ # which is why the read-then-insert-or-update helpers below are written as two plain
45
+ # statements rather than as three dialects' worth of ON CONFLICT syntax. SQLite still
46
+ # runs in WAL mode with a busy_timeout anyway: a stray reader (an editor plugin, a
47
+ # second terminal running `constable jail`) costs nothing to tolerate.
48
+ module Storage
49
+ autoload :SqliteAdapter, "constable/storage/sqlite_adapter"
50
+ autoload :PostgresAdapter, "constable/storage/postgres_adapter"
51
+ autoload :MysqlAdapter, "constable/storage/mysql_adapter"
52
+
53
+ # The SQL half of every shipped adapter.
54
+ #
55
+ # Subclasses must implement: +connect!+, +execute_raw+, +query+, +execute+, +insert+,
56
+ # +close+, and may override +types+ and +create_index+ for dialect quirks.
57
+ class RelationalAdapter < Adapter
58
+ # Bumped whenever the schema below changes. Stored in the schema_meta table so a
59
+ # future release can migrate an existing blotter instead of asking for a wipe.
60
+ SCHEMA_VERSION = 1
61
+
62
+ # How many samples the duration average remembers. Past this, each new sample is
63
+ # weighted 1/N against the running mean, so the index tracks a test that got slower
64
+ # (or faster) instead of being anchored by a year of old timings.
65
+ ROLLING_WINDOW = 20
66
+
67
+ TABLES = %w[
68
+ schema_meta runs flake_history jail_docket warrants durations coverage_snapshots
69
+ ].freeze
70
+
71
+ # Tables with a generated primary key, so an adapter knows when an insert has an id
72
+ # worth reading back. The rest are keyed by identity, which the caller already has.
73
+ AUTO_ID_TABLES = %w[runs flake_history coverage_snapshots].freeze
74
+
75
+ # Column-name -> Ruby type. Drivers disagree about what they hand back (pg returns
76
+ # every value as a String; sqlite3 and mysql2 return typed values), so every row
77
+ # passes through here and comes out the same shape regardless of engine.
78
+ INTEGER_COLUMNS = %w[
79
+ id run_id line samples times_seen clean_runs failed_runs parole_clean_runs
80
+ parole_violations times_jailed total passed failed jailed skipped errored
81
+ warranted file_count unpatrolled
82
+ ].freeze
83
+
84
+ FLOAT_COLUMNS = %w[duration average last_duration max_duration percent].freeze
85
+ BOOLEAN_COLUMNS = %w[full_run].freeze
86
+ SYMBOL_COLUMNS = %w[status kind tier mode state].freeze
87
+ # totals is Constable's own counter hash, so its keys become Symbols. files is keyed
88
+ # by *file path*, which must stay a String.
89
+ SYMBOL_KEYED_JSON_COLUMNS = %w[totals].freeze
90
+ STRING_KEYED_JSON_COLUMNS = %w[files].freeze
91
+
92
+ # Columns whose stored name is not the name callers should see. The run row is
93
+ # opened with `full:`, so it reads back as `:full`; "full" is a reserved word in
94
+ # Postgres, hence the full_run column underneath.
95
+ COLUMN_ALIASES = { "full_run" => :full }.freeze
96
+
97
+ def initialize(config)
98
+ super
99
+ @setup = false
100
+ end
101
+
102
+ # --- lifecycle -------------------------------------------------------------
103
+
104
+ # Idempotent. Creating the schema is CREATE TABLE IF NOT EXISTS all the way down,
105
+ # so calling this on every boot costs one round trip and never destroys anything.
106
+ def setup!
107
+ connect! unless @connection
108
+ ddl_statements.each { |sql| execute_raw(sql) }
109
+ index_statements.each { |(name, table, columns)| create_index(name, table, columns) }
110
+ stamp_schema_version!
111
+ @setup = true
112
+ self
113
+ end
114
+
115
+ def close
116
+ @connection = nil
117
+ @setup = false
118
+ nil
119
+ end
120
+
121
+ # Drops everything and rebuilds. Used by `constable history reset` and by our own
122
+ # suite; never called during a test run.
123
+ def reset!
124
+ connect! unless @connection
125
+ TABLES.reverse_each { |table| execute_raw("DROP TABLE IF EXISTS #{table}") }
126
+ @setup = false
127
+ setup!
128
+ end
129
+
130
+ # The schema version currently stamped on this blotter, as an Integer.
131
+ def schema_version
132
+ row = query("SELECT value FROM schema_meta WHERE name = ?", ["schema_version"]).first
133
+ row && row["value"].to_i
134
+ end
135
+
136
+ # --- runs ------------------------------------------------------------------
137
+
138
+ # Opens a run row and returns its id. Every flake_history and coverage row written
139
+ # afterwards points back at it.
140
+ def start_run(seed:, mode:, full:)
141
+ insert_row("runs", {
142
+ "seed" => seed&.to_s,
143
+ "mode" => (mode || :test).to_s,
144
+ "full_run" => full ? 1 : 0,
145
+ "started_at" => now
146
+ })
147
+ end
148
+
149
+ # +totals+ is whatever the reporter counted. Recognised keys land in their own
150
+ # columns so `constable status` can query them; the whole hash is also kept as JSON
151
+ # so a future counter needs no migration to be readable.
152
+ def finish_run(run_id, totals:)
153
+ totals = symbolize(totals || {})
154
+ update_row("runs", { "id" => run_id }, {
155
+ "finished_at" => now,
156
+ "duration" => totals[:duration]&.to_f,
157
+ "total" => totals[:total]&.to_i,
158
+ "passed" => totals[:passed]&.to_i,
159
+ "failed" => totals[:failed]&.to_i,
160
+ "jailed" => totals[:jailed]&.to_i,
161
+ "skipped" => totals[:skipped]&.to_i,
162
+ "warranted" => totals[:warranted]&.to_i,
163
+ "parole_violations" => totals[:parole_violations]&.to_i,
164
+ "totals" => JSON.generate(totals)
165
+ })
166
+ run(run_id)
167
+ end
168
+
169
+ def run(run_id)
170
+ row(query("SELECT * FROM runs WHERE id = ?", [run_id]).first)
171
+ end
172
+
173
+ # Most recent first.
174
+ def runs(limit: 30)
175
+ rows(query("SELECT * FROM runs ORDER BY id DESC LIMIT ?", [limit.to_i]))
176
+ end
177
+
178
+ # How much of each run was native and how much was still running as a cold case.
179
+ # Grouped in SQL, folded per run in Ruby -- a LIMIT inside an IN subquery is the one
180
+ # shape MySQL refuses, and this store has to read identically on all three engines.
181
+ def kind_totals(limit: 30)
182
+ grouped = rows(query(<<~SQL, [(limit.to_i * 4) + 8]))
183
+ SELECT run_id, kind, COUNT(*) AS tally
184
+ FROM flake_history
185
+ GROUP BY run_id, kind
186
+ ORDER BY run_id DESC
187
+ LIMIT ?
188
+ SQL
189
+
190
+ grouped.group_by { |row| row[:run_id] }
191
+ .sort_by { |run_id, _| -run_id.to_i }
192
+ .first(limit.to_i)
193
+ .map do |run_id, entries|
194
+ counts = entries.to_h { |e| [e[:kind].to_s, e[:tally].to_i] }
195
+ {
196
+ run_id: run_id,
197
+ native: counts.fetch("native", 0),
198
+ cold: counts.fetch("cold", 0)
199
+ }
200
+ end
201
+ end
202
+
203
+ # --- flake history ---------------------------------------------------------
204
+
205
+ # Every result, native and cold alike, one row per test per run. This is the raw
206
+ # material for flake detection (a status flip with no identity change) and for the
207
+ # native-vs-cold trend in `constable status`.
208
+ #
209
+ # Accepts a Constable::Result or the Hash a worker shipped over the pipe.
210
+ def record_result(run_id, result)
211
+ attrs = result_attributes(result)
212
+ insert_row("flake_history", {
213
+ "run_id" => run_id,
214
+ "identity" => attrs[:identity],
215
+ "label" => attrs[:label],
216
+ "case_name" => attrs[:case_name],
217
+ "description" => attrs[:description],
218
+ "file" => attrs[:file],
219
+ "line" => attrs[:line],
220
+ "kind" => attrs[:kind],
221
+ "tier" => attrs[:tier],
222
+ "status" => attrs[:status],
223
+ "duration" => attrs[:duration],
224
+ "failure_message" => attrs[:failure_message],
225
+ "recorded_at" => now
226
+ })
227
+ end
228
+
229
+ # Most recent first.
230
+ def history_for(identity, limit: 50)
231
+ rows(query(
232
+ "SELECT * FROM flake_history WHERE identity = ? ORDER BY id DESC LIMIT ?",
233
+ [identity.to_s, limit.to_i]
234
+ ))
235
+ end
236
+
237
+ # The most recently recorded status for a test, as a Symbol, or nil if never seen.
238
+ # The flake detector compares this against the status about to be recorded.
239
+ def last_status(identity)
240
+ row = query(
241
+ "SELECT status FROM flake_history WHERE identity = ? ORDER BY id DESC LIMIT 1",
242
+ [identity.to_s]
243
+ ).first
244
+ row && to_symbol(row["status"])
245
+ end
246
+
247
+ # Every identity the blotter has ever heard of, sorted. Rename detection diffs this
248
+ # against the identities in the current run to spot a test that vanished.
249
+ def known_identities
250
+ query(<<~SQL).map { |r| r["identity"] }.compact.sort
251
+ SELECT identity FROM flake_history
252
+ UNION SELECT identity FROM jail_docket
253
+ UNION SELECT identity FROM warrants
254
+ UNION SELECT identity FROM durations
255
+ SQL
256
+ end
257
+
258
+ # Moves a test's whole history from one content hash to another -- what
259
+ # `constable history relink OLD NEW` does after a rename that also touched the body.
260
+ #
261
+ # Flake history rows always move. The jail entry, warrant and duration index move
262
+ # too, unless the new identity already has one of its own, in which case the old
263
+ # row is dropped rather than clobbering live state (:merged below).
264
+ #
265
+ # Returns a summary: { moved_results: Integer, jail: Symbol, warrant: Symbol,
266
+ # durations: Symbol } where each Symbol is :moved, :merged or :none.
267
+ def relink(old_identity, new_identity)
268
+ old_identity = old_identity.to_s
269
+ new_identity = new_identity.to_s
270
+ return { moved_results: 0, jail: :none, warrant: :none, durations: :none } if old_identity == new_identity
271
+
272
+ moved = count("SELECT COUNT(*) AS c FROM flake_history WHERE identity = ?", [old_identity])
273
+ execute("UPDATE flake_history SET identity = ? WHERE identity = ?", [new_identity, old_identity])
274
+
275
+ {
276
+ moved_results: moved,
277
+ jail: move_keyed_row("jail_docket", old_identity, new_identity),
278
+ warrant: move_keyed_row("warrants", old_identity, new_identity),
279
+ durations: move_keyed_row("durations", old_identity, new_identity)
280
+ }
281
+ end
282
+
283
+ # --- jail docket -----------------------------------------------------------
284
+
285
+ # Jails a test, or re-jails one already on the docket. A repeat offender keeps its
286
+ # history: times_jailed goes up, parole_violations is untouched, and any parole
287
+ # progress is wiped -- coming back to jail means starting the clean-run count over.
288
+ def jail(identity, label:, file:, line:, reason:)
289
+ identity = identity.to_s
290
+ existing = jail_entry(identity)
291
+ if existing
292
+ update_row("jail_docket", { "identity" => identity }, {
293
+ "label" => label, "file" => file, "line" => line, "reason" => reason,
294
+ "jailed_at" => now, "state" => "jailed", "parole_clean_runs" => 0,
295
+ "paroled_at" => nil, "times_jailed" => existing[:times_jailed].to_i + 1,
296
+ "updated_at" => now
297
+ })
298
+ else
299
+ insert_row("jail_docket", {
300
+ "identity" => identity, "label" => label, "file" => file, "line" => line,
301
+ "reason" => reason, "jailed_at" => now, "state" => "jailed",
302
+ "parole_clean_runs" => 0, "parole_violations" => 0, "times_jailed" => 1,
303
+ "updated_at" => now
304
+ })
305
+ end
306
+ jail_entry(identity)
307
+ end
308
+
309
+ # Currently locked up (skipped in normal runs). Most recently jailed first.
310
+ def jailed
311
+ rows(query("SELECT * FROM jail_docket WHERE state = ? ORDER BY jailed_at DESC, identity ASC", ["jailed"]))
312
+ end
313
+
314
+ # Out on parole -- runs normally, but watched. Most recently paroled first.
315
+ def paroled
316
+ rows(query("SELECT * FROM jail_docket WHERE state = ? ORDER BY paroled_at DESC, identity ASC", ["parole"]))
317
+ end
318
+
319
+ def jail_entry(identity)
320
+ row(query("SELECT * FROM jail_docket WHERE identity = ?", [identity.to_s]).first)
321
+ end
322
+
323
+ # Jail -> parole. The clean-run count starts at zero; parole_violations and
324
+ # times_jailed carry over, because parole is supervision, not a fresh start.
325
+ # Returns the updated entry, or nil if the test is not on the docket.
326
+ def parole(identity)
327
+ identity = identity.to_s
328
+ return nil unless jail_entry(identity)
329
+
330
+ update_row("jail_docket", { "identity" => identity }, {
331
+ "state" => "parole", "parole_clean_runs" => 0, "paroled_at" => now, "updated_at" => now
332
+ })
333
+ jail_entry(identity)
334
+ end
335
+
336
+ # Off the docket entirely -- no supervision, no history kept here (flake history is
337
+ # untouched). Returns true if there was something to release.
338
+ def release(identity)
339
+ identity = identity.to_s
340
+ return false unless jail_entry(identity)
341
+
342
+ execute("DELETE FROM jail_docket WHERE identity = ?", [identity])
343
+ true
344
+ end
345
+
346
+ # One clean run for a test on parole. On reaching config.parole_period consecutive
347
+ # clean runs the test auto-releases -- no human step, per the spec.
348
+ #
349
+ # Returns the updated entry. An auto-release returns the entry it had at the moment
350
+ # of release with state: :released (the row is gone by then, so this is the only
351
+ # chance the caller gets to report it). Returns nil if the test is not on parole.
352
+ def record_parole_pass(identity)
353
+ identity = identity.to_s
354
+ entry = jail_entry(identity)
355
+ return nil unless entry && entry[:state] == :parole
356
+
357
+ clean = entry[:parole_clean_runs].to_i + 1
358
+ period = parole_period
359
+
360
+ if clean >= period
361
+ execute("DELETE FROM jail_docket WHERE identity = ?", [identity])
362
+ return entry.merge(state: :released, parole_clean_runs: clean, released: true)
363
+ end
364
+
365
+ update_row("jail_docket", { "identity" => identity },
366
+ { "parole_clean_runs" => clean, "updated_at" => now })
367
+ jail_entry(identity).merge(released: false)
368
+ end
369
+
370
+ # A paroled test failed. No leniency: straight back to jail, both counters up, the
371
+ # clean-run progress discarded. The original jail reason is preserved -- the caller
372
+ # decides whether to overwrite it with a fresh one via #jail.
373
+ # Returns the updated entry, or nil if the test is not on parole.
374
+ def record_parole_violation(identity)
375
+ identity = identity.to_s
376
+ entry = jail_entry(identity)
377
+ return nil unless entry && entry[:state] == :parole
378
+
379
+ update_row("jail_docket", { "identity" => identity }, {
380
+ "state" => "jailed",
381
+ "parole_clean_runs" => 0,
382
+ "parole_violations" => entry[:parole_violations].to_i + 1,
383
+ "times_jailed" => entry[:times_jailed].to_i + 1,
384
+ "jailed_at" => now,
385
+ "paroled_at" => nil,
386
+ "updated_at" => now
387
+ })
388
+ jail_entry(identity)
389
+ end
390
+
391
+ # --- warrants --------------------------------------------------------------
392
+
393
+ # Writes a warrant to the blotter -- never to source. Re-issuing an existing warrant
394
+ # refreshes its label/location and bumps failed_runs rather than resetting issued_at,
395
+ # so "how long has this been flaky" survives.
396
+ def issue_warrant(identity, label:, file:, line:, reason: nil)
397
+ identity = identity.to_s
398
+ existing = warrant_entry(identity)
399
+ if existing
400
+ update_row("warrants", { "identity" => identity }, {
401
+ "label" => label, "file" => file, "line" => line,
402
+ "reason" => reason || existing[:reason],
403
+ "last_seen_at" => now,
404
+ "times_seen" => existing[:times_seen].to_i + 1,
405
+ "failed_runs" => existing[:failed_runs].to_i + 1
406
+ })
407
+ else
408
+ insert_row("warrants", {
409
+ "identity" => identity, "label" => label, "file" => file, "line" => line,
410
+ "reason" => reason, "issued_at" => now, "last_seen_at" => now,
411
+ "times_seen" => 1, "clean_runs" => 0, "failed_runs" => 1
412
+ })
413
+ end
414
+ warrant_entry(identity)
415
+ end
416
+
417
+ # Most recently issued first.
418
+ def warrants
419
+ rows(query("SELECT * FROM warrants ORDER BY issued_at DESC, identity ASC"))
420
+ end
421
+
422
+ def warrant_entry(identity)
423
+ row(query("SELECT * FROM warrants WHERE identity = ?", [identity.to_s]).first)
424
+ end
425
+
426
+ # Manual clear -- `constable warrants release PATH:LINE`. Returns true if there was
427
+ # a warrant to clear.
428
+ def clear_warrant(identity)
429
+ identity = identity.to_s
430
+ return false unless warrant_entry(identity)
431
+
432
+ execute("DELETE FROM warrants WHERE identity = ?", [identity])
433
+ true
434
+ end
435
+
436
+ # Records this run's verdict on a standing warrant.
437
+ #
438
+ # cleared: true -> every retry passed. The warrant is lifted and the row removed.
439
+ # cleared: false -> at least one retry failed. Still under warrant, non-blocking.
440
+ #
441
+ # Returns the entry either way -- the cleared one carries state: :cleared and its
442
+ # final counters, since the row no longer exists for the reporter to look up.
443
+ # Returns nil when there is no warrant on this identity.
444
+ def touch_warrant(identity, cleared:)
445
+ identity = identity.to_s
446
+ entry = warrant_entry(identity)
447
+ return nil unless entry
448
+
449
+ if cleared
450
+ execute("DELETE FROM warrants WHERE identity = ?", [identity])
451
+ return entry.merge(
452
+ state: :cleared, cleared: true, last_seen_at: now,
453
+ times_seen: entry[:times_seen].to_i + 1, clean_runs: entry[:clean_runs].to_i + 1
454
+ )
455
+ end
456
+
457
+ update_row("warrants", { "identity" => identity }, {
458
+ "last_seen_at" => now,
459
+ "times_seen" => entry[:times_seen].to_i + 1,
460
+ "failed_runs" => entry[:failed_runs].to_i + 1
461
+ })
462
+ warrant_entry(identity).merge(state: :standing, cleared: false)
463
+ end
464
+
465
+ # --- durations -------------------------------------------------------------
466
+
467
+ # Feeds two things: the parallel workers' load balancer (longest test first) and the
468
+ # SLOWEST section of the summary.
469
+ #
470
+ # The average is rolling, not lifetime: past ROLLING_WINDOW samples each new timing
471
+ # is weighted 1/ROLLING_WINDOW, so a test that got slower shows up as slower within
472
+ # a handful of runs instead of being dragged back by ancient data.
473
+ def record_duration(identity, duration)
474
+ identity = identity.to_s
475
+ duration = duration.to_f
476
+ existing = row(query("SELECT * FROM durations WHERE identity = ?", [identity]).first)
477
+
478
+ if existing
479
+ weight = [existing[:samples].to_i, ROLLING_WINDOW].min
480
+ average = ((existing[:average].to_f * weight) + duration) / (weight + 1)
481
+ update_row("durations", { "identity" => identity }, {
482
+ "average" => average,
483
+ "last_duration" => duration,
484
+ "max_duration" => [existing[:max_duration].to_f, duration].max,
485
+ "samples" => existing[:samples].to_i + 1,
486
+ "updated_at" => now
487
+ })
488
+ else
489
+ insert_row("durations", {
490
+ "identity" => identity, "average" => duration, "last_duration" => duration,
491
+ "max_duration" => duration, "samples" => 1, "updated_at" => now
492
+ })
493
+ end
494
+ row(query("SELECT * FROM durations WHERE identity = ?", [identity]).first)
495
+ end
496
+
497
+ # { identity(String) => average_seconds(Float) }. The one read method that is not
498
+ # symbol-keyed, because its keys are content hashes rather than field names.
499
+ def duration_index
500
+ query("SELECT identity, average FROM durations").to_h { |r| [r["identity"], r["average"].to_f] }
501
+ end
502
+
503
+ # Slowest tests by rolling average, worst first. Display fields come from the most
504
+ # recent flake_history row for the identity -- record_duration is given only a
505
+ # timing, so the label lives where the results live.
506
+ def slowest(limit: 10)
507
+ rows(query(<<~SQL, [limit.to_i]))
508
+ SELECT d.identity, d.average, d.last_duration, d.max_duration, d.samples, d.updated_at,
509
+ (SELECT h.label FROM flake_history h WHERE h.identity = d.identity ORDER BY h.id DESC LIMIT 1) AS label,
510
+ (SELECT h.file FROM flake_history h WHERE h.identity = d.identity ORDER BY h.id DESC LIMIT 1) AS file,
511
+ (SELECT h.line FROM flake_history h WHERE h.identity = d.identity ORDER BY h.id DESC LIMIT 1) AS line
512
+ FROM durations d
513
+ ORDER BY d.average DESC, d.identity ASC
514
+ LIMIT ?
515
+ SQL
516
+ end
517
+
518
+ # --- coverage --------------------------------------------------------------
519
+
520
+ # One snapshot per covered run. +files+ is a { path => percent } Hash (an Array of
521
+ # paths is accepted too, and treated as unpatrolled); it round-trips as JSON so the
522
+ # beat report can rebuild a per-file breakdown from history.
523
+ def record_coverage(run_id, percent:, files:)
524
+ files ||= {}
525
+ insert_row("coverage_snapshots", {
526
+ "run_id" => run_id,
527
+ "percent" => percent.to_f,
528
+ "file_count" => file_count_for(files),
529
+ "unpatrolled" => unpatrolled_for(files),
530
+ "files" => JSON.generate(files),
531
+ "recorded_at" => now
532
+ })
533
+ end
534
+
535
+ # Most recent first. Reverse it to plot a line.
536
+ def coverage_trend(limit: 30)
537
+ rows(query("SELECT * FROM coverage_snapshots ORDER BY id DESC LIMIT ?", [limit.to_i]))
538
+ end
539
+
540
+ private
541
+
542
+ # --- schema ----------------------------------------------------------------
543
+
544
+ # Type names per dialect. Values are never interpolated into SQL anywhere in this
545
+ # file -- these are column *types*, fixed strings from the map below.
546
+ def types
547
+ {
548
+ pk: "INTEGER PRIMARY KEY AUTOINCREMENT",
549
+ ident: "VARCHAR(64)",
550
+ text: "TEXT",
551
+ int: "INTEGER",
552
+ float: "REAL",
553
+ time: "VARCHAR(32)"
554
+ }
555
+ end
556
+
557
+ def type(name) = types.fetch(name)
558
+
559
+ def ddl_statements
560
+ [
561
+ <<~SQL,
562
+ CREATE TABLE IF NOT EXISTS schema_meta (
563
+ name #{type(:ident)} PRIMARY KEY,
564
+ value #{type(:text)} NOT NULL
565
+ )
566
+ SQL
567
+ <<~SQL,
568
+ CREATE TABLE IF NOT EXISTS runs (
569
+ id #{type(:pk)},
570
+ seed #{type(:ident)},
571
+ mode #{type(:ident)},
572
+ full_run #{type(:int)} NOT NULL DEFAULT 0,
573
+ started_at #{type(:time)} NOT NULL,
574
+ finished_at #{type(:time)},
575
+ duration #{type(:float)},
576
+ total #{type(:int)},
577
+ passed #{type(:int)},
578
+ failed #{type(:int)},
579
+ jailed #{type(:int)},
580
+ skipped #{type(:int)},
581
+ warranted #{type(:int)},
582
+ parole_violations #{type(:int)},
583
+ totals #{type(:text)}
584
+ )
585
+ SQL
586
+ <<~SQL,
587
+ CREATE TABLE IF NOT EXISTS flake_history (
588
+ id #{type(:pk)},
589
+ run_id #{type(:int)},
590
+ identity #{type(:ident)} NOT NULL,
591
+ label #{type(:text)},
592
+ case_name #{type(:text)},
593
+ description #{type(:text)},
594
+ file #{type(:text)},
595
+ line #{type(:int)},
596
+ kind #{type(:ident)},
597
+ tier #{type(:ident)},
598
+ status #{type(:ident)} NOT NULL,
599
+ duration #{type(:float)},
600
+ failure_message #{type(:text)},
601
+ recorded_at #{type(:time)} NOT NULL
602
+ )
603
+ SQL
604
+ <<~SQL,
605
+ CREATE TABLE IF NOT EXISTS jail_docket (
606
+ identity #{type(:ident)} PRIMARY KEY,
607
+ label #{type(:text)},
608
+ file #{type(:text)},
609
+ line #{type(:int)},
610
+ reason #{type(:text)},
611
+ jailed_at #{type(:time)} NOT NULL,
612
+ state #{type(:ident)} NOT NULL DEFAULT 'jailed',
613
+ parole_clean_runs #{type(:int)} NOT NULL DEFAULT 0,
614
+ parole_violations #{type(:int)} NOT NULL DEFAULT 0,
615
+ times_jailed #{type(:int)} NOT NULL DEFAULT 1,
616
+ paroled_at #{type(:time)},
617
+ updated_at #{type(:time)} NOT NULL
618
+ )
619
+ SQL
620
+ <<~SQL,
621
+ CREATE TABLE IF NOT EXISTS warrants (
622
+ identity #{type(:ident)} PRIMARY KEY,
623
+ label #{type(:text)},
624
+ file #{type(:text)},
625
+ line #{type(:int)},
626
+ reason #{type(:text)},
627
+ issued_at #{type(:time)} NOT NULL,
628
+ last_seen_at #{type(:time)},
629
+ times_seen #{type(:int)} NOT NULL DEFAULT 0,
630
+ clean_runs #{type(:int)} NOT NULL DEFAULT 0,
631
+ failed_runs #{type(:int)} NOT NULL DEFAULT 0
632
+ )
633
+ SQL
634
+ <<~SQL,
635
+ CREATE TABLE IF NOT EXISTS durations (
636
+ identity #{type(:ident)} PRIMARY KEY,
637
+ average #{type(:float)} NOT NULL DEFAULT 0,
638
+ last_duration #{type(:float)},
639
+ max_duration #{type(:float)},
640
+ samples #{type(:int)} NOT NULL DEFAULT 0,
641
+ updated_at #{type(:time)} NOT NULL
642
+ )
643
+ SQL
644
+ <<~SQL
645
+ CREATE TABLE IF NOT EXISTS coverage_snapshots (
646
+ id #{type(:pk)},
647
+ run_id #{type(:int)},
648
+ percent #{type(:float)},
649
+ file_count #{type(:int)},
650
+ unpatrolled #{type(:int)},
651
+ files #{type(:text)},
652
+ recorded_at #{type(:time)} NOT NULL
653
+ )
654
+ SQL
655
+ ]
656
+ end
657
+
658
+ # [index name, table, columns]. Only VARCHAR/INTEGER columns are indexed -- MySQL
659
+ # refuses to index a TEXT column without a prefix length.
660
+ def index_statements
661
+ [
662
+ ["idx_flake_identity", "flake_history", %w[identity id]],
663
+ ["idx_flake_run", "flake_history", %w[run_id]],
664
+ ["idx_flake_status", "flake_history", %w[status]],
665
+ ["idx_jail_state", "jail_docket", %w[state]],
666
+ ["idx_coverage_run", "coverage_snapshots", %w[run_id]]
667
+ ]
668
+ end
669
+
670
+ def create_index(name, table, columns)
671
+ execute_raw("CREATE INDEX IF NOT EXISTS #{name} ON #{table} (#{columns.join(", ")})")
672
+ end
673
+
674
+ def stamp_schema_version!
675
+ existing = query("SELECT value FROM schema_meta WHERE name = ?", ["schema_version"]).first
676
+ if existing
677
+ # Nothing to migrate yet -- version 1 is the first schema. A future release adds
678
+ # its migrations here, keyed off existing["value"].to_i.
679
+ nil
680
+ else
681
+ execute("INSERT INTO schema_meta (name, value) VALUES (?, ?)",
682
+ ["schema_version", SCHEMA_VERSION.to_s])
683
+ end
684
+ end
685
+
686
+ # --- row plumbing ----------------------------------------------------------
687
+
688
+ def insert_row(table, attrs)
689
+ attrs = attrs.compact
690
+ columns = attrs.keys
691
+ sql = "INSERT INTO #{table} (#{columns.join(", ")}) VALUES (#{placeholders(columns.size)})"
692
+ insert(sql, attrs.values, table)
693
+ end
694
+
695
+ def update_row(table, where, attrs)
696
+ return if attrs.empty?
697
+
698
+ assignments = attrs.keys.map { |c| "#{c} = ?" }.join(", ")
699
+ conditions = where.keys.map { |c| "#{c} = ?" }.join(" AND ")
700
+ execute("UPDATE #{table} SET #{assignments} WHERE #{conditions}", attrs.values + where.values)
701
+ end
702
+
703
+ def placeholders(count) = Array.new(count, "?").join(", ")
704
+
705
+ def count(sql, binds = [])
706
+ result = query(sql, binds).first
707
+ return 0 unless result
708
+
709
+ (result["c"] || result.values.first).to_i
710
+ end
711
+
712
+ # Moves a single primary-keyed row from one identity to another. :moved when the row
713
+ # relocated, :merged when the target already had one (the old row is dropped rather
714
+ # than overwriting live state), :none when there was nothing to move.
715
+ def move_keyed_row(table, old_identity, new_identity)
716
+ old_exists = count("SELECT COUNT(*) AS c FROM #{table} WHERE identity = ?", [old_identity]).positive?
717
+ return :none unless old_exists
718
+
719
+ new_exists = count("SELECT COUNT(*) AS c FROM #{table} WHERE identity = ?", [new_identity]).positive?
720
+ if new_exists
721
+ execute("DELETE FROM #{table} WHERE identity = ?", [old_identity])
722
+ :merged
723
+ else
724
+ execute("UPDATE #{table} SET identity = ? WHERE identity = ?", [new_identity, old_identity])
725
+ :moved
726
+ end
727
+ end
728
+
729
+ # --- value coercion --------------------------------------------------------
730
+
731
+ # The single place a driver row becomes a Constable hash: symbol keys, Integers and
732
+ # Floats where the column says so, Symbols for enum-ish columns, parsed JSON for the
733
+ # blob columns, nil left as nil.
734
+ def row(raw)
735
+ return nil if raw.nil?
736
+
737
+ raw.each_with_object({}) do |(column, value), out|
738
+ column = column.to_s
739
+ out[COLUMN_ALIASES.fetch(column, column.to_sym)] = coerce(column, value)
740
+ end
741
+ end
742
+
743
+ def rows(raws) = Array(raws).map { |r| row(r) }
744
+
745
+ def coerce(column, value)
746
+ return nil if value.nil?
747
+
748
+ case column
749
+ when *INTEGER_COLUMNS then value.to_i
750
+ when *FLOAT_COLUMNS then value.to_f
751
+ when *BOOLEAN_COLUMNS then truthy_column(value)
752
+ when *SYMBOL_COLUMNS then to_symbol(value)
753
+ when *SYMBOL_KEYED_JSON_COLUMNS then parse_json(value, symbolize: true)
754
+ when *STRING_KEYED_JSON_COLUMNS then parse_json(value, symbolize: false)
755
+ when "seed" then numeric_seed(value)
756
+ else value.is_a?(String) ? value : value.to_s
757
+ end
758
+ end
759
+
760
+ # A seed is an Integer in practice (`--seed 8841`) but is stored as text so an
761
+ # unusual one is never truncated. Hand back the Integer when it round-trips.
762
+ def numeric_seed(value)
763
+ string = value.to_s
764
+ string.match?(/\A-?\d+\z/) ? string.to_i : string
765
+ end
766
+
767
+ def truthy_column(value)
768
+ return value if [true, false].include?(value)
769
+ return false if value.to_s.empty?
770
+
771
+ !%w[0 f false].include?(value.to_s.downcase)
772
+ end
773
+
774
+ def to_symbol(value)
775
+ string = value.to_s
776
+ string.empty? ? nil : string.to_sym
777
+ end
778
+
779
+ def parse_json(value, symbolize:)
780
+ return value unless value.is_a?(String)
781
+
782
+ JSON.parse(value, symbolize_names: symbolize)
783
+ rescue JSON::ParserError
784
+ value
785
+ end
786
+
787
+ def symbolize(hash)
788
+ return {} unless hash.respond_to?(:each_pair)
789
+
790
+ hash.each_with_object({}) { |(k, v), out| out[k.to_sym] = v }
791
+ end
792
+
793
+ # Accepts a Constable::Result or the Hash a worker shipped over the pipe, and
794
+ # flattens it into the columns flake_history stores.
795
+ def result_attributes(result)
796
+ hash = result.is_a?(Hash) ? symbolize(result) : symbolize(result.to_h)
797
+ failure = hash[:failure]
798
+ failure = symbolize(failure) if failure.is_a?(Hash)
799
+ case_name = hash[:case_name].to_s
800
+ description = hash[:description].to_s
801
+
802
+ {
803
+ identity: hash[:identity].to_s,
804
+ label: hash[:label] || %(#{case_name} "#{description}"),
805
+ case_name: case_name,
806
+ description: description,
807
+ file: hash[:file],
808
+ line: hash[:line],
809
+ kind: (hash[:kind] || :native).to_s,
810
+ tier: hash[:tier]&.to_s,
811
+ status: (hash[:status] || :passed).to_s,
812
+ duration: hash[:duration].to_f,
813
+ failure_message: failure.is_a?(Hash) ? failure[:message] : failure&.message
814
+ }
815
+ end
816
+
817
+ def file_count_for(files)
818
+ files.respond_to?(:size) ? files.size : 0
819
+ end
820
+
821
+ # "Unpatrolled" is a file with zero executed lines -- usually a file the suite never
822
+ # touched at all, which is worth naming separately from a thinly covered one.
823
+ def unpatrolled_for(files)
824
+ case files
825
+ when Hash then files.count { |_, percent| percent.to_f.zero? }
826
+ when Array then files.size
827
+ else 0
828
+ end
829
+ end
830
+
831
+ def parole_period
832
+ period = config.respond_to?(:parole_period) ? config.parole_period.to_i : 0
833
+ period.positive? ? period : 10
834
+ end
835
+
836
+ def now = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%LZ")
837
+
838
+ # --- driver hooks ----------------------------------------------------------
839
+
840
+ def connect! = raise(NotImplementedError, "#{self.class}#connect!")
841
+ def execute_raw(_sql) = raise(NotImplementedError, "#{self.class}#execute_raw")
842
+ def query(_sql, _binds = []) = raise(NotImplementedError, "#{self.class}#query")
843
+ def execute(_sql, _binds = []) = raise(NotImplementedError, "#{self.class}#execute")
844
+ def insert(_sql, _binds, _table) = raise(NotImplementedError, "#{self.class}#insert")
845
+ end
846
+ end
847
+ end