pallets 0.6.0 → 0.11.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a2894dd7dff299d2cb4f2c9bf67b7be01f27adc93cfc7729fbbac8c3c9988722
4
- data.tar.gz: ce6d91fc194a7441557bd3250f994b65dbb35dd29d7adbb0a08c3be699a88cf8
3
+ metadata.gz: c7ee0fbd91594f23bc84b81116f867490620f580223195f69fa9ff4f72be2558
4
+ data.tar.gz: b681bc765933ce36d3732a04eae3b9b7d60c2695b3d47f5704ebcfe96e3f884b
5
5
  SHA512:
6
- metadata.gz: 37ff1a501b2661b0a63d9d0c3844d323952e07e05135661169138dd3ee88f906115c3db13b33205dbcc7faf94af6ac2dc4a8cc2e2e49982789ca5e75cca0ea2a
7
- data.tar.gz: 5cc7c495ca3b2463ef2d8d168f0bb43f73c6c7d7a970d66b21ebe22068db389e272819e2d2f7bb49c6fc2835f6b22a8b3991421e5501dc763a94c5aecd5aca88
6
+ metadata.gz: 8dfe6d23000df032d7f65fd81bd7907a3600240eaa6f111a161d1b2fb6d234440c8cc9219203a094ae139847ba00011939b50943e5eaaa04c11dde9128c3bb94
7
+ data.tar.gz: c054b1abd6d25b0408fa8427550751c8c8d41cd204bed65a968302d2ad5be98ac5ef020a6d096efc52ec58e9fef6f66456528ea72cdf423666916dd3a1394a5f
@@ -0,0 +1 @@
1
+ github: linkyndy
@@ -4,11 +4,8 @@ services:
4
4
  - redis-server
5
5
  cache: bundler
6
6
  rvm:
7
- - 2.4.6
8
- - 2.5.5
9
- - 2.6.3
10
- before_install:
11
- # Bundler 2.0 needs a newer RubyGems
12
- - gem update --system
13
- - gem install bundler
7
+ - 2.4.10
8
+ - 2.5.8
9
+ - 2.6.6
10
+ - 2.7.1
14
11
  script: bundle exec rspec
@@ -6,6 +6,45 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.11.0] - 2020-10-09
10
+ ### Added
11
+ - JSON logger (#62)
12
+
13
+ ### Changed
14
+ - fix for leaking Redis connections (#61)
15
+
16
+ ## [0.10.0] - 2020-08-30
17
+ ### Added
18
+ - configure polling interval for scheduler (#60)
19
+
20
+ ### Changed
21
+ - handle persisting unforseen worker errors more gracefully (#59)
22
+ - add initial wait to scheduler startup (#60)
23
+
24
+ ## [0.9.0] - 2020-07-05
25
+ ### Added
26
+ - limit number of jobs in given up set by number (#56)
27
+ - job duration and metadata to all task logs (#57)
28
+
29
+ ### Changed
30
+ - remove all related workflow keys when giving up on a job (#55)
31
+ - support redis-rb ~> 4.2 (#58)
32
+
33
+ ### Removed
34
+ - support for configuring custom loggers (#57)
35
+
36
+ ## [0.8.0] - 2020-06-09
37
+ ### Added
38
+ - sync output in CLI (#49)
39
+ - support for configuring custom loggers (#50)
40
+
41
+ ### Changed
42
+ - improve job scheduling using jobmasks (#52)
43
+
44
+ ## [0.7.0] - 2020-01-19
45
+ ### Added
46
+ - support for Ruby 2.7 (#46)
47
+
9
48
  ## [0.6.0] - 2019-09-02
10
49
  ### Added
11
50
  - define task aliases in order to reuse tasks within a workflow definition (#44)
data/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2019 Andrei Horak
3
+ Copyright (c) 2020 Andrei Horak
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
data/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![Build Status](https://travis-ci.com/linkyndy/pallets.svg?branch=master)](https://travis-ci.com/linkyndy/pallets)
4
4
 
5
- Toy workflow engine, written in Ruby
5
+ Simple and reliable workflow engine, written in Ruby
6
6
 
7
7
  ## It is plain simple!
8
8
 
@@ -11,10 +11,10 @@ Toy workflow engine, written in Ruby
11
11
  require 'pallets'
12
12
 
13
13
  class MyWorkflow < Pallets::Workflow
14
- task Foo
15
- task Bar => Foo
16
- task Baz => Foo
17
- task Qux => [Bar, Baz]
14
+ task 'Foo'
15
+ task 'Bar' => 'Foo'
16
+ task 'Baz' => 'Foo'
17
+ task 'Qux' => ['Bar', 'Baz']
18
18
  end
19
19
 
20
20
  class Foo < Pallets::Task
@@ -120,7 +120,7 @@ end
120
120
 
121
121
  ## Motivation
122
122
 
123
- The main reason for Pallet's existence was the need of a fast, simple and reliable workflow engine, one that is easily extensible with various backends and serializer, one that does not lose your data and one that is intelligent enough to concurrently schedule a workflow's tasks.
123
+ The main reason for Pallets' existence was the need of a fast, simple and reliable workflow engine, one that is easily extensible with various backends and serializer, one that does not lose your data and one that is intelligent enough to concurrently schedule a workflow's tasks.
124
124
 
125
125
  ## Status
126
126
 
@@ -43,7 +43,7 @@ end
43
43
 
44
44
  class Volatile < Pallets::Task
45
45
  def run
46
- raise 'I am ràndomly failing' if [true, false].sample
46
+ raise 'I am randomly failing' if [true, false].sample
47
47
  end
48
48
  end
49
49
 
@@ -38,6 +38,7 @@ module Pallets
38
38
  cls.new(
39
39
  blocking_timeout: configuration.blocking_timeout,
40
40
  failed_job_lifespan: configuration.failed_job_lifespan,
41
+ failed_job_max_count: configuration.failed_job_max_count,
41
42
  job_timeout: configuration.job_timeout,
42
43
  pool_size: configuration.pool_size,
43
44
  **configuration.backend_args
@@ -62,8 +63,4 @@ module Pallets
62
63
  formatter: Pallets::Logger::Formatters::Pretty.new
63
64
  )
64
65
  end
65
-
66
- def self.logger=(logger)
67
- @logger = logger
68
- end
69
66
  end
@@ -6,12 +6,12 @@ module Pallets
6
6
  raise NotImplementedError
7
7
  end
8
8
 
9
- def get_context(workflow_id)
9
+ def get_context(wfid)
10
10
  raise NotImplementedError
11
11
  end
12
12
 
13
13
  # Saves a job after successfully processing it
14
- def save(workflow_id, job, context_buffer)
14
+ def save(wfid, jid, job, context_buffer)
15
15
  raise NotImplementedError
16
16
  end
17
17
 
@@ -20,8 +20,13 @@ module Pallets
20
20
  raise NotImplementedError
21
21
  end
22
22
 
23
+ # Discards malformed job
24
+ def discard(job)
25
+ raise NotImplementedError
26
+ end
27
+
23
28
  # Gives up job after repeteadly failing to process it
24
- def give_up(job, old_job)
29
+ def give_up(wfid, job, old_job)
25
30
  raise NotImplementedError
26
31
  end
27
32
 
@@ -29,7 +34,7 @@ module Pallets
29
34
  raise NotImplementedError
30
35
  end
31
36
 
32
- def run_workflow(workflow_id, jobs_with_dependencies, context)
37
+ def run_workflow(wfid, jobs, jobmasks, context)
33
38
  raise NotImplementedError
34
39
  end
35
40
  end
@@ -9,12 +9,15 @@ module Pallets
9
9
  RETRY_SET_KEY = 'retry-set'
10
10
  GIVEN_UP_SET_KEY = 'given-up-set'
11
11
  WORKFLOW_QUEUE_KEY = 'workflow-queue:%s'
12
+ JOBMASKS_KEY = 'jobmasks:%s'
13
+ JOBMASK_KEY = 'jobmask:%s'
12
14
  CONTEXT_KEY = 'context:%s'
13
15
  REMAINING_KEY = 'remaining:%s'
14
16
 
15
- def initialize(blocking_timeout:, failed_job_lifespan:, job_timeout:, pool_size:, **options)
17
+ def initialize(blocking_timeout:, failed_job_lifespan:, failed_job_max_count:, job_timeout:, pool_size:, **options)
16
18
  @blocking_timeout = blocking_timeout
17
19
  @failed_job_lifespan = failed_job_lifespan
20
+ @failed_job_max_count = failed_job_max_count
18
21
  @job_timeout = job_timeout
19
22
  @pool = Pallets::Pool.new(pool_size) { ::Redis.new(options) }
20
23
 
@@ -41,11 +44,11 @@ module Pallets
41
44
  end
42
45
  end
43
46
 
44
- def save(wfid, job, context_buffer)
47
+ def save(wfid, jid, job, context_buffer)
45
48
  @pool.execute do |client|
46
49
  client.evalsha(
47
50
  @scripts['save'],
48
- [WORKFLOW_QUEUE_KEY % wfid, QUEUE_KEY, RELIABILITY_QUEUE_KEY, RELIABILITY_SET_KEY, CONTEXT_KEY % wfid, REMAINING_KEY % wfid],
51
+ [WORKFLOW_QUEUE_KEY % wfid, QUEUE_KEY, RELIABILITY_QUEUE_KEY, RELIABILITY_SET_KEY, CONTEXT_KEY % wfid, REMAINING_KEY % wfid, JOBMASK_KEY % jid, JOBMASKS_KEY % wfid],
49
52
  context_buffer.to_a << job
50
53
  )
51
54
  end
@@ -61,12 +64,22 @@ module Pallets
61
64
  end
62
65
  end
63
66
 
64
- def give_up(job, old_job)
67
+ def discard(job)
65
68
  @pool.execute do |client|
66
69
  client.evalsha(
67
- @scripts['give_up'],
70
+ @scripts['discard'],
68
71
  [GIVEN_UP_SET_KEY, RELIABILITY_QUEUE_KEY, RELIABILITY_SET_KEY],
69
- [Time.now.to_f, job, old_job, Time.now.to_f - @failed_job_lifespan]
72
+ [Time.now.to_f, job, Time.now.to_f - @failed_job_lifespan, @failed_job_max_count]
73
+ )
74
+ end
75
+ end
76
+
77
+ def give_up(wfid, job, old_job)
78
+ @pool.execute do |client|
79
+ client.evalsha(
80
+ @scripts['give_up'],
81
+ [GIVEN_UP_SET_KEY, RELIABILITY_QUEUE_KEY, RELIABILITY_SET_KEY, JOBMASKS_KEY % wfid, WORKFLOW_QUEUE_KEY % wfid, REMAINING_KEY % wfid, CONTEXT_KEY % wfid],
82
+ [Time.now.to_f, job, old_job, Time.now.to_f - @failed_job_lifespan, @failed_job_max_count]
70
83
  )
71
84
  end
72
85
  end
@@ -81,13 +94,15 @@ module Pallets
81
94
  end
82
95
  end
83
96
 
84
- def run_workflow(wfid, jobs_with_order, context_buffer)
97
+ def run_workflow(wfid, jobs, jobmasks, context_buffer)
85
98
  @pool.execute do |client|
86
99
  client.multi do
100
+ jobmasks.each { |jid, jobmask| client.zadd(JOBMASK_KEY % jid, jobmask) }
101
+ client.sadd(JOBMASKS_KEY % wfid, jobmasks.map { |jid, _| JOBMASK_KEY % jid }) unless jobmasks.empty?
87
102
  client.evalsha(
88
103
  @scripts['run_workflow'],
89
104
  [WORKFLOW_QUEUE_KEY % wfid, QUEUE_KEY, REMAINING_KEY % wfid],
90
- jobs_with_order
105
+ jobs
91
106
  )
92
107
  client.hmset(CONTEXT_KEY % wfid, *context_buffer.to_a) unless context_buffer.empty?
93
108
  end
@@ -0,0 +1,11 @@
1
+ -- Remove job from reliability queue
2
+ redis.call("LREM", KEYS[2], 0, ARGV[2])
3
+ redis.call("ZREM", KEYS[3], ARGV[2])
4
+
5
+ -- Add job and its fail time (score) to failed sorted set
6
+ redis.call("ZADD", KEYS[1], ARGV[1], ARGV[2])
7
+
8
+ -- Remove any jobs that have been given up long enough ago (their score is
9
+ -- below given value) and make sure the number of jobs is capped
10
+ redis.call("ZREMRANGEBYSCORE", KEYS[1], "-inf", ARGV[3])
11
+ redis.call("ZREMRANGEBYRANK", KEYS[1], 0, -ARGV[4] - 1)
@@ -5,6 +5,11 @@ redis.call("ZREM", KEYS[3], ARGV[3])
5
5
  -- Add job and its fail time (score) to failed sorted set
6
6
  redis.call("ZADD", KEYS[1], ARGV[1], ARGV[2])
7
7
 
8
+ -- Remove all related workflow keys
9
+ local keys = redis.call("SMEMBERS", KEYS[4])
10
+ redis.call("DEL", KEYS[4], KEYS[5], KEYS[6], KEYS[7], unpack(keys))
11
+
8
12
  -- Remove any jobs that have been given up long enough ago (their score is
9
- -- below given value)
13
+ -- below given value) and make sure the number of jobs is capped
10
14
  redis.call("ZREMRANGEBYSCORE", KEYS[1], "-inf", ARGV[4])
15
+ redis.call("ZREMRANGEBYRANK", KEYS[1], 0, -ARGV[5] - 1)
@@ -6,9 +6,8 @@ redis.call("SET", KEYS[3], eta)
6
6
 
7
7
  -- Queue jobs that are ready to be processed (their score is 0) and
8
8
  -- remove queued jobs from the sorted set
9
- local count = redis.call("ZCOUNT", KEYS[1], 0, 0)
10
- if count > 0 then
11
- local work = redis.call("ZRANGEBYSCORE", KEYS[1], 0, 0)
9
+ local work = redis.call("ZRANGEBYSCORE", KEYS[1], 0, 0)
10
+ if #work > 0 then
12
11
  redis.call("LPUSH", KEYS[2], unpack(work))
13
12
  redis.call("ZREM", KEYS[1], unpack(work))
14
13
  end
@@ -10,24 +10,21 @@ if #ARGV > 0 then
10
10
  redis.call("HMSET", KEYS[5], unpack(ARGV))
11
11
  end
12
12
 
13
- -- Decrement all jobs from the sorted set
14
- local all_pending = redis.call("ZRANGE", KEYS[1], 0, -1)
15
- for score, task in pairs(all_pending) do
16
- redis.call("ZINCRBY", KEYS[1], -1, task)
17
- end
13
+ -- Decrement jobs from the sorted set by applying a jobmask
14
+ redis.call("ZUNIONSTORE", KEYS[1], 2, KEYS[1], KEYS[7])
15
+ redis.call("DEL", KEYS[7])
18
16
 
19
17
  -- Queue jobs that are ready to be processed (their score is 0) and
20
18
  -- remove queued jobs from sorted set
21
- local count = redis.call("ZCOUNT", KEYS[1], 0, 0)
22
- if count > 0 then
23
- local work = redis.call("ZRANGEBYSCORE", KEYS[1], 0, 0)
19
+ local work = redis.call("ZRANGEBYSCORE", KEYS[1], 0, 0)
20
+ if #work > 0 then
24
21
  redis.call("LPUSH", KEYS[2], unpack(work))
25
22
  redis.call("ZREM", KEYS[1], unpack(work))
26
23
  end
27
24
 
28
- -- Decrement ETA and remove it together with the context if all tasks have
29
- -- been processed (ETA is 0)
30
- redis.call("DECR", KEYS[6])
31
- if tonumber(redis.call("GET", KEYS[6])) == 0 then
32
- redis.call("DEL", KEYS[5], KEYS[6])
25
+ -- Decrement ETA and remove it together with the context and jobmasks if all
26
+ -- tasks have been processed (ETA is 0) or if workflow has been given up (ETA is -1)
27
+ local remaining = redis.call("DECR", KEYS[6])
28
+ if remaining <= 0 then
29
+ redis.call("DEL", KEYS[5], KEYS[6], KEYS[8])
33
30
  end
@@ -1,5 +1,7 @@
1
1
  require 'optparse'
2
2
 
3
+ $stdout.sync = true
4
+
3
5
  module Pallets
4
6
  class CLI
5
7
  def initialize
@@ -61,10 +63,18 @@ module Pallets
61
63
  Pallets.configuration.max_failures = max_failures
62
64
  end
63
65
 
66
+ opts.on('-i', '--scheduler-polling-interval NUM', Integer, 'Seconds between scheduler backend polls') do |scheduler_polling_interval|
67
+ Pallets.configuration.scheduler_polling_interval = scheduler_polling_interval
68
+ end
69
+
64
70
  opts.on('-l', '--failed-job-lifespan NUM', Integer, 'Seconds a job stays in the given up set') do |failed_job_lifespan|
65
71
  Pallets.configuration.failed_job_lifespan = failed_job_lifespan
66
72
  end
67
73
 
74
+ opts.on('-m', '--failed-job-max-count NUM', Integer, 'Maximum number of jobs in the given up set') do |failed_job_max_count|
75
+ Pallets.configuration.failed_job_max_count = failed_job_max_count
76
+ end
77
+
68
78
  opts.on('-p', '--pool-size NUM', Integer, 'Size of backend pool') do |pool_size|
69
79
  Pallets.configuration.pool_size = pool_size
70
80
  end
@@ -16,6 +16,10 @@ module Pallets
16
16
  # this period, jobs will be permanently deleted
17
17
  attr_accessor :failed_job_lifespan
18
18
 
19
+ # Maximum number of failed jobs that can be in the given up set. When this
20
+ # number is reached, the oldest jobs will be permanently deleted
21
+ attr_accessor :failed_job_max_count
22
+
19
23
  # Number of seconds allowed for a job to be processed. If a job exceeds this
20
24
  # period, it is considered failed, and scheduled to be processed again
21
25
  attr_accessor :job_timeout
@@ -27,6 +31,12 @@ module Pallets
27
31
  # Number of connections to the backend
28
32
  attr_writer :pool_size
29
33
 
34
+ # Number of seconds at which the scheduler checks whether there are jobs
35
+ # due to be (re)processed. Note that this interval is per process; it might
36
+ # require tweaking in case of running multiple Pallets instances, so that
37
+ # the backend is not polled too often
38
+ attr_accessor :scheduler_polling_interval
39
+
30
40
  # Serializer used for jobs
31
41
  attr_accessor :serializer
32
42
 
@@ -45,8 +55,10 @@ module Pallets
45
55
  @blocking_timeout = 5
46
56
  @concurrency = 2
47
57
  @failed_job_lifespan = 7_776_000 # 3 months
58
+ @failed_job_max_count = 1_000
48
59
  @job_timeout = 1_800 # 30 minutes
49
60
  @max_failures = 3
61
+ @scheduler_polling_interval = 10
50
62
  @serializer = :json
51
63
  @middleware = default_middleware
52
64
  end
@@ -1,7 +1,13 @@
1
1
  module Pallets
2
2
  module DSL
3
3
  module Workflow
4
- def task(arg, as: nil, depends_on: nil, max_failures: nil, &block)
4
+ def task(arg=nil, as: nil, depends_on: nil, max_failures: nil, **kwargs)
5
+ # Have to work more to keep Pallets' nice DSL valid in Ruby 2.7
6
+ arg = !kwargs.empty? ? kwargs : arg
7
+ raise ArgumentError, 'Task is incorrectly defined. It must receive '\
8
+ 'either a name, or a name => dependencies pair as '\
9
+ 'the first argument' unless arg
10
+
5
11
  klass, dependencies = case arg
6
12
  when Hash
7
13
  # The `task Foo => Bar` notation
@@ -24,18 +24,11 @@ module Pallets
24
24
  nodes.empty?
25
25
  end
26
26
 
27
- # Returns nodes topologically sorted, together with their order (number of
28
- # nodes that have to be executed prior)
29
- def sorted_with_order
30
- # Identify groups of nodes that can be executed concurrently
31
- groups = tsort_each.slice_when { |a, b| parents(a) != parents(b) }
32
-
33
- # Assign order to each node
34
- i = 0
35
- groups.flat_map do |group|
36
- group_with_order = group.product([i])
37
- i += group.size
38
- group_with_order
27
+ def each
28
+ return enum_for(__method__) unless block_given?
29
+
30
+ tsort_each do |node|
31
+ yield(node, parents(node))
39
32
  end
40
33
  end
41
34
 
@@ -5,18 +5,37 @@ module Pallets
5
5
  class Logger < ::Logger
6
6
  # Overwrite severity methods to add metadata capabilities
7
7
  %i[debug info warn error fatal unknown].each do |severity|
8
- define_method severity do |message, metadata = {}|
9
- return super(message) if metadata.empty?
8
+ define_method severity do |message|
9
+ metadata = Thread.current[:pallets_log_metadata]
10
+ return super(message) if metadata.nil?
10
11
 
11
- formatted_metadata = ' ' + metadata.map { |k, v| "#{k}=#{v}" }.join(' ')
12
- super(formatted_metadata) { message }
12
+ super(metadata) { message }
13
13
  end
14
14
  end
15
15
 
16
+ def with_metadata(hash)
17
+ Thread.current[:pallets_log_metadata] = hash
18
+ yield
19
+ ensure
20
+ Thread.current[:pallets_log_metadata] = nil
21
+ end
22
+
16
23
  module Formatters
17
24
  class Pretty < ::Logger::Formatter
18
25
  def call(severity, time, metadata, message)
19
- "#{time.utc.iso8601(4)} pid=#{Process.pid}#{metadata} #{severity}: #{message}\n"
26
+ formatted_metadata = ' ' + metadata.map { |k, v| "#{k}=#{v}" }.join(' ') if metadata
27
+ "#{time.utc.iso8601(4)} pid=#{Process.pid}#{formatted_metadata} #{severity}: #{message}\n"
28
+ end
29
+ end
30
+
31
+ class Json < ::Logger::Formatter
32
+ def call(severity, time, metadata, message)
33
+ {
34
+ timestamp: time.utc.iso8601(4),
35
+ pid: Process.pid,
36
+ severity: severity,
37
+ message: message
38
+ }.merge(metadata || {}).to_json + "\n"
20
39
  end
21
40
  end
22
41
  end
@@ -3,8 +3,9 @@ module Pallets
3
3
  attr_reader :workers, :scheduler
4
4
 
5
5
  def initialize(concurrency: Pallets.configuration.concurrency)
6
- @workers = concurrency.times.map { Worker.new(self) }
7
- @scheduler = Scheduler.new(self)
6
+ @backend = Pallets.backend
7
+ @workers = concurrency.times.map { Worker.new(self, @backend) }
8
+ @scheduler = Scheduler.new(self, @backend)
8
9
  @lock = Mutex.new
9
10
  @needs_to_stop = false
10
11
  end
@@ -48,7 +49,7 @@ module Pallets
48
49
 
49
50
  return if @needs_to_stop
50
51
 
51
- worker = Worker.new(self)
52
+ worker = Worker.new(self, @backend)
52
53
  @workers << worker
53
54
  worker.start
54
55
  end
@@ -2,14 +2,21 @@ module Pallets
2
2
  module Middleware
3
3
  class JobLogger
4
4
  def self.call(worker, job, context)
5
- Pallets.logger.info 'Started', extract_metadata(worker.id, job)
6
- result = yield
7
- Pallets.logger.info 'Done', extract_metadata(worker.id, job)
8
- result
9
- rescue => ex
10
- Pallets.logger.warn "#{ex.class.name}: #{ex.message}", extract_metadata(worker.id, job)
11
- Pallets.logger.warn ex.backtrace.join("\n"), extract_metadata(worker.id, job) unless ex.backtrace.nil?
12
- raise
5
+ start_time = current_time
6
+
7
+ Pallets.logger.with_metadata(extract_metadata(worker.id, job)) do
8
+ begin
9
+ Pallets.logger.info 'Started'
10
+ result = yield
11
+ Pallets.logger.info "Done in #{(current_time - start_time).round(3)}s"
12
+ result
13
+ rescue => ex
14
+ Pallets.logger.warn "Failed after #{(current_time - start_time).round(3)}s"
15
+ Pallets.logger.warn "#{ex.class.name}: #{ex.message}"
16
+ Pallets.logger.warn ex.backtrace.join("\n") unless ex.backtrace.nil?
17
+ raise
18
+ end
19
+ end
13
20
  end
14
21
 
15
22
  def self.extract_metadata(wid, job)
@@ -21,6 +28,10 @@ module Pallets
21
28
  tsk: job['task_class'],
22
29
  }
23
30
  end
31
+
32
+ def self.current_time
33
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
34
+ end
24
35
  end
25
36
  end
26
37
  end
@@ -1,13 +1,14 @@
1
1
  module Pallets
2
2
  class Scheduler
3
- def initialize(manager)
3
+ def initialize(manager, backend)
4
4
  @manager = manager
5
+ @backend = backend
5
6
  @needs_to_stop = false
6
7
  @thread = nil
7
8
  end
8
9
 
9
10
  def start
10
- @thread ||= Thread.new { work }
11
+ @thread ||= Thread.new { wait_initial_bit; work }
11
12
  end
12
13
 
13
14
  def shutdown
@@ -35,23 +36,25 @@ module Pallets
35
36
  loop do
36
37
  break if needs_to_stop?
37
38
 
38
- backend.reschedule_all(Time.now.to_f)
39
+ @backend.reschedule_all(Time.now.to_f)
39
40
  wait_a_bit
40
41
  end
41
42
  end
42
43
 
43
- def wait_a_bit
44
- # Wait for roughly 10 seconds
44
+ def wait_initial_bit
45
+ # Randomly wait a bit before starting working, so that multiple processes
46
+ # will not hit the backend all at once
47
+ wait_a_bit(rand(Pallets.configuration.scheduler_polling_interval))
48
+ end
49
+
50
+ def wait_a_bit(seconds = Pallets.configuration.scheduler_polling_interval)
51
+ # Wait for roughly the configured number of seconds
45
52
  # We don't want to block the entire polling interval, since we want to
46
53
  # deal with shutdowns synchronously and as fast as possible
47
- 10.times do
54
+ seconds.times do
48
55
  break if needs_to_stop?
49
56
  sleep 1
50
57
  end
51
58
  end
52
-
53
- def backend
54
- @backend ||= Pallets.backend
55
- end
56
59
  end
57
60
  end
@@ -1,3 +1,3 @@
1
1
  module Pallets
2
- VERSION = "0.6.0"
2
+ VERSION = "0.11.0"
3
3
  end
@@ -1,9 +1,8 @@
1
1
  module Pallets
2
2
  class Worker
3
- attr_reader :manager
4
-
5
- def initialize(manager)
3
+ def initialize(manager, backend)
6
4
  @manager = manager
5
+ @backend = backend
7
6
  @current_job = nil
8
7
  @needs_to_stop = false
9
8
  @thread = nil
@@ -40,7 +39,7 @@ module Pallets
40
39
  loop do
41
40
  break if needs_to_stop?
42
41
 
43
- @current_job = backend.pick
42
+ @current_job = @backend.pick
44
43
  # No need to requeue because of the reliability queue
45
44
  break if needs_to_stop?
46
45
  next if @current_job.nil?
@@ -53,8 +52,10 @@ module Pallets
53
52
  rescue Pallets::Shutdown
54
53
  @manager.remove_worker(self)
55
54
  rescue => ex
56
- Pallets.logger.error "#{ex.class.name}: #{ex.message}", wid: id
57
- Pallets.logger.error ex.backtrace.join("\n"), wid: id unless ex.backtrace.nil?
55
+ Pallets.logger.error "#{ex.class.name}: #{ex.message}"
56
+ Pallets.logger.error ex.backtrace.join("\n") unless ex.backtrace.nil?
57
+ # Do not flood the process in case of persisting unforeseen errors
58
+ sleep 1
58
59
  @manager.replace_worker(self)
59
60
  end
60
61
 
@@ -64,13 +65,13 @@ module Pallets
64
65
  rescue
65
66
  # We ensure only valid jobs are created. If something fishy reaches this
66
67
  # point, just give up on it
67
- backend.give_up(job, job)
68
- Pallets.logger.error "Could not deserialize #{job}. Gave up job", wid: id
68
+ @backend.discard(job)
69
+ Pallets.logger.error "Could not deserialize #{job}. Gave up job"
69
70
  return
70
71
  end
71
72
 
72
73
  context = Context[
73
- serializer.load_context(backend.get_context(job_hash['wfid']))
74
+ serializer.load_context(@backend.get_context(job_hash['wfid']))
74
75
  ]
75
76
 
76
77
  task_class = Pallets::Util.constantize(job_hash["task_class"])
@@ -101,9 +102,9 @@ module Pallets
101
102
  ))
102
103
  if failures < job_hash['max_failures']
103
104
  retry_at = Time.now.to_f + backoff_in_seconds(failures)
104
- backend.retry(new_job, job, retry_at)
105
+ @backend.retry(new_job, job, retry_at)
105
106
  else
106
- backend.give_up(new_job, job)
107
+ @backend.give_up(job_hash['wfid'], new_job, job)
107
108
  end
108
109
  end
109
110
 
@@ -112,21 +113,17 @@ module Pallets
112
113
  'given_up_at' => Time.now.to_f,
113
114
  'reason' => 'returned_false'
114
115
  ))
115
- backend.give_up(new_job, job)
116
+ @backend.give_up(job_hash['wfid'], new_job, job)
116
117
  end
117
118
 
118
119
  def handle_job_success(context, job, job_hash)
119
- backend.save(job_hash['wfid'], job, serializer.dump_context(context.buffer))
120
+ @backend.save(job_hash['wfid'], job_hash['jid'], job, serializer.dump_context(context.buffer))
120
121
  end
121
122
 
122
123
  def backoff_in_seconds(count)
123
124
  count ** 4 + rand(6..10)
124
125
  end
125
126
 
126
- def backend
127
- @backend ||= Pallets.backend
128
- end
129
-
130
127
  def serializer
131
128
  @serializer ||= Pallets.serializer
132
129
  end
@@ -20,7 +20,7 @@ module Pallets
20
20
  raise WorkflowError, "#{self.class.name} has no tasks. Workflows "\
21
21
  "must contain at least one task" if self.class.graph.empty?
22
22
 
23
- backend.run_workflow(id, jobs_with_order, serializer.dump_context(context.buffer))
23
+ backend.run_workflow(id, *prepare_jobs, serializer.dump_context(context.buffer))
24
24
  id
25
25
  end
26
26
 
@@ -30,11 +30,21 @@ module Pallets
30
30
 
31
31
  private
32
32
 
33
- def jobs_with_order
34
- self.class.graph.sorted_with_order.map do |task_alias, order|
35
- job = serializer.dump(construct_job(task_alias))
36
- [order, job]
33
+ def prepare_jobs
34
+ jobs = []
35
+ jobmasks = Hash.new { |h, k| h[k] = [] }
36
+ acc = {}
37
+
38
+ self.class.graph.each do |task_alias, dependencies|
39
+ job_hash = construct_job(task_alias)
40
+ acc[task_alias] = job_hash['jid']
41
+ job = serializer.dump(job_hash)
42
+
43
+ jobs << [dependencies.size, job]
44
+ dependencies.each { |d| jobmasks[acc[d]] << [-1, job] }
37
45
  end
46
+
47
+ [jobs, jobmasks]
38
48
  end
39
49
 
40
50
  def construct_job(task_alias)
@@ -9,8 +9,8 @@ Gem::Specification.new do |spec|
9
9
  spec.authors = ['Andrei Horak']
10
10
  spec.email = ['linkyndy@gmail.com']
11
11
 
12
- spec.summary = 'Toy workflow engine, written in Ruby'
13
- spec.description = 'Toy workflow engine, written in Ruby'
12
+ spec.summary = 'Simple and reliable workflow engine, written in Ruby'
13
+ spec.description = 'Simple and reliable workflow engine, written in Ruby'
14
14
  spec.homepage = 'https://github.com/linkyndy/pallets'
15
15
  spec.license = 'MIT'
16
16
 
@@ -20,6 +20,6 @@ Gem::Specification.new do |spec|
20
20
 
21
21
  spec.required_ruby_version = '>= 2.4'
22
22
 
23
- spec.add_dependency 'redis'
23
+ spec.add_dependency 'redis', '~> 4.2'
24
24
  spec.add_dependency 'msgpack'
25
25
  end
metadata CHANGED
@@ -1,29 +1,29 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pallets
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrei Horak
8
- autorequire:
8
+ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2019-09-02 00:00:00.000000000 Z
11
+ date: 2020-10-09 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: redis
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
- - - ">="
17
+ - - "~>"
18
18
  - !ruby/object:Gem::Version
19
- version: '0'
19
+ version: '4.2'
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
- - - ">="
24
+ - - "~>"
25
25
  - !ruby/object:Gem::Version
26
- version: '0'
26
+ version: '4.2'
27
27
  - !ruby/object:Gem::Dependency
28
28
  name: msgpack
29
29
  requirement: !ruby/object:Gem::Requirement
@@ -38,7 +38,7 @@ dependencies:
38
38
  - - ">="
39
39
  - !ruby/object:Gem::Version
40
40
  version: '0'
41
- description: Toy workflow engine, written in Ruby
41
+ description: Simple and reliable workflow engine, written in Ruby
42
42
  email:
43
43
  - linkyndy@gmail.com
44
44
  executables:
@@ -46,6 +46,7 @@ executables:
46
46
  extensions: []
47
47
  extra_rdoc_files: []
48
48
  files:
49
+ - ".github/FUNDING.yml"
49
50
  - ".gitignore"
50
51
  - ".rspec"
51
52
  - ".travis.yml"
@@ -66,6 +67,7 @@ files:
66
67
  - lib/pallets.rb
67
68
  - lib/pallets/backends/base.rb
68
69
  - lib/pallets/backends/redis.rb
70
+ - lib/pallets/backends/scripts/discard.lua
69
71
  - lib/pallets/backends/scripts/give_up.lua
70
72
  - lib/pallets/backends/scripts/reschedule_all.lua
71
73
  - lib/pallets/backends/scripts/retry.lua
@@ -97,7 +99,7 @@ homepage: https://github.com/linkyndy/pallets
97
99
  licenses:
98
100
  - MIT
99
101
  metadata: {}
100
- post_install_message:
102
+ post_install_message:
101
103
  rdoc_options: []
102
104
  require_paths:
103
105
  - lib
@@ -112,8 +114,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
112
114
  - !ruby/object:Gem::Version
113
115
  version: '0'
114
116
  requirements: []
115
- rubygems_version: 3.0.3
116
- signing_key:
117
+ rubygems_version: 3.1.2
118
+ signing_key:
117
119
  specification_version: 4
118
- summary: Toy workflow engine, written in Ruby
120
+ summary: Simple and reliable workflow engine, written in Ruby
119
121
  test_files: []