greenroom 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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +16 -0
- data/LICENSE.txt +21 -0
- data/README.md +165 -0
- data/Rakefile +10 -0
- data/exe/greenroom +6 -0
- data/lib/greenroom/census/cursor.rb +48 -0
- data/lib/greenroom/census/job.rb +118 -0
- data/lib/greenroom/census/middleware.rb +30 -0
- data/lib/greenroom/census/run.rb +59 -0
- data/lib/greenroom/cli/check.rb +454 -0
- data/lib/greenroom/cli/judge.rb +273 -0
- data/lib/greenroom/cli.rb +30 -0
- data/lib/greenroom/digest.rb +28 -0
- data/lib/greenroom/experiment_behavior.rb +82 -0
- data/lib/greenroom/recorder.rb +219 -0
- data/lib/greenroom/reporter/new_relic.rb +15 -0
- data/lib/greenroom/reporter.rb +61 -0
- data/lib/greenroom/version.rb +5 -0
- data/lib/greenroom.rb +57 -0
- data/sig/greenroom.rbs +4 -0
- metadata +105 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: d42a959640478bcc8b5b8fcc2e6c65773a8a6158fc4fdc7a7f9551b5023c2f9a
|
|
4
|
+
data.tar.gz: b0606a05f1aeb4d4ce8d573a197f79260b01a6fa54522d763377e74b04e47c8c
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 76468d54ede95d81cd679caa2e221d690e52db078c8f64bd848d9a1a469d9a5ac8dec909719cf365fffc722a23badb798bf49f3db55c31fbf8225aa0db296ddc
|
|
7
|
+
data.tar.gz: 688524ef7f944fb15ca4bf29d24a6e1c56ab43b3d102dc868a065b1c6c1ad41a577c4fd34e3eb8b95a38699022838773fa9a31cc946cfafce04d2a1cda2a5bef
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
## [Unreleased]
|
|
2
|
+
|
|
3
|
+
## [0.1.0] - 2026-08-22
|
|
4
|
+
|
|
5
|
+
- Add `Greenroom::ExperimentBehavior`, the `enabled?`, `publish`, and `raised`
|
|
6
|
+
methods a host mixes into its own Scientist experiment class.
|
|
7
|
+
- Add `Greenroom::Recorder`, which counts rows, comparisons, mismatches, and
|
|
8
|
+
errors for a run, and carries its counts through an interruption.
|
|
9
|
+
- Add the census core: `Greenroom::Census::Run`, `Cursor`, `Job`, and
|
|
10
|
+
`Middleware`.
|
|
11
|
+
- Add `Greenroom.assert_registered!`, which fails at boot when no class has
|
|
12
|
+
registered with Scientist.
|
|
13
|
+
- Add `greenroom check`, which reads a commit and confirms that the original
|
|
14
|
+
method body reached the `use` block unchanged.
|
|
15
|
+
- Add `greenroom judge`, which reads census counts and returns a verdict and a
|
|
16
|
+
patch.
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 meganemura
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# Greenroom
|
|
2
|
+
|
|
3
|
+
Turn a [Scientist](https://github.com/github/scientist) experiment into
|
|
4
|
+
evidence, without running the candidate on a live request.
|
|
5
|
+
|
|
6
|
+
An experiment stays inactive by default: `Scientist::Default#enabled?`
|
|
7
|
+
returns false, so the candidate never runs and `publish` never sends
|
|
8
|
+
anything. A nightly census job installs a recorder for the duration of its
|
|
9
|
+
run. While that recorder is installed, the experiment turns on and the
|
|
10
|
+
recorder counts every comparison. The run ends with one aggregate event,
|
|
11
|
+
plus a limited number of mismatch samples. A sample carries a row
|
|
12
|
+
identifier and a digest of the value, never the value itself.
|
|
13
|
+
|
|
14
|
+
## What this gem provides
|
|
15
|
+
|
|
16
|
+
- `Greenroom::ExperimentBehavior`, a module with the `enabled?`, `publish`,
|
|
17
|
+
and `raised` methods a Scientist experiment class needs.
|
|
18
|
+
- `Greenroom::Recorder`, the thread-local counter a census job installs and
|
|
19
|
+
removes around its run.
|
|
20
|
+
- `Greenroom::Census::Run`, the per-row work a census job performs, plus
|
|
21
|
+
`Greenroom::Census::Job`, a thin `Sidekiq::IterableJob` wrapper around it.
|
|
22
|
+
- `Greenroom.assert_registered!`, a boot-time check for one common
|
|
23
|
+
misconfiguration (see below).
|
|
24
|
+
- `Greenroom::Reporter`, the seam between a recorded event and wherever it
|
|
25
|
+
is sent. The gem ships `Reporter::Null` (the default, does nothing) and
|
|
26
|
+
`Reporter::Memory` (collects events, for tests). A New Relic adapter
|
|
27
|
+
lives in `greenroom/reporter/new_relic`, a separate file a host requires
|
|
28
|
+
on purpose.
|
|
29
|
+
|
|
30
|
+
## Check a commit
|
|
31
|
+
|
|
32
|
+
`greenroom check` accepts a commit when it contains one safe experiment
|
|
33
|
+
change. The command compares the commit with its parent through Git and
|
|
34
|
+
compares the two Ruby syntax trees. It accepts the change when the old method
|
|
35
|
+
body moves into `use` unchanged and one private candidate method is added.
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
bundle exec greenroom check
|
|
39
|
+
bundle exec greenroom check <commit>
|
|
40
|
+
bundle exec greenroom check --json <commit>
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
An accepted change exits with status 0. A rejected change exits with status
|
|
44
|
+
1, and a usage error exits with status 2. The command writes one result line.
|
|
45
|
+
The JSON form writes one object for CI.
|
|
46
|
+
|
|
47
|
+
This check has been measured with synthetic Git repositories. It has not been
|
|
48
|
+
measured with a pull request from a production codebase.
|
|
49
|
+
|
|
50
|
+
## Setup
|
|
51
|
+
|
|
52
|
+
A host application writes three things.
|
|
53
|
+
|
|
54
|
+
### 1. An experiment class
|
|
55
|
+
|
|
56
|
+
`Scientist::Experiment` allows exactly one class per process to register
|
|
57
|
+
as the default; a later `include` silently takes that slot from an
|
|
58
|
+
earlier one. Because of this, `ExperimentBehavior` ships as a module, not
|
|
59
|
+
a class: the host's own class claims the slot, and the gem never competes
|
|
60
|
+
for it.
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
class Pricing::Experiment
|
|
64
|
+
include Scientist::Experiment # registers this class with Scientist
|
|
65
|
+
include Greenroom::ExperimentBehavior # adds enabled?, publish, raised
|
|
66
|
+
|
|
67
|
+
attr_reader :name
|
|
68
|
+
|
|
69
|
+
def initialize(name)
|
|
70
|
+
@name = name
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Include `Scientist::Experiment` first. `ExperimentBehavior#raised` must
|
|
76
|
+
come earlier in the ancestor chain than `Scientist::Experiment#raised` (the
|
|
77
|
+
one that re-raises) to override it. Ruby puts the module included later closer
|
|
78
|
+
to the class. The other include order would re-raise every internal failure
|
|
79
|
+
into the caller.
|
|
80
|
+
|
|
81
|
+
`Scientist.run("price-v2") { |e| ... }` and `Scientist::Experiment.new(name)`
|
|
82
|
+
both look up the registered class, so the rest of a host's code calls
|
|
83
|
+
Scientist exactly as the [Scientist README](https://github.com/github/scientist)
|
|
84
|
+
describes; nothing else changes.
|
|
85
|
+
|
|
86
|
+
### 2. A boot-time check
|
|
87
|
+
|
|
88
|
+
A host that includes only `ExperimentBehavior`, and forgets
|
|
89
|
+
`Scientist::Experiment`, gets no error: `enabled?` and `publish` both
|
|
90
|
+
exist, but `Scientist::Experiment.new` still returns the inert
|
|
91
|
+
`Scientist::Default`, so every experiment reports success while running no
|
|
92
|
+
candidate, forever. Call this once at boot to turn that silent
|
|
93
|
+
misconfiguration into a startup failure instead:
|
|
94
|
+
|
|
95
|
+
```ruby
|
|
96
|
+
# config/initializers/greenroom.rb
|
|
97
|
+
Greenroom.assert_registered!
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### 3. A Sidekiq server middleware
|
|
101
|
+
|
|
102
|
+
A census job installs a recorder before it reads the first row and removes
|
|
103
|
+
it when the run stops. If a row raises an exception that escapes
|
|
104
|
+
`each_iteration`, Sidekiq skips the job's `on_stop` hook entirely, so that
|
|
105
|
+
removal never happens. Sidekiq also reuses worker threads across jobs, so
|
|
106
|
+
the next, unrelated job on that thread would otherwise find an experiment
|
|
107
|
+
already turned on. Add this middleware to close that gap on every path out
|
|
108
|
+
of a job, including the ones that hit an error:
|
|
109
|
+
|
|
110
|
+
```ruby
|
|
111
|
+
# config/initializers/sidekiq.rb
|
|
112
|
+
Sidekiq.configure_server do |config|
|
|
113
|
+
config.server_middleware do |chain|
|
|
114
|
+
chain.add Greenroom::Census::Middleware
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Writing a census target
|
|
120
|
+
|
|
121
|
+
A census job reads rows through a small target object that answers three
|
|
122
|
+
questions: which experiment it feeds, which rows to read, and how to call
|
|
123
|
+
one row.
|
|
124
|
+
|
|
125
|
+
```ruby
|
|
126
|
+
class PriceV2Census
|
|
127
|
+
def experiment = "price-v2"
|
|
128
|
+
def scope = Order.all
|
|
129
|
+
def call(order) = Pricing.new(order).compute
|
|
130
|
+
end
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
`Greenroom::Census::Run#visit(row)` freezes the row, counts it, tells the
|
|
134
|
+
recorder which row is under examination, and calls the target through a
|
|
135
|
+
`reader` -- `Greenroom::Census::Reader::Direct` by default, which just
|
|
136
|
+
calls the block. A host reading from a database replica supplies its own
|
|
137
|
+
reader with the same one-method interface; this gem does not ship a
|
|
138
|
+
replica-reading implementation.
|
|
139
|
+
|
|
140
|
+
`Greenroom::Census::Job` wires a target's `reader` and `each_iteration`
|
|
141
|
+
into `Sidekiq::IterableJob`, installs and restores a `Greenroom::Recorder`
|
|
142
|
+
keyed on the job's `jid`, and sends the `GreenroomCensusProgress` and
|
|
143
|
+
`GreenroomCensus` events described below from `on_stop` and `on_complete`.
|
|
144
|
+
Its default `row_enumerator` reads through Sidekiq's own
|
|
145
|
+
`active_record_records_enumerator`, so a target whose `scope` is an
|
|
146
|
+
ActiveRecord relation needs nothing beyond `target(*args)`; a host whose
|
|
147
|
+
target's `scope` is something else (an array, a CSV) overrides
|
|
148
|
+
`row_enumerator` instead.
|
|
149
|
+
|
|
150
|
+
## Events
|
|
151
|
+
|
|
152
|
+
| Name | Sent from | Carries |
|
|
153
|
+
|---|---|---|
|
|
154
|
+
| `GreenroomComparison` | `ExperimentBehavior#publish`, when no recorder is installed | one comparison's outcome |
|
|
155
|
+
| `GreenroomInternalError` | `ExperimentBehavior#raised` | the operation and exception class that failed inside this gem's own instrumentation |
|
|
156
|
+
| `GreenroomCensusProgress` | `Recorder#flush_segment` | a sign that the run is still alive, and any mismatch samples gathered so far |
|
|
157
|
+
| `GreenroomMismatch` | `Recorder#flush_segment`, one per sample | a row identifier and digests, never the value itself |
|
|
158
|
+
| `GreenroomCensus` | `Recorder#flush_total` | the run's full counts: `rows_scanned`, `comparisons`, `mismatches`, `ignored`, `candidate_errors`, `control_errors`, `row_errors` |
|
|
159
|
+
|
|
160
|
+
A judge reads `comparisons` and the other counts from `GreenroomCensus`
|
|
161
|
+
alone, never from `GreenroomCensusProgress`. A run sends one
|
|
162
|
+
`GreenroomCensus` event for each recorded experiment name. It sends an event
|
|
163
|
+
even when it triggered zero comparisons: the event's presence, with
|
|
164
|
+
`comparisons` at zero, is how a reader tells "ran and found nothing to
|
|
165
|
+
compare" apart from "did not run at all".
|
data/Rakefile
ADDED
data/exe/greenroom
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Greenroom
|
|
4
|
+
module Census
|
|
5
|
+
# Bundles the recorder's counts into the cursor Sidekiq's Iteration
|
|
6
|
+
# persists to Redis on every row, so that a resume after an
|
|
7
|
+
# interruption restarts the counts at the same row it restarts
|
|
8
|
+
# iteration at.
|
|
9
|
+
#
|
|
10
|
+
# Sidekiq writes whichever cursor was attached to the row it stopped
|
|
11
|
+
# on, and resumes iteration from that row. If the counts were kept
|
|
12
|
+
# anywhere else (an instance variable, a separate Redis key updated on
|
|
13
|
+
# its own schedule), a resume could restart iteration at row N while
|
|
14
|
+
# the counts still reflected some other row -- double-counting or
|
|
15
|
+
# skipping whatever rows fell in the gap. Attaching the snapshot to the
|
|
16
|
+
# very cursor that names the resume position keeps position and counts
|
|
17
|
+
# describing the same instant, across a plain interruption and across
|
|
18
|
+
# `each_iteration` raising (Sidekiq flushes the last cursor either way).
|
|
19
|
+
module Cursor
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
# Wraps `enumerator`, which must itself yield `[object, position]`
|
|
23
|
+
# pairs. Built with `Enumerator.new` and read from `enumerator` lazily
|
|
24
|
+
# -- one pair at a time, only as the caller pulls -- rather than
|
|
25
|
+
# eagerly, because eagerly building every pair up front would read
|
|
26
|
+
# `recorder.snapshot` once, at build time, instead of once per row.
|
|
27
|
+
# Every row would then carry the same stale counts, and a resume from
|
|
28
|
+
# any of them would replay rows already processed.
|
|
29
|
+
def decorate(enumerator, recorder)
|
|
30
|
+
Enumerator.new do |yielder|
|
|
31
|
+
enumerator.each do |object, position|
|
|
32
|
+
yielder << [object, {"position" => position, "counts" => recorder.snapshot}]
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# The inverse of the "counts" half of `decorate`: given the cursor
|
|
38
|
+
# Sidekiq handed back on resume (or nil, on a fresh start), returns
|
|
39
|
+
# `[position, counts]` so a caller can feed `position` back into its
|
|
40
|
+
# own enumerator and `counts` into `Recorder#restore`.
|
|
41
|
+
def split(cursor)
|
|
42
|
+
return [nil, nil] if cursor.nil?
|
|
43
|
+
|
|
44
|
+
[cursor["position"], cursor["counts"]]
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# `sidekiq/iterable_job` alone leaves `Sidekiq::Job` (which supplies `jid`,
|
|
4
|
+
# among other things) only partially loaded; the full mixin this job needs
|
|
5
|
+
# requires `sidekiq` itself first.
|
|
6
|
+
require "sidekiq"
|
|
7
|
+
require "sidekiq/iterable_job"
|
|
8
|
+
|
|
9
|
+
require "greenroom/recorder"
|
|
10
|
+
require "greenroom/census/cursor"
|
|
11
|
+
require "greenroom/census/middleware"
|
|
12
|
+
require "greenroom/census/run"
|
|
13
|
+
|
|
14
|
+
module Greenroom
|
|
15
|
+
module Census
|
|
16
|
+
# A Sidekiq iterable job that turns a host's target (see `Census::Run`)
|
|
17
|
+
# into a nightly census: a recorder is installed for exactly the
|
|
18
|
+
# duration of one run, so the experiment it drives is on only inside
|
|
19
|
+
# this job (see `ExperimentBehavior#enabled?`).
|
|
20
|
+
#
|
|
21
|
+
# A host subclasses this and defines `target(*args)`, returning an
|
|
22
|
+
# object with `experiment`, `scope`, and `call` (see the README). A
|
|
23
|
+
# target whose `scope` is an ActiveRecord relation needs nothing else;
|
|
24
|
+
# a target whose `scope` is something else overrides `row_enumerator`
|
|
25
|
+
# below instead.
|
|
26
|
+
class Job
|
|
27
|
+
include Sidekiq::IterableJob
|
|
28
|
+
|
|
29
|
+
# A host reading from a primary/replica split overrides this to
|
|
30
|
+
# return a reader that connects to the replica; the default reads
|
|
31
|
+
# through whatever connection is already active.
|
|
32
|
+
def reader
|
|
33
|
+
Reader::Direct.new
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The hook `Sidekiq::Job::Iterable#perform` calls to get this run's
|
|
37
|
+
# row source, on the first call and again on every resume. A
|
|
38
|
+
# subclass cannot supply its row source through `super`: the host's
|
|
39
|
+
# rows are only needed after `Cursor.split` has pulled greenroom's
|
|
40
|
+
# own position out of the combined cursor Sidekiq hands back, and
|
|
41
|
+
# `super` would still be carrying the cursor before that split.
|
|
42
|
+
# `row_enumerator` below is the seam a subclass overrides instead.
|
|
43
|
+
def build_enumerator(*args, cursor:)
|
|
44
|
+
assert_middleware_registered!
|
|
45
|
+
# A second, narrower guard than `Census::Middleware`'s own: this
|
|
46
|
+
# one only protects one census job from a recorder a *previous*
|
|
47
|
+
# census job left installed on this thread. It cannot help an
|
|
48
|
+
# unrelated job, which is why the middleware above is still
|
|
49
|
+
# required (see its own comment for that general case).
|
|
50
|
+
Recorder.uninstall
|
|
51
|
+
position, counts = Cursor.split(cursor)
|
|
52
|
+
@target = target(*args)
|
|
53
|
+
@recorder = Recorder.new(run_id: jid, experiment: @target.experiment)
|
|
54
|
+
@recorder.restore(counts) if counts
|
|
55
|
+
Recorder.install(@recorder)
|
|
56
|
+
@run = Run.new(target: @target, recorder: @recorder, reader: reader)
|
|
57
|
+
Cursor.decorate(row_enumerator(*args, cursor: position), @recorder)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# The default row source: `active_record_records_enumerator` comes
|
|
61
|
+
# from `Sidekiq::IterableJob` itself, so a target whose `scope` is an
|
|
62
|
+
# ActiveRecord relation needs nothing else. A target whose `scope` is
|
|
63
|
+
# not an ActiveRecord relation (an array, a CSV) overrides this
|
|
64
|
+
# method alone -- `build_enumerator` above stays correct regardless
|
|
65
|
+
# of where the rows actually come from.
|
|
66
|
+
#
|
|
67
|
+
# `cursor:` names the position alone here, in the vocabulary a host
|
|
68
|
+
# already knows from Sidekiq's own helper (its `cursor:` carries the
|
|
69
|
+
# same meaning). The combined `{"position" => ..., "counts" => ...}`
|
|
70
|
+
# cursor `build_enumerator` receives is greenroom's own bookkeeping;
|
|
71
|
+
# a subclass overriding this method never sees it.
|
|
72
|
+
def row_enumerator(*args, cursor:)
|
|
73
|
+
active_record_records_enumerator(@target.scope, cursor: cursor)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def each_iteration(row, *args)
|
|
77
|
+
@run.visit(row)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Called on every interruption, not only on completion -- this is the
|
|
81
|
+
# "the run is still alive" signal (see `Recorder#flush_segment`), so
|
|
82
|
+
# it must fire whether the run finishes this perform or is interrupted
|
|
83
|
+
# and resumed later.
|
|
84
|
+
#
|
|
85
|
+
# Uninstalling here, rather than leaving it for the next
|
|
86
|
+
# `build_enumerator` to do, keeps the thread-local recorder from
|
|
87
|
+
# surviving into whatever unrelated work this thread picks up next in
|
|
88
|
+
# between Sidekiq perform calls.
|
|
89
|
+
def on_stop
|
|
90
|
+
@recorder.flush_segment(Greenroom.reporter)
|
|
91
|
+
Recorder.uninstall
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Reads `@recorder`, not `Recorder.current`: Sidekiq calls `on_stop`
|
|
95
|
+
# before `on_complete`, and `on_stop` has already uninstalled the
|
|
96
|
+
# thread-local by the time `on_complete` runs.
|
|
97
|
+
def on_complete
|
|
98
|
+
@recorder.flush_total(Greenroom.reporter)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
# Raised before `build_enumerator` reads a single row (see the
|
|
104
|
+
# comment on `Census::Middleware` for why a missing registration
|
|
105
|
+
# matters): a host that forgot to add the middleware would otherwise
|
|
106
|
+
# only discover the gap the first time a row raises in production,
|
|
107
|
+
# when the leak it causes is already live on some other job.
|
|
108
|
+
def assert_middleware_registered!
|
|
109
|
+
return if Sidekiq.default_configuration.server_middleware.exists?(Middleware)
|
|
110
|
+
|
|
111
|
+
raise MiddlewareNotRegisteredError,
|
|
112
|
+
"Greenroom::Census::Middleware is not registered as a Sidekiq server " \
|
|
113
|
+
"middleware. Add it inside Sidekiq.configure_server: " \
|
|
114
|
+
"`config.server_middleware { |chain| chain.add Greenroom::Census::Middleware }`."
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "greenroom/recorder"
|
|
4
|
+
|
|
5
|
+
module Greenroom
|
|
6
|
+
module Census
|
|
7
|
+
# A Sidekiq server middleware that closes the one gap `Census::Job`
|
|
8
|
+
# cannot close on its own: when `each_iteration` raises, Sidekiq
|
|
9
|
+
# re-raises out of `perform` without calling `on_stop`, so the
|
|
10
|
+
# thread-local recorder `build_enumerator` installed is never
|
|
11
|
+
# uninstalled. Sidekiq reuses worker threads across jobs, so without
|
|
12
|
+
# this middleware, the next unrelated job Sidekiq happens to run on that
|
|
13
|
+
# thread would find an experiment already turned on -- the one safety
|
|
14
|
+
# property this gem promises (a candidate runs only inside a census)
|
|
15
|
+
# would be broken by an ordinary job failure.
|
|
16
|
+
#
|
|
17
|
+
# `Census::Job#build_enumerator` also uninstalls at its own start, as a
|
|
18
|
+
# second, narrower guard between one census job and the next -- but that
|
|
19
|
+
# only covers census jobs calling each other; it cannot help an
|
|
20
|
+
# unrelated job. A host must add this middleware to its Sidekiq server
|
|
21
|
+
# middleware chain for the guarantee to hold in general.
|
|
22
|
+
class Middleware
|
|
23
|
+
def call(_job_instance, _job_payload, _queue)
|
|
24
|
+
yield
|
|
25
|
+
ensure
|
|
26
|
+
Greenroom::Recorder.uninstall
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Greenroom
|
|
4
|
+
module Census
|
|
5
|
+
# Where a row is actually read from. `Direct` just calls the block; a
|
|
6
|
+
# replica-reading implementation (`ActiveRecord::Base.connected_to(role:
|
|
7
|
+
# :reading, prevent_writes: true)`) is a separate concern from the
|
|
8
|
+
# census core and is not implemented here -- this one-method interface
|
|
9
|
+
# (`#call { }`) is fixed now so a host can supply its own without
|
|
10
|
+
# touching `Run`.
|
|
11
|
+
module Reader
|
|
12
|
+
class Direct
|
|
13
|
+
def call
|
|
14
|
+
yield
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Does the work of one row: freeze it, count it, tell the recorder which
|
|
20
|
+
# row is being looked at, then call the host's target through the
|
|
21
|
+
# reader. Kept as a single, small method so a census job's
|
|
22
|
+
# `each_iteration` (see `Census::Job`) is just `@run.visit(row)`.
|
|
23
|
+
class Run
|
|
24
|
+
def initialize(target:, recorder:, reader:)
|
|
25
|
+
@target = target
|
|
26
|
+
@recorder = recorder
|
|
27
|
+
@reader = reader
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The control and candidate blocks run on the same row object, and
|
|
31
|
+
# Scientist shuffles their execution order; without freezing, a
|
|
32
|
+
# candidate's write would look like a mismatch caused by the control
|
|
33
|
+
# (or vice versa) rather than what it is, a bug in that one row's
|
|
34
|
+
# candidate.
|
|
35
|
+
#
|
|
36
|
+
# A raising row is caught here rather than left to escape: one bad row
|
|
37
|
+
# must not stop the rest of the census, and `record_row_error` keeps
|
|
38
|
+
# the count of rows that never reached a comparison visible instead of
|
|
39
|
+
# silently inflating `rows_scanned` beyond what was actually examined.
|
|
40
|
+
# `StandardError`, not `Exception`, so a signal like `SystemExit`
|
|
41
|
+
# still propagates; `Census::Middleware` is this gem's backstop for a
|
|
42
|
+
# row that escapes some other way.
|
|
43
|
+
def visit(row)
|
|
44
|
+
row.freeze
|
|
45
|
+
@recorder.count_row
|
|
46
|
+
@recorder.subject = subject_for(row)
|
|
47
|
+
@reader.call { @target.call(row) }
|
|
48
|
+
rescue => e
|
|
49
|
+
@recorder.record_row_error(e)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def subject_for(row)
|
|
55
|
+
row.id if row.respond_to?(:id)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|