job-workflow 0.4.0 → 0.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 213a0654cb0482f1a2574f4e55d26c72f5274d24b61b933abd04e9e21f4c19d1
4
- data.tar.gz: 0f19fce390ff18dcb13d80c8a4c6841dc2e096d4ea7586f0a2d6e2c6a467a45b
3
+ metadata.gz: 9474c65583cf21d485c3468675d4f23124e60e88f98c954bd38256b916d98ffe
4
+ data.tar.gz: 59d5e0b60a2b52e87ea4da4aab1834d02c63f88cba36f661acd8c74f89a4e6a9
5
5
  SHA512:
6
- metadata.gz: 89e0d194c4f14817bd2866b4b07f2efe3eea0ffdbc74ff4b7c1f5474d59fb0a18749d68fc2c724ea690f370d1b335d1e69bf0f006ab9bb1964f74fcfd689232b
7
- data.tar.gz: 49150a706fb10a4ba79a87397b4cbbfcb4554eba5e974d22d6176799d377f5d9814f20862be996bca253328fc1efa6a10429947cc308f4aa3dd884eaadd378d0
6
+ metadata.gz: 4ac034e1b373b28aa52dcffd059bc5b64e5c45fc04d9b07460c24491c2514eabddf839b2dcb2c4a2cfbf36b578e2836e153e9bd6f34cd24f8af543cf06242ce9
7
+ data.tar.gz: 455b6913137103b6a5871562a6adc0ffca11e9e99543b94ec7e0bda9f2b78c6c76c5cee73baec30ca5b5b5e2fd388d6a4b119c08d7cf1039c20e115e255f58d7
data/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.5.0] - 2026-05-13
4
+
5
+ ### Added
6
+
7
+ - Add task-scoped cursor helpers on `Context` so tasks can persist progress, restore task-local continuation state, and delegate explicit checkpoints without exposing ActiveJob continuation internals
8
+
9
+ ### Fixed
10
+
11
+ - Initialize the SolidQueue adapter from a Railtie after Rails boot so the `ClaimedExecution` patch is applied reliably, rescheduled `dependency_wait` jobs are not marked finished early, and Rails apps no longer need a manual initializer
12
+
3
13
  ## [0.4.0] - 2026-05-12
4
14
 
5
15
  ### Fixed
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # JobWorkflow
2
2
 
3
- > ⚠️ **Early Stage (v0.4.0):** This library is in active development. APIs and features may change in breaking ways without notice. Use in production at your own risk and expect potential breaking changes in future releases.
3
+ > ⚠️ **Early Stage (v0.5.0):** This library is in active development. APIs and features may change in breaking ways without notice. Use in production at your own risk and expect potential breaking changes in future releases.
4
4
 
5
5
  ## Overview
6
6
 
@@ -84,6 +84,76 @@ end
84
84
 
85
85
  **Map Task Output**: When `each:` is specified, outputs are automatically collected as an array.
86
86
 
87
+ ### Task continuation helpers
88
+
89
+ Inside a task body, you can read the current task cursor, store a new cursor, and create interruption points through the task context.
90
+
91
+ #### Regular task example
92
+
93
+ ```ruby
94
+ task :sync_pages, output: { processed: "Integer" } do |ctx|
95
+ page = ctx.cursor || 1
96
+ result = ExternalAPI.fetch(page:)
97
+
98
+ ctx.set_cursor!(page + 1) if result.next_page?
99
+
100
+ { processed: result.items.size }
101
+ end
102
+ ```
103
+
104
+ - `ctx.cursor` returns the current task cursor, or `nil` when no cursor has been stored
105
+ - `ctx.set_cursor!(value)` validates that `value` is ActiveJob-serializable, stores it in the current continuation step, and checkpoints the job through Active Job continuation
106
+ - `ctx.checkpoint!` creates a checkpoint without changing the public cursor value
107
+ - Call `ctx.set_cursor!` when you want to change the public cursor value and create a checkpoint at the same time
108
+ - Call `ctx.checkpoint!` when you want the current task execution to become interruptible without changing the public cursor value
109
+ - Outside task execution, `ctx.cursor` returns `nil`, and `ctx.set_cursor!` / `ctx.checkpoint!` raise an error
110
+
111
+ For regular tasks, a cursor is only persisted when you call `ctx.set_cursor!(value)` explicitly.
112
+
113
+ #### Checkpoint without changing the cursor
114
+
115
+ ```ruby
116
+ task :publish_report do |ctx|
117
+ report = build_report
118
+ ctx.checkpoint!
119
+ deliver_report(report)
120
+ end
121
+ ```
122
+
123
+ #### Repeating work inside a task
124
+
125
+ ```ruby
126
+ task :sync_users do |ctx|
127
+ start_index = ctx.cursor || 0
128
+
129
+ ctx.arguments.user_ids.drop(start_index).each_with_index do |user_id, offset|
130
+ sync_user(user_id)
131
+ ctx.set_cursor!(start_index + offset + 1)
132
+ end
133
+ end
134
+ ```
135
+
136
+ This pattern is useful when a single task iterates over an Enumerable internally and you want to resume from the last completed item after an interruption.
137
+
138
+ #### `each:` task example
139
+
140
+ ```ruby
141
+ task :sync_users, each: ->(ctx) { ctx.arguments.user_ids } do |ctx|
142
+ next_cursor = ExternalAPI.sync_user(
143
+ user_id: ctx.each_value,
144
+ cursor: ctx.cursor
145
+ )
146
+
147
+ ctx.set_cursor!(next_cursor) unless next_cursor.nil?
148
+ end
149
+ ```
150
+
151
+ For `each:` tasks, JobWorkflow keeps the existing integer resume behavior for completed iterations.
152
+
153
+ - If an `each:` task is interrupted after calling `ctx.set_cursor!`, JobWorkflow resumes with both the current iteration index and the saved task cursor
154
+ - If an iteration completes normally, the resume state advances to the next integer index
155
+ - In other words, a custom cursor in an `each:` task is meant for resuming work inside the current iteration, not for replacing the completion index of finished iterations
156
+
87
157
  ### workflow_concurrency
88
158
 
89
159
  Configure job-level concurrency limits with workflow-aware context.
@@ -1,6 +1,6 @@
1
1
  # Production Deployment
2
2
 
3
- > ⚠️ **Early Stage (v0.4.0):** JobWorkflow is still in early development. While this section outlines potential deployment patterns, please thoroughly test in your specific environment and monitor for any issues before relying on JobWorkflow in critical production systems.
3
+ > ⚠️ **Early Stage (v0.5.0):** JobWorkflow is still in early development. While this section outlines potential deployment patterns, please thoroughly test in your specific environment and monitor for any issues before relying on JobWorkflow in critical production systems.
4
4
 
5
5
  This section covers suggested settings and patterns for running JobWorkflow in production-like environments.
6
6
 
data/guides/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # JobWorkflow Guides
2
2
 
3
- > ⚠️ **Early Stage (v0.4.0):** JobWorkflow is in active development. APIs and features may change. The following guides provide patterns and examples for building workflows, but be aware that implementations may need adjustment as the library evolves.
3
+ > ⚠️ **Early Stage (v0.5.0):** JobWorkflow is in active development. APIs and features may change. The following guides provide patterns and examples for building workflows, but be aware that implementations may need adjustment as the library evolves.
4
4
 
5
5
  Welcome to the JobWorkflow documentation! This directory contains comprehensive guides to help you build robust workflows with JobWorkflow.
6
6
 
@@ -2,6 +2,8 @@
2
2
 
3
3
  module JobWorkflow
4
4
  class Context # rubocop:disable Metrics/ClassLength
5
+ EACH_TASK_CURSOR_MARKER = "__job_workflow_each_cursor__"
6
+
5
7
  attr_reader :workflow #: Workflow
6
8
  attr_reader :arguments #: Arguments
7
9
  attr_reader :output #: Output
@@ -52,7 +54,7 @@ module JobWorkflow
52
54
  # job_status: JobStatus,
53
55
  # ?job: DSL?
54
56
  # ) -> void
55
- def initialize(workflow:, arguments:, task_context:, output:, job_status:, job: nil) # rubocop:disable Metrics/ParameterLists
57
+ def initialize(workflow:, arguments:, task_context:, output:, job_status:, job: nil) # rubocop:disable Metrics/ParameterLists, Metrics/AbcSize, Metrics/MethodLength
56
58
  raise "job does not match the provided workflow" if job&.then { |j| j.class._workflow != workflow }
57
59
 
58
60
  self.job = job
@@ -64,6 +66,8 @@ module JobWorkflow
64
66
  self.enabled_with_each_value = false
65
67
  self.throttle_index = 0
66
68
  self.skip_in_dry_run_index = 0
69
+ self.current_step = nil
70
+ self.current_cursor = nil
67
71
  end
68
72
 
69
73
  #: () -> Hash[String, untyped]
@@ -77,6 +81,31 @@ module JobWorkflow
77
81
  self
78
82
  end
79
83
 
84
+ #: () -> untyped
85
+ def cursor
86
+ return if current_step.nil?
87
+
88
+ current_cursor
89
+ end
90
+
91
+ #: (untyped) -> void
92
+ def set_cursor!(value)
93
+ step = current_step || (raise "set_cursor! can be called only in task")
94
+
95
+ ActiveJob::Arguments.serialize([value])
96
+ self.current_cursor = value
97
+ step.set!(build_step_cursor(value))
98
+ end
99
+
100
+ #: () -> void
101
+ def checkpoint!
102
+ step = current_step || (raise "checkpoint! can be called only in task")
103
+
104
+ return step.checkpoint! unless each_task?
105
+
106
+ step.set!(build_step_cursor(current_cursor))
107
+ end
108
+
80
109
  #: (DSL) -> void
81
110
  def _job=(job)
82
111
  self.job = job
@@ -220,6 +249,18 @@ module JobWorkflow
220
249
  task_context
221
250
  end
222
251
 
252
+ #: (ActiveJob::Continuation::Step, ?cursor: untyped) { () -> void } -> void
253
+ def _with_current_step(step, cursor: nil)
254
+ previous_step = current_step
255
+ previous_cursor = current_cursor
256
+ self.current_step = step
257
+ self.current_cursor = cursor
258
+ yield
259
+ ensure
260
+ self.current_step = previous_step
261
+ self.current_cursor = previous_cursor
262
+ end
263
+
223
264
  #: (TaskOutput) -> void
224
265
  def _add_task_output(task_output)
225
266
  output.add_task_output(task_output)
@@ -245,12 +286,31 @@ module JobWorkflow
245
286
  attr_accessor :enabled_with_each_value #: bool
246
287
  attr_accessor :throttle_index #: Integer
247
288
  attr_accessor :skip_in_dry_run_index #: Integer
289
+ attr_accessor :current_step #: ActiveJob::Continuation::Step?
290
+ attr_accessor :current_cursor #: untyped
248
291
 
249
292
  #: () -> String
250
293
  def parent_job_id
251
294
  _task_context.parent_job_id || job_id
252
295
  end
253
296
 
297
+ #: () -> bool
298
+ def each_task?
299
+ task_context.task.each?
300
+ end
301
+
302
+ #: (untyped) -> untyped
303
+ def build_step_cursor(value)
304
+ return value unless each_task?
305
+ return task_context.index if value.nil?
306
+
307
+ {
308
+ EACH_TASK_CURSOR_MARKER => true,
309
+ "index" => task_context.index,
310
+ "cursor" => value
311
+ }
312
+ end
313
+
254
314
  #: () -> Hash[String, untyped]
255
315
  def serialize_for_job
256
316
  {
@@ -128,7 +128,7 @@ module JobWorkflow
128
128
  # ) { (untyped) -> void } -> void
129
129
  def task(
130
130
  task_name,
131
- each: ->(_ctx) { [TaskContext::NULL_VALUE] },
131
+ each: Task::DEFAULT_EACH,
132
132
  enqueue: nil,
133
133
  retry: 0,
134
134
  output: {},
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JobWorkflow
4
+ class Railtie < Rails::Railtie
5
+ config.after_initialize do
6
+ JobWorkflow::QueueAdapter.reset!
7
+ JobWorkflow::QueueAdapter.current.initialize_adapter!
8
+ end
9
+ end
10
+ end
@@ -14,7 +14,7 @@ module JobWorkflow
14
14
  def run
15
15
  task = context._task_context.task
16
16
  if !task.nil? && context.sub_job?
17
- run_task(task)
17
+ job.step(task.task_name) { |step| run_task(task, step:) }
18
18
  persist_current_job_context
19
19
  return
20
20
  end
@@ -63,25 +63,46 @@ module JobWorkflow
63
63
  result
64
64
  end
65
65
 
66
- #: (Task, ?step: ActiveJob::Continuation::Step?) -> void
67
- def run_task(task, step: nil)
66
+ #: (Task, step: ActiveJob::Continuation::Step) -> void
67
+ def run_task(task, step:) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
68
68
  context._load_parent_task_output
69
- context._with_each_value(task, start_index: step&.cursor).each do |ctx|
70
- run_each_task(task, ctx)
71
- step&.set!(ctx._task_context.index + 1)
69
+ start_index, task_cursor = decode_task_cursor(task, step.cursor)
70
+
71
+ context._with_each_value(task, start_index:).each do |ctx|
72
+ iteration_cursor = task_cursor
73
+ iteration_cursor = nil if task.each? && (task_cursor.nil? || start_index != ctx._task_context.index)
74
+
75
+ run_each_task(task, ctx, step:, cursor: iteration_cursor)
76
+ step.set!(ctx._task_context.index + 1) if task.each?
72
77
  rescue StandardError => e
73
78
  run_error_hooks(task, ctx, e)
74
79
  raise
75
80
  end
76
81
  end
77
82
 
78
- #: (Task, Context) -> void
79
- def run_each_task(task, ctx)
83
+ #: (Task, untyped) -> [Integer?, untyped]
84
+ def decode_task_cursor(task, task_cursor)
85
+ return [nil, task_cursor] unless task.each?
86
+ return [task_cursor, nil] if task_cursor.is_a?(Integer)
87
+
88
+ if task_cursor.is_a?(Hash) && task_cursor[Context::EACH_TASK_CURSOR_MARKER]
89
+ return [task_cursor.fetch("index"), task_cursor.fetch("cursor")]
90
+ end
91
+
92
+ raise "invalid each task cursor: #{task_cursor.inspect}" unless task_cursor.nil?
93
+
94
+ [nil, nil]
95
+ end
96
+
97
+ #: (Task, Context, step: ActiveJob::Continuation::Step, ?cursor: untyped) -> void
98
+ def run_each_task(task, ctx, step:, cursor: nil)
80
99
  Instrumentation.instrument_task(job, task, ctx) do
81
- ctx._with_task_throttle do
82
- run_hooks(task, ctx) do
83
- data = task.block.call(ctx)
84
- add_task_output(ctx:, task:, each_index: ctx._task_context.index, data:)
100
+ ctx._with_current_step(step, cursor:) do
101
+ ctx._with_task_throttle do
102
+ run_hooks(task, ctx) do
103
+ data = task.block.call(ctx)
104
+ add_task_output(ctx:, task:, each_index: ctx._task_context.index, data:)
105
+ end
85
106
  end
86
107
  end
87
108
  end
@@ -2,6 +2,8 @@
2
2
 
3
3
  module JobWorkflow
4
4
  class Task
5
+ DEFAULT_EACH = ->(_ctx) { [nil] }
6
+
5
7
  attr_reader :job_name #: String
6
8
  attr_reader :block #: ^(untyped) -> void
7
9
  attr_reader :each #: ^(Context) -> untyped
@@ -72,6 +74,11 @@ module JobWorkflow
72
74
  "#{job_name}:#{task_name}"
73
75
  end
74
76
 
77
+ #: () -> bool
78
+ def each?
79
+ !each.nil? && !each.equal?(DEFAULT_EACH)
80
+ end
81
+
75
82
  private
76
83
 
77
84
  attr_reader :name #: Symbol
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module JobWorkflow
4
- VERSION = "0.4.0" # : String
4
+ VERSION = "0.5.0" # : String
5
5
  end
data/lib/job_workflow.rb CHANGED
@@ -45,6 +45,9 @@ require_relative "job_workflow/task_output"
45
45
  require_relative "job_workflow/output"
46
46
  require_relative "job_workflow/queue"
47
47
  require_relative "job_workflow/auto_scaling"
48
+ # :nocov:
49
+ require_relative "job_workflow/railtie" if defined?(Rails::Railtie)
50
+ # :nocov:
48
51
 
49
52
  module JobWorkflow
50
53
  class Error < StandardError; end
@@ -53,6 +56,5 @@ module JobWorkflow
53
56
 
54
57
  Instrumentation::LogSubscriber.attach!
55
58
 
56
- ActiveSupport.on_load(:solid_queue) { QueueAdapter.current.initialize_adapter! }
57
59
  QueueAdapter.current.initialize_adapter!
58
60
  end
@@ -3,6 +3,8 @@
3
3
  module JobWorkflow
4
4
  class Context
5
5
  # rubocop:disable Metrics/ClassLength
6
+ EACH_TASK_CURSOR_MARKER: ::String
7
+
6
8
  attr_reader workflow: Workflow
7
9
 
8
10
  attr_reader arguments: Arguments
@@ -33,6 +35,15 @@ module JobWorkflow
33
35
  # : (Hash[Symbol, untyped]) -> Context
34
36
  def _update_arguments: (Hash[Symbol, untyped]) -> Context
35
37
 
38
+ # : () -> untyped
39
+ def cursor: () -> untyped
40
+
41
+ # : (untyped) -> void
42
+ def set_cursor!: (untyped) -> void
43
+
44
+ # : () -> void
45
+ def checkpoint!: () -> void
46
+
36
47
  # : (DSL) -> void
37
48
  def _job=: (DSL) -> void
38
49
 
@@ -93,6 +104,9 @@ module JobWorkflow
93
104
  # : () -> TaskContext
94
105
  def _task_context: () -> TaskContext
95
106
 
107
+ # : (ActiveJob::Continuation::Step, ?cursor: untyped) { () -> void } -> void
108
+ def _with_current_step: (ActiveJob::Continuation::Step, ?cursor: untyped) { () -> void } -> void
109
+
96
110
  # : (TaskOutput) -> void
97
111
  def _add_task_output: (TaskOutput) -> void
98
112
 
@@ -119,9 +133,19 @@ module JobWorkflow
119
133
 
120
134
  attr_accessor skip_in_dry_run_index: Integer
121
135
 
136
+ attr_accessor current_step: ActiveJob::Continuation::Step?
137
+
138
+ attr_accessor current_cursor: untyped
139
+
122
140
  # : () -> String
123
141
  def parent_job_id: () -> String
124
142
 
143
+ # : () -> bool
144
+ def each_task?: () -> bool
145
+
146
+ # : (untyped) -> untyped
147
+ def build_step_cursor: (untyped) -> untyped
148
+
125
149
  # : () -> Hash[String, untyped]
126
150
  def serialize_for_job: () -> Hash[String, untyped]
127
151
 
@@ -0,0 +1,6 @@
1
+ # Generated from lib/job_workflow/railtie.rb with RBS::Inline
2
+
3
+ module JobWorkflow
4
+ class Railtie < Rails::Railtie
5
+ end
6
+ end
@@ -30,11 +30,14 @@ module JobWorkflow
30
30
  # : (Task) -> bool
31
31
  def skip_task?: (Task) -> bool
32
32
 
33
- # : (Task, ?step: ActiveJob::Continuation::Step?) -> void
34
- def run_task: (Task, ?step: ActiveJob::Continuation::Step?) -> void
33
+ # : (Task, step: ActiveJob::Continuation::Step) -> void
34
+ def run_task: (Task, step: ActiveJob::Continuation::Step) -> void
35
35
 
36
- # : (Task, Context) -> void
37
- def run_each_task: (Task, Context) -> void
36
+ # : (Task, untyped) -> [Integer?, untyped]
37
+ def decode_task_cursor: (Task, untyped) -> [ Integer?, untyped ]
38
+
39
+ # : (Task, Context, step: ActiveJob::Continuation::Step, ?cursor: untyped) -> void
40
+ def run_each_task: (Task, Context, step: ActiveJob::Continuation::Step, ?cursor: untyped) -> void
38
41
 
39
42
  # : (Task, Context) { () -> void } -> void
40
43
  def run_hooks: (Task, Context) { () -> void } -> void
@@ -2,6 +2,8 @@
2
2
 
3
3
  module JobWorkflow
4
4
  class Task
5
+ DEFAULT_EACH: untyped
6
+
5
7
  attr_reader job_name: String
6
8
 
7
9
  attr_reader block: ^(untyped) -> void
@@ -50,6 +52,9 @@ module JobWorkflow
50
52
  # : () -> String
51
53
  def throttle_prefix_key: () -> String
52
54
 
55
+ # : () -> bool
56
+ def each?: () -> bool
57
+
53
58
  private
54
59
 
55
60
  attr_reader name: Symbol
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: job-workflow
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - shoma07
@@ -90,6 +90,7 @@ files:
90
90
  - lib/job_workflow/queue_adapters/abstract.rb
91
91
  - lib/job_workflow/queue_adapters/null_adapter.rb
92
92
  - lib/job_workflow/queue_adapters/solid_queue_adapter.rb
93
+ - lib/job_workflow/railtie.rb
93
94
  - lib/job_workflow/runner.rb
94
95
  - lib/job_workflow/schedule.rb
95
96
  - lib/job_workflow/semaphore.rb
@@ -141,6 +142,7 @@ files:
141
142
  - sig/generated/job_workflow/queue_adapters/abstract.rbs
142
143
  - sig/generated/job_workflow/queue_adapters/null_adapter.rbs
143
144
  - sig/generated/job_workflow/queue_adapters/solid_queue_adapter.rbs
145
+ - sig/generated/job_workflow/railtie.rbs
144
146
  - sig/generated/job_workflow/runner.rbs
145
147
  - sig/generated/job_workflow/schedule.rbs
146
148
  - sig/generated/job_workflow/semaphore.rbs