qless_lua 1.0.0 → 1.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 (4) hide show
  1. checksums.yaml +4 -4
  2. data/lib/qless_lua.rb +2483 -3
  3. data/qless_lua.gemspec +1 -1
  4. metadata +2 -2
data/lib/qless_lua.rb CHANGED
@@ -6,7 +6,2489 @@ require 'digest/sha1'
6
6
 
7
7
  # A wrapper that will give you `qless.lua` source to be used with your redis
8
8
  class QlessLua
9
- SOURCE = <<-LUA_SOURCE.strip.freeze
9
+ QLESS_LIB_SOURCE = <<-LUA_SOURCE.strip.freeze
10
+ -------------------------------------------------------------------------------
11
+ -- Forward declarations to make everything happy
12
+ -------------------------------------------------------------------------------
13
+ local Qless = {
14
+ ns = 'ql:'
15
+ }
16
+
17
+ -- Queue forward delcaration
18
+ local QlessQueue = {
19
+ ns = Qless.ns .. 'q:'
20
+ }
21
+ QlessQueue.__index = QlessQueue
22
+
23
+ -- Worker forward declaration
24
+ local QlessWorker = {
25
+ ns = Qless.ns .. 'w:'
26
+ }
27
+ QlessWorker.__index = QlessWorker
28
+
29
+ -- Job forward declaration
30
+ local QlessJob = {
31
+ ns = Qless.ns .. 'j:'
32
+ }
33
+ QlessJob.__index = QlessJob
34
+
35
+ -- RecurringJob forward declaration
36
+ local QlessRecurringJob = {}
37
+ QlessRecurringJob.__index = QlessRecurringJob
38
+
39
+ -- Config forward declaration
40
+ Qless.config = {}
41
+
42
+ -- Extend a table. This comes up quite frequently
43
+ local function tbl_extend(self, other)
44
+ for i, v in ipairs(other) do
45
+ table.insert(self, v)
46
+ end
47
+ end
48
+
49
+ -- This is essentially the same as redis' publish, but it prefixes the channel
50
+ -- with the Qless namespace
51
+ function Qless.publish(channel, message)
52
+ redis.call('publish', Qless.ns .. channel, message)
53
+ end
54
+
55
+ -- Return a job object given its job id
56
+ function Qless.job(jid)
57
+ assert(jid, 'Job(): no jid provided')
58
+ local job = {}
59
+ setmetatable(job, QlessJob)
60
+ job.jid = jid
61
+ return job
62
+ end
63
+
64
+ -- Return a recurring job object
65
+ function Qless.recurring(jid)
66
+ assert(jid, 'Recurring(): no jid provided')
67
+ local job = {}
68
+ setmetatable(job, QlessRecurringJob)
69
+ job.jid = jid
70
+ return job
71
+ end
72
+
73
+ -- Failed([group, [start, [limit]]])
74
+ -- ------------------------------------
75
+ -- If no group is provided, this returns a JSON blob of the counts of the
76
+ -- various groups of failures known. If a group is provided, it will report up
77
+ -- to `limit` from `start` of the jobs affected by that issue.
78
+ --
79
+ -- # If no group, then...
80
+ -- {
81
+ -- 'group1': 1,
82
+ -- 'group2': 5,
83
+ -- ...
84
+ -- }
85
+ --
86
+ -- # If a group is provided, then...
87
+ -- {
88
+ -- 'total': 20,
89
+ -- 'jobs': [
90
+ -- {
91
+ -- # All the normal keys for a job
92
+ -- 'jid': ...,
93
+ -- 'data': ...
94
+ -- # The message for this particular instance
95
+ -- 'message': ...,
96
+ -- 'group': ...,
97
+ -- }, ...
98
+ -- ]
99
+ -- }
100
+ --
101
+ function Qless.failed(group, start, limit)
102
+ start = assert(tonumber(start or 0),
103
+ 'Failed(): Arg "start" is not a number: ' .. (start or 'nil'))
104
+ limit = assert(tonumber(limit or 25),
105
+ 'Failed(): Arg "limit" is not a number: ' .. (limit or 'nil'))
106
+
107
+ if group then
108
+ -- If a group was provided, then we should do paginated lookup
109
+ return {
110
+ total = redis.call('llen', 'ql:f:' .. group),
111
+ jobs = redis.call('lrange', 'ql:f:' .. group, start, start + limit - 1)
112
+ }
113
+ else
114
+ -- Otherwise, we should just list all the known failure groups we have
115
+ local response = {}
116
+ local groups = redis.call('smembers', 'ql:failures')
117
+ for index, group in ipairs(groups) do
118
+ response[group] = redis.call('llen', 'ql:f:' .. group)
119
+ end
120
+ return response
121
+ end
122
+ end
123
+
124
+ -- Jobs(now, 'complete', [offset, [count]])
125
+ -- Jobs(now, (
126
+ -- 'stalled' | 'running' | 'scheduled' | 'depends', 'recurring'
127
+ -- ), queue, [offset, [count]])
128
+ -------------------------------------------------------------------------------
129
+ -- Return all the job ids currently considered to be in the provided state
130
+ -- in a particular queue. The response is a list of job ids:
131
+ --
132
+ -- [
133
+ -- jid1,
134
+ -- jid2,
135
+ -- ...
136
+ -- ]
137
+ function Qless.jobs(now, state, ...)
138
+ assert(state, 'Jobs(): Arg "state" missing')
139
+ if state == 'complete' then
140
+ local offset = assert(tonumber(arg[1] or 0),
141
+ 'Jobs(): Arg "offset" not a number: ' .. tostring(arg[1]))
142
+ local count = assert(tonumber(arg[2] or 25),
143
+ 'Jobs(): Arg "count" not a number: ' .. tostring(arg[2]))
144
+ return redis.call('zrevrange', 'ql:completed', offset,
145
+ offset + count - 1)
146
+ else
147
+ local name = assert(arg[1], 'Jobs(): Arg "queue" missing')
148
+ local offset = assert(tonumber(arg[2] or 0),
149
+ 'Jobs(): Arg "offset" not a number: ' .. tostring(arg[2]))
150
+ local count = assert(tonumber(arg[3] or 25),
151
+ 'Jobs(): Arg "count" not a number: ' .. tostring(arg[3]))
152
+
153
+ local queue = Qless.queue(name)
154
+ if state == 'running' then
155
+ return queue.locks.peek(now, offset, count)
156
+ elseif state == 'stalled' then
157
+ return queue.locks.expired(now, offset, count)
158
+ elseif state == 'scheduled' then
159
+ queue:check_scheduled(now, queue.scheduled.length())
160
+ return queue.scheduled.peek(now, offset, count)
161
+ elseif state == 'depends' then
162
+ return queue.depends.peek(now, offset, count)
163
+ elseif state == 'recurring' then
164
+ return queue.recurring.peek('+inf', offset, count)
165
+ else
166
+ error('Jobs(): Unknown type "' .. state .. '"')
167
+ end
168
+ end
169
+ end
170
+
171
+ -- Track()
172
+ -- Track(now, ('track' | 'untrack'), jid)
173
+ -- ------------------------------------------
174
+ -- If no arguments are provided, it returns details of all currently-tracked
175
+ -- jobs. If the first argument is 'track', then it will start tracking the job
176
+ -- associated with that id, and 'untrack' stops tracking it. In this context,
177
+ -- tracking is nothing more than saving the job to a list of jobs that are
178
+ -- considered special.
179
+ --
180
+ -- {
181
+ -- 'jobs': [
182
+ -- {
183
+ -- 'jid': ...,
184
+ -- # All the other details you'd get from 'get'
185
+ -- }, {
186
+ -- ...
187
+ -- }
188
+ -- ], 'expired': [
189
+ -- # These are all the jids that are completed and whose data expired
190
+ -- 'deadbeef',
191
+ -- ...,
192
+ -- ...,
193
+ -- ]
194
+ -- }
195
+ --
196
+ function Qless.track(now, command, jid)
197
+ if command ~= nil then
198
+ assert(jid, 'Track(): Arg "jid" missing')
199
+ -- Verify that job exists
200
+ assert(Qless.job(jid):exists(), 'Track(): Job does not exist')
201
+ if string.lower(command) == 'track' then
202
+ Qless.publish('track', jid)
203
+ return redis.call('zadd', 'ql:tracked', now, jid)
204
+ elseif string.lower(command) == 'untrack' then
205
+ Qless.publish('untrack', jid)
206
+ return redis.call('zrem', 'ql:tracked', jid)
207
+ else
208
+ error('Track(): Unknown action "' .. command .. '"')
209
+ end
210
+ else
211
+ local response = {
212
+ jobs = {},
213
+ expired = {}
214
+ }
215
+ local jids = redis.call('zrange', 'ql:tracked', 0, -1)
216
+ for index, jid in ipairs(jids) do
217
+ local data = Qless.job(jid):data()
218
+ if data then
219
+ table.insert(response.jobs, data)
220
+ else
221
+ table.insert(response.expired, jid)
222
+ end
223
+ end
224
+ return response
225
+ end
226
+ end
227
+
228
+ -- tag(now, ('add' | 'remove'), jid, tag, [tag, ...])
229
+ -- tag(now, 'get', tag, [offset, [count]])
230
+ -- tag(now, 'top', [offset, [count]])
231
+ -- -----------------------------------------------------------------------------
232
+ -- Accepts a jid, 'add' or 'remove', and then a list of tags
233
+ -- to either add or remove from the job. Alternatively, 'get',
234
+ -- a tag to get jobs associated with that tag, and offset and
235
+ -- count
236
+ --
237
+ -- If 'add' or 'remove', the response is a list of the jobs
238
+ -- current tags, or False if the job doesn't exist. If 'get',
239
+ -- the response is of the form:
240
+ --
241
+ -- {
242
+ -- total: ...,
243
+ -- jobs: [
244
+ -- jid,
245
+ -- ...
246
+ -- ]
247
+ -- }
248
+ --
249
+ -- If 'top' is supplied, it returns the most commonly-used tags
250
+ -- in a paginated fashion.
251
+ function Qless.tag(now, command, ...)
252
+ assert(command,
253
+ 'Tag(): Arg "command" must be "add", "remove", "get" or "top"')
254
+
255
+ if command == 'add' then
256
+ local jid = assert(arg[1], 'Tag(): Arg "jid" missing')
257
+ local tags = redis.call('hget', QlessJob.ns .. jid, 'tags')
258
+ -- If the job has been canceled / deleted, then return false
259
+ if tags then
260
+ -- Decode the json blob, convert to dictionary
261
+ tags = cjson.decode(tags)
262
+ local _tags = {}
263
+ for i,v in ipairs(tags) do _tags[v] = true end
264
+
265
+ -- Otherwise, add the job to the sorted set with that tags
266
+ for i=2,#arg do
267
+ local tag = arg[i]
268
+ if _tags[tag] == nil or _tags[tag] == false then
269
+ _tags[tag] = true
270
+ table.insert(tags, tag)
271
+ end
272
+ redis.call('zadd', 'ql:t:' .. tag, now, jid)
273
+ redis.call('zincrby', 'ql:tags', 1, tag)
274
+ end
275
+
276
+ redis.call('hset', QlessJob.ns .. jid, 'tags', cjson.encode(tags))
277
+ return tags
278
+ else
279
+ error('Tag(): Job ' .. jid .. ' does not exist')
280
+ end
281
+ elseif command == 'remove' then
282
+ local jid = assert(arg[1], 'Tag(): Arg "jid" missing')
283
+ local tags = redis.call('hget', QlessJob.ns .. jid, 'tags')
284
+ -- If the job has been canceled / deleted, then return false
285
+ if tags then
286
+ -- Decode the json blob, convert to dictionary
287
+ tags = cjson.decode(tags)
288
+ local _tags = {}
289
+ for i,v in ipairs(tags) do _tags[v] = true end
290
+
291
+ -- Otherwise, add the job to the sorted set with that tags
292
+ for i=2,#arg do
293
+ local tag = arg[i]
294
+ _tags[tag] = nil
295
+ redis.call('zrem', 'ql:t:' .. tag, jid)
296
+ redis.call('zincrby', 'ql:tags', -1, tag)
297
+ end
298
+
299
+ local results = {}
300
+ for i,tag in ipairs(tags) do if _tags[tag] then table.insert(results, tag) end end
301
+
302
+ redis.call('hset', QlessJob.ns .. jid, 'tags', cjson.encode(results))
303
+ return results
304
+ else
305
+ error('Tag(): Job ' .. jid .. ' does not exist')
306
+ end
307
+ elseif command == 'get' then
308
+ local tag = assert(arg[1], 'Tag(): Arg "tag" missing')
309
+ local offset = assert(tonumber(arg[2] or 0),
310
+ 'Tag(): Arg "offset" not a number: ' .. tostring(arg[2]))
311
+ local count = assert(tonumber(arg[3] or 25),
312
+ 'Tag(): Arg "count" not a number: ' .. tostring(arg[3]))
313
+ return {
314
+ total = redis.call('zcard', 'ql:t:' .. tag),
315
+ jobs = redis.call('zrange', 'ql:t:' .. tag, offset, offset + count - 1)
316
+ }
317
+ elseif command == 'top' then
318
+ local offset = assert(tonumber(arg[1] or 0) , 'Tag(): Arg "offset" not a number: ' .. tostring(arg[1]))
319
+ local count = assert(tonumber(arg[2] or 25), 'Tag(): Arg "count" not a number: ' .. tostring(arg[2]))
320
+ return redis.call('zrevrangebyscore', 'ql:tags', '+inf', 2, 'limit', offset, count)
321
+ else
322
+ error('Tag(): First argument must be "add", "remove" or "get"')
323
+ end
324
+ end
325
+
326
+ -- Cancel(...)
327
+ -- --------------
328
+ -- Cancel a job from taking place. It will be deleted from the system, and any
329
+ -- attempts to renew a heartbeat will fail, and any attempts to complete it
330
+ -- will fail. If you try to get the data on the object, you will get nothing.
331
+ function Qless.cancel(...)
332
+ -- Dependents is a mapping of a job to its dependent jids
333
+ local dependents = {}
334
+ for _, jid in ipairs(arg) do
335
+ dependents[jid] = redis.call(
336
+ 'smembers', QlessJob.ns .. jid .. '-dependents') or {}
337
+ end
338
+
339
+ -- Now, we'll loop through every jid we intend to cancel, and we'll go
340
+ -- make sure that this operation will be ok
341
+ for i, jid in ipairs(arg) do
342
+ for j, dep in ipairs(dependents[jid]) do
343
+ if dependents[dep] == nil or dependents[dep] == false then
344
+ error('Cancel(): ' .. jid .. ' is a dependency of ' .. dep ..
345
+ ' but is not mentioned to be canceled')
346
+ end
347
+ end
348
+ end
349
+
350
+ -- If we've made it this far, then we are good to go. We can now just
351
+ -- remove any trace of all these jobs, as they form a dependent clique
352
+ for _, jid in ipairs(arg) do
353
+ -- Find any stage it's associated with and remove its from that stage
354
+ local state, queue, failure, worker = unpack(redis.call(
355
+ 'hmget', QlessJob.ns .. jid, 'state', 'queue', 'failure', 'worker'))
356
+
357
+ if state ~= 'complete' then
358
+ -- Send a message out on the appropriate channels
359
+ local encoded = cjson.encode({
360
+ jid = jid,
361
+ worker = worker,
362
+ event = 'canceled',
363
+ queue = queue
364
+ })
365
+ Qless.publish('log', encoded)
366
+
367
+ -- Remove this job from whatever worker has it, if any
368
+ if worker and (worker ~= '') then
369
+ redis.call('zrem', 'ql:w:' .. worker .. ':jobs', jid)
370
+ -- If necessary, send a message to the appropriate worker, too
371
+ Qless.publish('w:' .. worker, encoded)
372
+ end
373
+
374
+ -- Remove it from that queue
375
+ if queue then
376
+ local queue = Qless.queue(queue)
377
+ queue.work.remove(jid)
378
+ queue.locks.remove(jid)
379
+ queue.scheduled.remove(jid)
380
+ queue.depends.remove(jid)
381
+ end
382
+
383
+ -- We should probably go through all our dependencies and remove
384
+ -- ourselves from the list of dependents
385
+ for i, j in ipairs(redis.call(
386
+ 'smembers', QlessJob.ns .. jid .. '-dependencies')) do
387
+ redis.call('srem', QlessJob.ns .. j .. '-dependents', jid)
388
+ end
389
+
390
+ -- Delete any notion of dependencies it has
391
+ redis.call('del', QlessJob.ns .. jid .. '-dependencies')
392
+
393
+ -- If we're in the failed state, remove all of our data
394
+ if state == 'failed' then
395
+ failure = cjson.decode(failure)
396
+ -- We need to make this remove it from the failed queues
397
+ redis.call('lrem', 'ql:f:' .. failure.group, 0, jid)
398
+ if redis.call('llen', 'ql:f:' .. failure.group) == 0 then
399
+ redis.call('srem', 'ql:failures', failure.group)
400
+ end
401
+ -- Remove one count from the failed count of the particular
402
+ -- queue
403
+ local bin = failure.when - (failure.when % 86400)
404
+ local failed = redis.call(
405
+ 'hget', 'ql:s:stats:' .. bin .. ':' .. queue, 'failed')
406
+ redis.call('hset',
407
+ 'ql:s:stats:' .. bin .. ':' .. queue, 'failed', failed - 1)
408
+ end
409
+
410
+ -- Remove it as a job that's tagged with this particular tag
411
+ local tags = cjson.decode(
412
+ redis.call('hget', QlessJob.ns .. jid, 'tags') or '{}')
413
+ for i, tag in ipairs(tags) do
414
+ redis.call('zrem', 'ql:t:' .. tag, jid)
415
+ redis.call('zincrby', 'ql:tags', -1, tag)
416
+ end
417
+
418
+ -- If the job was being tracked, we should notify
419
+ if redis.call('zscore', 'ql:tracked', jid) ~= false then
420
+ Qless.publish('canceled', jid)
421
+ end
422
+
423
+ -- Just go ahead and delete our data
424
+ redis.call('del', QlessJob.ns .. jid)
425
+ redis.call('del', QlessJob.ns .. jid .. '-history')
426
+ end
427
+ end
428
+
429
+ return arg
430
+ end
431
+
432
+ -------------------------------------------------------------------------------
433
+ -- Configuration interactions
434
+ -------------------------------------------------------------------------------
435
+
436
+ -- This represents our default configuration settings
437
+ Qless.config.defaults = {
438
+ ['application'] = 'qless',
439
+ ['heartbeat'] = 60,
440
+ ['grace-period'] = 10,
441
+ ['stats-history'] = 30,
442
+ ['histogram-history'] = 7,
443
+ ['jobs-history-count'] = 50000,
444
+ ['jobs-history'] = 604800
445
+ }
446
+
447
+ -- Get one or more of the keys
448
+ Qless.config.get = function(key, default)
449
+ if key then
450
+ return redis.call('hget', 'ql:config', key) or
451
+ Qless.config.defaults[key] or default
452
+ else
453
+ -- Inspired by redis-lua https://github.com/nrk/redis-lua/blob/version-2.0/src/redis.lua
454
+ local reply = redis.call('hgetall', 'ql:config')
455
+ for i = 1, #reply, 2 do
456
+ Qless.config.defaults[reply[i]] = reply[i + 1]
457
+ end
458
+ return Qless.config.defaults
459
+ end
460
+ end
461
+
462
+ -- Set a configuration variable
463
+ Qless.config.set = function(option, value)
464
+ assert(option, 'config.set(): Arg "option" missing')
465
+ assert(value , 'config.set(): Arg "value" missing')
466
+ -- Send out a log message
467
+ Qless.publish('log', cjson.encode({
468
+ event = 'config_set',
469
+ option = option,
470
+ value = value
471
+ }))
472
+
473
+ redis.call('hset', 'ql:config', option, value)
474
+ end
475
+
476
+ -- Unset a configuration option
477
+ Qless.config.unset = function(option)
478
+ assert(option, 'config.unset(): Arg "option" missing')
479
+ -- Send out a log message
480
+ Qless.publish('log', cjson.encode({
481
+ event = 'config_unset',
482
+ option = option
483
+ }))
484
+
485
+ redis.call('hdel', 'ql:config', option)
486
+ end
487
+ -------------------------------------------------------------------------------
488
+ -- Job Class
489
+ --
490
+ -- It returns an object that represents the job with the provided JID
491
+ -------------------------------------------------------------------------------
492
+
493
+ -- This gets all the data associated with the job with the provided id. If the
494
+ -- job is not found, it returns nil. If found, it returns an object with the
495
+ -- appropriate properties
496
+ function QlessJob:data(...)
497
+ local job = redis.call(
498
+ 'hmget', QlessJob.ns .. self.jid, 'jid', 'klass', 'state', 'queue',
499
+ 'worker', 'priority', 'expires', 'retries', 'remaining', 'data',
500
+ 'tags', 'failure', 'spawned_from_jid')
501
+
502
+ -- Return nil if we haven't found it
503
+ if not job[1] then
504
+ return nil
505
+ end
506
+
507
+ local data = {
508
+ jid = job[1],
509
+ klass = job[2],
510
+ state = job[3],
511
+ queue = job[4],
512
+ worker = job[5] or '',
513
+ tracked = redis.call(
514
+ 'zscore', 'ql:tracked', self.jid) ~= false,
515
+ priority = tonumber(job[6]),
516
+ expires = tonumber(job[7]) or 0,
517
+ retries = tonumber(job[8]),
518
+ remaining = math.floor(tonumber(job[9])),
519
+ data = job[10],
520
+ tags = cjson.decode(job[11]),
521
+ history = self:history(),
522
+ failure = cjson.decode(job[12] or '{}'),
523
+ spawned_from_jid = job[13],
524
+ dependents = redis.call(
525
+ 'smembers', QlessJob.ns .. self.jid .. '-dependents'),
526
+ dependencies = redis.call(
527
+ 'smembers', QlessJob.ns .. self.jid .. '-dependencies')
528
+ }
529
+
530
+ if #arg > 0 then
531
+ -- This section could probably be optimized, but I wanted the interface
532
+ -- in place first
533
+ local response = {}
534
+ for index, key in ipairs(arg) do
535
+ table.insert(response, data[key])
536
+ end
537
+ return response
538
+ else
539
+ return data
540
+ end
541
+ end
542
+
543
+ -- Complete a job and optionally put it in another queue, either scheduled or
544
+ -- to be considered waiting immediately. It can also optionally accept other
545
+ -- jids on which this job will be considered dependent before it's considered
546
+ -- valid.
547
+ --
548
+ -- The variable-length arguments may be pairs of the form:
549
+ --
550
+ -- ('next' , queue) : The queue to advance it to next
551
+ -- ('delay' , delay) : The delay for the next queue
552
+ -- ('depends', : Json of jobs it depends on in the new queue
553
+ -- '["jid1", "jid2", ...]')
554
+ ---
555
+ function QlessJob:complete(now, worker, queue, raw_data, ...)
556
+ assert(worker, 'Complete(): Arg "worker" missing')
557
+ assert(queue , 'Complete(): Arg "queue" missing')
558
+ local data = assert(cjson.decode(raw_data),
559
+ 'Complete(): Arg "data" missing or not JSON: ' .. tostring(raw_data))
560
+
561
+ -- Read in all the optional parameters
562
+ local options = {}
563
+ for i = 1, #arg, 2 do options[arg[i]] = arg[i + 1] end
564
+
565
+ -- Sanity check on optional args
566
+ local nextq = options['next']
567
+ local delay = assert(tonumber(options['delay'] or 0))
568
+ local depends = assert(cjson.decode(options['depends'] or '[]'),
569
+ 'Complete(): Arg "depends" not JSON: ' .. tostring(options['depends']))
570
+
571
+ -- Depends doesn't make sense without nextq
572
+ if options['delay'] and nextq == nil then
573
+ error('Complete(): "delay" cannot be used without a "next".')
574
+ end
575
+
576
+ -- Depends doesn't make sense without nextq
577
+ if options['depends'] and nextq == nil then
578
+ error('Complete(): "depends" cannot be used without a "next".')
579
+ end
580
+
581
+ -- The bin is midnight of the provided day
582
+ -- 24 * 60 * 60 = 86400
583
+ local bin = now - (now % 86400)
584
+
585
+ -- First things first, we should see if the worker still owns this job
586
+ local lastworker, state, priority, retries, current_queue = unpack(
587
+ redis.call('hmget', QlessJob.ns .. self.jid, 'worker', 'state',
588
+ 'priority', 'retries', 'queue'))
589
+
590
+ if lastworker == false then
591
+ error('Complete(): Job ' .. self.jid .. ' does not exist')
592
+ elseif (state ~= 'running') then
593
+ error('Complete(): Job ' .. self.jid .. ' is not currently running: ' ..
594
+ state)
595
+ elseif lastworker ~= worker then
596
+ error('Complete(): Job ' .. self.jid ..
597
+ ' has been handed out to another worker: ' .. tostring(lastworker))
598
+ elseif queue ~= current_queue then
599
+ error('Complete(): Job ' .. self.jid .. ' running in another queue: ' ..
600
+ tostring(current_queue))
601
+ end
602
+
603
+ -- Now we can assume that the worker does own the job. We need to
604
+ -- 1) Remove the job from the 'locks' from the old queue
605
+ -- 2) Enqueue it in the next stage if necessary
606
+ -- 3) Update the data
607
+ -- 4) Mark the job as completed, remove the worker, remove expires, and
608
+ -- update history
609
+ self:history(now, 'done')
610
+
611
+ if raw_data then
612
+ redis.call('hset', QlessJob.ns .. self.jid, 'data', raw_data)
613
+ end
614
+
615
+ -- Remove the job from the previous queue
616
+ local queue_obj = Qless.queue(queue)
617
+ queue_obj.work.remove(self.jid)
618
+ queue_obj.locks.remove(self.jid)
619
+ queue_obj.scheduled.remove(self.jid)
620
+
621
+ ----------------------------------------------------------
622
+ -- This is the massive stats update that we have to do
623
+ ----------------------------------------------------------
624
+ -- This is how long we've been waiting to get popped
625
+ -- local waiting = math.floor(now) - history[#history]['popped']
626
+ local time = tonumber(
627
+ redis.call('hget', QlessJob.ns .. self.jid, 'time') or now)
628
+ local waiting = now - time
629
+ Qless.queue(queue):stat(now, 'run', waiting)
630
+ redis.call('hset', QlessJob.ns .. self.jid,
631
+ 'time', string.format("%.20f", now))
632
+
633
+ -- Remove this job from the jobs that the worker that was running it has
634
+ redis.call('zrem', 'ql:w:' .. worker .. ':jobs', self.jid)
635
+
636
+ if redis.call('zscore', 'ql:tracked', self.jid) ~= false then
637
+ Qless.publish('completed', self.jid)
638
+ end
639
+
640
+ if nextq then
641
+ queue_obj = Qless.queue(nextq)
642
+ -- Send a message out to log
643
+ Qless.publish('log', cjson.encode({
644
+ jid = self.jid,
645
+ event = 'advanced',
646
+ queue = queue,
647
+ to = nextq
648
+ }))
649
+
650
+ -- Enqueue the job
651
+ self:history(now, 'put', {q = nextq})
652
+
653
+ -- We're going to make sure that this queue is in the
654
+ -- set of known queues
655
+ if redis.call('zscore', 'ql:queues', nextq) == false then
656
+ redis.call('zadd', 'ql:queues', now, nextq)
657
+ end
658
+
659
+ redis.call('hmset', QlessJob.ns .. self.jid,
660
+ 'state', 'waiting',
661
+ 'worker', '',
662
+ 'failure', '{}',
663
+ 'queue', nextq,
664
+ 'expires', 0,
665
+ 'remaining', tonumber(retries))
666
+
667
+ if (delay > 0) and (#depends == 0) then
668
+ queue_obj.scheduled.add(now + delay, self.jid)
669
+ return 'scheduled'
670
+ else
671
+ -- These are the jids we legitimately have to wait on
672
+ local count = 0
673
+ for i, j in ipairs(depends) do
674
+ -- Make sure it's something other than 'nil' or complete.
675
+ local state = redis.call('hget', QlessJob.ns .. j, 'state')
676
+ if (state and state ~= 'complete') then
677
+ count = count + 1
678
+ redis.call(
679
+ 'sadd', QlessJob.ns .. j .. '-dependents',self.jid)
680
+ redis.call(
681
+ 'sadd', QlessJob.ns .. self.jid .. '-dependencies', j)
682
+ end
683
+ end
684
+ if count > 0 then
685
+ queue_obj.depends.add(now, self.jid)
686
+ redis.call('hset', QlessJob.ns .. self.jid, 'state', 'depends')
687
+ if delay > 0 then
688
+ -- We've already put it in 'depends'. Now, we must just save the data
689
+ -- for when it's scheduled
690
+ queue_obj.depends.add(now, self.jid)
691
+ redis.call('hset', QlessJob.ns .. self.jid, 'scheduled', now + delay)
692
+ end
693
+ return 'depends'
694
+ else
695
+ queue_obj.work.add(now, priority, self.jid)
696
+ return 'waiting'
697
+ end
698
+ end
699
+ else
700
+ -- Send a message out to log
701
+ Qless.publish('log', cjson.encode({
702
+ jid = self.jid,
703
+ event = 'completed',
704
+ queue = queue
705
+ }))
706
+
707
+ redis.call('hmset', QlessJob.ns .. self.jid,
708
+ 'state', 'complete',
709
+ 'worker', '',
710
+ 'failure', '{}',
711
+ 'queue', '',
712
+ 'expires', 0,
713
+ 'remaining', tonumber(retries))
714
+
715
+ -- Do the completion dance
716
+ local count = Qless.config.get('jobs-history-count')
717
+ local time = Qless.config.get('jobs-history')
718
+
719
+ -- These are the default values
720
+ count = tonumber(count or 50000)
721
+ time = tonumber(time or 7 * 24 * 60 * 60)
722
+
723
+ -- Schedule this job for destructination eventually
724
+ redis.call('zadd', 'ql:completed', now, self.jid)
725
+
726
+ -- Now look at the expired job data. First, based on the current time
727
+ local jids = redis.call('zrangebyscore', 'ql:completed', 0, now - time)
728
+ -- Any jobs that need to be expired... delete
729
+ for index, jid in ipairs(jids) do
730
+ local tags = cjson.decode(
731
+ redis.call('hget', QlessJob.ns .. jid, 'tags') or '{}')
732
+ for i, tag in ipairs(tags) do
733
+ redis.call('zrem', 'ql:t:' .. tag, jid)
734
+ redis.call('zincrby', 'ql:tags', -1, tag)
735
+ end
736
+ redis.call('del', QlessJob.ns .. jid)
737
+ redis.call('del', QlessJob.ns .. jid .. '-history')
738
+ end
739
+ -- And now remove those from the queued-for-cleanup queue
740
+ redis.call('zremrangebyscore', 'ql:completed', 0, now - time)
741
+
742
+ -- Now take the all by the most recent 'count' ids
743
+ jids = redis.call('zrange', 'ql:completed', 0, (-1-count))
744
+ for index, jid in ipairs(jids) do
745
+ local tags = cjson.decode(
746
+ redis.call('hget', QlessJob.ns .. jid, 'tags') or '{}')
747
+ for i, tag in ipairs(tags) do
748
+ redis.call('zrem', 'ql:t:' .. tag, jid)
749
+ redis.call('zincrby', 'ql:tags', -1, tag)
750
+ end
751
+ redis.call('del', QlessJob.ns .. jid)
752
+ redis.call('del', QlessJob.ns .. jid .. '-history')
753
+ end
754
+ redis.call('zremrangebyrank', 'ql:completed', 0, (-1-count))
755
+
756
+ -- Alright, if this has any dependents, then we should go ahead
757
+ -- and unstick those guys.
758
+ for i, j in ipairs(redis.call(
759
+ 'smembers', QlessJob.ns .. self.jid .. '-dependents')) do
760
+ redis.call('srem', QlessJob.ns .. j .. '-dependencies', self.jid)
761
+ if redis.call(
762
+ 'scard', QlessJob.ns .. j .. '-dependencies') == 0 then
763
+ local q, p, scheduled = unpack(
764
+ redis.call('hmget', QlessJob.ns .. j, 'queue', 'priority', 'scheduled'))
765
+ if q then
766
+ local queue = Qless.queue(q)
767
+ queue.depends.remove(j)
768
+ if scheduled then
769
+ queue.scheduled.add(scheduled, j)
770
+ redis.call('hset', QlessJob.ns .. j, 'state', 'scheduled')
771
+ redis.call('hdel', QlessJob.ns .. j, 'scheduled')
772
+ else
773
+ queue.work.add(now, p, j)
774
+ redis.call('hset', QlessJob.ns .. j, 'state', 'waiting')
775
+ end
776
+ end
777
+ end
778
+ end
779
+
780
+ -- Delete our dependents key
781
+ redis.call('del', QlessJob.ns .. self.jid .. '-dependents')
782
+
783
+ return 'complete'
784
+ end
785
+ end
786
+
787
+ -- Fail(now, worker, group, message, [data])
788
+ -- -------------------------------------------------
789
+ -- Mark the particular job as failed, with the provided group, and a more
790
+ -- specific message. By `group`, we mean some phrase that might be one of
791
+ -- several categorical modes of failure. The `message` is something more
792
+ -- job-specific, like perhaps a traceback.
793
+ --
794
+ -- This method should __not__ be used to note that a job has been dropped or
795
+ -- has failed in a transient way. This method __should__ be used to note that
796
+ -- a job has something really wrong with it that must be remedied.
797
+ --
798
+ -- The motivation behind the `group` is so that similar errors can be grouped
799
+ -- together. Optionally, updated data can be provided for the job. A job in
800
+ -- any state can be marked as failed. If it has been given to a worker as a
801
+ -- job, then its subsequent requests to heartbeat or complete that job will
802
+ -- fail. Failed jobs are kept until they are canceled or completed.
803
+ --
804
+ -- __Returns__ the id of the failed job if successful, or `False` on failure.
805
+ --
806
+ -- Args:
807
+ -- 1) jid
808
+ -- 2) worker
809
+ -- 3) group
810
+ -- 4) message
811
+ -- 5) the current time
812
+ -- 6) [data]
813
+ function QlessJob:fail(now, worker, group, message, data)
814
+ local worker = assert(worker , 'Fail(): Arg "worker" missing')
815
+ local group = assert(group , 'Fail(): Arg "group" missing')
816
+ local message = assert(message , 'Fail(): Arg "message" missing')
817
+
818
+ -- The bin is midnight of the provided day
819
+ -- 24 * 60 * 60 = 86400
820
+ local bin = now - (now % 86400)
821
+
822
+ if data then
823
+ data = cjson.decode(data)
824
+ end
825
+
826
+ -- First things first, we should get the history
827
+ local queue, state, oldworker = unpack(redis.call(
828
+ 'hmget', QlessJob.ns .. self.jid, 'queue', 'state', 'worker'))
829
+
830
+ -- If the job has been completed, we cannot fail it
831
+ if not state then
832
+ error('Fail(): Job ' .. self.jid .. 'does not exist')
833
+ elseif state ~= 'running' then
834
+ error('Fail(): Job ' .. self.jid .. 'not currently running: ' .. state)
835
+ elseif worker ~= oldworker then
836
+ error('Fail(): Job ' .. self.jid .. ' running with another worker: ' ..
837
+ oldworker)
838
+ end
839
+
840
+ -- Send out a log message
841
+ Qless.publish('log', cjson.encode({
842
+ jid = self.jid,
843
+ event = 'failed',
844
+ worker = worker,
845
+ group = group,
846
+ message = message
847
+ }))
848
+
849
+ if redis.call('zscore', 'ql:tracked', self.jid) ~= false then
850
+ Qless.publish('failed', self.jid)
851
+ end
852
+
853
+ -- Remove this job from the jobs that the worker that was running it has
854
+ redis.call('zrem', 'ql:w:' .. worker .. ':jobs', self.jid)
855
+
856
+ -- Now, take the element of the history for which our provided worker is
857
+ -- the worker, and update 'failed'
858
+ self:history(now, 'failed', {worker = worker, group = group})
859
+
860
+ -- Increment the number of failures for that queue for the
861
+ -- given day.
862
+ redis.call('hincrby', 'ql:s:stats:' .. bin .. ':' .. queue, 'failures', 1)
863
+ redis.call('hincrby', 'ql:s:stats:' .. bin .. ':' .. queue, 'failed' , 1)
864
+
865
+ -- Now remove the instance from the schedule, and work queues for the
866
+ -- queue it's in
867
+ local queue_obj = Qless.queue(queue)
868
+ queue_obj.work.remove(self.jid)
869
+ queue_obj.locks.remove(self.jid)
870
+ queue_obj.scheduled.remove(self.jid)
871
+
872
+ -- The reason that this appears here is that the above will fail if the
873
+ -- job doesn't exist
874
+ if data then
875
+ redis.call('hset', QlessJob.ns .. self.jid, 'data', cjson.encode(data))
876
+ end
877
+
878
+ redis.call('hmset', QlessJob.ns .. self.jid,
879
+ 'state', 'failed',
880
+ 'worker', '',
881
+ 'expires', '',
882
+ 'failure', cjson.encode({
883
+ ['group'] = group,
884
+ ['message'] = message,
885
+ ['when'] = math.floor(now),
886
+ ['worker'] = worker
887
+ }))
888
+
889
+ -- Add this group of failure to the list of failures
890
+ redis.call('sadd', 'ql:failures', group)
891
+ -- And add this particular instance to the failed groups
892
+ redis.call('lpush', 'ql:f:' .. group, self.jid)
893
+
894
+ -- Here is where we'd intcrement stats about the particular stage
895
+ -- and possibly the workers
896
+
897
+ return self.jid
898
+ end
899
+
900
+ -- retry(now, queue, worker, [delay, [group, [message]]])
901
+ -- ------------------------------------------
902
+ -- This script accepts jid, queue, worker and delay for retrying a job. This
903
+ -- is similar in functionality to `put`, except that this counts against the
904
+ -- retries a job has for a stage.
905
+ --
906
+ -- Throws an exception if:
907
+ -- - the worker is not the worker with a lock on the job
908
+ -- - the job is not actually running
909
+ --
910
+ -- Otherwise, it returns the number of retries remaining. If the allowed
911
+ -- retries have been exhausted, then it is automatically failed, and a negative
912
+ -- number is returned.
913
+ --
914
+ -- If a group and message is provided, then if the retries are exhausted, then
915
+ -- the provided group and message will be used in place of the default
916
+ -- messaging about retries in the particular queue being exhausted
917
+ function QlessJob:retry(now, queue, worker, delay, group, message)
918
+ assert(queue , 'Retry(): Arg "queue" missing')
919
+ assert(worker, 'Retry(): Arg "worker" missing')
920
+ delay = assert(tonumber(delay or 0),
921
+ 'Retry(): Arg "delay" not a number: ' .. tostring(delay))
922
+
923
+ -- Let's see what the old priority, and tags were
924
+ local oldqueue, state, retries, oldworker, priority, failure = unpack(
925
+ redis.call('hmget', QlessJob.ns .. self.jid, 'queue', 'state',
926
+ 'retries', 'worker', 'priority', 'failure'))
927
+
928
+ -- If this isn't the worker that owns
929
+ if oldworker == false then
930
+ error('Retry(): Job ' .. self.jid .. ' does not exist')
931
+ elseif state ~= 'running' then
932
+ error('Retry(): Job ' .. self.jid .. ' is not currently running: ' ..
933
+ state)
934
+ elseif oldworker ~= worker then
935
+ error('Retry(): Job ' .. self.jid ..
936
+ ' has been given to another worker: ' .. oldworker)
937
+ end
938
+
939
+ -- For each of these, decrement their retries. If any of them
940
+ -- have exhausted their retries, then we should mark them as
941
+ -- failed.
942
+ local remaining = tonumber(redis.call(
943
+ 'hincrby', QlessJob.ns .. self.jid, 'remaining', -1))
944
+ redis.call('hdel', QlessJob.ns .. self.jid, 'grace')
945
+
946
+ -- Remove it from the locks key of the old queue
947
+ Qless.queue(oldqueue).locks.remove(self.jid)
948
+
949
+ -- Remove this job from the worker that was previously working it
950
+ redis.call('zrem', 'ql:w:' .. worker .. ':jobs', self.jid)
951
+
952
+ if remaining < 0 then
953
+ -- Now remove the instance from the schedule, and work queues for the
954
+ -- queue it's in
955
+ local group = group or 'failed-retries-' .. queue
956
+ self:history(now, 'failed', {['group'] = group})
957
+
958
+ redis.call('hmset', QlessJob.ns .. self.jid, 'state', 'failed',
959
+ 'worker', '',
960
+ 'expires', '')
961
+ -- If the failure has not already been set, then set it
962
+ if group ~= nil and message ~= nil then
963
+ redis.call('hset', QlessJob.ns .. self.jid,
964
+ 'failure', cjson.encode({
965
+ ['group'] = group,
966
+ ['message'] = message,
967
+ ['when'] = math.floor(now),
968
+ ['worker'] = worker
969
+ })
970
+ )
971
+ else
972
+ redis.call('hset', QlessJob.ns .. self.jid,
973
+ 'failure', cjson.encode({
974
+ ['group'] = group,
975
+ ['message'] =
976
+ 'Job exhausted retries in queue "' .. oldqueue .. '"',
977
+ ['when'] = now,
978
+ ['worker'] = unpack(self:data('worker'))
979
+ }))
980
+ end
981
+
982
+ if redis.call('zscore', 'ql:tracked', self.jid) ~= false then
983
+ Qless.publish('failed', self.jid)
984
+ end
985
+
986
+ -- Add this type of failure to the list of failures
987
+ redis.call('sadd', 'ql:failures', group)
988
+ -- And add this particular instance to the failed types
989
+ redis.call('lpush', 'ql:f:' .. group, self.jid)
990
+ -- Increment the count of the failed jobs
991
+ local bin = now - (now % 86400)
992
+ redis.call('hincrby', 'ql:s:stats:' .. bin .. ':' .. queue, 'failures', 1)
993
+ redis.call('hincrby', 'ql:s:stats:' .. bin .. ':' .. queue, 'failed' , 1)
994
+ else
995
+ -- Put it in the queue again with a delay. Like put()
996
+ local queue_obj = Qless.queue(queue)
997
+ if delay > 0 then
998
+ queue_obj.scheduled.add(now + delay, self.jid)
999
+ redis.call('hset', QlessJob.ns .. self.jid, 'state', 'scheduled')
1000
+ else
1001
+ queue_obj.work.add(now, priority, self.jid)
1002
+ redis.call('hset', QlessJob.ns .. self.jid, 'state', 'waiting')
1003
+ end
1004
+
1005
+ -- If a group and a message was provided, then we should save it
1006
+ if group ~= nil and message ~= nil then
1007
+ redis.call('hset', QlessJob.ns .. self.jid,
1008
+ 'failure', cjson.encode({
1009
+ ['group'] = group,
1010
+ ['message'] = message,
1011
+ ['when'] = math.floor(now),
1012
+ ['worker'] = worker
1013
+ })
1014
+ )
1015
+ end
1016
+ end
1017
+
1018
+ return math.floor(remaining)
1019
+ end
1020
+
1021
+ -- Depends(jid, 'on', [jid, [jid, [...]]]
1022
+ -- Depends(jid, 'off', [jid, [jid, [...]]])
1023
+ -- Depends(jid, 'off', 'all')
1024
+ -------------------------------------------------------------------------------
1025
+ -- Add or remove dependencies a job has. If 'on' is provided, the provided
1026
+ -- jids are added as dependencies. If 'off' and 'all' are provided, then all
1027
+ -- the current dependencies are removed. If 'off' is provided and the next
1028
+ -- argument is not 'all', then those jids are removed as dependencies.
1029
+ --
1030
+ -- If a job is not already in the 'depends' state, then this call will return
1031
+ -- false. Otherwise, it will return true
1032
+ function QlessJob:depends(now, command, ...)
1033
+ assert(command, 'Depends(): Arg "command" missing')
1034
+ local state = redis.call('hget', QlessJob.ns .. self.jid, 'state')
1035
+ if state ~= 'depends' then
1036
+ error('Depends(): Job ' .. self.jid ..
1037
+ ' not in the depends state: ' .. tostring(state))
1038
+ end
1039
+
1040
+ if command == 'on' then
1041
+ -- These are the jids we legitimately have to wait on
1042
+ for i, j in ipairs(arg) do
1043
+ -- Make sure it's something other than 'nil' or complete.
1044
+ local state = redis.call('hget', QlessJob.ns .. j, 'state')
1045
+ if (state and state ~= 'complete') then
1046
+ redis.call(
1047
+ 'sadd', QlessJob.ns .. j .. '-dependents' , self.jid)
1048
+ redis.call(
1049
+ 'sadd', QlessJob.ns .. self.jid .. '-dependencies', j)
1050
+ end
1051
+ end
1052
+ return true
1053
+ elseif command == 'off' then
1054
+ if arg[1] == 'all' then
1055
+ for i, j in ipairs(redis.call(
1056
+ 'smembers', QlessJob.ns .. self.jid .. '-dependencies')) do
1057
+ redis.call('srem', QlessJob.ns .. j .. '-dependents', self.jid)
1058
+ end
1059
+ redis.call('del', QlessJob.ns .. self.jid .. '-dependencies')
1060
+ local q, p = unpack(redis.call(
1061
+ 'hmget', QlessJob.ns .. self.jid, 'queue', 'priority'))
1062
+ if q then
1063
+ local queue_obj = Qless.queue(q)
1064
+ queue_obj.depends.remove(self.jid)
1065
+ queue_obj.work.add(now, p, self.jid)
1066
+ redis.call('hset', QlessJob.ns .. self.jid, 'state', 'waiting')
1067
+ end
1068
+ else
1069
+ for i, j in ipairs(arg) do
1070
+ redis.call('srem', QlessJob.ns .. j .. '-dependents', self.jid)
1071
+ redis.call(
1072
+ 'srem', QlessJob.ns .. self.jid .. '-dependencies', j)
1073
+ if redis.call('scard',
1074
+ QlessJob.ns .. self.jid .. '-dependencies') == 0 then
1075
+ local q, p = unpack(redis.call(
1076
+ 'hmget', QlessJob.ns .. self.jid, 'queue', 'priority'))
1077
+ if q then
1078
+ local queue_obj = Qless.queue(q)
1079
+ queue_obj.depends.remove(self.jid)
1080
+ queue_obj.work.add(now, p, self.jid)
1081
+ redis.call('hset',
1082
+ QlessJob.ns .. self.jid, 'state', 'waiting')
1083
+ end
1084
+ end
1085
+ end
1086
+ end
1087
+ return true
1088
+ else
1089
+ error('Depends(): Argument "command" must be "on" or "off"')
1090
+ end
1091
+ end
1092
+
1093
+ -- Heartbeat
1094
+ ------------
1095
+ -- Renew this worker's lock on this job. Throws an exception if:
1096
+ -- - the job's been given to another worker
1097
+ -- - the job's been completed
1098
+ -- - the job's been canceled
1099
+ -- - the job's not running
1100
+ function QlessJob:heartbeat(now, worker, data)
1101
+ assert(worker, 'Heatbeat(): Arg "worker" missing')
1102
+
1103
+ -- We should find the heartbeat interval for this queue
1104
+ -- heartbeat. First, though, we need to find the queue
1105
+ -- this particular job is in
1106
+ local queue = redis.call('hget', QlessJob.ns .. self.jid, 'queue') or ''
1107
+ local expires = now + tonumber(
1108
+ Qless.config.get(queue .. '-heartbeat') or
1109
+ Qless.config.get('heartbeat', 60))
1110
+
1111
+ if data then
1112
+ data = cjson.decode(data)
1113
+ end
1114
+
1115
+ -- First, let's see if the worker still owns this job, and there is a
1116
+ -- worker
1117
+ local job_worker, state = unpack(
1118
+ redis.call('hmget', QlessJob.ns .. self.jid, 'worker', 'state'))
1119
+ if job_worker == false then
1120
+ -- This means the job doesn't exist
1121
+ error('Heartbeat(): Job ' .. self.jid .. ' does not exist')
1122
+ elseif state ~= 'running' then
1123
+ error(
1124
+ 'Heartbeat(): Job ' .. self.jid .. ' not currently running: ' .. state)
1125
+ elseif job_worker ~= worker or #job_worker == 0 then
1126
+ error(
1127
+ 'Heartbeat(): Job ' .. self.jid ..
1128
+ ' given out to another worker: ' .. job_worker)
1129
+ else
1130
+ -- Otherwise, optionally update the user data, and the heartbeat
1131
+ if data then
1132
+ -- I don't know if this is wise, but I'm decoding and encoding
1133
+ -- the user data to hopefully ensure its sanity
1134
+ redis.call('hmset', QlessJob.ns .. self.jid, 'expires',
1135
+ expires, 'worker', worker, 'data', cjson.encode(data))
1136
+ else
1137
+ redis.call('hmset', QlessJob.ns .. self.jid,
1138
+ 'expires', expires, 'worker', worker)
1139
+ end
1140
+
1141
+ -- Update hwen this job was last updated on that worker
1142
+ -- Add this job to the list of jobs handled by this worker
1143
+ redis.call('zadd', 'ql:w:' .. worker .. ':jobs', expires, self.jid)
1144
+
1145
+ -- Make sure we this worker to the list of seen workers
1146
+ redis.call('zadd', 'ql:workers', now, worker)
1147
+
1148
+ -- And now we should just update the locks
1149
+ local queue = Qless.queue(
1150
+ redis.call('hget', QlessJob.ns .. self.jid, 'queue'))
1151
+ queue.locks.add(expires, self.jid)
1152
+ return expires
1153
+ end
1154
+ end
1155
+
1156
+ -- Priority
1157
+ -- --------
1158
+ -- Update the priority of this job. If the job doesn't exist, throws an
1159
+ -- exception
1160
+ function QlessJob:priority(priority)
1161
+ priority = assert(tonumber(priority),
1162
+ 'Priority(): Arg "priority" missing or not a number: ' ..
1163
+ tostring(priority))
1164
+
1165
+ -- Get the queue the job is currently in, if any
1166
+ local queue = redis.call('hget', QlessJob.ns .. self.jid, 'queue')
1167
+
1168
+ if queue == nil or queue == false then
1169
+ -- If the job doesn't exist, throw an error
1170
+ error('Priority(): Job ' .. self.jid .. ' does not exist')
1171
+ elseif queue == '' then
1172
+ -- Just adjust the priority
1173
+ redis.call('hset', QlessJob.ns .. self.jid, 'priority', priority)
1174
+ return priority
1175
+ else
1176
+ -- Adjust the priority and see if it's a candidate for updating
1177
+ -- its priority in the queue it's currently in
1178
+ local queue_obj = Qless.queue(queue)
1179
+ if queue_obj.work.score(self.jid) then
1180
+ queue_obj.work.add(0, priority, self.jid)
1181
+ end
1182
+ redis.call('hset', QlessJob.ns .. self.jid, 'priority', priority)
1183
+ return priority
1184
+ end
1185
+ end
1186
+
1187
+ -- Update the jobs' attributes with the provided dictionary
1188
+ function QlessJob:update(data)
1189
+ local tmp = {}
1190
+ for k, v in pairs(data) do
1191
+ table.insert(tmp, k)
1192
+ table.insert(tmp, v)
1193
+ end
1194
+ redis.call('hmset', QlessJob.ns .. self.jid, unpack(tmp))
1195
+ end
1196
+
1197
+ -- Times out the job now rather than when its lock is normally set to expire
1198
+ function QlessJob:timeout(now)
1199
+ local queue_name, state, worker = unpack(redis.call('hmget',
1200
+ QlessJob.ns .. self.jid, 'queue', 'state', 'worker'))
1201
+ if queue_name == nil or queue_name == false then
1202
+ error('Timeout(): Job ' .. self.jid .. ' does not exist')
1203
+ elseif state ~= 'running' then
1204
+ error('Timeout(): Job ' .. self.jid .. ' not running')
1205
+ else
1206
+ -- Time out the job
1207
+ self:history(now, 'timed-out')
1208
+ local queue = Qless.queue(queue_name)
1209
+ queue.locks.remove(self.jid)
1210
+ queue.work.add(now, '+inf', self.jid)
1211
+ redis.call('hmset', QlessJob.ns .. self.jid,
1212
+ 'state', 'stalled', 'expires', 0)
1213
+ local encoded = cjson.encode({
1214
+ jid = self.jid,
1215
+ event = 'lock_lost',
1216
+ worker = worker
1217
+ })
1218
+ Qless.publish('w:' .. worker, encoded)
1219
+ Qless.publish('log', encoded)
1220
+ return queue_name
1221
+ end
1222
+ end
1223
+
1224
+ -- Return whether or not this job exists
1225
+ function QlessJob:exists()
1226
+ return redis.call('exists', QlessJob.ns .. self.jid) == 1
1227
+ end
1228
+
1229
+ -- Get or append to history
1230
+ function QlessJob:history(now, what, item)
1231
+ -- First, check if there's an old-style history, and update it if there is
1232
+ local history = redis.call('hget', QlessJob.ns .. self.jid, 'history')
1233
+ if history then
1234
+ history = cjson.decode(history)
1235
+ for i, value in ipairs(history) do
1236
+ redis.call('rpush', QlessJob.ns .. self.jid .. '-history',
1237
+ cjson.encode({math.floor(value.put), 'put', {q = value.q}}))
1238
+
1239
+ -- If there's any popped time
1240
+ if value.popped then
1241
+ redis.call('rpush', QlessJob.ns .. self.jid .. '-history',
1242
+ cjson.encode({math.floor(value.popped), 'popped',
1243
+ {worker = value.worker}}))
1244
+ end
1245
+
1246
+ -- If there's any failure
1247
+ if value.failed then
1248
+ redis.call('rpush', QlessJob.ns .. self.jid .. '-history',
1249
+ cjson.encode(
1250
+ {math.floor(value.failed), 'failed', nil}))
1251
+ end
1252
+
1253
+ -- If it was completed
1254
+ if value.done then
1255
+ redis.call('rpush', QlessJob.ns .. self.jid .. '-history',
1256
+ cjson.encode(
1257
+ {math.floor(value.done), 'done', nil}))
1258
+ end
1259
+ end
1260
+ -- With all this ported forward, delete the old-style history
1261
+ redis.call('hdel', QlessJob.ns .. self.jid, 'history')
1262
+ end
1263
+
1264
+ -- Now to the meat of the function
1265
+ if what == nil then
1266
+ -- Get the history
1267
+ local response = {}
1268
+ for i, value in ipairs(redis.call('lrange',
1269
+ QlessJob.ns .. self.jid .. '-history', 0, -1)) do
1270
+ value = cjson.decode(value)
1271
+ local dict = value[3] or {}
1272
+ dict['when'] = value[1]
1273
+ dict['what'] = value[2]
1274
+ table.insert(response, dict)
1275
+ end
1276
+ return response
1277
+ else
1278
+ -- Append to the history. If the length of the history should be limited,
1279
+ -- then we'll truncate it.
1280
+ local count = tonumber(Qless.config.get('max-job-history', 100))
1281
+ if count > 0 then
1282
+ -- We'll always keep the first item around
1283
+ local obj = redis.call('lpop', QlessJob.ns .. self.jid .. '-history')
1284
+ redis.call('ltrim', QlessJob.ns .. self.jid .. '-history', -count + 2, -1)
1285
+ if obj ~= nil and obj ~= false then
1286
+ redis.call('lpush', QlessJob.ns .. self.jid .. '-history', obj)
1287
+ end
1288
+ end
1289
+ return redis.call('rpush', QlessJob.ns .. self.jid .. '-history',
1290
+ cjson.encode({math.floor(now), what, item}))
1291
+ end
1292
+ end
1293
+ -------------------------------------------------------------------------------
1294
+ -- Queue class
1295
+ -------------------------------------------------------------------------------
1296
+ -- Return a queue object
1297
+ function Qless.queue(name)
1298
+ assert(name, 'Queue(): no queue name provided')
1299
+ local queue = {}
1300
+ setmetatable(queue, QlessQueue)
1301
+ queue.name = name
1302
+
1303
+ -- Access to our work
1304
+ queue.work = {
1305
+ peek = function(count)
1306
+ if count == 0 then
1307
+ return {}
1308
+ end
1309
+ local jids = {}
1310
+ for index, jid in ipairs(redis.call(
1311
+ 'zrevrange', queue:prefix('work'), 0, count - 1)) do
1312
+ table.insert(jids, jid)
1313
+ end
1314
+ return jids
1315
+ end, remove = function(...)
1316
+ if #arg > 0 then
1317
+ return redis.call('zrem', queue:prefix('work'), unpack(arg))
1318
+ end
1319
+ end, add = function(now, priority, jid)
1320
+ if priority ~= '+inf' then
1321
+ priority = priority - (now / 10000000000)
1322
+ end
1323
+ return redis.call('zadd',
1324
+ queue:prefix('work'), priority, jid)
1325
+ end, score = function(jid)
1326
+ return redis.call('zscore', queue:prefix('work'), jid)
1327
+ end, length = function()
1328
+ return redis.call('zcard', queue:prefix('work'))
1329
+ end
1330
+ }
1331
+
1332
+ -- Access to our locks
1333
+ queue.locks = {
1334
+ expired = function(now, offset, count)
1335
+ return redis.call('zrangebyscore',
1336
+ queue:prefix('locks'), '-inf', now, 'LIMIT', offset, count)
1337
+ end, peek = function(now, offset, count)
1338
+ return redis.call('zrangebyscore', queue:prefix('locks'),
1339
+ now, '+inf', 'LIMIT', offset, count)
1340
+ end, add = function(expires, jid)
1341
+ redis.call('zadd', queue:prefix('locks'), expires, jid)
1342
+ end, remove = function(...)
1343
+ if #arg > 0 then
1344
+ return redis.call('zrem', queue:prefix('locks'), unpack(arg))
1345
+ end
1346
+ end, running = function(now)
1347
+ return redis.call('zcount', queue:prefix('locks'), now, '+inf')
1348
+ end, length = function(now)
1349
+ -- If a 'now' is provided, we're interested in how many are before
1350
+ -- that time
1351
+ if now then
1352
+ return redis.call('zcount', queue:prefix('locks'), 0, now)
1353
+ else
1354
+ return redis.call('zcard', queue:prefix('locks'))
1355
+ end
1356
+ end
1357
+ }
1358
+
1359
+ -- Access to our dependent jobs
1360
+ queue.depends = {
1361
+ peek = function(now, offset, count)
1362
+ return redis.call('zrange',
1363
+ queue:prefix('depends'), offset, offset + count - 1)
1364
+ end, add = function(now, jid)
1365
+ redis.call('zadd', queue:prefix('depends'), now, jid)
1366
+ end, remove = function(...)
1367
+ if #arg > 0 then
1368
+ return redis.call('zrem', queue:prefix('depends'), unpack(arg))
1369
+ end
1370
+ end, length = function()
1371
+ return redis.call('zcard', queue:prefix('depends'))
1372
+ end
1373
+ }
1374
+
1375
+ -- Access to our scheduled jobs
1376
+ queue.scheduled = {
1377
+ peek = function(now, offset, count)
1378
+ return redis.call('zrange',
1379
+ queue:prefix('scheduled'), offset, offset + count - 1)
1380
+ end, ready = function(now, offset, count)
1381
+ return redis.call('zrangebyscore',
1382
+ queue:prefix('scheduled'), 0, now, 'LIMIT', offset, count)
1383
+ end, add = function(when, jid)
1384
+ redis.call('zadd', queue:prefix('scheduled'), when, jid)
1385
+ end, remove = function(...)
1386
+ if #arg > 0 then
1387
+ return redis.call('zrem', queue:prefix('scheduled'), unpack(arg))
1388
+ end
1389
+ end, length = function()
1390
+ return redis.call('zcard', queue:prefix('scheduled'))
1391
+ end
1392
+ }
1393
+
1394
+ -- Access to our recurring jobs
1395
+ queue.recurring = {
1396
+ peek = function(now, offset, count)
1397
+ return redis.call('zrangebyscore', queue:prefix('recur'),
1398
+ 0, now, 'LIMIT', offset, count)
1399
+ end, ready = function(now, offset, count)
1400
+ end, add = function(when, jid)
1401
+ redis.call('zadd', queue:prefix('recur'), when, jid)
1402
+ end, remove = function(...)
1403
+ if #arg > 0 then
1404
+ return redis.call('zrem', queue:prefix('recur'), unpack(arg))
1405
+ end
1406
+ end, update = function(increment, jid)
1407
+ redis.call('zincrby', queue:prefix('recur'), increment, jid)
1408
+ end, score = function(jid)
1409
+ return redis.call('zscore', queue:prefix('recur'), jid)
1410
+ end, length = function()
1411
+ return redis.call('zcard', queue:prefix('recur'))
1412
+ end
1413
+ }
1414
+ return queue
1415
+ end
1416
+
1417
+ -- Return the prefix for this particular queue
1418
+ function QlessQueue:prefix(group)
1419
+ if group then
1420
+ return QlessQueue.ns..self.name..'-'..group
1421
+ else
1422
+ return QlessQueue.ns..self.name
1423
+ end
1424
+ end
1425
+
1426
+ -- Stats(now, date)
1427
+ -- ---------------------
1428
+ -- Return the current statistics for a given queue on a given date. The
1429
+ -- results are returned are a JSON blob:
1430
+ --
1431
+ --
1432
+ -- {
1433
+ -- # These are unimplemented as of yet
1434
+ -- 'failed': 3,
1435
+ -- 'retries': 5,
1436
+ -- 'wait' : {
1437
+ -- 'total' : ...,
1438
+ -- 'mean' : ...,
1439
+ -- 'variance' : ...,
1440
+ -- 'histogram': [
1441
+ -- ...
1442
+ -- ]
1443
+ -- }, 'run': {
1444
+ -- 'total' : ...,
1445
+ -- 'mean' : ...,
1446
+ -- 'variance' : ...,
1447
+ -- 'histogram': [
1448
+ -- ...
1449
+ -- ]
1450
+ -- }
1451
+ -- }
1452
+ --
1453
+ -- The histogram's data points are at the second resolution for the first
1454
+ -- minute, the minute resolution for the first hour, the 15-minute resolution
1455
+ -- for the first day, the hour resolution for the first 3 days, and then at
1456
+ -- the day resolution from there on out. The `histogram` key is a list of
1457
+ -- those values.
1458
+ function QlessQueue:stats(now, date)
1459
+ date = assert(tonumber(date),
1460
+ 'Stats(): Arg "date" missing or not a number: '.. (date or 'nil'))
1461
+
1462
+ -- The bin is midnight of the provided day
1463
+ -- 24 * 60 * 60 = 86400
1464
+ local bin = date - (date % 86400)
1465
+
1466
+ -- This a table of all the keys we want to use in order to produce a histogram
1467
+ local histokeys = {
1468
+ 's0','s1','s2','s3','s4','s5','s6','s7','s8','s9','s10','s11','s12','s13','s14','s15','s16','s17','s18','s19','s20','s21','s22','s23','s24','s25','s26','s27','s28','s29','s30','s31','s32','s33','s34','s35','s36','s37','s38','s39','s40','s41','s42','s43','s44','s45','s46','s47','s48','s49','s50','s51','s52','s53','s54','s55','s56','s57','s58','s59',
1469
+ 'm1','m2','m3','m4','m5','m6','m7','m8','m9','m10','m11','m12','m13','m14','m15','m16','m17','m18','m19','m20','m21','m22','m23','m24','m25','m26','m27','m28','m29','m30','m31','m32','m33','m34','m35','m36','m37','m38','m39','m40','m41','m42','m43','m44','m45','m46','m47','m48','m49','m50','m51','m52','m53','m54','m55','m56','m57','m58','m59',
1470
+ 'h1','h2','h3','h4','h5','h6','h7','h8','h9','h10','h11','h12','h13','h14','h15','h16','h17','h18','h19','h20','h21','h22','h23',
1471
+ 'd1','d2','d3','d4','d5','d6'
1472
+ }
1473
+
1474
+ local mkstats = function(name, bin, queue)
1475
+ -- The results we'll be sending back
1476
+ local results = {}
1477
+
1478
+ local key = 'ql:s:' .. name .. ':' .. bin .. ':' .. queue
1479
+ local count, mean, vk = unpack(redis.call('hmget', key, 'total', 'mean', 'vk'))
1480
+
1481
+ count = tonumber(count) or 0
1482
+ mean = tonumber(mean) or 0
1483
+ vk = tonumber(vk)
1484
+
1485
+ results.count = count or 0
1486
+ results.mean = mean or 0
1487
+ results.histogram = {}
1488
+
1489
+ if not count then
1490
+ results.std = 0
1491
+ else
1492
+ if count > 1 then
1493
+ results.std = math.sqrt(vk / (count - 1))
1494
+ else
1495
+ results.std = 0
1496
+ end
1497
+ end
1498
+
1499
+ local histogram = redis.call('hmget', key, unpack(histokeys))
1500
+ for i=1,#histokeys do
1501
+ table.insert(results.histogram, tonumber(histogram[i]) or 0)
1502
+ end
1503
+ return results
1504
+ end
1505
+
1506
+ local retries, failed, failures = unpack(redis.call('hmget', 'ql:s:stats:' .. bin .. ':' .. self.name, 'retries', 'failed', 'failures'))
1507
+ return {
1508
+ retries = tonumber(retries or 0),
1509
+ failed = tonumber(failed or 0),
1510
+ failures = tonumber(failures or 0),
1511
+ wait = mkstats('wait', bin, self.name),
1512
+ run = mkstats('run' , bin, self.name)
1513
+ }
1514
+ end
1515
+
1516
+ -- Peek
1517
+ -------
1518
+ -- Examine the next jobs that would be popped from the queue without actually
1519
+ -- popping them.
1520
+ function QlessQueue:peek(now, count)
1521
+ count = assert(tonumber(count),
1522
+ 'Peek(): Arg "count" missing or not a number: ' .. tostring(count))
1523
+
1524
+ -- These are the ids that we're going to return. We'll begin with any jobs
1525
+ -- that have lost their locks
1526
+ local jids = self.locks.expired(now, 0, count)
1527
+
1528
+ -- If we still need jobs in order to meet demand, then we should
1529
+ -- look for all the recurring jobs that need jobs run
1530
+ self:check_recurring(now, count - #jids)
1531
+
1532
+ -- Now we've checked __all__ the locks for this queue the could
1533
+ -- have expired, and are no more than the number requested. If
1534
+ -- we still need values in order to meet the demand, then we
1535
+ -- should check if any scheduled items, and if so, we should
1536
+ -- insert them to ensure correctness when pulling off the next
1537
+ -- unit of work.
1538
+ self:check_scheduled(now, count - #jids)
1539
+
1540
+ -- With these in place, we can expand this list of jids based on the work
1541
+ -- queue itself and the priorities therein
1542
+ tbl_extend(jids, self.work.peek(count - #jids))
1543
+
1544
+ return jids
1545
+ end
1546
+
1547
+ -- Return true if this queue is paused
1548
+ function QlessQueue:paused()
1549
+ return redis.call('sismember', 'ql:paused_queues', self.name) == 1
1550
+ end
1551
+
1552
+ -- Pause this queue
1553
+ --
1554
+ -- Note: long term, we have discussed adding a rate-limiting
1555
+ -- feature to qless-core, which would be more flexible and
1556
+ -- could be used for pausing (i.e. pause = set the rate to 0).
1557
+ -- For now, this is far simpler, but we should rewrite this
1558
+ -- in terms of the rate limiting feature if/when that is added.
1559
+ function QlessQueue.pause(now, ...)
1560
+ redis.call('sadd', 'ql:paused_queues', unpack(arg))
1561
+ end
1562
+
1563
+ -- Unpause this queue
1564
+ function QlessQueue.unpause(...)
1565
+ redis.call('srem', 'ql:paused_queues', unpack(arg))
1566
+ end
1567
+
1568
+ -- Checks for expired locks, scheduled and recurring jobs, returning any
1569
+ -- jobs that are ready to be processes
1570
+ function QlessQueue:pop(now, worker, count)
1571
+ assert(worker, 'Pop(): Arg "worker" missing')
1572
+ count = assert(tonumber(count),
1573
+ 'Pop(): Arg "count" missing or not a number: ' .. tostring(count))
1574
+
1575
+ -- We should find the heartbeat interval for this queue heartbeat
1576
+ local expires = now + tonumber(
1577
+ Qless.config.get(self.name .. '-heartbeat') or
1578
+ Qless.config.get('heartbeat', 60))
1579
+
1580
+ -- If this queue is paused, then return no jobs
1581
+ if self:paused() then
1582
+ return {}
1583
+ end
1584
+
1585
+ -- Make sure we this worker to the list of seen workers
1586
+ redis.call('zadd', 'ql:workers', now, worker)
1587
+
1588
+ -- Check our max concurrency, and limit the count
1589
+ local max_concurrency = tonumber(
1590
+ Qless.config.get(self.name .. '-max-concurrency', 0))
1591
+
1592
+ if max_concurrency > 0 then
1593
+ -- Allow at most max_concurrency - #running
1594
+ local allowed = math.max(0, max_concurrency - self.locks.running(now))
1595
+ count = math.min(allowed, count)
1596
+ if count == 0 then
1597
+ return {}
1598
+ end
1599
+ end
1600
+
1601
+ local jids = self:invalidate_locks(now, count)
1602
+ -- Now we've checked __all__ the locks for this queue the could
1603
+ -- have expired, and are no more than the number requested.
1604
+
1605
+ -- If we still need jobs in order to meet demand, then we should
1606
+ -- look for all the recurring jobs that need jobs run
1607
+ self:check_recurring(now, count - #jids)
1608
+
1609
+ -- If we still need values in order to meet the demand, then we
1610
+ -- should check if any scheduled items, and if so, we should
1611
+ -- insert them to ensure correctness when pulling off the next
1612
+ -- unit of work.
1613
+ self:check_scheduled(now, count - #jids)
1614
+
1615
+ -- With these in place, we can expand this list of jids based on the work
1616
+ -- queue itself and the priorities therein
1617
+ tbl_extend(jids, self.work.peek(count - #jids))
1618
+
1619
+ local state
1620
+ for index, jid in ipairs(jids) do
1621
+ local job = Qless.job(jid)
1622
+ state = unpack(job:data('state'))
1623
+ job:history(now, 'popped', {worker = worker})
1624
+
1625
+ -- Update the wait time statistics
1626
+ local time = tonumber(
1627
+ redis.call('hget', QlessJob.ns .. jid, 'time') or now)
1628
+ local waiting = now - time
1629
+ self:stat(now, 'wait', waiting)
1630
+ redis.call('hset', QlessJob.ns .. jid,
1631
+ 'time', string.format("%.20f", now))
1632
+
1633
+ -- Add this job to the list of jobs handled by this worker
1634
+ redis.call('zadd', 'ql:w:' .. worker .. ':jobs', expires, jid)
1635
+
1636
+ -- Update the jobs data, and add its locks, and return the job
1637
+ job:update({
1638
+ worker = worker,
1639
+ expires = expires,
1640
+ state = 'running'
1641
+ })
1642
+
1643
+ self.locks.add(expires, jid)
1644
+
1645
+ local tracked = redis.call('zscore', 'ql:tracked', jid) ~= false
1646
+ if tracked then
1647
+ Qless.publish('popped', jid)
1648
+ end
1649
+ end
1650
+
1651
+ -- If we are returning any jobs, then we should remove them from the work
1652
+ -- queue
1653
+ self.work.remove(unpack(jids))
1654
+
1655
+ return jids
1656
+ end
1657
+
1658
+ -- Update the stats for this queue
1659
+ function QlessQueue:stat(now, stat, val)
1660
+ -- The bin is midnight of the provided day
1661
+ local bin = now - (now % 86400)
1662
+ local key = 'ql:s:' .. stat .. ':' .. bin .. ':' .. self.name
1663
+
1664
+ -- Get the current data
1665
+ local count, mean, vk = unpack(
1666
+ redis.call('hmget', key, 'total', 'mean', 'vk'))
1667
+
1668
+ -- If there isn't any data there presently, then we must initialize it
1669
+ count = count or 0
1670
+ if count == 0 then
1671
+ mean = val
1672
+ vk = 0
1673
+ count = 1
1674
+ else
1675
+ count = count + 1
1676
+ local oldmean = mean
1677
+ mean = mean + (val - mean) / count
1678
+ vk = vk + (val - mean) * (val - oldmean)
1679
+ end
1680
+
1681
+ -- Now, update the histogram
1682
+ -- - `s1`, `s2`, ..., -- second-resolution histogram counts
1683
+ -- - `m1`, `m2`, ..., -- minute-resolution
1684
+ -- - `h1`, `h2`, ..., -- hour-resolution
1685
+ -- - `d1`, `d2`, ..., -- day-resolution
1686
+ val = math.floor(val)
1687
+ if val < 60 then -- seconds
1688
+ redis.call('hincrby', key, 's' .. val, 1)
1689
+ elseif val < 3600 then -- minutes
1690
+ redis.call('hincrby', key, 'm' .. math.floor(val / 60), 1)
1691
+ elseif val < 86400 then -- hours
1692
+ redis.call('hincrby', key, 'h' .. math.floor(val / 3600), 1)
1693
+ else -- days
1694
+ redis.call('hincrby', key, 'd' .. math.floor(val / 86400), 1)
1695
+ end
1696
+ redis.call('hmset', key, 'total', count, 'mean', mean, 'vk', vk)
1697
+ end
1698
+
1699
+ -- Put(now, jid, klass, data, delay,
1700
+ -- [priority, p],
1701
+ -- [tags, t],
1702
+ -- [retries, r],
1703
+ -- [depends, '[...]'])
1704
+ -- -----------------------
1705
+ -- Insert a job into the queue with the given priority, tags, delay, klass and
1706
+ -- data.
1707
+ function QlessQueue:put(now, worker, jid, klass, raw_data, delay, ...)
1708
+ assert(jid , 'Put(): Arg "jid" missing')
1709
+ assert(klass, 'Put(): Arg "klass" missing')
1710
+ local data = assert(cjson.decode(raw_data),
1711
+ 'Put(): Arg "data" missing or not JSON: ' .. tostring(raw_data))
1712
+ delay = assert(tonumber(delay),
1713
+ 'Put(): Arg "delay" not a number: ' .. tostring(delay))
1714
+
1715
+ -- Read in all the optional parameters. All of these must come in pairs, so
1716
+ -- if we have an odd number of extra args, raise an error
1717
+ if #arg % 2 == 1 then
1718
+ error('Odd number of additional args: ' .. tostring(arg))
1719
+ end
1720
+ local options = {}
1721
+ for i = 1, #arg, 2 do options[arg[i]] = arg[i + 1] end
1722
+
1723
+ -- Let's see what the old priority and tags were
1724
+ local job = Qless.job(jid)
1725
+ local priority, tags, oldqueue, state, failure, retries, oldworker =
1726
+ unpack(redis.call('hmget', QlessJob.ns .. jid, 'priority', 'tags',
1727
+ 'queue', 'state', 'failure', 'retries', 'worker'))
1728
+
1729
+ -- If there are old tags, then we should remove the tags this job has
1730
+ if tags then
1731
+ Qless.tag(now, 'remove', jid, unpack(cjson.decode(tags)))
1732
+ end
1733
+
1734
+ -- Sanity check on optional args
1735
+ retries = assert(tonumber(options['retries'] or retries or 5) ,
1736
+ 'Put(): Arg "retries" not a number: ' .. tostring(options['retries']))
1737
+ tags = assert(cjson.decode(options['tags'] or tags or '[]' ),
1738
+ 'Put(): Arg "tags" not JSON' .. tostring(options['tags']))
1739
+ priority = assert(tonumber(options['priority'] or priority or 0),
1740
+ 'Put(): Arg "priority" not a number' .. tostring(options['priority']))
1741
+ local depends = assert(cjson.decode(options['depends'] or '[]') ,
1742
+ 'Put(): Arg "depends" not JSON: ' .. tostring(options['depends']))
1743
+
1744
+ -- If the job has old dependencies, determine which dependencies are
1745
+ -- in the new dependencies but not in the old ones, and which are in the
1746
+ -- old ones but not in the new
1747
+ if #depends > 0 then
1748
+ -- This makes it easier to check if it's in the new list
1749
+ local new = {}
1750
+ for _, d in ipairs(depends) do new[d] = 1 end
1751
+
1752
+ -- Now find what's in the original, but not the new
1753
+ local original = redis.call(
1754
+ 'smembers', QlessJob.ns .. jid .. '-dependencies')
1755
+ for _, dep in pairs(original) do
1756
+ if new[dep] == nil or new[dep] == false then
1757
+ -- Remove k as a dependency
1758
+ redis.call('srem', QlessJob.ns .. dep .. '-dependents' , jid)
1759
+ redis.call('srem', QlessJob.ns .. jid .. '-dependencies', dep)
1760
+ end
1761
+ end
1762
+ end
1763
+
1764
+ -- Send out a log message
1765
+ Qless.publish('log', cjson.encode({
1766
+ jid = jid,
1767
+ event = 'put',
1768
+ queue = self.name
1769
+ }))
1770
+
1771
+ -- Update the history to include this new change
1772
+ job:history(now, 'put', {q = self.name})
1773
+
1774
+ -- If this item was previously in another queue, then we should remove it from there
1775
+ if oldqueue then
1776
+ local queue_obj = Qless.queue(oldqueue)
1777
+ queue_obj.work.remove(jid)
1778
+ queue_obj.locks.remove(jid)
1779
+ queue_obj.depends.remove(jid)
1780
+ queue_obj.scheduled.remove(jid)
1781
+ end
1782
+
1783
+ -- If this had previously been given out to a worker, make sure to remove it
1784
+ -- from that worker's jobs
1785
+ if oldworker and oldworker ~= '' then
1786
+ redis.call('zrem', 'ql:w:' .. oldworker .. ':jobs', jid)
1787
+ -- If it's a different worker that's putting this job, send a notification
1788
+ -- to the last owner of the job
1789
+ if oldworker ~= worker then
1790
+ -- We need to inform whatever worker had that job
1791
+ local encoded = cjson.encode({
1792
+ jid = jid,
1793
+ event = 'lock_lost',
1794
+ worker = oldworker
1795
+ })
1796
+ Qless.publish('w:' .. oldworker, encoded)
1797
+ Qless.publish('log', encoded)
1798
+ end
1799
+ end
1800
+
1801
+ -- If the job was previously in the 'completed' state, then we should
1802
+ -- remove it from being enqueued for destructination
1803
+ if state == 'complete' then
1804
+ redis.call('zrem', 'ql:completed', jid)
1805
+ end
1806
+
1807
+ -- Add this job to the list of jobs tagged with whatever tags were supplied
1808
+ for i, tag in ipairs(tags) do
1809
+ redis.call('zadd', 'ql:t:' .. tag, now, jid)
1810
+ redis.call('zincrby', 'ql:tags', 1, tag)
1811
+ end
1812
+
1813
+ -- If we're in the failed state, remove all of our data
1814
+ if state == 'failed' then
1815
+ failure = cjson.decode(failure)
1816
+ -- We need to make this remove it from the failed queues
1817
+ redis.call('lrem', 'ql:f:' .. failure.group, 0, jid)
1818
+ if redis.call('llen', 'ql:f:' .. failure.group) == 0 then
1819
+ redis.call('srem', 'ql:failures', failure.group)
1820
+ end
1821
+ -- The bin is midnight of the provided day
1822
+ -- 24 * 60 * 60 = 86400
1823
+ local bin = failure.when - (failure.when % 86400)
1824
+ -- We also need to decrement the stats about the queue on
1825
+ -- the day that this failure actually happened.
1826
+ redis.call('hincrby', 'ql:s:stats:' .. bin .. ':' .. self.name, 'failed' , -1)
1827
+ end
1828
+
1829
+ -- First, let's save its data
1830
+ redis.call('hmset', QlessJob.ns .. jid,
1831
+ 'jid' , jid,
1832
+ 'klass' , klass,
1833
+ 'data' , raw_data,
1834
+ 'priority' , priority,
1835
+ 'tags' , cjson.encode(tags),
1836
+ 'state' , ((delay > 0) and 'scheduled') or 'waiting',
1837
+ 'worker' , '',
1838
+ 'expires' , 0,
1839
+ 'queue' , self.name,
1840
+ 'retries' , retries,
1841
+ 'remaining', retries,
1842
+ 'time' , string.format("%.20f", now))
1843
+
1844
+ -- These are the jids we legitimately have to wait on
1845
+ for i, j in ipairs(depends) do
1846
+ -- Make sure it's something other than 'nil' or complete.
1847
+ local state = redis.call('hget', QlessJob.ns .. j, 'state')
1848
+ if (state and state ~= 'complete') then
1849
+ redis.call('sadd', QlessJob.ns .. j .. '-dependents' , jid)
1850
+ redis.call('sadd', QlessJob.ns .. jid .. '-dependencies', j)
1851
+ end
1852
+ end
1853
+
1854
+ -- Now, if a delay was provided, and if it's in the future,
1855
+ -- then we'll have to schedule it. Otherwise, we're just
1856
+ -- going to add it to the work queue.
1857
+ if delay > 0 then
1858
+ if redis.call('scard', QlessJob.ns .. jid .. '-dependencies') > 0 then
1859
+ -- We've already put it in 'depends'. Now, we must just save the data
1860
+ -- for when it's scheduled
1861
+ self.depends.add(now, jid)
1862
+ redis.call('hmset', QlessJob.ns .. jid,
1863
+ 'state', 'depends',
1864
+ 'scheduled', now + delay)
1865
+ else
1866
+ self.scheduled.add(now + delay, jid)
1867
+ end
1868
+ else
1869
+ if redis.call('scard', QlessJob.ns .. jid .. '-dependencies') > 0 then
1870
+ self.depends.add(now, jid)
1871
+ redis.call('hset', QlessJob.ns .. jid, 'state', 'depends')
1872
+ else
1873
+ self.work.add(now, priority, jid)
1874
+ end
1875
+ end
1876
+
1877
+ -- Lastly, we're going to make sure that this item is in the
1878
+ -- set of known queues. We should keep this sorted by the
1879
+ -- order in which we saw each of these queues
1880
+ if redis.call('zscore', 'ql:queues', self.name) == false then
1881
+ redis.call('zadd', 'ql:queues', now, self.name)
1882
+ end
1883
+
1884
+ if redis.call('zscore', 'ql:tracked', jid) ~= false then
1885
+ Qless.publish('put', jid)
1886
+ end
1887
+
1888
+ return jid
1889
+ end
1890
+
1891
+ -- Move `count` jobs out of the failed state and into this queue
1892
+ function QlessQueue:unfail(now, group, count)
1893
+ assert(group, 'Unfail(): Arg "group" missing')
1894
+ count = assert(tonumber(count or 25),
1895
+ 'Unfail(): Arg "count" not a number: ' .. tostring(count))
1896
+
1897
+ -- Get up to that many jobs, and we'll put them in the appropriate queue
1898
+ local jids = redis.call('lrange', 'ql:f:' .. group, -count, -1)
1899
+
1900
+ -- And now set each job's state, and put it into the appropriate queue
1901
+ local toinsert = {}
1902
+ for index, jid in ipairs(jids) do
1903
+ local job = Qless.job(jid)
1904
+ local data = job:data()
1905
+ job:history(now, 'put', {q = self.name})
1906
+ redis.call('hmset', QlessJob.ns .. data.jid,
1907
+ 'state' , 'waiting',
1908
+ 'worker' , '',
1909
+ 'expires' , 0,
1910
+ 'queue' , self.name,
1911
+ 'remaining', data.retries or 5)
1912
+ self.work.add(now, data.priority, data.jid)
1913
+ end
1914
+
1915
+ -- Remove these jobs from the failed state
1916
+ redis.call('ltrim', 'ql:f:' .. group, 0, -count - 1)
1917
+ if (redis.call('llen', 'ql:f:' .. group) == 0) then
1918
+ redis.call('srem', 'ql:failures', group)
1919
+ end
1920
+
1921
+ return #jids
1922
+ end
1923
+
1924
+ -- Recur a job of type klass in this queue
1925
+ function QlessQueue:recur(now, jid, klass, raw_data, spec, ...)
1926
+ assert(jid , 'RecurringJob On(): Arg "jid" missing')
1927
+ assert(klass, 'RecurringJob On(): Arg "klass" missing')
1928
+ assert(spec , 'RecurringJob On(): Arg "spec" missing')
1929
+ local data = assert(cjson.decode(raw_data),
1930
+ 'RecurringJob On(): Arg "data" not JSON: ' .. tostring(raw_data))
1931
+
1932
+ -- At some point in the future, we may have different types of recurring
1933
+ -- jobs, but for the time being, we only have 'interval'-type jobs
1934
+ if spec == 'interval' then
1935
+ local interval = assert(tonumber(arg[1]),
1936
+ 'Recur(): Arg "interval" not a number: ' .. tostring(arg[1]))
1937
+ local offset = assert(tonumber(arg[2]),
1938
+ 'Recur(): Arg "offset" not a number: ' .. tostring(arg[2]))
1939
+ if interval <= 0 then
1940
+ error('Recur(): Arg "interval" must be greater than 0')
1941
+ end
1942
+
1943
+ -- Read in all the optional parameters. All of these must come in
1944
+ -- pairs, so if we have an odd number of extra args, raise an error
1945
+ if #arg % 2 == 1 then
1946
+ error('Odd number of additional args: ' .. tostring(arg))
1947
+ end
1948
+
1949
+ -- Read in all the optional parameters
1950
+ local options = {}
1951
+ for i = 3, #arg, 2 do options[arg[i]] = arg[i + 1] end
1952
+ options.tags = assert(cjson.decode(options.tags or '{}'),
1953
+ 'Recur(): Arg "tags" must be JSON string array: ' .. tostring(
1954
+ options.tags))
1955
+ options.priority = assert(tonumber(options.priority or 0),
1956
+ 'Recur(): Arg "priority" not a number: ' .. tostring(
1957
+ options.priority))
1958
+ options.retries = assert(tonumber(options.retries or 0),
1959
+ 'Recur(): Arg "retries" not a number: ' .. tostring(
1960
+ options.retries))
1961
+ options.backlog = assert(tonumber(options.backlog or 0),
1962
+ 'Recur(): Arg "backlog" not a number: ' .. tostring(
1963
+ options.backlog))
1964
+
1965
+ local count, old_queue = unpack(redis.call('hmget', 'ql:r:' .. jid, 'count', 'queue'))
1966
+ count = count or 0
1967
+
1968
+ -- If it has previously been in another queue, then we should remove
1969
+ -- some information about it
1970
+ if old_queue then
1971
+ Qless.queue(old_queue).recurring.remove(jid)
1972
+ end
1973
+
1974
+ -- Do some insertions
1975
+ redis.call('hmset', 'ql:r:' .. jid,
1976
+ 'jid' , jid,
1977
+ 'klass' , klass,
1978
+ 'data' , raw_data,
1979
+ 'priority', options.priority,
1980
+ 'tags' , cjson.encode(options.tags or {}),
1981
+ 'state' , 'recur',
1982
+ 'queue' , self.name,
1983
+ 'type' , 'interval',
1984
+ -- How many jobs we've spawned from this
1985
+ 'count' , count,
1986
+ 'interval', interval,
1987
+ 'retries' , options.retries,
1988
+ 'backlog' , options.backlog)
1989
+ -- Now, we should schedule the next run of the job
1990
+ self.recurring.add(now + offset, jid)
1991
+
1992
+ -- Lastly, we're going to make sure that this item is in the
1993
+ -- set of known queues. We should keep this sorted by the
1994
+ -- order in which we saw each of these queues
1995
+ if redis.call('zscore', 'ql:queues', self.name) == false then
1996
+ redis.call('zadd', 'ql:queues', now, self.name)
1997
+ end
1998
+
1999
+ return jid
2000
+ else
2001
+ error('Recur(): schedule type "' .. tostring(spec) .. '" unknown')
2002
+ end
2003
+ end
2004
+
2005
+ -- Return the length of the queue
2006
+ function QlessQueue:length()
2007
+ return self.locks.length() + self.work.length() + self.scheduled.length()
2008
+ end
2009
+
2010
+ -------------------------------------------------------------------------------
2011
+ -- Housekeeping methods
2012
+ -------------------------------------------------------------------------------
2013
+ -- Instantiate any recurring jobs that are ready
2014
+ function QlessQueue:check_recurring(now, count)
2015
+ -- This is how many jobs we've moved so far
2016
+ local moved = 0
2017
+ -- These are the recurring jobs that need work
2018
+ local r = self.recurring.peek(now, 0, count)
2019
+ for index, jid in ipairs(r) do
2020
+ -- For each of the jids that need jobs scheduled, first
2021
+ -- get the last time each of them was run, and then increment
2022
+ -- it by its interval. While this time is less than now,
2023
+ -- we need to keep putting jobs on the queue
2024
+ local klass, data, priority, tags, retries, interval, backlog = unpack(
2025
+ redis.call('hmget', 'ql:r:' .. jid, 'klass', 'data', 'priority',
2026
+ 'tags', 'retries', 'interval', 'backlog'))
2027
+ local _tags = cjson.decode(tags)
2028
+ local score = math.floor(tonumber(self.recurring.score(jid)))
2029
+ interval = tonumber(interval)
2030
+
2031
+ -- If the backlog is set for this job, then see if it's been a long
2032
+ -- time since the last pop
2033
+ backlog = tonumber(backlog or 0)
2034
+ if backlog ~= 0 then
2035
+ -- Check how many jobs we could concievably generate
2036
+ local num = ((now - score) / interval)
2037
+ if num > backlog then
2038
+ -- Update the score
2039
+ score = score + (
2040
+ math.ceil(num - backlog) * interval
2041
+ )
2042
+ end
2043
+ end
2044
+
2045
+ -- We're saving this value so that in the history, we can accurately
2046
+ -- reflect when the job would normally have been scheduled
2047
+ while (score <= now) and (moved < count) do
2048
+ local count = redis.call('hincrby', 'ql:r:' .. jid, 'count', 1)
2049
+ moved = moved + 1
2050
+
2051
+ local child_jid = jid .. '-' .. count
2052
+
2053
+ -- Add this job to the list of jobs tagged with whatever tags were
2054
+ -- supplied
2055
+ for i, tag in ipairs(_tags) do
2056
+ redis.call('zadd', 'ql:t:' .. tag, now, child_jid)
2057
+ redis.call('zincrby', 'ql:tags', 1, tag)
2058
+ end
2059
+
2060
+ -- First, let's save its data
2061
+ redis.call('hmset', QlessJob.ns .. child_jid,
2062
+ 'jid' , child_jid,
2063
+ 'klass' , klass,
2064
+ 'data' , data,
2065
+ 'priority' , priority,
2066
+ 'tags' , tags,
2067
+ 'state' , 'waiting',
2068
+ 'worker' , '',
2069
+ 'expires' , 0,
2070
+ 'queue' , self.name,
2071
+ 'retries' , retries,
2072
+ 'remaining' , retries,
2073
+ 'time' , string.format("%.20f", score),
2074
+ 'spawned_from_jid', jid)
2075
+ Qless.job(child_jid):history(score, 'put', {q = self.name})
2076
+
2077
+ -- Now, if a delay was provided, and if it's in the future,
2078
+ -- then we'll have to schedule it. Otherwise, we're just
2079
+ -- going to add it to the work queue.
2080
+ self.work.add(score, priority, child_jid)
2081
+
2082
+ score = score + interval
2083
+ self.recurring.add(score, jid)
2084
+ end
2085
+ end
2086
+ end
2087
+
2088
+ -- Check for any jobs that have been scheduled, and shovel them onto
2089
+ -- the work queue. Returns nothing, but afterwards, up to `count`
2090
+ -- scheduled jobs will be moved into the work queue
2091
+ function QlessQueue:check_scheduled(now, count)
2092
+ -- zadd is a list of arguments that we'll be able to use to
2093
+ -- insert into the work queue
2094
+ local scheduled = self.scheduled.ready(now, 0, count)
2095
+ for index, jid in ipairs(scheduled) do
2096
+ -- With these in hand, we'll have to go out and find the
2097
+ -- priorities of these jobs, and then we'll insert them
2098
+ -- into the work queue and then when that's complete, we'll
2099
+ -- remove them from the scheduled queue
2100
+ local priority = tonumber(
2101
+ redis.call('hget', QlessJob.ns .. jid, 'priority') or 0)
2102
+ self.work.add(now, priority, jid)
2103
+ self.scheduled.remove(jid)
2104
+
2105
+ -- We should also update them to have the state 'waiting'
2106
+ -- instead of 'scheduled'
2107
+ redis.call('hset', QlessJob.ns .. jid, 'state', 'waiting')
2108
+ end
2109
+ end
2110
+
2111
+ -- Check for and invalidate any locks that have been lost. Returns the
2112
+ -- list of jids that have been invalidated
2113
+ function QlessQueue:invalidate_locks(now, count)
2114
+ local jids = {}
2115
+ -- Iterate through all the expired locks and add them to the list
2116
+ -- of keys that we'll return
2117
+ for index, jid in ipairs(self.locks.expired(now, 0, count)) do
2118
+ -- Remove this job from the jobs that the worker that was running it
2119
+ -- has
2120
+ local worker, failure = unpack(
2121
+ redis.call('hmget', QlessJob.ns .. jid, 'worker', 'failure'))
2122
+ redis.call('zrem', 'ql:w:' .. worker .. ':jobs', jid)
2123
+
2124
+ -- We'll provide a grace period after jobs time out for them to give
2125
+ -- some indication of the failure mode. After that time, however, we'll
2126
+ -- consider the worker dust in the wind
2127
+ local grace_period = tonumber(Qless.config.get('grace-period'))
2128
+
2129
+ -- Whether or not we've already sent a coutesy message
2130
+ local courtesy_sent = tonumber(
2131
+ redis.call('hget', QlessJob.ns .. jid, 'grace') or 0)
2132
+
2133
+ -- If the remaining value is an odd multiple of 0.5, then we'll assume
2134
+ -- that we're just sending the message. Otherwise, it's time to
2135
+ -- actually hand out the work to another worker
2136
+ local send_message = (courtesy_sent ~= 1)
2137
+ local invalidate = not send_message
2138
+
2139
+ -- If the grace period has been disabled, then we'll do both.
2140
+ if grace_period <= 0 then
2141
+ send_message = true
2142
+ invalidate = true
2143
+ end
2144
+
2145
+ if send_message then
2146
+ -- This is where we supply a courtesy message and give the worker
2147
+ -- time to provide a failure message
2148
+ if redis.call('zscore', 'ql:tracked', jid) ~= false then
2149
+ Qless.publish('stalled', jid)
2150
+ end
2151
+ Qless.job(jid):history(now, 'timed-out')
2152
+ redis.call('hset', QlessJob.ns .. jid, 'grace', 1)
2153
+
2154
+ -- Send a message to let the worker know that its lost its lock on
2155
+ -- the job
2156
+ local encoded = cjson.encode({
2157
+ jid = jid,
2158
+ event = 'lock_lost',
2159
+ worker = worker
2160
+ })
2161
+ Qless.publish('w:' .. worker, encoded)
2162
+ Qless.publish('log', encoded)
2163
+ self.locks.add(now + grace_period, jid)
2164
+
2165
+ -- If we got any expired locks, then we should increment the
2166
+ -- number of retries for this stage for this bin. The bin is
2167
+ -- midnight of the provided day
2168
+ local bin = now - (now % 86400)
2169
+ redis.call('hincrby',
2170
+ 'ql:s:stats:' .. bin .. ':' .. self.name, 'retries', 1)
2171
+ end
2172
+
2173
+ if invalidate then
2174
+ -- Unset the grace period attribute so that next time we'll send
2175
+ -- the grace period
2176
+ redis.call('hdel', QlessJob.ns .. jid, 'grace', 0)
2177
+
2178
+ -- See how many remaining retries the job has
2179
+ local remaining = tonumber(redis.call(
2180
+ 'hincrby', QlessJob.ns .. jid, 'remaining', -1))
2181
+
2182
+ -- This is where we actually have to time out the work
2183
+ if remaining < 0 then
2184
+ -- Now remove the instance from the schedule, and work queues
2185
+ -- for the queue it's in
2186
+ self.work.remove(jid)
2187
+ self.locks.remove(jid)
2188
+ self.scheduled.remove(jid)
2189
+
2190
+ local group = 'failed-retries-' .. Qless.job(jid):data()['queue']
2191
+ local job = Qless.job(jid)
2192
+ job:history(now, 'failed', {group = group})
2193
+ redis.call('hmset', QlessJob.ns .. jid, 'state', 'failed',
2194
+ 'worker', '',
2195
+ 'expires', '')
2196
+ -- If the failure has not already been set, then set it
2197
+ redis.call('hset', QlessJob.ns .. jid,
2198
+ 'failure', cjson.encode({
2199
+ ['group'] = group,
2200
+ ['message'] =
2201
+ 'Job exhausted retries in queue "' .. self.name .. '"',
2202
+ ['when'] = now,
2203
+ ['worker'] = unpack(job:data('worker'))
2204
+ }))
2205
+
2206
+ -- Add this type of failure to the list of failures
2207
+ redis.call('sadd', 'ql:failures', group)
2208
+ -- And add this particular instance to the failed types
2209
+ redis.call('lpush', 'ql:f:' .. group, jid)
2210
+
2211
+ if redis.call('zscore', 'ql:tracked', jid) ~= false then
2212
+ Qless.publish('failed', jid)
2213
+ end
2214
+ Qless.publish('log', cjson.encode({
2215
+ jid = jid,
2216
+ event = 'failed',
2217
+ group = group,
2218
+ worker = worker,
2219
+ message =
2220
+ 'Job exhausted retries in queue "' .. self.name .. '"'
2221
+ }))
2222
+
2223
+ -- Increment the count of the failed jobs
2224
+ local bin = now - (now % 86400)
2225
+ redis.call('hincrby',
2226
+ 'ql:s:stats:' .. bin .. ':' .. self.name, 'failures', 1)
2227
+ redis.call('hincrby',
2228
+ 'ql:s:stats:' .. bin .. ':' .. self.name, 'failed' , 1)
2229
+ else
2230
+ table.insert(jids, jid)
2231
+ end
2232
+ end
2233
+ end
2234
+
2235
+ return jids
2236
+ end
2237
+
2238
+ -- Forget the provided queues. As in, remove them from the list of known queues
2239
+ function QlessQueue.deregister(...)
2240
+ redis.call('zrem', Qless.ns .. 'queues', unpack(arg))
2241
+ end
2242
+
2243
+ -- Return information about a particular queue, or all queues
2244
+ -- [
2245
+ -- {
2246
+ -- 'name': 'testing',
2247
+ -- 'stalled': 2,
2248
+ -- 'waiting': 5,
2249
+ -- 'running': 5,
2250
+ -- 'scheduled': 10,
2251
+ -- 'depends': 5,
2252
+ -- 'recurring': 0
2253
+ -- }, {
2254
+ -- ...
2255
+ -- }
2256
+ -- ]
2257
+ function QlessQueue.counts(now, name)
2258
+ if name then
2259
+ local queue = Qless.queue(name)
2260
+ local stalled = queue.locks.length(now)
2261
+ -- Check for any scheduled jobs that need to be moved
2262
+ queue:check_scheduled(now, queue.scheduled.length())
2263
+ return {
2264
+ name = name,
2265
+ waiting = queue.work.length(),
2266
+ stalled = stalled,
2267
+ running = queue.locks.length() - stalled,
2268
+ scheduled = queue.scheduled.length(),
2269
+ depends = queue.depends.length(),
2270
+ recurring = queue.recurring.length(),
2271
+ paused = queue:paused()
2272
+ }
2273
+ else
2274
+ local queues = redis.call('zrange', 'ql:queues', 0, -1)
2275
+ local response = {}
2276
+ for index, qname in ipairs(queues) do
2277
+ table.insert(response, QlessQueue.counts(now, qname))
2278
+ end
2279
+ return response
2280
+ end
2281
+ end
2282
+ -- Get all the attributes of this particular job
2283
+ function QlessRecurringJob:data()
2284
+ local job = redis.call(
2285
+ 'hmget', 'ql:r:' .. self.jid, 'jid', 'klass', 'state', 'queue',
2286
+ 'priority', 'interval', 'retries', 'count', 'data', 'tags', 'backlog')
2287
+
2288
+ if not job[1] then
2289
+ return nil
2290
+ end
2291
+
2292
+ return {
2293
+ jid = job[1],
2294
+ klass = job[2],
2295
+ state = job[3],
2296
+ queue = job[4],
2297
+ priority = tonumber(job[5]),
2298
+ interval = tonumber(job[6]),
2299
+ retries = tonumber(job[7]),
2300
+ count = tonumber(job[8]),
2301
+ data = job[9],
2302
+ tags = cjson.decode(job[10]),
2303
+ backlog = tonumber(job[11] or 0)
2304
+ }
2305
+ end
2306
+
2307
+ -- Update the recurring job data. Key can be:
2308
+ -- - priority
2309
+ -- - interval
2310
+ -- - retries
2311
+ -- - data
2312
+ -- - klass
2313
+ -- - queue
2314
+ -- - backlog
2315
+ function QlessRecurringJob:update(now, ...)
2316
+ local options = {}
2317
+ -- Make sure that the job exists
2318
+ if redis.call('exists', 'ql:r:' .. self.jid) ~= 0 then
2319
+ for i = 1, #arg, 2 do
2320
+ local key = arg[i]
2321
+ local value = arg[i+1]
2322
+ assert(value, 'No value provided for ' .. tostring(key))
2323
+ if key == 'priority' or key == 'interval' or key == 'retries' then
2324
+ value = assert(tonumber(value), 'Recur(): Arg "' .. key .. '" must be a number: ' .. tostring(value))
2325
+ -- If the command is 'interval', then we need to update the
2326
+ -- time when it should next be scheduled
2327
+ if key == 'interval' then
2328
+ local queue, interval = unpack(redis.call('hmget', 'ql:r:' .. self.jid, 'queue', 'interval'))
2329
+ Qless.queue(queue).recurring.update(
2330
+ value - tonumber(interval), self.jid)
2331
+ end
2332
+ redis.call('hset', 'ql:r:' .. self.jid, key, value)
2333
+ elseif key == 'data' then
2334
+ assert(cjson.decode(value), 'Recur(): Arg "data" is not JSON-encoded: ' .. tostring(value))
2335
+ redis.call('hset', 'ql:r:' .. self.jid, 'data', value)
2336
+ elseif key == 'klass' then
2337
+ redis.call('hset', 'ql:r:' .. self.jid, 'klass', value)
2338
+ elseif key == 'queue' then
2339
+ local queue_obj = Qless.queue(
2340
+ redis.call('hget', 'ql:r:' .. self.jid, 'queue'))
2341
+ local score = queue_obj.recurring.score(self.jid)
2342
+ queue_obj.recurring.remove(self.jid)
2343
+ Qless.queue(value).recurring.add(score, self.jid)
2344
+ redis.call('hset', 'ql:r:' .. self.jid, 'queue', value)
2345
+ -- If we don't already know about the queue, learn about it
2346
+ if redis.call('zscore', 'ql:queues', value) == false then
2347
+ redis.call('zadd', 'ql:queues', now, value)
2348
+ end
2349
+ elseif key == 'backlog' then
2350
+ value = assert(tonumber(value),
2351
+ 'Recur(): Arg "backlog" not a number: ' .. tostring(value))
2352
+ redis.call('hset', 'ql:r:' .. self.jid, 'backlog', value)
2353
+ else
2354
+ error('Recur(): Unrecognized option "' .. key .. '"')
2355
+ end
2356
+ end
2357
+ return true
2358
+ else
2359
+ error('Recur(): No recurring job ' .. self.jid)
2360
+ end
2361
+ end
2362
+
2363
+ -- Tags this recurring job with the provided tags
2364
+ function QlessRecurringJob:tag(...)
2365
+ local tags = redis.call('hget', 'ql:r:' .. self.jid, 'tags')
2366
+ -- If the job has been canceled / deleted, then return false
2367
+ if tags then
2368
+ -- Decode the json blob, convert to dictionary
2369
+ tags = cjson.decode(tags)
2370
+ local _tags = {}
2371
+ for i,v in ipairs(tags) do _tags[v] = true end
2372
+
2373
+ -- Otherwise, add the job to the sorted set with that tags
2374
+ for i=1,#arg do if _tags[arg[i]] == nil or _tags[arg[i]] == false then table.insert(tags, arg[i]) end end
2375
+
2376
+ tags = cjson.encode(tags)
2377
+ redis.call('hset', 'ql:r:' .. self.jid, 'tags', tags)
2378
+ return tags
2379
+ else
2380
+ error('Tag(): Job ' .. self.jid .. ' does not exist')
2381
+ end
2382
+ end
2383
+
2384
+ -- Removes a tag from the recurring job
2385
+ function QlessRecurringJob:untag(...)
2386
+ -- Get the existing tags
2387
+ local tags = redis.call('hget', 'ql:r:' .. self.jid, 'tags')
2388
+ -- If the job has been canceled / deleted, then return false
2389
+ if tags then
2390
+ -- Decode the json blob, convert to dictionary
2391
+ tags = cjson.decode(tags)
2392
+ local _tags = {}
2393
+ -- Make a hash
2394
+ for i,v in ipairs(tags) do _tags[v] = true end
2395
+ -- Delete these from the hash
2396
+ for i = 1,#arg do _tags[arg[i]] = nil end
2397
+ -- Back into a list
2398
+ local results = {}
2399
+ for i, tag in ipairs(tags) do if _tags[tag] then table.insert(results, tag) end end
2400
+ -- json encode them, set, and return
2401
+ tags = cjson.encode(results)
2402
+ redis.call('hset', 'ql:r:' .. self.jid, 'tags', tags)
2403
+ return tags
2404
+ else
2405
+ error('Untag(): Job ' .. self.jid .. ' does not exist')
2406
+ end
2407
+ end
2408
+
2409
+ -- Stop further occurrences of this job
2410
+ function QlessRecurringJob:unrecur()
2411
+ -- First, find out what queue it was attached to
2412
+ local queue = redis.call('hget', 'ql:r:' .. self.jid, 'queue')
2413
+ if queue then
2414
+ -- Now, delete it from the queue it was attached to, and delete the
2415
+ -- thing itself
2416
+ Qless.queue(queue).recurring.remove(self.jid)
2417
+ redis.call('del', 'ql:r:' .. self.jid)
2418
+ return true
2419
+ else
2420
+ return true
2421
+ end
2422
+ end
2423
+ -- Deregisters these workers from the list of known workers
2424
+ function QlessWorker.deregister(...)
2425
+ redis.call('zrem', 'ql:workers', unpack(arg))
2426
+ end
2427
+
2428
+ -- Provide data about all the workers, or if a specific worker is provided,
2429
+ -- then which jobs that worker is responsible for. If no worker is provided,
2430
+ -- expect a response of the form:
2431
+ --
2432
+ -- [
2433
+ -- # This is sorted by the recency of activity from that worker
2434
+ -- {
2435
+ -- 'name' : 'hostname1-pid1',
2436
+ -- 'jobs' : 20,
2437
+ -- 'stalled': 0
2438
+ -- }, {
2439
+ -- ...
2440
+ -- }
2441
+ -- ]
2442
+ --
2443
+ -- If a worker id is provided, then expect a response of the form:
2444
+ --
2445
+ -- {
2446
+ -- 'jobs': [
2447
+ -- jid1,
2448
+ -- jid2,
2449
+ -- ...
2450
+ -- ], 'stalled': [
2451
+ -- jid1,
2452
+ -- ...
2453
+ -- ]
2454
+ -- }
2455
+ --
2456
+ function QlessWorker.counts(now, worker)
2457
+ -- Clean up all the workers' job lists if they're too old. This is
2458
+ -- determined by the `max-worker-age` configuration, defaulting to the
2459
+ -- last day. Seems like a 'reasonable' default
2460
+ local interval = tonumber(Qless.config.get('max-worker-age', 86400))
2461
+
2462
+ local workers = redis.call('zrangebyscore', 'ql:workers', 0, now - interval)
2463
+ for index, worker in ipairs(workers) do
2464
+ redis.call('del', 'ql:w:' .. worker .. ':jobs')
2465
+ end
2466
+
2467
+ -- And now remove them from the list of known workers
2468
+ redis.call('zremrangebyscore', 'ql:workers', 0, now - interval)
2469
+
2470
+ if worker then
2471
+ return {
2472
+ jobs = redis.call('zrevrangebyscore', 'ql:w:' .. worker .. ':jobs', now + 8640000, now),
2473
+ stalled = redis.call('zrevrangebyscore', 'ql:w:' .. worker .. ':jobs', now, 0)
2474
+ }
2475
+ else
2476
+ local response = {}
2477
+ local workers = redis.call('zrevrange', 'ql:workers', 0, -1)
2478
+ for index, worker in ipairs(workers) do
2479
+ table.insert(response, {
2480
+ name = worker,
2481
+ jobs = redis.call('zcount', 'ql:w:' .. worker .. ':jobs', now, now + 8640000),
2482
+ stalled = redis.call('zcount', 'ql:w:' .. worker .. ':jobs', 0, now)
2483
+ })
2484
+ end
2485
+ return response
2486
+ end
2487
+ end
2488
+
2489
+ LUA_SOURCE
2490
+
2491
+ QLESS_SOURCE = <<-LUA_SOURCE.strip.freeze
10
2492
  local Qless = {
11
2493
  ns = 'ql:'
12
2494
  }
@@ -2035,6 +4517,4 @@ local now = assert(
2035
4517
  return command(now, unpack(ARGV))
2036
4518
 
2037
4519
  LUA_SOURCE
2038
-
2039
- SOURCE_SHA = Digest::SHA1.hexdigest(SOURCE).freeze
2040
4520
  end