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,12 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative '../clock'
4
+ require_relative 'store'
4
5
 
5
6
  module Async
6
7
  module Background
7
8
  module Queue
8
- EMPTY_OPTIONS = {}.freeze
9
-
10
9
  class Client
11
10
  include Clock
12
11
 
@@ -15,17 +14,17 @@ module Async
15
14
  @notifier = notifier
16
15
  end
17
16
 
18
- def push(class_name, args = [], run_at = nil, options: {})
17
+ def push(class_name, args = EMPTY_ARGS, run_at = nil, options: EMPTY_OPTIONS)
19
18
  id = @store.enqueue(class_name, args, run_at, options: options)
20
19
  @notifier&.notify_all
21
20
  id
22
21
  end
23
22
 
24
- def push_in(delay, class_name, args = [], options: {})
23
+ def push_in(delay, class_name, args = EMPTY_ARGS, options: EMPTY_OPTIONS)
25
24
  push(class_name, args, realtime_now + delay.to_f, options: options)
26
25
  end
27
26
 
28
- def push_at(time, class_name, args = [], options: {})
27
+ def push_at(time, class_name, args = EMPTY_ARGS, options: EMPTY_OPTIONS)
29
28
  run_at = time.respond_to?(:to_f) ? time.to_f : time
30
29
  push(class_name, args, run_at, options: options)
31
30
  end
@@ -34,19 +33,27 @@ module Async
34
33
  class << self
35
34
  attr_accessor :default_client
36
35
 
37
- def enqueue(job_class, *args, options: {})
36
+ def migrate!(path: Store.default_path, options: {})
37
+ Store.migrate!(path: path, options: options)
38
+ end
39
+
40
+ def prepare_dashboard!(path: Store.default_path, options: {})
41
+ Store.prepare_dashboard!(path: path, options: options)
42
+ end
43
+
44
+ def enqueue(job_class, *args, options: EMPTY_OPTIONS)
38
45
  ensure_configured!
39
- default_client.push(resolve_class_name(job_class), args, nil, options: build_options(job_class, options))
46
+ default_client.push(resolve_class_name(job_class), normalized_args(args), nil, options: build_options(job_class, options))
40
47
  end
41
48
 
42
- def enqueue_in(delay, job_class, *args, options: {})
49
+ def enqueue_in(delay, job_class, *args, options: EMPTY_OPTIONS)
43
50
  ensure_configured!
44
- default_client.push_in(delay, resolve_class_name(job_class), args, options: build_options(job_class, options))
51
+ default_client.push_in(delay, resolve_class_name(job_class), normalized_args(args), options: build_options(job_class, options))
45
52
  end
46
53
 
47
- def enqueue_at(time, job_class, *args, options: {})
54
+ def enqueue_at(time, job_class, *args, options: EMPTY_OPTIONS)
48
55
  ensure_configured!
49
- default_client.push_at(time, resolve_class_name(job_class), args, options: build_options(job_class, options))
56
+ default_client.push_at(time, resolve_class_name(job_class), normalized_args(args), options: build_options(job_class, options))
50
57
  end
51
58
 
52
59
  private
@@ -55,8 +62,11 @@ module Async
55
62
  private_constant :RETRY_KEYS
56
63
 
57
64
  def build_options(job_class, call_site)
58
- call_site ||= {}
59
- merged = resolve_options(job_class).merge(call_site.compact)
65
+ call_site ||= EMPTY_OPTIONS
66
+ class_options = resolve_options(job_class)
67
+ return EMPTY_OPTIONS if class_options.empty? && call_site.empty?
68
+
69
+ merged = class_options.merge(call_site.compact)
60
70
  apply_retry_overrides!(merged, call_site)
61
71
 
62
72
  merged.empty? ? EMPTY_OPTIONS : Job::Options.new(**merged).to_h.compact
@@ -72,6 +82,8 @@ module Async
72
82
  raise "Async::Background::Queue not configured" unless default_client
73
83
  end
74
84
 
85
+ def normalized_args(args) = args.empty? ? EMPTY_ARGS : args
86
+
75
87
  def resolve_class_name(job_class)
76
88
  return job_class if job_class.is_a?(String)
77
89
  return job_class.name if job_class.respond_to?(:perform_now)
@@ -80,9 +92,15 @@ module Async
80
92
  end
81
93
 
82
94
  def resolve_options(job_class)
83
- return {} unless job_class.respond_to?(:resolve_options)
95
+ return EMPTY_OPTIONS unless job_class.respond_to?(:resolve_options)
96
+
97
+ options = if job_class.respond_to?(:queue_options)
98
+ job_class.queue_options
99
+ else
100
+ job_class.resolve_options
101
+ end
84
102
 
85
- job_class.resolve_options.dup
103
+ options.empty? ? EMPTY_OPTIONS : options
86
104
  end
87
105
  end
88
106
  end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Async
4
+ module Background
5
+ module Queue
6
+ EMPTY_ARGS = [].freeze
7
+ EMPTY_OPTIONS = {}.freeze
8
+
9
+ SYNCHRONOUS_LEVELS = {normal: 'NORMAL', full: 'FULL', extra: 'EXTRA'}.freeze
10
+ WAL_AUTOCHECKPOINT_RANGE = 100..10_000
11
+ DEFAULT_STORE_OPTIONS = {mmap: true, synchronous: :normal, wal_autocheckpoint: 1_000}.freeze
12
+ DEFAULTS = DEFAULT_STORE_OPTIONS
13
+ MMAP_SIZE = 268_435_456
14
+
15
+ StoreOptions = Data.define(:mmap, :synchronous, :wal_autocheckpoint) do
16
+ def self.build(value = {})
17
+ return value if value.is_a?(self)
18
+
19
+ new(**DEFAULT_STORE_OPTIONS, **value)
20
+ end
21
+
22
+ def initialize(mmap:, synchronous:, wal_autocheckpoint:)
23
+ validate_mmap!(mmap)
24
+ validate_synchronous!(synchronous)
25
+ validate_wal_autocheckpoint!(wal_autocheckpoint)
26
+
27
+ super
28
+ end
29
+
30
+ def synchronous_pragma = SYNCHRONOUS_LEVELS.fetch(synchronous)
31
+ def mmap_size = mmap ? MMAP_SIZE : 0
32
+
33
+ def pragma_sql
34
+ <<~SQL
35
+ PRAGMA journal_mode = WAL;
36
+ PRAGMA synchronous = #{synchronous_pragma};
37
+ PRAGMA mmap_size = #{mmap_size};
38
+ PRAGMA cache_size = -16000;
39
+ PRAGMA temp_store = MEMORY;
40
+ PRAGMA journal_size_limit = 67108864;
41
+ PRAGMA wal_autocheckpoint = #{wal_autocheckpoint};
42
+ SQL
43
+ end
44
+
45
+ private
46
+
47
+ def validate_mmap!(value)
48
+ return if value == true || value == false
49
+
50
+ raise ArgumentError, "mmap must be true or false, got #{value.inspect}"
51
+ end
52
+
53
+ def validate_synchronous!(value)
54
+ return if SYNCHRONOUS_LEVELS.key?(value)
55
+
56
+ raise ArgumentError,
57
+ "synchronous must be one of #{SYNCHRONOUS_LEVELS.keys.inspect}, got #{value.inspect}"
58
+ end
59
+
60
+ def validate_wal_autocheckpoint!(value)
61
+ return if value.is_a?(Integer) && WAL_AUTOCHECKPOINT_RANGE.cover?(value)
62
+
63
+ raise ArgumentError,
64
+ "wal_autocheckpoint must be an Integer in #{WAL_AUTOCHECKPOINT_RANGE}, " \
65
+ "got #{value.inspect}"
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'sql'
4
+
5
+ module Async
6
+ module Background
7
+ module Queue
8
+ class Store
9
+ class SchemaError < StandardError; end
10
+ end
11
+
12
+ module Schema
13
+ VERSION = 1
14
+ MIGRATION_BUSY_TIMEOUT_MS = 30_000
15
+ CORE_INDEXES = %w[idx_jobs_pending].freeze
16
+ DASHBOARD_INDEXES = %w[
17
+ idx_jobs_done_finished_at
18
+ idx_jobs_failed_finished_at
19
+ idx_jobs_executing_started_at
20
+ idx_jobs_claimed_locked_at
21
+ ].freeze
22
+ REQUIRED_INDEXES = CORE_INDEXES
23
+
24
+ module_function
25
+
26
+ def migrate!(db)
27
+ reject_future_version!(db)
28
+ return if current?(db)
29
+
30
+ enable_incremental_vacuum!(db) unless jobs_table?(db)
31
+
32
+ with_migration_timeout(db) do
33
+ immediate_transaction(db) do
34
+ reject_future_version!(db)
35
+ upgrade!(db) unless current?(db)
36
+ end
37
+ end
38
+ end
39
+
40
+ def prepare_dashboard!(db)
41
+ migrate!(db)
42
+ return if dashboard_indexes_current?(db)
43
+
44
+ with_migration_timeout(db) do
45
+ immediate_transaction(db) do
46
+ create_dashboard_indexes!(db) unless dashboard_indexes_current?(db)
47
+ end
48
+ end
49
+ end
50
+
51
+ def current?(db)
52
+ jobs_table?(db) && version(db) == VERSION && core_indexes_current?(db)
53
+ end
54
+
55
+ def dashboard_indexes_current?(db)
56
+ (DASHBOARD_INDEXES - index_names(db)).empty?
57
+ end
58
+
59
+ def version(db)
60
+ db.get_first_value(SQL::USER_VERSION).to_i
61
+ end
62
+
63
+ def upgrade!(db)
64
+ jobs_table?(db) ? upgrade_existing_database!(db) : create_current_schema!(db)
65
+ end
66
+
67
+ def create_current_schema!(db)
68
+ db.execute_batch(SQL::CREATE_SCHEMA)
69
+ set_version!(db, VERSION)
70
+ end
71
+
72
+ def upgrade_existing_database!(db)
73
+ add_column_unless_exists!(db, 'options', 'TEXT')
74
+ add_lifecycle_columns!(db)
75
+ backfill_finished_at!(db)
76
+ ensure_pending_index!(db)
77
+ set_version!(db, VERSION)
78
+ end
79
+
80
+ def add_lifecycle_columns!(db)
81
+ {
82
+ 'claim_token' => 'TEXT',
83
+ 'started_at' => 'REAL',
84
+ 'finished_at' => 'REAL',
85
+ 'duration_ms' => 'INTEGER',
86
+ 'last_error_class' => 'TEXT',
87
+ 'last_error_message' => 'TEXT'
88
+ }.each { |name, type| add_column_unless_exists!(db, name, type) }
89
+ end
90
+
91
+ def backfill_finished_at!(db)
92
+ db.execute(SQL::BACKFILL_FINISHED_AT)
93
+ end
94
+
95
+ def ensure_pending_index!(db)
96
+ db.execute(SQL::DROP_LEGACY_PENDING_INDEX)
97
+ db.execute(SQL::CREATE_PENDING_INDEX)
98
+ end
99
+
100
+ def create_dashboard_indexes!(db)
101
+ SQL::CREATE_DASHBOARD_INDEXES.each { |statement| db.execute(statement) }
102
+ end
103
+
104
+ def core_indexes_current?(db)
105
+ (CORE_INDEXES - index_names(db)).empty?
106
+ end
107
+
108
+ def jobs_table?(db)
109
+ !db.get_first_value(SQL::JOBS_TABLE_EXISTS).nil?
110
+ end
111
+
112
+ def index_names(db)
113
+ db.execute(SQL::JOB_INDEX_NAMES).map(&:first)
114
+ end
115
+
116
+ def add_column_unless_exists!(db, name, sql_type)
117
+ return if table_columns(db).include?(name)
118
+
119
+ db.execute(SQL.add_column(name, sql_type))
120
+ end
121
+
122
+ def table_columns(db)
123
+ db.execute(SQL::TABLE_INFO).map { |row| row[1] }
124
+ end
125
+
126
+ def reject_future_version!(db)
127
+ return unless version(db) > VERSION
128
+
129
+ raise Store::SchemaError,
130
+ "queue database schema #{version(db)} is newer than supported schema #{VERSION}"
131
+ end
132
+
133
+ def set_version!(db, value)
134
+ db.execute(SQL.user_version(value))
135
+ end
136
+
137
+ def enable_incremental_vacuum!(db)
138
+ db.execute(SQL::AUTO_VACUUM_INCREMENTAL)
139
+ end
140
+
141
+ def with_migration_timeout(db)
142
+ original_timeout = db.get_first_value(SQL::BUSY_TIMEOUT).to_i
143
+ db.execute(SQL.busy_timeout(MIGRATION_BUSY_TIMEOUT_MS))
144
+ yield
145
+ ensure
146
+ begin
147
+ db.execute(SQL.busy_timeout(original_timeout)) if defined?(original_timeout)
148
+ rescue StandardError
149
+ # Restoring a connection option must not hide the migration exception.
150
+ end
151
+ end
152
+
153
+ def immediate_transaction(db)
154
+ db.execute(SQL::BEGIN_IMMEDIATE)
155
+ result = yield
156
+ db.execute(SQL::COMMIT)
157
+ result
158
+ rescue StandardError
159
+ db.execute(SQL::ROLLBACK) rescue nil
160
+ raise
161
+ end
162
+ end
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,216 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Async
4
+ module Background
5
+ module Queue
6
+ module SQL
7
+ USER_VERSION = 'PRAGMA user_version'.freeze
8
+ DATA_VERSION = 'PRAGMA data_version'.freeze
9
+ BUSY_TIMEOUT = 'PRAGMA busy_timeout'.freeze
10
+ TABLE_INFO = 'PRAGMA table_info(jobs)'.freeze
11
+ OPTIMIZE = 'PRAGMA optimize'.freeze
12
+ INCREMENTAL_VACUUM = 'PRAGMA incremental_vacuum'.freeze
13
+ AUTO_VACUUM_INCREMENTAL = 'PRAGMA auto_vacuum = INCREMENTAL'.freeze
14
+ BEGIN_IMMEDIATE = 'BEGIN IMMEDIATE'.freeze
15
+ COMMIT = 'COMMIT'.freeze
16
+ ROLLBACK = 'ROLLBACK'.freeze
17
+
18
+ def self.busy_timeout(milliseconds)
19
+ "PRAGMA busy_timeout = #{Integer(milliseconds)}"
20
+ end
21
+
22
+ def self.user_version(version)
23
+ "PRAGMA user_version = #{Integer(version)}"
24
+ end
25
+
26
+ def self.add_column(name, sql_type)
27
+ "ALTER TABLE jobs ADD COLUMN #{name} #{sql_type}"
28
+ end
29
+
30
+ CREATE_SCHEMA = <<~SQL.freeze
31
+ CREATE TABLE IF NOT EXISTS jobs (
32
+ id INTEGER PRIMARY KEY,
33
+ class_name TEXT NOT NULL,
34
+ args TEXT NOT NULL DEFAULT '[]',
35
+ options TEXT,
36
+ status TEXT NOT NULL DEFAULT 'pending',
37
+ created_at REAL NOT NULL,
38
+ run_at REAL NOT NULL,
39
+ locked_by INTEGER,
40
+ locked_at REAL,
41
+ claim_token TEXT,
42
+ started_at REAL,
43
+ finished_at REAL,
44
+ duration_ms INTEGER,
45
+ last_error_class TEXT,
46
+ last_error_message TEXT
47
+ );
48
+ CREATE INDEX IF NOT EXISTS idx_jobs_pending
49
+ ON jobs(run_at) WHERE status = 'pending';
50
+ SQL
51
+
52
+ INSERT_JOB = <<~SQL.freeze
53
+ INSERT INTO jobs (class_name, args, options, created_at, run_at)
54
+ VALUES (?, ?, ?, ?, ?)
55
+ SQL
56
+
57
+ FETCH_NEXT_JOB = <<~SQL.freeze
58
+ UPDATE jobs
59
+ SET status = 'running',
60
+ locked_by = ?,
61
+ locked_at = ?,
62
+ claim_token = ?,
63
+ started_at = NULL,
64
+ finished_at = NULL,
65
+ duration_ms = NULL
66
+ WHERE id = (
67
+ SELECT id FROM jobs
68
+ WHERE status = 'pending' AND run_at <= ?
69
+ ORDER BY run_at, id
70
+ LIMIT 1
71
+ )
72
+ RETURNING id, class_name, args, options
73
+ SQL
74
+
75
+ MARK_STARTED = <<~SQL.freeze
76
+ UPDATE jobs
77
+ SET started_at = ?
78
+ WHERE id = ? AND claim_token = ? AND status = 'running' AND started_at IS NULL
79
+ SQL
80
+
81
+ COMPLETE_JOB = <<~SQL.freeze
82
+ UPDATE jobs
83
+ SET status = 'done',
84
+ locked_by = NULL,
85
+ locked_at = NULL,
86
+ finished_at = ?,
87
+ duration_ms = ?
88
+ WHERE id = ? AND claim_token = ? AND status = 'running'
89
+ SQL
90
+
91
+ FAIL_JOB = <<~SQL.freeze
92
+ UPDATE jobs
93
+ SET status = 'failed',
94
+ locked_by = NULL,
95
+ locked_at = NULL,
96
+ finished_at = ?,
97
+ duration_ms = ?,
98
+ last_error_class = ?,
99
+ last_error_message = ?
100
+ WHERE id = ? AND claim_token = ? AND status = 'running'
101
+ SQL
102
+
103
+ RETRY_STATE = <<~SQL.freeze
104
+ SELECT options
105
+ FROM jobs
106
+ WHERE id = ? AND claim_token = ? AND status = 'running'
107
+ SQL
108
+
109
+ LEASE_ALIVE = <<~SQL.freeze
110
+ SELECT 1
111
+ FROM jobs
112
+ WHERE id = ? AND claim_token = ? AND status = 'running'
113
+ SQL
114
+
115
+ RETRY_JOB = <<~SQL.freeze
116
+ UPDATE jobs
117
+ SET status = 'pending',
118
+ locked_by = NULL,
119
+ locked_at = NULL,
120
+ claim_token = NULL,
121
+ started_at = NULL,
122
+ finished_at = NULL,
123
+ duration_ms = NULL,
124
+ run_at = ?,
125
+ options = ?,
126
+ last_error_class = ?,
127
+ last_error_message = ?
128
+ WHERE id = ? AND claim_token = ? AND status = 'running'
129
+ SQL
130
+
131
+ RECOVER_WORKER = <<~SQL.freeze
132
+ UPDATE jobs
133
+ SET status = 'pending',
134
+ locked_by = NULL,
135
+ locked_at = NULL,
136
+ claim_token = NULL,
137
+ started_at = NULL
138
+ WHERE status = 'running' AND locked_by = ?
139
+ SQL
140
+
141
+ BACKFILL_FINISHED_AT = <<~SQL.freeze
142
+ UPDATE jobs
143
+ SET finished_at = created_at
144
+ WHERE finished_at IS NULL AND status IN ('done', 'failed')
145
+ SQL
146
+
147
+ CLEANUP_DONE = <<~SQL.freeze
148
+ DELETE FROM jobs
149
+ WHERE status = 'done' AND finished_at IS NOT NULL AND finished_at < ?
150
+ SQL
151
+
152
+ CLEANUP_FAILED = <<~SQL.freeze
153
+ DELETE FROM jobs
154
+ WHERE status = 'failed' AND finished_at IS NOT NULL AND finished_at < ?
155
+ SQL
156
+
157
+ NEXT_PENDING_RUN_AT = <<~SQL.freeze
158
+ SELECT MIN(run_at)
159
+ FROM jobs
160
+ WHERE status = 'pending'
161
+ SQL
162
+
163
+ JOBS_TABLE_EXISTS = <<~SQL.freeze
164
+ SELECT 1
165
+ FROM sqlite_master
166
+ WHERE type = 'table' AND name = 'jobs'
167
+ LIMIT 1
168
+ SQL
169
+
170
+ JOB_INDEX_NAMES = <<~SQL.freeze
171
+ SELECT name
172
+ FROM sqlite_master
173
+ WHERE type = 'index' AND tbl_name = 'jobs'
174
+ SQL
175
+
176
+ DROP_LEGACY_PENDING_INDEX = 'DROP INDEX IF EXISTS idx_jobs_status_run_at_id'.freeze
177
+
178
+ CREATE_PENDING_INDEX = <<~SQL.freeze
179
+ CREATE INDEX IF NOT EXISTS idx_jobs_pending
180
+ ON jobs(run_at) WHERE status = 'pending'
181
+ SQL
182
+
183
+ CREATE_DONE_INDEX = <<~SQL.freeze
184
+ CREATE INDEX IF NOT EXISTS idx_jobs_done_finished_at
185
+ ON jobs(finished_at DESC, id DESC)
186
+ WHERE status = 'done'
187
+ SQL
188
+
189
+ CREATE_FAILED_INDEX = <<~SQL.freeze
190
+ CREATE INDEX IF NOT EXISTS idx_jobs_failed_finished_at
191
+ ON jobs(finished_at DESC, id DESC)
192
+ WHERE status = 'failed'
193
+ SQL
194
+
195
+ CREATE_EXECUTING_INDEX = <<~SQL.freeze
196
+ CREATE INDEX IF NOT EXISTS idx_jobs_executing_started_at
197
+ ON jobs(started_at)
198
+ WHERE status = 'running' AND started_at IS NOT NULL
199
+ SQL
200
+
201
+ CREATE_CLAIMED_INDEX = <<~SQL.freeze
202
+ CREATE INDEX IF NOT EXISTS idx_jobs_claimed_locked_at
203
+ ON jobs(locked_at)
204
+ WHERE status = 'running' AND started_at IS NULL
205
+ SQL
206
+
207
+ CREATE_DASHBOARD_INDEXES = [
208
+ CREATE_DONE_INDEX,
209
+ CREATE_FAILED_INDEX,
210
+ CREATE_EXECUTING_INDEX,
211
+ CREATE_CLAIMED_INDEX
212
+ ].freeze
213
+ end
214
+ end
215
+ end
216
+ end