pallets 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,3 @@
1
+ -- Remove job from reliability queue
2
+ redis.call("LREM", KEYS[1], 0, ARGV[1])
3
+ redis.call("ZREM", KEYS[2], ARGV[1])
@@ -0,0 +1,6 @@
1
+ -- Remove job from reliability queue
2
+ redis.call("LREM", KEYS[2], 0, ARGV[3])
3
+ redis.call("ZREM", KEYS[3], ARGV[3])
4
+
5
+ -- Add job and its fail time (score) to failed sorted set
6
+ redis.call("ZADD", KEYS[1], ARGV[1], ARGV[2])
@@ -0,0 +1,22 @@
1
+ -- Queue reliability queue jobs that are ready to be retried (their score is
2
+ -- below given value) and remove jobs from sorted set and list
3
+ -- TODO: Add limit of items to get
4
+ local count = redis.call("ZCOUNT", KEYS[1], "-inf", ARGV[1])
5
+ if count > 0 then
6
+ local work = redis.call("ZRANGEBYSCORE", KEYS[1], "-inf", ARGV[1])
7
+ redis.call("LPUSH", KEYS[4], unpack(work))
8
+ redis.call("ZREMRANGEBYSCORE", KEYS[1], "-inf", ARGV[1])
9
+ for _, job in pairs(work) do
10
+ redis.call("LREM", KEYS[2], 0, job)
11
+ end
12
+ end
13
+
14
+ -- Queue jobs that are ready to be retried (their score is below given value) and
15
+ -- remove jobs from sorted set
16
+ -- TODO: Add limit of items to get
17
+ local count = redis.call("ZCOUNT", KEYS[3], "-inf", ARGV[1])
18
+ if count > 0 then
19
+ local work = redis.call("ZRANGEBYSCORE", KEYS[3], "-inf", ARGV[1])
20
+ redis.call("LPUSH", KEYS[4], unpack(work))
21
+ redis.call("ZREMRANGEBYSCORE", KEYS[3], "-inf", ARGV[1])
22
+ end
@@ -0,0 +1,6 @@
1
+ -- Remove job from reliability queue
2
+ redis.call("LREM", KEYS[2], 0, ARGV[3])
3
+ redis.call("ZREM", KEYS[3], ARGV[3])
4
+
5
+ -- Add job and its retry time (score) to retry sorted set
6
+ redis.call("ZADD", KEYS[1], ARGV[1], ARGV[2])
@@ -0,0 +1,11 @@
1
+ -- Add all jobs to sorted set
2
+ redis.call("ZADD", KEYS[1], unpack(ARGV))
3
+
4
+ -- Queue jobs that are ready to be processed (their score is 0) and
5
+ -- remove queued jobs from the sorted set
6
+ local count = redis.call("ZCOUNT", KEYS[1], 0, 0)
7
+ if count > 0 then
8
+ local work = redis.call("ZRANGEBYSCORE", KEYS[1], 0, 0)
9
+ redis.call("LPUSH", KEYS[2], unpack(work))
10
+ redis.call("ZREM", KEYS[1], unpack(work))
11
+ end
@@ -0,0 +1,18 @@
1
+ -- Remove job from reliability queue
2
+ redis.call("LREM", KEYS[3], 0, ARGV[1])
3
+ redis.call("ZREM", KEYS[4], ARGV[1])
4
+
5
+ -- Decrement all jobs from the sorted set
6
+ local all_pending = redis.call("ZRANGE", KEYS[1], 0, -1)
7
+ for score, task in pairs(all_pending) do
8
+ redis.call("ZINCRBY", KEYS[1], -1, task)
9
+ end
10
+
11
+ -- Queue jobs that are ready to be processed (their score is 0) and
12
+ -- remove queued jobs from sorted set
13
+ local count = redis.call("ZCOUNT", KEYS[1], 0, 0)
14
+ if count > 0 then
15
+ local work = redis.call("ZRANGEBYSCORE", KEYS[1], 0, 0)
16
+ redis.call("LPUSH", KEYS[2], unpack(work))
17
+ redis.call("ZREM", KEYS[1], unpack(work))
18
+ end
@@ -0,0 +1,102 @@
1
+ require 'optparse'
2
+
3
+ module Pallets
4
+ class CLI
5
+ def initialize
6
+ parse_options
7
+ setup_signal_handlers
8
+
9
+ @manager = Manager.new
10
+ @signal_queue = Queue.new
11
+ end
12
+
13
+ def run
14
+ Pallets.logger.info 'Starting the awesomeness of Pallets <3'
15
+
16
+ @manager.start
17
+
18
+ loop do
19
+ # This blocks until signals are received
20
+ handle_signal(@signal_queue.pop)
21
+ end
22
+ rescue Interrupt
23
+ Pallets.logger.info 'Shutting down...'
24
+ @manager.shutdown
25
+ Pallets.logger.info 'Buh-bye!'
26
+ exit
27
+ end
28
+
29
+ private
30
+
31
+ def handle_signal(signal)
32
+ case signal
33
+ when 'INT'
34
+ raise Interrupt
35
+ end
36
+ end
37
+
38
+ def parse_options
39
+ OptionParser.new do |opts|
40
+ opts.banner = 'Usage: pallets [options]'
41
+
42
+ opts.on('-b', '--backend NAME', 'Backend to use') do |backend|
43
+ Pallets.configuration.backend = backend
44
+ end
45
+
46
+ opts.on('-c', '--concurrency NUM', Integer, 'Number of workers to start') do |concurrency|
47
+ Pallets.configuration.concurrency = concurrency
48
+ end
49
+
50
+ opts.on('-f', '--max-failures NUM', Integer, 'Maximum allowed number of failures per task') do |max_failures|
51
+ Pallets.configuration.max_failures = max_failures
52
+ end
53
+
54
+ opts.on('-n', '--namespace NAME', 'Namespace to use for backend') do |namespace|
55
+ Pallets.configuration.namespace = namespace
56
+ end
57
+
58
+ opts.on('-p', '--pool-size NUM', Integer, 'Size of backend pool') do |pool_size|
59
+ Pallets.configuration.pool_size = pool_size
60
+ end
61
+
62
+ opts.on('-q', '--quiet', 'Output less logs') do
63
+ Pallets.logger.level = Logger::ERROR
64
+ end
65
+
66
+ opts.on('-r', '--require PATH', 'Path containing workflow definitions') do |path|
67
+ require(path)
68
+ end
69
+
70
+ opts.on('-s', '--serializer NAME', 'Serializer to use') do |serializer|
71
+ Pallets.configuration.serializer = serializer
72
+ end
73
+
74
+ opts.on('-u', '--blocking-timeout NUM', Integer, 'Seconds to block while waiting for work') do |blocking_timeout|
75
+ Pallets.configuration.blocking_timeout = blocking_timeout
76
+ end
77
+
78
+ opts.on('-v', '--verbose', 'Output more logs') do
79
+ Pallets.logger.level = Logger::DEBUG
80
+ end
81
+
82
+ opts.on('--version', 'Version of Pallets') do
83
+ puts "Pallets v#{Pallets::VERSION}"
84
+ exit
85
+ end
86
+
87
+ opts.on_tail('-h', '--help', 'Show this message') do
88
+ puts opts
89
+ exit
90
+ end
91
+ end.parse!
92
+ end
93
+
94
+ def setup_signal_handlers
95
+ %w(INT).each do |signal|
96
+ trap signal do
97
+ @signal_queue.push signal
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,44 @@
1
+ module Pallets
2
+ class Configuration
3
+ # Backend to use for handling workflows
4
+ attr_accessor :backend
5
+
6
+ # Arguments used to initialize the backend
7
+ attr_accessor :backend_args
8
+
9
+ # Number of seconds to block while waiting for jobs
10
+ attr_accessor :blocking_timeout
11
+
12
+ # Number of workers to process jobs
13
+ attr_accessor :concurrency
14
+
15
+ # Number of seconds allowed for a job to be processed. If a job exceeds this
16
+ # period, it is considered failed, and scheduled to be processed again
17
+ attr_accessor :job_timeout
18
+
19
+ # Maximum number of failures allowed per job. Can also be configured on a
20
+ # per task basis
21
+ attr_accessor :max_failures
22
+
23
+ # Namespace used by the backend to store information
24
+ attr_accessor :namespace
25
+
26
+ # Number of connections to the backend
27
+ attr_accessor :pool_size
28
+
29
+ # Serializer used for jobs
30
+ attr_accessor :serializer
31
+
32
+ def initialize
33
+ @backend = :redis
34
+ @backend_args = {}
35
+ @blocking_timeout = 5
36
+ @concurrency = 2
37
+ @job_timeout = 1800 # 30 minutes
38
+ @max_failures = 3
39
+ @namespace = 'pallets'
40
+ @pool_size = 5
41
+ @serializer = :json
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,32 @@
1
+ require 'active_support'
2
+
3
+ module Pallets
4
+ module DSL
5
+ module Workflow
6
+ def task(*args, &block)
7
+ options = args.extract_options!
8
+ name, depends_on = if args.any?
9
+ [args.first, options[:depends_on]]
10
+ else
11
+ options.first
12
+ end
13
+ raise ArgumentError, "A task must have a name" unless name
14
+
15
+ # Handle nils, symbols or arrays consistently
16
+ name = name.to_sym
17
+ dependencies = Array(depends_on).compact.map(&:to_sym)
18
+ graph.add(name, dependencies)
19
+
20
+ class_name = options[:class_name] || name.to_s.camelize
21
+ max_failures = options[:max_failures] || Pallets.configuration.max_failures
22
+
23
+ task_config[name] = {
24
+ 'class_name' => class_name,
25
+ 'max_failures' => max_failures
26
+ }
27
+
28
+ nil
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,4 @@
1
+ module Pallets
2
+ class Shutdown < Interrupt
3
+ end
4
+ end
@@ -0,0 +1,44 @@
1
+ require 'tsort'
2
+
3
+ module Pallets
4
+ class Graph
5
+ include TSort
6
+
7
+ def initialize
8
+ @nodes = {}
9
+ end
10
+
11
+ def add(node, dependencies)
12
+ @nodes[node] = dependencies
13
+ end
14
+
15
+ def parents(node)
16
+ @nodes[node]
17
+ end
18
+
19
+ # Returns nodes topologically sorted, together with their order (number of
20
+ # nodes that have to be executed prior)
21
+ def sorted_with_order
22
+ # Identify groups of nodes that can be executed concurrently
23
+ groups = tsort_each.slice_when { |a, b| parents(a) != parents(b) }
24
+
25
+ # Assign order to each node
26
+ i = 0
27
+ groups.flat_map do |group|
28
+ group_with_order = group.product([i])
29
+ i += group.size
30
+ group_with_order
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ def tsort_each_node(&block)
37
+ @nodes.each_key(&block)
38
+ end
39
+
40
+ def tsort_each_child(node, &block)
41
+ @nodes.fetch(node).each(&block)
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,57 @@
1
+ module Pallets
2
+ class Manager
3
+ attr_reader :workers, :timeout
4
+
5
+ def initialize(concurrency: Pallets.configuration.concurrency)
6
+ @workers = concurrency.times.map { Worker.new(self) }
7
+ @scheduler = Scheduler.new(self)
8
+ @lock = Mutex.new
9
+ @needs_to_stop = false
10
+ end
11
+
12
+ def start
13
+ @workers.each(&:start)
14
+ @scheduler.start
15
+ end
16
+
17
+ # Attempt to gracefully shutdown every worker. If any is still busy after
18
+ # the given timeout, hard shutdown it. We don't need to worry about lost
19
+ # jobs caused by the hard shutdown; there is a reliability list that
20
+ # contains all active jobs, which will be automatically requeued upon next
21
+ # start
22
+ def shutdown
23
+ @needs_to_stop = true
24
+
25
+ @workers.reverse_each(&:graceful_shutdown)
26
+ @scheduler.shutdown
27
+
28
+ Pallets.logger.info 'Waiting for workers to finish their jobs...'
29
+ # Wait for 10 seconds at most
30
+ 10.times do
31
+ return if @workers.empty?
32
+ sleep 1
33
+ end
34
+
35
+ @workers.reverse_each(&:hard_shutdown)
36
+ # Ensure Pallets::Shutdown got propagated and workers finished; if not,
37
+ # their threads will be killed anyway when the manager quits
38
+ sleep 0.5
39
+ end
40
+
41
+ def remove_worker(worker)
42
+ @lock.synchronize { @workers.delete(worker) }
43
+ end
44
+
45
+ def replace_worker(worker)
46
+ @lock.synchronize do
47
+ @workers.delete(worker)
48
+
49
+ return if @needs_to_stop
50
+
51
+ worker = Worker.new(self)
52
+ @workers << worker
53
+ worker.start
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,26 @@
1
+ module Pallets
2
+ class Pool
3
+ def initialize(size)
4
+ raise ArgumentError, 'Pool needs a block to initialize' unless block_given?
5
+
6
+ @queue = Queue.new
7
+ @size = size
8
+ size.times { @queue << yield }
9
+ end
10
+
11
+ def size
12
+ @queue.size
13
+ end
14
+
15
+ def execute
16
+ raise ArgumentError, 'Pool needs a block to execute' unless block_given?
17
+
18
+ begin
19
+ item = @queue.pop
20
+ yield item
21
+ ensure
22
+ @queue << item
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,53 @@
1
+ module Pallets
2
+ class Scheduler
3
+ def initialize(manager)
4
+ @manager = manager
5
+ @needs_to_stop = false
6
+ @thread = nil
7
+ end
8
+
9
+ def start
10
+ @thread ||= Thread.new { work }
11
+ end
12
+
13
+ def shutdown
14
+ @needs_to_stop = true
15
+
16
+ return unless @thread
17
+ @thread.join
18
+ end
19
+
20
+ def needs_to_stop?
21
+ @needs_to_stop
22
+ end
23
+
24
+ def id
25
+ "S#{@thread.object_id.to_s(36)}".upcase if @thread
26
+ end
27
+
28
+ private
29
+
30
+ def work
31
+ loop do
32
+ break if needs_to_stop?
33
+
34
+ backend.reschedule_all(Time.now.to_f)
35
+ wait_a_bit
36
+ end
37
+ end
38
+
39
+ def wait_a_bit
40
+ # Wait for roughly 10 seconds
41
+ # We don't want to block the entire polling interval, since we want to
42
+ # deal with shutdowns synchronously and as fast as possible
43
+ 10.times do
44
+ break if needs_to_stop?
45
+ sleep 1
46
+ end
47
+ end
48
+
49
+ def backend
50
+ @backend ||= Pallets.backend
51
+ end
52
+ end
53
+ end