joblin 0.1.3 → 0.1.5

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: d79f8417c584523ab669a7e4c4d13df180eaad4c921b915073aec83e2c8b6197
4
- data.tar.gz: 334c035490eeb2537a8503df28585ef6cf736c25ca8822e82e812c2f0841a443
3
+ metadata.gz: 6cb0e0c2038030615d4e6a50e08aac0166f022a6af89f2a5d157d977e2f03f58
4
+ data.tar.gz: f25744584d54054fe566770b10870159ac9c2a422c8e44cb609aca67116f4c03
5
5
  SHA512:
6
- metadata.gz: 48b1d7e9370ca38d31a1a4f4f17943210cd3203a2e0e0c397be45878c520e2a7c50653147e1feb6596cf17ab32fdbccd681938b13fbcf528d660fb358282444b
7
- data.tar.gz: 7deceab65a315fb8534db3d436fab18ebf96e1452c4d01887a7c35c4b6e57831a6b96299e86f3d3623e1f4b33cab01b78cf9aed664ed6e989b017c28744654da
6
+ metadata.gz: 726db8abb51ae0697aa6299df1179c7ef7a5e7de96681b6da9341d97242d4a39928ca4a274a26d58b35ba519f29c3b7d18b8d19dc4cf66bbdd7f25f0c5f83a2b
7
+ data.tar.gz: 83ca76bd6c338207259f08e4115920613c525c5bbe3be431a6a1299bfc0b79282f69a986ce3c04fa17078b0c2dce24f1de1e8556e8d371bda03bcb7231c5ba15
@@ -1,155 +1,159 @@
1
1
  module Joblin
2
- module BackgroundTask::ApiAccess
3
- extend ActiveSupport::Concern
4
-
5
- class_methods do
6
- def api_access_rules(&blk)
7
- @api_access_rules ||= []
8
- if blk
9
- @api_access_rules << ApiAccessRules.new.tap do |aar|
10
- aar.instance_exec(&blk)
2
+ class BackgroundTask
3
+ module ApiAccess
4
+ extend ActiveSupport::Concern
5
+
6
+ class_methods do
7
+ def api_access_rules(&blk)
8
+ @api_access_rules ||= []
9
+ if blk
10
+ @api_access_rules << ApiAccessRules.new.tap do |aar|
11
+ aar.instance_exec(&blk)
12
+ end
13
+ nil
14
+ else
15
+ [*superclass.try(:api_access_rules), *@api_access_rules].compact.freeze
11
16
  end
12
- nil
13
- else
14
- [*superclass.try(:api_access_rules), *@api_access_rules].compact.freeze
15
17
  end
16
- end
17
18
 
18
- def allow_api_access!(&blk)
19
- include Mixin unless self < Mixin
20
- @api_access_allowed = true
21
- api_access_rules(&blk) if blk
22
- end
23
-
24
- def api_access_allowed?
25
- # This is intentionally not inherited by subclasses
26
- @api_access_allowed
27
- end
19
+ def allow_api_access!(&blk)
20
+ include Mixin unless self < Mixin
21
+ @api_access_allowed = true
22
+ api_access_rules(&blk) if blk
23
+ end
28
24
 
29
- def find_for_api(id)
30
- task = find(id)
31
- raise ActiveRecord::RecordNotFound unless task.api_access_allowed?
32
- task
33
- end
25
+ def api_access_allowed?
26
+ # This is intentionally not inherited by subclasses
27
+ @api_access_allowed
28
+ end
34
29
 
35
- def build_from_api(controller_or_params, options = {}, params: nil, exec_context: nil)
36
- if controller_or_params.respond_to?(:params)
37
- exec_context ||= controller_or_params
38
- params ||= controller_or_params.params.require(:background_task)
39
- else
40
- params ||= controller_or_params
30
+ def find_for_api(id)
31
+ task = find(id)
32
+ raise ActiveRecord::RecordNotFound unless task.api_access_allowed?
33
+ task
41
34
  end
42
35
 
43
- task_type = self == BackgroundTask ? params[:type].safe_constantize : self
44
- raise ActiveRecord::RecordNotFound unless task_type && task_type <= BackgroundTask
36
+ def build_from_api(controller_or_params, options = {}, params: nil, exec_context: nil)
37
+ if controller_or_params.respond_to?(:params)
38
+ exec_context ||= controller_or_params
39
+ params ||= controller_or_params.params.require(:background_task)
40
+ else
41
+ params ||= controller_or_params
42
+ end
43
+
44
+ task_type = self == BackgroundTask ? params[:type].safe_constantize : self
45
+ raise ActiveRecord::RecordNotFound unless task_type && task_type <= BackgroundTask
45
46
 
46
- task = task_type.new
47
- raise ActiveRecord::RecordNotFound unless task.api_access_allowed?
48
- rule_sets = task_type.api_access_rules
47
+ task = task_type.new
48
+ raise ActiveRecord::RecordNotFound unless task.api_access_allowed?
49
+ rule_sets = task_type.api_access_rules
49
50
 
50
- # Apply permitted options
51
- if params[:options].present?
52
- permitted_options = rule_sets.map(&:permitted_options).flatten
53
- task.options.merge!(params[:options].permit(permitted_options).to_h)
54
- end
51
+ # Apply permitted options
52
+ if params[:options].present?
53
+ permitted_options = rule_sets.map(&:permitted_options).flatten
54
+ task.options.merge!(params[:options].permit(permitted_options).to_h)
55
+ end
55
56
 
56
- # Apply default options
57
- rule_sets.reverse.each do |aar|
58
- aar.default_options.each do |k, v|
59
- next if task.options.key?(k)
60
- task.options[k] = v.is_a?(Proc) ? exec_context.instance_exec(&v) : v
57
+ # Apply default options
58
+ rule_sets.reverse.each do |aar|
59
+ aar.default_options.each do |k, v|
60
+ next if task.options.key?(k)
61
+ task.options[k] = v.is_a?(Proc) ? exec_context.instance_exec(&v) : v
62
+ end
61
63
  end
62
- end
63
64
 
64
- # Apply any additional/hard-coded options
65
- task.options.merge!(options)
65
+ # Apply any additional/hard-coded options
66
+ task.options.merge!(options)
66
67
 
67
- raise ActiveRecord::RecordInvalid.new(task) unless task.api_access_allowed?
68
+ raise ActiveRecord::RecordInvalid.new(task) unless task.api_access_allowed?
68
69
 
69
- val_errors = task.api_validate_options
70
- raise ActiveRecord::ValidationError.new(val_errors) unless val_errors.empty?
70
+ val_errors = task.api_validate_options
71
+ raise ActiveRecord::ValidationError.new(val_errors) unless val_errors.empty?
71
72
 
72
- task
73
+ task
74
+ end
73
75
  end
74
- end
75
76
 
76
- included do
77
- api_access_rules do
78
- default_option(:creator) { try(:current_user) }
77
+ included do
78
+ api_access_rules do
79
+ default_option(:creator) { try(:current_user) }
80
+ end
79
81
  end
80
- end
81
82
 
82
- def api_access_allowed?
83
- self.class.api_access_allowed?
84
- end
83
+ def api_access_allowed?
84
+ self.class.api_access_allowed?
85
+ end
85
86
 
86
- module Mixin
87
- extend ActiveSupport::Concern
87
+ module Mixin
88
+ extend ActiveSupport::Concern
88
89
 
89
- included do
90
- begin
91
- ::BackgroundTaskChannel
92
- after_commit do
93
- ::BackgroundTaskChannel.broadcast_to(self, api_serialize) if api_access_allowed?
90
+ included do
91
+ begin
92
+ ::BackgroundTaskChannel
93
+ after_commit do
94
+ ::BackgroundTaskChannel.broadcast_to(self, api_serialize) if api_access_allowed?
95
+ end
96
+ rescue NameError
94
97
  end
95
- rescue NameError
96
98
  end
97
- end
98
99
 
99
- def api_serialize
100
- builder = Jbuilder.new do |json|
101
- json.id id
102
- json.type type
103
- json.workflow_state workflow_state
100
+ def api_serialize
101
+ builder = Jbuilder.new do |json|
102
+ json.id id
103
+ json.type type
104
+ json.workflow_state workflow_state
104
105
 
105
- rule_sets = self.class.api_access_rules
106
- rule_sets.each do |aar|
107
- instance_exec(json, &aar.serializer) if aar.serializer
106
+ json.error_message error_message || "Internal Error" if workflow_state == 'failed'
107
+
108
+ rule_sets = self.class.api_access_rules
109
+ rule_sets.each do |aar|
110
+ instance_exec(json, &aar.serializer) if aar.serializer
111
+ end
108
112
  end
109
- end
110
113
 
111
- builder.attributes!
112
- end
114
+ builder.attributes!
115
+ end
113
116
 
114
- def api_validate_options
115
- errors = []
116
- rule_sets = self.class.api_access_rules
117
- rule_sets.each do |aar|
118
- aar.validators.each do |validator|
119
- errors << instance_exec(&validator)
117
+ def api_validate_options
118
+ errors = []
119
+ rule_sets = self.class.api_access_rules
120
+ rule_sets.each do |aar|
121
+ aar.validators.each do |validator|
122
+ errors << instance_exec(&validator)
123
+ end
120
124
  end
125
+ errors.flatten.compact.uniq
121
126
  end
122
- errors.flatten.compact.uniq
123
127
  end
124
- end
125
128
 
126
- class ApiAccessRules
127
- attr_accessor :serializer
128
- attr_reader :validators
129
- attr_reader :default_options
130
- attr_reader :permitted_options
131
-
132
- def initialize
133
- @serializer = nil
134
- @validators = []
135
- @permitted_options = []
136
- @default_options = {}
137
- end
129
+ class ApiAccessRules
130
+ attr_accessor :serializer
131
+ attr_reader :validators
132
+ attr_reader :default_options
133
+ attr_reader :permitted_options
134
+
135
+ def initialize
136
+ @serializer = nil
137
+ @validators = []
138
+ @permitted_options = []
139
+ @default_options = {}
140
+ end
138
141
 
139
- def default_option(key, value = nil, &blk)
140
- @default_options[key] = blk || value
141
- end
142
+ def default_option(key, value = nil, &blk)
143
+ @default_options[key] = blk || value
144
+ end
142
145
 
143
- def permit_options(*args)
144
- @permitted_options.concat(args)
145
- end
146
+ def permit_options(*args)
147
+ @permitted_options.concat(args)
148
+ end
146
149
 
147
- def validate(&blk)
148
- @validators << blk
149
- end
150
+ def validate(&blk)
151
+ @validators << blk
152
+ end
150
153
 
151
- def serialize(&blk)
152
- @serializer = blk
154
+ def serialize(&blk)
155
+ @serializer = blk
156
+ end
153
157
  end
154
158
  end
155
159
  end
@@ -1,49 +1,51 @@
1
1
  module Joblin
2
- module BackgroundTask::Attachments
3
- extend ActiveSupport::Concern
4
-
5
- include Concerns::JobWorkingDirs
6
-
7
- def attachment_path(key, expires_in: true)
8
- if !expires_in || Rails.env.development? || Rails.env.test?
9
- return Rails.application.routes.url_helpers.rails_blob_path(
10
- send(key),
11
- only_path: true,
12
- disposition: 'attachment',
13
- # organization_id: current_organization&.id,
14
- )
15
- end
2
+ class BackgroundTask
3
+ module Attachments
4
+ extend ActiveSupport::Concern
5
+
6
+ include Joblin::Concerns::JobWorkingDirs
7
+
8
+ def attachment_path(key, expires_in: true)
9
+ if !expires_in || Rails.env.development? || Rails.env.test?
10
+ return Rails.application.routes.url_helpers.rails_blob_path(
11
+ send(key),
12
+ only_path: true,
13
+ disposition: 'attachment',
14
+ # organization_id: current_organization&.id,
15
+ )
16
+ end
16
17
 
17
- if expires_in == true
18
- send(key).url
19
- else expires_in
20
- send(key).url(expires_in:)
18
+ if expires_in == true
19
+ send(key).url
20
+ else expires_in
21
+ send(key).url(expires_in:)
22
+ end
21
23
  end
22
- end
23
24
 
24
- def attach_file(key, path, as: nil)
25
- path = File.join(working_dir, path) unless Pathname.new(path).absolute?
25
+ def attach_file(key, path, as: nil)
26
+ path = File.join(working_dir, path) unless Pathname.new(path).absolute?
26
27
 
27
- self.send(key).attach(
28
- io: File.open(path),
29
- filename: as || File.basename(path)
30
- )
31
- end
28
+ self.send(key).attach(
29
+ io: File.open(path),
30
+ filename: as || File.basename(path)
31
+ )
32
+ end
32
33
 
33
- def load_attachment(key, save_as: nil)
34
- @loaded_attachments ||= {}
34
+ def load_attachment(key, save_as: nil)
35
+ @loaded_attachments ||= {}
35
36
 
36
- @loaded_attachments[key] ||= begin
37
- save_as = save_as || send(key).filename.to_s || key.to_s
38
- save_as = File.join(working_dir, save_as) unless Pathname.new(save_as).absolute?
37
+ @loaded_attachments[key] ||= begin
38
+ save_as = save_as || send(key).filename.to_s || key.to_s
39
+ save_as = File.join(working_dir, save_as) unless Pathname.new(save_as).absolute?
39
40
 
40
- File.open(save_as, 'w') do |file|
41
- send(key).download do |chunk|
42
- file.write(chunk.force_encoding("UTF-8"))
41
+ File.open(save_as, 'w') do |file|
42
+ send(key).download do |chunk|
43
+ file.write(chunk.force_encoding("UTF-8"))
44
+ end
43
45
  end
46
+ save_as
44
47
  end
45
- save_as
46
48
  end
47
49
  end
48
50
  end
49
- end
51
+ end
@@ -1,79 +1,81 @@
1
1
  module Joblin
2
- module BackgroundTask::Executor
3
- extend ActiveSupport::Concern
2
+ class BackgroundTask
3
+ module Executor
4
+ extend ActiveSupport::Concern
4
5
 
5
- class_methods do
6
- def inherited(subclass)
7
- subclass.const_set(:ExecutorJob, Class.new(self::ExecutorJob))
8
- super
9
- end
6
+ class_methods do
7
+ def inherited(subclass)
8
+ subclass.const_set(:ExecutorJob, Class.new(self::ExecutorJob))
9
+ super
10
+ end
10
11
 
11
- def job_executor_class
12
- self::ExecutorJob
13
- end
12
+ def job_executor_class
13
+ self::ExecutorJob
14
+ end
14
15
 
15
- def executor_eval(&blk)
16
- job_executor_class.class_eval(&blk)
17
- end
16
+ def executor_eval(&blk)
17
+ job_executor_class.class_eval(&blk)
18
+ end
18
19
 
19
- delegate :queue_as, :sidekiq_options, to: :job_executor_class
20
+ delegate :queue_as, :sidekiq_options, to: :job_executor_class
20
21
 
21
- %i[before around after].each do |hook_type|
22
- define_method(:"#{hook_type}_perform") do |*args, &callback|
23
- callback ||= args[0]
24
- if callback.is_a?(Symbol) || callback.is_a?(String)
25
- job_executor_class.define_method(callback) do |*margs, &blk|
26
- the_task.send(callback, *margs, &blk)
27
- end
28
- job_executor_class.send(:"#{hook_type}_perform", callback)
29
- elsif callback.is_a?(Proc)
30
- # Not exactly pretty...
31
- proc = case callback.arity
32
- when 0
33
- -> { the_task.instance_exec(&callback) }
34
- when 1
35
- ->(a) { the_task.instance_exec(a, &callback) }
36
- when 2
37
- ->(a, b) { the_task.instance_exec(a, b, &callback) }
38
- else
39
- raise ArgumentError, "Unsupported callback arity #{callback.arity}"
22
+ %i[before around after].each do |hook_type|
23
+ define_method(:"#{hook_type}_perform") do |*args, &callback|
24
+ callback ||= args[0]
25
+ if callback.is_a?(Symbol) || callback.is_a?(String)
26
+ job_executor_class.define_method(callback) do |*margs, &blk|
27
+ the_task.send(callback, *margs, &blk)
28
+ end
29
+ job_executor_class.send(:"#{hook_type}_perform", callback)
30
+ elsif callback.is_a?(Proc)
31
+ # Not exactly pretty...
32
+ proc = case callback.arity
33
+ when 0
34
+ -> { the_task.instance_exec(&callback) }
35
+ when 1
36
+ ->(a) { the_task.instance_exec(a, &callback) }
37
+ when 2
38
+ ->(a, b) { the_task.instance_exec(a, b, &callback) }
39
+ else
40
+ raise ArgumentError, "Unsupported callback arity #{callback.arity}"
41
+ end
42
+ job_executor_class.send(:"#{hook_type}_perform", &proc)
40
43
  end
41
- job_executor_class.send(:"#{hook_type}_perform", &proc)
42
44
  end
43
45
  end
44
- end
45
-
46
- # delegate :set, to: :job_executor_class
47
-
48
- # delegate :before_perform, :around_perform, :after_perform, to: :job_executor_class
49
- # delegate :before_enqueue, :around_enqueue, :after_enqueue, to: :job_executor_class
50
- end
51
-
52
- included do
53
- delegate_missing_to :_current_executor
54
- end
55
46
 
56
- protected
47
+ # delegate :set, to: :job_executor_class
57
48
 
58
- def _current_executor
59
- @executor
60
- end
49
+ # delegate :before_perform, :around_perform, :after_perform, to: :job_executor_class
50
+ # delegate :before_enqueue, :around_enqueue, :after_enqueue, to: :job_executor_class
51
+ end
61
52
 
62
- class ExecutorJob < ActiveJob::Base
63
- # include JobWorkingDirs
53
+ included do
54
+ delegate_missing_to :_current_executor
55
+ end
64
56
 
65
- def perform
66
- the_task.update(workflow_state: "started") if the_task.workflow_state == "scheduled"
57
+ protected
67
58
 
68
- the_task.perform
59
+ def _current_executor
60
+ @executor
69
61
  end
70
62
 
71
- def the_task
72
- @the_task ||= BackgroundTask.find(batch_context[:background_task_id]).tap do |task|
73
- task.instance_variable_set(:@executor, self)
63
+ class ExecutorJob < ActiveJob::Base
64
+ def perform
65
+ the_task.update(workflow_state: "started") if the_task.workflow_state == "scheduled"
66
+ the_task.perform
67
+ rescue InvalidJobError => ex
68
+ the_task.error_message ||= ex.message
69
+ the_task.workflow_state = "failed"
70
+ the_task.save! if the_task.changed?
71
+ end
72
+
73
+ def the_task
74
+ @the_task ||= BackgroundTask.find(batch_context[:background_task_id]).tap do |task|
75
+ task.instance_variable_set(:@executor, self)
76
+ end
74
77
  end
75
78
  end
76
79
  end
77
-
78
80
  end
79
81
  end
@@ -1,5 +1,5 @@
1
- module Joblin
2
- module BackgroundTask::Options
1
+ class Joblin::BackgroundTask
2
+ module Options
3
3
  extend ActiveSupport::Concern
4
4
 
5
5
  class_methods do
@@ -16,7 +16,8 @@ module Joblin
16
16
  end
17
17
 
18
18
  included do
19
- serialize :extra_options, coder: LazyAccess
19
+ serialize :results, coder: Joblin::LazyAccess
20
+ serialize :extra_options, coder: Joblin::LazyAccess
20
21
 
21
22
  after_initialize do
22
23
  self.extra_options ||= HashWithIndifferentAccess.new
@@ -1,26 +1,28 @@
1
1
  module Joblin
2
- module BackgroundTask::RetentionPolicy
3
- extend ActiveSupport::Concern
2
+ class BackgroundTask
3
+ module RetentionPolicy
4
+ extend ActiveSupport::Concern
4
5
 
5
- class_methods do
6
- def record_retention(policy = nil)
7
- if policy.nil?
8
- @record_retention || superclass.try(:record_retention)
9
- else
10
- @record_retention = policy
6
+ class_methods do
7
+ def record_retention(policy = nil)
8
+ if policy.nil?
9
+ @record_retention || superclass.try(:record_retention)
10
+ else
11
+ @record_retention = policy
12
+ end
11
13
  end
12
14
  end
13
- end
14
15
 
15
- class BackgroundTaskCleaner < ActiveJob::Base
16
- def perform
17
- types = BackgroundTask.distinct.pluck(:type)
18
- types.each do |type|
19
- type = type.constantize
20
- rp = type.record_retention
21
- next unless rp
16
+ class BackgroundTaskCleaner < ActiveJob::Base
17
+ def perform
18
+ types = BackgroundTask.distinct.pluck(:type)
19
+ types.each do |type|
20
+ type = type.constantize
21
+ rp = type.record_retention
22
+ next unless rp
22
23
 
23
- type.where("created_at < ?", rp.ago).destroy_all
24
+ type.where("created_at < ?", rp.ago).destroy_all
25
+ end
24
26
  end
25
27
  end
26
28
  end
@@ -6,8 +6,12 @@ module Joblin
6
6
  include Attachments
7
7
  include ApiAccess
8
8
 
9
+ class InvalidJobError < StandardError; end
10
+
9
11
  belongs_to :creator, polymorphic: true, optional: true
10
12
 
13
+ validates :workflow_state, inclusion: { in: %w[unscheduled scheduled started completed failed cancelled] }
14
+
11
15
  after_initialize do
12
16
  self.workflow_state ||= 'unscheduled'
13
17
  end
@@ -34,6 +38,10 @@ module Joblin
34
38
  update!(workflow_state: 'cancelled')
35
39
  end
36
40
 
41
+ def handle_batch_stagnation
42
+ Joblin::Batching::Batch.cleanup_redis(batch_id)
43
+ end
44
+
37
45
  protected
38
46
 
39
47
  def perform
@@ -46,6 +54,7 @@ module Joblin
46
54
  b.description = "BackgroundTask #{id}"
47
55
  b.on(:complete, BackgroundTaskCallbacks, id: id)
48
56
  b.on(:success, BackgroundTaskCallbacks, id: id)
57
+ b.on(:stagnated, BackgroundTaskCallbacks, id: id)
49
58
  b.context[:background_task_id] = id
50
59
  end
51
60
  b.jobs(&blk)
@@ -56,16 +65,21 @@ module Joblin
56
65
  tracker = BackgroundTask.find(options['id'])
57
66
  return if tracker.workflow_state == 'cancelled'
58
67
 
59
- tracker.workflow_state = status.success? ? 'completed' : 'failed'
60
- tracker.save! if tracker.changed?
68
+ # tracker.update!(workflow_state: 'completed') if status.success?
61
69
  end
62
70
 
63
71
  def on_success(status, options)
64
72
  tracker = BackgroundTask.find(options['id'])
65
73
  return if tracker.workflow_state == 'cancelled'
74
+ return if tracker.workflow_state == 'failed'
66
75
 
67
- tracker.workflow_state = 'completed'
68
- tracker.save! if tracker.changed?
76
+ tracker.update!(workflow_state: 'completed')
77
+ end
78
+
79
+ def on_stagnated(status, options)
80
+ tracker = BackgroundTask.find(options['id'])
81
+ tracker.update(workflow_state: 'failed') if tracker.workflow_state != 'cancelled'
82
+ tracker.handle_batch_stagnation
69
83
  end
70
84
  end
71
85
  end
@@ -0,0 +1,6 @@
1
+ class AddResultsToJoblinBackgroundTasks < ActiveRecord::Migration[5.2]
2
+ def change
3
+ add_column :joblin_background_tasks, :results, :jsonb
4
+ add_column :joblin_background_tasks, :error_message, :text
5
+ end
6
+ end
@@ -1,3 +1,3 @@
1
1
  module Joblin
2
- VERSION = "0.1.3".freeze
2
+ VERSION = "0.1.5".freeze
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: joblin
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.3
4
+ version: 0.1.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ethan Knapp
@@ -156,6 +156,7 @@ files:
156
156
  - app/models/joblin/background_task/retention_policy.rb
157
157
  - app/models/joblin/concerns/job_working_dirs.rb
158
158
  - db/migrate/20250903184852_create_joblin_background_tasks.rb
159
+ - db/migrate/20250916184852_add_results_to_joblin_background_tasks.rb
159
160
  - joblin.gemspec
160
161
  - lib/joblin.rb
161
162
  - lib/joblin/batching/batch.rb