pallets 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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