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 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
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "minitest/test_task"
5
+
6
+ Minitest::TestTask.create
7
+
8
+ require "standard/rake"
9
+
10
+ task default: %i[test standard]
data/exe/greenroom ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../lib/greenroom/cli"
5
+
6
+ exit Greenroom::CLI.run(ARGV)
@@ -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