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.
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,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "jobpayload"
5
+
6
+ exit JobPayload::CLI.new(ARGV, stdout: $stdout, stderr: $stderr).run
@@ -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