glide-mq 0.4.3 → 0.6.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.
- package/.jules/bolt.md +3 -0
- package/README.md +27 -314
- package/dist/flow-producer.d.ts.map +1 -1
- package/dist/flow-producer.js +17 -1
- package/dist/flow-producer.js.map +1 -1
- package/dist/functions/index.d.ts +18 -7
- package/dist/functions/index.d.ts.map +1 -1
- package/dist/functions/index.js +763 -16
- package/dist/functions/index.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/job.d.ts +2 -0
- package/dist/job.d.ts.map +1 -1
- package/dist/job.js +4 -0
- package/dist/job.js.map +1 -1
- package/dist/queue.d.ts +17 -1
- package/dist/queue.d.ts.map +1 -1
- package/dist/queue.js +111 -4
- package/dist/queue.js.map +1 -1
- package/dist/scheduler.d.ts +10 -0
- package/dist/scheduler.d.ts.map +1 -1
- package/dist/scheduler.js +11 -0
- package/dist/scheduler.js.map +1 -1
- package/dist/types.d.ts +25 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/utils.d.ts +4 -0
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +4 -0
- package/dist/utils.js.map +1 -1
- package/dist/worker.d.ts +7 -0
- package/dist/worker.d.ts.map +1 -1
- package/dist/worker.js +72 -9
- package/dist/worker.js.map +1 -1
- package/package.json +1 -1
package/dist/functions/index.js
CHANGED
|
@@ -13,6 +13,7 @@ exports.resume = resume;
|
|
|
13
13
|
exports.rateLimit = rateLimit;
|
|
14
14
|
exports.checkConcurrency = checkConcurrency;
|
|
15
15
|
exports.moveToActive = moveToActive;
|
|
16
|
+
exports.promoteRateLimited = promoteRateLimited;
|
|
16
17
|
exports.deferActive = deferActive;
|
|
17
18
|
exports.removeJob = removeJob;
|
|
18
19
|
exports.revokeJob = revokeJob;
|
|
@@ -20,7 +21,7 @@ exports.searchByName = searchByName;
|
|
|
20
21
|
exports.addFlow = addFlow;
|
|
21
22
|
exports.completeChild = completeChild;
|
|
22
23
|
exports.LIBRARY_NAME = 'glidemq';
|
|
23
|
-
exports.LIBRARY_VERSION = '
|
|
24
|
+
exports.LIBRARY_VERSION = '19';
|
|
24
25
|
// Consumer group name used by workers
|
|
25
26
|
exports.CONSUMER_GROUP = 'workers';
|
|
26
27
|
// Embedded Lua library source (from glidemq.lua)
|
|
@@ -76,6 +77,136 @@ local function markOrderingDone(jobKey, jobId)
|
|
|
76
77
|
end
|
|
77
78
|
end
|
|
78
79
|
|
|
80
|
+
-- Refill token bucket using remainder accumulator for precision.
|
|
81
|
+
-- tbRefillRate is in millitokens/second. Returns current millitokens after refill.
|
|
82
|
+
-- Side effect: updates tbTokens, tbLastRefill, tbRefillRemainder on the group hash.
|
|
83
|
+
local function tbRefill(groupHashKey, g, now)
|
|
84
|
+
local tbCapacity = tonumber(g.tbCapacity) or 0
|
|
85
|
+
if tbCapacity <= 0 then return 0 end
|
|
86
|
+
local tbTokens = tonumber(g.tbTokens) or tbCapacity
|
|
87
|
+
local tbRefillRate = tonumber(g.tbRefillRate) or 0
|
|
88
|
+
local tbLastRefill = tonumber(g.tbLastRefill) or now
|
|
89
|
+
local tbRefillRemainder = tonumber(g.tbRefillRemainder) or 0
|
|
90
|
+
local elapsed = now - tbLastRefill
|
|
91
|
+
if elapsed <= 0 or tbRefillRate <= 0 then return tbTokens end
|
|
92
|
+
-- Cap elapsed to prevent overflow in long-idle buckets
|
|
93
|
+
local maxElapsed = math.ceil(tbCapacity * 1000 / tbRefillRate)
|
|
94
|
+
if elapsed > maxElapsed then elapsed = maxElapsed end
|
|
95
|
+
local raw = elapsed * tbRefillRate + tbRefillRemainder
|
|
96
|
+
local added = math.floor(raw / 1000)
|
|
97
|
+
local newRemainder = raw % 1000
|
|
98
|
+
local newTokens = math.min(tbCapacity, tbTokens + added)
|
|
99
|
+
redis.call('HSET', groupHashKey,
|
|
100
|
+
'tbTokens', tostring(newTokens),
|
|
101
|
+
'tbLastRefill', tostring(now),
|
|
102
|
+
'tbRefillRemainder', tostring(newRemainder))
|
|
103
|
+
return newTokens
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
local function releaseGroupSlotAndPromote(jobKey, jobId, now)
|
|
107
|
+
local gk = redis.call('HGET', jobKey, 'groupKey')
|
|
108
|
+
if not gk or gk == '' then return end
|
|
109
|
+
local prefix = string.sub(jobKey, 1, #jobKey - #('job:' .. jobId))
|
|
110
|
+
local groupHashKey = prefix .. 'group:' .. gk
|
|
111
|
+
-- Load all group fields in one call
|
|
112
|
+
local gFields = redis.call('HGETALL', groupHashKey)
|
|
113
|
+
local g = {}
|
|
114
|
+
for gf = 1, #gFields, 2 do g[gFields[gf]] = gFields[gf + 1] end
|
|
115
|
+
local cur = tonumber(g.active) or 0
|
|
116
|
+
local newActive = (cur > 0) and (cur - 1) or 0
|
|
117
|
+
if cur > 0 then
|
|
118
|
+
redis.call('HSET', groupHashKey, 'active', tostring(newActive))
|
|
119
|
+
end
|
|
120
|
+
local waitListKey = prefix .. 'groupq:' .. gk
|
|
121
|
+
local waitLen = redis.call('LLEN', waitListKey)
|
|
122
|
+
if waitLen == 0 then return end
|
|
123
|
+
-- Concurrency gate: if still at or above max after decrement, do not promote
|
|
124
|
+
local maxConc = tonumber(g.maxConcurrency) or 0
|
|
125
|
+
if maxConc > 0 and newActive >= maxConc then return end
|
|
126
|
+
-- Rate limit gate (skip if now is nil or 0 for safe fallback)
|
|
127
|
+
-- Only blocks promotion; does NOT increment rateCount. moveToActive handles counting.
|
|
128
|
+
local rateMax = tonumber(g.rateMax) or 0
|
|
129
|
+
local rateRemaining = 0
|
|
130
|
+
local ts = tonumber(now) or 0
|
|
131
|
+
if ts > 0 and rateMax > 0 then
|
|
132
|
+
local rateDuration = tonumber(g.rateDuration) or 0
|
|
133
|
+
if rateDuration > 0 then
|
|
134
|
+
local rateWindowStart = tonumber(g.rateWindowStart) or 0
|
|
135
|
+
local rateCount = tonumber(g.rateCount) or 0
|
|
136
|
+
if ts - rateWindowStart < rateDuration then
|
|
137
|
+
if rateCount >= rateMax then
|
|
138
|
+
-- Window active and at capacity: do not promote, register for scheduler
|
|
139
|
+
local rateLimitedKey = prefix .. 'ratelimited'
|
|
140
|
+
redis.call('ZADD', rateLimitedKey, rateWindowStart + rateDuration, gk)
|
|
141
|
+
return
|
|
142
|
+
end
|
|
143
|
+
rateRemaining = rateMax - rateCount
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
-- Token bucket gate: check head job cost before promoting
|
|
148
|
+
local tbCap = tonumber(g.tbCapacity) or 0
|
|
149
|
+
if ts > 0 and tbCap > 0 then
|
|
150
|
+
local tbTokensCur = tbRefill(groupHashKey, g, ts)
|
|
151
|
+
-- Peek at head job, skipping tombstones and DLQ'd jobs (up to 10 iterations)
|
|
152
|
+
local tbCheckPasses = 0
|
|
153
|
+
local tbOk = false
|
|
154
|
+
while tbCheckPasses < 10 do
|
|
155
|
+
tbCheckPasses = tbCheckPasses + 1
|
|
156
|
+
local headJobId = redis.call('LINDEX', waitListKey, 0)
|
|
157
|
+
if not headJobId then break end
|
|
158
|
+
local headJobKey = prefix .. 'job:' .. headJobId
|
|
159
|
+
-- Tombstone guard: job hash deleted - pop and check next
|
|
160
|
+
if redis.call('EXISTS', headJobKey) == 0 then
|
|
161
|
+
redis.call('LPOP', waitListKey)
|
|
162
|
+
else
|
|
163
|
+
local headCost = tonumber(redis.call('HGET', headJobKey, 'cost')) or 1000
|
|
164
|
+
-- DLQ guard: cost > capacity - pop, fail, check next
|
|
165
|
+
if headCost > tbCap then
|
|
166
|
+
redis.call('LPOP', waitListKey)
|
|
167
|
+
redis.call('ZADD', prefix .. 'failed', ts, headJobId)
|
|
168
|
+
redis.call('HSET', headJobKey,
|
|
169
|
+
'state', 'failed',
|
|
170
|
+
'failedReason', 'cost exceeds token bucket capacity',
|
|
171
|
+
'finishedOn', tostring(ts))
|
|
172
|
+
emitEvent(prefix .. 'events', 'failed', headJobId, {'failedReason', 'cost exceeds token bucket capacity'})
|
|
173
|
+
elseif tbTokensCur < headCost then
|
|
174
|
+
-- Not enough tokens: register delay and skip promotion
|
|
175
|
+
local tbRateVal = tonumber(g.tbRefillRate) or 0
|
|
176
|
+
if tbRateVal <= 0 then break end
|
|
177
|
+
local tbDelayMs = math.ceil((headCost - tbTokensCur) * 1000 / tbRateVal)
|
|
178
|
+
local rateLimitedKey = prefix .. 'ratelimited'
|
|
179
|
+
redis.call('ZADD', rateLimitedKey, ts + tbDelayMs, gk)
|
|
180
|
+
return
|
|
181
|
+
else
|
|
182
|
+
tbOk = true
|
|
183
|
+
break
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
if not tbOk and tbCheckPasses >= 10 then return end
|
|
188
|
+
end
|
|
189
|
+
-- Calculate how many slots are available for promotion
|
|
190
|
+
local available = 1
|
|
191
|
+
if maxConc > 0 then
|
|
192
|
+
available = maxConc - newActive
|
|
193
|
+
else
|
|
194
|
+
available = math.min(waitLen, 1000)
|
|
195
|
+
end
|
|
196
|
+
-- Cap by rate limit remaining if a window is active
|
|
197
|
+
if rateRemaining > 0 then
|
|
198
|
+
available = math.min(available, rateRemaining)
|
|
199
|
+
end
|
|
200
|
+
local streamKey = prefix .. 'stream'
|
|
201
|
+
for p = 1, available do
|
|
202
|
+
local nextJobId = redis.call('LPOP', waitListKey)
|
|
203
|
+
if not nextJobId then break end
|
|
204
|
+
redis.call('XADD', streamKey, '*', 'jobId', nextJobId)
|
|
205
|
+
local nextJobKey = prefix .. 'job:' .. nextJobId
|
|
206
|
+
redis.call('HSET', nextJobKey, 'state', 'waiting')
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
79
210
|
local function extractOrderingKeyFromOpts(optsJson)
|
|
80
211
|
if not optsJson or optsJson == '' then
|
|
81
212
|
return ''
|
|
@@ -95,6 +226,67 @@ local function extractOrderingKeyFromOpts(optsJson)
|
|
|
95
226
|
return tostring(key)
|
|
96
227
|
end
|
|
97
228
|
|
|
229
|
+
local function extractGroupConcurrencyFromOpts(optsJson)
|
|
230
|
+
if not optsJson or optsJson == '' then
|
|
231
|
+
return 0
|
|
232
|
+
end
|
|
233
|
+
local ok, decoded = pcall(cjson.decode, optsJson)
|
|
234
|
+
if not ok or type(decoded) ~= 'table' then
|
|
235
|
+
return 0
|
|
236
|
+
end
|
|
237
|
+
local ordering = decoded['ordering']
|
|
238
|
+
if type(ordering) ~= 'table' then
|
|
239
|
+
return 0
|
|
240
|
+
end
|
|
241
|
+
local conc = ordering['concurrency']
|
|
242
|
+
if conc == nil then
|
|
243
|
+
return 0
|
|
244
|
+
end
|
|
245
|
+
return tonumber(conc) or 0
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
local function extractGroupRateLimitFromOpts(optsJson)
|
|
249
|
+
if not optsJson or optsJson == '' then
|
|
250
|
+
return 0, 0
|
|
251
|
+
end
|
|
252
|
+
local ok, decoded = pcall(cjson.decode, optsJson)
|
|
253
|
+
if not ok or type(decoded) ~= 'table' then
|
|
254
|
+
return 0, 0
|
|
255
|
+
end
|
|
256
|
+
local ordering = decoded['ordering']
|
|
257
|
+
if type(ordering) ~= 'table' then
|
|
258
|
+
return 0, 0
|
|
259
|
+
end
|
|
260
|
+
local rl = ordering['rateLimit']
|
|
261
|
+
if type(rl) ~= 'table' then
|
|
262
|
+
return 0, 0
|
|
263
|
+
end
|
|
264
|
+
local max = tonumber(rl['max']) or 0
|
|
265
|
+
local duration = tonumber(rl['duration']) or 0
|
|
266
|
+
return max, duration
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
local function extractTokenBucketFromOpts(optsJson)
|
|
270
|
+
if not optsJson or optsJson == '' then return 0, 0 end
|
|
271
|
+
local ok, decoded = pcall(cjson.decode, optsJson)
|
|
272
|
+
if not ok or type(decoded) ~= 'table' then return 0, 0 end
|
|
273
|
+
local ordering = decoded['ordering']
|
|
274
|
+
if type(ordering) ~= 'table' then return 0, 0 end
|
|
275
|
+
local tb = ordering['tokenBucket']
|
|
276
|
+
if type(tb) ~= 'table' then return 0, 0 end
|
|
277
|
+
local capacity = tonumber(tb['capacity']) or 0
|
|
278
|
+
local refillRate = tonumber(tb['refillRate']) or 0
|
|
279
|
+
return math.floor(capacity * 1000), math.floor(refillRate * 1000)
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
local function extractCostFromOpts(optsJson)
|
|
283
|
+
if not optsJson or optsJson == '' then return 0 end
|
|
284
|
+
local ok, decoded = pcall(cjson.decode, optsJson)
|
|
285
|
+
if not ok or type(decoded) ~= 'table' then return 0 end
|
|
286
|
+
local cost = tonumber(decoded['cost']) or 0
|
|
287
|
+
return math.floor(cost * 1000)
|
|
288
|
+
end
|
|
289
|
+
|
|
98
290
|
redis.register_function('glidemq_version', function(keys, args)
|
|
99
291
|
return '${exports.LIBRARY_VERSION}'
|
|
100
292
|
end)
|
|
@@ -113,15 +305,80 @@ redis.register_function('glidemq_addJob', function(keys, args)
|
|
|
113
305
|
local parentId = args[7] or ''
|
|
114
306
|
local maxAttempts = tonumber(args[8]) or 0
|
|
115
307
|
local orderingKey = args[9] or ''
|
|
308
|
+
local groupConcurrency = tonumber(args[10]) or 0
|
|
309
|
+
local groupRateMax = tonumber(args[11]) or 0
|
|
310
|
+
local groupRateDuration = tonumber(args[12]) or 0
|
|
311
|
+
local tbCapacity = tonumber(args[13]) or 0
|
|
312
|
+
local tbRefillRate = tonumber(args[14]) or 0
|
|
313
|
+
local jobCost = tonumber(args[15]) or 0
|
|
116
314
|
local jobId = redis.call('INCR', idKey)
|
|
117
315
|
local jobIdStr = tostring(jobId)
|
|
118
316
|
local prefix = string.sub(idKey, 1, #idKey - 2)
|
|
119
317
|
local jobKey = prefix .. 'job:' .. jobIdStr
|
|
318
|
+
local useGroupConcurrency = (orderingKey ~= '' and (groupConcurrency > 1 or groupRateMax > 0 or tbCapacity > 0))
|
|
120
319
|
local orderingSeq = 0
|
|
121
|
-
if orderingKey ~= '' then
|
|
320
|
+
if orderingKey ~= '' and not useGroupConcurrency then
|
|
122
321
|
local orderingMetaKey = prefix .. 'ordering'
|
|
123
322
|
orderingSeq = redis.call('HINCRBY', orderingMetaKey, orderingKey, 1)
|
|
124
323
|
end
|
|
324
|
+
if useGroupConcurrency then
|
|
325
|
+
local groupHashKey = prefix .. 'group:' .. orderingKey
|
|
326
|
+
local curMax = tonumber(redis.call('HGET', groupHashKey, 'maxConcurrency')) or 0
|
|
327
|
+
if curMax ~= groupConcurrency then
|
|
328
|
+
redis.call('HSET', groupHashKey, 'maxConcurrency', tostring(groupConcurrency))
|
|
329
|
+
end
|
|
330
|
+
-- When rate limit or token bucket forces group path but concurrency is 0 or 1, ensure maxConcurrency >= 1
|
|
331
|
+
if curMax == 0 and groupConcurrency <= 1 then
|
|
332
|
+
redis.call('HSET', groupHashKey, 'maxConcurrency', '1')
|
|
333
|
+
end
|
|
334
|
+
-- Upsert rate limit fields on group hash
|
|
335
|
+
if groupRateMax > 0 then
|
|
336
|
+
local curRateMax = tonumber(redis.call('HGET', groupHashKey, 'rateMax')) or 0
|
|
337
|
+
if curRateMax ~= groupRateMax then
|
|
338
|
+
redis.call('HSET', groupHashKey, 'rateMax', tostring(groupRateMax))
|
|
339
|
+
end
|
|
340
|
+
local curRateDuration = tonumber(redis.call('HGET', groupHashKey, 'rateDuration')) or 0
|
|
341
|
+
if curRateDuration ~= groupRateDuration then
|
|
342
|
+
redis.call('HSET', groupHashKey, 'rateDuration', tostring(groupRateDuration))
|
|
343
|
+
end
|
|
344
|
+
else
|
|
345
|
+
-- Clear stale rate limit fields if group was previously rate-limited
|
|
346
|
+
local oldRateMax = tonumber(redis.call('HGET', groupHashKey, 'rateMax')) or 0
|
|
347
|
+
if oldRateMax > 0 then
|
|
348
|
+
redis.call('HDEL', groupHashKey, 'rateMax', 'rateDuration', 'rateWindowStart', 'rateCount')
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
-- Upsert token bucket fields on group hash
|
|
352
|
+
if tbCapacity > 0 then
|
|
353
|
+
local curTbCap = tonumber(redis.call('HGET', groupHashKey, 'tbCapacity')) or 0
|
|
354
|
+
if curTbCap ~= tbCapacity then
|
|
355
|
+
redis.call('HSET', groupHashKey, 'tbCapacity', tostring(tbCapacity))
|
|
356
|
+
end
|
|
357
|
+
local curTbRate = tonumber(redis.call('HGET', groupHashKey, 'tbRefillRate')) or 0
|
|
358
|
+
if curTbRate ~= tbRefillRate then
|
|
359
|
+
redis.call('HSET', groupHashKey, 'tbRefillRate', tostring(tbRefillRate))
|
|
360
|
+
end
|
|
361
|
+
-- Initialize tokens on first setup
|
|
362
|
+
if curTbCap == 0 then
|
|
363
|
+
redis.call('HSET', groupHashKey,
|
|
364
|
+
'tbTokens', tostring(tbCapacity),
|
|
365
|
+
'tbLastRefill', tostring(timestamp),
|
|
366
|
+
'tbRefillRemainder', '0')
|
|
367
|
+
end
|
|
368
|
+
-- Validate cost <= capacity at enqueue
|
|
369
|
+
-- Validate cost (explicit or default 1000 millitokens) against capacity
|
|
370
|
+
local effectiveCost = (jobCost > 0) and jobCost or 1000
|
|
371
|
+
if effectiveCost > tbCapacity then
|
|
372
|
+
return 'ERR:COST_EXCEEDS_CAPACITY'
|
|
373
|
+
end
|
|
374
|
+
else
|
|
375
|
+
-- Clear stale tb fields
|
|
376
|
+
local oldTbCap = tonumber(redis.call('HGET', groupHashKey, 'tbCapacity')) or 0
|
|
377
|
+
if oldTbCap > 0 then
|
|
378
|
+
redis.call('HDEL', groupHashKey, 'tbCapacity', 'tbRefillRate', 'tbTokens', 'tbLastRefill', 'tbRefillRemainder')
|
|
379
|
+
end
|
|
380
|
+
end
|
|
381
|
+
end
|
|
125
382
|
local hashFields = {
|
|
126
383
|
'id', jobIdStr,
|
|
127
384
|
'name', jobName,
|
|
@@ -133,12 +390,19 @@ redis.register_function('glidemq_addJob', function(keys, args)
|
|
|
133
390
|
'priority', tostring(priority),
|
|
134
391
|
'maxAttempts', tostring(maxAttempts)
|
|
135
392
|
}
|
|
136
|
-
if
|
|
393
|
+
if useGroupConcurrency then
|
|
394
|
+
hashFields[#hashFields + 1] = 'groupKey'
|
|
395
|
+
hashFields[#hashFields + 1] = orderingKey
|
|
396
|
+
elseif orderingKey ~= '' then
|
|
137
397
|
hashFields[#hashFields + 1] = 'orderingKey'
|
|
138
398
|
hashFields[#hashFields + 1] = orderingKey
|
|
139
399
|
hashFields[#hashFields + 1] = 'orderingSeq'
|
|
140
400
|
hashFields[#hashFields + 1] = tostring(orderingSeq)
|
|
141
401
|
end
|
|
402
|
+
if jobCost > 0 then
|
|
403
|
+
hashFields[#hashFields + 1] = 'cost'
|
|
404
|
+
hashFields[#hashFields + 1] = tostring(jobCost)
|
|
405
|
+
end
|
|
142
406
|
if parentId ~= '' then
|
|
143
407
|
hashFields[#hashFields + 1] = 'parentId'
|
|
144
408
|
hashFields[#hashFields + 1] = parentId
|
|
@@ -230,6 +494,7 @@ redis.register_function('glidemq_complete', function(keys, args)
|
|
|
230
494
|
'finishedOn', tostring(timestamp)
|
|
231
495
|
)
|
|
232
496
|
markOrderingDone(jobKey, jobId)
|
|
497
|
+
releaseGroupSlotAndPromote(jobKey, jobId, timestamp)
|
|
233
498
|
emitEvent(eventsKey, 'completed', jobId, {'returnvalue', returnvalue})
|
|
234
499
|
local prefix = string.sub(jobKey, 1, #jobKey - #('job:' .. jobId))
|
|
235
500
|
if removeMode == 'true' then
|
|
@@ -311,6 +576,7 @@ redis.register_function('glidemq_completeAndFetchNext', function(keys, args)
|
|
|
311
576
|
'finishedOn', tostring(timestamp)
|
|
312
577
|
)
|
|
313
578
|
markOrderingDone(jobKey, jobId)
|
|
579
|
+
releaseGroupSlotAndPromote(jobKey, jobId, timestamp)
|
|
314
580
|
emitEvent(eventsKey, 'completed', jobId, {'returnvalue', returnvalue})
|
|
315
581
|
local prefix = string.sub(jobKey, 1, #jobKey - #('job:' .. jobId))
|
|
316
582
|
|
|
@@ -378,6 +644,93 @@ redis.register_function('glidemq_completeAndFetchNext', function(keys, args)
|
|
|
378
644
|
if revoked == '1' then
|
|
379
645
|
return cjson.encode({completed = jobId, next = 'REVOKED', nextJobId = nextJobId, nextEntryId = nextEntryId})
|
|
380
646
|
end
|
|
647
|
+
local nextGroupKey = redis.call('HGET', nextJobKey, 'groupKey')
|
|
648
|
+
if nextGroupKey and nextGroupKey ~= '' then
|
|
649
|
+
local nextGroupHashKey = prefix .. 'group:' .. nextGroupKey
|
|
650
|
+
-- Load all group fields in one call
|
|
651
|
+
local nGrpFields = redis.call('HGETALL', nextGroupHashKey)
|
|
652
|
+
local nGrp = {}
|
|
653
|
+
for nf = 1, #nGrpFields, 2 do nGrp[nGrpFields[nf]] = nGrpFields[nf + 1] end
|
|
654
|
+
local nextMaxConc = tonumber(nGrp.maxConcurrency) or 0
|
|
655
|
+
local nextActive = tonumber(nGrp.active) or 0
|
|
656
|
+
-- Concurrency gate first (avoids burning rate/token slots on parked jobs)
|
|
657
|
+
if nextMaxConc > 0 and nextActive >= nextMaxConc then
|
|
658
|
+
redis.call('XACK', streamKey, group, nextEntryId)
|
|
659
|
+
redis.call('XDEL', streamKey, nextEntryId)
|
|
660
|
+
local nextWaitListKey = prefix .. 'groupq:' .. nextGroupKey
|
|
661
|
+
redis.call('RPUSH', nextWaitListKey, nextJobId)
|
|
662
|
+
redis.call('HSET', nextJobKey, 'state', 'group-waiting')
|
|
663
|
+
return cjson.encode({completed = jobId, next = false})
|
|
664
|
+
end
|
|
665
|
+
-- Token bucket gate (read-only)
|
|
666
|
+
local nextTbCapacity = tonumber(nGrp.tbCapacity) or 0
|
|
667
|
+
local nextTbBlocked = false
|
|
668
|
+
local nextTbDelay = 0
|
|
669
|
+
local nextTbTokens = 0
|
|
670
|
+
local nextJobCostVal = 0
|
|
671
|
+
if nextTbCapacity > 0 then
|
|
672
|
+
nextTbTokens = tbRefill(nextGroupHashKey, nGrp, tonumber(timestamp))
|
|
673
|
+
nextJobCostVal = tonumber(redis.call('HGET', nextJobKey, 'cost')) or 1000
|
|
674
|
+
-- DLQ guard: cost > capacity
|
|
675
|
+
if nextJobCostVal > nextTbCapacity then
|
|
676
|
+
redis.call('XACK', streamKey, group, nextEntryId)
|
|
677
|
+
redis.call('XDEL', streamKey, nextEntryId)
|
|
678
|
+
redis.call('ZADD', prefix .. 'failed', tonumber(timestamp), nextJobId)
|
|
679
|
+
redis.call('HSET', nextJobKey,
|
|
680
|
+
'state', 'failed',
|
|
681
|
+
'failedReason', 'cost exceeds token bucket capacity',
|
|
682
|
+
'finishedOn', tostring(timestamp))
|
|
683
|
+
emitEvent(prefix .. 'events', 'failed', nextJobId, {'failedReason', 'cost exceeds token bucket capacity'})
|
|
684
|
+
return cjson.encode({completed = jobId, next = false})
|
|
685
|
+
end
|
|
686
|
+
if nextTbTokens < nextJobCostVal then
|
|
687
|
+
nextTbBlocked = true
|
|
688
|
+
local nextTbRefillRateVal = math.max(tonumber(nGrp.tbRefillRate) or 0, 1)
|
|
689
|
+
nextTbDelay = math.ceil((nextJobCostVal - nextTbTokens) * 1000 / nextTbRefillRateVal)
|
|
690
|
+
end
|
|
691
|
+
end
|
|
692
|
+
-- Sliding window gate (read-only)
|
|
693
|
+
local nextRateMax = tonumber(nGrp.rateMax) or 0
|
|
694
|
+
local nextRlBlocked = false
|
|
695
|
+
local nextRlDelay = 0
|
|
696
|
+
if nextRateMax > 0 then
|
|
697
|
+
local nextRateDuration = tonumber(nGrp.rateDuration) or 0
|
|
698
|
+
local nextRateWindowStart = tonumber(nGrp.rateWindowStart) or 0
|
|
699
|
+
local nextRateCount = tonumber(nGrp.rateCount) or 0
|
|
700
|
+
if nextRateDuration > 0 and timestamp - nextRateWindowStart < nextRateDuration and nextRateCount >= nextRateMax then
|
|
701
|
+
nextRlBlocked = true
|
|
702
|
+
nextRlDelay = (nextRateWindowStart + nextRateDuration) - timestamp
|
|
703
|
+
end
|
|
704
|
+
end
|
|
705
|
+
-- If ANY gate blocked: park + register
|
|
706
|
+
if nextTbBlocked or nextRlBlocked then
|
|
707
|
+
redis.call('XACK', streamKey, group, nextEntryId)
|
|
708
|
+
redis.call('XDEL', streamKey, nextEntryId)
|
|
709
|
+
local nextWaitListKey = prefix .. 'groupq:' .. nextGroupKey
|
|
710
|
+
redis.call('RPUSH', nextWaitListKey, nextJobId)
|
|
711
|
+
redis.call('HSET', nextJobKey, 'state', 'group-waiting')
|
|
712
|
+
local nextMaxDelay = math.max(nextTbDelay, nextRlDelay)
|
|
713
|
+
local rateLimitedKey = prefix .. 'ratelimited'
|
|
714
|
+
redis.call('ZADD', rateLimitedKey, tonumber(timestamp) + nextMaxDelay, nextGroupKey)
|
|
715
|
+
return cjson.encode({completed = jobId, next = false})
|
|
716
|
+
end
|
|
717
|
+
-- All gates passed: mutate state
|
|
718
|
+
if nextTbCapacity > 0 then
|
|
719
|
+
redis.call('HINCRBY', nextGroupHashKey, 'tbTokens', -nextJobCostVal)
|
|
720
|
+
end
|
|
721
|
+
if nextRateMax > 0 then
|
|
722
|
+
local nextRateDuration = tonumber(nGrp.rateDuration) or 0
|
|
723
|
+
if nextRateDuration > 0 then
|
|
724
|
+
local nextRateWindowStart = tonumber(nGrp.rateWindowStart) or 0
|
|
725
|
+
if timestamp - nextRateWindowStart >= nextRateDuration then
|
|
726
|
+
redis.call('HSET', nextGroupHashKey, 'rateWindowStart', tostring(timestamp), 'rateCount', '1')
|
|
727
|
+
else
|
|
728
|
+
redis.call('HINCRBY', nextGroupHashKey, 'rateCount', 1)
|
|
729
|
+
end
|
|
730
|
+
end
|
|
731
|
+
end
|
|
732
|
+
redis.call('HINCRBY', nextGroupHashKey, 'active', 1)
|
|
733
|
+
end
|
|
381
734
|
redis.call('HSET', nextJobKey, 'state', 'active', 'processedOn', tostring(timestamp), 'lastActive', tostring(timestamp))
|
|
382
735
|
local nextHash = redis.call('HGETALL', nextJobKey)
|
|
383
736
|
return cjson.encode({completed = jobId, next = nextHash, nextJobId = nextJobId, nextEntryId = nextEntryId})
|
|
@@ -412,6 +765,7 @@ redis.register_function('glidemq_fail', function(keys, args)
|
|
|
412
765
|
'failedReason', failedReason,
|
|
413
766
|
'processedOn', tostring(timestamp)
|
|
414
767
|
)
|
|
768
|
+
releaseGroupSlotAndPromote(jobKey, jobId, timestamp)
|
|
415
769
|
emitEvent(eventsKey, 'retrying', jobId, {
|
|
416
770
|
'failedReason', failedReason,
|
|
417
771
|
'attemptsMade', tostring(attemptsMade),
|
|
@@ -427,6 +781,7 @@ redis.register_function('glidemq_fail', function(keys, args)
|
|
|
427
781
|
'processedOn', tostring(timestamp)
|
|
428
782
|
)
|
|
429
783
|
markOrderingDone(jobKey, jobId)
|
|
784
|
+
releaseGroupSlotAndPromote(jobKey, jobId, timestamp)
|
|
430
785
|
emitEvent(eventsKey, 'failed', jobId, {'failedReason', failedReason})
|
|
431
786
|
local prefix = string.sub(jobKey, 1, #jobKey - #('job:' .. jobId))
|
|
432
787
|
if removeMode == 'true' then
|
|
@@ -514,6 +869,7 @@ redis.register_function('glidemq_reclaimStalled', function(keys, args)
|
|
|
514
869
|
'finishedOn', tostring(timestamp)
|
|
515
870
|
)
|
|
516
871
|
markOrderingDone(jobKey, jobId)
|
|
872
|
+
releaseGroupSlotAndPromote(jobKey, jobId, timestamp)
|
|
517
873
|
emitEvent(eventsKey, 'failed', jobId, {
|
|
518
874
|
'failedReason', 'job stalled more than maxStalledCount'
|
|
519
875
|
})
|
|
@@ -562,6 +918,12 @@ redis.register_function('glidemq_dedup', function(keys, args)
|
|
|
562
918
|
local parentId = args[10] or ''
|
|
563
919
|
local maxAttempts = tonumber(args[11]) or 0
|
|
564
920
|
local orderingKey = args[12] or ''
|
|
921
|
+
local groupConcurrency = tonumber(args[13]) or 0
|
|
922
|
+
local groupRateMax = tonumber(args[14]) or 0
|
|
923
|
+
local groupRateDuration = tonumber(args[15]) or 0
|
|
924
|
+
local tbCapacity = tonumber(args[16]) or 0
|
|
925
|
+
local tbRefillRate = tonumber(args[17]) or 0
|
|
926
|
+
local jobCost = tonumber(args[18]) or 0
|
|
565
927
|
local prefix = string.sub(idKey, 1, #idKey - 2)
|
|
566
928
|
local existing = redis.call('HGET', dedupKey, dedupId)
|
|
567
929
|
if mode == 'simple' then
|
|
@@ -607,11 +969,67 @@ redis.register_function('glidemq_dedup', function(keys, args)
|
|
|
607
969
|
local jobId = redis.call('INCR', idKey)
|
|
608
970
|
local jobIdStr = tostring(jobId)
|
|
609
971
|
local jobKey = prefix .. 'job:' .. jobIdStr
|
|
972
|
+
local useGroupConcurrency = (orderingKey ~= '' and (groupConcurrency > 1 or groupRateMax > 0 or tbCapacity > 0))
|
|
610
973
|
local orderingSeq = 0
|
|
611
|
-
if orderingKey ~= '' then
|
|
974
|
+
if orderingKey ~= '' and not useGroupConcurrency then
|
|
612
975
|
local orderingMetaKey = prefix .. 'ordering'
|
|
613
976
|
orderingSeq = redis.call('HINCRBY', orderingMetaKey, orderingKey, 1)
|
|
614
977
|
end
|
|
978
|
+
if useGroupConcurrency then
|
|
979
|
+
local groupHashKey = prefix .. 'group:' .. orderingKey
|
|
980
|
+
local curMax = tonumber(redis.call('HGET', groupHashKey, 'maxConcurrency')) or 0
|
|
981
|
+
if curMax ~= groupConcurrency then
|
|
982
|
+
redis.call('HSET', groupHashKey, 'maxConcurrency', tostring(groupConcurrency))
|
|
983
|
+
end
|
|
984
|
+
if curMax == 0 and groupConcurrency <= 1 then
|
|
985
|
+
redis.call('HSET', groupHashKey, 'maxConcurrency', '1')
|
|
986
|
+
end
|
|
987
|
+
if groupRateMax > 0 then
|
|
988
|
+
local curRateMax = tonumber(redis.call('HGET', groupHashKey, 'rateMax')) or 0
|
|
989
|
+
if curRateMax ~= groupRateMax then
|
|
990
|
+
redis.call('HSET', groupHashKey, 'rateMax', tostring(groupRateMax))
|
|
991
|
+
end
|
|
992
|
+
local curRateDuration = tonumber(redis.call('HGET', groupHashKey, 'rateDuration')) or 0
|
|
993
|
+
if curRateDuration ~= groupRateDuration then
|
|
994
|
+
redis.call('HSET', groupHashKey, 'rateDuration', tostring(groupRateDuration))
|
|
995
|
+
end
|
|
996
|
+
else
|
|
997
|
+
local oldRateMax = tonumber(redis.call('HGET', groupHashKey, 'rateMax')) or 0
|
|
998
|
+
if oldRateMax > 0 then
|
|
999
|
+
redis.call('HDEL', groupHashKey, 'rateMax', 'rateDuration', 'rateWindowStart', 'rateCount')
|
|
1000
|
+
end
|
|
1001
|
+
end
|
|
1002
|
+
-- Upsert token bucket fields on group hash
|
|
1003
|
+
if tbCapacity > 0 then
|
|
1004
|
+
local curTbCap = tonumber(redis.call('HGET', groupHashKey, 'tbCapacity')) or 0
|
|
1005
|
+
if curTbCap ~= tbCapacity then
|
|
1006
|
+
redis.call('HSET', groupHashKey, 'tbCapacity', tostring(tbCapacity))
|
|
1007
|
+
end
|
|
1008
|
+
local curTbRate = tonumber(redis.call('HGET', groupHashKey, 'tbRefillRate')) or 0
|
|
1009
|
+
if curTbRate ~= tbRefillRate then
|
|
1010
|
+
redis.call('HSET', groupHashKey, 'tbRefillRate', tostring(tbRefillRate))
|
|
1011
|
+
end
|
|
1012
|
+
-- Initialize tokens on first setup
|
|
1013
|
+
if curTbCap == 0 then
|
|
1014
|
+
redis.call('HSET', groupHashKey,
|
|
1015
|
+
'tbTokens', tostring(tbCapacity),
|
|
1016
|
+
'tbLastRefill', tostring(timestamp),
|
|
1017
|
+
'tbRefillRemainder', '0')
|
|
1018
|
+
end
|
|
1019
|
+
-- Validate cost <= capacity at enqueue
|
|
1020
|
+
-- Validate cost (explicit or default 1000 millitokens) against capacity
|
|
1021
|
+
local effectiveCost = (jobCost > 0) and jobCost or 1000
|
|
1022
|
+
if effectiveCost > tbCapacity then
|
|
1023
|
+
return 'ERR:COST_EXCEEDS_CAPACITY'
|
|
1024
|
+
end
|
|
1025
|
+
else
|
|
1026
|
+
-- Clear stale tb fields
|
|
1027
|
+
local oldTbCap = tonumber(redis.call('HGET', groupHashKey, 'tbCapacity')) or 0
|
|
1028
|
+
if oldTbCap > 0 then
|
|
1029
|
+
redis.call('HDEL', groupHashKey, 'tbCapacity', 'tbRefillRate', 'tbTokens', 'tbLastRefill', 'tbRefillRemainder')
|
|
1030
|
+
end
|
|
1031
|
+
end
|
|
1032
|
+
end
|
|
615
1033
|
local hashFields = {
|
|
616
1034
|
'id', jobIdStr,
|
|
617
1035
|
'name', jobName,
|
|
@@ -623,12 +1041,19 @@ redis.register_function('glidemq_dedup', function(keys, args)
|
|
|
623
1041
|
'priority', tostring(priority),
|
|
624
1042
|
'maxAttempts', tostring(maxAttempts)
|
|
625
1043
|
}
|
|
626
|
-
if
|
|
1044
|
+
if useGroupConcurrency then
|
|
1045
|
+
hashFields[#hashFields + 1] = 'groupKey'
|
|
1046
|
+
hashFields[#hashFields + 1] = orderingKey
|
|
1047
|
+
elseif orderingKey ~= '' then
|
|
627
1048
|
hashFields[#hashFields + 1] = 'orderingKey'
|
|
628
1049
|
hashFields[#hashFields + 1] = orderingKey
|
|
629
1050
|
hashFields[#hashFields + 1] = 'orderingSeq'
|
|
630
1051
|
hashFields[#hashFields + 1] = tostring(orderingSeq)
|
|
631
1052
|
end
|
|
1053
|
+
if jobCost > 0 then
|
|
1054
|
+
hashFields[#hashFields + 1] = 'cost'
|
|
1055
|
+
hashFields[#hashFields + 1] = tostring(jobCost)
|
|
1056
|
+
end
|
|
632
1057
|
if parentId ~= '' then
|
|
633
1058
|
hashFields[#hashFields + 1] = 'parentId'
|
|
634
1059
|
hashFields[#hashFields + 1] = parentId
|
|
@@ -661,6 +1086,12 @@ redis.register_function('glidemq_rateLimit', function(keys, args)
|
|
|
661
1086
|
local maxPerWindow = tonumber(args[1])
|
|
662
1087
|
local windowDuration = tonumber(args[2])
|
|
663
1088
|
local now = tonumber(args[3])
|
|
1089
|
+
-- Fallback: read rate limit config from meta if not provided inline
|
|
1090
|
+
if maxPerWindow <= 0 then
|
|
1091
|
+
maxPerWindow = tonumber(redis.call('HGET', metaKey, 'rateLimitMax')) or 0
|
|
1092
|
+
windowDuration = tonumber(redis.call('HGET', metaKey, 'rateLimitDuration')) or 0
|
|
1093
|
+
if maxPerWindow <= 0 then return 0 end
|
|
1094
|
+
end
|
|
664
1095
|
local windowStart = tonumber(redis.call('HGET', rateKey, 'windowStart')) or 0
|
|
665
1096
|
local count = tonumber(redis.call('HGET', rateKey, 'count')) or 0
|
|
666
1097
|
if now - windowStart >= windowDuration then
|
|
@@ -675,6 +1106,87 @@ redis.register_function('glidemq_rateLimit', function(keys, args)
|
|
|
675
1106
|
return 0
|
|
676
1107
|
end)
|
|
677
1108
|
|
|
1109
|
+
redis.register_function('glidemq_promoteRateLimited', function(keys, args)
|
|
1110
|
+
local rateLimitedKey = keys[1]
|
|
1111
|
+
local streamKey = keys[2]
|
|
1112
|
+
local now = tonumber(args[1])
|
|
1113
|
+
-- Derive prefix from the server-validated key instead of caller-supplied arg
|
|
1114
|
+
local prefix = string.sub(rateLimitedKey, 1, #rateLimitedKey - #'ratelimited')
|
|
1115
|
+
local expired = redis.call('ZRANGEBYSCORE', rateLimitedKey, '0', string.format('%.0f', now), 'LIMIT', 0, 100)
|
|
1116
|
+
if not expired or #expired == 0 then return 0 end
|
|
1117
|
+
local promoted = 0
|
|
1118
|
+
for i = 1, #expired do
|
|
1119
|
+
local gk = expired[i]
|
|
1120
|
+
redis.call('ZREM', rateLimitedKey, gk)
|
|
1121
|
+
local groupHashKey = prefix .. 'group:' .. gk
|
|
1122
|
+
local waitListKey = prefix .. 'groupq:' .. gk
|
|
1123
|
+
-- Load all group fields in one call for rate limit + token bucket checks
|
|
1124
|
+
local prGrpFields = redis.call('HGETALL', groupHashKey)
|
|
1125
|
+
local prGrp = {}
|
|
1126
|
+
for pf = 1, #prGrpFields, 2 do prGrp[prGrpFields[pf]] = prGrpFields[pf + 1] end
|
|
1127
|
+
local rateMax = tonumber(prGrp.rateMax) or 0
|
|
1128
|
+
local maxConc = tonumber(prGrp.maxConcurrency) or 0
|
|
1129
|
+
local active = tonumber(prGrp.active) or 0
|
|
1130
|
+
-- Token bucket pre-check: peek head job cost before promoting
|
|
1131
|
+
local prTbCap = tonumber(prGrp.tbCapacity) or 0
|
|
1132
|
+
local tbCheckPassed = true
|
|
1133
|
+
if prTbCap > 0 then
|
|
1134
|
+
local prTbTokens = tbRefill(groupHashKey, prGrp, now)
|
|
1135
|
+
local headJobId = redis.call('LINDEX', waitListKey, 0)
|
|
1136
|
+
if headJobId then
|
|
1137
|
+
local headJobKey = prefix .. 'job:' .. headJobId
|
|
1138
|
+
-- Tombstone guard
|
|
1139
|
+
if redis.call('EXISTS', headJobKey) == 0 then
|
|
1140
|
+
redis.call('LPOP', waitListKey)
|
|
1141
|
+
tbCheckPassed = false
|
|
1142
|
+
end
|
|
1143
|
+
if tbCheckPassed then
|
|
1144
|
+
local headCost = tonumber(redis.call('HGET', headJobKey, 'cost')) or 1000
|
|
1145
|
+
-- DLQ guard: cost > capacity
|
|
1146
|
+
if headCost > prTbCap then
|
|
1147
|
+
redis.call('LPOP', waitListKey)
|
|
1148
|
+
redis.call('ZADD', prefix .. 'failed', now, headJobId)
|
|
1149
|
+
redis.call('HSET', headJobKey,
|
|
1150
|
+
'state', 'failed',
|
|
1151
|
+
'failedReason', 'cost exceeds token bucket capacity',
|
|
1152
|
+
'finishedOn', tostring(now))
|
|
1153
|
+
emitEvent(prefix .. 'events', 'failed', headJobId, {'failedReason', 'cost exceeds token bucket capacity'})
|
|
1154
|
+
tbCheckPassed = false
|
|
1155
|
+
end
|
|
1156
|
+
if tbCheckPassed and prTbTokens < headCost then
|
|
1157
|
+
-- Not enough tokens: re-register with calculated delay
|
|
1158
|
+
local prTbRate = math.max(tonumber(prGrp.tbRefillRate) or 0, 1)
|
|
1159
|
+
local prTbDelay = math.ceil((headCost - prTbTokens) * 1000 / prTbRate)
|
|
1160
|
+
redis.call('ZADD', rateLimitedKey, now + prTbDelay, gk)
|
|
1161
|
+
tbCheckPassed = false
|
|
1162
|
+
end
|
|
1163
|
+
end
|
|
1164
|
+
end
|
|
1165
|
+
end
|
|
1166
|
+
if tbCheckPassed then
|
|
1167
|
+
-- Promote up to min(rateMax, available concurrency) jobs.
|
|
1168
|
+
-- Do NOT touch rateCount/rateWindowStart here - moveToActive handles
|
|
1169
|
+
-- window reset and counting when the worker picks up the promoted jobs.
|
|
1170
|
+
local canPromote = 1000
|
|
1171
|
+
if rateMax > 0 then
|
|
1172
|
+
canPromote = math.min(canPromote, rateMax)
|
|
1173
|
+
end
|
|
1174
|
+
if maxConc > 0 then
|
|
1175
|
+
canPromote = math.min(canPromote, math.max(0, maxConc - active))
|
|
1176
|
+
end
|
|
1177
|
+
for j = 1, canPromote do
|
|
1178
|
+
local nextJobId = redis.call('LPOP', waitListKey)
|
|
1179
|
+
if not nextJobId then break end
|
|
1180
|
+
redis.call('XADD', streamKey, '*', 'jobId', nextJobId)
|
|
1181
|
+
local nextJobKey = prefix .. 'job:' .. nextJobId
|
|
1182
|
+
redis.call('HSET', nextJobKey, 'state', 'waiting')
|
|
1183
|
+
promoted = promoted + 1
|
|
1184
|
+
end
|
|
1185
|
+
end
|
|
1186
|
+
end
|
|
1187
|
+
return promoted
|
|
1188
|
+
end)
|
|
1189
|
+
|
|
678
1190
|
redis.register_function('glidemq_checkConcurrency', function(keys, args)
|
|
679
1191
|
local metaKey = keys[1]
|
|
680
1192
|
local streamKey = keys[2]
|
|
@@ -694,7 +1206,11 @@ end)
|
|
|
694
1206
|
|
|
695
1207
|
redis.register_function('glidemq_moveToActive', function(keys, args)
|
|
696
1208
|
local jobKey = keys[1]
|
|
1209
|
+
local streamKey = keys[2] or ''
|
|
697
1210
|
local timestamp = args[1]
|
|
1211
|
+
local entryId = args[2] or ''
|
|
1212
|
+
local group = args[3] or ''
|
|
1213
|
+
local jobId = args[4] or ''
|
|
698
1214
|
local exists = redis.call('EXISTS', jobKey)
|
|
699
1215
|
if exists == 0 then
|
|
700
1216
|
return ''
|
|
@@ -703,6 +1219,104 @@ redis.register_function('glidemq_moveToActive', function(keys, args)
|
|
|
703
1219
|
if revoked == '1' then
|
|
704
1220
|
return 'REVOKED'
|
|
705
1221
|
end
|
|
1222
|
+
local groupKey = redis.call('HGET', jobKey, 'groupKey')
|
|
1223
|
+
if groupKey and groupKey ~= '' then
|
|
1224
|
+
local prefix = string.sub(jobKey, 1, #jobKey - #('job:' .. jobId))
|
|
1225
|
+
local groupHashKey = prefix .. 'group:' .. groupKey
|
|
1226
|
+
-- Load all group fields in one call
|
|
1227
|
+
local grpFields = redis.call('HGETALL', groupHashKey)
|
|
1228
|
+
local grp = {}
|
|
1229
|
+
for f = 1, #grpFields, 2 do grp[grpFields[f]] = grpFields[f + 1] end
|
|
1230
|
+
local maxConc = tonumber(grp.maxConcurrency) or 0
|
|
1231
|
+
local active = tonumber(grp.active) or 0
|
|
1232
|
+
-- Concurrency gate (checked first to avoid burning rate/token slots on parked jobs)
|
|
1233
|
+
if maxConc > 0 and active >= maxConc then
|
|
1234
|
+
if streamKey ~= '' and entryId ~= '' and group ~= '' then
|
|
1235
|
+
redis.call('XACK', streamKey, group, entryId)
|
|
1236
|
+
redis.call('XDEL', streamKey, entryId)
|
|
1237
|
+
end
|
|
1238
|
+
local waitListKey = prefix .. 'groupq:' .. groupKey
|
|
1239
|
+
redis.call('RPUSH', waitListKey, jobId)
|
|
1240
|
+
redis.call('HSET', jobKey, 'state', 'group-waiting')
|
|
1241
|
+
return 'GROUP_FULL'
|
|
1242
|
+
end
|
|
1243
|
+
-- Token bucket gate (read-only)
|
|
1244
|
+
local tbCapacity = tonumber(grp.tbCapacity) or 0
|
|
1245
|
+
local tbBlocked = false
|
|
1246
|
+
local tbDelay = 0
|
|
1247
|
+
local tbTokens = 0
|
|
1248
|
+
local jobCostVal = 0
|
|
1249
|
+
if tbCapacity > 0 then
|
|
1250
|
+
tbTokens = tbRefill(groupHashKey, grp, tonumber(timestamp))
|
|
1251
|
+
jobCostVal = tonumber(redis.call('HGET', jobKey, 'cost')) or 1000
|
|
1252
|
+
-- DLQ guard: cost > capacity
|
|
1253
|
+
if jobCostVal > tbCapacity then
|
|
1254
|
+
if streamKey ~= '' and entryId ~= '' and group ~= '' then
|
|
1255
|
+
redis.call('XACK', streamKey, group, entryId)
|
|
1256
|
+
redis.call('XDEL', streamKey, entryId)
|
|
1257
|
+
end
|
|
1258
|
+
redis.call('ZADD', prefix .. 'failed', tonumber(timestamp), jobId)
|
|
1259
|
+
redis.call('HSET', jobKey,
|
|
1260
|
+
'state', 'failed',
|
|
1261
|
+
'failedReason', 'cost exceeds token bucket capacity',
|
|
1262
|
+
'finishedOn', timestamp)
|
|
1263
|
+
emitEvent(prefix .. 'events', 'failed', jobId, {'failedReason', 'cost exceeds token bucket capacity'})
|
|
1264
|
+
return 'ERR:COST_EXCEEDS_CAPACITY'
|
|
1265
|
+
end
|
|
1266
|
+
if tbTokens < jobCostVal then
|
|
1267
|
+
tbBlocked = true
|
|
1268
|
+
local tbRefillRateVal = tonumber(grp.tbRefillRate) or 0
|
|
1269
|
+
if tbRefillRateVal <= 0 then tbRefillRateVal = 1 end
|
|
1270
|
+
tbDelay = math.ceil((jobCostVal - tbTokens) * 1000 / tbRefillRateVal)
|
|
1271
|
+
end
|
|
1272
|
+
end
|
|
1273
|
+
-- Sliding window gate (read-only)
|
|
1274
|
+
local rateMax = tonumber(grp.rateMax) or 0
|
|
1275
|
+
local rlBlocked = false
|
|
1276
|
+
local rlDelay = 0
|
|
1277
|
+
if rateMax > 0 then
|
|
1278
|
+
local rateDuration = tonumber(grp.rateDuration) or 0
|
|
1279
|
+
local rateWindowStart = tonumber(grp.rateWindowStart) or 0
|
|
1280
|
+
local rateCount = tonumber(grp.rateCount) or 0
|
|
1281
|
+
local now = tonumber(timestamp)
|
|
1282
|
+
if rateDuration > 0 and now - rateWindowStart < rateDuration and rateCount >= rateMax then
|
|
1283
|
+
rlBlocked = true
|
|
1284
|
+
rlDelay = (rateWindowStart + rateDuration) - now
|
|
1285
|
+
end
|
|
1286
|
+
end
|
|
1287
|
+
-- If ANY gate blocked: park + register
|
|
1288
|
+
if tbBlocked or rlBlocked then
|
|
1289
|
+
if streamKey ~= '' and entryId ~= '' and group ~= '' then
|
|
1290
|
+
redis.call('XACK', streamKey, group, entryId)
|
|
1291
|
+
redis.call('XDEL', streamKey, entryId)
|
|
1292
|
+
end
|
|
1293
|
+
local waitListKey = prefix .. 'groupq:' .. groupKey
|
|
1294
|
+
redis.call('RPUSH', waitListKey, jobId)
|
|
1295
|
+
redis.call('HSET', jobKey, 'state', 'group-waiting')
|
|
1296
|
+
local maxDelay = math.max(tbDelay, rlDelay)
|
|
1297
|
+
local rateLimitedKey = prefix .. 'ratelimited'
|
|
1298
|
+
redis.call('ZADD', rateLimitedKey, tonumber(timestamp) + maxDelay, groupKey)
|
|
1299
|
+
if tbBlocked then return 'GROUP_TOKEN_LIMITED' end
|
|
1300
|
+
return 'GROUP_RATE_LIMITED'
|
|
1301
|
+
end
|
|
1302
|
+
-- All gates passed: mutate state
|
|
1303
|
+
if tbCapacity > 0 then
|
|
1304
|
+
redis.call('HINCRBY', groupHashKey, 'tbTokens', -jobCostVal)
|
|
1305
|
+
end
|
|
1306
|
+
if rateMax > 0 then
|
|
1307
|
+
local rateDuration = tonumber(grp.rateDuration) or 0
|
|
1308
|
+
if rateDuration > 0 then
|
|
1309
|
+
local rateWindowStart = tonumber(grp.rateWindowStart) or 0
|
|
1310
|
+
local now = tonumber(timestamp)
|
|
1311
|
+
if now - rateWindowStart >= rateDuration then
|
|
1312
|
+
redis.call('HSET', groupHashKey, 'rateWindowStart', tostring(now), 'rateCount', '1')
|
|
1313
|
+
else
|
|
1314
|
+
redis.call('HINCRBY', groupHashKey, 'rateCount', 1)
|
|
1315
|
+
end
|
|
1316
|
+
end
|
|
1317
|
+
end
|
|
1318
|
+
redis.call('HINCRBY', groupHashKey, 'active', 1)
|
|
1319
|
+
end
|
|
706
1320
|
redis.call('HSET', jobKey, 'state', 'active', 'processedOn', timestamp, 'lastActive', timestamp)
|
|
707
1321
|
local fields = redis.call('HGETALL', jobKey)
|
|
708
1322
|
return cjson.encode(fields)
|
|
@@ -744,8 +1358,13 @@ redis.register_function('glidemq_addFlow', function(keys, args)
|
|
|
744
1358
|
local parentJobKey = parentPrefix .. 'job:' .. parentJobIdStr
|
|
745
1359
|
local depsKey = parentPrefix .. 'deps:' .. parentJobIdStr
|
|
746
1360
|
local parentOrderingKey = extractOrderingKeyFromOpts(parentOpts)
|
|
1361
|
+
local parentGroupConc = extractGroupConcurrencyFromOpts(parentOpts)
|
|
1362
|
+
local parentRateMax, parentRateDuration = extractGroupRateLimitFromOpts(parentOpts)
|
|
1363
|
+
local parentTbCapacity, parentTbRefillRate = extractTokenBucketFromOpts(parentOpts)
|
|
1364
|
+
local parentCost = extractCostFromOpts(parentOpts)
|
|
1365
|
+
local parentUseGroup = (parentOrderingKey ~= '' and (parentGroupConc > 1 or parentRateMax > 0 or parentTbCapacity > 0))
|
|
747
1366
|
local parentOrderingSeq = 0
|
|
748
|
-
if parentOrderingKey ~= '' then
|
|
1367
|
+
if parentOrderingKey ~= '' and not parentUseGroup then
|
|
749
1368
|
local parentOrderingMetaKey = parentPrefix .. 'ordering'
|
|
750
1369
|
parentOrderingSeq = redis.call('HINCRBY', parentOrderingMetaKey, parentOrderingKey, 1)
|
|
751
1370
|
end
|
|
@@ -761,16 +1380,52 @@ redis.register_function('glidemq_addFlow', function(keys, args)
|
|
|
761
1380
|
'maxAttempts', tostring(parentMaxAttempts),
|
|
762
1381
|
'state', 'waiting-children'
|
|
763
1382
|
}
|
|
764
|
-
if
|
|
1383
|
+
if parentUseGroup then
|
|
1384
|
+
parentHash[#parentHash + 1] = 'groupKey'
|
|
1385
|
+
parentHash[#parentHash + 1] = parentOrderingKey
|
|
1386
|
+
local groupHashKey = parentPrefix .. 'group:' .. parentOrderingKey
|
|
1387
|
+
redis.call('HSET', groupHashKey, 'maxConcurrency', tostring(parentGroupConc > 1 and parentGroupConc or 1))
|
|
1388
|
+
redis.call('HSETNX', groupHashKey, 'active', '0')
|
|
1389
|
+
if parentRateMax > 0 then
|
|
1390
|
+
redis.call('HSET', groupHashKey, 'rateMax', tostring(parentRateMax))
|
|
1391
|
+
redis.call('HSET', groupHashKey, 'rateDuration', tostring(parentRateDuration))
|
|
1392
|
+
end
|
|
1393
|
+
if parentTbCapacity > 0 then
|
|
1394
|
+
if parentCost > 0 and parentCost > parentTbCapacity then
|
|
1395
|
+
return 'ERR:COST_EXCEEDS_CAPACITY'
|
|
1396
|
+
end
|
|
1397
|
+
redis.call('HSET', groupHashKey, 'tbCapacity', tostring(parentTbCapacity), 'tbRefillRate', tostring(parentTbRefillRate))
|
|
1398
|
+
redis.call('HSETNX', groupHashKey, 'tbTokens', tostring(parentTbCapacity))
|
|
1399
|
+
redis.call('HSETNX', groupHashKey, 'tbLastRefill', tostring(timestamp))
|
|
1400
|
+
redis.call('HSETNX', groupHashKey, 'tbRefillRemainder', '0')
|
|
1401
|
+
end
|
|
1402
|
+
elseif parentOrderingKey ~= '' then
|
|
765
1403
|
parentHash[#parentHash + 1] = 'orderingKey'
|
|
766
1404
|
parentHash[#parentHash + 1] = parentOrderingKey
|
|
767
1405
|
parentHash[#parentHash + 1] = 'orderingSeq'
|
|
768
1406
|
parentHash[#parentHash + 1] = tostring(parentOrderingSeq)
|
|
769
1407
|
end
|
|
1408
|
+
if parentCost > 0 then
|
|
1409
|
+
parentHash[#parentHash + 1] = 'cost'
|
|
1410
|
+
parentHash[#parentHash + 1] = tostring(parentCost)
|
|
1411
|
+
end
|
|
770
1412
|
redis.call('HSET', parentJobKey, unpack(parentHash))
|
|
771
|
-
|
|
1413
|
+
-- Pre-validate all children's cost vs capacity before any child writes
|
|
772
1414
|
local childArgOffset = 8
|
|
773
1415
|
local childKeyOffset = 4
|
|
1416
|
+
for i = 1, numChildren do
|
|
1417
|
+
local base = childArgOffset + (i - 1) * 8
|
|
1418
|
+
local preChildOpts = args[base + 3]
|
|
1419
|
+
local preChildTbCap, _ = extractTokenBucketFromOpts(preChildOpts)
|
|
1420
|
+
if preChildTbCap > 0 then
|
|
1421
|
+
local preChildCost = extractCostFromOpts(preChildOpts)
|
|
1422
|
+
local preEffective = (preChildCost > 0) and preChildCost or 1000
|
|
1423
|
+
if preEffective > preChildTbCap then
|
|
1424
|
+
return 'ERR:COST_EXCEEDS_CAPACITY'
|
|
1425
|
+
end
|
|
1426
|
+
end
|
|
1427
|
+
end
|
|
1428
|
+
local childIds = {}
|
|
774
1429
|
for i = 1, numChildren do
|
|
775
1430
|
local base = childArgOffset + (i - 1) * 8
|
|
776
1431
|
local childName = args[base + 1]
|
|
@@ -791,8 +1446,13 @@ redis.register_function('glidemq_addFlow', function(keys, args)
|
|
|
791
1446
|
local childPrefix = string.sub(childIdKey, 1, #childIdKey - 2)
|
|
792
1447
|
local childJobKey = childPrefix .. 'job:' .. childJobIdStr
|
|
793
1448
|
local childOrderingKey = extractOrderingKeyFromOpts(childOpts)
|
|
1449
|
+
local childGroupConc = extractGroupConcurrencyFromOpts(childOpts)
|
|
1450
|
+
local childRateMax, childRateDuration = extractGroupRateLimitFromOpts(childOpts)
|
|
1451
|
+
local childTbCapacity, childTbRefillRate = extractTokenBucketFromOpts(childOpts)
|
|
1452
|
+
local childCost = extractCostFromOpts(childOpts)
|
|
1453
|
+
local childUseGroup = (childOrderingKey ~= '' and (childGroupConc > 1 or childRateMax > 0 or childTbCapacity > 0))
|
|
794
1454
|
local childOrderingSeq = 0
|
|
795
|
-
if childOrderingKey ~= '' then
|
|
1455
|
+
if childOrderingKey ~= '' and not childUseGroup then
|
|
796
1456
|
local childOrderingMetaKey = childPrefix .. 'ordering'
|
|
797
1457
|
childOrderingSeq = redis.call('HINCRBY', childOrderingMetaKey, childOrderingKey, 1)
|
|
798
1458
|
end
|
|
@@ -809,12 +1469,32 @@ redis.register_function('glidemq_addFlow', function(keys, args)
|
|
|
809
1469
|
'parentId', parentJobIdStr,
|
|
810
1470
|
'parentQueue', childParentQueue
|
|
811
1471
|
}
|
|
812
|
-
if
|
|
1472
|
+
if childUseGroup then
|
|
1473
|
+
childHash[#childHash + 1] = 'groupKey'
|
|
1474
|
+
childHash[#childHash + 1] = childOrderingKey
|
|
1475
|
+
local childGroupHashKey = childPrefix .. 'group:' .. childOrderingKey
|
|
1476
|
+
redis.call('HSETNX', childGroupHashKey, 'maxConcurrency', tostring(childGroupConc > 1 and childGroupConc or 1))
|
|
1477
|
+
redis.call('HSETNX', childGroupHashKey, 'active', '0')
|
|
1478
|
+
if childRateMax > 0 then
|
|
1479
|
+
redis.call('HSET', childGroupHashKey, 'rateMax', tostring(childRateMax))
|
|
1480
|
+
redis.call('HSET', childGroupHashKey, 'rateDuration', tostring(childRateDuration))
|
|
1481
|
+
end
|
|
1482
|
+
if childTbCapacity > 0 then
|
|
1483
|
+
redis.call('HSET', childGroupHashKey, 'tbCapacity', tostring(childTbCapacity), 'tbRefillRate', tostring(childTbRefillRate))
|
|
1484
|
+
redis.call('HSETNX', childGroupHashKey, 'tbTokens', tostring(childTbCapacity))
|
|
1485
|
+
redis.call('HSETNX', childGroupHashKey, 'tbLastRefill', tostring(timestamp))
|
|
1486
|
+
redis.call('HSETNX', childGroupHashKey, 'tbRefillRemainder', '0')
|
|
1487
|
+
end
|
|
1488
|
+
elseif childOrderingKey ~= '' then
|
|
813
1489
|
childHash[#childHash + 1] = 'orderingKey'
|
|
814
1490
|
childHash[#childHash + 1] = childOrderingKey
|
|
815
1491
|
childHash[#childHash + 1] = 'orderingSeq'
|
|
816
1492
|
childHash[#childHash + 1] = tostring(childOrderingSeq)
|
|
817
1493
|
end
|
|
1494
|
+
if childCost > 0 then
|
|
1495
|
+
childHash[#childHash + 1] = 'cost'
|
|
1496
|
+
childHash[#childHash + 1] = tostring(childCost)
|
|
1497
|
+
end
|
|
818
1498
|
if childDelay > 0 or childPriority > 0 then
|
|
819
1499
|
childHash[#childHash + 1] = 'state'
|
|
820
1500
|
childHash[#childHash + 1] = childDelay > 0 and 'delayed' or 'prioritized'
|
|
@@ -882,6 +1562,17 @@ redis.register_function('glidemq_removeJob', function(keys, args)
|
|
|
882
1562
|
if exists == 0 then
|
|
883
1563
|
return 0
|
|
884
1564
|
end
|
|
1565
|
+
local state = redis.call('HGET', jobKey, 'state')
|
|
1566
|
+
local groupKey = redis.call('HGET', jobKey, 'groupKey')
|
|
1567
|
+
if groupKey and groupKey ~= '' then
|
|
1568
|
+
if state == 'active' then
|
|
1569
|
+
releaseGroupSlotAndPromote(jobKey, jobId, 0)
|
|
1570
|
+
elseif state == 'group-waiting' then
|
|
1571
|
+
local prefix = string.sub(jobKey, 1, #jobKey - #('job:' .. jobId))
|
|
1572
|
+
local waitListKey = prefix .. 'groupq:' .. groupKey
|
|
1573
|
+
redis.call('LREM', waitListKey, 1, jobId)
|
|
1574
|
+
end
|
|
1575
|
+
end
|
|
885
1576
|
redis.call('ZREM', scheduledKey, jobId)
|
|
886
1577
|
redis.call('ZREM', completedKey, jobId)
|
|
887
1578
|
redis.call('ZREM', failedKey, jobId)
|
|
@@ -907,6 +1598,22 @@ redis.register_function('glidemq_revoke', function(keys, args)
|
|
|
907
1598
|
end
|
|
908
1599
|
redis.call('HSET', jobKey, 'revoked', '1')
|
|
909
1600
|
local state = redis.call('HGET', jobKey, 'state')
|
|
1601
|
+
if state == 'group-waiting' then
|
|
1602
|
+
local gk = redis.call('HGET', jobKey, 'groupKey')
|
|
1603
|
+
if gk and gk ~= '' then
|
|
1604
|
+
local prefix = string.sub(jobKey, 1, #jobKey - #('job:' .. jobId))
|
|
1605
|
+
local waitListKey = prefix .. 'groupq:' .. gk
|
|
1606
|
+
redis.call('LREM', waitListKey, 1, jobId)
|
|
1607
|
+
end
|
|
1608
|
+
redis.call('ZADD', failedKey, timestamp, jobId)
|
|
1609
|
+
redis.call('HSET', jobKey,
|
|
1610
|
+
'state', 'failed',
|
|
1611
|
+
'failedReason', 'revoked',
|
|
1612
|
+
'finishedOn', tostring(timestamp)
|
|
1613
|
+
)
|
|
1614
|
+
emitEvent(eventsKey, 'revoked', jobId, nil)
|
|
1615
|
+
return 'revoked'
|
|
1616
|
+
end
|
|
910
1617
|
if state == 'waiting' or state == 'delayed' or state == 'prioritized' then
|
|
911
1618
|
redis.call('ZREM', scheduledKey, jobId)
|
|
912
1619
|
local entries = redis.call('XRANGE', streamKey, '-', '+')
|
|
@@ -982,7 +1689,7 @@ end)
|
|
|
982
1689
|
* Add a job to the queue atomically.
|
|
983
1690
|
* Returns the new job ID (string).
|
|
984
1691
|
*/
|
|
985
|
-
async function addJob(client, k, jobName, data, opts, timestamp, delay, priority, parentId, maxAttempts, orderingKey = '') {
|
|
1692
|
+
async function addJob(client, k, jobName, data, opts, timestamp, delay, priority, parentId, maxAttempts, orderingKey = '', groupConcurrency = 0, groupRateMax = 0, groupRateDuration = 0, tbCapacity = 0, tbRefillRate = 0, jobCost = 0) {
|
|
986
1693
|
const result = await client.fcall('glidemq_addJob', [k.id, k.stream, k.scheduled, k.events], [
|
|
987
1694
|
jobName,
|
|
988
1695
|
data,
|
|
@@ -993,6 +1700,12 @@ async function addJob(client, k, jobName, data, opts, timestamp, delay, priority
|
|
|
993
1700
|
parentId,
|
|
994
1701
|
maxAttempts.toString(),
|
|
995
1702
|
orderingKey,
|
|
1703
|
+
groupConcurrency.toString(),
|
|
1704
|
+
groupRateMax.toString(),
|
|
1705
|
+
groupRateDuration.toString(),
|
|
1706
|
+
tbCapacity.toString(),
|
|
1707
|
+
tbRefillRate.toString(),
|
|
1708
|
+
jobCost.toString(),
|
|
996
1709
|
]);
|
|
997
1710
|
return result;
|
|
998
1711
|
}
|
|
@@ -1000,7 +1713,7 @@ async function addJob(client, k, jobName, data, opts, timestamp, delay, priority
|
|
|
1000
1713
|
* Add a job with deduplication. Checks the dedup hash and either skips or adds the job.
|
|
1001
1714
|
* Returns "skipped" if deduplicated, otherwise the new job ID (string).
|
|
1002
1715
|
*/
|
|
1003
|
-
async function dedup(client, k, dedupId, ttlMs, mode, jobName, data, opts, timestamp, delay, priority, parentId, maxAttempts, orderingKey = '') {
|
|
1716
|
+
async function dedup(client, k, dedupId, ttlMs, mode, jobName, data, opts, timestamp, delay, priority, parentId, maxAttempts, orderingKey = '', groupConcurrency = 0, groupRateMax = 0, groupRateDuration = 0, tbCapacity = 0, tbRefillRate = 0, jobCost = 0) {
|
|
1004
1717
|
const result = await client.fcall('glidemq_dedup', [k.dedup, k.id, k.stream, k.scheduled, k.events], [
|
|
1005
1718
|
dedupId,
|
|
1006
1719
|
ttlMs.toString(),
|
|
@@ -1014,6 +1727,12 @@ async function dedup(client, k, dedupId, ttlMs, mode, jobName, data, opts, times
|
|
|
1014
1727
|
parentId,
|
|
1015
1728
|
maxAttempts.toString(),
|
|
1016
1729
|
orderingKey,
|
|
1730
|
+
groupConcurrency.toString(),
|
|
1731
|
+
groupRateMax.toString(),
|
|
1732
|
+
groupRateDuration.toString(),
|
|
1733
|
+
tbCapacity.toString(),
|
|
1734
|
+
tbRefillRate.toString(),
|
|
1735
|
+
jobCost.toString(),
|
|
1017
1736
|
]);
|
|
1018
1737
|
return result;
|
|
1019
1738
|
}
|
|
@@ -1173,20 +1892,39 @@ async function checkConcurrency(client, k, group = exports.CONSUMER_GROUP) {
|
|
|
1173
1892
|
/**
|
|
1174
1893
|
* Move a job to active state in a single round trip.
|
|
1175
1894
|
* Reads the full job hash, checks revoked flag, sets state=active + processedOn + lastActive.
|
|
1895
|
+
* For group-concurrency jobs, checks if the group has capacity. If not, parks the job
|
|
1896
|
+
* in the group wait list and returns 'GROUP_FULL'.
|
|
1897
|
+
* For rate-limited groups, parks the job and returns 'GROUP_RATE_LIMITED'.
|
|
1176
1898
|
* Returns:
|
|
1177
1899
|
* - null if job hash doesn't exist
|
|
1178
1900
|
* - 'REVOKED' if the job's revoked flag is set
|
|
1901
|
+
* - 'GROUP_FULL' if the job's group is at max concurrency (job was parked)
|
|
1902
|
+
* - 'GROUP_RATE_LIMITED' if the job's group exceeded its rate limit (job was parked)
|
|
1903
|
+
* - 'GROUP_TOKEN_LIMITED' if the job's group has insufficient tokens (job was parked)
|
|
1904
|
+
* - 'ERR:COST_EXCEEDS_CAPACITY' if the job cost exceeds token bucket capacity (job was failed)
|
|
1179
1905
|
* - Record<string, string> with all job fields otherwise
|
|
1180
|
-
*
|
|
1181
|
-
* Replaces: HGETALL + revoked check + HSET lastActive (3 round trips -> 1)
|
|
1182
1906
|
*/
|
|
1183
|
-
async function moveToActive(client, k, jobId, timestamp) {
|
|
1184
|
-
const
|
|
1907
|
+
async function moveToActive(client, k, jobId, timestamp, streamKey = '', entryId = '', group = '') {
|
|
1908
|
+
const keys = [k.job(jobId)];
|
|
1909
|
+
const args = [timestamp.toString()];
|
|
1910
|
+
if (streamKey) {
|
|
1911
|
+
keys.push(streamKey);
|
|
1912
|
+
args.push(entryId, group, jobId);
|
|
1913
|
+
}
|
|
1914
|
+
const result = await client.fcall('glidemq_moveToActive', keys, args);
|
|
1185
1915
|
const str = String(result);
|
|
1186
1916
|
if (str === '' || str === 'null')
|
|
1187
1917
|
return null;
|
|
1188
1918
|
if (str === 'REVOKED')
|
|
1189
1919
|
return 'REVOKED';
|
|
1920
|
+
if (str === 'GROUP_FULL')
|
|
1921
|
+
return 'GROUP_FULL';
|
|
1922
|
+
if (str === 'GROUP_RATE_LIMITED')
|
|
1923
|
+
return 'GROUP_RATE_LIMITED';
|
|
1924
|
+
if (str === 'GROUP_TOKEN_LIMITED')
|
|
1925
|
+
return 'GROUP_TOKEN_LIMITED';
|
|
1926
|
+
if (str === 'ERR:COST_EXCEEDS_CAPACITY')
|
|
1927
|
+
return 'ERR:COST_EXCEEDS_CAPACITY';
|
|
1190
1928
|
// Parse the cjson.encode output: [field1, value1, field2, value2, ...]
|
|
1191
1929
|
const arr = JSON.parse(str);
|
|
1192
1930
|
const hash = {};
|
|
@@ -1195,6 +1933,15 @@ async function moveToActive(client, k, jobId, timestamp) {
|
|
|
1195
1933
|
}
|
|
1196
1934
|
return hash;
|
|
1197
1935
|
}
|
|
1936
|
+
/**
|
|
1937
|
+
* Promote rate-limited groups whose window has expired.
|
|
1938
|
+
* Moves waiting jobs from the group queue back into the stream.
|
|
1939
|
+
* Returns the number of jobs promoted.
|
|
1940
|
+
*/
|
|
1941
|
+
async function promoteRateLimited(client, k, timestamp) {
|
|
1942
|
+
const result = await client.fcall('glidemq_promoteRateLimited', [k.ratelimited, k.stream], [timestamp.toString()]);
|
|
1943
|
+
return Number(result) || 0;
|
|
1944
|
+
}
|
|
1198
1945
|
/**
|
|
1199
1946
|
* Defers an active job back to waiting by acknowledging + deleting the current
|
|
1200
1947
|
* stream entry and re-enqueuing the same jobId to the stream tail.
|