jobpayload 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 +52 -0
- data/LICENSE +21 -0
- data/README.md +622 -0
- data/exe/jobpayload +6 -0
- data/lib/jobpayload/boot.rb +49 -0
- data/lib/jobpayload/canonical.rb +87 -0
- data/lib/jobpayload/case_registry.rb +86 -0
- data/lib/jobpayload/checker.rb +187 -0
- data/lib/jobpayload/cli.rb +221 -0
- data/lib/jobpayload/errors.rb +16 -0
- data/lib/jobpayload/exception_classifier.rb +88 -0
- data/lib/jobpayload/finding.rb +82 -0
- data/lib/jobpayload/fixture.rb +94 -0
- data/lib/jobpayload/fixture_loader.rb +36 -0
- data/lib/jobpayload/formatter/json.rb +27 -0
- data/lib/jobpayload/formatter/text.rb +82 -0
- data/lib/jobpayload/formatter.rb +19 -0
- data/lib/jobpayload/result.rb +60 -0
- data/lib/jobpayload/snapshotter.rb +122 -0
- data/lib/jobpayload/version.rb +5 -0
- data/lib/jobpayload.rb +61 -0
- metadata +89 -0
data/README.md
ADDED
|
@@ -0,0 +1,622 @@
|
|
|
1
|
+
# jobpayload
|
|
2
|
+
|
|
3
|
+
Checks that Active Job payloads written by an **old** version of your
|
|
4
|
+
application can still be deserialized by the **new** version.
|
|
5
|
+
|
|
6
|
+
> **`jobpayload` does not execute jobs.** It never calls `perform`,
|
|
7
|
+
> `perform_now`, `perform_later`, `enqueue` or `retry_job`. It only runs
|
|
8
|
+
> `ActiveJob::Base.deserialize` and `ActiveJob::Arguments.deserialize`.
|
|
9
|
+
|
|
10
|
+
> **`jobpayload` checks Active Job's serialized job data, not
|
|
11
|
+
> Sidekiq/GoodJob/Solid Queue backend-specific storage formats.**
|
|
12
|
+
|
|
13
|
+
- [What jobpayload checks](#what-jobpayload-checks)
|
|
14
|
+
- [Why queued payload compatibility matters](#why-queued-payload-compatibility-matters)
|
|
15
|
+
- [Installation](#installation)
|
|
16
|
+
- [Quick start](#quick-start)
|
|
17
|
+
- [Creating snapshot cases](#creating-snapshot-cases)
|
|
18
|
+
- [Generating baseline fixtures](#generating-baseline-fixtures)
|
|
19
|
+
- [Checking old fixtures on new code](#checking-old-fixtures-on-new-code)
|
|
20
|
+
- [Two-way rolling-deploy checking](#two-way-rolling-deploy-checking)
|
|
21
|
+
- [GlobalID / inconclusive semantics](#globalid--inconclusive-semantics)
|
|
22
|
+
- [Finding codes](#finding-codes)
|
|
23
|
+
- [Exit codes](#exit-codes)
|
|
24
|
+
- [Human output](#human-output)
|
|
25
|
+
- [JSON output](#json-output)
|
|
26
|
+
- [CI example](#ci-example)
|
|
27
|
+
- [Supported Ruby / Active Job versions](#supported-ruby--active-job-versions)
|
|
28
|
+
- [Non-goals](#non-goals)
|
|
29
|
+
- [Known limitations](#known-limitations)
|
|
30
|
+
- [Security / safety notes](#security--safety-notes)
|
|
31
|
+
|
|
32
|
+
## What jobpayload checks
|
|
33
|
+
|
|
34
|
+
The compatibility contract is the payload Active Job actually stores
|
|
35
|
+
(`ActiveJob::Base#serialize`), not the shape of your source code.
|
|
36
|
+
|
|
37
|
+
A baseline fixture is compatible with the current application when:
|
|
38
|
+
|
|
39
|
+
1. **The fixture is valid** (schema v1 wrapper, see [Fixture files](#fixture-files)).
|
|
40
|
+
2. **The job can be restored**: the payload's `job_class` resolves to an
|
|
41
|
+
`ActiveJob::Base` subclass and `ActiveJob::Base.deserialize(job_data)`
|
|
42
|
+
succeeds. This exercises job class resolution, any `deserialize(job_data)`
|
|
43
|
+
override and custom job metadata added by `serialize` overrides.
|
|
44
|
+
3. **The arguments can be restored**:
|
|
45
|
+
`ActiveJob::Arguments.deserialize(job_data["arguments"])` succeeds. This
|
|
46
|
+
exercises built-in serializers, custom `ActiveJob::Serializers::ObjectSerializer`
|
|
47
|
+
subclasses, nested arrays/hashes and GlobalID lookups, in your real
|
|
48
|
+
(test) environment.
|
|
49
|
+
|
|
50
|
+
v0.1.0 checks **backward-read compatibility only**: old payload → new code.
|
|
51
|
+
|
|
52
|
+
## Why queued payload compatibility matters
|
|
53
|
+
|
|
54
|
+
Jobs outlive deploys. A job enqueued by v1 of your app may be picked up by a
|
|
55
|
+
v2 worker minutes or days later (scheduled jobs, retries, backlogs):
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
v1 application ──serialize──▶ queue ──deploy──▶ v2 worker ──deserialize──▶ 💥
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Typical breaks:
|
|
62
|
+
|
|
63
|
+
- a job class was renamed or removed
|
|
64
|
+
- a custom serializer class was renamed or removed
|
|
65
|
+
- a custom serializer's `deserialize` no longer understands the old payload
|
|
66
|
+
- a job's own `deserialize(job_data)` requires a field old payloads lack
|
|
67
|
+
- a model referenced by a GlobalID was renamed or removed
|
|
68
|
+
|
|
69
|
+
These break at runtime in production, usually after the old code is gone.
|
|
70
|
+
`jobpayload` turns them into a failing CI check.
|
|
71
|
+
|
|
72
|
+
## Installation
|
|
73
|
+
|
|
74
|
+
Add it to the `test` (or `development`) group of your Gemfile:
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
group :test do
|
|
78
|
+
gem "jobpayload", require: false
|
|
79
|
+
end
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
`require: false` matters: the CLI boots your application itself.
|
|
83
|
+
(Requiring the gem never loads Active Job anyway, so it cannot change your
|
|
84
|
+
initializer or serializer registration order. Nothing jobpayload does before
|
|
85
|
+
booting your app loads the json library either, so the app's own gem versions win.)
|
|
86
|
+
|
|
87
|
+
Run it with `bundle exec jobpayload ...` so it uses your application's bundle.
|
|
88
|
+
|
|
89
|
+
Runtime dependency: `activejob >= 7.2, < 9`. Nothing else.
|
|
90
|
+
|
|
91
|
+
## Quick start
|
|
92
|
+
|
|
93
|
+
```sh
|
|
94
|
+
# 1. Describe the jobs you care about
|
|
95
|
+
$EDITOR test/jobpayload_cases.rb
|
|
96
|
+
|
|
97
|
+
# 2. Write baseline fixtures (and commit them)
|
|
98
|
+
bundle exec jobpayload snapshot
|
|
99
|
+
git add test/jobpayload_fixtures
|
|
100
|
+
|
|
101
|
+
# 3. Later, on new code: can the new code still read the old payloads?
|
|
102
|
+
bundle exec jobpayload check
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Creating snapshot cases
|
|
106
|
+
|
|
107
|
+
Snapshot cases live in `test/jobpayload_cases.rb` (override with `--cases`).
|
|
108
|
+
Each `fixture` block must return an **`ActiveJob::Base` instance** (not
|
|
109
|
+
enqueued). Returning anything else is an error.
|
|
110
|
+
|
|
111
|
+
```ruby
|
|
112
|
+
# test/jobpayload_cases.rb
|
|
113
|
+
JobPayload.define do
|
|
114
|
+
fixture "billing-money-v1" do
|
|
115
|
+
BillingJob.new(Money.new(1_250, "USD"))
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
fixture "notify-user-v1" do
|
|
119
|
+
user = User.find_or_create_by!(email: "jobpayload@example.test")
|
|
120
|
+
NotifyUserJob.new(user)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
fixture "tenant-reindex-v1" do
|
|
124
|
+
TenantJob.new("reindex").tap { |job| job.tenant_id = 42 }
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Rules:
|
|
130
|
+
|
|
131
|
+
- Names must be unique (ignoring case, so fixtures do not collide on
|
|
132
|
+
case-insensitive file systems) and match `[A-Za-z0-9][A-Za-z0-9._-]*`
|
|
133
|
+
(no `/`, no `..`). The name is also the fixture file name (`<name>.json`).
|
|
134
|
+
- Blocks are plain Ruby run inside your booted application, so they may create
|
|
135
|
+
records (in the test database) or build any objects your jobs accept.
|
|
136
|
+
- Treat a fixture as an immutable record of "what v1 wrote". When the payload
|
|
137
|
+
format intentionally changes, add a new case (`billing-money-v2`) and keep
|
|
138
|
+
the old fixture: queues may still contain v1 payloads.
|
|
139
|
+
|
|
140
|
+
## Generating baseline fixtures
|
|
141
|
+
|
|
142
|
+
```sh
|
|
143
|
+
bundle exec jobpayload snapshot [options]
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
| Option | Default | Meaning |
|
|
147
|
+
| --- | --- | --- |
|
|
148
|
+
| `--cases PATH` | `test/jobpayload_cases.rb` | Snapshot cases file |
|
|
149
|
+
| `--output DIR` | `test/jobpayload_fixtures` | Where fixtures are written |
|
|
150
|
+
| `--boot PATH` | `config/environment.rb` | File required to boot the app |
|
|
151
|
+
| `--environment NAME` | `test` | Value assigned to `RAILS_ENV` before booting |
|
|
152
|
+
| `--update` | off | Replace existing fixtures whose content changed |
|
|
153
|
+
| `--format text\|json` | `text` | Output format |
|
|
154
|
+
|
|
155
|
+
Behaviour:
|
|
156
|
+
|
|
157
|
+
- The source of truth is `ActiveJob::Base#serialize`. No adapter-specific
|
|
158
|
+
conversion is applied.
|
|
159
|
+
- **Existing fixtures are never overwritten by default.** A fixture whose
|
|
160
|
+
content would change is reported as `skipped`; pass `--update` to replace it.
|
|
161
|
+
Fixtures with no matching case are never deleted.
|
|
162
|
+
- All cases are evaluated before anything is written; if any case raises or
|
|
163
|
+
returns a non-job, nothing is written and the command exits 2.
|
|
164
|
+
- Statuses: `created`, `updated`, `identical`, `skipped`.
|
|
165
|
+
|
|
166
|
+
### Fixture files
|
|
167
|
+
|
|
168
|
+
One JSON file per fixture, schema v1:
|
|
169
|
+
|
|
170
|
+
```json
|
|
171
|
+
{
|
|
172
|
+
"job": {
|
|
173
|
+
"arguments": [
|
|
174
|
+
{
|
|
175
|
+
"_aj_serialized": "MoneySerializer",
|
|
176
|
+
"amount": 1250,
|
|
177
|
+
"currency": "USD"
|
|
178
|
+
}
|
|
179
|
+
],
|
|
180
|
+
"enqueued_at": "2000-01-01T00:00:00.000000000Z",
|
|
181
|
+
"exception_executions": {},
|
|
182
|
+
"executions": 0,
|
|
183
|
+
"job_class": "BillingJob",
|
|
184
|
+
"job_id": "00000000-0000-0000-0000-000000000000",
|
|
185
|
+
"locale": "en",
|
|
186
|
+
"priority": null,
|
|
187
|
+
"provider_job_id": null,
|
|
188
|
+
"queue_name": "default",
|
|
189
|
+
"scheduled_at": null,
|
|
190
|
+
"timezone": "UTC"
|
|
191
|
+
},
|
|
192
|
+
"jobpayload_schema": 1,
|
|
193
|
+
"name": "billing-money-v1",
|
|
194
|
+
"source": {
|
|
195
|
+
"active_job_version": "8.1.4",
|
|
196
|
+
"jobpayload_version": "0.1.0",
|
|
197
|
+
"rails_version": "8.1.4",
|
|
198
|
+
"ruby_version": "3.4.8"
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
- `source` records where the fixture came from (`rails_version` is `null` when
|
|
204
|
+
Rails is not loaded). It is informational.
|
|
205
|
+
- Volatile metadata is normalized so snapshots do not churn in `git diff`:
|
|
206
|
+
`job_id` → `00000000-0000-0000-0000-000000000000`, `provider_job_id` → `null`,
|
|
207
|
+
`enqueued_at` → `2000-01-01T00:00:00.000000000Z`, `executions` → `0`,
|
|
208
|
+
`exception_executions` → `{}`, and a non-null `scheduled_at` →
|
|
209
|
+
`2000-01-01T00:00:00.000000000Z`. Keys are only normalized when present.
|
|
210
|
+
- `job_class`, `queue_name`, `priority`, `arguments`, `locale`, `timezone` and
|
|
211
|
+
**every custom key added by a job's `serialize` override** are kept as-is.
|
|
212
|
+
- Output is byte-for-byte deterministic: object keys sorted recursively, a fixed
|
|
213
|
+
two-space layout (independent of the json gem version), UTF-8, LF line endings,
|
|
214
|
+
a final newline, written atomically (temp file + rename).
|
|
215
|
+
|
|
216
|
+
## Checking old fixtures on new code
|
|
217
|
+
|
|
218
|
+
```sh
|
|
219
|
+
bundle exec jobpayload check [options]
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
| Option | Default | Meaning |
|
|
223
|
+
| --- | --- | --- |
|
|
224
|
+
| `--fixtures PATH` | `test/jobpayload_fixtures` | Fixture directory (all `*.json`, sorted) or a single fixture file |
|
|
225
|
+
| `--boot PATH` | `config/environment.rb` | File required to boot the app |
|
|
226
|
+
| `--environment NAME` | `test` | Value assigned to `RAILS_ENV` before booting |
|
|
227
|
+
| `--format text\|json` | `text` | Output format |
|
|
228
|
+
| `--fail-on-inconclusive` | off | Exit 1 when any fixture is inconclusive |
|
|
229
|
+
| `--debug` | off | Add cause chains with backtraces to text output |
|
|
230
|
+
|
|
231
|
+
For each fixture, in order:
|
|
232
|
+
|
|
233
|
+
- **Phase A (parse)**: JSON parse and schema validation. Problems are
|
|
234
|
+
reported as `AJP001` (an input error, not a compatibility failure).
|
|
235
|
+
- **Phase B (job)**: resolve `job_class`, then
|
|
236
|
+
`ActiveJob::Base.deserialize(copy_of_job_data)`.
|
|
237
|
+
- **Phase C (arguments)**:
|
|
238
|
+
`ActiveJob::Arguments.deserialize(copy_of_arguments)`. The full call is the
|
|
239
|
+
source of truth for *whether* the arguments are readable. When it fails,
|
|
240
|
+
every argument is retried on its own (descending into plain arrays/hashes)
|
|
241
|
+
to report *each* failing argument with its own finding. Active Job stops at
|
|
242
|
+
the first error, so this keeps an inconclusive missing GlobalID record in
|
|
243
|
+
`arguments[0]` from hiding a broken serializer in `arguments[1]`. A plain
|
|
244
|
+
array/hash whose children fail is also retried with those children blanked
|
|
245
|
+
out, so a missing record inside it cannot hide a problem with the container
|
|
246
|
+
itself.
|
|
247
|
+
|
|
248
|
+
Each phase gets its own deep copy of the payload. Phase C runs even when
|
|
249
|
+
Phase B fails, so one run reports every problem.
|
|
250
|
+
|
|
251
|
+
The application is booted **once per process**, before any fixture is
|
|
252
|
+
checked. Startup order is always: parse options → boot the app → use Active
|
|
253
|
+
Job APIs. Non-Rails apps can point `--boot` at any Ruby file that loads their
|
|
254
|
+
jobs, for example `--boot spec/dummy/config/environment.rb`.
|
|
255
|
+
|
|
256
|
+
## Two-way rolling-deploy checking
|
|
257
|
+
|
|
258
|
+
During a rolling deploy, old and new workers run side by side, so payloads
|
|
259
|
+
flow in both directions. jobpayload itself only checks one direction (old
|
|
260
|
+
payload → current code) and does not check out Git refs, but you can run it
|
|
261
|
+
twice from two checkouts:
|
|
262
|
+
|
|
263
|
+
```sh
|
|
264
|
+
# base = main, head = your branch, each in its own checkout with its own bundle
|
|
265
|
+
(cd head && bundle exec jobpayload snapshot --output tmp/head_fixtures)
|
|
266
|
+
(cd base && bundle exec jobpayload snapshot --output tmp/base_fixtures)
|
|
267
|
+
|
|
268
|
+
# old payloads → new workers
|
|
269
|
+
(cd head && bundle exec jobpayload check --fixtures ../base/tmp/base_fixtures)
|
|
270
|
+
# new payloads → old workers (still running mid-deploy)
|
|
271
|
+
(cd base && bundle exec jobpayload check --fixtures ../head/tmp/head_fixtures)
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
Note that the same snapshot cases file must work on both checkouts.
|
|
275
|
+
|
|
276
|
+
## GlobalID / inconclusive semantics
|
|
277
|
+
|
|
278
|
+
A fixture holding `gid://app/User/123` cannot be deserialized when user 123
|
|
279
|
+
does not exist in the test database. That makes the *job* unrunnable, but it
|
|
280
|
+
does not prove the *payload format* is incompatible. So:
|
|
281
|
+
|
|
282
|
+
- An argument is **inconclusive** (`AJP202`) only when all of these hold:
|
|
283
|
+
1. `ActiveJob::Arguments.deserialize` fails for it,
|
|
284
|
+
2. the failing serialized value is an Active Job GlobalID reference
|
|
285
|
+
(exactly `{"_aj_globalid": "gid://..."}`, the form Active Job writes),
|
|
286
|
+
and
|
|
287
|
+
3. the cause chain contains a "record does not exist" exception
|
|
288
|
+
(`ActiveRecord::RecordNotFound`, `GlobalID::Locator::RecordNotFound`, or
|
|
289
|
+
subclasses; also `Mongoid::Errors::DocumentNotFound`).
|
|
290
|
+
|
|
291
|
+
Exit code stays 0 unless `--fail-on-inconclusive` is given.
|
|
292
|
+
- Any other argument failure is a normal **compatibility failure**
|
|
293
|
+
(`AJP201`), **even when `RecordNotFound` is in the cause chain**. For
|
|
294
|
+
example, a custom serializer whose `deserialize` now does
|
|
295
|
+
`User.find_by!(email: hash["email_address"])` cannot read an old
|
|
296
|
+
`{"_aj_serialized": "UserEmailSerializer", "email": "..."}` payload: that
|
|
297
|
+
is a broken payload, not a missing record. The same applies when the model
|
|
298
|
+
constant is gone, the GlobalID class cannot be resolved, or a custom
|
|
299
|
+
locator raises.
|
|
300
|
+
- A `RecordNotFound` raised by a job's own `deserialize(job_data)` override is
|
|
301
|
+
`AJP102` (breaking): only GlobalID arguments can be inconclusive.
|
|
302
|
+
- If the database is unusable (connection errors, missing database or database
|
|
303
|
+
configuration, `ActiveRecord::StatementInvalid` such as a schema that was
|
|
304
|
+
never loaded), the check environment is broken: `AJP900`, exit 2.
|
|
305
|
+
|
|
306
|
+
Classification uses exception classes (by name, including ancestors, so it
|
|
307
|
+
works whether or not Active Record is loaded) along the full cause chain, never
|
|
308
|
+
message text. Make the records your fixtures reference exist in the test
|
|
309
|
+
database (seeds, fixtures, or a boot file) to get a conclusive answer.
|
|
310
|
+
|
|
311
|
+
## Finding codes
|
|
312
|
+
|
|
313
|
+
Finding codes are a stable API for 0.1.x.
|
|
314
|
+
|
|
315
|
+
| Code | Name | Kind | Meaning |
|
|
316
|
+
| --- | --- | --- | --- |
|
|
317
|
+
| `AJP001` | `fixture_invalid` | fatal | Fixture file is invalid (bad JSON, unknown schema, missing `name`/`job`/`job_class`, `arguments` not an array, invalid name, name ≠ file name). Input/config error, not a compatibility result. |
|
|
318
|
+
| `AJP101` | `unknown_job_class` | breaking | `job_class` cannot be resolved to an `ActiveJob::Base` subclass. |
|
|
319
|
+
| `AJP102` | `job_deserialize_failed` | breaking | The class exists but `ActiveJob::Base.deserialize(job_data)` (including your `deserialize(job_data)` override) raised. |
|
|
320
|
+
| `AJP201` | `argument_deserialize_failed` | breaking | `ActiveJob::Arguments.deserialize` raised: serializer removed/renamed, old field missing, type expectation changed, malformed representation, GlobalID model removed... |
|
|
321
|
+
| `AJP202` | `globalid_record_missing` | inconclusive | A GlobalID argument (`{"_aj_globalid": ...}`) points at a record that does not exist in the test environment. Never used for custom serializers or job-level `deserialize`. |
|
|
322
|
+
| `AJP900` | `environment_error` | fatal | Boot failure, database unavailable, the Ruby stack exhausted by a pathologically deep payload, or another problem with the check environment itself. |
|
|
323
|
+
|
|
324
|
+
## Exit codes
|
|
325
|
+
|
|
326
|
+
| Exit | Meaning |
|
|
327
|
+
| --- | --- |
|
|
328
|
+
| `0` | All fixtures compatible (inconclusive fixtures allowed unless `--fail-on-inconclusive`) |
|
|
329
|
+
| `1` | At least one compatibility failure (or an inconclusive fixture with `--fail-on-inconclusive`) |
|
|
330
|
+
| `2` | Usage, configuration, boot or tool error (`AJP001`, `AJP900`, unknown command/option, missing files) |
|
|
331
|
+
|
|
332
|
+
When several apply, the highest priority wins: `2` > `1` > `0`.
|
|
333
|
+
|
|
334
|
+
Results are written to **stdout**. Usage, configuration and boot errors are
|
|
335
|
+
written to **stderr** (and nothing is written to stdout in that case).
|
|
336
|
+
jobpayload itself writes nothing else to stdout. While booting and
|
|
337
|
+
deserializing, `$stdout` is pointed at stderr, so anything your application
|
|
338
|
+
prints with `puts`/`print` (or a logger built on `$stdout`) goes to stderr.
|
|
339
|
+
Writes that bypass `$stdout`, such as `STDOUT.puts`, `Logger.new(STDOUT)` or
|
|
340
|
+
writing to file descriptor 1 directly, are **not** redirected and would end up
|
|
341
|
+
in front of the JSON document; keep them out of the environment you boot for
|
|
342
|
+
jobpayload. If application code calls `exit` or `abort`, or raises an
|
|
343
|
+
exception that does not inherit from `StandardError`, jobpayload exits 2.
|
|
344
|
+
|
|
345
|
+
Long options must be spelled out in full (`--fail-on-inconclusive`, not `--fail`).
|
|
346
|
+
|
|
347
|
+
## Human output
|
|
348
|
+
|
|
349
|
+
All fixtures compatible:
|
|
350
|
+
|
|
351
|
+
```
|
|
352
|
+
PASS billing-money-v1
|
|
353
|
+
PASS invoice-period-v1
|
|
354
|
+
|
|
355
|
+
2 fixtures checked
|
|
356
|
+
2 compatible
|
|
357
|
+
0 incompatible
|
|
358
|
+
0 inconclusive
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
A failure:
|
|
362
|
+
|
|
363
|
+
```
|
|
364
|
+
AJP201 breaking billing-money-v1
|
|
365
|
+
|
|
366
|
+
Job:
|
|
367
|
+
BillingJob
|
|
368
|
+
|
|
369
|
+
Argument:
|
|
370
|
+
arguments[0]
|
|
371
|
+
|
|
372
|
+
Old payload cannot be deserialized by the current application.
|
|
373
|
+
|
|
374
|
+
Cause:
|
|
375
|
+
ArgumentError: Serializer MoneySerializer is not known
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
An inconclusive GlobalID:
|
|
379
|
+
|
|
380
|
+
```
|
|
381
|
+
AJP202 inconclusive notify-user-v1
|
|
382
|
+
|
|
383
|
+
Job:
|
|
384
|
+
NotifyUserJob
|
|
385
|
+
|
|
386
|
+
Argument:
|
|
387
|
+
arguments[0]
|
|
388
|
+
|
|
389
|
+
GlobalID target record was not found in the current test environment.
|
|
390
|
+
|
|
391
|
+
This does not by itself prove a payload compatibility break.
|
|
392
|
+
|
|
393
|
+
Cause:
|
|
394
|
+
ActiveRecord::RecordNotFound: Couldn't find User with 'id'=123
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
`Cause` shows the innermost exception of the cause chain. With `--debug`, the
|
|
398
|
+
whole chain and backtraces are printed as well. Fixtures appear in name order,
|
|
399
|
+
findings in code / argument-path order, so the output is deterministic.
|
|
400
|
+
A summary line `N tool error(s)` is added when fixtures had fatal findings.
|
|
401
|
+
|
|
402
|
+
## JSON output
|
|
403
|
+
|
|
404
|
+
```sh
|
|
405
|
+
bundle exec jobpayload check --format json
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
```json
|
|
409
|
+
{
|
|
410
|
+
"schema_version": 1,
|
|
411
|
+
"tool_version": "0.1.0",
|
|
412
|
+
"status": "fail",
|
|
413
|
+
"summary": {
|
|
414
|
+
"fixtures": 3,
|
|
415
|
+
"compatible": 1,
|
|
416
|
+
"incompatible": 1,
|
|
417
|
+
"inconclusive": 1,
|
|
418
|
+
"tool_errors": 0
|
|
419
|
+
},
|
|
420
|
+
"fixtures": [
|
|
421
|
+
{ "name": "billing-money-v1", "status": "fail" },
|
|
422
|
+
{ "name": "invoice-period-v1", "status": "pass" },
|
|
423
|
+
{ "name": "notify-user-v1", "status": "inconclusive" }
|
|
424
|
+
],
|
|
425
|
+
"findings": [
|
|
426
|
+
{
|
|
427
|
+
"code": "AJP201",
|
|
428
|
+
"name": "argument_deserialize_failed",
|
|
429
|
+
"severity": "error",
|
|
430
|
+
"fixture": "billing-money-v1",
|
|
431
|
+
"job_class": "BillingJob",
|
|
432
|
+
"argument_path": "arguments[0]",
|
|
433
|
+
"message": "Old payload cannot be deserialized by the current application.",
|
|
434
|
+
"exception_class": "ActiveJob::DeserializationError",
|
|
435
|
+
"exception_message": "Error while trying to deserialize arguments: Serializer MoneySerializer is not known",
|
|
436
|
+
"cause_chain": [
|
|
437
|
+
{
|
|
438
|
+
"class": "ActiveJob::DeserializationError",
|
|
439
|
+
"message": "Error while trying to deserialize arguments: Serializer MoneySerializer is not known"
|
|
440
|
+
},
|
|
441
|
+
{ "class": "ArgumentError", "message": "Serializer MoneySerializer is not known" }
|
|
442
|
+
]
|
|
443
|
+
},
|
|
444
|
+
{
|
|
445
|
+
"code": "AJP202",
|
|
446
|
+
"name": "globalid_record_missing",
|
|
447
|
+
"severity": "warning",
|
|
448
|
+
"fixture": "notify-user-v1",
|
|
449
|
+
"job_class": "NotifyUserJob",
|
|
450
|
+
"argument_path": "arguments[0]",
|
|
451
|
+
"message": "GlobalID target record was not found in the current test environment.",
|
|
452
|
+
"exception_class": "ActiveJob::DeserializationError",
|
|
453
|
+
"exception_message": "Error while trying to deserialize arguments: Couldn't find User with 'id'=123",
|
|
454
|
+
"cause_chain": [
|
|
455
|
+
{
|
|
456
|
+
"class": "ActiveJob::DeserializationError",
|
|
457
|
+
"message": "Error while trying to deserialize arguments: Couldn't find User with 'id'=123"
|
|
458
|
+
},
|
|
459
|
+
{ "class": "ActiveRecord::RecordNotFound", "message": "Couldn't find User with 'id'=123" }
|
|
460
|
+
]
|
|
461
|
+
}
|
|
462
|
+
]
|
|
463
|
+
}
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
(The real output uses the same two-space layout as fixtures, one value per line.)
|
|
467
|
+
|
|
468
|
+
| Field | Meaning |
|
|
469
|
+
| --- | --- |
|
|
470
|
+
| `schema_version` | JSON output schema version. Always `1` in 0.1.x. |
|
|
471
|
+
| `tool_version` | jobpayload version. |
|
|
472
|
+
| `status` | `"pass"` (exit 0), `"fail"` (exit 1) or `"tool_error"` (exit 2). With `--fail-on-inconclusive`, inconclusive fixtures make it `"fail"`. |
|
|
473
|
+
| `summary.fixtures` | Number of fixture files checked. |
|
|
474
|
+
| `summary.compatible` / `incompatible` / `inconclusive` / `tool_errors` | Number of fixtures whose status is `pass` / `fail` / `inconclusive` / `tool_error`. |
|
|
475
|
+
| `fixtures[]` | `{name, status}` for every fixture, sorted by name. `status` is the fixture's worst finding: `"tool_error"` (a `fatal` finding) > `"fail"` (an `error` finding) > `"inconclusive"` (a `warning` finding) > `"pass"` (no findings). `--fail-on-inconclusive` does not change fixture statuses. |
|
|
476
|
+
| `findings[]` | Sorted by `fixture`, then `code`, then `argument_path`. |
|
|
477
|
+
| `findings[].code` / `name` | Finding code and its stable name (see [Finding codes](#finding-codes)). |
|
|
478
|
+
| `findings[].severity` | `"error"` (compatibility break), `"warning"` (inconclusive) or `"fatal"` (tool/environment failure). |
|
|
479
|
+
| `findings[].fixture` | Fixture name (file name without `.json` for unparseable fixtures). |
|
|
480
|
+
| `findings[].job_class` | `job_class` from the payload, or `null`. |
|
|
481
|
+
| `findings[].argument_path` | Failing argument, e.g. `arguments[1]` or `arguments[0]["batch"][1]`; `arguments` when no single argument fails on its own; `null` for job-level findings. |
|
|
482
|
+
| `findings[].message` | Human-readable explanation. |
|
|
483
|
+
| `findings[].exception_class` / `exception_message` | Outermost exception, or `null`. |
|
|
484
|
+
| `findings[].cause_chain` | `[{class, message}]`, outermost first, following `Exception#cause` (cycle-safe). |
|
|
485
|
+
|
|
486
|
+
Backtraces are never included in JSON output. The same input always produces
|
|
487
|
+
byte-identical JSON. Exception messages, fixture file names and paths are
|
|
488
|
+
emitted as valid UTF-8 (invalid bytes become U+FFFD).
|
|
489
|
+
|
|
490
|
+
**Stability policy.** Within `schema_version: 1` (all 0.1.x releases),
|
|
491
|
+
existing fields are never removed or renamed, and the meaning and allowed
|
|
492
|
+
values of existing fields do not change. New fields may be added, so consumers
|
|
493
|
+
should ignore fields they do not know. Any incompatible change bumps
|
|
494
|
+
`schema_version`.
|
|
495
|
+
|
|
496
|
+
## CI example
|
|
497
|
+
|
|
498
|
+
Commit baseline fixtures, then run `check` on every pull request. A breaking
|
|
499
|
+
payload change makes the job fail with exit 1.
|
|
500
|
+
|
|
501
|
+
```yaml
|
|
502
|
+
# .github/workflows/jobpayload.yml
|
|
503
|
+
name: jobpayload
|
|
504
|
+
on: [pull_request]
|
|
505
|
+
jobs:
|
|
506
|
+
payload-compatibility:
|
|
507
|
+
runs-on: ubuntu-latest
|
|
508
|
+
env:
|
|
509
|
+
RAILS_ENV: test
|
|
510
|
+
steps:
|
|
511
|
+
- uses: actions/checkout@v4
|
|
512
|
+
- uses: ruby/setup-ruby@v1
|
|
513
|
+
with:
|
|
514
|
+
bundler-cache: true
|
|
515
|
+
- run: bin/rails db:prepare
|
|
516
|
+
- run: bundle exec jobpayload check
|
|
517
|
+
```
|
|
518
|
+
|
|
519
|
+
For two-way checking, check out both the base and the head revision (for
|
|
520
|
+
example two `actions/checkout` steps with different `path:` and `ref:`), then
|
|
521
|
+
run `check` twice: base fixtures → head app, and head fixtures → base app, as
|
|
522
|
+
shown in [Two-way rolling-deploy checking](#two-way-rolling-deploy-checking).
|
|
523
|
+
jobpayload 0.1.0 does not orchestrate checkouts itself.
|
|
524
|
+
|
|
525
|
+
## Supported Ruby / Active Job versions
|
|
526
|
+
|
|
527
|
+
| | Active Job 7.2 | Active Job 8.0 | Active Job 8.1 |
|
|
528
|
+
| --- | --- | --- | --- |
|
|
529
|
+
| Ruby 3.3 | ✅ | ✅ | ✅ |
|
|
530
|
+
| Ruby 3.4 | ✅ | ✅ | ✅ |
|
|
531
|
+
| Ruby 4.0 | ✅ | ✅ | ✅ |
|
|
532
|
+
|
|
533
|
+
Every combination is tested in CI (`gemfiles/activejob_*.gemfile`). Active
|
|
534
|
+
Job < 7.2 and Ruby < 3.3 are not supported.
|
|
535
|
+
|
|
536
|
+
Fixtures written under an older supported Active Job version are checked
|
|
537
|
+
under newer versions (`test/fixtures/active_job_*`). Checking payloads written
|
|
538
|
+
by a *newer* Active Job version on an older one is not covered.
|
|
539
|
+
|
|
540
|
+
Version-specific types: `ActionController::Parameters` arguments
|
|
541
|
+
(serializable since Active Job 8.1) need Action Pack and are not part of the
|
|
542
|
+
built-in fixture set.
|
|
543
|
+
|
|
544
|
+
## Non-goals
|
|
545
|
+
|
|
546
|
+
v0.1.0 deliberately does **not**:
|
|
547
|
+
|
|
548
|
+
- execute `perform` or any job business logic, enqueue, or retry jobs
|
|
549
|
+
- check `perform` arity or execution semantics. A successful deserialize does
|
|
550
|
+
not prove the result is *semantically* right: a serializer that now reads a
|
|
551
|
+
renamed key with `hash["new_key"]` gets `nil` and still passes. Read old
|
|
552
|
+
keys with `fetch` (or validate) if you want jobpayload to catch renames.
|
|
553
|
+
- parse Sidekiq, GoodJob, Solid Queue, Resque or Delayed Job storage formats
|
|
554
|
+
- connect to queue backends or dump jobs from real queues
|
|
555
|
+
- check out Git refs, create worktrees, or fetch base/head automatically
|
|
556
|
+
- analyse Ruby source (no Prism / AST analysis)
|
|
557
|
+
- analyse retry safety
|
|
558
|
+
- orchestrate forward/two-way compatibility automatically
|
|
559
|
+
- re-serialize deserialized arguments (round-trip / re-enqueue compatibility)
|
|
560
|
+
- provide ignore/suppression lists or a configuration file
|
|
561
|
+
|
|
562
|
+
## Known limitations
|
|
563
|
+
|
|
564
|
+
- **Pathologically deep payloads.** Fixtures nested deeply enough to exhaust
|
|
565
|
+
the Ruby stack are not supported: with Ruby's default stack size that can
|
|
566
|
+
start at roughly 2,000 levels of nested arrays/hashes (`snapshot` itself
|
|
567
|
+
never writes more than 100). Such a fixture is reported as `AJP900` with a
|
|
568
|
+
`SystemStackError` cause, or the check stops with an internal error; either
|
|
569
|
+
way the exit status is 2, never 0 or 1.
|
|
570
|
+
- **GlobalIDs inside custom serializer payloads.** Only a top-level or
|
|
571
|
+
plain-array/hash GlobalID argument can be inconclusive. If a custom
|
|
572
|
+
serializer's own payload contains a GlobalID whose record is missing, the
|
|
573
|
+
failing value is the custom serializer's payload, so it is reported as
|
|
574
|
+
`AJP201` (breaking). Seed that record in the test database to get a
|
|
575
|
+
conclusive answer.
|
|
576
|
+
- **Direct writes to stdout.** Output written through the `STDOUT` constant or
|
|
577
|
+
file descriptor 1 bypasses the redirection described in
|
|
578
|
+
[Exit codes](#exit-codes) and can corrupt `--format json` output.
|
|
579
|
+
- **Semantic changes** that still deserialize successfully pass (see
|
|
580
|
+
[Non-goals](#non-goals)).
|
|
581
|
+
- **Database errors during GlobalID lookup** (`ActiveRecord::StatementInvalid`,
|
|
582
|
+
for example a column or table that the current schema no longer has) are
|
|
583
|
+
reported as `AJP900` with exit 2, not as `AJP201`. The run still fails.
|
|
584
|
+
|
|
585
|
+
## Security / safety notes
|
|
586
|
+
|
|
587
|
+
**jobpayload runs trusted application code in your test environment.**
|
|
588
|
+
|
|
589
|
+
- `check` itself does not enqueue, perform or retry jobs, connect to queues,
|
|
590
|
+
write to the database, make HTTP requests, run shell commands or modify Git.
|
|
591
|
+
However, booting your application and running your serializers, GlobalID
|
|
592
|
+
locators and `deserialize` overrides executes *your* code, which can do
|
|
593
|
+
anything. Only check fixtures against code you trust.
|
|
594
|
+
- Fixtures are data, but deserializing them calls whatever serializer or
|
|
595
|
+
locator classes they name. Treat fixture files like code: review them and do
|
|
596
|
+
not check untrusted fixtures.
|
|
597
|
+
- `snapshot` evaluates your cases file, which may create database records
|
|
598
|
+
(for example `find_or_create_by!`) in the environment it boots.
|
|
599
|
+
- The default environment is `test`. `production` is never booted implicitly;
|
|
600
|
+
passing `--environment production` prints a warning. Do not point jobpayload
|
|
601
|
+
at production databases. Note that a `DATABASE_URL` set in your shell is
|
|
602
|
+
still used by Rails in the `test` environment.
|
|
603
|
+
|
|
604
|
+
## Development
|
|
605
|
+
|
|
606
|
+
```sh
|
|
607
|
+
bundle install
|
|
608
|
+
bundle exec rake test # Minitest; randomized order
|
|
609
|
+
bundle exec rake build # pkg/jobpayload-0.1.0.gem
|
|
610
|
+
|
|
611
|
+
# Another Active Job version:
|
|
612
|
+
BUNDLE_GEMFILE=gemfiles/activejob_7.2.gemfile bundle install
|
|
613
|
+
BUNDLE_GEMFILE=gemfiles/activejob_7.2.gemfile bundle exec rake test
|
|
614
|
+
```
|
|
615
|
+
|
|
616
|
+
The test suite boots small Rails-free applications from `test/apps` (Active
|
|
617
|
+
Job + Active Record on in-memory SQLite) in subprocesses: `v1` writes baseline
|
|
618
|
+
fixtures, `v2_compatible` and `v2_breaking` model later versions of the same app.
|
|
619
|
+
|
|
620
|
+
## License
|
|
621
|
+
|
|
622
|
+
MIT. See [LICENSE](LICENSE).
|
data/exe/jobpayload
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JobPayload
|
|
4
|
+
# Boots the host application exactly once, then makes sure Active Job is
|
|
5
|
+
# available. Active Job is only required *after* the application's own boot
|
|
6
|
+
# file has run, so the application controls load order.
|
|
7
|
+
class Boot
|
|
8
|
+
DEFAULT_PATH = "config/environment.rb"
|
|
9
|
+
DEFAULT_ENVIRONMENT = "test"
|
|
10
|
+
|
|
11
|
+
def self.call(path:, environment:)
|
|
12
|
+
new(path: path, environment: environment).call
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def initialize(path:, environment:)
|
|
16
|
+
@path = File.expand_path(path)
|
|
17
|
+
@environment = environment
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def call
|
|
21
|
+
raise BootError, "boot file not found: #{@path}" unless File.file?(@path)
|
|
22
|
+
|
|
23
|
+
ENV["RAILS_ENV"] = @environment
|
|
24
|
+
begin
|
|
25
|
+
# require keeps a boot file from running twice; files without the .rb
|
|
26
|
+
# extension cannot be required, so they are loaded instead.
|
|
27
|
+
@path.end_with?(".rb") ? require(@path) : load(@path)
|
|
28
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
29
|
+
raise if e.is_a?(SystemExit) || e.is_a?(SignalException) || e.is_a?(NoMemoryError)
|
|
30
|
+
|
|
31
|
+
raise BootError, "failed to boot #{@path}: #{e.class}: #{e.message}"
|
|
32
|
+
end
|
|
33
|
+
load_active_job
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def load_active_job
|
|
39
|
+
require "active_job" unless defined?(::ActiveJob::Base)
|
|
40
|
+
# Touch the public APIs the checker relies on so missing pieces surface
|
|
41
|
+
# as boot errors rather than as per-fixture failures.
|
|
42
|
+
::ActiveJob::Base
|
|
43
|
+
::ActiveJob::Arguments
|
|
44
|
+
nil
|
|
45
|
+
rescue StandardError, ScriptError => e
|
|
46
|
+
raise BootError, "Active Job could not be loaded after booting #{@path}: #{e.class}: #{e.message}"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|