async-background 1.0.2 → 1.1.1

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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +81 -0
  3. data/README.md +36 -15
  4. data/async-background.gemspec +13 -7
  5. data/lib/async/background/job.rb +75 -28
  6. data/lib/async/background/metrics.rb +41 -33
  7. data/lib/async/background/min_heap.rb +16 -12
  8. data/lib/async/background/queue/client.rb +11 -8
  9. data/lib/async/background/queue/options.rb +3 -3
  10. data/lib/async/background/queue/schema.rb +19 -15
  11. data/lib/async/background/queue/socket_notifier.rb +14 -4
  12. data/lib/async/background/queue/socket_waker.rb +139 -56
  13. data/lib/async/background/queue/sql.rb +31 -31
  14. data/lib/async/background/queue/store.rb +44 -45
  15. data/lib/async/background/runner/lifecycle.rb +131 -0
  16. data/lib/async/background/runner/queue_execution.rb +101 -57
  17. data/lib/async/background/runner/schedule.rb +20 -17
  18. data/lib/async/background/runner.rb +98 -77
  19. data/lib/async/background/runtime/notification.rb +42 -0
  20. data/lib/async/background/runtime/semaphore.rb +151 -0
  21. data/lib/async/background/runtime/task.rb +146 -0
  22. data/lib/async/background/runtime/task_group.rb +81 -0
  23. data/lib/async/background/runtime.rb +246 -0
  24. data/lib/async/background/scheduler.rb +145 -0
  25. data/lib/async/background/version.rb +1 -1
  26. data/lib/async/background/web/app.rb +43 -40
  27. data/lib/async/background/web/assets/app.css +174 -0
  28. data/lib/async/background/web/assets/app.js +486 -0
  29. data/lib/async/background/web/assets/index.html +29 -0
  30. data/lib/async/background/web/assets.rb +4 -697
  31. data/lib/async/background/web/auth.rb +3 -3
  32. data/lib/async/background/web/configuration.rb +40 -93
  33. data/lib/async/background/web/cursor.rb +22 -26
  34. data/lib/async/background/web/event_hub.rb +12 -7
  35. data/lib/async/background/web/metrics_reader.rb +28 -36
  36. data/lib/async/background/web/response.rb +39 -51
  37. data/lib/async/background/web/serializer.rb +23 -63
  38. data/lib/async/background/web/snapshot.rb +35 -81
  39. data/lib/async/background/web/sql.rb +63 -69
  40. data/lib/async/background/web/stream.rb +52 -32
  41. data/lib/async/background/web.rb +1 -1
  42. data/lib/async/background.rb +1 -2
  43. metadata +30 -22
@@ -11,10 +11,10 @@ module Async
11
11
 
12
12
  def authorized?(env)
13
13
  !!@callable.call(env)
14
- rescue StandardError => error
14
+ rescue StandardError => e
15
15
  @logger&.warn(
16
- "[async-background-web] auth callable raised: " \
17
- "#{error.class}: #{error.message}"
16
+ '[async-background-web] auth callable raised: ' \
17
+ "#{e.class}: #{e.message}"
18
18
  )
19
19
  false
20
20
  end
@@ -53,18 +53,47 @@ module Async
53
53
  @logger = nil
54
54
  end
55
55
 
56
+ RULES = [
57
+ [:queue_path, ->(value, _) { !value.nil? && !value.to_s.empty? }, 'queue_path must be set'],
58
+ [:auth, ->(value, _) { !value.nil? }, 'auth must be configured (gem ships no permissive default)'],
59
+ [:auth, ->(value, _) { value.respond_to?(:call) },
60
+ 'auth must respond to #call(env) and return truthy on success'],
61
+ [:list_limit, ->(value, _) { value.is_a?(Integer) && value.between?(1, MAX_LIST_LIMIT) },
62
+ "list_limit must be an Integer between 1 and #{MAX_LIST_LIMIT}"],
63
+ [:counts_cache_ttl, ->(value, _) { value.is_a?(Numeric) && value >= 0 },
64
+ 'counts_cache_ttl must be a non-negative Numeric'],
65
+ [:poll_interval_ms, ->(value, _) { value.is_a?(Integer) && value >= 200 },
66
+ 'poll_interval_ms must be an Integer >= 200'],
67
+ [:transport, ->(value, _) { TRANSPORTS.include?(value) },
68
+ "transport must be one of #{TRANSPORTS.inspect}"],
69
+ [:stream_poll_seconds, ->(value, _) { value.is_a?(Numeric) && value >= 0.1 },
70
+ 'stream_poll_seconds must be a Numeric >= 0.1'],
71
+ [:stream_heartbeat_seconds, ->(value, _) { value.is_a?(Numeric) && value >= 5 },
72
+ 'stream_heartbeat_seconds must be a Numeric >= 5'],
73
+ [:stream_retry_ms, ->(value, _) { value.is_a?(Integer) && value >= 500 },
74
+ 'stream_retry_ms must be an Integer >= 500'],
75
+ [:redact_args, ->(value, config) { !config.expose_args || value.nil? || value.respond_to?(:call) },
76
+ 'redact_args must respond to #call(args)'],
77
+ [:total_workers, lambda { |value, config|
78
+ !config.metrics_enabled? || (value.is_a?(Integer) && value.positive?)
79
+ }, 'metrics_path requires total_workers to be a positive Integer'],
80
+ [:mount_path, ->(value, _) { value.is_a?(String) }, 'mount_path must be a String'],
81
+ [:mount_path, ->(value, _) { value.empty? || value.start_with?('/') },
82
+ 'mount_path must start with "/" or be empty'],
83
+ [:mount_path, ->(value, _) { value.empty? || !value.end_with?('/') },
84
+ 'mount_path must not end with "/"'],
85
+ [:mount_path, ->(value, _) { value.empty? || !value.match?(/[[:cntrl:]]/) },
86
+ 'mount_path must not contain control characters'],
87
+ [:mount_path, ->(value, _) { value.empty? || !value.match?(/\s/) },
88
+ 'mount_path must not contain whitespace'],
89
+ [:logger, ->(value, _) { value.nil? || (value.respond_to?(:warn) && value.respond_to?(:error)) },
90
+ 'logger must respond to #warn and #error']
91
+ ].freeze
92
+
56
93
  def validate!
57
- validate_queue_path!
58
- validate_auth!
59
- validate_list_limit!
60
- validate_cache_ttl!
61
- validate_poll_interval!
62
- validate_transport!
63
- validate_stream!
64
- validate_redactor!
65
- validate_metrics!
66
- validate_mount_path!
67
- validate_logger!
94
+ RULES.each do |attribute, valid, message|
95
+ raise ConfigurationError, message unless valid.call(public_send(attribute), self)
96
+ end
68
97
  self
69
98
  end
70
99
 
@@ -84,88 +113,6 @@ module Async
84
113
  def metrics_enabled?
85
114
  !metrics_path.nil?
86
115
  end
87
-
88
- private
89
-
90
- def validate_queue_path!
91
- raise ConfigurationError, 'queue_path must be set' if queue_path.nil? || queue_path.to_s.empty?
92
- end
93
-
94
- def validate_auth!
95
- raise ConfigurationError, 'auth must be configured (gem ships no permissive default)' if auth.nil?
96
-
97
- return if auth.respond_to?(:call)
98
-
99
- raise ConfigurationError, 'auth must respond to #call(env) and return truthy on success'
100
- end
101
-
102
- def validate_list_limit!
103
- return if list_limit.is_a?(Integer) && list_limit.between?(1, MAX_LIST_LIMIT)
104
-
105
- raise ConfigurationError, "list_limit must be an Integer between 1 and #{MAX_LIST_LIMIT}"
106
- end
107
-
108
- def validate_cache_ttl!
109
- return if counts_cache_ttl.is_a?(Numeric) && counts_cache_ttl >= 0
110
-
111
- raise ConfigurationError, 'counts_cache_ttl must be a non-negative Numeric'
112
- end
113
-
114
- def validate_poll_interval!
115
- return if poll_interval_ms.is_a?(Integer) && poll_interval_ms >= 200
116
-
117
- raise ConfigurationError, 'poll_interval_ms must be an Integer >= 200'
118
- end
119
-
120
- def validate_transport!
121
- return if TRANSPORTS.include?(transport)
122
-
123
- raise ConfigurationError, "transport must be one of #{TRANSPORTS.inspect}"
124
- end
125
-
126
- def validate_stream!
127
- unless stream_poll_seconds.is_a?(Numeric) && stream_poll_seconds >= 0.1
128
- raise ConfigurationError, 'stream_poll_seconds must be a Numeric >= 0.1'
129
- end
130
-
131
- unless stream_heartbeat_seconds.is_a?(Numeric) && stream_heartbeat_seconds >= 5
132
- raise ConfigurationError, 'stream_heartbeat_seconds must be a Numeric >= 5'
133
- end
134
-
135
- return if stream_retry_ms.is_a?(Integer) && stream_retry_ms >= 500
136
-
137
- raise ConfigurationError, 'stream_retry_ms must be an Integer >= 500'
138
- end
139
-
140
- def validate_redactor!
141
- return unless expose_args && redact_args && !redact_args.respond_to?(:call)
142
-
143
- raise ConfigurationError, 'redact_args must respond to #call(args)'
144
- end
145
-
146
- def validate_metrics!
147
- return unless metrics_enabled?
148
- return if total_workers.is_a?(Integer) && total_workers.positive?
149
-
150
- raise ConfigurationError, 'metrics_path requires total_workers to be a positive Integer'
151
- end
152
-
153
- def validate_mount_path!
154
- raise ConfigurationError, 'mount_path must be a String' unless mount_path.is_a?(String)
155
- return if mount_path.empty?
156
-
157
- raise ConfigurationError, 'mount_path must start with "/" or be empty' unless mount_path.start_with?('/')
158
- raise ConfigurationError, 'mount_path must not end with "/"' if mount_path.end_with?('/')
159
- raise ConfigurationError, 'mount_path must not contain control characters' if mount_path.match?(/[[:cntrl:]]/)
160
- raise ConfigurationError, 'mount_path must not contain whitespace' if mount_path.match?(/\s/)
161
- end
162
-
163
- def validate_logger!
164
- return if logger.nil?
165
- return if logger.respond_to?(:warn) && logger.respond_to?(:error)
166
-
167
- raise ConfigurationError, 'logger must respond to #warn and #error'
168
- end
169
116
  end
170
117
  end
171
118
  end
@@ -8,50 +8,46 @@ module Async
8
8
  module Cursor
9
9
  module_function
10
10
 
11
- def encode_finished(finished_at, id)
12
- encode(finished_at, id)
13
- end
11
+ def encode(timestamp, id)
12
+ return if timestamp.nil? || id.nil?
14
13
 
15
- def encode_pending(run_at, id)
16
- encode(run_at, id)
14
+ Base64.urlsafe_encode64("#{Float(timestamp)}:#{Integer(id)}", padding: false)
17
15
  end
18
16
 
19
- def decode_finished(value)
20
- timestamp, id = decode(value)
21
- return unless timestamp
17
+ def encode_finished(finished_at, id) = encode(finished_at, id)
18
+ def encode_pending(run_at, id) = encode(run_at, id)
19
+ def decode_finished(value) = decode_as(:finished_at, value)
20
+ def decode_pending(value) = decode_as(:run_at, value)
22
21
 
23
- {finished_at: timestamp, id: id}
24
- end
25
-
26
- def decode_pending(value)
22
+ def decode_as(key, value)
27
23
  timestamp, id = decode(value)
28
24
  return unless timestamp
29
25
 
30
- {run_at: timestamp, id: id}
26
+ { key => timestamp, :id => id }
31
27
  end
32
28
 
33
- def encode(timestamp, id)
34
- return if timestamp.nil? || id.nil?
35
-
36
- Base64.urlsafe_encode64("#{Float(timestamp)}:#{Integer(id)}", padding: false)
37
- end
38
- private_class_method :encode
39
-
40
29
  def decode(value)
41
30
  return if value.nil? || value.to_s.empty?
42
31
 
43
- timestamp_raw, id_raw, extra = Base64.urlsafe_decode64(value.to_s).split(':', 3)
32
+ decode_fields(Base64.urlsafe_decode64(value.to_s))
33
+ rescue ArgumentError, TypeError
34
+ raise RequestError, 'invalid cursor'
35
+ end
36
+
37
+ def decode_fields(payload)
38
+ timestamp_raw, id_raw, extra = payload.split(':', 3)
44
39
  raise RequestError, 'invalid cursor' if timestamp_raw.nil? || id_raw.nil? || extra
45
40
 
46
41
  timestamp = Float(timestamp_raw)
47
42
  id = Integer(id_raw)
48
- raise RequestError, 'invalid cursor' unless timestamp.finite? && id.positive?
49
-
43
+ validate_fields!(timestamp, id)
50
44
  [timestamp, id]
51
- rescue ArgumentError, TypeError
52
- raise RequestError, 'invalid cursor'
53
45
  end
54
- private_class_method :decode
46
+
47
+ def validate_fields!(timestamp, id)
48
+ raise RequestError, 'invalid cursor' unless timestamp.finite? && id.positive?
49
+ end
50
+ private_class_method :decode, :decode_as, :decode_fields, :validate_fields!
55
51
  end
56
52
  end
57
53
  end
@@ -20,14 +20,13 @@ module Async
20
20
  end
21
21
 
22
22
  def current_version
23
- @mutex.synchronize { raise ClosedError, 'event hub is closed' if @closed }
23
+ with_open {}
24
24
  @snapshot.data_version
25
25
  end
26
26
 
27
27
  def frame_for(version)
28
- @mutex.synchronize do
29
- raise ClosedError, 'event hub is closed' if @closed
30
- return @cached_frame if @cached_version == version && @cached_frame
28
+ with_open do
29
+ next @cached_frame if @cached_version == version && @cached_frame
31
30
 
32
31
  refresh_frame_locked!
33
32
  @cached_frame
@@ -35,9 +34,7 @@ module Async
35
34
  end
36
35
 
37
36
  def initial_frame
38
- @mutex.synchronize do
39
- raise ClosedError, 'event hub is closed' if @closed
40
-
37
+ with_open do
41
38
  refresh_frame_locked!
42
39
  [@cached_version, @cached_frame]
43
40
  end
@@ -58,6 +55,14 @@ module Async
58
55
 
59
56
  private
60
57
 
58
+ def with_open
59
+ @mutex.synchronize do
60
+ raise ClosedError, 'event hub is closed' if @closed
61
+
62
+ yield
63
+ end
64
+ end
65
+
61
66
  def refresh_frame_locked!
62
67
  overview = @snapshot.overview(force: true)
63
68
  metrics = @metrics_reader&.aggregated
@@ -11,16 +11,22 @@ module Async
11
11
 
12
12
  DEFAULT_TTL = 1.0
13
13
  EMPTY_WORKERS = [].freeze
14
- EMPTY_TOTALS = {
15
- total_runs: 0,
16
- total_successes: 0,
17
- total_failures: 0,
18
- total_timeouts: 0,
19
- total_skips: 0,
20
- active_jobs: 0,
21
- last_run_at: 0,
22
- last_duration_ms: nil
23
- }.freeze
14
+ SUMMED_FIELDS = %i[
15
+ total_runs
16
+ total_successes
17
+ total_failures
18
+ total_timeouts
19
+ total_skips
20
+ active_jobs
21
+ ].freeze
22
+
23
+ LATEST_FIELD = :last_run_at
24
+ LATEST_COMPANION = :last_duration_ms
25
+
26
+ EMPTY_TOTALS = SUMMED_FIELDS.to_h { |field| [field, 0] }
27
+ .merge(LATEST_FIELD => 0, LATEST_COMPANION => nil).freeze
28
+
29
+ UNAVAILABLE = { available: false, workers: EMPTY_WORKERS, totals: EMPTY_TOTALS }.freeze
24
30
 
25
31
  def initialize(path:, total_workers:, ttl: DEFAULT_TTL)
26
32
  @path = path
@@ -52,42 +58,28 @@ module Async
52
58
  return unavailable unless Metrics.available? && File.file?(@path)
53
59
 
54
60
  workers = Metrics.read_all(total_workers: @total_workers, path: @path)
55
- {available: true, workers: workers, totals: aggregate(workers)}
61
+ { available: true, workers: workers, totals: aggregate(workers) }
56
62
  rescue StandardError
57
63
  unavailable
58
64
  end
59
65
 
60
- def unavailable
61
- {available: false, workers: EMPTY_WORKERS, totals: EMPTY_TOTALS}
62
- end
66
+ def unavailable = UNAVAILABLE
63
67
 
64
68
  def aggregate(workers)
65
- totals = {
66
- total_runs: 0,
67
- total_successes: 0,
68
- total_failures: 0,
69
- total_timeouts: 0,
70
- total_skips: 0,
71
- active_jobs: 0,
72
- last_run_at: 0,
73
- last_duration_ms: nil
74
- }
69
+ totals = EMPTY_TOTALS.dup
70
+ most_recent = nil
75
71
 
76
72
  workers.each do |worker|
77
- totals[:total_runs] += worker[:total_runs].to_i
78
- totals[:total_successes] += worker[:total_successes].to_i
79
- totals[:total_failures] += worker[:total_failures].to_i
80
- totals[:total_timeouts] += worker[:total_timeouts].to_i
81
- totals[:total_skips] += worker[:total_skips].to_i
82
- totals[:active_jobs] += worker[:active_jobs].to_i
83
-
84
- last_run_at = worker[:last_run_at].to_i
85
- next unless last_run_at > totals[:last_run_at]
86
-
87
- totals[:last_run_at] = last_run_at
88
- totals[:last_duration_ms] = worker[:last_duration_ms]
73
+ SUMMED_FIELDS.each { |field| totals[field] += worker[field].to_i }
74
+
75
+ last_run_at = worker[LATEST_FIELD].to_i
76
+ next unless last_run_at > totals[LATEST_FIELD]
77
+
78
+ totals[LATEST_FIELD] = last_run_at
79
+ most_recent = worker
89
80
  end
90
81
 
82
+ totals[LATEST_COMPANION] = most_recent[LATEST_COMPANION] if most_recent
91
83
  totals.freeze
92
84
  end
93
85
  end
@@ -13,8 +13,12 @@ module Async
13
13
  TEXT_TYPE = 'text/plain; charset=utf-8'
14
14
  JAVASCRIPT_TYPE = 'application/javascript; charset=utf-8'
15
15
  CSS_TYPE = 'text/css; charset=utf-8'
16
+ EVENT_STREAM_TYPE = 'text/event-stream; charset=utf-8'
17
+
16
18
  NO_STORE = 'no-store'
17
19
  ASSET_CACHE = 'public, max-age=31536000, immutable'
20
+ SSE_CACHE = 'no-cache, no-transform'
21
+
18
22
  BASE_SECURITY_HEADERS = {
19
23
  'x-content-type-options' => 'nosniff',
20
24
  'referrer-policy' => 'no-referrer',
@@ -34,73 +38,57 @@ module Async
34
38
  "form-action 'none'"
35
39
  ).freeze
36
40
 
37
- UNAUTHORIZED_BODY = JSON.generate(error: 'unauthorized').freeze
38
- NOT_FOUND_BODY = JSON.generate(error: 'not_found').freeze
39
- BAD_REQUEST_BODY = JSON.generate(error: 'invalid_request').freeze
40
- UNAVAILABLE_BODY = JSON.generate(error: 'service_unavailable').freeze
41
- INTERNAL_ERROR_BODY = JSON.generate(error: 'internal_error').freeze
42
- EVENT_STREAM_TYPE = 'text/event-stream; charset=utf-8'
41
+ ERRORS = {
42
+ unauthorized: [401, 'unauthorized'],
43
+ not_found: [404, 'not_found'],
44
+ bad_request: [400, 'invalid_request'],
45
+ unavailable: [503, 'service_unavailable'],
46
+ internal_error: [500, 'internal_error']
47
+ }.freeze
43
48
 
44
- def sse(body)
45
- [200, sse_headers, body]
46
- end
49
+ ERROR_BODY = ERRORS.to_h { |name, (_, code)| [name, JSON.generate(error: code).freeze] }.freeze
47
50
 
48
- def json(payload, status: 200)
49
- [status, no_store_headers(JSON_TYPE), [JSON.generate(payload)]]
51
+ def headers(content_type, cache_control, security = BASE_SECURITY_HEADERS)
52
+ { 'content-type' => content_type, 'cache-control' => cache_control }.merge(security)
50
53
  end
51
54
 
52
- def html(body)
53
- [200, html_headers, [body]]
54
- end
55
+ def no_store_headers(content_type) = headers(content_type, NO_STORE)
56
+ def html_headers = headers(HTML_TYPE, NO_STORE, HTML_SECURITY_HEADERS)
57
+ def asset_headers(content_type) = headers(content_type, ASSET_CACHE)
55
58
 
56
- def javascript(body)
57
- [200, asset_headers(JAVASCRIPT_TYPE), [body]]
59
+ def sse_headers
60
+ {
61
+ 'content-type' => EVENT_STREAM_TYPE,
62
+ 'cache-control' => SSE_CACHE,
63
+ 'x-accel-buffering' => 'no'
64
+ }.merge(BASE_SECURITY_HEADERS)
58
65
  end
59
66
 
60
- def stylesheet(body)
61
- [200, asset_headers(CSS_TYPE), [body]]
62
- end
67
+ def sse(body) = [200, sse_headers, body]
68
+ def html(body) = [200, html_headers, [body]]
69
+ def javascript(body) = [200, asset_headers(JAVASCRIPT_TYPE), [body]]
70
+ def stylesheet(body) = [200, asset_headers(CSS_TYPE), [body]]
63
71
 
64
- def unauthorized
65
- [401, no_store_headers(JSON_TYPE), [UNAUTHORIZED_BODY]]
72
+ def json(payload, status: 200)
73
+ [status, no_store_headers(JSON_TYPE), [JSON.generate(payload)]]
66
74
  end
67
75
 
68
- def not_found
69
- [404, no_store_headers(JSON_TYPE), [NOT_FOUND_BODY]]
70
- end
76
+ def unauthorized = error_response(:unauthorized)
77
+ def not_found = error_response(:not_found)
78
+ def unavailable = error_response(:unavailable)
79
+ def internal_error = error_response(:internal_error)
71
80
 
72
81
  def bad_request(message = nil)
73
- body = message.nil? ? BAD_REQUEST_BODY : JSON.generate(error: 'invalid_request', message: message)
74
- [400, no_store_headers(JSON_TYPE), [body]]
75
- end
76
-
77
- def unavailable
78
- [503, no_store_headers(JSON_TYPE), [UNAVAILABLE_BODY]]
79
- end
80
-
81
- def internal_error
82
- [500, no_store_headers(JSON_TYPE), [INTERNAL_ERROR_BODY]]
83
- end
84
-
85
- def no_store_headers(content_type)
86
- {'content-type' => content_type, 'cache-control' => NO_STORE}.merge(BASE_SECURITY_HEADERS)
87
- end
88
-
89
- def html_headers
90
- {'content-type' => HTML_TYPE, 'cache-control' => NO_STORE}.merge(HTML_SECURITY_HEADERS)
91
- end
82
+ return error_response(:bad_request) if message.nil?
92
83
 
93
- def asset_headers(content_type)
94
- {'content-type' => content_type, 'cache-control' => ASSET_CACHE}.merge(BASE_SECURITY_HEADERS)
84
+ error_response(:bad_request, JSON.generate(error: 'invalid_request', message: message))
95
85
  end
96
86
 
97
- def sse_headers
98
- {
99
- 'content-type' => EVENT_STREAM_TYPE,
100
- 'cache-control' => 'no-cache, no-transform',
101
- 'x-accel-buffering' => 'no'
102
- }.merge(BASE_SECURITY_HEADERS)
87
+ def error_response(name, body = ERROR_BODY.fetch(name))
88
+ status = ERRORS.fetch(name).first
89
+ [status, no_store_headers(JSON_TYPE), [body]]
103
90
  end
91
+ private_class_method :error_response
104
92
  end
105
93
  end
106
94
  end
@@ -26,97 +26,57 @@ module Async
26
26
  end
27
27
 
28
28
  def executing(rows)
29
- rows.map { |row| executing_item(row) }
29
+ rows.map { |row| item(:executing, row) }
30
30
  end
31
31
 
32
32
  def claimed(rows)
33
- rows.map { |row| claimed_item(row) }
33
+ rows.map { |row| item(:claimed, row) }
34
34
  end
35
35
 
36
36
  def done(rows)
37
- page(rows.map { |row| done_item(row) }) { |item| Cursor.encode_finished(item[:finished_at], item[:id]) }
37
+ page(:done, rows)
38
38
  end
39
39
 
40
40
  def failed(rows)
41
- page(rows.map { |row| failed_item(row) }) { |item| Cursor.encode_finished(item[:finished_at], item[:id]) }
41
+ page(:failed, rows)
42
42
  end
43
43
 
44
44
  def pending(rows)
45
- page(rows.map { |row| pending_item(row) }) { |item| Cursor.encode_pending(item[:run_at], item[:id]) }
45
+ page(:pending, rows)
46
46
  end
47
47
 
48
48
  private
49
49
 
50
- def page(items)
51
- {items: items, next_cursor: items.empty? ? nil : yield(items.last)}
52
- end
50
+ EXTRA_FIELDS = {
51
+ executing: %i[started_at locked_by locked_at].freeze,
52
+ claimed: %i[locked_at locked_by].freeze,
53
+ done: %i[finished_at duration_ms].freeze,
54
+ failed: %i[finished_at duration_ms last_error_class last_error_message].freeze,
55
+ pending: %i[created_at run_at].freeze
56
+ }.freeze
53
57
 
54
- def executing_item(row)
55
- args, args_count = args_for(row[:args_raw])
56
- {
57
- id: row[:id],
58
- class_name: row[:class_name],
59
- args: args,
60
- args_count: args_count,
61
- options: parse_options(row[:options_raw]),
62
- started_at: row[:started_at],
63
- locked_by: row[:locked_by],
64
- locked_at: row[:locked_at]
65
- }
66
- end
58
+ CURSOR_KEY = { done: :finished_at, failed: :finished_at, pending: :run_at }.freeze
67
59
 
68
- def claimed_item(row)
60
+ def item(kind, row)
69
61
  args, args_count = args_for(row[:args_raw])
70
- {
71
- id: row[:id],
72
- class_name: row[:class_name],
73
- args: args,
74
- args_count: args_count,
75
- options: parse_options(row[:options_raw]),
76
- locked_at: row[:locked_at],
77
- locked_by: row[:locked_by]
78
- }
79
- end
80
62
 
81
- def done_item(row)
82
- args, args_count = args_for(row[:args_raw])
83
- {
63
+ fields = {
84
64
  id: row[:id],
85
65
  class_name: row[:class_name],
86
66
  args: args,
87
67
  args_count: args_count,
88
- options: parse_options(row[:options_raw]),
89
- finished_at: row[:finished_at],
90
- duration_ms: row[:duration_ms]
68
+ options: parse_options(row[:options_raw])
91
69
  }
70
+ EXTRA_FIELDS.fetch(kind).each { |name| fields[name] = row[name] }
71
+ fields
92
72
  end
93
73
 
94
- def failed_item(row)
95
- args, args_count = args_for(row[:args_raw])
96
- {
97
- id: row[:id],
98
- class_name: row[:class_name],
99
- args: args,
100
- args_count: args_count,
101
- options: parse_options(row[:options_raw]),
102
- finished_at: row[:finished_at],
103
- duration_ms: row[:duration_ms],
104
- last_error_class: row[:last_error_class],
105
- last_error_message: row[:last_error_message]
106
- }
107
- end
74
+ def page(kind, rows)
75
+ items = rows.map { |row| item(kind, row) }
76
+ last = items.last
77
+ cursor = last && Cursor.encode(last[CURSOR_KEY.fetch(kind)], last[:id])
108
78
 
109
- def pending_item(row)
110
- args, args_count = args_for(row[:args_raw])
111
- {
112
- id: row[:id],
113
- class_name: row[:class_name],
114
- args: args,
115
- args_count: args_count,
116
- options: parse_options(row[:options_raw]),
117
- created_at: row[:created_at],
118
- run_at: row[:run_at]
119
- }
79
+ { items: items, next_cursor: cursor }
120
80
  end
121
81
 
122
82
  def args_for(raw)