pallets 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +36 -0
- data/.rspec +2 -0
- data/.travis.yml +12 -0
- data/CONTRIBUTING.md +19 -0
- data/Gemfile +4 -0
- data/LICENSE +21 -0
- data/README.md +91 -0
- data/Rakefile +6 -0
- data/bin/console +14 -0
- data/bin/pallets +7 -0
- data/bin/setup +8 -0
- data/lib/pallets.rb +53 -0
- data/lib/pallets/backends/base.rb +38 -0
- data/lib/pallets/backends/redis.rb +109 -0
- data/lib/pallets/backends/scripts/discard.lua +3 -0
- data/lib/pallets/backends/scripts/give_up.lua +6 -0
- data/lib/pallets/backends/scripts/reschedule_all.lua +22 -0
- data/lib/pallets/backends/scripts/retry.lua +6 -0
- data/lib/pallets/backends/scripts/run_workflow.lua +11 -0
- data/lib/pallets/backends/scripts/save.lua +18 -0
- data/lib/pallets/cli.rb +102 -0
- data/lib/pallets/configuration.rb +44 -0
- data/lib/pallets/dsl/workflow.rb +32 -0
- data/lib/pallets/errors.rb +4 -0
- data/lib/pallets/graph.rb +44 -0
- data/lib/pallets/manager.rb +57 -0
- data/lib/pallets/pool.rb +26 -0
- data/lib/pallets/scheduler.rb +53 -0
- data/lib/pallets/serializers/base.rb +13 -0
- data/lib/pallets/serializers/json.rb +15 -0
- data/lib/pallets/task.rb +12 -0
- data/lib/pallets/version.rb +3 -0
- data/lib/pallets/worker.rb +109 -0
- data/lib/pallets/workflow.rb +58 -0
- data/pallets.gemspec +28 -0
- metadata +177 -0
@@ -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,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
|
data/lib/pallets/cli.rb
ADDED
@@ -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,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
|
data/lib/pallets/pool.rb
ADDED
@@ -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
|