async-background 0.3.0 → 0.4.5

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 00b7ba6035c743fb4c4568d2309da4bc7f454a3cbe5e17ac5d2251a417a4a31c
4
- data.tar.gz: a3dff9b2b51f590a095120a18f1e3d5424b339ab5a6cff90b148f8e9316ee242
3
+ metadata.gz: c9a1ac97495dc7d6a39adf802a847b62e3601df0fe0ef4aa70dbcc248a2f77b8
4
+ data.tar.gz: 74bab717411caf3e8a7b469666e9df5f8f59d540ebd98446c5e44e167cacd3c9
5
5
  SHA512:
6
- metadata.gz: b9c36893b1cb14ba251d9c5a1c614ac50b988ffcbd69c43a876c1e0a2995ee749592618c2e328d2e05573ccf46186616a59615d7c01a785b28fe5897a1e0650f
7
- data.tar.gz: 3f47cf30226b45b6c543f48812e95778206e9cccc07445c5066e32cdf88a71f68bf88be9f0134750344cd7e0ed8df1a907107d0e5a3c910ce00a3a493ea700e7
6
+ metadata.gz: fe41a129f66a72c51016397b866ee9ff31b973d5e92ee6af1f61c05ce87f8f85f9824a68499caefcf950cdb33186c74749817e0ae8b89a483ede580d412e13fc
7
+ data.tar.gz: 92208ce41c49095cdf652b798ed892e4db5ae12c504cfe94c45bf124054256a94906fba14f68365f938a6d7289c76ca79d7ae7f6fac53bb76d8097d463db684f
@@ -23,10 +23,11 @@ module Async
23
23
  @enabled = false
24
24
  @registry = ::Async::Utilization::Registry.new
25
25
  @enabled = true
26
-
27
26
  ensure_shm!(total_workers, shm_path)
28
27
  attach_observer!(worker_index, total_workers, shm_path)
29
- rescue LoadError
28
+ rescue LoadError, ArgumentError
29
+ @registry = nil
30
+ @enabled = false
30
31
  end
31
32
 
32
33
  def enabled?
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Async
4
+ module Background
5
+ module Queue
6
+ # Usage:
7
+ # Async::Background::Queue.enqueue(SendEmailJob, user_id, "welcome")
8
+ #
9
+ class Client
10
+ def initialize(store:, notifier: nil)
11
+ @store = store
12
+ @notifier = notifier
13
+ end
14
+
15
+ def push(class_name, args = [])
16
+ id = @store.enqueue(class_name, args)
17
+ @notifier&.notify
18
+ id
19
+ end
20
+ end
21
+
22
+ class << self
23
+ attr_accessor :default_client
24
+
25
+ def enqueue(job_class, *args)
26
+ raise "Async::Background::Queue not configured" unless default_client
27
+
28
+ if job_class.is_a?(String)
29
+ class_name = job_class
30
+ else
31
+ raise ArgumentError, "#{job_class} must implement .perform_now" unless job_class.respond_to?(:perform_now)
32
+ class_name = job_class.name
33
+ end
34
+
35
+ default_client.push(class_name, args)
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Async
4
+ module Background
5
+ module Queue
6
+ class Notifier
7
+ attr_reader :reader, :writer
8
+
9
+ def initialize
10
+ @reader, @writer = IO.pipe
11
+ @reader.binmode
12
+ @writer.binmode
13
+ end
14
+
15
+ def notify
16
+ @writer.write_nonblock("\x01")
17
+ rescue IO::WaitWritable, Errno::EAGAIN
18
+ # pipe buffer full — consumer is already behind, skip
19
+ rescue IOError
20
+ # pipe closed
21
+ end
22
+
23
+ def wait(timeout: nil)
24
+ @reader.wait_readable(timeout)
25
+ drain
26
+ end
27
+
28
+ def close_writer
29
+ @writer.close unless @writer.closed?
30
+ end
31
+
32
+ def close_reader
33
+ @reader.close unless @reader.closed?
34
+ end
35
+
36
+ def close
37
+ close_reader
38
+ close_writer
39
+ end
40
+
41
+ def for_producer!
42
+ close_reader
43
+ end
44
+
45
+ def for_consumer!
46
+ close_writer
47
+ end
48
+
49
+ private
50
+
51
+ def drain
52
+ loop do
53
+ @reader.read_nonblock(256)
54
+ rescue IO::WaitReadable, EOFError
55
+ break
56
+ end
57
+ nil
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Async
6
+ module Background
7
+ module Queue
8
+ class Store
9
+ SCHEMA = <<~SQL
10
+ PRAGMA auto_vacuum = INCREMENTAL;
11
+ CREATE TABLE IF NOT EXISTS jobs (
12
+ id INTEGER PRIMARY KEY,
13
+ class_name TEXT NOT NULL,
14
+ args TEXT NOT NULL DEFAULT '[]',
15
+ status TEXT NOT NULL DEFAULT 'pending',
16
+ created_at REAL NOT NULL,
17
+ locked_by INTEGER,
18
+ locked_at REAL
19
+ );
20
+ CREATE INDEX IF NOT EXISTS idx_jobs_status_id ON jobs(status, id);
21
+ SQL
22
+
23
+ MMAP_SIZE = 268_435_456
24
+ PRAGMAS = ->(mmap_size) {
25
+ <<~SQL
26
+ PRAGMA journal_mode = WAL;
27
+ PRAGMA synchronous = NORMAL;
28
+ PRAGMA mmap_size = #{mmap_size};
29
+ PRAGMA cache_size = -16000;
30
+ PRAGMA temp_store = MEMORY;
31
+ PRAGMA busy_timeout = 5000;
32
+ PRAGMA journal_size_limit = 67108864;
33
+ SQL
34
+ }.freeze
35
+
36
+ CLEANUP_INTERVAL = 300
37
+ CLEANUP_AGE = 3600
38
+
39
+ attr_reader :path
40
+
41
+ def initialize(path: self.class.default_path, mmap: true)
42
+ @path = path
43
+ @mmap = mmap
44
+ @db = nil
45
+ @schema_checked = false
46
+ @last_cleanup_at = nil
47
+ end
48
+
49
+ def ensure_database!
50
+ require_sqlite3
51
+ db = SQLite3::Database.new(@path)
52
+ db.execute_batch(PRAGMAS.call(@mmap ? MMAP_SIZE : 0))
53
+ db.execute_batch(SCHEMA)
54
+ db.execute("PRAGMA wal_checkpoint(TRUNCATE)")
55
+ db.close
56
+ @schema_checked = true
57
+ end
58
+
59
+ def enqueue(class_name, args = [])
60
+ ensure_connection
61
+ @enqueue_stmt.execute(class_name, JSON.generate(args), realtime_now)
62
+ @db.last_insert_row_id
63
+ end
64
+
65
+ def fetch(worker_id)
66
+ ensure_connection
67
+ @db.execute("BEGIN IMMEDIATE")
68
+ row = @fetch_stmt.execute(worker_id, realtime_now).first
69
+ @db.execute("COMMIT")
70
+ return unless row
71
+
72
+ maybe_cleanup
73
+ { id: row[0], class_name: row[1], args: JSON.parse(row[2]) }
74
+ rescue
75
+ @db.execute("ROLLBACK") rescue nil
76
+ raise
77
+ end
78
+
79
+ def complete(job_id)
80
+ ensure_connection
81
+ @complete_stmt.execute(job_id)
82
+ end
83
+
84
+ def fail(job_id)
85
+ ensure_connection
86
+ @fail_stmt.execute(job_id)
87
+ end
88
+
89
+ def recover(worker_id)
90
+ ensure_connection
91
+ @requeue_stmt.execute(worker_id)
92
+ @db.changes
93
+ end
94
+
95
+ def close
96
+ return unless @db && !@db.closed?
97
+
98
+ finalize_statements
99
+ @db.execute("PRAGMA optimize") rescue nil
100
+ @db.close
101
+ @db = nil
102
+ end
103
+
104
+ def self.default_path
105
+ "async_background_queue.db"
106
+ end
107
+
108
+ private
109
+
110
+ def require_sqlite3
111
+ require 'sqlite3'
112
+ rescue LoadError
113
+ raise LoadError,
114
+ "sqlite3 gem is required for Async::Background::Queue. " \
115
+ "Add `gem 'sqlite3', '~> 2.0'` to your Gemfile."
116
+ end
117
+
118
+ def ensure_connection
119
+ return if @db && !@db.closed?
120
+
121
+ require_sqlite3
122
+ finalize_statements
123
+ @db = SQLite3::Database.new(@path)
124
+ @db.execute_batch(PRAGMAS.call(@mmap ? MMAP_SIZE : 0))
125
+
126
+ unless @schema_checked
127
+ @db.execute_batch(SCHEMA)
128
+ @schema_checked = true
129
+ end
130
+
131
+ prepare_statements
132
+ @last_cleanup_at = monotonic_now
133
+ end
134
+
135
+ def prepare_statements
136
+ @enqueue_stmt = @db.prepare(
137
+ "INSERT INTO jobs (class_name, args, created_at) VALUES (?, ?, ?)"
138
+ )
139
+
140
+ @fetch_stmt = @db.prepare(<<~SQL)
141
+ UPDATE jobs
142
+ SET status = 'running', locked_by = ?, locked_at = ?
143
+ WHERE id = (
144
+ SELECT id FROM jobs
145
+ WHERE status = 'pending'
146
+ ORDER BY id
147
+ LIMIT 1
148
+ )
149
+ RETURNING id, class_name, args
150
+ SQL
151
+
152
+ @complete_stmt = @db.prepare("UPDATE jobs SET status = 'done' WHERE id = ?")
153
+ @fail_stmt = @db.prepare("UPDATE jobs SET status = 'failed' WHERE id = ?")
154
+
155
+ @requeue_stmt = @db.prepare(
156
+ "UPDATE jobs SET status = 'pending', locked_by = NULL, locked_at = NULL " \
157
+ "WHERE status = 'running' AND locked_by = ?"
158
+ )
159
+
160
+ @cleanup_stmt = @db.prepare("DELETE FROM jobs WHERE status = 'done' AND created_at < ?")
161
+ end
162
+
163
+ def finalize_statements
164
+ %i[enqueue_stmt fetch_stmt complete_stmt fail_stmt requeue_stmt cleanup_stmt].each do |name|
165
+ stmt = instance_variable_get(:"@#{name}")
166
+ stmt&.close rescue nil
167
+ instance_variable_set(:"@#{name}", nil)
168
+ end
169
+ end
170
+
171
+ def maybe_cleanup
172
+ now = monotonic_now
173
+ return if (now - @last_cleanup_at) < CLEANUP_INTERVAL
174
+
175
+ @last_cleanup_at = now
176
+ @cleanup_stmt.execute(realtime_now - CLEANUP_AGE)
177
+
178
+ if @db.changes > 100
179
+ @db.execute("PRAGMA incremental_vacuum")
180
+ end
181
+ end
182
+
183
+ def realtime_now
184
+ Process.clock_gettime(Process::CLOCK_REALTIME)
185
+ end
186
+
187
+ def monotonic_now
188
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
189
+ end
190
+ end
191
+ end
192
+ end
193
+ end
@@ -10,11 +10,15 @@ module Async
10
10
  DEFAULT_TIMEOUT = 30
11
11
  MIN_SLEEP_TIME = 0.1
12
12
  MAX_JITTER = 5
13
+ QUEUE_POLL_INTERVAL = 5
13
14
 
14
15
  class Runner
15
- attr_reader :logger, :semaphore, :heap, :worker_index, :total_workers, :shutdown, :metrics
16
+ attr_reader :logger, :semaphore, :heap, :worker_index, :total_workers, :shutdown, :metrics, :queue_store
16
17
 
17
- def initialize(config_path:, job_count: 2, worker_index:, total_workers:)
18
+ def initialize(
19
+ config_path:, job_count: 2, worker_index:, total_workers:,
20
+ queue_notifier: nil, queue_db_path: nil, queue_mmap: true
21
+ )
18
22
  @logger = Console.logger
19
23
  @worker_index = worker_index
20
24
  @total_workers = total_workers
@@ -26,44 +30,20 @@ module Async
26
30
 
27
31
  @semaphore = ::Async::Semaphore.new(job_count)
28
32
  @heap = build_heap(config_path)
33
+
34
+ setup_queue(queue_notifier, queue_db_path, queue_mmap)
29
35
  end
30
36
 
31
37
  def run
32
38
  Async do |task|
33
39
  setup_signal_handlers
34
40
  start_signal_watcher(task)
41
+ start_queue_listener(task) if @listen_queue
35
42
 
36
- loop do
37
- entry = heap.peek
38
- break unless entry
39
-
40
- now = monotonic_now
41
- wait = [entry.next_run_at - now, MIN_SLEEP_TIME].max
42
- wait_with_shutdown(task, wait)
43
- break unless running?
44
-
45
- now = monotonic_now
46
- while (entry = heap.peek) && entry.next_run_at <= now
47
- break unless running?
48
-
49
- if entry.running
50
- logger.warn('Async::Background') { "#{entry.name}: skipped, previous run still active" }
51
- metrics.job_skipped(entry)
52
- else
53
- entry.running = true
54
- semaphore.async do
55
- run_job(task, entry)
56
- ensure
57
- entry.running = false
58
- end
59
- end
60
-
61
- entry.reschedule(monotonic_now)
62
- heap.replace_top(entry)
63
- end
64
- end
43
+ scheduler_loop(task)
65
44
 
66
45
  semaphore.acquire {}
46
+ @queue_store&.close
67
47
  end
68
48
  end
69
49
 
@@ -73,6 +53,7 @@ module Async
73
53
  @running = false
74
54
  logger.info { "Async::Background: stopping gracefully" }
75
55
  shutdown.signal
56
+ @queue_notifier&.notify # unblock queue listener from @reader.wait_readable
76
57
  end
77
58
 
78
59
  def running?
@@ -81,6 +62,121 @@ module Async
81
62
 
82
63
  private
83
64
 
65
+ def setup_queue(queue_notifier, queue_db_path, queue_mmap)
66
+ @listen_queue = false
67
+ return unless queue_notifier
68
+
69
+ # Lazy require — only loaded when queue is actually used
70
+ require_relative 'queue/store'
71
+ require_relative 'queue/notifier'
72
+ require_relative 'queue/client'
73
+
74
+ isolated = ENV.fetch("ISOLATION_FORKS", "").split(",").map(&:to_i)
75
+ return if isolated.include?(worker_index)
76
+
77
+ @listen_queue = true
78
+ @queue_notifier = queue_notifier
79
+ @queue_store = Queue::Store.new(
80
+ path: queue_db_path || Queue::Store.default_path,
81
+ mmap: queue_mmap
82
+ )
83
+
84
+ recovered = @queue_store.recover(worker_index)
85
+ logger.info { "Async::Background queue: recovered #{recovered} stale jobs" } if recovered > 0
86
+ end
87
+
88
+ def start_queue_listener(task)
89
+ task.async do
90
+ logger.info { "Async::Background queue: listening on worker #{worker_index}" }
91
+
92
+ while running?
93
+ @queue_notifier.wait(timeout: QUEUE_POLL_INTERVAL)
94
+
95
+ while running?
96
+ job = @queue_store.fetch(worker_index)
97
+ break unless job
98
+
99
+ semaphore.async { run_queue_job(task, job) }
100
+ end
101
+ end
102
+ end
103
+ end
104
+
105
+ def run_queue_job(task, job)
106
+ class_name = job[:class_name]
107
+ args = job[:args]
108
+ klass = resolve_job_class(class_name)
109
+
110
+ metrics.job_started(nil)
111
+ t = monotonic_now
112
+
113
+ task.with_timeout(DEFAULT_TIMEOUT) { klass.perform_now(*args) }
114
+
115
+ duration = monotonic_now - t
116
+ metrics.job_finished(nil, duration)
117
+ @queue_store.complete(job[:id])
118
+
119
+ logger.info('Async::Background') {
120
+ "queue(#{class_name}): completed in #{duration.round(2)}s"
121
+ }
122
+ rescue ::Async::TimeoutError
123
+ metrics.job_timed_out(nil)
124
+ @queue_store.fail(job[:id])
125
+ logger.error('Async::Background') { "queue(#{class_name}): timed out" }
126
+ rescue => e
127
+ metrics.job_failed(nil, e)
128
+ @queue_store.fail(job[:id])
129
+ logger.error('Async::Background') {
130
+ "queue(#{class_name}): #{e.class} #{e.message}\n#{e.backtrace.join("\n")}"
131
+ }
132
+ end
133
+
134
+ def resolve_job_class(class_name)
135
+ raise ConfigError, "empty class name in queue job" if class_name.nil? || class_name.strip.empty?
136
+
137
+ names = class_name.split("::")
138
+ klass = names.reduce(Object) do |mod, name|
139
+ raise ConfigError, "unknown class: #{class_name}" unless mod.const_defined?(name, false)
140
+ mod.const_get(name, false)
141
+ end
142
+
143
+ raise ConfigError, "#{class_name} must implement .perform_now" unless klass.respond_to?(:perform_now)
144
+
145
+ klass
146
+ end
147
+
148
+ def scheduler_loop(task)
149
+ loop do
150
+ entry = heap.peek
151
+ break unless entry
152
+
153
+ now = monotonic_now
154
+ wait = [entry.next_run_at - now, MIN_SLEEP_TIME].max
155
+ wait_with_shutdown(task, wait)
156
+ break unless running?
157
+
158
+ now = monotonic_now
159
+ while (entry = heap.peek) && entry.next_run_at <= now
160
+ break unless running?
161
+
162
+ if entry.running
163
+ logger.warn('Async::Background') { "#{entry.name}: skipped, previous run still active" }
164
+ metrics.job_skipped(entry)
165
+ else
166
+ entry.running = true
167
+ semaphore.async do
168
+ run_job(task, entry)
169
+ ensure
170
+ entry.running = false
171
+ end
172
+ end
173
+
174
+ entry.reschedule(monotonic_now)
175
+ heap.replace_top(entry)
176
+ end
177
+ end
178
+ end
179
+
84
180
  def setup_signal_handlers
85
181
  @signal_r, @signal_w = IO.pipe
86
182
 
@@ -98,6 +194,7 @@ module Async
98
194
  @signal_r.wait_readable
99
195
  @signal_r.read_nonblock(256) rescue nil
100
196
  shutdown.signal
197
+ @queue_notifier&.notify
101
198
  break unless running?
102
199
  end
103
200
  end
@@ -183,10 +280,10 @@ module Async
183
280
 
184
281
  def run_job(task, entry)
185
282
  metrics.job_started(entry)
186
- t = Process.clock_gettime(Process::CLOCK_MONOTONIC)
283
+ t = monotonic_now
187
284
  task.with_timeout(entry.timeout) { entry.job_class.perform_now }
188
285
 
189
- duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - t
286
+ duration = monotonic_now - t
190
287
  metrics.job_finished(entry, duration)
191
288
  logger.info('Async::Background') {
192
289
  "#{entry.name}: completed in #{duration.round(2)}s"
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Async
4
4
  module Background
5
- VERSION = '0.3.0'
5
+ VERSION = '0.4.5'
6
6
  end
7
7
  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.3.0
4
+ version: 0.4.5
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-03-25 00:00:00.000000000 Z
11
+ date: 2026-03-29 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: async
@@ -94,6 +94,9 @@ files:
94
94
  - lib/async/background/entry.rb
95
95
  - lib/async/background/metrics.rb
96
96
  - lib/async/background/min_heap.rb
97
+ - lib/async/background/queue/client.rb
98
+ - lib/async/background/queue/notifier.rb
99
+ - lib/async/background/queue/store.rb
97
100
  - lib/async/background/runner.rb
98
101
  - lib/async/background/version.rb
99
102
  homepage: https://github.com/roman-haidarov/async-background