async-background 0.7.1 → 1.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.
@@ -1,140 +1,146 @@
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
8
13
  module Queue
9
- SYNCHRONOUS_LEVELS = { normal: 'NORMAL', full: 'FULL', extra: 'EXTRA' }.freeze
10
- WAL_AUTOCHECKPOINT_RANGE = 100..10_000
11
- DEFAULTS = { mmap: true, synchronous: :normal, wal_autocheckpoint: 1_000 }.freeze
12
- MMAP_SIZE = 268_435_456
14
+ class Store
15
+ include Clock
13
16
 
14
- StoreOptions = Data.define(:mmap, :synchronous, :wal_autocheckpoint) do
15
- def self.build(value = {}) = value.is_a?(self) ? value : new(**DEFAULTS, **value)
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
16
21
 
17
- def initialize(mmap:, synchronous:, wal_autocheckpoint:)
18
- unless mmap == true || mmap == false
19
- raise ArgumentError, "mmap must be true or false, got #{mmap.inspect}"
20
- end
22
+ CLEANUP_INTERVAL = 300
23
+ CLEANUP_AGE = 3600
24
+ FAILED_RETENTION_AGE = 7 * 24 * 3600
25
+ ERROR_MESSAGE_MAX_LEN = 2_000
26
+ EMPTY_ARGS_JSON = '[]'.freeze
21
27
 
22
- unless SYNCHRONOUS_LEVELS.key?(synchronous)
23
- raise ArgumentError,
24
- "synchronous must be one of #{SYNCHRONOUS_LEVELS.keys.inspect}, got #{synchronous.inspect}"
25
- end
28
+ attr_reader :path, :options
26
29
 
27
- unless wal_autocheckpoint.is_a?(Integer) && WAL_AUTOCHECKPOINT_RANGE.cover?(wal_autocheckpoint)
28
- raise ArgumentError,
29
- "wal_autocheckpoint must be an Integer in #{WAL_AUTOCHECKPOINT_RANGE}, " \
30
- "got #{wal_autocheckpoint.inspect}"
31
- end
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
32
37
 
33
- super
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
34
44
  end
35
45
 
36
- def synchronous_pragma = SYNCHRONOUS_LEVELS.fetch(synchronous)
37
- def mmap_size = mmap ? MMAP_SIZE : 0
46
+ def self.default_path = 'async_background_queue.db'
38
47
 
39
- def pragma_sql
40
- <<~SQL
41
- PRAGMA journal_mode = WAL;
42
- PRAGMA synchronous = #{synchronous_pragma};
43
- PRAGMA mmap_size = #{mmap_size};
44
- PRAGMA cache_size = -16000;
45
- PRAGMA temp_store = MEMORY;
46
- PRAGMA journal_size_limit = 67108864;
47
- PRAGMA wal_autocheckpoint = #{wal_autocheckpoint};
48
- SQL
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
54
+ @last_cleanup_at = nil
49
55
  end
50
- end
51
56
 
52
- class Store
53
- include Clock
57
+ def migrate!
58
+ raise SchemaError, 'close the Store before calling migrate!' if connected?
54
59
 
55
- SCHEMA = <<~SQL
56
- PRAGMA auto_vacuum = INCREMENTAL;
57
- CREATE TABLE IF NOT EXISTS jobs (
58
- id INTEGER PRIMARY KEY,
59
- class_name TEXT NOT NULL,
60
- args TEXT NOT NULL DEFAULT '[]',
61
- options TEXT,
62
- status TEXT NOT NULL DEFAULT 'pending',
63
- created_at REAL NOT NULL,
64
- run_at REAL NOT NULL,
65
- locked_by INTEGER,
66
- locked_at REAL
67
- );
68
- CREATE INDEX IF NOT EXISTS idx_jobs_pending ON jobs(run_at, id) WHERE status = 'pending';
69
- SQL
60
+ with_database { |db| migrate_database!(db) }
61
+ @schema_checked = true
62
+ self
63
+ end
70
64
 
71
- CLEANUP_INTERVAL = 300
72
- CLEANUP_AGE = 3600
65
+ alias ensure_database! migrate!
73
66
 
74
- attr_reader :path, :options
67
+ def prepare_dashboard!
68
+ raise SchemaError, 'close the Store before calling prepare_dashboard!' if connected?
75
69
 
76
- def initialize(path: self.class.default_path, options: {})
77
- @path = path
78
- @options = StoreOptions.build(options)
79
- @pragma_sql = @options.pragma_sql.freeze
80
- @db = nil
81
- @schema_checked = false
82
- @last_cleanup_at = nil
70
+ with_database { |db| Schema.prepare_dashboard!(db) }
71
+ @schema_checked = true
72
+ self
83
73
  end
84
74
 
85
- def ensure_database!
86
- require_sqlite3
87
- db = SQLite3::Database.new(@path)
88
- configure_database(db)
89
- db.execute_batch(SCHEMA)
90
- db.execute("PRAGMA wal_checkpoint(TRUNCATE)")
91
- db.close
92
- @schema_checked = true
75
+ def schema_version
76
+ ensure_connection
77
+ @db.get_first_value(SQL::USER_VERSION).to_i
93
78
  end
94
79
 
95
- def enqueue(class_name, args = [], run_at = nil, options: {})
80
+ def enqueue(class_name, args = EMPTY_ARGS, run_at = nil, options: EMPTY_OPTIONS)
96
81
  ensure_connection
97
82
  now = realtime_now
98
- @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)
99
84
  @db.last_insert_row_id
100
85
  end
101
86
 
102
87
  def fetch(worker_id)
103
88
  ensure_connection
89
+ token = generate_claim_token
104
90
  now = realtime_now
105
91
 
106
- 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
107
95
  return unless row
108
96
 
109
97
  maybe_cleanup
110
- { id: row[0], class_name: row[1], args: JSON.parse(row[2]), options: load_options(row[3]) }
98
+ job_from_row(row, token)
111
99
  end
112
100
 
113
- def complete(job_id)
101
+ def mark_started!(job_id, claim_token:, started_at: realtime_now)
114
102
  ensure_connection
115
- @complete_stmt.execute(job_id)
103
+ @mark_started_stmt.execute(started_at, job_id, claim_token)
104
+ @db.changes.positive?
116
105
  end
117
106
 
118
- def fail(job_id)
107
+ def complete(job_id, claim_token:, finished_at: realtime_now, duration_ms: nil)
119
108
  ensure_connection
120
- @fail_stmt.execute(job_id)
109
+ @complete_stmt.execute(finished_at, duration_ms, job_id, claim_token)
110
+ @db.changes.positive?
121
111
  end
122
112
 
123
- 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
+ )
124
135
  ensure_connection
125
136
 
126
137
  transaction do
127
- stored = with_stmt(@retry_state_stmt) { |s| load_options(s.execute(job_id).first&.first) }
128
- policy = stored.empty? ? normalize_options(fallback_options) : Job::Options.new(**stored)
129
-
130
- if policy&.retry? && policy.next_attempt <= policy.retry
131
- advanced = policy.with_attempt(policy.next_attempt)
132
- @retry_stmt.execute(realtime_now + advanced.next_retry_delay(advanced.attempt), dump_options(advanced.to_h.compact), job_id)
133
- :retried
134
- else
135
- @fail_stmt.execute(job_id)
136
- :failed
137
- 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)
138
144
  end
139
145
  end
140
146
 
@@ -144,66 +150,172 @@ module Async
144
150
  @db.changes
145
151
  end
146
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
+
147
163
  def close
148
- return unless @db && !@db.closed?
164
+ return unless connected?
149
165
 
150
166
  finalize_statements
151
- @db.execute("PRAGMA optimize") rescue nil
167
+ @db.execute(SQL::OPTIMIZE) rescue nil
152
168
  @db.close
153
169
  @db = nil
170
+ @schema_checked = false
154
171
  end
155
172
 
156
- def self.default_path
157
- "async_background_queue.db"
173
+ private
174
+
175
+ def connected?
176
+ @db && !@db.closed?
158
177
  end
159
178
 
160
- private
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
187
+ end
188
+
189
+ def with_database
190
+ db = open_database
191
+ yield db
192
+ ensure
193
+ db&.close unless db&.closed?
194
+ end
161
195
 
162
196
  def require_sqlite3
163
197
  require 'sqlite3'
164
198
  rescue LoadError
165
199
  raise LoadError,
166
- "sqlite3 gem is required for Async::Background::Queue. " \
167
- "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."
168
202
  end
169
203
 
170
204
  def ensure_connection
171
205
  return if @db && !@db.closed?
172
206
 
173
- require_sqlite3
174
207
  finalize_statements
175
- @db = SQLite3::Database.new(@path)
176
- configure_database(@db)
177
-
178
- unless @schema_checked
179
- @db.execute_batch(SCHEMA)
180
- @db.execute("ALTER TABLE jobs ADD COLUMN options TEXT") rescue nil
181
- @schema_checked = true
182
- end
183
-
208
+ db = open_database
209
+ migrate_database!(db) unless @schema_checked
210
+ @schema_checked = true
211
+ @db = db
184
212
  prepare_statements
185
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
186
224
  end
187
225
 
188
226
  def configure_database(db)
189
- db.execute("PRAGMA busy_timeout = 5000")
227
+ db.execute(SQL.busy_timeout(5000))
190
228
  db.execute_batch(@pragma_sql)
191
229
  end
192
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
+
193
301
  def transaction
194
- @db.execute("BEGIN IMMEDIATE")
302
+ @db.execute(SQL::BEGIN_IMMEDIATE)
195
303
  result = yield
196
- @db.execute("COMMIT")
304
+ @db.execute(SQL::COMMIT)
197
305
  result
198
- rescue
199
- @db.execute("ROLLBACK") rescue nil
306
+ rescue StandardError
307
+ @db.execute(SQL::ROLLBACK) rescue nil
200
308
  raise
201
309
  end
202
310
 
203
- def with_stmt(stmt)
204
- yield stmt
311
+ def with_statement(statement)
312
+ yield statement
205
313
  ensure
206
- 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)
207
319
  end
208
320
 
209
321
  def dump_options(options)
@@ -222,43 +334,48 @@ module Async
222
334
  end
223
335
 
224
336
  def prepare_statements
225
- @enqueue_stmt = @db.prepare(
226
- "INSERT INTO jobs (class_name, args, options, created_at, run_at) VALUES (?, ?, ?, ?, ?)"
227
- )
228
-
229
- @fetch_stmt = @db.prepare(<<~SQL)
230
- UPDATE jobs
231
- SET status = 'running', locked_by = ?, locked_at = ?
232
- WHERE id = (
233
- SELECT id FROM jobs
234
- WHERE status = 'pending' AND run_at <= ?
235
- ORDER BY run_at, id
236
- LIMIT 1
237
- )
238
- RETURNING id, class_name, args, options
239
- SQL
240
-
241
- @complete_stmt = @db.prepare("UPDATE jobs SET status = 'done', locked_by = NULL, locked_at = NULL WHERE id = ?")
242
- @fail_stmt = @db.prepare("UPDATE jobs SET status = 'failed', locked_by = NULL, locked_at = NULL WHERE id = ?")
243
- @retry_state_stmt = @db.prepare("SELECT options FROM jobs WHERE id = ?")
244
- @retry_stmt = @db.prepare(
245
- "UPDATE jobs SET status = 'pending', locked_by = NULL, locked_at = NULL, run_at = ?, options = ? WHERE id = ?"
246
- )
247
- @requeue_stmt = @db.prepare(
248
- "UPDATE jobs SET status = 'pending', locked_by = NULL, locked_at = NULL " \
249
- "WHERE status = 'running' AND locked_by = ?"
250
- )
251
- @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)
252
349
  end
253
350
 
254
351
  def finalize_statements
255
- [@enqueue_stmt, @fetch_stmt, @complete_stmt, @fail_stmt,
256
- @retry_state_stmt, @retry_stmt, @requeue_stmt, @cleanup_stmt].each do |stmt|
257
- stmt&.close rescue next
258
- 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
259
372
 
260
- @enqueue_stmt = @fetch_stmt = @complete_stmt = @fail_stmt = nil
261
- @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
262
379
  end
263
380
 
264
381
  def maybe_cleanup
@@ -266,8 +383,13 @@ module Async
266
383
  return if (now - @last_cleanup_at) < CLEANUP_INTERVAL
267
384
 
268
385
  @last_cleanup_at = now
269
- @cleanup_stmt.execute(realtime_now - CLEANUP_AGE)
270
- @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
271
393
  end
272
394
  end
273
395
  end