async-background 0.7.1 → 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.
@@ -0,0 +1,160 @@
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[idx_jobs_done_finished_at idx_jobs_failed_finished_at idx_jobs_running].freeze
17
+ REQUIRED_INDEXES = CORE_INDEXES
18
+
19
+ module_function
20
+
21
+ def migrate!(db)
22
+ reject_future_version!(db)
23
+ return if current?(db)
24
+
25
+ enable_incremental_vacuum!(db) unless jobs_table?(db)
26
+
27
+ with_migration_timeout(db) do
28
+ immediate_transaction(db) do
29
+ reject_future_version!(db)
30
+ upgrade!(db) unless current?(db)
31
+ end
32
+ end
33
+ end
34
+
35
+ def prepare_dashboard!(db)
36
+ migrate!(db)
37
+ return if dashboard_indexes_current?(db)
38
+
39
+ with_migration_timeout(db) do
40
+ immediate_transaction(db) do
41
+ create_dashboard_indexes!(db) unless dashboard_indexes_current?(db)
42
+ end
43
+ end
44
+ end
45
+
46
+ def current?(db)
47
+ jobs_table?(db) && version(db) == VERSION && core_indexes_current?(db)
48
+ end
49
+
50
+ def dashboard_indexes_current?(db)
51
+ (DASHBOARD_INDEXES - index_names(db)).empty?
52
+ end
53
+
54
+ def version(db)
55
+ db.get_first_value(SQL::USER_VERSION).to_i
56
+ end
57
+
58
+ def upgrade!(db)
59
+ jobs_table?(db) ? upgrade_existing_database!(db) : create_current_schema!(db)
60
+ end
61
+
62
+ def create_current_schema!(db)
63
+ db.execute_batch(SQL::CREATE_SCHEMA)
64
+ set_version!(db, VERSION)
65
+ end
66
+
67
+ def upgrade_existing_database!(db)
68
+ add_column_unless_exists!(db, 'options', 'TEXT')
69
+ add_lifecycle_columns!(db)
70
+ backfill_finished_at!(db)
71
+ ensure_pending_index!(db)
72
+ set_version!(db, VERSION)
73
+ end
74
+
75
+ def add_lifecycle_columns!(db)
76
+ {
77
+ 'claim_token' => 'TEXT',
78
+ 'started_at' => 'REAL',
79
+ 'finished_at' => 'REAL',
80
+ 'duration_ms' => 'INTEGER',
81
+ 'last_error_class' => 'TEXT',
82
+ 'last_error_message' => 'TEXT'
83
+ }.each { |name, type| add_column_unless_exists!(db, name, type) }
84
+ end
85
+
86
+ def backfill_finished_at!(db)
87
+ db.execute(SQL::BACKFILL_FINISHED_AT)
88
+ end
89
+
90
+ def ensure_pending_index!(db)
91
+ db.execute(SQL::DROP_LEGACY_PENDING_INDEX)
92
+ db.execute(SQL::CREATE_PENDING_INDEX)
93
+ end
94
+
95
+ def create_dashboard_indexes!(db)
96
+ SQL::CREATE_DASHBOARD_INDEXES.each { |statement| db.execute(statement) }
97
+ end
98
+
99
+ def core_indexes_current?(db)
100
+ (CORE_INDEXES - index_names(db)).empty?
101
+ end
102
+
103
+ def jobs_table?(db)
104
+ !db.get_first_value(SQL::JOBS_TABLE_EXISTS).nil?
105
+ end
106
+
107
+ def index_names(db)
108
+ db.execute(SQL::JOB_INDEX_NAMES).map(&:first)
109
+ end
110
+
111
+ def add_column_unless_exists!(db, name, sql_type)
112
+ return if table_columns(db).include?(name)
113
+
114
+ db.execute(SQL.add_column(name, sql_type))
115
+ end
116
+
117
+ def table_columns(db)
118
+ db.execute(SQL::TABLE_INFO).map { |row| row[1] }
119
+ end
120
+
121
+ def reject_future_version!(db)
122
+ return unless version(db) > VERSION
123
+
124
+ raise Store::SchemaError,
125
+ "queue database schema #{version(db)} is newer than supported schema #{VERSION}"
126
+ end
127
+
128
+ def set_version!(db, value)
129
+ db.execute(SQL.user_version(value))
130
+ end
131
+
132
+ def enable_incremental_vacuum!(db)
133
+ db.execute(SQL::AUTO_VACUUM_INCREMENTAL)
134
+ end
135
+
136
+ def with_migration_timeout(db)
137
+ original_timeout = db.get_first_value(SQL::BUSY_TIMEOUT).to_i
138
+ db.execute(SQL.busy_timeout(MIGRATION_BUSY_TIMEOUT_MS))
139
+ yield
140
+ ensure
141
+ begin
142
+ db.execute(SQL.busy_timeout(original_timeout)) if defined?(original_timeout)
143
+ rescue StandardError
144
+ # Restoring a connection option must not hide the migration exception.
145
+ end
146
+ end
147
+
148
+ def immediate_transaction(db)
149
+ db.execute(SQL::BEGIN_IMMEDIATE)
150
+ result = yield
151
+ db.execute(SQL::COMMIT)
152
+ result
153
+ rescue StandardError
154
+ db.execute(SQL::ROLLBACK) rescue nil
155
+ raise
156
+ end
157
+ end
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,205 @@
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_RUNNING_INDEX = <<~SQL.freeze
196
+ CREATE INDEX IF NOT EXISTS idx_jobs_running
197
+ ON jobs(locked_at)
198
+ WHERE status = 'running'
199
+ SQL
200
+
201
+ CREATE_DASHBOARD_INDEXES = [CREATE_DONE_INDEX, CREATE_FAILED_INDEX, CREATE_RUNNING_INDEX].freeze
202
+ end
203
+ end
204
+ end
205
+ end