resque-job-chain 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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +15 -0
- data/LICENSE +21 -0
- data/README.md +116 -0
- data/lib/resque/job_chain/builder.rb +40 -0
- data/lib/resque/job_chain/chain.rb +33 -0
- data/lib/resque/job_chain/configuration.rb +37 -0
- data/lib/resque/job_chain/context.rb +15 -0
- data/lib/resque/job_chain/executor.rb +80 -0
- data/lib/resque/job_chain/observable.rb +21 -0
- data/lib/resque/job_chain/persistence.rb +115 -0
- data/lib/resque/job_chain/step.rb +51 -0
- data/lib/resque/job_chain/strategies/abort.rb +9 -0
- data/lib/resque/job_chain/strategies/base.rb +14 -0
- data/lib/resque/job_chain/strategies/retry.rb +9 -0
- data/lib/resque/job_chain/strategies/skip.rb +9 -0
- data/lib/resque/job_chain/version.rb +5 -0
- data/lib/resque/job_chain.rb +55 -0
- data/lib/resque/plugins/job_chain.rb +32 -0
- data/lib/resque-job-chain.rb +1 -0
- data/lua/advance_chain.lua +40 -0
- data/lua/fail_step.lua +84 -0
- data/lua/start_chain.lua +38 -0
- metadata +98 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 7dfd67579d991ba4ffe0e52761b622ff1870698aef56171db0897ca54cedfa7c
|
|
4
|
+
data.tar.gz: 134f3550a163edfcf4cf480e1c422898c06214bb727ab4443795c197576ca496
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: d95bf665b569b900720538bc0014c5fcd89939eae8bd9d8f93d500fc0c64808471e7ac567cc4b2347d58708c073f86883cd965159c652beb8b3984ba7d15dc50
|
|
7
|
+
data.tar.gz: d5a0e5ee4aacf8e31c25b528ee0ac8a9aed6baaf0aa7b95d315ab3a34fa8a81d689f75e59571bf9c9957a9d8adc9ae1c9db1b49212de5e0d203975793a8893ca
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.1.0] - Unreleased
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- Chain declaration via Builder DSL
|
|
7
|
+
- Step command pattern with serialization
|
|
8
|
+
- Atomic Redis state management via Lua scripts (start, advance, fail)
|
|
9
|
+
- Error strategies: abort, retry, skip
|
|
10
|
+
- Shared context between steps
|
|
11
|
+
- Observer callbacks for lifecycle events
|
|
12
|
+
- Resque plugin hooks (after_perform, on_failure)
|
|
13
|
+
- Idempotent chain start (duplicate prevention)
|
|
14
|
+
- Configurable TTL for completed chain state
|
|
15
|
+
- Optional StatsD and logger integration
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Duarte Reis
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# resque-job-chain
|
|
2
|
+
|
|
3
|
+
A Resque plugin for declaring ordered chains of jobs with shared context, configurable error strategies, and atomic Redis state tracking.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
gem 'resque-job-chain'
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
### Declaring a chain
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
chain = Resque::JobChain.build("order-processing-#{order_id}") do |c|
|
|
17
|
+
c.step FetchOrderJob, order_id: order_id
|
|
18
|
+
c.step ValidateInventoryJob, order_id: order_id, on_failure: :skip
|
|
19
|
+
c.step ChargePaymentJob, order_id: order_id, on_failure: :retry, max_attempts: 3
|
|
20
|
+
c.step ShipOrderJob, order_id: order_id
|
|
21
|
+
c.step SendConfirmationJob, order_id: order_id
|
|
22
|
+
|
|
23
|
+
c.on_failure :abort
|
|
24
|
+
c.context requested_at: Time.now.iso8601
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
chain.start!
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
### Job classes
|
|
31
|
+
|
|
32
|
+
Extend `Resque::Plugins::JobChain` in your job classes to enable chain progression:
|
|
33
|
+
|
|
34
|
+
```ruby
|
|
35
|
+
class ChargePaymentJob
|
|
36
|
+
extend Resque::Plugins::JobChain
|
|
37
|
+
@queue = :payments
|
|
38
|
+
|
|
39
|
+
def self.perform(params)
|
|
40
|
+
result = PaymentGateway.charge(params['order_id'], params['amount'])
|
|
41
|
+
|
|
42
|
+
# Write to chain context for downstream steps
|
|
43
|
+
Resque::JobChain.update_context(params['_chain_id'], payment_ref: result.reference)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Each step receives a hash with:
|
|
49
|
+
- Its declared args
|
|
50
|
+
- The current chain context (accumulated from prior steps)
|
|
51
|
+
- `_chain_id` and `_step_index` metadata
|
|
52
|
+
|
|
53
|
+
### Error strategies
|
|
54
|
+
|
|
55
|
+
Per-step or chain-level:
|
|
56
|
+
|
|
57
|
+
| Strategy | Behavior |
|
|
58
|
+
|----------|----------|
|
|
59
|
+
| `:abort` | Mark chain as failed, stop execution (default) |
|
|
60
|
+
| `:retry` | Re-enqueue the step, up to `max_attempts` |
|
|
61
|
+
| `:skip` | Mark step as skipped, advance to next |
|
|
62
|
+
|
|
63
|
+
### Configuration
|
|
64
|
+
|
|
65
|
+
```ruby
|
|
66
|
+
Resque::JobChain.configure do |c|
|
|
67
|
+
c.default_strategy = :abort
|
|
68
|
+
c.default_max_attempts = 3
|
|
69
|
+
c.completed_chain_ttl = 86_400 # 24h
|
|
70
|
+
c.statsd_client = MyStatsD
|
|
71
|
+
c.logger = Rails.logger
|
|
72
|
+
|
|
73
|
+
c.on(:chain_started) { |id| log("Chain #{id} started") }
|
|
74
|
+
c.on(:step_completed) { |id, idx| log("Chain #{id} step #{idx} done") }
|
|
75
|
+
c.on(:chain_completed) { |id| log("Chain #{id} completed") }
|
|
76
|
+
c.on(:chain_failed) { |id, idx| alert("Chain #{id} failed at step #{idx}") }
|
|
77
|
+
end
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Querying chain status
|
|
81
|
+
|
|
82
|
+
```ruby
|
|
83
|
+
Resque::JobChain.status('order-processing-42')
|
|
84
|
+
# => {"status"=>"running", "current_step"=>"2", "total_steps"=>"5", ...}
|
|
85
|
+
|
|
86
|
+
Resque::JobChain.context('order-processing-42')
|
|
87
|
+
# => {"requested_at"=>"2024-01-01T00:00:00Z", "payment_ref"=>"pay_abc"}
|
|
88
|
+
|
|
89
|
+
Resque::JobChain.active_chains
|
|
90
|
+
# => ["order-processing-42", "order-processing-43"]
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Design
|
|
94
|
+
|
|
95
|
+
Built using Gang of Four patterns:
|
|
96
|
+
|
|
97
|
+
- **Builder** for chain declaration DSL
|
|
98
|
+
- **Command** for step encapsulation
|
|
99
|
+
- **Chain of Responsibility** for sequential execution
|
|
100
|
+
- **Strategy** for pluggable error handling
|
|
101
|
+
- **Template Method** for execution lifecycle
|
|
102
|
+
- **Observer** for lifecycle callbacks
|
|
103
|
+
|
|
104
|
+
All state transitions are atomic via Redis Lua scripts.
|
|
105
|
+
|
|
106
|
+
## Development
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
docker-compose up -d
|
|
110
|
+
REDIS_URL=redis://localhost:6380/15 bundle exec rspec
|
|
111
|
+
bundle exec rubocop
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## License
|
|
115
|
+
|
|
116
|
+
MIT
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
module Resque
|
|
2
|
+
module JobChain
|
|
3
|
+
class Builder
|
|
4
|
+
def initialize(chain_id)
|
|
5
|
+
@chain_id = chain_id
|
|
6
|
+
@steps = []
|
|
7
|
+
@default_strategy = nil
|
|
8
|
+
@initial_context = {}
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def step(job_class, **options)
|
|
12
|
+
step_args = options.except(:on_failure, :max_attempts, :queue)
|
|
13
|
+
@steps << Step.new(
|
|
14
|
+
job_class,
|
|
15
|
+
args: step_args,
|
|
16
|
+
on_failure: options[:on_failure],
|
|
17
|
+
max_attempts: options[:max_attempts],
|
|
18
|
+
queue: options[:queue]
|
|
19
|
+
)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def on_failure(strategy)
|
|
23
|
+
@default_strategy = strategy
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def context(**initial_data)
|
|
27
|
+
@initial_context.merge!(initial_data)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def to_chain
|
|
31
|
+
Chain.new(
|
|
32
|
+
id: @chain_id,
|
|
33
|
+
steps: @steps,
|
|
34
|
+
default_strategy: @default_strategy,
|
|
35
|
+
initial_context: @initial_context
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
module Resque
|
|
2
|
+
module JobChain
|
|
3
|
+
class Chain
|
|
4
|
+
attr_reader :id, :steps, :default_strategy, :initial_context
|
|
5
|
+
|
|
6
|
+
def initialize(id:, steps:, default_strategy: nil, initial_context: {})
|
|
7
|
+
@id = id
|
|
8
|
+
@steps = steps
|
|
9
|
+
@default_strategy = default_strategy || JobChain.configuration.default_strategy
|
|
10
|
+
@initial_context = initial_context
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def start!
|
|
14
|
+
started = Persistence.start_chain(
|
|
15
|
+
chain_id: id,
|
|
16
|
+
steps: steps,
|
|
17
|
+
context: initial_context
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
if started
|
|
21
|
+
steps.first.enqueue(id, 0, initial_context)
|
|
22
|
+
Observable.notify(:chain_started, id)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
started
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def total_steps
|
|
29
|
+
steps.length
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
module Resque
|
|
2
|
+
module JobChain
|
|
3
|
+
class Configuration
|
|
4
|
+
# @return [Symbol] Default error strategy (:abort, :retry, :skip).
|
|
5
|
+
attr_accessor :default_strategy
|
|
6
|
+
|
|
7
|
+
# @return [Integer] Default max retry attempts for the :retry strategy.
|
|
8
|
+
attr_accessor :default_max_attempts
|
|
9
|
+
|
|
10
|
+
# @return [Integer] TTL in seconds for completed chain state in Redis.
|
|
11
|
+
attr_accessor :completed_chain_ttl
|
|
12
|
+
|
|
13
|
+
# @return [#increment, #gauge, nil] Optional StatsD-compatible client.
|
|
14
|
+
attr_accessor :statsd_client
|
|
15
|
+
|
|
16
|
+
# @return [#info, #error, nil] Optional logger for chain lifecycle events.
|
|
17
|
+
attr_accessor :logger
|
|
18
|
+
|
|
19
|
+
def initialize
|
|
20
|
+
@default_strategy = :abort
|
|
21
|
+
@default_max_attempts = 3
|
|
22
|
+
@completed_chain_ttl = 86_400
|
|
23
|
+
@statsd_client = nil
|
|
24
|
+
@logger = nil
|
|
25
|
+
@observers = Hash.new { |h, k| h[k] = [] }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def on(event, &block)
|
|
29
|
+
@observers[event.to_sym] << block
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def observers_for(event)
|
|
33
|
+
@observers[event.to_sym]
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
module Resque
|
|
2
|
+
module JobChain
|
|
3
|
+
class Executor
|
|
4
|
+
def initialize(chain_id, step_index)
|
|
5
|
+
@chain_id = chain_id
|
|
6
|
+
@step_index = step_index
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def after_step
|
|
10
|
+
result = Persistence.advance(@chain_id, @step_index)
|
|
11
|
+
|
|
12
|
+
case result
|
|
13
|
+
when 'completed'
|
|
14
|
+
Observable.notify(:chain_completed, @chain_id)
|
|
15
|
+
when 'advanced'
|
|
16
|
+
enqueue_next_step
|
|
17
|
+
Observable.notify(:step_completed, @chain_id, @step_index)
|
|
18
|
+
when 'stale'
|
|
19
|
+
Observable.notify(:step_stale, @chain_id, @step_index)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
result
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def on_failure(error)
|
|
26
|
+
step = Persistence.load_step(@chain_id, @step_index)
|
|
27
|
+
strategy = resolve_strategy(step)
|
|
28
|
+
max_attempts = step.max_attempts || JobChain.configuration.default_max_attempts
|
|
29
|
+
|
|
30
|
+
result = Persistence.fail_step(
|
|
31
|
+
@chain_id, @step_index, error.message, strategy, max_attempts
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
handle_failure_result(result, step)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def resolve_strategy(step)
|
|
40
|
+
step.on_failure || JobChain.configuration.default_strategy
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def handle_failure_result(result, step)
|
|
44
|
+
case result
|
|
45
|
+
when 'aborted', 'exhausted'
|
|
46
|
+
Observable.notify(:chain_failed, @chain_id, @step_index)
|
|
47
|
+
when /\Aretrying:(\d+)\z/
|
|
48
|
+
re_enqueue_current_step(step)
|
|
49
|
+
Observable.notify(:step_retrying, @chain_id, @step_index, ::Regexp.last_match(1).to_i)
|
|
50
|
+
when 'skipped'
|
|
51
|
+
handle_skipped_step
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
result
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def handle_skipped_step
|
|
58
|
+
state = Persistence.load_state(@chain_id)
|
|
59
|
+
if state['status'] == 'completed'
|
|
60
|
+
Observable.notify(:chain_completed, @chain_id)
|
|
61
|
+
else
|
|
62
|
+
enqueue_next_step
|
|
63
|
+
Observable.notify(:step_skipped, @chain_id, @step_index)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def enqueue_next_step
|
|
68
|
+
next_index = @step_index + 1
|
|
69
|
+
step = Persistence.load_step(@chain_id, next_index)
|
|
70
|
+
context = Persistence.load_context(@chain_id)
|
|
71
|
+
step.enqueue(@chain_id, next_index, context)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def re_enqueue_current_step(step)
|
|
75
|
+
context = Persistence.load_context(@chain_id)
|
|
76
|
+
step.enqueue(@chain_id, @step_index, context)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module Resque
|
|
2
|
+
module JobChain
|
|
3
|
+
module Observable
|
|
4
|
+
module_function
|
|
5
|
+
|
|
6
|
+
def notify(event, *args)
|
|
7
|
+
listeners = JobChain.configuration.observers_for(event)
|
|
8
|
+
listeners.each { |listener| listener.call(*args) }
|
|
9
|
+
|
|
10
|
+
log_event(event, args)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def log_event(event, args)
|
|
14
|
+
logger = JobChain.configuration.logger
|
|
15
|
+
return unless logger
|
|
16
|
+
|
|
17
|
+
logger.info("[resque-job-chain] #{event}: #{args.inspect}")
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
|
|
3
|
+
module Resque
|
|
4
|
+
module JobChain
|
|
5
|
+
module Persistence
|
|
6
|
+
START_SCRIPT = File.read(File.expand_path('../../../lua/start_chain.lua', __dir__))
|
|
7
|
+
ADVANCE_SCRIPT = File.read(File.expand_path('../../../lua/advance_chain.lua', __dir__))
|
|
8
|
+
FAIL_SCRIPT = File.read(File.expand_path('../../../lua/fail_step.lua', __dir__))
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def start_chain(chain_id:, steps:, context:)
|
|
13
|
+
steps_json = steps.map { |s| JSON.generate(s.to_h) }
|
|
14
|
+
result = raw_redis.eval(
|
|
15
|
+
START_SCRIPT,
|
|
16
|
+
keys: [state_key(chain_id), steps_key(chain_id), context_key(chain_id), active_key],
|
|
17
|
+
argv: [
|
|
18
|
+
steps.length.to_s,
|
|
19
|
+
JSON.generate(stringify_keys(context)),
|
|
20
|
+
chain_id,
|
|
21
|
+
JSON.generate(steps_json),
|
|
22
|
+
Time.now.utc.iso8601
|
|
23
|
+
]
|
|
24
|
+
)
|
|
25
|
+
result == 1
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def advance(chain_id, step_index)
|
|
29
|
+
raw_redis.eval(
|
|
30
|
+
ADVANCE_SCRIPT,
|
|
31
|
+
keys: [state_key(chain_id), active_key],
|
|
32
|
+
argv: [
|
|
33
|
+
step_index.to_s,
|
|
34
|
+
Time.now.utc.iso8601,
|
|
35
|
+
chain_id,
|
|
36
|
+
JobChain.configuration.completed_chain_ttl.to_s
|
|
37
|
+
]
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def fail_step(chain_id, step_index, error_message, strategy, max_attempts)
|
|
42
|
+
raw_redis.eval(
|
|
43
|
+
FAIL_SCRIPT,
|
|
44
|
+
keys: [state_key(chain_id), active_key],
|
|
45
|
+
argv: [
|
|
46
|
+
step_index.to_s,
|
|
47
|
+
error_message.to_s,
|
|
48
|
+
strategy.to_s,
|
|
49
|
+
max_attempts.to_s,
|
|
50
|
+
Time.now.utc.iso8601,
|
|
51
|
+
chain_id,
|
|
52
|
+
JobChain.configuration.completed_chain_ttl.to_s
|
|
53
|
+
]
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def load_state(chain_id)
|
|
58
|
+
raw_redis.hgetall(state_key(chain_id))
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def load_step(chain_id, step_index)
|
|
62
|
+
json = raw_redis.lindex(steps_key(chain_id), step_index)
|
|
63
|
+
return nil unless json
|
|
64
|
+
|
|
65
|
+
Step.from_h(JSON.parse(json))
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def load_context(chain_id)
|
|
69
|
+
json = raw_redis.get(context_key(chain_id))
|
|
70
|
+
return {} unless json
|
|
71
|
+
|
|
72
|
+
JSON.parse(json)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def merge_context(chain_id, updates)
|
|
76
|
+
current = load_context(chain_id)
|
|
77
|
+
merged = current.merge(stringify_keys(updates))
|
|
78
|
+
raw_redis.set(context_key(chain_id), JSON.generate(merged))
|
|
79
|
+
merged
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def active_chains
|
|
83
|
+
raw_redis.smembers(active_key)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def state_key(chain_id)
|
|
87
|
+
"#{namespace}:job_chain:#{chain_id}:state"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def steps_key(chain_id)
|
|
91
|
+
"#{namespace}:job_chain:#{chain_id}:steps"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def context_key(chain_id)
|
|
95
|
+
"#{namespace}:job_chain:#{chain_id}:context"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def active_key
|
|
99
|
+
"#{namespace}:job_chain:active"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def namespace
|
|
103
|
+
Resque.redis.namespace
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def raw_redis
|
|
107
|
+
Resque.redis.redis
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def stringify_keys(hash)
|
|
111
|
+
hash.each_with_object({}) { |(k, v), h| h[k.to_s] = v }
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
|
|
3
|
+
module Resque
|
|
4
|
+
module JobChain
|
|
5
|
+
class Step
|
|
6
|
+
attr_reader :job_class, :args, :on_failure, :max_attempts, :queue
|
|
7
|
+
|
|
8
|
+
def initialize(job_class, args: {}, on_failure: nil, max_attempts: nil, queue: nil)
|
|
9
|
+
@job_class = job_class
|
|
10
|
+
@args = args
|
|
11
|
+
@on_failure = on_failure
|
|
12
|
+
@max_attempts = max_attempts
|
|
13
|
+
@queue = queue || job_class.instance_variable_get(:@queue)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def enqueue(chain_id, step_index, context)
|
|
17
|
+
merged = stringify_keys(args)
|
|
18
|
+
.merge(stringify_keys(context))
|
|
19
|
+
.merge('_chain_id' => chain_id, '_step_index' => step_index)
|
|
20
|
+
|
|
21
|
+
Resque.enqueue_to(queue, job_class, merged)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def to_h
|
|
25
|
+
{
|
|
26
|
+
'class' => job_class.to_s,
|
|
27
|
+
'args' => stringify_keys(args),
|
|
28
|
+
'on_failure' => on_failure&.to_s,
|
|
29
|
+
'max_attempts' => max_attempts,
|
|
30
|
+
'queue' => queue.to_s
|
|
31
|
+
}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.from_h(hash)
|
|
35
|
+
new(
|
|
36
|
+
Object.const_get(hash['class']),
|
|
37
|
+
args: hash['args'] || {},
|
|
38
|
+
on_failure: hash['on_failure']&.to_sym,
|
|
39
|
+
max_attempts: hash['max_attempts'],
|
|
40
|
+
queue: hash['queue']&.to_sym
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def stringify_keys(hash)
|
|
47
|
+
hash.each_with_object({}) { |(k, v), h| h[k.to_s] = v }
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
module Resque
|
|
2
|
+
module JobChain
|
|
3
|
+
module Strategies
|
|
4
|
+
def self.resolve(name)
|
|
5
|
+
case name.to_sym
|
|
6
|
+
when :abort then Abort
|
|
7
|
+
when :retry then Retry
|
|
8
|
+
when :skip then Skip
|
|
9
|
+
else raise ArgumentError, "Unknown strategy: #{name}"
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
require 'resque'
|
|
2
|
+
require_relative 'job_chain/version'
|
|
3
|
+
require_relative 'job_chain/configuration'
|
|
4
|
+
require_relative 'job_chain/step'
|
|
5
|
+
require_relative 'job_chain/chain'
|
|
6
|
+
require_relative 'job_chain/builder'
|
|
7
|
+
require_relative 'job_chain/context'
|
|
8
|
+
require_relative 'job_chain/persistence'
|
|
9
|
+
require_relative 'job_chain/executor'
|
|
10
|
+
require_relative 'job_chain/observable'
|
|
11
|
+
require_relative 'job_chain/strategies/base'
|
|
12
|
+
require_relative 'job_chain/strategies/abort'
|
|
13
|
+
require_relative 'job_chain/strategies/retry'
|
|
14
|
+
require_relative 'job_chain/strategies/skip'
|
|
15
|
+
require_relative 'plugins/job_chain'
|
|
16
|
+
|
|
17
|
+
module Resque
|
|
18
|
+
module JobChain
|
|
19
|
+
class << self
|
|
20
|
+
def configuration
|
|
21
|
+
@configuration ||= Configuration.new
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def configure
|
|
25
|
+
yield(configuration)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def reset_configuration!
|
|
29
|
+
@configuration = Configuration.new
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def build(chain_id, &block)
|
|
33
|
+
builder = Builder.new(chain_id)
|
|
34
|
+
block.call(builder)
|
|
35
|
+
builder.to_chain
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def status(chain_id)
|
|
39
|
+
Persistence.load_state(chain_id)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def context(chain_id)
|
|
43
|
+
Persistence.load_context(chain_id)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def update_context(chain_id, **updates)
|
|
47
|
+
Persistence.merge_context(chain_id, updates)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def active_chains
|
|
51
|
+
Persistence.active_chains
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
module Resque
|
|
2
|
+
module Plugins
|
|
3
|
+
module JobChain
|
|
4
|
+
def after_perform_job_chain(*args)
|
|
5
|
+
chain_id, step_index = extract_chain_meta(args)
|
|
6
|
+
return unless chain_id
|
|
7
|
+
|
|
8
|
+
executor = Resque::JobChain::Executor.new(chain_id, step_index)
|
|
9
|
+
executor.after_step
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def on_failure_job_chain(exception, *args)
|
|
13
|
+
chain_id, step_index = extract_chain_meta(args)
|
|
14
|
+
return unless chain_id
|
|
15
|
+
|
|
16
|
+
executor = Resque::JobChain::Executor.new(chain_id, step_index)
|
|
17
|
+
executor.on_failure(exception)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def extract_chain_meta(args)
|
|
23
|
+
hash = args.find { |a| a.is_a?(Hash) }
|
|
24
|
+
return [nil, nil] unless hash
|
|
25
|
+
|
|
26
|
+
chain_id = hash['_chain_id'] || hash[:_chain_id]
|
|
27
|
+
step_index = hash['_step_index'] || hash[:_step_index]
|
|
28
|
+
[chain_id, step_index&.to_i]
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
require 'resque/job_chain'
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
-- Atomically marks current step complete and advances chain.
|
|
2
|
+
--
|
|
3
|
+
-- KEYS[1] = chain state HASH key
|
|
4
|
+
-- KEYS[2] = active chains SET key
|
|
5
|
+
--
|
|
6
|
+
-- ARGV[1] = current step index (expected)
|
|
7
|
+
-- ARGV[2] = current timestamp ISO8601
|
|
8
|
+
-- ARGV[3] = chain_id
|
|
9
|
+
-- ARGV[4] = ttl_seconds (for completed chains)
|
|
10
|
+
--
|
|
11
|
+
-- Returns: "advanced" | "completed" | "stale"
|
|
12
|
+
|
|
13
|
+
local current = redis.call("HGET", KEYS[1], "current_step")
|
|
14
|
+
if current ~= ARGV[1] then
|
|
15
|
+
return "stale"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
local total = tonumber(redis.call("HGET", KEYS[1], "total_steps"))
|
|
19
|
+
local next_step = tonumber(ARGV[1]) + 1
|
|
20
|
+
|
|
21
|
+
redis.call("HSET", KEYS[1],
|
|
22
|
+
"step_status:" .. ARGV[1], "completed",
|
|
23
|
+
"step_completed_at:" .. ARGV[1], ARGV[2],
|
|
24
|
+
"updated_at", ARGV[2])
|
|
25
|
+
|
|
26
|
+
if next_step >= total then
|
|
27
|
+
redis.call("HSET", KEYS[1],
|
|
28
|
+
"status", "completed",
|
|
29
|
+
"completed_at", ARGV[2])
|
|
30
|
+
redis.call("SREM", KEYS[2], ARGV[3])
|
|
31
|
+
redis.call("EXPIRE", KEYS[1], tonumber(ARGV[4]))
|
|
32
|
+
return "completed"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
redis.call("HSET", KEYS[1],
|
|
36
|
+
"current_step", tostring(next_step),
|
|
37
|
+
"step_status:" .. tostring(next_step), "running",
|
|
38
|
+
"step_started_at:" .. tostring(next_step), ARGV[2])
|
|
39
|
+
|
|
40
|
+
return "advanced"
|
data/lua/fail_step.lua
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
-- Atomically handles step failure based on the configured strategy.
|
|
2
|
+
--
|
|
3
|
+
-- KEYS[1] = chain state HASH key
|
|
4
|
+
-- KEYS[2] = active chains SET key
|
|
5
|
+
--
|
|
6
|
+
-- ARGV[1] = step index
|
|
7
|
+
-- ARGV[2] = error message
|
|
8
|
+
-- ARGV[3] = strategy ("abort" | "retry" | "skip")
|
|
9
|
+
-- ARGV[4] = max_attempts (for retry)
|
|
10
|
+
-- ARGV[5] = timestamp ISO8601
|
|
11
|
+
-- ARGV[6] = chain_id
|
|
12
|
+
-- ARGV[7] = ttl_seconds
|
|
13
|
+
--
|
|
14
|
+
-- Returns: "aborted" | "retrying:{N}" | "exhausted" | "skipped" | "stale"
|
|
15
|
+
|
|
16
|
+
local current = redis.call("HGET", KEYS[1], "current_step")
|
|
17
|
+
if current ~= ARGV[1] then
|
|
18
|
+
return "stale"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
local total = tonumber(redis.call("HGET", KEYS[1], "total_steps"))
|
|
22
|
+
|
|
23
|
+
if ARGV[3] == "abort" then
|
|
24
|
+
redis.call("HSET", KEYS[1],
|
|
25
|
+
"status", "failed",
|
|
26
|
+
"step_status:" .. ARGV[1], "failed",
|
|
27
|
+
"error_message", ARGV[2],
|
|
28
|
+
"error_step", ARGV[1],
|
|
29
|
+
"updated_at", ARGV[5])
|
|
30
|
+
redis.call("SREM", KEYS[2], ARGV[6])
|
|
31
|
+
redis.call("EXPIRE", KEYS[1], tonumber(ARGV[7]))
|
|
32
|
+
return "aborted"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
if ARGV[3] == "retry" then
|
|
36
|
+
local attempts_key = "attempts:" .. ARGV[1]
|
|
37
|
+
local current_attempts = redis.call("HGET", KEYS[1], attempts_key)
|
|
38
|
+
local attempts = tonumber(current_attempts or "0") + 1
|
|
39
|
+
local max = tonumber(ARGV[4])
|
|
40
|
+
|
|
41
|
+
if attempts >= max then
|
|
42
|
+
redis.call("HSET", KEYS[1],
|
|
43
|
+
"status", "failed",
|
|
44
|
+
"step_status:" .. ARGV[1], "failed",
|
|
45
|
+
attempts_key, tostring(attempts),
|
|
46
|
+
"error_message", ARGV[2],
|
|
47
|
+
"error_step", ARGV[1],
|
|
48
|
+
"updated_at", ARGV[5])
|
|
49
|
+
redis.call("SREM", KEYS[2], ARGV[6])
|
|
50
|
+
redis.call("EXPIRE", KEYS[1], tonumber(ARGV[7]))
|
|
51
|
+
return "exhausted"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
redis.call("HSET", KEYS[1],
|
|
55
|
+
attempts_key, tostring(attempts),
|
|
56
|
+
"step_status:" .. ARGV[1], "running",
|
|
57
|
+
"updated_at", ARGV[5])
|
|
58
|
+
return "retrying:" .. tostring(attempts)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
if ARGV[3] == "skip" then
|
|
62
|
+
local next_step = tonumber(ARGV[1]) + 1
|
|
63
|
+
|
|
64
|
+
redis.call("HSET", KEYS[1],
|
|
65
|
+
"step_status:" .. ARGV[1], "skipped",
|
|
66
|
+
"updated_at", ARGV[5])
|
|
67
|
+
|
|
68
|
+
if next_step >= total then
|
|
69
|
+
redis.call("HSET", KEYS[1],
|
|
70
|
+
"status", "completed",
|
|
71
|
+
"completed_at", ARGV[5])
|
|
72
|
+
redis.call("SREM", KEYS[2], ARGV[6])
|
|
73
|
+
redis.call("EXPIRE", KEYS[1], tonumber(ARGV[7]))
|
|
74
|
+
return "skipped"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
redis.call("HSET", KEYS[1],
|
|
78
|
+
"current_step", tostring(next_step),
|
|
79
|
+
"step_status:" .. tostring(next_step), "running",
|
|
80
|
+
"step_started_at:" .. tostring(next_step), ARGV[5])
|
|
81
|
+
return "skipped"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
return "unknown_strategy"
|
data/lua/start_chain.lua
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
-- Atomically initializes a chain. NX semantics prevent duplicate starts.
|
|
2
|
+
--
|
|
3
|
+
-- KEYS[1] = chain state HASH key
|
|
4
|
+
-- KEYS[2] = chain steps LIST key
|
|
5
|
+
-- KEYS[3] = chain context STRING key
|
|
6
|
+
-- KEYS[4] = active chains SET key
|
|
7
|
+
--
|
|
8
|
+
-- ARGV[1] = total_steps (integer as string)
|
|
9
|
+
-- ARGV[2] = initial context JSON
|
|
10
|
+
-- ARGV[3] = chain_id
|
|
11
|
+
-- ARGV[4] = steps JSON array (array of JSON strings)
|
|
12
|
+
-- ARGV[5] = current timestamp ISO8601
|
|
13
|
+
--
|
|
14
|
+
-- Returns: 1 if started, 0 if already exists
|
|
15
|
+
|
|
16
|
+
if redis.call("EXISTS", KEYS[1]) == 1 then
|
|
17
|
+
return 0
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
redis.call("HSET", KEYS[1],
|
|
21
|
+
"status", "running",
|
|
22
|
+
"current_step", "0",
|
|
23
|
+
"total_steps", ARGV[1],
|
|
24
|
+
"started_at", ARGV[5],
|
|
25
|
+
"updated_at", ARGV[5],
|
|
26
|
+
"step_status:0", "running",
|
|
27
|
+
"step_started_at:0", ARGV[5])
|
|
28
|
+
|
|
29
|
+
redis.call("SET", KEYS[3], ARGV[2])
|
|
30
|
+
|
|
31
|
+
local steps = cjson.decode(ARGV[4])
|
|
32
|
+
for _, step_json in ipairs(steps) do
|
|
33
|
+
redis.call("RPUSH", KEYS[2], step_json)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
redis.call("SADD", KEYS[4], ARGV[3])
|
|
37
|
+
|
|
38
|
+
return 1
|
metadata
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: resque-job-chain
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Duarte Reis
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-07-21 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: redis
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - ">="
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '4.0'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - ">="
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '4.0'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: resque
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - ">="
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '2.0'
|
|
34
|
+
type: :runtime
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - ">="
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '2.0'
|
|
41
|
+
description: A Resque plugin that declares ordered chains of jobs with shared context,
|
|
42
|
+
configurable error strategies (abort, retry, skip), and atomic Redis state tracking.
|
|
43
|
+
email:
|
|
44
|
+
- duarte.reis@zendesk.com
|
|
45
|
+
executables: []
|
|
46
|
+
extensions: []
|
|
47
|
+
extra_rdoc_files: []
|
|
48
|
+
files:
|
|
49
|
+
- CHANGELOG.md
|
|
50
|
+
- LICENSE
|
|
51
|
+
- README.md
|
|
52
|
+
- lib/resque-job-chain.rb
|
|
53
|
+
- lib/resque/job_chain.rb
|
|
54
|
+
- lib/resque/job_chain/builder.rb
|
|
55
|
+
- lib/resque/job_chain/chain.rb
|
|
56
|
+
- lib/resque/job_chain/configuration.rb
|
|
57
|
+
- lib/resque/job_chain/context.rb
|
|
58
|
+
- lib/resque/job_chain/executor.rb
|
|
59
|
+
- lib/resque/job_chain/observable.rb
|
|
60
|
+
- lib/resque/job_chain/persistence.rb
|
|
61
|
+
- lib/resque/job_chain/step.rb
|
|
62
|
+
- lib/resque/job_chain/strategies/abort.rb
|
|
63
|
+
- lib/resque/job_chain/strategies/base.rb
|
|
64
|
+
- lib/resque/job_chain/strategies/retry.rb
|
|
65
|
+
- lib/resque/job_chain/strategies/skip.rb
|
|
66
|
+
- lib/resque/job_chain/version.rb
|
|
67
|
+
- lib/resque/plugins/job_chain.rb
|
|
68
|
+
- lua/advance_chain.lua
|
|
69
|
+
- lua/fail_step.lua
|
|
70
|
+
- lua/start_chain.lua
|
|
71
|
+
homepage: https://github.com/duarteareiasdosreis/resque-job-chain
|
|
72
|
+
licenses:
|
|
73
|
+
- MIT
|
|
74
|
+
metadata:
|
|
75
|
+
homepage_uri: https://github.com/duarteareiasdosreis/resque-job-chain
|
|
76
|
+
source_code_uri: https://github.com/duarteareiasdosreis/resque-job-chain
|
|
77
|
+
changelog_uri: https://github.com/duarteareiasdosreis/resque-job-chain/blob/main/CHANGELOG.md
|
|
78
|
+
rubygems_mfa_required: 'true'
|
|
79
|
+
post_install_message:
|
|
80
|
+
rdoc_options: []
|
|
81
|
+
require_paths:
|
|
82
|
+
- lib
|
|
83
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
84
|
+
requirements:
|
|
85
|
+
- - ">="
|
|
86
|
+
- !ruby/object:Gem::Version
|
|
87
|
+
version: 3.1.0
|
|
88
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
89
|
+
requirements:
|
|
90
|
+
- - ">="
|
|
91
|
+
- !ruby/object:Gem::Version
|
|
92
|
+
version: '0'
|
|
93
|
+
requirements: []
|
|
94
|
+
rubygems_version: 3.5.22
|
|
95
|
+
signing_key:
|
|
96
|
+
specification_version: 4
|
|
97
|
+
summary: Ordered job chain execution for Resque
|
|
98
|
+
test_files: []
|