sidejob 3.0.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: d993f3349b71794286776cd15dd1a08052466080
4
+ data.tar.gz: db8eb058a8329b4be54604762efaa68b7f053bff
5
+ SHA512:
6
+ metadata.gz: ab477bb2a9ad4ad67977249c081a235c95c74e44cc4157e884d8570c4b6dde177d8f458142eff70323867706216c45329167441be46875d75676e0801c42e6c3
7
+ data.tar.gz: 94543337bb043a8c35a11ea0823419572d6bdf8c4d48011cc61d2c28e08ff9d2bb891d18d8e8cb10d430eb21d81d183284c5400d3e5288376aa6926fef2ee364
data/.gitignore ADDED
@@ -0,0 +1,5 @@
1
+ .bundle/
2
+ *.gem
3
+ coverage/
4
+ .yardoc/
5
+ doc/
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --color
2
+ --format progress
data/Gemfile ADDED
@@ -0,0 +1,3 @@
1
+ source 'https://rubygems.org'
2
+
3
+ gemspec
data/Gemfile.lock ADDED
@@ -0,0 +1,61 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ sidejob (3.0.0)
5
+ sidekiq (~> 3.2.5)
6
+
7
+ GEM
8
+ remote: https://rubygems.org/
9
+ specs:
10
+ celluloid (0.15.2)
11
+ timers (~> 1.1.0)
12
+ coderay (1.1.0)
13
+ connection_pool (2.0.0)
14
+ diff-lcs (1.2.5)
15
+ docile (1.1.5)
16
+ json (1.8.1)
17
+ method_source (0.8.2)
18
+ multi_json (1.10.1)
19
+ pry (0.9.12.6)
20
+ coderay (~> 1.0)
21
+ method_source (~> 0.8)
22
+ slop (~> 3.4)
23
+ redis (3.1.0)
24
+ redis-namespace (1.5.1)
25
+ redis (~> 3.0, >= 3.0.4)
26
+ rspec (3.1.0)
27
+ rspec-core (~> 3.1.0)
28
+ rspec-expectations (~> 3.1.0)
29
+ rspec-mocks (~> 3.1.0)
30
+ rspec-core (3.1.7)
31
+ rspec-support (~> 3.1.0)
32
+ rspec-expectations (3.1.2)
33
+ diff-lcs (>= 1.2.0, < 2.0)
34
+ rspec-support (~> 3.1.0)
35
+ rspec-mocks (3.1.3)
36
+ rspec-support (~> 3.1.0)
37
+ rspec-support (3.1.2)
38
+ sidekiq (3.2.6)
39
+ celluloid (= 0.15.2)
40
+ connection_pool (>= 2.0.0)
41
+ json
42
+ redis (>= 3.0.6)
43
+ redis-namespace (>= 1.3.1)
44
+ simplecov (0.9.0)
45
+ docile (~> 1.1.0)
46
+ multi_json
47
+ simplecov-html (~> 0.8.0)
48
+ simplecov-html (0.8.0)
49
+ slop (3.4.7)
50
+ timers (1.1.0)
51
+ yard (0.8.7.4)
52
+
53
+ PLATFORMS
54
+ ruby
55
+
56
+ DEPENDENCIES
57
+ pry
58
+ rspec
59
+ sidejob!
60
+ simplecov
61
+ yard
data/README.md ADDED
@@ -0,0 +1,128 @@
1
+ SideJob
2
+ =======
3
+
4
+ SideJob is built on top of [Sidekiq](https://github.com/mperham/sidekiq) and
5
+ [Redis](http://redis.io/). Sidekiq jobs typically complete relatively quickly.
6
+ Just like a typical side job, a job in SideJob may depend on other jobs or may make slow progress
7
+ and take a long time (such as months). They may be suspended and restarted many times.
8
+ The job should be robust to the crashing or downtime of any portion of the infrastructure.
9
+
10
+ Requirements
11
+ ------------
12
+
13
+ Ruby 2.0 or greater and Redis 2.8 or greater is recommended.
14
+
15
+ Jobs
16
+ ----
17
+
18
+ * Jobs have a unique ID assigned using incrementing numbers
19
+ * This ID is used as Sidekiq's jid
20
+ * Note: a job can be queued multiple times on Sidekiq's queues
21
+ * Therefore, Sidekiq's jids are not unique
22
+ * Jobs have a queue and class name
23
+ * Jobs have any number of input and output ports
24
+ * A job can have any number of named child jobs
25
+ * Each job has at most one parent job
26
+ * Jobs can store any JSON encoded object in its internal state
27
+
28
+ Jobs can have a number of different status. The statuses and possible status transitions:
29
+
30
+ * -> queued
31
+ * queued -> running | terminating
32
+ * running -> queued | suspended | completed | failed | terminating
33
+ * suspended | completed | failed -> queued | terminating
34
+ * terminating -> terminated
35
+ * terminated -> queued
36
+
37
+ The difference between suspended, completed, and failed is only in their implications on the
38
+ internal job state. Completed implies that the job has processed all data and can be naturally
39
+ terminated. If additional input arrives, a completed job could continue running. Suspended implies
40
+ that the job is waiting for some input. Failed means that an exception was thrown.
41
+
42
+ Jobs that have been terminated along with all their children can be deleted entirely.
43
+
44
+ Ports
45
+ -----
46
+
47
+ * Ports are named (case sensitive) and must match `/^[a-zA-Z0-9_]+$/`.
48
+ * Any object that can be JSON encoded can be written or read from any input or output port.
49
+ * Ports must be explicitly specified for each job either by the worker configuration or when queuing new jobs unless
50
+ a port named `*` exists in which case new ports are dynamically created and inherit its options.
51
+
52
+ Port options:
53
+
54
+ * mode
55
+ * Queue - This is the default operation mode. All data written is read in a first in first out manner.
56
+ * Memory - No data is stored on the port. The most recent value sets the port default value.
57
+ * default - Default value when a read is done on the port with no data
58
+
59
+ Workers
60
+ -------
61
+
62
+ * A worker is the implementation of a specific job class
63
+ * Workers are required to register themselves
64
+ * A Sidekiq process should only handle a single queue so all registered workers in the process are for the same queue
65
+ * It should have a perform method that is called on each run
66
+ * It may have a shutdown method that is called before the job is terminated
67
+ * Workers should be idempotent as they may be run more than once for the same state
68
+ * SideJob ensures only one worker thread runs for a given job at a time
69
+ * Workers are responsible for managing state across runs
70
+ * Workers can suspend themselves when waiting for inputs
71
+
72
+ Data Structure
73
+ --------------
74
+
75
+ SideJob uses Redis for all job processing and storage. Code using
76
+ SideJob should use API functions instead of accessing redis directly,
77
+ but this is a description of the current data storage format.
78
+
79
+ The easiest way to set the redis location is via the environment
80
+ variable SIDEJOB_URL, e.g. redis://redis.myhost.com:6379/4
81
+
82
+ The keys used by Sidekiq:
83
+
84
+ * queues - Set containing all queue names
85
+ * queue:<queue> - List containing jobs to be run (new jobs pushed on left) on the given queue
86
+ * A sidekiq job is encoded as json and contains at minimum: queue, retry, class, jid, args
87
+ * schedule - Sorted set by schedule time of jobs to run in the future
88
+ * retry - Sorted set by retry time of jobs to retry
89
+ * dead - Sorted set by addition time of dead jobs
90
+ * processes - Set of sidekiq processes (values are host:pid)
91
+ * <host>:<pid> - Hash representing a connected sidekiq process
92
+ * beat - heartbeat timestamp (every 5 seconds)
93
+ * busy - number of busy workers
94
+ * info - JSON encoded info with keys hostname, started_at, pid, tag, concurrency, queues, labels. Expiry of 60 seconds.
95
+ * <host>:<pid>:workers - Hash containing running workers (thread ID -> { queue: <queue>, payload: <message>, run_at: <timestamp> })
96
+ * <host>:<pid>-signals - List for remotely sending signals to a sidekiq process (USR1 and TERM), 60 second expiry.
97
+ * stat:processed - Cumulative number of jobs processed
98
+ * stat:failed - Cumulative number of jobs failed
99
+ * stat:processed:<date> - Number of jobs processed for given date
100
+ * stat:failed:<date> - Number of jobs failed for given date
101
+
102
+ Additional keys used by SideJob:
103
+
104
+ * workers:<queue> - Hash mapping class name to worker configuration. A worker should define
105
+ the inports and outports hashes that map port names to port options.
106
+ * jobs:last_id - Stores the last job ID (we use incrementing integers from 1)
107
+ * jobs:logs - List with JSON encoded logs.
108
+ * { timestamp: <date>, job: <id>, read: [{ job: <id>, <in|out>port: <port>, data: [<data>] }, ...], write: [{ job: <id>, <in|out>port: <port>, data: [<data>] }, ...] }
109
+ * { timestamp: <date>, job: <id>, error: <message>, backtrace: <exception backtrace> }
110
+ * jobs - Hash mapping active job IDs to JSON encoded job state.
111
+ * queue - queue name
112
+ * class - name of class
113
+ * args - array of arguments passed to worker's perform method
114
+ * created_at - timestamp that the job was first queued
115
+ * created_by - string indicating the entity that created the job. SideJob uses job:<id> for jobs created by another job.
116
+ * ran_at - timestamp of the start of the last run
117
+ * Any additional keys used by the worker to track internal state
118
+ * job:<id>:status - Job status as string.
119
+ * job:<id>:in:<inport> and job:<id>:out:<outport> - List with unread port data. New data is pushed on the right.
120
+ * job:<id>:inports:mode and job:<id>:outports:mode - Hash mapping port name to port mode. All existing ports must be here.
121
+ * job:<id>:inports:default and job:<id>:outports:default - Hash mapping port name to JSON encoded default value for port.
122
+ * job:<id>:ancestors - List with parent job IDs up to the root job that has no parent.
123
+ Newer jobs are pushed on the left so the immediate parent is on the left and the root job is on the right.
124
+ * job:<id>:children - Hash mapping child job name to child job ID
125
+ * job:<id>:rate:<timestamp> - Rate limiter used to prevent run away executing of a job.
126
+ Keys are automatically expired.
127
+ * job:<id>:lock - Used to prevent multiple worker threads from running a job.
128
+ Auto expired to prevent stale locks.
data/bin/build ADDED
@@ -0,0 +1,11 @@
1
+ #!/bin/bash
2
+
3
+ echo "Building..."
4
+
5
+ # clean source tree
6
+ git clean -fxd
7
+
8
+ bundle check --no-color || bundle install --no-color || exit 1
9
+ bundle exec rspec spec || exit 1
10
+
11
+ bundle exec yard
data/bin/console ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'bundler/setup'
4
+ require 'sidejob'
5
+
6
+ require 'pry'
7
+ Pry.start
@@ -0,0 +1,425 @@
1
+ module SideJob
2
+ # Methods shared between {SideJob::Job} and {SideJob::Worker}.
3
+ module JobMethods
4
+ attr_reader :id
5
+ attr_accessor :logger
6
+
7
+ # @return [Boolean] True if two jobs or workers have the same id
8
+ def ==(other)
9
+ other.respond_to?(:id) && id == other.id
10
+ end
11
+
12
+ # @see #==
13
+ def eql?(other)
14
+ self == other
15
+ end
16
+
17
+ # @return [Fixnum] Hash value based on the id
18
+ def hash
19
+ id.hash
20
+ end
21
+
22
+ # @return [String] Prefix for all redis keys related to this job
23
+ def redis_key
24
+ "job:#{id}"
25
+ end
26
+ alias :to_s :redis_key
27
+
28
+ # Returns if the job still exists.
29
+ # @return [Boolean] Returns true if this job exists and has not been deleted
30
+ def exists?
31
+ SideJob.redis.hexists 'jobs', id
32
+ end
33
+
34
+ # If a job logger is defined, call the log method on it with the log entry. Otherwise, call {SideJob.log}.
35
+ # @param entry [Hash] Log entry
36
+ def log(entry)
37
+ entry[:job] = id unless entry[:job]
38
+ (@logger || SideJob).log(entry)
39
+ end
40
+
41
+ # Groups all port reads and writes within the block into a single logged event.
42
+ # @param metadata [Hash] If provided, the metadata is merged into the final log entry
43
+ def group_port_logs(metadata={}, &block)
44
+ new_group = @logger.nil?
45
+ @logger ||= GroupPortLogs.new(self)
46
+ @logger.add_metadata metadata
47
+ yield
48
+ ensure
49
+ if new_group
50
+ @logger.done
51
+ @logger = nil
52
+ end
53
+ end
54
+
55
+ # Retrieve the job's status.
56
+ # @return [String] Job status
57
+ def status
58
+ SideJob.redis.get "#{redis_key}:status"
59
+ end
60
+
61
+ # Set the job status.
62
+ # @param status [String] The new job status
63
+ def status=(status)
64
+ SideJob.redis.set "#{redis_key}:status", status
65
+ end
66
+
67
+ # Prepare to terminate the job. Sets status to 'terminating'.
68
+ # Then queues the job so that its shutdown method if it exists can be run.
69
+ # After shutdown, the status will be 'terminated'.
70
+ # If the job is currently running, it will finish running first.
71
+ # If the job is already terminated, it does nothing.
72
+ # To start the job after termination, call {#run} with force: true.
73
+ # @param recursive [Boolean] If true, recursively terminate all children (default false)
74
+ # @return [SideJob::Job] self
75
+ def terminate(recursive: false)
76
+ if status != 'terminated'
77
+ self.status = 'terminating'
78
+ sidekiq_queue
79
+ end
80
+ if recursive
81
+ children.each_value do |child|
82
+ child.terminate(recursive: true)
83
+ end
84
+ end
85
+ self
86
+ end
87
+
88
+ # Run the job.
89
+ # This method ensures that the job runs at least once from the beginning.
90
+ # If the job is currently running, it will run again.
91
+ # Just like sidekiq, we make no guarantees that the job will not be run more than once.
92
+ # Unless force is set, if the status is terminating or terminated, the job will not be run.
93
+ # @param force [Boolean] Whether to run if job is terminated (default false)
94
+ # @param at [Time, Float] Time to schedule the job, otherwise queue immediately
95
+ # @param wait [Float] Run in the specified number of seconds
96
+ # @return [SideJob::Job] self
97
+ def run(force: false, at: nil, wait: nil)
98
+ check_exists
99
+
100
+ case status
101
+ when 'terminating', 'terminated'
102
+ return unless force
103
+ end
104
+
105
+ self.status = 'queued'
106
+
107
+ time = nil
108
+ if at
109
+ time = at
110
+ time = time.to_f if time.is_a?(Time)
111
+ elsif wait
112
+ time = Time.now.to_f + wait
113
+ end
114
+ sidekiq_queue(time)
115
+
116
+ self
117
+ end
118
+
119
+ # Returns a child job by name.
120
+ # @param name [Symbol, String] Child job name to look up
121
+ # @return [SideJob::Job, nil] Child job or nil if not found
122
+ def child(name)
123
+ SideJob.find(SideJob.redis.hget("#{redis_key}:children", name))
124
+ end
125
+
126
+ # Returns all children jobs.
127
+ # @return [Hash<String => SideJob::Job>] Children jobs by name
128
+ def children
129
+ SideJob.redis.hgetall("#{redis_key}:children").each_with_object({}) {|child, hash| hash[child[0]] = SideJob.find(child[1])}
130
+ end
131
+
132
+ # Returns all ancestor jobs.
133
+ # @return [Array<SideJob::Job>] Ancestors (parent will be first and root job will be last)
134
+ def ancestors
135
+ SideJob.redis.lrange("#{redis_key}:ancestors", 0, -1).map { |id| SideJob.find(id) }
136
+ end
137
+
138
+ # Returns the parent job.
139
+ # @return [SideJob::Job, nil] Parent job or nil if none
140
+ def parent
141
+ parent = SideJob.redis.lindex("#{redis_key}:ancestors", 0)
142
+ parent = SideJob.find(parent) if parent
143
+ parent
144
+ end
145
+
146
+ # Returns if job and all children are terminated.
147
+ # @return [Boolean] True if this job and all children recursively are terminated
148
+ def terminated?
149
+ return false if status != 'terminated'
150
+ children.each_value do |child|
151
+ return false unless child.terminated?
152
+ end
153
+ return true
154
+ end
155
+
156
+ # Deletes the job and all children jobs (recursively) if all are terminated.
157
+ # @return [Boolean] Whether the job was deleted
158
+ def delete
159
+ return false unless terminated?
160
+
161
+ # recursively delete all children first
162
+ children.each_value do |child|
163
+ child.delete
164
+ end
165
+
166
+ # delete all SideJob keys
167
+ ports = inports.map(&:redis_key) + outports.map(&:redis_key)
168
+ SideJob.redis.multi do |multi|
169
+ multi.hdel 'jobs', id
170
+ multi.del ports + %w{status children ancestors inports:mode outports:mode inports:default outports:default}.map {|x| "#{redis_key}:#{x}" }
171
+ end
172
+ reload
173
+ return true
174
+ end
175
+
176
+ # Returns an input port.
177
+ # @param name [Symbol,String] Name of the port
178
+ # @return [SideJob::Port]
179
+ # @raise [RuntimeError] Error raised if port does not exist
180
+ def input(name)
181
+ get_port :in, name
182
+ end
183
+
184
+ # Returns an output port
185
+ # @param name [Symbol,String] Name of the port
186
+ # @return [SideJob::Port]
187
+ # @raise [RuntimeError] Error raised if port does not exist
188
+ def output(name)
189
+ get_port :out, name
190
+ end
191
+
192
+ # Gets all input ports.
193
+ # @return [Array<SideJob::Port>] Input ports
194
+ def inports
195
+ load_ports if ! @ports
196
+ @ports[:in].values
197
+ end
198
+
199
+ # Sets the input ports for the job.
200
+ # The ports are merged with the worker configuration.
201
+ # Any current ports that are not in the new port set are deleted (including any data on those ports).
202
+ # @param ports [Hash{Symbol,String => Hash}] Input port configuration. Port name to options.
203
+ def inports=(ports)
204
+ set_ports :in, ports
205
+ end
206
+
207
+ # Gets all output ports.
208
+ # @return [Array<SideJob::Port>] Output ports
209
+ def outports
210
+ load_ports if ! @ports
211
+ @ports[:out].values
212
+ end
213
+
214
+ # Sets the input ports for the job.
215
+ # The ports are merged with the worker configuration.
216
+ # Any current ports that are not in the new port set are deleted (including any data on those ports).
217
+ # @param ports [Hash{Symbol,String => Hash}] Output port configuration. Port name to options.
218
+ def outports=(ports)
219
+ set_ports :out, ports
220
+ end
221
+
222
+ # Returns some data from the job's state.
223
+ # The job state is cached for the lifetime of the job object. Call {#reload} if the state may have changed.
224
+ # @param key [Symbol,String] Retrieve value for the given key
225
+ # @return [Object,nil] Value from the job state or nil if key does not exist
226
+ # @raise [RuntimeError] Error raised if job no longer exists
227
+ def get(key)
228
+ load_state
229
+ @state[key.to_s]
230
+ end
231
+
232
+ # Clears the state and ports cache.
233
+ def reload
234
+ @state = nil
235
+ @ports = nil
236
+ @config = nil
237
+ end
238
+
239
+ # Returns the worker configuration for the job.
240
+ # @see SideJob::Worker.config
241
+ def config
242
+ @config ||= SideJob::Worker.config(get(:queue), get(:class))
243
+ end
244
+
245
+ private
246
+
247
+ # Queue or schedule this job using sidekiq.
248
+ # @param time [Time, Float, nil] Time to schedule the job if specified
249
+ def sidekiq_queue(time=nil)
250
+ queue = get(:queue)
251
+ klass = get(:class)
252
+ args = get(:args)
253
+
254
+ if ! SideJob.redis.hexists("workers:#{queue}", klass)
255
+ self.status = 'terminated'
256
+ raise "Worker no longer registered for #{klass} in queue #{queue}"
257
+ end
258
+ item = {'jid' => id, 'queue' => queue, 'class' => klass, 'args' => args || [], 'retry' => false}
259
+ item['at'] = time if time && time > Time.now.to_f
260
+ Sidekiq::Client.push(item)
261
+ end
262
+
263
+ # Caches all inports and outports.
264
+ def load_ports
265
+ @ports = {}
266
+ %i{in out}.each do |type|
267
+ @ports[type] = {}
268
+ SideJob.redis.hkeys("#{redis_key}:#{type}ports:mode").each do |name|
269
+ if name == '*'
270
+ @ports["#{type}*"] = SideJob::Port.new(self, type, name)
271
+ else
272
+ @ports[type][name] = SideJob::Port.new(self, type, name)
273
+ end
274
+ end
275
+ end
276
+ end
277
+
278
+ # Returns an input or output port.
279
+ # @param type [:in, :out] Input or output port
280
+ # @param name [Symbol,String] Name of the port
281
+ # @return [SideJob::Port]
282
+ def get_port(type, name)
283
+ load_ports if ! @ports
284
+ name = name.to_s
285
+ return @ports[type][name] if @ports[type][name]
286
+
287
+ if @ports["#{type}*"]
288
+ # create port with default port options for dynamic ports
289
+ port = SideJob::Port.new(self, type, name)
290
+ port.options = @ports["#{type}*"].options
291
+ @ports[type][name] = port
292
+ return port
293
+ else
294
+ raise "Unknown #{type}put port: #{name}"
295
+ end
296
+ end
297
+
298
+ # Sets the input/outputs ports for the job and overwrites all current options.
299
+ # The ports are merged with the worker configuration.
300
+ # Any current ports that are not in the new port set are deleted (including any data on those ports).
301
+ # @param type [:in, :out] Input or output ports
302
+ # @param ports [Hash{Symbol,String => Hash}] Port configuration. Port name to options.
303
+ def set_ports(type, ports)
304
+ current = SideJob.redis.hkeys("#{redis_key}:#{type}ports:mode") || []
305
+
306
+ replace_port_data = []
307
+ ports = (ports || {}).stringify_keys
308
+ ports = (config["#{type}ports"] || {}).merge(ports)
309
+ ports.each_key do |port|
310
+ ports[port] = ports[port].stringify_keys
311
+ replace_port_data << port if ports[port]['data']
312
+ end
313
+
314
+ SideJob.redis.multi do |multi|
315
+ # remove data from old ports
316
+ ((current - ports.keys) | replace_port_data).each do |port|
317
+ multi.del "#{redis_key}:#{type}:#{port}"
318
+ end
319
+
320
+ # completely replace the mode and default keys
321
+
322
+ multi.del "#{redis_key}:#{type}ports:mode"
323
+ modes = ports.map do |port, options|
324
+ [port, options['mode'] || 'queue']
325
+ end.flatten(1)
326
+ multi.hmset "#{redis_key}:#{type}ports:mode", *modes if modes.length > 0
327
+
328
+ defaults = ports.map do |port, options|
329
+ if options.has_key?('default')
330
+ [port, options['default'].to_json]
331
+ else
332
+ nil
333
+ end
334
+ end.compact.flatten(1)
335
+ multi.del "#{redis_key}:#{type}ports:default"
336
+ multi.hmset "#{redis_key}:#{type}ports:default", *defaults if defaults.length > 0
337
+ end
338
+
339
+ @ports = nil
340
+
341
+ group_port_logs do
342
+ ports.each_pair do |port, options|
343
+ if options['data']
344
+ port = get_port(type, port)
345
+ options['data'].each do |x|
346
+ port.write x
347
+ end
348
+ end
349
+ end
350
+ end
351
+ end
352
+
353
+ # @raise [RuntimeError] Error raised if job no longer exists
354
+ def check_exists
355
+ raise "Job #{id} no longer exists!" unless exists?
356
+ end
357
+
358
+ def load_state
359
+ if ! @state
360
+ state = SideJob.redis.hget('jobs', id)
361
+ raise "Job #{id} no longer exists!" if ! state
362
+ @state = JSON.parse(state)
363
+ end
364
+ @state
365
+ end
366
+ end
367
+
368
+ # Wrapper for a job which may not be in progress unlike SideJob::Worker.
369
+ # @see SideJob::JobMethods
370
+ class Job
371
+ include JobMethods
372
+
373
+ # @param id [String] Job id
374
+ def initialize(id)
375
+ @id = id
376
+ end
377
+ end
378
+
379
+ # Logger that groups all port read/writes together.
380
+ # @see {JobMethods#group_port_logs}
381
+ class GroupPortLogs
382
+ def initialize(job)
383
+ @metadata = {job: job.id}
384
+ end
385
+
386
+ # If entry is not a port log, send it on to {SideJob.log}. Otherwise, collect the log until {#done} is called.
387
+ # @param entry [Hash] Log entry
388
+ def log(entry)
389
+ if entry[:read] && entry[:write]
390
+ # collect reads and writes by port and group data together
391
+ @port_events ||= {read: {}, write: {}} # {job: id, <in|out>port: port} -> data array
392
+ %i{read write}.each do |type|
393
+ entry[type].each do |event|
394
+ data = event.delete(:data)
395
+ @port_events[type][event] ||= []
396
+ @port_events[type][event].concat data
397
+ end
398
+ end
399
+ else
400
+ SideJob.log(entry)
401
+ end
402
+ end
403
+
404
+ # Merges the collected port read and writes and send logs to {SideJob.log}.
405
+ def done
406
+ return unless @port_events && (@port_events[:read].length > 0 || @port_events[:write].length > 0)
407
+
408
+ entry = {}
409
+ %i{read write}.each do |type|
410
+ entry[type] = @port_events[type].map do |port, data|
411
+ port.merge({data: data})
412
+ end
413
+ end
414
+
415
+ SideJob.log @metadata.merge(entry)
416
+ @port_events = nil
417
+ end
418
+
419
+ # Add metadata fields to the final log entry.
420
+ # @param metadata [Hash] Data to be merged with the existing metadata and final log entry
421
+ def add_metadata(metadata)
422
+ @metadata.merge!(metadata.symbolize_keys)
423
+ end
424
+ end
425
+ end