job-workflow 0.2.0 → 0.4.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: 9ac9f67bfd9ba71b60f98643a5f5ed720423239650bdf80a8bb07de594d9b502
4
- data.tar.gz: 6902b370e54d0285721390a40af5ff0a7b459c6ce0a67af6c538dc1877f0df85
3
+ metadata.gz: 213a0654cb0482f1a2574f4e55d26c72f5274d24b61b933abd04e9e21f4c19d1
4
+ data.tar.gz: 0f19fce390ff18dcb13d80c8a4c6841dc2e096d4ea7586f0a2d6e2c6a467a45b
5
5
  SHA512:
6
- metadata.gz: 6c24f513d3c1192513fef902a92d844050a25e095b2739e9f40e774639cde3fb75c4df1a6e2878a50f13583e5df0e665faa6ef803a434e9444d020a81ec2a71b
7
- data.tar.gz: 1c7218d7a1c2d45444a111956bd26d152f16b2e1c6f86fb2959fd255c2ab844444d7929863149f3c7e4322073d520ff40ec3ea02ec29a1595135350723ed4a6a
6
+ metadata.gz: 89e0d194c4f14817bd2866b4b07f2efe3eea0ffdbc74ff4b7c1f5474d59fb0a18749d68fc2c724ea690f370d1b335d1e69bf0f006ab9bb1964f74fcfd689232b
7
+ data.tar.gz: 49150a706fb10a4ba79a87397b4cbbfcb4554eba5e974d22d6176799d377f5d9814f20862be996bca253328fc1efa6a10429947cc308f4aa3dd884eaadd378d0
data/CHANGELOG.md CHANGED
@@ -1,5 +1,36 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.4.0] - 2026-05-12
4
+
5
+ ### Fixed
6
+
7
+ - Fix `enqueue: true` consumer tasks that read `depends_on` outputs by persisting the parent context before `perform_all_later`, so sub-jobs load the latest dependency outputs instead of stale persisted state
8
+ - Fix sequential `each` task resumption to persist continuation cursor progress at the workflow step level, resume from `step.cursor`, and keep the failed iteration retrying from the same `each_index` without skipping previously collected outputs
9
+
10
+ ### Removed
11
+
12
+ - Remove the `namespace` DSL and `JobWorkflow::Namespace`, flatten task naming to a single task space, and delete the dedicated namespace guide/example coverage that existed for the removed feature
13
+
14
+ ## [0.3.0] - 2026-03-13
15
+
16
+ ### Added
17
+
18
+ - Add `fetch_job_contexts(job_ids)` to queue adapter interface (`Abstract`, `SolidQueueAdapter`, `NullAdapter`) for fetching sub-job context data without direct `SolidQueue::Job` dependency from domain classes
19
+ - Add `persist_job_context(job)` to queue adapter interface for persisting task outputs back to SolidQueue job records after execution
20
+ - Add `without_query_cache` private helper to `SolidQueueAdapter` to bypass ActiveRecord query cache during polling queries
21
+ - Add `"job_workflow_context"` key to `find_job` return hash for direct access to workflow context data
22
+ - Add `AcceptanceNoDependencyWaitJob` and acceptance tests for `depends_on` without `dependency_wait` (polling-only mode)
23
+ - Add acceptance test for output aggregation verification in async workflows
24
+
25
+ ### Changed
26
+
27
+ - **Breaking (internal):** Replace `Output#update_task_outputs_from_db` and `Output#update_task_outputs_from_jobs` with `Output#update_task_outputs_from_contexts` — callers now pass context data hashes instead of `SolidQueue::Job` objects
28
+ - `Runner#update_task_outputs` now routes through `QueueAdapter.current.fetch_job_contexts` instead of directly querying `SolidQueue::Job`
29
+ - `Runner#run` now calls `QueueAdapter.current.persist_job_context(job)` after both sub-job and workflow execution
30
+ - `WorkflowStatus.from_job_data` now reads `job_workflow_context` from top-level data first, falling back to `arguments.first.dig("job_workflow_context")`
31
+ - `reschedule_solid_queue_job` now saves full serialized job hash (`active_job.serialize.deep_stringify_keys`) instead of only `["arguments"]`
32
+ - Wrap `find_job`, `fetch_job_statuses`, `job_status`, `reschedule_job`, and `fetch_job_contexts` with `without_query_cache` to prevent stale reads under SolidQueue executor
33
+
3
34
  ## [0.2.0] - 2026-03-12
4
35
 
5
36
  ### Added
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # JobWorkflow
2
2
 
3
- > ⚠️ **Early Stage (v0.1.3):** 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.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.
4
4
 
5
5
  ## Overview
6
6
 
@@ -1,6 +1,6 @@
1
1
  # Production Deployment
2
2
 
3
- > ⚠️ **Early Stage (v0.1.3):** 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.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.
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.1.3):** 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.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.
4
4
 
5
5
  Welcome to the JobWorkflow documentation! This directory contains comprehensive guides to help you build robust workflows with JobWorkflow.
6
6
 
@@ -67,11 +67,6 @@ Power features for complex workflows:
67
67
  - Configuration options (poll_timeout, poll_interval, reschedule_delay)
68
68
  - SolidQueue integration
69
69
 
70
- - **[NAMESPACES.md](NAMESPACES.md)** - Organizing large workflows
71
- - Basic namespace usage
72
- - Nested namespaces
73
- - Cross-namespace dependencies
74
-
75
70
  - **[THROTTLING.md](THROTTLING.md)** - Rate limiting and resource control
76
71
  - Task-level throttling
77
72
  - Runtime throttling
@@ -108,13 +108,13 @@ module JobWorkflow
108
108
  [task_context.parent_job_id, task.task_name].compact.join("/")
109
109
  end
110
110
 
111
- #: (Task) -> Enumerator[Context]
112
- def _with_each_value(task)
111
+ #: (Task, ?start_index: Integer?) -> Enumerator[Context]
112
+ def _with_each_value(task, start_index: nil)
113
113
  raise "Nested _with_each_value calls are not allowed" if enabled_with_each_value
114
114
 
115
115
  self.enabled_with_each_value = true
116
116
  Enumerator.new do |y|
117
- with_task_context(task, y)
117
+ with_task_context(task, y, start_index:)
118
118
  ensure
119
119
  self.enabled_with_each_value = false
120
120
  end
@@ -270,11 +270,11 @@ module JobWorkflow
270
270
  }
271
271
  end
272
272
 
273
- #: (Task, Enumerator::Yielder) -> void
274
- def with_task_context(task, yielder) # rubocop:disable Metrics/MethodLength
273
+ #: (Task, Enumerator::Yielder, ?start_index: Integer?) -> void
274
+ def with_task_context(task, yielder, start_index: nil) # rubocop:disable Metrics/MethodLength
275
275
  reset_task_context_if_task_changed(task)
276
276
 
277
- with_each_index_and_value(task) do |value, index|
277
+ with_each_index_and_value(task, start_index:) do |value, index|
278
278
  dry_run = calculate_dry_run(task)
279
279
  with_retry(task) do |retry_count|
280
280
  self.task_context = TaskContext.new(task:, parent_job_id:, index:, value:, retry_count:, dry_run:)
@@ -294,10 +294,12 @@ module JobWorkflow
294
294
  self.task_context = TaskContext.new if task_context.task&.task_name != task.task_name
295
295
  end
296
296
 
297
- #: (Task) { (untyped, Integer) -> void } -> void
298
- def with_each_index_and_value(task)
297
+ #: (Task, ?start_index: Integer?) { (untyped, Integer) -> void } -> void
298
+ def with_each_index_and_value(task, start_index: nil)
299
+ resume_index = start_index || task_context.index
300
+
299
301
  task.each.call(self).each.with_index do |value, index|
300
- next if index < task_context.index
302
+ next if index < resume_index
301
303
 
302
304
  yield value, index
303
305
 
@@ -108,15 +108,9 @@ module JobWorkflow
108
108
 
109
109
  #: (Symbol argument_name, String type, ?default: untyped) -> void
110
110
  def argument(argument_name, type, default: nil)
111
- validate_namespace!
112
111
  _workflow.add_argument(ArgumentDef.new(name: argument_name, type:, default:))
113
112
  end
114
113
 
115
- #: (Symbol) { () -> void } -> void
116
- def namespace(namespace_name, &)
117
- _workflow.add_namespace(Namespace.new(name: namespace_name), &)
118
- end
119
-
120
114
  # rubocop:disable Metrics/ParameterLists
121
115
  #
122
116
  #: (
@@ -149,7 +143,6 @@ module JobWorkflow
149
143
  new_task = Task.new(
150
144
  job_name: name,
151
145
  name: task_name,
152
- namespace: _workflow.namespace,
153
146
  block: block,
154
147
  enqueue:,
155
148
  each:,
@@ -172,25 +165,21 @@ module JobWorkflow
172
165
 
173
166
  #: (*Symbol) { (Context) -> void } -> void
174
167
  def before(*task_names, &block)
175
- validate_namespace!
176
168
  _workflow.add_hook(:before, task_names:, block:)
177
169
  end
178
170
 
179
171
  #: (*Symbol) { (Context) -> void } -> void
180
172
  def after(*task_names, &block)
181
- validate_namespace!
182
173
  _workflow.add_hook(:after, task_names:, block:)
183
174
  end
184
175
 
185
176
  #: (*Symbol) { (Context, TaskCallable) -> void } -> void
186
177
  def around(*task_names, &block)
187
- validate_namespace!
188
178
  _workflow.add_hook(:around, task_names:, block:)
189
179
  end
190
180
 
191
181
  #: (*Symbol) { (Context, StandardError, Task) -> void } -> void
192
182
  def on_error(*task_names, &block)
193
- validate_namespace!
194
183
  _workflow.add_hook(:error, task_names:, block:)
195
184
  end
196
185
 
@@ -240,7 +229,6 @@ module JobWorkflow
240
229
 
241
230
  #: (?bool) ?{ (Context) -> bool } -> void
242
231
  def dry_run(value = nil, &block)
243
- validate_namespace!
244
232
  _workflow.dry_run_config = block || value
245
233
  end
246
234
 
@@ -254,7 +242,6 @@ module JobWorkflow
254
242
  # ?description: String?
255
243
  # ) -> void
256
244
  def schedule(expression, key: nil, queue: nil, priority: nil, args: {}, description: nil)
257
- validate_namespace!
258
245
  _workflow.add_schedule(
259
246
  Schedule.new(
260
247
  expression:,
@@ -268,13 +255,6 @@ module JobWorkflow
268
255
  )
269
256
  end
270
257
  # rubocop:enable Metrics/ParameterLists
271
-
272
- private
273
-
274
- #: () -> void
275
- def validate_namespace!
276
- raise "cannot be defined within a namespace." unless _workflow.namespace.default?
277
- end
278
258
  end
279
259
  end
280
260
  end
@@ -50,18 +50,10 @@ module JobWorkflow
50
50
  task_outputs[task_output.task_name][task_output.each_index] = task_output
51
51
  end
52
52
 
53
- #: (Array[String], Workflow) -> void
54
- def update_task_outputs_from_db(job_ids, workflow)
55
- jobs = SolidQueue::Job.where(active_job_id: job_ids)
56
- return if jobs.empty?
57
-
58
- update_task_outputs_from_jobs(jobs.to_a, workflow)
59
- end
60
-
61
- #: (Array[SolidQueue::Job], Workflow) -> void
62
- def update_task_outputs_from_jobs(jobs, workflow)
63
- jobs.each do |job|
64
- context = Context.deserialize(job.arguments["job_workflow_context"].merge("workflow" => workflow))
53
+ #: (Array[Hash[String, untyped]], Workflow) -> void
54
+ def update_task_outputs_from_contexts(context_data_list, workflow)
55
+ context_data_list.each do |context_data|
56
+ context = Context.deserialize(context_data.merge("workflow" => workflow))
65
57
  task_output = context.each_task_output
66
58
  next if task_output.nil?
67
59
 
@@ -77,10 +77,18 @@ module JobWorkflow
77
77
  raise NotImplementedError, "#{self.class}#find_job must be implemented"
78
78
  end
79
79
 
80
+ #: (Array[String]) -> Array[Hash[String, untyped]]
81
+ def fetch_job_contexts(_job_ids)
82
+ raise NotImplementedError, "#{self.class}#fetch_job_contexts must be implemented"
83
+ end
84
+
80
85
  #: (DSL, Numeric) -> bool
81
86
  def reschedule_job(_job, _wait)
82
87
  false
83
88
  end
89
+
90
+ #: (DSL) -> void
91
+ def persist_job_context(_job); end
84
92
  end
85
93
  # rubocop:enable Naming/PredicateMethod
86
94
  end
@@ -90,6 +90,11 @@ module JobWorkflow
90
90
  @stored_jobs[job_id]
91
91
  end
92
92
 
93
+ #: (Array[String]) -> Array[Hash[String, untyped]]
94
+ def fetch_job_contexts(_job_ids)
95
+ []
96
+ end
97
+
93
98
  #: (DSL, Numeric) -> bool
94
99
  def reschedule_job(_job, _wait)
95
100
  false
@@ -2,7 +2,7 @@
2
2
 
3
3
  module JobWorkflow
4
4
  module QueueAdapters
5
- # rubocop:disable Naming/PredicateMethod
5
+ # rubocop:disable Naming/PredicateMethod, Metrics/ClassLength
6
6
  class SolidQueueAdapter < Abstract
7
7
  # @note
8
8
  # - Registry scope: @semaphore_registry is process-scoped (shared across fibers/threads
@@ -74,16 +74,20 @@ module JobWorkflow
74
74
  def fetch_job_statuses(job_ids)
75
75
  return {} unless defined?(SolidQueue::Job)
76
76
 
77
- SolidQueue::Job.where(active_job_id: job_ids).index_by(&:active_job_id)
77
+ without_query_cache do
78
+ SolidQueue::Job.where(active_job_id: job_ids).index_by(&:active_job_id)
79
+ end
78
80
  end
79
81
 
80
82
  #: (untyped) -> Symbol
81
83
  def job_status(job)
82
- return :failed if job.failed?
83
- return :succeeded if job.finished?
84
- return :running if job.claimed?
84
+ without_query_cache do
85
+ return :failed if job.failed?
86
+ return :succeeded if job.finished?
87
+ return :running if job.claimed?
85
88
 
86
- :pending
89
+ :pending
90
+ end
87
91
  end
88
92
 
89
93
  #: () -> bool
@@ -153,23 +157,40 @@ module JobWorkflow
153
157
  def find_job(job_id)
154
158
  return unless defined?(SolidQueue::Job)
155
159
 
156
- job = SolidQueue::Job.find_by(active_job_id: job_id)
160
+ job = without_query_cache { SolidQueue::Job.find_by(active_job_id: job_id) }
157
161
  return if job.nil?
158
162
 
163
+ args = job.arguments
159
164
  {
160
165
  "job_id" => job.active_job_id,
161
166
  "class_name" => job.class_name,
162
167
  "queue_name" => job.queue_name,
163
- "arguments" => job.arguments.is_a?(Hash) ? job.arguments["arguments"] : job.arguments,
168
+ "arguments" => args.is_a?(Hash) ? args["arguments"] : args,
169
+ "job_workflow_context" => args.is_a?(Hash) ? args["job_workflow_context"] : nil,
164
170
  "status" => job_status(job)
165
171
  }
166
172
  end
167
173
 
174
+ # @note
175
+ # - Fetches job_workflow_context hashes for the given job IDs.
176
+ #
177
+ #: (Array[String]) -> Array[Hash[String, untyped]]
178
+ def fetch_job_contexts(job_ids)
179
+ return [] unless defined?(SolidQueue::Job)
180
+ return [] if job_ids.empty?
181
+
182
+ jobs = without_query_cache { SolidQueue::Job.where(active_job_id: job_ids).to_a }
183
+ jobs.filter_map do |job|
184
+ args = job.arguments
185
+ args.is_a?(Hash) ? args["job_workflow_context"] : nil
186
+ end
187
+ end
188
+
168
189
  #: (DSL, Numeric) -> bool
169
190
  def reschedule_job(job, wait)
170
191
  return false unless defined?(SolidQueue::Job)
171
192
 
172
- solid_queue_job = SolidQueue::Job.find_by(active_job_id: job.job_id)
193
+ solid_queue_job = without_query_cache { SolidQueue::Job.find_by(active_job_id: job.job_id) }
173
194
  return false unless solid_queue_job&.claimed?
174
195
 
175
196
  reschedule_solid_queue_job(solid_queue_job, job, wait)
@@ -177,17 +198,44 @@ module JobWorkflow
177
198
  false
178
199
  end
179
200
 
201
+ # @note
202
+ # - Persists the job's updated context (including task outputs) back
203
+ # to the SolidQueue job record after execution completes. Without this,
204
+ # outputs computed during job execution would be lost because
205
+ # SolidQueue does not re-serialize job arguments after perform.
206
+ #
207
+ #: (DSL) -> void
208
+ def persist_job_context(job)
209
+ return unless defined?(SolidQueue::Job)
210
+
211
+ solid_queue_job = SolidQueue::Job.find_by(active_job_id: job.job_id)
212
+ return if solid_queue_job.nil?
213
+
214
+ solid_queue_job.update!(arguments: job.serialize.deep_stringify_keys)
215
+ end
216
+
180
217
  private
181
218
 
182
219
  attr_reader :semaphore_registry #: Hash[Object, ^(SolidQueue::Worker) -> void]
183
220
 
221
+ # @note
222
+ # - Bypasses ActiveRecord query cache for the given block.
223
+ # - When running under SolidQueue's executor, SELECT queries are cached
224
+ # for the entire job execution. Polling queries must bypass this cache
225
+ # to observe status changes made by other threads/processes.
226
+ #
227
+ #: [T] () { () -> T } -> T
228
+ def without_query_cache(&)
229
+ defined?(SolidQueue::Job) ? SolidQueue::Job.uncached(&) : yield
230
+ end
231
+
184
232
  #: (SolidQueue::Job, DSL, Numeric) -> bool
185
233
  def reschedule_solid_queue_job(solid_queue_job, active_job, wait)
186
234
  solid_queue_job.with_lock do
187
235
  solid_queue_job.claimed_execution&.destroy!
188
236
  solid_queue_job.update!(
189
237
  scheduled_at: wait.seconds.from_now,
190
- arguments: active_job.serialize.deep_stringify_keys["arguments"]
238
+ arguments: active_job.serialize.deep_stringify_keys
191
239
  )
192
240
  solid_queue_job.prepare_for_execution
193
241
  end
@@ -219,6 +267,6 @@ module JobWorkflow
219
267
  end
220
268
  end
221
269
  end
222
- # rubocop:enable Naming/PredicateMethod
270
+ # rubocop:enable Naming/PredicateMethod, Metrics/ClassLength
223
271
  end
224
272
  end
@@ -13,9 +13,14 @@ module JobWorkflow
13
13
  #: () -> void
14
14
  def run
15
15
  task = context._task_context.task
16
- return run_task(task) if !task.nil? && context.sub_job?
16
+ if !task.nil? && context.sub_job?
17
+ run_task(task)
18
+ persist_current_job_context
19
+ return
20
+ end
17
21
 
18
22
  catch(:rescheduled) { run_workflow }
23
+ persist_current_job_context
19
24
  end
20
25
 
21
26
  private
@@ -45,7 +50,7 @@ module JobWorkflow
45
50
 
46
51
  job.step(task.task_name) do |step|
47
52
  wait_for_dependent_tasks(task, step)
48
- task.enqueue.should_enqueue?(context) ? enqueue_task(task) : run_task(task)
53
+ task.enqueue.should_enqueue?(context) ? enqueue_task(task) : run_task(task, step:)
49
54
  end
50
55
  end
51
56
  end
@@ -58,11 +63,12 @@ module JobWorkflow
58
63
  result
59
64
  end
60
65
 
61
- #: (Task) -> void
62
- def run_task(task)
66
+ #: (Task, ?step: ActiveJob::Continuation::Step?) -> void
67
+ def run_task(task, step: nil)
63
68
  context._load_parent_task_output
64
- context._with_each_value(task).each do |ctx|
69
+ context._with_each_value(task, start_index: step&.cursor).each do |ctx|
65
70
  run_each_task(task, ctx)
71
+ step&.set!(ctx._task_context.index + 1)
66
72
  rescue StandardError => e
67
73
  run_error_hooks(task, ctx, e)
68
74
  raise
@@ -107,6 +113,7 @@ module JobWorkflow
107
113
  #: (Task) -> void
108
114
  def enqueue_task(task)
109
115
  sub_jobs = context._with_each_value(task).map { |ctx| job.class.from_context(ctx) }
116
+ persist_current_job_context
110
117
  ActiveJob.perform_all_later(sub_jobs)
111
118
  context.job_status.update_task_job_statuses_from_jobs(task_name: task.task_name, jobs: sub_jobs)
112
119
  Instrumentation.notify_task_enqueue(job, task, sub_jobs.size)
@@ -167,7 +174,13 @@ module JobWorkflow
167
174
  #: (Task) -> void
168
175
  def update_task_outputs(task)
169
176
  finished_job_ids = context.job_status.finished_job_ids(task_name: task.task_name)
170
- context.output.update_task_outputs_from_db(finished_job_ids, context.workflow)
177
+ context_data_list = QueueAdapter.current.fetch_job_contexts(finished_job_ids)
178
+ context.output.update_task_outputs_from_contexts(context_data_list, context.workflow)
179
+ end
180
+
181
+ #: () -> void
182
+ def persist_current_job_context
183
+ QueueAdapter.current.persist_job_context(job)
171
184
  end
172
185
  end
173
186
  end
@@ -3,7 +3,6 @@
3
3
  module JobWorkflow
4
4
  class Task
5
5
  attr_reader :job_name #: String
6
- attr_reader :namespace #: Namespace
7
6
  attr_reader :block #: ^(untyped) -> void
8
7
  attr_reader :each #: ^(Context) -> untyped
9
8
  attr_reader :enqueue #: TaskEnqueue
@@ -16,11 +15,10 @@ module JobWorkflow
16
15
  attr_reader :dependency_wait #: TaskDependencyWait
17
16
  attr_reader :dry_run_config #: DryRunConfig
18
17
 
19
- # rubocop:disable Metrics/ParameterLists, Metrics/MethodLength, Metrics/AbcSize
18
+ # rubocop:disable Metrics/ParameterLists, Metrics/MethodLength
20
19
  #: (
21
20
  # job_name: String,
22
21
  # name: Symbol,
23
- # namespace: Namespace,
24
22
  # block: ^(untyped) -> void,
25
23
  # ?each: ^(Context) -> untyped,
26
24
  # ?enqueue: true | false | ^(Context) -> bool | Hash[Symbol, untyped],
@@ -36,7 +34,6 @@ module JobWorkflow
36
34
  def initialize(
37
35
  job_name:,
38
36
  name:,
39
- namespace:,
40
37
  block:,
41
38
  each: nil,
42
39
  enqueue: nil,
@@ -51,7 +48,6 @@ module JobWorkflow
51
48
  )
52
49
  @job_name = job_name
53
50
  @name = name
54
- @namespace = namespace #: Namespace
55
51
  @block = block
56
52
  @each = each
57
53
  @enqueue = TaskEnqueue.from_primitive_value(enqueue)
@@ -64,11 +60,11 @@ module JobWorkflow
64
60
  @dependency_wait = TaskDependencyWait.from_primitive_value(dependency_wait)
65
61
  @dry_run_config = DryRunConfig.from_primitive_value(dry_run)
66
62
  end
67
- # rubocop:enable Metrics/ParameterLists, Metrics/MethodLength, Metrics/AbcSize
63
+ # rubocop:enable Metrics/ParameterLists, Metrics/MethodLength
68
64
 
69
65
  #: () -> Symbol
70
66
  def task_name
71
- [namespace.full_name.to_s, name.to_s].reject(&:empty?).join(":").to_sym
67
+ name
72
68
  end
73
69
 
74
70
  #: () -> String
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module JobWorkflow
4
- VERSION = "0.2.0" # : String
4
+ VERSION = "0.4.0" # : String
5
5
  end
@@ -2,7 +2,6 @@
2
2
 
3
3
  module JobWorkflow
4
4
  class Workflow
5
- attr_reader :namespace #: Namespace
6
5
  attr_reader :dry_run_config #: DryRunConfig
7
6
 
8
7
  #: () -> void
@@ -10,7 +9,6 @@ module JobWorkflow
10
9
  @task_graph = TaskGraph.new
11
10
  @argument_defs = {} #: Hash[Symbol, ArgumentDef]
12
11
  @hook_registry = HookRegistry.new
13
- @namespace = Namespace.default #: Namespace
14
12
  @schedules = {} #: Hash[Symbol, Schedule]
15
13
  @dry_run_config = DryRunConfig.new
16
14
  end
@@ -20,15 +18,6 @@ module JobWorkflow
20
18
  @dry_run_config = DryRunConfig.from_primitive_value(value)
21
19
  end
22
20
 
23
- #: (Namespace) { () -> void } -> void
24
- def add_namespace(namespace)
25
- original_namespace = @namespace
26
- @namespace = namespace.update_parent(original_namespace)
27
- yield
28
- ensure
29
- @namespace = original_namespace
30
- end
31
-
32
21
  #: (Task) -> void
33
22
  def add_task(task)
34
23
  @task_graph.add(task)
@@ -34,7 +34,7 @@ module JobWorkflow
34
34
  job_class = job_class_name.constantize
35
35
  workflow = job_class._workflow
36
36
 
37
- context_data = data["arguments"].first["job_workflow_context"]
37
+ context_data = data["job_workflow_context"] || data["arguments"]&.first&.dig("job_workflow_context")
38
38
  context = if context_data
39
39
  Context.deserialize(context_data.merge("workflow" => workflow))
40
40
  else
data/lib/job_workflow.rb CHANGED
@@ -23,7 +23,6 @@ require_relative "job_workflow/task_throttle"
23
23
  require_relative "job_workflow/task_enqueue"
24
24
  require_relative "job_workflow/task_dependency_wait"
25
25
  require_relative "job_workflow/semaphore"
26
- require_relative "job_workflow/namespace"
27
26
  require_relative "job_workflow/hook"
28
27
  require_relative "job_workflow/error_hook"
29
28
  require_relative "job_workflow/hook_registry"
@@ -6,7 +6,7 @@ gems:
6
6
  source:
7
7
  type: git
8
8
  name: ruby/gem_rbs_collection
9
- revision: 2af46cde7fec5e4130f6c84c5b691c0538af8789
9
+ revision: 81fa8bd0617286078617a62b6a3229cebfd4af23
10
10
  remote: https://github.com/ruby/gem_rbs_collection.git
11
11
  repo_dir: gems
12
12
  - name: activerecord
@@ -14,7 +14,7 @@ gems:
14
14
  source:
15
15
  type: git
16
16
  name: ruby/gem_rbs_collection
17
- revision: 2af46cde7fec5e4130f6c84c5b691c0538af8789
17
+ revision: 81fa8bd0617286078617a62b6a3229cebfd4af23
18
18
  remote: https://github.com/ruby/gem_rbs_collection.git
19
19
  repo_dir: gems
20
20
  - name: activesupport
@@ -22,7 +22,7 @@ gems:
22
22
  source:
23
23
  type: git
24
24
  name: ruby/gem_rbs_collection
25
- revision: 2af46cde7fec5e4130f6c84c5b691c0538af8789
25
+ revision: 81fa8bd0617286078617a62b6a3229cebfd4af23
26
26
  remote: https://github.com/ruby/gem_rbs_collection.git
27
27
  repo_dir: gems
28
28
  - name: base64
@@ -34,7 +34,7 @@ gems:
34
34
  source:
35
35
  type: git
36
36
  name: ruby/gem_rbs_collection
37
- revision: 2af46cde7fec5e4130f6c84c5b691c0538af8789
37
+ revision: 81fa8bd0617286078617a62b6a3229cebfd4af23
38
38
  remote: https://github.com/ruby/gem_rbs_collection.git
39
39
  repo_dir: gems
40
40
  - name: concurrent-ruby
@@ -42,7 +42,7 @@ gems:
42
42
  source:
43
43
  type: git
44
44
  name: ruby/gem_rbs_collection
45
- revision: 2af46cde7fec5e4130f6c84c5b691c0538af8789
45
+ revision: 81fa8bd0617286078617a62b6a3229cebfd4af23
46
46
  remote: https://github.com/ruby/gem_rbs_collection.git
47
47
  repo_dir: gems
48
48
  - name: connection_pool
@@ -50,7 +50,7 @@ gems:
50
50
  source:
51
51
  type: git
52
52
  name: ruby/gem_rbs_collection
53
- revision: 2af46cde7fec5e4130f6c84c5b691c0538af8789
53
+ revision: 81fa8bd0617286078617a62b6a3229cebfd4af23
54
54
  remote: https://github.com/ruby/gem_rbs_collection.git
55
55
  repo_dir: gems
56
56
  - name: date
@@ -74,7 +74,7 @@ gems:
74
74
  source:
75
75
  type: git
76
76
  name: ruby/gem_rbs_collection
77
- revision: 2af46cde7fec5e4130f6c84c5b691c0538af8789
77
+ revision: 81fa8bd0617286078617a62b6a3229cebfd4af23
78
78
  remote: https://github.com/ruby/gem_rbs_collection.git
79
79
  repo_dir: gems
80
80
  - name: i18n
@@ -82,7 +82,7 @@ gems:
82
82
  source:
83
83
  type: git
84
84
  name: ruby/gem_rbs_collection
85
- revision: 2af46cde7fec5e4130f6c84c5b691c0538af8789
85
+ revision: 81fa8bd0617286078617a62b6a3229cebfd4af23
86
86
  remote: https://github.com/ruby/gem_rbs_collection.git
87
87
  repo_dir: gems
88
88
  - name: json
@@ -98,7 +98,7 @@ gems:
98
98
  source:
99
99
  type: git
100
100
  name: ruby/gem_rbs_collection
101
- revision: 2af46cde7fec5e4130f6c84c5b691c0538af8789
101
+ revision: 81fa8bd0617286078617a62b6a3229cebfd4af23
102
102
  remote: https://github.com/ruby/gem_rbs_collection.git
103
103
  repo_dir: gems
104
104
  - name: monitor
@@ -138,7 +138,7 @@ gems:
138
138
  source:
139
139
  type: git
140
140
  name: ruby/gem_rbs_collection
141
- revision: 2af46cde7fec5e4130f6c84c5b691c0538af8789
141
+ revision: 81fa8bd0617286078617a62b6a3229cebfd4af23
142
142
  remote: https://github.com/ruby/gem_rbs_collection.git
143
143
  repo_dir: gems
144
144
  - name: stringio
@@ -162,7 +162,7 @@ gems:
162
162
  source:
163
163
  type: git
164
164
  name: ruby/gem_rbs_collection
165
- revision: 2af46cde7fec5e4130f6c84c5b691c0538af8789
165
+ revision: 81fa8bd0617286078617a62b6a3229cebfd4af23
166
166
  remote: https://github.com/ruby/gem_rbs_collection.git
167
167
  repo_dir: gems
168
168
  - name: uri
@@ -48,8 +48,8 @@ module JobWorkflow
48
48
  # : () -> String?
49
49
  def concurrency_key: () -> String?
50
50
 
51
- # : (Task) -> Enumerator[Context]
52
- def _with_each_value: (Task) -> Enumerator[Context]
51
+ # : (Task, ?start_index: Integer?) -> Enumerator[Context]
52
+ def _with_each_value: (Task, ?start_index: Integer?) -> Enumerator[Context]
53
53
 
54
54
  # : () { () -> void } -> void
55
55
  def _with_task_throttle: () { () -> void } -> void
@@ -128,14 +128,14 @@ module JobWorkflow
128
128
  # : () -> Hash[String, untyped]
129
129
  def serialize_for_sub_job: () -> Hash[String, untyped]
130
130
 
131
- # : (Task, Enumerator::Yielder) -> void
132
- def with_task_context: (Task, Enumerator::Yielder) -> void
131
+ # : (Task, Enumerator::Yielder, ?start_index: Integer?) -> void
132
+ def with_task_context: (Task, Enumerator::Yielder, ?start_index: Integer?) -> void
133
133
 
134
134
  # : (Task) -> void
135
135
  def reset_task_context_if_task_changed: (Task) -> void
136
136
 
137
- # : (Task) { (untyped, Integer) -> void } -> void
138
- def with_each_index_and_value: (Task) { (untyped, Integer) -> void } -> void
137
+ # : (Task, ?start_index: Integer?) { (untyped, Integer) -> void } -> void
138
+ def with_each_index_and_value: (Task, ?start_index: Integer?) { (untyped, Integer) -> void } -> void
139
139
 
140
140
  # : () -> void
141
141
  def clear_after_each_index_and_value: () -> void
@@ -62,9 +62,6 @@ module JobWorkflow
62
62
  # : (Symbol argument_name, String type, ?default: untyped) -> void
63
63
  def argument: (Symbol argument_name, String type, ?default: untyped) -> void
64
64
 
65
- # : (Symbol) { () -> void } -> void
66
- def namespace: (Symbol) { () -> void } -> void
67
-
68
65
  # rubocop:disable Metrics/ParameterLists
69
66
  #
70
67
  # : (
@@ -139,11 +136,6 @@ module JobWorkflow
139
136
  # ?description: String?
140
137
  # ) -> void
141
138
  def schedule: (String expression, ?key: (String | Symbol)?, ?queue: String?, ?priority: Integer?, ?args: Hash[Symbol, untyped], ?description: String?) -> void
142
-
143
- private
144
-
145
- # : () -> void
146
- def validate_namespace!: () -> void
147
139
  end
148
140
  end
149
141
  end
@@ -23,11 +23,8 @@ module JobWorkflow
23
23
  # : (TaskOutput) -> void
24
24
  def add_task_output: (TaskOutput) -> void
25
25
 
26
- # : (Array[String], Workflow) -> void
27
- def update_task_outputs_from_db: (Array[String], Workflow) -> void
28
-
29
- # : (Array[SolidQueue::Job], Workflow) -> void
30
- def update_task_outputs_from_jobs: (Array[SolidQueue::Job], Workflow) -> void
26
+ # : (Array[Hash[String, untyped]], Workflow) -> void
27
+ def update_task_outputs_from_contexts: (Array[Hash[String, untyped]], Workflow) -> void
31
28
 
32
29
  # : () -> Array[TaskOutput]
33
30
  def flat_task_outputs: () -> Array[TaskOutput]
@@ -49,8 +49,14 @@ module JobWorkflow
49
49
  # : (String) -> Hash[String, untyped]?
50
50
  def find_job: (String) -> Hash[String, untyped]?
51
51
 
52
+ # : (Array[String]) -> Array[Hash[String, untyped]]
53
+ def fetch_job_contexts: (Array[String]) -> Array[Hash[String, untyped]]
54
+
52
55
  # : (DSL, Numeric) -> bool
53
56
  def reschedule_job: (DSL, Numeric) -> bool
57
+
58
+ # : (DSL) -> void
59
+ def persist_job_context: (DSL) -> void
54
60
  end
55
61
  end
56
62
  end
@@ -51,6 +51,9 @@ module JobWorkflow
51
51
  # : (String) -> Hash[String, untyped]?
52
52
  def find_job: (String) -> Hash[String, untyped]?
53
53
 
54
+ # : (Array[String]) -> Array[Hash[String, untyped]]
55
+ def fetch_job_contexts: (Array[String]) -> Array[Hash[String, untyped]]
56
+
54
57
  # : (DSL, Numeric) -> bool
55
58
  def reschedule_job: (DSL, Numeric) -> bool
56
59
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  module JobWorkflow
4
4
  module QueueAdapters
5
- # rubocop:disable Naming/PredicateMethod
5
+ # rubocop:disable Naming/PredicateMethod, Metrics/ClassLength
6
6
  class SolidQueueAdapter < Abstract
7
7
  # @note
8
8
  # - Registry scope: @semaphore_registry is process-scoped (shared across fibers/threads
@@ -82,13 +82,37 @@ module JobWorkflow
82
82
  # : (String) -> Hash[String, untyped]?
83
83
  def find_job: (String) -> Hash[String, untyped]?
84
84
 
85
+ # @note
86
+ # - Fetches job_workflow_context hashes for the given job IDs.
87
+ #
88
+ # : (Array[String]) -> Array[Hash[String, untyped]]
89
+ def fetch_job_contexts: (Array[String]) -> Array[Hash[String, untyped]]
90
+
85
91
  # : (DSL, Numeric) -> bool
86
92
  def reschedule_job: (DSL, Numeric) -> bool
87
93
 
94
+ # @note
95
+ # - Persists the job's updated context (including task outputs) back
96
+ # to the SolidQueue job record after execution completes. Without this,
97
+ # outputs computed during job execution would be lost because
98
+ # SolidQueue does not re-serialize job arguments after perform.
99
+ #
100
+ # : (DSL) -> void
101
+ def persist_job_context: (DSL) -> void
102
+
88
103
  private
89
104
 
90
105
  attr_reader semaphore_registry: Hash[Object, ^(SolidQueue::Worker) -> void]
91
106
 
107
+ # @note
108
+ # - Bypasses ActiveRecord query cache for the given block.
109
+ # - When running under SolidQueue's executor, SELECT queries are cached
110
+ # for the entire job execution. Polling queries must bypass this cache
111
+ # to observe status changes made by other threads/processes.
112
+ #
113
+ # : [T] () { () -> T } -> T
114
+ def without_query_cache: [T] () { () -> T } -> T
115
+
92
116
  # : (SolidQueue::Job, DSL, Numeric) -> bool
93
117
  def reschedule_solid_queue_job: (SolidQueue::Job, DSL, Numeric) -> bool
94
118
 
@@ -30,8 +30,8 @@ module JobWorkflow
30
30
  # : (Task) -> bool
31
31
  def skip_task?: (Task) -> bool
32
32
 
33
- # : (Task) -> void
34
- def run_task: (Task) -> void
33
+ # : (Task, ?step: ActiveJob::Continuation::Step?) -> void
34
+ def run_task: (Task, ?step: ActiveJob::Continuation::Step?) -> void
35
35
 
36
36
  # : (Task, Context) -> void
37
37
  def run_each_task: (Task, Context) -> void
@@ -62,5 +62,8 @@ module JobWorkflow
62
62
 
63
63
  # : (Task) -> void
64
64
  def update_task_outputs: (Task) -> void
65
+
66
+ # : () -> void
67
+ def persist_current_job_context: () -> void
65
68
  end
66
69
  end
@@ -4,8 +4,6 @@ module JobWorkflow
4
4
  class Task
5
5
  attr_reader job_name: String
6
6
 
7
- attr_reader namespace: Namespace
8
-
9
7
  attr_reader block: ^(untyped) -> void
10
8
 
11
9
  attr_reader each: ^(Context) -> untyped
@@ -28,11 +26,10 @@ module JobWorkflow
28
26
 
29
27
  attr_reader dry_run_config: DryRunConfig
30
28
 
31
- # rubocop:disable Metrics/ParameterLists, Metrics/MethodLength, Metrics/AbcSize
29
+ # rubocop:disable Metrics/ParameterLists, Metrics/MethodLength
32
30
  # : (
33
31
  # job_name: String,
34
32
  # name: Symbol,
35
- # namespace: Namespace,
36
33
  # block: ^(untyped) -> void,
37
34
  # ?each: ^(Context) -> untyped,
38
35
  # ?enqueue: true | false | ^(Context) -> bool | Hash[Symbol, untyped],
@@ -45,7 +42,7 @@ module JobWorkflow
45
42
  # ?dependency_wait: Hash[Symbol, untyped],
46
43
  # ?dry_run: bool | ^(Context) -> bool
47
44
  # ) -> void
48
- def initialize: (job_name: String, name: Symbol, namespace: Namespace, block: ^(untyped) -> void, condition: ^(Context) -> bool, ?each: ^(Context) -> untyped, ?enqueue: true | false | ^(Context) -> bool | Hash[Symbol, untyped], ?output: Hash[Symbol, String], ?depends_on: Array[Symbol], ?task_retry: Integer | Hash[Symbol, untyped], ?throttle: Integer | Hash[Symbol, untyped], ?timeout: Numeric?, ?dependency_wait: Hash[Symbol, untyped], ?dry_run: bool | ^(Context) -> bool) -> void
45
+ def initialize: (job_name: String, name: Symbol, block: ^(untyped) -> void, condition: ^(Context) -> bool, ?each: ^(Context) -> untyped, ?enqueue: true | false | ^(Context) -> bool | Hash[Symbol, untyped], ?output: Hash[Symbol, String], ?depends_on: Array[Symbol], ?task_retry: Integer | Hash[Symbol, untyped], ?throttle: Integer | Hash[Symbol, untyped], ?timeout: Numeric?, ?dependency_wait: Hash[Symbol, untyped], ?dry_run: bool | ^(Context) -> bool) -> void
49
46
 
50
47
  # : () -> Symbol
51
48
  def task_name: () -> Symbol
@@ -2,8 +2,6 @@
2
2
 
3
3
  module JobWorkflow
4
4
  class Workflow
5
- attr_reader namespace: Namespace
6
-
7
5
  attr_reader dry_run_config: DryRunConfig
8
6
 
9
7
  # : () -> void
@@ -12,9 +10,6 @@ module JobWorkflow
12
10
  # : (bool | ^(Context) -> bool) -> void
13
11
  def dry_run_config=: (bool | ^(Context) -> bool) -> void
14
12
 
15
- # : (Namespace) { () -> void } -> void
16
- def add_namespace: (Namespace) { () -> void } -> void
17
-
18
13
  # : (Task) -> void
19
14
  def add_task: (Task) -> void
20
15
 
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.2.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - shoma07
@@ -49,7 +49,6 @@ files:
49
49
  - guides/GETTING_STARTED.md
50
50
  - guides/INSTRUMENTATION.md
51
51
  - guides/LIFECYCLE_HOOKS.md
52
- - guides/NAMESPACES.md
53
52
  - guides/OPENTELEMETRY_INTEGRATION.md
54
53
  - guides/PARALLEL_PROCESSING.md
55
54
  - guides/PRODUCTION_DEPLOYMENT.md
@@ -84,7 +83,6 @@ files:
84
83
  - lib/job_workflow/instrumentation/opentelemetry_subscriber.rb
85
84
  - lib/job_workflow/job_status.rb
86
85
  - lib/job_workflow/logger.rb
87
- - lib/job_workflow/namespace.rb
88
86
  - lib/job_workflow/output.rb
89
87
  - lib/job_workflow/output_def.rb
90
88
  - lib/job_workflow/queue.rb
@@ -136,7 +134,6 @@ files:
136
134
  - sig/generated/job_workflow/instrumentation/opentelemetry_subscriber.rbs
137
135
  - sig/generated/job_workflow/job_status.rbs
138
136
  - sig/generated/job_workflow/logger.rbs
139
- - sig/generated/job_workflow/namespace.rbs
140
137
  - sig/generated/job_workflow/output.rbs
141
138
  - sig/generated/job_workflow/output_def.rbs
142
139
  - sig/generated/job_workflow/queue.rbs
data/guides/NAMESPACES.md DELETED
@@ -1,75 +0,0 @@
1
- # Namespaces
2
-
3
- Logically grouping tasks improves readability and maintainability of complex workflows. JobWorkflow provides namespace functionality.
4
-
5
- ## Basic Namespaces
6
-
7
- ### namespace DSL
8
-
9
- Group related tasks.
10
-
11
- ```ruby
12
- class ECommerceOrderJob < ApplicationJob
13
- include JobWorkflow::DSL
14
-
15
- argument :order, "Order"
16
-
17
- # Payment-related tasks
18
- namespace :payment do
19
- task :validate do |ctx|
20
- order = ctx.arguments.order
21
- PaymentValidator.validate(order)
22
- end
23
-
24
- task :charge, depends_on: [:"payment:validate"], output: { payment_result: "Hash" } do |ctx|
25
- order = ctx.arguments.order
26
- { payment_result: PaymentProcessor.charge(order) }
27
- end
28
-
29
- task :send_receipt, depends_on: [:"payment:charge"] do |ctx|
30
- order = ctx.arguments.order
31
- payment_result = ctx.output[:"payment:charge"].first.payment_result
32
- ReceiptMailer.send(order, payment_result)
33
- end
34
- end
35
-
36
- # Inventory-related tasks
37
- namespace :inventory do
38
- task :check_availability do |ctx|
39
- order = ctx.arguments.order
40
- InventoryService.check(order.items)
41
- end
42
-
43
- task :reserve, depends_on: [:"inventory:check_availability"], output: { reserved: "bool" } do |ctx|
44
- order = ctx.arguments.order
45
- { reserved: InventoryService.reserve(order.items) }
46
- end
47
- end
48
-
49
- # Shipping-related tasks
50
- namespace :shipping do
51
- task :calculate_cost, output: { shipping_cost: "Float" } do |ctx|
52
- order = ctx.arguments.order
53
- { shipping_cost: ShippingCalculator.calculate(order) }
54
- end
55
-
56
- task :create_label, depends_on: [:"shipping:calculate_cost"], output: { shipping_label: "String" } do |ctx|
57
- order = ctx.arguments.order
58
- { shipping_label: ShippingService.create_label(order) }
59
- end
60
- end
61
- end
62
- ```
63
-
64
- Tasks in namespaces are identified as `:namespace:task_name` at runtime:
65
-
66
- ```ruby
67
- # Executed tasks:
68
- # - :payment:validate
69
- # - :payment:charge
70
- # - :payment:send_receipt
71
- # - :inventory:check_availability
72
- # - :inventory:reserve
73
- # - :shipping:calculate_cost
74
- # - :shipping:create_label
75
- ```
@@ -1,36 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module JobWorkflow
4
- class Namespace
5
- attr_reader :name #: Symbol
6
- attr_reader :parent #: Namespace?
7
-
8
- class << self
9
- #: () -> Namespace
10
- def default
11
- new(name: :"")
12
- end
13
- end
14
-
15
- #: (name: Symbol, ?parent: Namespace?) -> void
16
- def initialize(name:, parent: nil)
17
- @name = name #: Symbol
18
- @parent = parent #: Namespace?
19
- end
20
-
21
- #: () -> bool
22
- def default?
23
- name.empty?
24
- end
25
-
26
- #: (Namespace) -> Namespace
27
- def update_parent(parent)
28
- self.class.new(name:, parent:)
29
- end
30
-
31
- #: () -> Symbol
32
- def full_name
33
- [parent&.full_name, name.to_s].compact.reject(&:empty?).join(":").to_sym
34
- end
35
- end
36
- end
@@ -1,24 +0,0 @@
1
- # Generated from lib/job_workflow/namespace.rb with RBS::Inline
2
-
3
- module JobWorkflow
4
- class Namespace
5
- attr_reader name: Symbol
6
-
7
- attr_reader parent: Namespace?
8
-
9
- # : () -> Namespace
10
- def self.default: () -> Namespace
11
-
12
- # : (name: Symbol, ?parent: Namespace?) -> void
13
- def initialize: (name: Symbol, ?parent: Namespace?) -> void
14
-
15
- # : () -> bool
16
- def default?: () -> bool
17
-
18
- # : (Namespace) -> Namespace
19
- def update_parent: (Namespace) -> Namespace
20
-
21
- # : () -> Symbol
22
- def full_name: () -> Symbol
23
- end
24
- end