async-background 0.7.0 → 0.7.2

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.
@@ -1,7 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'json'
4
+ require 'securerandom'
5
+
4
6
  require_relative '../clock'
7
+ require_relative 'options'
8
+ require_relative 'schema'
9
+ require_relative 'sql'
5
10
 
6
11
  module Async
7
12
  module Background
@@ -9,101 +14,133 @@ module Async
9
14
  class Store
10
15
  include Clock
11
16
 
12
- SCHEMA = <<~SQL
13
- PRAGMA auto_vacuum = INCREMENTAL;
14
- CREATE TABLE IF NOT EXISTS jobs (
15
- id INTEGER PRIMARY KEY,
16
- class_name TEXT NOT NULL,
17
- args TEXT NOT NULL DEFAULT '[]',
18
- options TEXT,
19
- status TEXT NOT NULL DEFAULT 'pending',
20
- created_at REAL NOT NULL,
21
- run_at REAL NOT NULL,
22
- locked_by INTEGER,
23
- locked_at REAL
24
- );
25
- CREATE INDEX IF NOT EXISTS idx_jobs_pending ON jobs(run_at, id) WHERE status = 'pending';
26
- SQL
27
-
28
- MMAP_SIZE = 268_435_456
29
- PRAGMAS = ->(mmap_size) {
30
- <<~SQL
31
- PRAGMA journal_mode = WAL;
32
- PRAGMA synchronous = NORMAL;
33
- PRAGMA mmap_size = #{mmap_size};
34
- PRAGMA cache_size = -16000;
35
- PRAGMA temp_store = MEMORY;
36
- PRAGMA busy_timeout = 5000;
37
- PRAGMA journal_size_limit = 67108864;
38
- SQL
39
- }.freeze
17
+ SCHEMA_VERSION = Schema::VERSION
18
+ MIGRATION_BUSY_TIMEOUT_MS = Schema::MIGRATION_BUSY_TIMEOUT_MS
19
+ REQUIRED_INDEXES = Schema::REQUIRED_INDEXES
20
+ SCHEMA = SQL::CREATE_SCHEMA
40
21
 
41
22
  CLEANUP_INTERVAL = 300
42
- CLEANUP_AGE = 3600
23
+ CLEANUP_AGE = 3600
24
+ FAILED_RETENTION_AGE = 7 * 24 * 3600
25
+ ERROR_MESSAGE_MAX_LEN = 2_000
26
+ EMPTY_ARGS_JSON = '[]'.freeze
27
+
28
+ attr_reader :path, :options
29
+
30
+ def self.migrate!(path: default_path, options: {})
31
+ store = new(path: path, options: options)
32
+ store.migrate!
33
+ SCHEMA_VERSION
34
+ ensure
35
+ store&.close
36
+ end
37
+
38
+ def self.prepare_dashboard!(path: default_path, options: {})
39
+ store = new(path: path, options: options)
40
+ store.prepare_dashboard!
41
+ SCHEMA_VERSION
42
+ ensure
43
+ store&.close
44
+ end
43
45
 
44
- attr_reader :path
46
+ def self.default_path = 'async_background_queue.db'
45
47
 
46
- def initialize(path: self.class.default_path, mmap: true)
47
- @path = path
48
- @pragma_sql = PRAGMAS.call(mmap ? MMAP_SIZE : 0).freeze
49
- @db = nil
50
- @schema_checked = false
48
+ def initialize(path: self.class.default_path, options: {})
49
+ @path = path
50
+ @options = StoreOptions.build(options)
51
+ @pragma_sql = @options.pragma_sql.freeze
52
+ @db = nil
53
+ @schema_checked = false
51
54
  @last_cleanup_at = nil
52
55
  end
53
56
 
54
- def ensure_database!
55
- require_sqlite3
56
- db = SQLite3::Database.new(@path)
57
- configure_database(db)
58
- db.execute_batch(SCHEMA)
59
- db.execute("PRAGMA wal_checkpoint(TRUNCATE)")
60
- db.close
57
+ def migrate!
58
+ raise SchemaError, 'close the Store before calling migrate!' if connected?
59
+
60
+ with_database { |db| migrate_database!(db) }
61
+ @schema_checked = true
62
+ self
63
+ end
64
+
65
+ alias ensure_database! migrate!
66
+
67
+ def prepare_dashboard!
68
+ raise SchemaError, 'close the Store before calling prepare_dashboard!' if connected?
69
+
70
+ with_database { |db| Schema.prepare_dashboard!(db) }
61
71
  @schema_checked = true
72
+ self
62
73
  end
63
74
 
64
- def enqueue(class_name, args = [], run_at = nil, options: {})
75
+ def schema_version
76
+ ensure_connection
77
+ @db.get_first_value(SQL::USER_VERSION).to_i
78
+ end
79
+
80
+ def enqueue(class_name, args = EMPTY_ARGS, run_at = nil, options: EMPTY_OPTIONS)
65
81
  ensure_connection
66
82
  now = realtime_now
67
- @enqueue_stmt.execute(class_name, JSON.generate(args), dump_options(options), now, run_at || now)
83
+ @enqueue_stmt.execute(class_name, dump_args(args), dump_options(options), now, run_at || now)
68
84
  @db.last_insert_row_id
69
85
  end
70
86
 
71
87
  def fetch(worker_id)
72
88
  ensure_connection
89
+ token = generate_claim_token
73
90
  now = realtime_now
74
91
 
75
- row = transaction { with_stmt(@fetch_stmt) { |s| s.execute(worker_id, now, now).first } }
92
+ row = transaction do
93
+ with_statement(@fetch_stmt) { |statement| statement.execute(worker_id, now, token, now).first }
94
+ end
76
95
  return unless row
77
96
 
78
97
  maybe_cleanup
79
- { id: row[0], class_name: row[1], args: JSON.parse(row[2]), options: load_options(row[3]) }
98
+ job_from_row(row, token)
80
99
  end
81
100
 
82
- def complete(job_id)
101
+ def mark_started!(job_id, claim_token:, started_at: realtime_now)
83
102
  ensure_connection
84
- @complete_stmt.execute(job_id)
103
+ @mark_started_stmt.execute(started_at, job_id, claim_token)
104
+ @db.changes.positive?
85
105
  end
86
106
 
87
- def fail(job_id)
107
+ def complete(job_id, claim_token:, finished_at: realtime_now, duration_ms: nil)
88
108
  ensure_connection
89
- @fail_stmt.execute(job_id)
109
+ @complete_stmt.execute(finished_at, duration_ms, job_id, claim_token)
110
+ @db.changes.positive?
90
111
  end
91
112
 
92
- def retry_or_fail(job_id, fallback_options: nil)
113
+ def fail(job_id, claim_token:, error_class: nil, error_message: nil, finished_at: realtime_now, duration_ms: nil)
114
+ ensure_connection
115
+ @fail_stmt.execute(
116
+ finished_at,
117
+ duration_ms,
118
+ error_class&.to_s,
119
+ truncate_message(error_message),
120
+ job_id,
121
+ claim_token
122
+ )
123
+ @db.changes.positive?
124
+ end
125
+
126
+ def retry_or_fail(
127
+ job_id,
128
+ claim_token:,
129
+ error_class: nil,
130
+ error_message: nil,
131
+ fallback_options: nil,
132
+ finished_at: realtime_now,
133
+ duration_ms: nil
134
+ )
93
135
  ensure_connection
94
136
 
95
137
  transaction do
96
- stored = with_stmt(@retry_state_stmt) { |s| load_options(s.execute(job_id).first&.first) }
97
- policy = stored.empty? ? normalize_options(fallback_options) : Job::Options.new(**stored)
98
-
99
- if policy&.retry? && policy.next_attempt <= policy.retry
100
- advanced = policy.with_attempt(policy.next_attempt)
101
- @retry_stmt.execute(realtime_now + advanced.next_retry_delay(advanced.attempt), dump_options(advanced.to_h.compact), job_id)
102
- :retried
103
- else
104
- @fail_stmt.execute(job_id)
105
- :failed
106
- end
138
+ stored_options = stored_options_for(job_id, claim_token)
139
+ next unless lease_alive?(job_id, claim_token)
140
+
141
+ policy = retry_policy(stored_options, fallback_options)
142
+ policy_retries?(policy) ? retry_job!(job_id, claim_token, policy, error_class, error_message) :
143
+ fail_job!(job_id, claim_token, error_class, error_message, finished_at, duration_ms)
107
144
  end
108
145
  end
109
146
 
@@ -113,66 +150,172 @@ module Async
113
150
  @db.changes
114
151
  end
115
152
 
153
+ def next_pending_run_at
154
+ ensure_connection
155
+ with_statement(@next_pending_stmt) { |statement| statement.execute.first&.first }
156
+ end
157
+
158
+ def data_version
159
+ ensure_connection
160
+ @db.get_first_value(SQL::DATA_VERSION).to_i
161
+ end
162
+
116
163
  def close
117
- return unless @db && !@db.closed?
164
+ return unless connected?
118
165
 
119
166
  finalize_statements
120
- @db.execute("PRAGMA optimize") rescue nil
167
+ @db.execute(SQL::OPTIMIZE) rescue nil
121
168
  @db.close
122
169
  @db = nil
170
+ @schema_checked = false
171
+ end
172
+
173
+ private
174
+
175
+ def connected?
176
+ @db && !@db.closed?
123
177
  end
124
178
 
125
- def self.default_path
126
- "async_background_queue.db"
179
+ def open_database
180
+ require_sqlite3
181
+ db = SQLite3::Database.new(@path)
182
+ configure_database(db)
183
+ db
184
+ rescue StandardError
185
+ db&.close unless db&.closed?
186
+ raise
127
187
  end
128
188
 
129
- private
189
+ def with_database
190
+ db = open_database
191
+ yield db
192
+ ensure
193
+ db&.close unless db&.closed?
194
+ end
130
195
 
131
196
  def require_sqlite3
132
197
  require 'sqlite3'
133
198
  rescue LoadError
134
199
  raise LoadError,
135
- "sqlite3 gem is required for Async::Background::Queue. " \
136
- "Add `gem 'sqlite3', '~> 2.0'` to your Gemfile."
200
+ "sqlite3 gem is required for Async::Background::Queue. " \
201
+ "Add `gem 'sqlite3', '~> 2.0'` to your Gemfile."
137
202
  end
138
203
 
139
204
  def ensure_connection
140
205
  return if @db && !@db.closed?
141
206
 
142
- require_sqlite3
143
207
  finalize_statements
144
- @db = SQLite3::Database.new(@path)
145
- configure_database(@db)
146
-
147
- unless @schema_checked
148
- @db.execute_batch(SCHEMA)
149
- @db.execute("ALTER TABLE jobs ADD COLUMN options TEXT") rescue nil
150
- @schema_checked = true
151
- end
152
-
208
+ db = open_database
209
+ migrate_database!(db) unless @schema_checked
210
+ @schema_checked = true
211
+ @db = db
153
212
  prepare_statements
154
213
  @last_cleanup_at = monotonic_now
214
+ rescue StandardError
215
+ db&.close unless db&.equal?(@db) || db&.closed?
216
+ reset_connection!
217
+ raise
218
+ end
219
+
220
+ def reset_connection!
221
+ @schema_checked = false
222
+ @db&.close unless @db&.closed?
223
+ @db = nil
155
224
  end
156
225
 
157
226
  def configure_database(db)
158
- db.execute("PRAGMA busy_timeout = 5000")
227
+ db.execute(SQL.busy_timeout(5000))
159
228
  db.execute_batch(@pragma_sql)
160
229
  end
161
230
 
231
+ def migrate_database!(db)
232
+ Schema.migrate!(db)
233
+ end
234
+
235
+ def job_from_row(row, claim_token)
236
+ {
237
+ id: row[0],
238
+ class_name: row[1],
239
+ args: JSON.parse(row[2]),
240
+ options: load_options(row[3]),
241
+ claim_token: claim_token
242
+ }
243
+ end
244
+
245
+ def stored_options_for(job_id, claim_token)
246
+ with_statement(@retry_state_stmt) do |statement|
247
+ load_options(statement.execute(job_id, claim_token).first&.first)
248
+ end
249
+ end
250
+
251
+ def retry_policy(stored_options, fallback_options)
252
+ return Job::Options.new(**stored_options) unless stored_options.empty?
253
+
254
+ normalize_options(fallback_options)
255
+ end
256
+
257
+ def policy_retries?(policy)
258
+ policy&.retry? && policy.next_attempt <= policy.retry
259
+ end
260
+
261
+ def retry_job!(job_id, claim_token, policy, error_class, error_message)
262
+ advanced = policy.with_attempt(policy.next_attempt)
263
+ @retry_stmt.execute(
264
+ realtime_now + advanced.next_retry_delay(advanced.attempt),
265
+ dump_options(advanced.to_h.compact),
266
+ error_class&.to_s,
267
+ truncate_message(error_message),
268
+ job_id,
269
+ claim_token
270
+ )
271
+ @db.changes.positive? ? :retried : nil
272
+ end
273
+
274
+ def fail_job!(job_id, claim_token, error_class, error_message, finished_at, duration_ms)
275
+ @fail_stmt.execute(
276
+ finished_at,
277
+ duration_ms,
278
+ error_class&.to_s,
279
+ truncate_message(error_message),
280
+ job_id,
281
+ claim_token
282
+ )
283
+ @db.changes.positive? ? :failed : nil
284
+ end
285
+
286
+ def generate_claim_token = SecureRandom.hex(16)
287
+
288
+ def truncate_message(message)
289
+ return if message.nil?
290
+
291
+ string = message.to_s
292
+ string.length > ERROR_MESSAGE_MAX_LEN ? string.byteslice(0, ERROR_MESSAGE_MAX_LEN) : string
293
+ end
294
+
295
+ def lease_alive?(job_id, claim_token)
296
+ with_statement(@lease_check_stmt) do |statement|
297
+ !statement.execute(job_id, claim_token).first.nil?
298
+ end
299
+ end
300
+
162
301
  def transaction
163
- @db.execute("BEGIN IMMEDIATE")
302
+ @db.execute(SQL::BEGIN_IMMEDIATE)
164
303
  result = yield
165
- @db.execute("COMMIT")
304
+ @db.execute(SQL::COMMIT)
166
305
  result
167
- rescue
168
- @db.execute("ROLLBACK") rescue nil
306
+ rescue StandardError
307
+ @db.execute(SQL::ROLLBACK) rescue nil
169
308
  raise
170
309
  end
171
310
 
172
- def with_stmt(stmt)
173
- yield stmt
311
+ def with_statement(statement)
312
+ yield statement
174
313
  ensure
175
- stmt.reset! rescue nil
314
+ statement.reset! rescue nil
315
+ end
316
+
317
+ def dump_args(args)
318
+ args.equal?(EMPTY_ARGS) ? EMPTY_ARGS_JSON : JSON.generate(args)
176
319
  end
177
320
 
178
321
  def dump_options(options)
@@ -191,43 +334,48 @@ module Async
191
334
  end
192
335
 
193
336
  def prepare_statements
194
- @enqueue_stmt = @db.prepare(
195
- "INSERT INTO jobs (class_name, args, options, created_at, run_at) VALUES (?, ?, ?, ?, ?)"
196
- )
197
-
198
- @fetch_stmt = @db.prepare(<<~SQL)
199
- UPDATE jobs
200
- SET status = 'running', locked_by = ?, locked_at = ?
201
- WHERE id = (
202
- SELECT id FROM jobs
203
- WHERE status = 'pending' AND run_at <= ?
204
- ORDER BY run_at, id
205
- LIMIT 1
206
- )
207
- RETURNING id, class_name, args, options
208
- SQL
209
-
210
- @complete_stmt = @db.prepare("UPDATE jobs SET status = 'done', locked_by = NULL, locked_at = NULL WHERE id = ?")
211
- @fail_stmt = @db.prepare("UPDATE jobs SET status = 'failed', locked_by = NULL, locked_at = NULL WHERE id = ?")
212
- @retry_state_stmt = @db.prepare("SELECT options FROM jobs WHERE id = ?")
213
- @retry_stmt = @db.prepare(
214
- "UPDATE jobs SET status = 'pending', locked_by = NULL, locked_at = NULL, run_at = ?, options = ? WHERE id = ?"
215
- )
216
- @requeue_stmt = @db.prepare(
217
- "UPDATE jobs SET status = 'pending', locked_by = NULL, locked_at = NULL " \
218
- "WHERE status = 'running' AND locked_by = ?"
219
- )
220
- @cleanup_stmt = @db.prepare("DELETE FROM jobs WHERE status = 'done' AND created_at < ?")
337
+ @enqueue_stmt = @db.prepare(SQL::INSERT_JOB)
338
+ @fetch_stmt = @db.prepare(SQL::FETCH_NEXT_JOB)
339
+ @mark_started_stmt = @db.prepare(SQL::MARK_STARTED)
340
+ @complete_stmt = @db.prepare(SQL::COMPLETE_JOB)
341
+ @fail_stmt = @db.prepare(SQL::FAIL_JOB)
342
+ @retry_state_stmt = @db.prepare(SQL::RETRY_STATE)
343
+ @lease_check_stmt = @db.prepare(SQL::LEASE_ALIVE)
344
+ @retry_stmt = @db.prepare(SQL::RETRY_JOB)
345
+ @requeue_stmt = @db.prepare(SQL::RECOVER_WORKER)
346
+ @cleanup_done_stmt = @db.prepare(SQL::CLEANUP_DONE)
347
+ @cleanup_failed_stmt = @db.prepare(SQL::CLEANUP_FAILED)
348
+ @next_pending_stmt = @db.prepare(SQL::NEXT_PENDING_RUN_AT)
221
349
  end
222
350
 
223
351
  def finalize_statements
224
- [@enqueue_stmt, @fetch_stmt, @complete_stmt, @fail_stmt,
225
- @retry_state_stmt, @retry_stmt, @requeue_stmt, @cleanup_stmt].each do |stmt|
226
- stmt&.close rescue next
227
- end
352
+ statements.each { |statement| statement&.close rescue nil }
353
+ clear_statements
354
+ end
355
+
356
+ def statements
357
+ [
358
+ @enqueue_stmt,
359
+ @fetch_stmt,
360
+ @mark_started_stmt,
361
+ @complete_stmt,
362
+ @fail_stmt,
363
+ @retry_state_stmt,
364
+ @lease_check_stmt,
365
+ @retry_stmt,
366
+ @requeue_stmt,
367
+ @cleanup_done_stmt,
368
+ @cleanup_failed_stmt,
369
+ @next_pending_stmt
370
+ ]
371
+ end
228
372
 
229
- @enqueue_stmt = @fetch_stmt = @complete_stmt = @fail_stmt = nil
230
- @retry_state_stmt = @retry_stmt = @requeue_stmt = @cleanup_stmt = nil
373
+ def clear_statements
374
+ @enqueue_stmt = @fetch_stmt = @mark_started_stmt = nil
375
+ @complete_stmt = @fail_stmt = @retry_state_stmt = @lease_check_stmt = nil
376
+ @retry_stmt = @requeue_stmt = nil
377
+ @cleanup_done_stmt = @cleanup_failed_stmt = nil
378
+ @next_pending_stmt = nil
231
379
  end
232
380
 
233
381
  def maybe_cleanup
@@ -235,8 +383,13 @@ module Async
235
383
  return if (now - @last_cleanup_at) < CLEANUP_INTERVAL
236
384
 
237
385
  @last_cleanup_at = now
238
- @cleanup_stmt.execute(realtime_now - CLEANUP_AGE)
239
- @db.execute("PRAGMA incremental_vacuum") if @db.changes > 100
386
+ cleanup_finished_jobs(realtime_now)
387
+ end
388
+
389
+ def cleanup_finished_jobs(now)
390
+ @cleanup_done_stmt.execute(now - CLEANUP_AGE)
391
+ @cleanup_failed_stmt.execute(now - FAILED_RETENTION_AGE)
392
+ @db.execute(SQL::INCREMENTAL_VACUUM) if @db.changes > 100
240
393
  end
241
394
  end
242
395
  end