rspec-hopper 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +10 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +537 -0
  5. data/Rakefile +10 -0
  6. data/docs/DESIGN.md +386 -0
  7. data/exe/rspec-hopper +6 -0
  8. data/lib/rspec/hopper/attempt_log.rb +133 -0
  9. data/lib/rspec/hopper/ci_env.rb +87 -0
  10. data/lib/rspec/hopper/cli/formatter_args.rb +129 -0
  11. data/lib/rspec/hopper/cli/report.rb +117 -0
  12. data/lib/rspec/hopper/cli/work/parser.rb +166 -0
  13. data/lib/rspec/hopper/cli/work.rb +59 -0
  14. data/lib/rspec/hopper/cli.rb +61 -0
  15. data/lib/rspec/hopper/config.rb +48 -0
  16. data/lib/rspec/hopper/errors.rb +88 -0
  17. data/lib/rspec/hopper/example_reset.rb +41 -0
  18. data/lib/rspec/hopper/fingerprint.rb +185 -0
  19. data/lib/rspec/hopper/keys.rb +38 -0
  20. data/lib/rspec/hopper/manifest.rb +113 -0
  21. data/lib/rspec/hopper/queue/redis_streams/lua/init.lua +94 -0
  22. data/lib/rspec/hopper/queue/redis_streams/lua/transition.lua +476 -0
  23. data/lib/rspec/hopper/queue/redis_streams.rb +307 -0
  24. data/lib/rspec/hopper/queue.rb +24 -0
  25. data/lib/rspec/hopper/report.rb +286 -0
  26. data/lib/rspec/hopper/reservation.rb +18 -0
  27. data/lib/rspec/hopper/supervisor.rb +196 -0
  28. data/lib/rspec/hopper/unit.rb +15 -0
  29. data/lib/rspec/hopper/version.rb +7 -0
  30. data/lib/rspec/hopper/worker/buffering_reporter.rb +51 -0
  31. data/lib/rspec/hopper/worker/heartbeat.rb +178 -0
  32. data/lib/rspec/hopper/worker/requeue_policy.rb +86 -0
  33. data/lib/rspec/hopper/worker/runner.rb +28 -0
  34. data/lib/rspec/hopper/worker/suite.rb +205 -0
  35. data/lib/rspec/hopper/worker.rb +299 -0
  36. data/lib/rspec/hopper.rb +65 -0
  37. metadata +137 -0
@@ -0,0 +1,476 @@
1
+ -- State transitions for in-flight units. One dispatcher, one function per mode.
2
+ --
3
+ -- KEYS: units, units:priority, attempts, meta, unit_state, workers
4
+ -- ARGV: mode, worker_id, ttl_ms, then mode-specific arguments (see dispatch).
5
+ --
6
+ -- Replies are arrays whose first element is a status string: "OK", "NONE",
7
+ -- "FINALIZED", "REQUEUED", "STALE" (fence failed) or "CORRUPT" (meta or the
8
+ -- unit's unit_state entry is missing after ready). STALE and CORRUPT are
9
+ -- status replies, not Lua errors, so the caller can tell them from Redis errors.
10
+
11
+ local units, priority, attempts, meta, unit_state, workers =
12
+ KEYS[1], KEYS[2], KEYS[3], KEYS[4], KEYS[5], KEYS[6]
13
+ local mode, worker_id, ttl_ms = ARGV[1], ARGV[2], tonumber(ARGV[3])
14
+
15
+ local GROUP = "workers"
16
+ local STALE, CORRUPT = { "STALE" }, { "CORRUPT" }
17
+
18
+ -- ---------------------------------------------------------------------------
19
+ -- Small helpers
20
+ -- ---------------------------------------------------------------------------
21
+
22
+ local function now_ms()
23
+ local t = redis.call("TIME")
24
+ return tonumber(t[1]) * 1000 + math.floor(tonumber(t[2]) / 1000)
25
+ end
26
+
27
+ local function stream_key(short)
28
+ if short == "units" then
29
+ return units
30
+ elseif short == "units:priority" then
31
+ return priority
32
+ end
33
+ error("unknown stream " .. tostring(short))
34
+ end
35
+
36
+ local function stream_short(key)
37
+ if key == units then
38
+ return "units"
39
+ end
40
+ return "units:priority"
41
+ end
42
+
43
+ -- Renews the inactivity TTL on every live key that exists. Never the lease or
44
+ -- the tombstone.
45
+ local function renew_ttls()
46
+ for _, key in ipairs({ units, priority, attempts, meta, unit_state, workers }) do
47
+ redis.call("PEXPIRE", key, ttl_ms)
48
+ end
49
+ end
50
+
51
+ local function meta_present()
52
+ return redis.call("EXISTS", meta) == 1
53
+ end
54
+
55
+ -- Pending-entry row for one entry: {id, consumer, idle_ms, delivery_count} or nil.
56
+ local function pending_row(stream, entry_id)
57
+ local rows = redis.call("XPENDING", stream, GROUP, entry_id, entry_id, 1)
58
+ local row = rows[1]
59
+ if type(row) ~= "table" then
60
+ return nil
61
+ end
62
+ return { id = row[1], consumer = row[2], idle_ms = tonumber(row[3]), delivery_count = tonumber(row[4]) }
63
+ end
64
+
65
+ -- The fence. Returns the pending row when the entry is still pending, owned
66
+ -- by worker_id and (when given) at the expected delivery count; nil otherwise.
67
+ local function fence(stream, entry_id, owner, delivery_count)
68
+ local row = pending_row(stream, entry_id)
69
+ if not row or row.consumer ~= owner then
70
+ return nil
71
+ end
72
+ if delivery_count ~= nil and row.delivery_count ~= delivery_count then
73
+ return nil
74
+ end
75
+ return row
76
+ end
77
+
78
+ -- Stream entry fields ({"id", "x", "type", "file"}) to a table.
79
+ local function entry_fields(list)
80
+ local fields = {}
81
+ for i = 1, #list, 2 do
82
+ fields[list[i]] = list[i + 1]
83
+ end
84
+ return fields
85
+ end
86
+
87
+ local function read_unit_state(unit_id)
88
+ local raw = redis.call("HGET", unit_state, unit_id)
89
+ if not raw then
90
+ return nil
91
+ end
92
+ return cjson.decode(raw)
93
+ end
94
+
95
+ local function write_unit_state(unit_id, state)
96
+ redis.call("HSET", unit_state, unit_id, string.format(
97
+ '{"retry_index":%d,"reclaim_count":%d,"entered_retry":%s}',
98
+ state.retry_index, state.reclaim_count, tostring(state.entered_retry == true)
99
+ ))
100
+ end
101
+
102
+ -- Encodes an ordered list of {key, value} (or {key, raw_json, true}) pairs as
103
+ -- one JSON object. Raw values are Ruby-encoded JSON embedded verbatim, never
104
+ -- re-encoded. Deterministic key order makes the log easy to read and diff.
105
+ local function json_object(pairs_list)
106
+ local parts = {}
107
+ for _, pair in ipairs(pairs_list) do
108
+ local key, value, raw = pair[1], pair[2], pair[3]
109
+ if value == nil then
110
+ value = cjson.null
111
+ end
112
+ parts[#parts + 1] = cjson.encode(key) .. ":" .. (raw and value or cjson.encode(value))
113
+ end
114
+ return "{" .. table.concat(parts, ",") .. "}"
115
+ end
116
+
117
+ local function append_json(json)
118
+ redis.call("XADD", attempts, "*", "json", json)
119
+ end
120
+
121
+ -- Appends a unit-scoped event. `extra` is an ordered list of pairs.
122
+ local function append_event(event_type, unit_id, state, extra)
123
+ local pairs_list = {
124
+ { "type", event_type },
125
+ { "unit_id", unit_id },
126
+ { "worker_id", worker_id },
127
+ { "retry_index", state.retry_index },
128
+ { "reclaim_count", state.reclaim_count },
129
+ { "ownership_generation", 1 + state.retry_index + state.reclaim_count },
130
+ { "at_ms", now_ms() },
131
+ }
132
+ for _, pair in ipairs(extra or {}) do
133
+ pairs_list[#pairs_list + 1] = pair
134
+ end
135
+ append_json(json_object(pairs_list))
136
+ end
137
+
138
+ -- Stamps at_ms onto a Ruby-built event object without re-encoding it.
139
+ local function append_prebuilt(json)
140
+ if json:sub(-1) ~= "}" then
141
+ error("event payload is not a JSON object")
142
+ end
143
+ local body = json:sub(1, -2)
144
+ local sep = (body:match("^%s*{%s*$") and "") or ","
145
+ append_json(body .. sep .. '"at_ms":' .. now_ms() .. "}")
146
+ end
147
+
148
+ -- Merges `changes` into workers[worker_id]. Pass cjson.null to clear a field,
149
+ -- nil (absent) to leave it alone. `processed_delta` adds to the counter.
150
+ local function touch_worker(changes, processed_delta)
151
+ local raw = redis.call("HGET", workers, worker_id)
152
+ local record = raw and cjson.decode(raw) or { last_seen = 0, current_unit = cjson.null, processed = 0 }
153
+ record.last_seen = now_ms()
154
+ if changes.current_unit ~= nil then
155
+ record.current_unit = changes.current_unit
156
+ end
157
+ record.processed = (tonumber(record.processed) or 0) + (processed_delta or 0)
158
+ redis.call("HSET", workers, worker_id, json_object({
159
+ { "last_seen", record.last_seen },
160
+ { "current_unit", record.current_unit },
161
+ { "processed", record.processed },
162
+ }))
163
+ end
164
+
165
+ local function raw_or_null(json)
166
+ if json == nil or json == "" then
167
+ return { nil }
168
+ end
169
+ return { json, true }
170
+ end
171
+
172
+ local function nil_if_empty(value)
173
+ if value == nil or value == "" then
174
+ return nil
175
+ end
176
+ return value
177
+ end
178
+
179
+ -- Terminal transition shared by finalize, requeue (budget exhausted) and
180
+ -- reclaim (budget exhausted): XACK, finalized event, finalized_count += 1.
181
+ local function finalize_entry(stream, entry_id, unit_id, state, outcome, duration_ms, reason, errors_json, extra)
182
+ redis.call("XACK", stream, GROUP, entry_id)
183
+ local fields = {
184
+ { "outcome", outcome },
185
+ { "duration_ms", tonumber(duration_ms) or 0 },
186
+ { "reason", nil_if_empty(reason) },
187
+ { "errors", unpack(raw_or_null(errors_json)) },
188
+ { "stream", stream_short(stream) },
189
+ { "entry_id", entry_id },
190
+ }
191
+ for _, pair in ipairs(extra or {}) do
192
+ fields[#fields + 1] = pair
193
+ end
194
+ append_event("finalized", unit_id, state, fields)
195
+ redis.call("HINCRBY", meta, "finalized_count", 1)
196
+ end
197
+
198
+ -- Budget values are frozen into meta at initialization so every worker
199
+ -- enforces the same caps; the caller's own value is only a fallback.
200
+ local function budget(field, fallback)
201
+ local value = redis.call("HGET", meta, field)
202
+ return tonumber(value) or tonumber(fallback)
203
+ end
204
+
205
+ -- ---------------------------------------------------------------------------
206
+ -- Modes
207
+ -- ---------------------------------------------------------------------------
208
+
209
+ -- ARGV: stream, entry_id, unit_id. Fenced by consumer only; delivery count is
210
+ -- read from XPENDING and returned so the caller can build its handle.
211
+ local function delivery_accounting(short, entry_id, unit_id)
212
+ local stream = stream_key(short)
213
+ local row = fence(stream, entry_id, worker_id, nil)
214
+ if not row then
215
+ return STALE
216
+ end
217
+ local state = read_unit_state(unit_id)
218
+ if not state then
219
+ return CORRUPT
220
+ end
221
+ append_event("delivered", unit_id, state, {
222
+ { "stream", short },
223
+ { "entry_id", entry_id },
224
+ { "delivery_count", row.delivery_count },
225
+ })
226
+ touch_worker({ current_unit = unit_id })
227
+ renew_ttls()
228
+ return { "OK", row.delivery_count, state.retry_index, state.reclaim_count }
229
+ end
230
+
231
+ -- First real entry in an XAUTOCLAIM reply. The reply is {next_id, entries} on
232
+ -- Redis 6.2 and {next_id, entries, deleted_ids} on 7+; on 6.2 `entries` may
233
+ -- hold false/nil placeholders for entries deleted from the stream. Only the
234
+ -- second element is ever inspected, so the shape does not matter.
235
+ local function first_claimed(reply)
236
+ local entries = type(reply) == "table" and reply[2] or nil
237
+ if type(entries) ~= "table" then
238
+ return nil
239
+ end
240
+ for _, entry in ipairs(entries) do
241
+ if type(entry) == "table" and type(entry[2]) == "table" then
242
+ return entry[1], entry_fields(entry[2])
243
+ end
244
+ end
245
+ return nil
246
+ end
247
+
248
+ -- Oldest entry idle for at least timeout_ms, read before XAUTOCLAIM moves it
249
+ -- so the previous owner is still known.
250
+ local function idle_candidate(stream, timeout_ms)
251
+ local rows = redis.call("XPENDING", stream, GROUP, "IDLE", timeout_ms, "-", "+", 1)
252
+ local row = rows[1]
253
+ if type(row) ~= "table" then
254
+ return nil
255
+ end
256
+ return { id = row[1], consumer = row[2] }
257
+ end
258
+
259
+ local function unit_id_of(stream, entry_id)
260
+ local range = redis.call("XRANGE", stream, entry_id, entry_id)
261
+ local entry = range[1]
262
+ if type(entry) ~= "table" then
263
+ return nil
264
+ end
265
+ return entry_fields(entry[2]).id
266
+ end
267
+
268
+ -- Claims from one stream. Returns nil (nothing idle), CORRUPT, or the reply.
269
+ local function reclaim_from(stream, timeout_ms, max_reclaims)
270
+ local candidate = idle_candidate(stream, timeout_ms)
271
+ if candidate then
272
+ -- Refuse before moving ownership when the unit's state is already gone.
273
+ local candidate_unit = unit_id_of(stream, candidate.id)
274
+ if candidate_unit and redis.call("HEXISTS", unit_state, candidate_unit) == 0 then
275
+ return CORRUPT
276
+ end
277
+ end
278
+
279
+ local reply = redis.call("XAUTOCLAIM", stream, GROUP, worker_id, timeout_ms, "0-0", "COUNT", 1)
280
+ local entry_id, fields = first_claimed(reply)
281
+ if not entry_id then
282
+ return nil
283
+ end
284
+
285
+ local unit_id, unit_type = fields.id, fields.type
286
+ local row = pending_row(stream, entry_id)
287
+ local delivery_count = row and row.delivery_count or 0
288
+ local previous_worker_id = (candidate and candidate.id == entry_id) and candidate.consumer or nil
289
+ local state = read_unit_state(unit_id)
290
+ if not state then
291
+ return CORRUPT
292
+ end
293
+
294
+ if state.reclaim_count + 1 > max_reclaims then
295
+ finalize_entry(stream, entry_id, unit_id, state, "failed", 0, "reclaim_budget_exhausted", nil, {
296
+ { "previous_worker_id", previous_worker_id },
297
+ { "delivery_count", delivery_count },
298
+ })
299
+ touch_worker({})
300
+ renew_ttls()
301
+ return { "FINALIZED", unit_id }
302
+ end
303
+
304
+ state.reclaim_count = state.reclaim_count + 1
305
+ write_unit_state(unit_id, state)
306
+ append_event("reclaimed", unit_id, state, {
307
+ { "previous_worker_id", previous_worker_id },
308
+ { "stream", stream_short(stream) },
309
+ { "entry_id", entry_id },
310
+ { "delivery_count", delivery_count },
311
+ })
312
+ touch_worker({ current_unit = unit_id })
313
+ renew_ttls()
314
+ return { "OK", stream_short(stream), entry_id, unit_id, unit_type, delivery_count, state.retry_index, state.reclaim_count }
315
+ end
316
+
317
+ -- ARGV: timeout_ms, max_reclaims. Priority stream first.
318
+ local function reclaim(timeout_ms, max_reclaims)
319
+ timeout_ms = tonumber(timeout_ms)
320
+ max_reclaims = budget("max_reclaims", max_reclaims)
321
+ for _, stream in ipairs({ priority, units }) do
322
+ local reply = reclaim_from(stream, timeout_ms, max_reclaims)
323
+ if reply then
324
+ return reply
325
+ end
326
+ end
327
+ return { "NONE" }
328
+ end
329
+
330
+ -- ARGV: stream, entry_id, delivery_count, unit_id.
331
+ local function heartbeat(short, entry_id, delivery_count, unit_id)
332
+ local stream = stream_key(short)
333
+ if not fence(stream, entry_id, worker_id, tonumber(delivery_count)) then
334
+ return STALE
335
+ end
336
+ redis.call("XCLAIM", stream, GROUP, worker_id, 0, entry_id, "JUSTID")
337
+ touch_worker({ current_unit = unit_id })
338
+ renew_ttls()
339
+ return { "OK" }
340
+ end
341
+
342
+ -- ARGV: stream, entry_id, delivery_count, unit_id, outcome, duration_ms, reason, errors_json.
343
+ local function finalize(short, entry_id, delivery_count, unit_id, outcome, duration_ms, reason, errors_json)
344
+ local stream = stream_key(short)
345
+ if not fence(stream, entry_id, worker_id, tonumber(delivery_count)) then
346
+ return STALE
347
+ end
348
+ local state = read_unit_state(unit_id)
349
+ if not state then
350
+ return CORRUPT
351
+ end
352
+ finalize_entry(stream, entry_id, unit_id, state, outcome, duration_ms, reason, errors_json)
353
+ touch_worker({ current_unit = cjson.null }, 1)
354
+ renew_ttls()
355
+ return { "OK" }
356
+ end
357
+
358
+ local function max_requeued_units(tolerance)
359
+ local total = tonumber(redis.call("HGET", meta, "total_units")) or 0
360
+ -- Subtracting an epsilon keeps ceil(100 * 0.7) at 70, not 71.
361
+ return math.max(0, math.ceil(total * tolerance - 1e-9))
362
+ end
363
+
364
+ -- ARGV: stream, entry_id, delivery_count, unit_id, unit_type, max_requeues,
365
+ -- requeue_tolerance, duration_ms, failure_summary, errors_json.
366
+ local function requeue(short, entry_id, delivery_count, unit_id, unit_type, max_requeues, tolerance, duration_ms,
367
+ failure_summary, errors_json)
368
+ local stream = stream_key(short)
369
+ if not fence(stream, entry_id, worker_id, tonumber(delivery_count)) then
370
+ return STALE
371
+ end
372
+ local state = read_unit_state(unit_id)
373
+ if not state then
374
+ return CORRUPT
375
+ end
376
+
377
+ max_requeues = budget("max_requeues", max_requeues)
378
+ tolerance = budget("requeue_tolerance", tolerance)
379
+ local requeued_units = tonumber(redis.call("HGET", meta, "requeued_units_count")) or 0
380
+ local over_unit_cap = state.retry_index + 1 > max_requeues
381
+ local over_tolerance = (not state.entered_retry) and requeued_units >= max_requeued_units(tolerance)
382
+
383
+ if over_unit_cap or over_tolerance then
384
+ finalize_entry(stream, entry_id, unit_id, state, "failed", duration_ms, "retry_budget_exhausted", errors_json, {
385
+ { "failure_summary", nil_if_empty(failure_summary) },
386
+ })
387
+ touch_worker({ current_unit = cjson.null }, 1)
388
+ renew_ttls()
389
+ return { "FINALIZED", state.retry_index }
390
+ end
391
+
392
+ if not state.entered_retry then
393
+ state.entered_retry = true
394
+ redis.call("HINCRBY", meta, "requeued_units_count", 1)
395
+ end
396
+ local previous_retry_index = state.retry_index
397
+ state.retry_index = state.retry_index + 1
398
+ write_unit_state(unit_id, state)
399
+ redis.call("XACK", stream, GROUP, entry_id)
400
+ local new_entry_id = redis.call("XADD", priority, "*", "id", unit_id, "type", unit_type)
401
+ append_event("requeued", unit_id, state, {
402
+ { "previous_retry_index", previous_retry_index },
403
+ { "duration_ms", tonumber(duration_ms) or 0 },
404
+ { "failure_summary", nil_if_empty(failure_summary) },
405
+ { "errors", unpack(raw_or_null(errors_json)) },
406
+ { "stream", short },
407
+ { "entry_id", entry_id },
408
+ { "new_entry_id", new_entry_id },
409
+ })
410
+ touch_worker({ current_unit = cjson.null }, 1)
411
+ renew_ttls()
412
+ return { "REQUEUED", state.retry_index }
413
+ end
414
+
415
+ -- ARGV: stream, entry_id, delivery_count, unit_id, elapsed_ms.
416
+ local function abandoned(short, entry_id, delivery_count, unit_id, elapsed_ms)
417
+ local stream = stream_key(short)
418
+ if not fence(stream, entry_id, worker_id, tonumber(delivery_count)) then
419
+ return STALE
420
+ end
421
+ local state = read_unit_state(unit_id)
422
+ if not state then
423
+ return CORRUPT
424
+ end
425
+ append_event("abandoned", unit_id, state, {
426
+ { "elapsed_ms", tonumber(elapsed_ms) or 0 },
427
+ { "stream", short },
428
+ { "entry_id", entry_id },
429
+ })
430
+ touch_worker({})
431
+ renew_ttls()
432
+ return { "OK" }
433
+ end
434
+
435
+ -- ARGV: current_unit (may be empty).
436
+ local function liveness(current_unit)
437
+ touch_worker({ current_unit = nil_if_empty(current_unit) or cjson.null })
438
+ renew_ttls()
439
+ return { "OK" }
440
+ end
441
+
442
+ -- ARGV: Ruby-built event JSON. Not fenced, does not require meta.
443
+ local function append_prebuilt_event(json)
444
+ append_prebuilt(json)
445
+ renew_ttls()
446
+ return { "OK" }
447
+ end
448
+
449
+ -- ---------------------------------------------------------------------------
450
+ -- Dispatch
451
+ -- ---------------------------------------------------------------------------
452
+
453
+ if mode == "worker_error" or mode == "stale_rejected" then
454
+ return append_prebuilt_event(ARGV[4])
455
+ end
456
+
457
+ if not meta_present() then
458
+ return CORRUPT
459
+ end
460
+
461
+ if mode == "delivery_accounting" then
462
+ return delivery_accounting(ARGV[4], ARGV[5], ARGV[6])
463
+ elseif mode == "reclaim" then
464
+ return reclaim(ARGV[4], ARGV[5])
465
+ elseif mode == "heartbeat" then
466
+ return heartbeat(ARGV[4], ARGV[5], ARGV[6], ARGV[7])
467
+ elseif mode == "finalize" then
468
+ return finalize(ARGV[4], ARGV[5], ARGV[6], ARGV[7], ARGV[8], ARGV[9], ARGV[10], ARGV[11])
469
+ elseif mode == "requeue" then
470
+ return requeue(ARGV[4], ARGV[5], ARGV[6], ARGV[7], ARGV[8], ARGV[9], ARGV[10], ARGV[11], ARGV[12], ARGV[13])
471
+ elseif mode == "abandoned" then
472
+ return abandoned(ARGV[4], ARGV[5], ARGV[6], ARGV[7], ARGV[8])
473
+ elseif mode == "liveness" then
474
+ return liveness(ARGV[4])
475
+ end
476
+ return redis.error_reply("ERR unknown transition mode " .. tostring(mode))