async-background 0.3.0 → 0.4.4

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: db310afc2038735e979a5041ff92998de5ff27655457569e588ab952c8e2cde0
4
+ data.tar.gz: b079e4f9769ffb917afa2b6f7eac860e92a7dd93f6b5b73a5b32b76fbbb9e683
5
5
  SHA512:
6
- metadata.gz: b9c36893b1cb14ba251d9c5a1c614ac50b988ffcbd69c43a876c1e0a2995ee749592618c2e328d2e05573ccf46186616a59615d7c01a785b28fe5897a1e0650f
7
- data.tar.gz: 3f47cf30226b45b6c543f48812e95778206e9cccc07445c5066e32cdf88a71f68bf88be9f0134750344cd7e0ed8df1a907107d0e5a3c910ce00a3a493ea700e7
6
+ metadata.gz: 99e68ef3c3ee9899f8072d2eb7cc2e6a0da4745b637bc849835927a5390cabcdd81f3beb30cc3587618da230000d9f6b193101bed31f7a910ef62855e79cd24e
7
+ data.tar.gz: a8d683c78a0720c04e9c2b9b70a3f5f90c9caf34b495867e8c78ef14bc1270b3001ca8a04ca01dbd70e784d9a4f1b62d7a459845f7436997fccbbf88cb75ce15
@@ -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,183 @@
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
+ CREATE TABLE IF NOT EXISTS jobs (
11
+ id INTEGER PRIMARY KEY,
12
+ class_name TEXT NOT NULL,
13
+ args TEXT NOT NULL DEFAULT '[]',
14
+ status TEXT NOT NULL DEFAULT 'pending',
15
+ created_at REAL NOT NULL,
16
+ locked_by INTEGER,
17
+ locked_at REAL
18
+ );
19
+ CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status);
20
+ SQL
21
+
22
+ PRAGMAS = <<~SQL
23
+ PRAGMA journal_mode = WAL;
24
+ PRAGMA synchronous = NORMAL;
25
+ PRAGMA mmap_size = 0;
26
+ PRAGMA cache_size = -16000;
27
+ PRAGMA temp_store = MEMORY;
28
+ PRAGMA busy_timeout = 5000;
29
+ PRAGMA journal_size_limit = 67108864;
30
+ SQL
31
+
32
+ CLEANUP_INTERVAL = 300
33
+ CLEANUP_AGE = 3600
34
+
35
+ attr_reader :path
36
+
37
+ def initialize(path: self.class.default_path)
38
+ @path = path
39
+ @db = nil
40
+ @schema_checked = false
41
+ @last_cleanup_at = nil
42
+ end
43
+
44
+ def ensure_database!
45
+ require_sqlite3
46
+ db = SQLite3::Database.new(@path)
47
+ db.execute_batch(PRAGMAS)
48
+ db.execute_batch(SCHEMA)
49
+ db.execute("PRAGMA wal_checkpoint(TRUNCATE)")
50
+ db.close
51
+ @schema_checked = true
52
+ end
53
+
54
+ def enqueue(class_name, args = [])
55
+ ensure_connection
56
+ @enqueue_stmt.execute(class_name, JSON.generate(args), realtime_now)
57
+ @db.last_insert_row_id
58
+ end
59
+
60
+ def fetch(worker_id)
61
+ ensure_connection
62
+ row = @fetch_stmt.execute(worker_id, realtime_now).first
63
+ return unless row
64
+
65
+ maybe_cleanup
66
+ { id: row[0], class_name: row[1], args: JSON.parse(row[2]) }
67
+ end
68
+
69
+ def complete(job_id)
70
+ ensure_connection
71
+ @complete_stmt.execute(job_id)
72
+ end
73
+
74
+ def fail(job_id)
75
+ ensure_connection
76
+ @fail_stmt.execute(job_id)
77
+ end
78
+
79
+ def recover(worker_id)
80
+ ensure_connection
81
+ @requeue_stmt.execute(worker_id)
82
+ @db.changes
83
+ end
84
+
85
+ def close
86
+ return unless @db && !@db.closed?
87
+
88
+ finalize_statements
89
+ @db.execute("PRAGMA optimize")
90
+ @db.close
91
+ @db = nil
92
+ end
93
+
94
+ def self.default_path
95
+ "async_background_queue.db"
96
+ end
97
+
98
+ private
99
+
100
+ def require_sqlite3
101
+ require 'sqlite3'
102
+ rescue LoadError
103
+ raise LoadError,
104
+ "sqlite3 gem is required for Async::Background::Queue. " \
105
+ "Add `gem 'sqlite3', '~> 2.0'` to your Gemfile."
106
+ end
107
+
108
+ def ensure_connection
109
+ return if @db && !@db.closed?
110
+
111
+ require_sqlite3
112
+ finalize_statements
113
+ @db = SQLite3::Database.new(@path)
114
+ @db.execute_batch(PRAGMAS)
115
+
116
+ unless @schema_checked
117
+ @db.execute_batch(SCHEMA)
118
+ @schema_checked = true
119
+ end
120
+
121
+ prepare_statements
122
+ @last_cleanup_at = monotonic_now
123
+ end
124
+
125
+ def prepare_statements
126
+ @enqueue_stmt = @db.prepare(
127
+ "INSERT INTO jobs (class_name, args, created_at) VALUES (?, ?, ?)"
128
+ )
129
+
130
+ @fetch_stmt = @db.prepare(<<~SQL)
131
+ UPDATE jobs
132
+ SET status = 'running', locked_by = ?, locked_at = ?
133
+ WHERE id = (
134
+ SELECT id FROM jobs
135
+ WHERE status = 'pending'
136
+ ORDER BY id
137
+ LIMIT 1
138
+ )
139
+ RETURNING id, class_name, args
140
+ SQL
141
+
142
+ @complete_stmt = @db.prepare("UPDATE jobs SET status = 'done' WHERE id = ?")
143
+ @fail_stmt = @db.prepare("UPDATE jobs SET status = 'failed' WHERE id = ?")
144
+
145
+ @requeue_stmt = @db.prepare(
146
+ "UPDATE jobs SET status = 'pending', locked_by = NULL, locked_at = NULL " \
147
+ "WHERE status = 'running' AND locked_by = ?"
148
+ )
149
+
150
+ @cleanup_stmt = @db.prepare("DELETE FROM jobs WHERE status = 'done' AND created_at < ?")
151
+ end
152
+
153
+ def finalize_statements
154
+ %i[@enqueue_stmt @fetch_stmt @complete_stmt @fail_stmt @requeue_stmt @cleanup_stmt].each do |name|
155
+ stmt = instance_variable_get(name)
156
+ stmt&.close rescue nil
157
+ instance_variable_set(name, nil)
158
+ end
159
+ end
160
+
161
+ def maybe_cleanup
162
+ now = monotonic_now
163
+ return if (now - @last_cleanup_at) < CLEANUP_INTERVAL
164
+
165
+ @last_cleanup_at = now
166
+ @cleanup_stmt.execute(realtime_now - CLEANUP_AGE)
167
+
168
+ if @db.changes > 100
169
+ @db.execute("PRAGMA incremental_vacuum")
170
+ end
171
+ end
172
+
173
+ def realtime_now
174
+ Process.clock_gettime(Process::CLOCK_REALTIME)
175
+ end
176
+
177
+ def monotonic_now
178
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
179
+ end
180
+ end
181
+ end
182
+ end
183
+ 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
16
  attr_reader :logger, :semaphore, :heap, :worker_index, :total_workers, :shutdown, :metrics
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
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)
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,118 @@ module Async
81
62
 
82
63
  private
83
64
 
65
+ def setup_queue(queue_notifier, queue_db_path)
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(path: queue_db_path || Queue::Store.default_path)
80
+
81
+ recovered = @queue_store.recover(worker_index)
82
+ logger.info { "Async::Background queue: recovered #{recovered} stale jobs" } if recovered > 0
83
+ end
84
+
85
+ def start_queue_listener(task)
86
+ task.async do
87
+ logger.info { "Async::Background queue: listening on worker #{worker_index}" }
88
+
89
+ while running?
90
+ @queue_notifier.wait(timeout: QUEUE_POLL_INTERVAL)
91
+
92
+ while running?
93
+ job = @queue_store.fetch(worker_index)
94
+ break unless job
95
+
96
+ semaphore.async { run_queue_job(task, job) }
97
+ end
98
+ end
99
+ end
100
+ end
101
+
102
+ def run_queue_job(task, job)
103
+ class_name = job[:class_name]
104
+ args = job[:args]
105
+ klass = resolve_job_class(class_name)
106
+
107
+ metrics.job_started(nil)
108
+ t = monotonic_now
109
+
110
+ task.with_timeout(DEFAULT_TIMEOUT) { klass.perform_now(*args) }
111
+
112
+ duration = monotonic_now - t
113
+ metrics.job_finished(nil, duration)
114
+ @queue_store.complete(job[:id])
115
+
116
+ logger.info('Async::Background') {
117
+ "queue(#{class_name}): completed in #{duration.round(2)}s"
118
+ }
119
+ rescue ::Async::TimeoutError
120
+ metrics.job_timed_out(nil)
121
+ @queue_store.fail(job[:id])
122
+ logger.error('Async::Background') { "queue(#{class_name}): timed out" }
123
+ rescue => e
124
+ metrics.job_failed(nil, e)
125
+ @queue_store.fail(job[:id])
126
+ logger.error('Async::Background') {
127
+ "queue(#{class_name}): #{e.class} #{e.message}\n#{e.backtrace.join("\n")}"
128
+ }
129
+ end
130
+
131
+ def resolve_job_class(class_name)
132
+ raise ConfigError, "empty class name in queue job" if class_name.nil? || class_name.strip.empty?
133
+
134
+ names = class_name.split("::")
135
+ klass = names.reduce(Object) do |mod, name|
136
+ raise ConfigError, "unknown class: #{class_name}" unless mod.const_defined?(name, false)
137
+ mod.const_get(name, false)
138
+ end
139
+
140
+ raise ConfigError, "#{class_name} must implement .perform_now" unless klass.respond_to?(:perform_now)
141
+
142
+ klass
143
+ end
144
+
145
+ def scheduler_loop(task)
146
+ loop do
147
+ entry = heap.peek
148
+ break unless entry
149
+
150
+ now = monotonic_now
151
+ wait = [entry.next_run_at - now, MIN_SLEEP_TIME].max
152
+ wait_with_shutdown(task, wait)
153
+ break unless running?
154
+
155
+ now = monotonic_now
156
+ while (entry = heap.peek) && entry.next_run_at <= now
157
+ break unless running?
158
+
159
+ if entry.running
160
+ logger.warn('Async::Background') { "#{entry.name}: skipped, previous run still active" }
161
+ metrics.job_skipped(entry)
162
+ else
163
+ entry.running = true
164
+ semaphore.async do
165
+ run_job(task, entry)
166
+ ensure
167
+ entry.running = false
168
+ end
169
+ end
170
+
171
+ entry.reschedule(monotonic_now)
172
+ heap.replace_top(entry)
173
+ end
174
+ end
175
+ end
176
+
84
177
  def setup_signal_handlers
85
178
  @signal_r, @signal_w = IO.pipe
86
179
 
@@ -98,6 +191,7 @@ module Async
98
191
  @signal_r.wait_readable
99
192
  @signal_r.read_nonblock(256) rescue nil
100
193
  shutdown.signal
194
+ @queue_notifier&.notify
101
195
  break unless running?
102
196
  end
103
197
  end
@@ -183,10 +277,10 @@ module Async
183
277
 
184
278
  def run_job(task, entry)
185
279
  metrics.job_started(entry)
186
- t = Process.clock_gettime(Process::CLOCK_MONOTONIC)
280
+ t = monotonic_now
187
281
  task.with_timeout(entry.timeout) { entry.job_class.perform_now }
188
282
 
189
- duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - t
283
+ duration = monotonic_now - t
190
284
  metrics.job_finished(entry, duration)
191
285
  logger.info('Async::Background') {
192
286
  "#{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.4'
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.4
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-28 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