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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +537 -0
- data/Rakefile +10 -0
- data/docs/DESIGN.md +386 -0
- data/exe/rspec-hopper +6 -0
- data/lib/rspec/hopper/attempt_log.rb +133 -0
- data/lib/rspec/hopper/ci_env.rb +87 -0
- data/lib/rspec/hopper/cli/formatter_args.rb +129 -0
- data/lib/rspec/hopper/cli/report.rb +117 -0
- data/lib/rspec/hopper/cli/work/parser.rb +166 -0
- data/lib/rspec/hopper/cli/work.rb +59 -0
- data/lib/rspec/hopper/cli.rb +61 -0
- data/lib/rspec/hopper/config.rb +48 -0
- data/lib/rspec/hopper/errors.rb +88 -0
- data/lib/rspec/hopper/example_reset.rb +41 -0
- data/lib/rspec/hopper/fingerprint.rb +185 -0
- data/lib/rspec/hopper/keys.rb +38 -0
- data/lib/rspec/hopper/manifest.rb +113 -0
- data/lib/rspec/hopper/queue/redis_streams/lua/init.lua +94 -0
- data/lib/rspec/hopper/queue/redis_streams/lua/transition.lua +476 -0
- data/lib/rspec/hopper/queue/redis_streams.rb +307 -0
- data/lib/rspec/hopper/queue.rb +24 -0
- data/lib/rspec/hopper/report.rb +286 -0
- data/lib/rspec/hopper/reservation.rb +18 -0
- data/lib/rspec/hopper/supervisor.rb +196 -0
- data/lib/rspec/hopper/unit.rb +15 -0
- data/lib/rspec/hopper/version.rb +7 -0
- data/lib/rspec/hopper/worker/buffering_reporter.rb +51 -0
- data/lib/rspec/hopper/worker/heartbeat.rb +178 -0
- data/lib/rspec/hopper/worker/requeue_policy.rb +86 -0
- data/lib/rspec/hopper/worker/runner.rb +28 -0
- data/lib/rspec/hopper/worker/suite.rb +205 -0
- data/lib/rspec/hopper/worker.rb +299 -0
- data/lib/rspec/hopper.rb +65 -0
- 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))
|