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.
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require_relative 'cursor'
5
+
6
+ module Async
7
+ module Background
8
+ module Web
9
+ class Serializer
10
+ EMPTY_OPTIONS = {}.freeze
11
+ EMPTY_ARGS = [].freeze
12
+
13
+ def initialize(config)
14
+ @config = config
15
+ end
16
+
17
+ def overview(snapshot_data, metrics_data = nil)
18
+ payload = {
19
+ counts: snapshot_data.fetch(:counts),
20
+ next_pending_run_at: snapshot_data[:next_pending_run_at],
21
+ data_version: snapshot_data.fetch(:data_version),
22
+ generated_at: snapshot_data.fetch(:generated_at)
23
+ }
24
+ payload[:metrics] = metrics_data if metrics_data
25
+ payload
26
+ end
27
+
28
+ def executing(rows)
29
+ rows.map { |row| executing_item(row) }
30
+ end
31
+
32
+ def claimed(rows)
33
+ rows.map { |row| claimed_item(row) }
34
+ end
35
+
36
+ def done(rows)
37
+ page(rows.map { |row| done_item(row) }) { |item| Cursor.encode_finished(item[:finished_at], item[:id]) }
38
+ end
39
+
40
+ def failed(rows)
41
+ page(rows.map { |row| failed_item(row) }) { |item| Cursor.encode_finished(item[:finished_at], item[:id]) }
42
+ end
43
+
44
+ def pending(rows)
45
+ page(rows.map { |row| pending_item(row) }) { |item| Cursor.encode_pending(item[:run_at], item[:id]) }
46
+ end
47
+
48
+ private
49
+
50
+ def page(items)
51
+ {items: items, next_cursor: items.empty? ? nil : yield(items.last)}
52
+ end
53
+
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
67
+
68
+ def claimed_item(row)
69
+ 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
+
81
+ def done_item(row)
82
+ args, args_count = args_for(row[:args_raw])
83
+ {
84
+ id: row[:id],
85
+ class_name: row[:class_name],
86
+ args: args,
87
+ args_count: args_count,
88
+ options: parse_options(row[:options_raw]),
89
+ finished_at: row[:finished_at],
90
+ duration_ms: row[:duration_ms]
91
+ }
92
+ end
93
+
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
108
+
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
+ }
120
+ end
121
+
122
+ def args_for(raw)
123
+ if raw.nil? || raw.empty? || raw == '[]'
124
+ return [@config.expose_args ? redact(EMPTY_ARGS) : nil, 0]
125
+ end
126
+
127
+ parsed = parse_json(raw)
128
+ count = parsed.is_a?(Array) ? parsed.length : 0
129
+ return [nil, count] unless @config.expose_args
130
+
131
+ [redact(parsed), count]
132
+ end
133
+
134
+ def redact(args)
135
+ redactor = @config.redact_args
136
+ redactor ? redactor.call(args) : args
137
+ end
138
+
139
+ def parse_options(raw)
140
+ return EMPTY_OPTIONS if raw.nil? || raw.empty? || raw == '{}'
141
+
142
+ parsed = parse_json(raw)
143
+ parsed.is_a?(Hash) ? parsed : EMPTY_OPTIONS
144
+ end
145
+
146
+ def parse_json(raw)
147
+ JSON.parse(raw)
148
+ rescue JSON::ParserError
149
+ nil
150
+ end
151
+ end
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,247 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+
5
+ require_relative '../clock'
6
+ require_relative '../queue/sql'
7
+ require_relative 'sql'
8
+
9
+ module Async
10
+ module Background
11
+ module Web
12
+ class Snapshot
13
+ include Clock
14
+
15
+ CacheEntry = Data.define(:value, :created_at)
16
+
17
+ def initialize(path:, counts_cache_ttl:)
18
+ @path = path
19
+ @overview_cache_ttl = counts_cache_ttl
20
+ @mutex = Mutex.new
21
+ @db = nil
22
+ @overview_cache = nil
23
+ end
24
+
25
+ def open!
26
+ @mutex.synchronize do
27
+ return self if connected?
28
+
29
+ db = open_database
30
+ configure_database(db)
31
+ @db = db
32
+ rescue StandardError
33
+ db&.close unless db&.closed?
34
+ raise
35
+ end
36
+ self
37
+ end
38
+
39
+ def close
40
+ @mutex.synchronize do
41
+ @db&.close unless @db&.closed?
42
+ @db = nil
43
+ @overview_cache = nil
44
+ end
45
+ self
46
+ end
47
+
48
+ def closed?
49
+ @mutex.synchronize { !connected? }
50
+ end
51
+
52
+ def data_version
53
+ with_database { |db| db.get_first_value(Queue::SQL::DATA_VERSION).to_i }
54
+ end
55
+
56
+ def overview(force: false)
57
+ with_database do |db|
58
+ now = monotonic_now
59
+ return @overview_cache.value if !force && overview_cache_current?(now)
60
+
61
+ value = read_transaction(db) { overview_from(db) }.freeze
62
+ @overview_cache = CacheEntry.new(value, now)
63
+ value
64
+ end
65
+ end
66
+
67
+ def executing(limit:)
68
+ read_rows(SQL::EXECUTING, [limit]).map { |row| executing_row(row) }
69
+ end
70
+
71
+ def claimed(limit:)
72
+ read_rows(SQL::CLAIMED, [limit]).map { |row| claimed_row(row) }
73
+ end
74
+
75
+ 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) }
78
+ end
79
+
80
+ 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) }
83
+ end
84
+
85
+ def pending(limit:, cursor: nil)
86
+ sql, binds = pending_query(limit, cursor)
87
+ read_rows(sql, binds).map { |row| pending_row(row) }
88
+ end
89
+
90
+ private
91
+
92
+ def connected?
93
+ @db && !@db.closed?
94
+ end
95
+
96
+ def open_database
97
+ require_sqlite3
98
+ SQLite3::Database.new(database_uri, uri: true)
99
+ rescue LoadError
100
+ raise
101
+ rescue StandardError => error
102
+ raise UnavailableError, "cannot open queue database: #{error.message}"
103
+ end
104
+
105
+ def database_uri
106
+ path = URI::DEFAULT_PARSER.escape(File.expand_path(@path)).gsub('?', '%3F')
107
+ "file:#{path}?mode=ro"
108
+ end
109
+
110
+ def configure_database(db)
111
+ db.execute(SQL::BUSY_TIMEOUT)
112
+ db.execute(SQL::QUERY_ONLY)
113
+ end
114
+
115
+ def with_database
116
+ @mutex.synchronize do
117
+ raise ClosedError, 'snapshot is closed' unless connected?
118
+
119
+ yield @db
120
+ end
121
+ rescue ClosedError, UnavailableError
122
+ raise
123
+ rescue StandardError
124
+ raise UnavailableError, 'queue database is unavailable'
125
+ end
126
+
127
+ def read_rows(sql, binds)
128
+ with_database { |db| read_transaction(db) { db.execute(sql, binds) } }
129
+ end
130
+
131
+ def read_transaction(db)
132
+ db.execute(SQL::BEGIN_READ_TRANSACTION)
133
+ result = yield
134
+ db.execute(SQL::COMMIT)
135
+ result
136
+ rescue StandardError
137
+ rollback(db)
138
+ raise
139
+ end
140
+
141
+ def rollback(db)
142
+ db.execute(SQL::ROLLBACK)
143
+ rescue StandardError
144
+ nil
145
+ end
146
+
147
+ def overview_cache_current?(now)
148
+ cache = @overview_cache
149
+ cache && (now - cache.created_at) < @overview_cache_ttl
150
+ end
151
+
152
+ def overview_from(db)
153
+ {
154
+ counts: {
155
+ 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
160
+ }.freeze,
161
+ next_pending_run_at: db.get_first_value(SQL::OVERVIEW_NEXT_PENDING),
162
+ data_version: db.get_first_value(Queue::SQL::DATA_VERSION).to_i,
163
+ generated_at: realtime_now
164
+ }
165
+ end
166
+
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]]
177
+ end
178
+
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
190
+
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
+ }
235
+ end
236
+
237
+ def require_sqlite3
238
+ require 'sqlite3'
239
+ rescue LoadError
240
+ raise LoadError,
241
+ "sqlite3 gem is required for Async::Background::Web. " \
242
+ "Add `gem 'sqlite3', '~> 2.0'` to your Gemfile."
243
+ end
244
+ end
245
+ end
246
+ end
247
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Async
4
+ module Background
5
+ module Web
6
+ module SQL
7
+ BEGIN_READ_TRANSACTION = 'BEGIN'.freeze
8
+ COMMIT = 'COMMIT'.freeze
9
+ ROLLBACK = 'ROLLBACK'.freeze
10
+ QUERY_ONLY = 'PRAGMA query_only = ON'.freeze
11
+ BUSY_TIMEOUT = 'PRAGMA busy_timeout = 2000'.freeze
12
+
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
19
+
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
27
+
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
35
+
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
43
+
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
51
+
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
60
+
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
69
+
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
77
+
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
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Async
4
+ module Background
5
+ module Web
6
+ class Stream
7
+ def initialize(hub, heartbeat_seconds:, retry_ms:)
8
+ @hub = hub
9
+ @heartbeat_seconds = heartbeat_seconds
10
+ @retry_ms = retry_ms
11
+ end
12
+
13
+ def each
14
+ subscription, initial_frame = @hub.subscribe
15
+ yield "retry: #{@retry_ms}\n\n"
16
+ yield initial_frame
17
+
18
+ loop do
19
+ frame = subscription.pop(timeout: @heartbeat_seconds)
20
+ break if frame.nil? && subscription.closed?
21
+
22
+ yield(frame || EventHub::HEARTBEAT_FRAME)
23
+ end
24
+ rescue Errno::EPIPE, IOError
25
+ nil
26
+ rescue ClosedError, UnavailableError
27
+ safe_yield(EventHub::UNAVAILABLE_FRAME) { |frame| yield frame }
28
+ nil
29
+ ensure
30
+ @hub.unsubscribe(subscription) if subscription
31
+ end
32
+
33
+ private
34
+
35
+ def safe_yield(frame)
36
+ yield frame
37
+ rescue Errno::EPIPE, IOError
38
+ nil
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require 'rack'
5
+ rescue LoadError
6
+ raise LoadError,
7
+ "Async::Background::Web requires 'rack'. " \
8
+ "Add `gem 'rack', '~> 3.0'` to your Gemfile."
9
+ end
10
+
11
+ require_relative 'web/errors'
12
+ require_relative 'web/configuration'
13
+ require_relative 'web/sql'
14
+ require_relative 'web/cursor'
15
+ require_relative 'web/request'
16
+ require_relative 'web/response'
17
+ require_relative 'web/snapshot'
18
+ require_relative 'web/metrics_reader'
19
+ require_relative 'web/serializer'
20
+ require_relative 'web/auth'
21
+ require_relative 'web/router'
22
+ require_relative 'web/event_hub'
23
+ require_relative 'web/stream'
24
+ require_relative 'web/assets'
25
+ require_relative 'web/app'
26
+
27
+ module Async
28
+ module Background
29
+ module Web
30
+ module_function
31
+
32
+ def configure
33
+ @configuration ||= Configuration.new
34
+ yield @configuration if block_given?
35
+ @configuration
36
+ end
37
+
38
+ def configuration
39
+ @configuration or raise NotConfiguredError,
40
+ 'Async::Background::Web is not configured. Call Async::Background::Web.configure.'
41
+ end
42
+
43
+ def reset!
44
+ @configuration = nil
45
+ end
46
+
47
+ def app
48
+ App.new(configuration)
49
+ end
50
+ end
51
+ end
52
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: async-background
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.1
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Roman Hajdarov
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-05-02 00:00:00.000000000 Z
11
+ date: 2026-06-28 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: async
@@ -52,6 +52,20 @@ dependencies:
52
52
  - - "~>"
53
53
  - !ruby/object:Gem::Version
54
54
  version: '1.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: base64
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '0.2'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '0.2'
55
69
  - !ruby/object:Gem::Dependency
56
70
  name: rake
57
71
  requirement: !ruby/object:Gem::Requirement
@@ -80,6 +94,40 @@ dependencies:
80
94
  - - "~>"
81
95
  - !ruby/object:Gem::Version
82
96
  version: '3.12'
97
+ - !ruby/object:Gem::Dependency
98
+ name: rack
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '3.0'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '3.0'
111
+ - !ruby/object:Gem::Dependency
112
+ name: async-utilization
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - ">="
116
+ - !ruby/object:Gem::Version
117
+ version: '0.3'
118
+ - - "<"
119
+ - !ruby/object:Gem::Version
120
+ version: '0.5'
121
+ type: :development
122
+ prerelease: false
123
+ version_requirements: !ruby/object:Gem::Requirement
124
+ requirements:
125
+ - - ">="
126
+ - !ruby/object:Gem::Version
127
+ version: '0.3'
128
+ - - "<"
129
+ - !ruby/object:Gem::Version
130
+ version: '0.5'
83
131
  description: A production-grade lightweight scheduler built on top of Async. Single
84
132
  event loop with min-heap timer, skip-overlapping execution, jitter, monotonic clock
85
133
  intervals, semaphore concurrency control, and deterministic worker sharding. Designed
@@ -102,11 +150,32 @@ files:
102
150
  - lib/async/background/min_heap.rb
103
151
  - lib/async/background/queue/client.rb
104
152
  - lib/async/background/queue/notifier.rb
153
+ - lib/async/background/queue/options.rb
154
+ - lib/async/background/queue/schema.rb
105
155
  - lib/async/background/queue/socket_notifier.rb
106
156
  - lib/async/background/queue/socket_waker.rb
157
+ - lib/async/background/queue/sql.rb
107
158
  - lib/async/background/queue/store.rb
108
159
  - lib/async/background/runner.rb
160
+ - lib/async/background/runner/queue_execution.rb
161
+ - lib/async/background/runner/schedule.rb
109
162
  - lib/async/background/version.rb
163
+ - lib/async/background/web.rb
164
+ - lib/async/background/web/app.rb
165
+ - lib/async/background/web/assets.rb
166
+ - lib/async/background/web/auth.rb
167
+ - lib/async/background/web/configuration.rb
168
+ - lib/async/background/web/cursor.rb
169
+ - lib/async/background/web/errors.rb
170
+ - lib/async/background/web/event_hub.rb
171
+ - lib/async/background/web/metrics_reader.rb
172
+ - lib/async/background/web/request.rb
173
+ - lib/async/background/web/response.rb
174
+ - lib/async/background/web/router.rb
175
+ - lib/async/background/web/serializer.rb
176
+ - lib/async/background/web/snapshot.rb
177
+ - lib/async/background/web/sql.rb
178
+ - lib/async/background/web/stream.rb
110
179
  homepage: https://github.com/roman-haidarov/async-background
111
180
  licenses:
112
181
  - MIT