async-background 1.0.2 → 1.1.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.
@@ -8,34 +8,23 @@ 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
22
-
23
- {finished_at: timestamp, id: id}
24
- end
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)
25
21
 
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}
31
- end
32
-
33
- def encode(timestamp, id)
34
- return if timestamp.nil? || id.nil?
35
-
36
- Base64.urlsafe_encode64("#{Float(timestamp)}:#{Integer(id)}", padding: false)
26
+ {key => timestamp, :id => id}
37
27
  end
38
- private_class_method :encode
39
28
 
40
29
  def decode(value)
41
30
  return if value.nil? || value.to_s.empty?
@@ -51,7 +40,7 @@ module Async
51
40
  rescue ArgumentError, TypeError
52
41
  raise RequestError, 'invalid cursor'
53
42
  end
54
- private_class_method :decode
43
+ private_class_method :decode, :decode_as
55
44
  end
56
45
  end
57
46
  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,24 @@ 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 = (
27
+ SUMMED_FIELDS.to_h { |field| [field, 0] }
28
+ .merge(LATEST_FIELD => 0, LATEST_COMPANION => nil)
29
+ ).freeze
30
+
31
+ UNAVAILABLE = {available: false, workers: EMPTY_WORKERS, totals: EMPTY_TOTALS}.freeze
24
32
 
25
33
  def initialize(path:, total_workers:, ttl: DEFAULT_TTL)
26
34
  @path = path
@@ -57,37 +65,23 @@ module Async
57
65
  unavailable
58
66
  end
59
67
 
60
- def unavailable
61
- {available: false, workers: EMPTY_WORKERS, totals: EMPTY_TOTALS}
62
- end
68
+ def unavailable = UNAVAILABLE
63
69
 
64
70
  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
- }
71
+ totals = EMPTY_TOTALS.dup
72
+ most_recent = nil
75
73
 
76
74
  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]
75
+ SUMMED_FIELDS.each { |field| totals[field] += worker[field].to_i }
76
+
77
+ last_run_at = worker[LATEST_FIELD].to_i
78
+ next unless last_run_at > totals[LATEST_FIELD]
79
+
80
+ totals[LATEST_FIELD] = last_run_at
81
+ most_recent = worker
89
82
  end
90
83
 
84
+ totals[LATEST_COMPANION] = most_recent[LATEST_COMPANION] if most_recent
91
85
  totals.freeze
92
86
  end
93
87
  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)
@@ -13,6 +13,20 @@ module Async
13
13
  include Clock
14
14
 
15
15
  CacheEntry = Data.define(:value, :created_at)
16
+ RAW_COLUMNS = {'args' => :args_raw, 'options' => :options_raw}.freeze
17
+
18
+ ROW_KEYS = SQL::EXTRA_COLUMNS.to_h do |status, extra|
19
+ columns = SQL::BASE_COLUMNS + extra
20
+ [status, columns.map { |column| RAW_COLUMNS.fetch(column, column.to_sym) }.freeze]
21
+ end.freeze
22
+
23
+ PAGING = {
24
+ executing: [SQL::EXECUTING, nil, nil].freeze,
25
+ claimed: [SQL::CLAIMED, nil, nil].freeze,
26
+ done: [SQL::DONE, SQL::DONE_AFTER, %i[finished_at id].freeze].freeze,
27
+ failed: [SQL::FAILED, SQL::FAILED_AFTER, %i[finished_at id].freeze].freeze,
28
+ pending: [SQL::PENDING, SQL::PENDING_AFTER, %i[run_at id].freeze].freeze
29
+ }.freeze
16
30
 
17
31
  def initialize(path:, counts_cache_ttl:)
18
32
  @path = path
@@ -65,26 +79,23 @@ module Async
65
79
  end
66
80
 
67
81
  def executing(limit:)
68
- read_rows(SQL::EXECUTING, [limit]).map { |row| executing_row(row) }
82
+ list(:executing, limit: limit)
69
83
  end
70
84
 
71
85
  def claimed(limit:)
72
- read_rows(SQL::CLAIMED, [limit]).map { |row| claimed_row(row) }
86
+ list(:claimed, limit: limit)
73
87
  end
74
88
 
75
89
  def recent_done(limit:, cursor: nil)
76
- sql, binds = terminal_query(SQL::DONE, SQL::DONE_AFTER, limit, cursor)
77
- read_rows(sql, binds).map { |row| done_row(row) }
90
+ list(:done, limit: limit, cursor: cursor)
78
91
  end
79
92
 
80
93
  def recent_failed(limit:, cursor: nil)
81
- sql, binds = terminal_query(SQL::FAILED, SQL::FAILED_AFTER, limit, cursor)
82
- read_rows(sql, binds).map { |row| failed_row(row) }
94
+ list(:failed, limit: limit, cursor: cursor)
83
95
  end
84
96
 
85
97
  def pending(limit:, cursor: nil)
86
- sql, binds = pending_query(limit, cursor)
87
- read_rows(sql, binds).map { |row| pending_row(row) }
98
+ list(:pending, limit: limit, cursor: cursor)
88
99
  end
89
100
 
90
101
  private
@@ -103,7 +114,7 @@ module Async
103
114
  end
104
115
 
105
116
  def database_uri
106
- path = URI::DEFAULT_PARSER.escape(File.expand_path(@path)).gsub('?', '%3F')
117
+ path = URI::RFC2396_PARSER.escape(File.expand_path(@path)).gsub('?', '%3F')
107
118
  "file:#{path}?mode=ro"
108
119
  end
109
120
 
@@ -153,10 +164,10 @@ module Async
153
164
  {
154
165
  counts: {
155
166
  executing: db.get_first_value(SQL::OVERVIEW_EXECUTING).to_i,
156
- claimed: db.get_first_value(SQL::OVERVIEW_CLAIMED).to_i,
157
- pending: db.get_first_value(SQL::OVERVIEW_PENDING).to_i,
158
- done: db.get_first_value(SQL::OVERVIEW_DONE).to_i,
159
- failed: db.get_first_value(SQL::OVERVIEW_FAILED).to_i
167
+ claimed: db.get_first_value(SQL::OVERVIEW_CLAIMED).to_i,
168
+ pending: db.get_first_value(SQL::OVERVIEW_PENDING).to_i,
169
+ done: db.get_first_value(SQL::OVERVIEW_DONE).to_i,
170
+ failed: db.get_first_value(SQL::OVERVIEW_FAILED).to_i
160
171
  }.freeze,
161
172
  next_pending_run_at: db.get_first_value(SQL::OVERVIEW_NEXT_PENDING),
162
173
  data_version: db.get_first_value(Queue::SQL::DATA_VERSION).to_i,
@@ -164,74 +175,17 @@ module Async
164
175
  }
165
176
  end
166
177
 
167
- def terminal_query(first_page_sql, next_page_sql, limit, cursor)
168
- return [first_page_sql, [limit]] unless cursor
169
-
170
- [next_page_sql, [cursor.fetch(:finished_at), cursor.fetch(:id), limit]]
171
- end
172
-
173
- def pending_query(limit, cursor)
174
- return [SQL::PENDING, [limit]] unless cursor
175
-
176
- [SQL::PENDING_AFTER, [cursor.fetch(:run_at), cursor.fetch(:id), limit]]
178
+ def list(status, limit:, cursor: nil)
179
+ sql, binds = page_query(status, limit, cursor)
180
+ keys = ROW_KEYS.fetch(status)
181
+ read_rows(sql, binds).map { |row| keys.zip(row).to_h }
177
182
  end
178
183
 
179
- def executing_row(row)
180
- {
181
- id: row[0],
182
- class_name: row[1],
183
- args_raw: row[2],
184
- options_raw: row[3],
185
- started_at: row[4],
186
- locked_by: row[5],
187
- locked_at: row[6]
188
- }
189
- end
184
+ def page_query(status, limit, cursor)
185
+ first_sql, seek_sql, cursor_keys = PAGING.fetch(status)
186
+ return [first_sql, [limit]] unless cursor
190
187
 
191
- def claimed_row(row)
192
- {
193
- id: row[0],
194
- class_name: row[1],
195
- args_raw: row[2],
196
- options_raw: row[3],
197
- locked_at: row[4],
198
- locked_by: row[5]
199
- }
200
- end
201
-
202
- def done_row(row)
203
- {
204
- id: row[0],
205
- class_name: row[1],
206
- args_raw: row[2],
207
- options_raw: row[3],
208
- finished_at: row[4],
209
- duration_ms: row[5]
210
- }
211
- end
212
-
213
- def failed_row(row)
214
- {
215
- id: row[0],
216
- class_name: row[1],
217
- args_raw: row[2],
218
- options_raw: row[3],
219
- finished_at: row[4],
220
- duration_ms: row[5],
221
- last_error_class: row[6],
222
- last_error_message: row[7]
223
- }
224
- end
225
-
226
- def pending_row(row)
227
- {
228
- id: row[0],
229
- class_name: row[1],
230
- args_raw: row[2],
231
- options_raw: row[3],
232
- created_at: row[4],
233
- run_at: row[5]
234
- }
188
+ [seek_sql, cursor_keys.map { |key| cursor.fetch(key) } << limit]
235
189
  end
236
190
 
237
191
  def require_sqlite3