async-background 1.0.1 → 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.
@@ -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
@@ -9,79 +9,73 @@ module Async
9
9
  ROLLBACK = 'ROLLBACK'.freeze
10
10
  QUERY_ONLY = 'PRAGMA query_only = ON'.freeze
11
11
  BUSY_TIMEOUT = 'PRAGMA busy_timeout = 2000'.freeze
12
+ BASE_COLUMNS = %w[id class_name args options].freeze
12
13
 
13
- OVERVIEW_EXECUTING = "SELECT COUNT(*) FROM jobs WHERE status = 'running' AND started_at IS NOT NULL".freeze
14
- OVERVIEW_CLAIMED = "SELECT COUNT(*) FROM jobs WHERE status = 'running' AND started_at IS NULL".freeze
15
- OVERVIEW_PENDING = "SELECT COUNT(*) FROM jobs WHERE status = 'pending'".freeze
16
- OVERVIEW_DONE = "SELECT COUNT(*) FROM jobs WHERE status = 'done'".freeze
17
- OVERVIEW_FAILED = "SELECT COUNT(*) FROM jobs WHERE status = 'failed'".freeze
18
- OVERVIEW_NEXT_PENDING = "SELECT MIN(run_at) FROM jobs WHERE status = 'pending'".freeze
14
+ STATUS_PREDICATE = {
15
+ executing: "status = 'running' AND started_at IS NOT NULL",
16
+ claimed: "status = 'running' AND started_at IS NULL",
17
+ pending: "status = 'pending'",
18
+ done: "status = 'done'",
19
+ failed: "status = 'failed'"
20
+ }.freeze
19
21
 
20
- EXECUTING = <<~SQL.freeze
21
- SELECT id, class_name, args, options, started_at, locked_by, locked_at
22
- FROM jobs
23
- WHERE status = 'running' AND started_at IS NOT NULL
24
- ORDER BY started_at, id
25
- LIMIT ?
26
- SQL
22
+ EXTRA_COLUMNS = {
23
+ executing: %w[started_at locked_by locked_at],
24
+ claimed: %w[locked_at locked_by],
25
+ pending: %w[created_at run_at],
26
+ done: %w[finished_at duration_ms],
27
+ failed: %w[finished_at duration_ms last_error_class last_error_message]
28
+ }.freeze
27
29
 
28
- CLAIMED = <<~SQL.freeze
29
- SELECT id, class_name, args, options, locked_at, locked_by
30
- FROM jobs
31
- WHERE status = 'running' AND started_at IS NULL
32
- ORDER BY locked_at, id
33
- LIMIT ?
34
- SQL
30
+ KEYSET = {
31
+ executing: ['started_at', :asc],
32
+ claimed: ['locked_at', :asc],
33
+ pending: ['run_at', :asc],
34
+ done: ['finished_at', :desc],
35
+ failed: ['finished_at', :desc]
36
+ }.freeze
35
37
 
36
- DONE = <<~SQL.freeze
37
- SELECT id, class_name, args, options, finished_at, duration_ms
38
- FROM jobs
39
- WHERE status = 'done'
40
- ORDER BY finished_at DESC, id DESC
41
- LIMIT ?
42
- SQL
38
+ ORDER_SQL = {asc: 'ORDER BY %<column>s, id', desc: 'ORDER BY %<column>s DESC, id DESC'}.freeze
39
+ SEEK_SQL = {asc: '(%<column>s, id) > (?, ?)', desc: '(%<column>s, id) < (?, ?)'}.freeze
43
40
 
44
- DONE_AFTER = <<~SQL.freeze
45
- SELECT id, class_name, args, options, finished_at, duration_ms
46
- FROM jobs
47
- WHERE status = 'done' AND (finished_at, id) < (?, ?)
48
- ORDER BY finished_at DESC, id DESC
49
- LIMIT ?
50
- SQL
41
+ def self.columns_for(status)
42
+ (BASE_COLUMNS + EXTRA_COLUMNS.fetch(status)).join(', ')
43
+ end
51
44
 
52
- FAILED = <<~SQL.freeze
53
- SELECT id, class_name, args, options, finished_at, duration_ms,
54
- last_error_class, last_error_message
55
- FROM jobs
56
- WHERE status = 'failed'
57
- ORDER BY finished_at DESC, id DESC
58
- LIMIT ?
59
- SQL
45
+ def self.count_query(status)
46
+ "SELECT COUNT(*) FROM jobs WHERE #{STATUS_PREDICATE.fetch(status)}".freeze
47
+ end
60
48
 
61
- FAILED_AFTER = <<~SQL.freeze
62
- SELECT id, class_name, args, options, finished_at, duration_ms,
63
- last_error_class, last_error_message
64
- FROM jobs
65
- WHERE status = 'failed' AND (finished_at, id) < (?, ?)
66
- ORDER BY finished_at DESC, id DESC
67
- LIMIT ?
68
- SQL
49
+ def self.list_query(status, seek: false)
50
+ column, direction = KEYSET.fetch(status)
51
+ predicate = STATUS_PREDICATE.fetch(status)
52
+ predicate += " AND #{format(SEEK_SQL.fetch(direction), column: column)}" if seek
69
53
 
70
- PENDING = <<~SQL.freeze
71
- SELECT id, class_name, args, options, created_at, run_at
72
- FROM jobs
73
- WHERE status = 'pending'
74
- ORDER BY run_at, id
75
- LIMIT ?
76
- SQL
54
+ <<~SQL.freeze
55
+ SELECT #{columns_for(status)}
56
+ FROM jobs
57
+ WHERE #{predicate}
58
+ #{format(ORDER_SQL.fetch(direction), column: column)}
59
+ LIMIT ?
60
+ SQL
61
+ end
77
62
 
78
- PENDING_AFTER = <<~SQL.freeze
79
- SELECT id, class_name, args, options, created_at, run_at
80
- FROM jobs
81
- WHERE status = 'pending' AND (run_at, id) > (?, ?)
82
- ORDER BY run_at, id
83
- LIMIT ?
84
- SQL
63
+ OVERVIEW_EXECUTING = count_query(:executing)
64
+ OVERVIEW_CLAIMED = count_query(:claimed)
65
+ OVERVIEW_PENDING = count_query(:pending)
66
+ OVERVIEW_DONE = count_query(:done)
67
+ OVERVIEW_FAILED = count_query(:failed)
68
+ OVERVIEW_NEXT_PENDING = "SELECT MIN(run_at) FROM jobs WHERE #{STATUS_PREDICATE.fetch(:pending)}".freeze
69
+
70
+ EXECUTING = list_query(:executing)
71
+ CLAIMED = list_query(:claimed)
72
+ DONE = list_query(:done)
73
+ FAILED = list_query(:failed)
74
+ PENDING = list_query(:pending)
75
+
76
+ DONE_AFTER = list_query(:done, seek: true)
77
+ FAILED_AFTER = list_query(:failed, seek: true)
78
+ PENDING_AFTER = list_query(:pending, seek: true)
85
79
  end
86
80
  end
87
81
  end