sidejob 3.0.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 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