async-background 0.7.0 → 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.
@@ -1,38 +1,59 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'yaml'
4
- require 'zlib'
3
+ require 'async/barrier'
4
+
5
+ require_relative 'runner/queue_execution'
6
+ require_relative 'runner/schedule'
5
7
 
6
8
  module Async
7
9
  module Background
8
10
  class ConfigError < StandardError; end
9
11
 
10
- DEFAULT_TIMEOUT = 30
11
- MIN_SLEEP_TIME = 0.1
12
- MAX_JITTER = 5
12
+ DEFAULT_TIMEOUT = 30
13
+ MIN_SLEEP_TIME = 0.1
14
+ MAX_JITTER = 5
13
15
  QUEUE_POLL_INTERVAL = 5
16
+ MIN_QUEUE_WAIT = 0.001
14
17
 
15
18
  class Runner
16
19
  include Clock
17
-
18
- attr_reader :logger, :semaphore, :heap, :worker_index, :total_workers, :shutdown, :metrics, :queue_store
20
+ include QueueExecution
21
+ include Schedule
22
+
23
+ attr_reader :logger,
24
+ :semaphore,
25
+ :heap,
26
+ :worker_index,
27
+ :total_workers,
28
+ :shutdown,
29
+ :metrics,
30
+ :queue_store
19
31
 
20
32
  def initialize(
21
- config_path:, job_count: 2, worker_index:, total_workers:,
22
- queue_socket_dir: nil, queue_db_path: nil, queue_mmap: true
33
+ config_path:,
34
+ job_count: 2,
35
+ worker_index:,
36
+ total_workers:,
37
+ queue_socket_dir: nil,
38
+ queue_db_path: nil,
39
+ queue_mmap: true,
40
+ metrics_shm_path: Metrics.default_shm_path
23
41
  )
24
- @logger = Console.logger
25
- @worker_index = worker_index
42
+ @logger = Console.logger
43
+ @worker_index = worker_index
26
44
  @total_workers = total_workers
27
- @running = true
28
- @shutdown = ::Async::Condition.new
29
- @metrics = Metrics.new(worker_index: worker_index, total_workers: total_workers)
30
-
45
+ @running = true
46
+ @shutdown = ::Async::Condition.new
47
+ @metrics = Metrics.new(
48
+ worker_index: worker_index,
49
+ total_workers: total_workers,
50
+ shm_path: metrics_shm_path
51
+ )
31
52
  logger.info { "Async::Background worker_index=#{worker_index}/#{total_workers}, job_count=#{job_count}" }
32
53
 
33
- @semaphore = ::Async::Semaphore.new(job_count)
34
- @heap = build_heap(config_path)
35
-
54
+ @drain_barrier = ::Async::Barrier.new
55
+ @semaphore = ::Async::Semaphore.new(job_count, parent: @drain_barrier)
56
+ @heap = build_heap(config_path)
36
57
  setup_queue(queue_socket_dir, queue_db_path, queue_mmap)
37
58
  end
38
59
 
@@ -43,10 +64,7 @@ module Async
43
64
  start_queue_listener(task) if @listen_queue
44
65
 
45
66
  scheduler_loop(task)
46
-
47
- semaphore.acquire {}
48
- @queue_store&.close
49
- @queue_waker&.close
67
+ drain_and_close_queue
50
68
  end
51
69
  end
52
70
 
@@ -54,154 +72,86 @@ module Async
54
72
  return unless @running
55
73
 
56
74
  @running = false
57
- logger.info { "Async::Background: stopping gracefully" }
75
+ logger.info { 'Async::Background: stopping gracefully' }
58
76
  shutdown.signal
59
77
  @queue_waker&.signal
60
78
  end
61
79
 
62
- def running?
63
- @running
64
- end
80
+ def running? = @running
65
81
 
66
82
  private
67
83
 
68
- def setup_queue(queue_socket_dir, queue_db_path, queue_mmap)
69
- @listen_queue = false
70
- return unless queue_socket_dir
71
-
72
- isolated = ENV.fetch("ISOLATION_FORKS", "").split(",").map(&:to_i)
73
- return if isolated.include?(worker_index)
74
-
75
- require_relative 'queue/store'
76
- require_relative 'queue/socket_waker'
77
- require_relative 'queue/client'
78
-
79
- @listen_queue = true
80
- @queue_store = Queue::Store.new(
81
- path: queue_db_path || Queue::Store.default_path,
82
- mmap: queue_mmap
83
- )
84
-
85
- socket_path = File.join(queue_socket_dir, "async_bg_worker_#{worker_index}.sock")
86
- @queue_waker = Queue::SocketWaker.new(socket_path)
87
- @queue_waker.open!
88
-
89
- recovered = @queue_store.recover(worker_index)
90
- logger.info { "Async::Background queue: recovered #{recovered} stale jobs" } if recovered > 0
91
- end
92
-
93
- def start_queue_listener(task)
94
- @queue_waker.start_accept_loop(task)
95
-
96
- task.async do
97
- logger.info { "Async::Background queue: listening on worker #{worker_index}" }
98
-
99
- while running?
100
- @queue_waker.wait(timeout: QUEUE_POLL_INTERVAL)
84
+ def scheduler_loop(task)
85
+ loop do
86
+ entry = heap.peek
87
+ break unless entry
101
88
 
102
- while running?
103
- job = @queue_store.fetch(worker_index)
104
- break unless job
89
+ wait_for_next_entry(task, entry)
90
+ break unless running?
105
91
 
106
- semaphore.async { |job_task| run_queue_job(job_task, job) }
107
- end
108
- end
92
+ dispatch_due_entries
109
93
  end
110
94
  end
111
95
 
112
- def run_queue_job(job_task, job)
113
- class_name = job[:class_name]
114
- klass = resolve_job_class(class_name)
115
- options = parse_job_options(job[:options])
116
-
117
- metrics.job_started(nil)
118
- started = monotonic_now
119
- job_task.with_timeout(options.timeout) { klass.perform_now(*job[:args]) }
120
- duration = monotonic_now - started
121
-
122
- metrics.job_finished(nil, duration)
123
- @queue_store.complete(job[:id])
124
- logger.info('Async::Background') { "queue(#{class_name}): completed in #{duration.round(2)}s" }
125
- rescue ConfigError => e
126
- metrics.job_failed(nil, e) if options
127
- @queue_store.fail(job[:id])
128
- logger.error('Async::Background') { "queue(#{class_name}): #{e.class} #{e.message}" }
129
- rescue ::Async::TimeoutError
130
- metrics.job_timed_out(nil)
131
- handle_queue_failure(job, options, "timed out after #{options.timeout}s", backtrace: nil)
132
- rescue => e
133
- metrics.job_failed(nil, e)
134
- handle_queue_failure(job, options, "#{e.class} #{e.message}", backtrace: e.backtrace)
135
- end
136
-
137
- def parse_job_options(raw)
138
- Job::Options.new(**(raw || {}))
139
- rescue ArgumentError, TypeError => e
140
- raise ConfigError, "invalid queue options: #{e.message}"
96
+ def wait_for_next_entry(task, entry)
97
+ wait = [entry.next_run_at - monotonic_now, MIN_SLEEP_TIME].max
98
+ wait_with_shutdown(task, wait)
141
99
  end
142
100
 
143
- def handle_queue_failure(job, options, message, backtrace:)
144
- result = @queue_store.retry_or_fail(job[:id], fallback_options: options)
145
- class_name = job[:class_name]
101
+ def dispatch_due_entries
102
+ now = monotonic_now
103
+ while (entry = heap.peek) && entry.next_run_at <= now
104
+ break unless running?
146
105
 
147
- if result == :retried
148
- @queue_waker&.signal
149
- attempt = options.next_attempt
150
- logger.warn('Async::Background') do
151
- "queue(#{class_name}): #{message}; retry #{attempt}/#{options.retry}"
152
- end
153
- else
154
- tail = backtrace ? "\n#{backtrace.join("\n")}" : ''
155
- logger.error('Async::Background') { "queue(#{class_name}): #{message}#{tail}" }
106
+ dispatch_entry(entry)
156
107
  end
157
108
  end
158
109
 
159
- def resolve_job_class(class_name)
160
- raise ConfigError, "empty class name in queue job" if class_name.nil? || class_name.to_s.strip.empty?
161
-
162
- klass = class_name.split("::").reduce(Object) do |mod, name|
163
- raise ConfigError, "unknown class: #{class_name}" unless mod.const_defined?(name, false)
164
- mod.const_get(name, false)
110
+ def dispatch_entry(entry)
111
+ if entry.running
112
+ skip_entry(entry)
113
+ else
114
+ execute_entry(entry)
165
115
  end
166
116
 
167
- raise ConfigError, "#{class_name} must include Async::Background::Job" unless klass.respond_to?(:perform_now)
168
-
169
- klass
117
+ entry.reschedule(monotonic_now)
118
+ heap.replace_top(entry)
170
119
  end
171
120
 
172
- def scheduler_loop(task)
173
- loop do
174
- entry = heap.peek
175
- break unless entry
176
-
177
- now = monotonic_now
178
- wait = [entry.next_run_at - now, MIN_SLEEP_TIME].max
179
- wait_with_shutdown(task, wait)
180
- break unless running?
181
-
182
- now = monotonic_now
183
- while (entry = heap.peek) && entry.next_run_at <= now
184
- break unless running?
121
+ def skip_entry(entry)
122
+ logger.warn('Async::Background') { "#{entry.name}: skipped, previous run still active" }
123
+ metrics.job_skipped(entry)
124
+ end
185
125
 
186
- if entry.running
187
- logger.warn('Async::Background') { "#{entry.name}: skipped, previous run still active" }
188
- metrics.job_skipped(entry)
189
- entry.reschedule(monotonic_now)
190
- heap.replace_top(entry)
191
- next
192
- end
126
+ def execute_entry(entry)
127
+ entry.running = true
128
+ semaphore.async do |job_task|
129
+ run_job(job_task, entry)
130
+ ensure
131
+ entry.running = false
132
+ end
133
+ end
193
134
 
194
- entry.running = true
195
- semaphore.async do |job_task|
196
- run_job(job_task, entry)
197
- ensure
198
- entry.running = false
199
- end
135
+ def run_job(job_task, entry)
136
+ metrics_started = false
137
+ metrics.job_started(entry)
138
+ metrics_started = true
139
+ started_at = monotonic_now
140
+ job_task.with_timeout(entry.timeout) { entry.job_class.perform_now }
200
141
 
201
- entry.reschedule(monotonic_now)
202
- heap.replace_top(entry)
203
- end
204
- end
142
+ duration = monotonic_now - started_at
143
+ metrics.job_succeeded(entry, duration)
144
+ logger.info('Async::Background') { "#{entry.name}: completed in #{duration.round(2)}s" }
145
+ rescue ::Async::TimeoutError
146
+ metrics.job_timed_out(entry)
147
+ logger.error('Async::Background') { "#{entry.name}: timed out after #{entry.timeout}s" }
148
+ rescue StandardError => error
149
+ metrics.job_failed(entry, error)
150
+ logger.error('Async::Background') {
151
+ "#{entry.name}: #{error.class} #{error.message}\n#{error.backtrace.join("\n")}"
152
+ }
153
+ ensure
154
+ metrics.job_stopped(entry) if metrics_started
205
155
  end
206
156
 
207
157
  def setup_signal_handlers
@@ -232,92 +182,10 @@ module Async
232
182
  rescue ::Async::TimeoutError
233
183
  end
234
184
 
235
- def build_heap(config_path)
236
- raise ConfigError, "Schedule file not found: #{config_path}" unless File.exist?(config_path)
237
-
238
- raw = YAML.safe_load_file(config_path)
239
- raise ConfigError, "Empty schedule: #{config_path}" unless raw&.any?
240
-
241
- heap = MinHeap.new
242
- now = monotonic_now
243
-
244
- raw.each do |name, config|
245
- assigned = config['worker']&.to_i || ((Zlib.crc32(name) % total_workers) + 1)
246
- next unless assigned == worker_index
247
-
248
- task_config = build_task_config(name, config)
249
- jitter = rand * [task_config[:interval] || MAX_JITTER, MAX_JITTER].min
250
-
251
- next_run_at = if task_config[:interval]
252
- now + jitter + task_config[:interval]
253
- else
254
- now_wall = Time.now
255
- wall_wait = task_config[:cron].next_time(now_wall).to_f - now_wall.to_f
256
- now + jitter + [wall_wait, MIN_SLEEP_TIME].max
257
- end
258
-
259
- heap.push(Entry.new(
260
- name: name,
261
- job_class: task_config[:job_class],
262
- interval: task_config[:interval],
263
- cron: task_config[:cron],
264
- timeout: task_config[:timeout],
265
- next_run_at: next_run_at
266
- ))
267
- end
268
-
269
- heap
270
- end
271
-
272
- def build_task_config(name, config)
273
- class_name = config&.dig('class').to_s.strip
274
- raise ConfigError, "[#{name}] missing class" if class_name.empty?
275
-
276
- job_class = begin
277
- resolve_job_class(class_name)
278
- rescue ConfigError => e
279
- raise ConfigError, "[#{name}] #{e.message}"
280
- end
281
-
282
- interval = config['every']&.then { |v|
283
- int = v.to_i
284
- raise ConfigError, "[#{name}] 'every' must be > 0" unless int.positive?
285
- int
286
- }
287
-
288
- cron = config['cron']&.then { |c|
289
- Fugit::Cron.new(c) || raise(ConfigError, "[#{name}] invalid cron: #{c}")
290
- }
291
-
292
- raise ConfigError, "[#{name}] specify 'every' or 'cron'" unless interval || cron
293
-
294
- timeout = begin
295
- Job::Options.new(timeout: config.fetch('timeout', DEFAULT_TIMEOUT)).timeout
296
- rescue ArgumentError, TypeError => e
297
- raise ConfigError, "[#{name}] #{e.message}"
298
- end
299
-
300
- { job_class: job_class, interval: interval, cron: cron, timeout: timeout }
301
- end
302
-
303
- def run_job(job_task, entry)
304
- metrics.job_started(entry)
305
- t = monotonic_now
306
- job_task.with_timeout(entry.timeout) { entry.job_class.perform_now }
307
-
308
- duration = monotonic_now - t
309
- metrics.job_finished(entry, duration)
310
- logger.info('Async::Background') {
311
- "#{entry.name}: completed in #{duration.round(2)}s"
312
- }
313
- rescue ::Async::TimeoutError
314
- metrics.job_timed_out(entry)
315
- logger.error('Async::Background') { "#{entry.name}: timed out after #{entry.timeout}s" }
316
- rescue => e
317
- metrics.job_failed(entry, e)
318
- logger.error('Async::Background') {
319
- "#{entry.name}: #{e.class} #{e.message}\n#{e.backtrace.join("\n")}"
320
- }
185
+ def drain_and_close_queue
186
+ @drain_barrier.wait
187
+ @queue_store&.close
188
+ @queue_waker&.close
321
189
  end
322
190
  end
323
191
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Async
4
4
  module Background
5
- VERSION = '0.7.0'
5
+ VERSION = '0.7.2'
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: async-background
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 0.7.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Roman Hajdarov
8
+ autorequire:
8
9
  bindir: bin
9
10
  cert_chain: []
10
- date: 2026-04-23 00:00:00.000000000 Z
11
+ date: 2026-06-27 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: async
@@ -79,6 +80,26 @@ dependencies:
79
80
  - - "~>"
80
81
  - !ruby/object:Gem::Version
81
82
  version: '3.12'
83
+ - !ruby/object:Gem::Dependency
84
+ name: async-utilization
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0.3'
90
+ - - "<"
91
+ - !ruby/object:Gem::Version
92
+ version: '0.5'
93
+ type: :development
94
+ prerelease: false
95
+ version_requirements: !ruby/object:Gem::Requirement
96
+ requirements:
97
+ - - ">="
98
+ - !ruby/object:Gem::Version
99
+ version: '0.3'
100
+ - - "<"
101
+ - !ruby/object:Gem::Version
102
+ version: '0.5'
82
103
  description: A production-grade lightweight scheduler built on top of Async. Single
83
104
  event loop with min-heap timer, skip-overlapping execution, jitter, monotonic clock
84
105
  intervals, semaphore concurrency control, and deterministic worker sharding. Designed
@@ -101,10 +122,15 @@ files:
101
122
  - lib/async/background/min_heap.rb
102
123
  - lib/async/background/queue/client.rb
103
124
  - lib/async/background/queue/notifier.rb
125
+ - lib/async/background/queue/options.rb
126
+ - lib/async/background/queue/schema.rb
104
127
  - lib/async/background/queue/socket_notifier.rb
105
128
  - lib/async/background/queue/socket_waker.rb
129
+ - lib/async/background/queue/sql.rb
106
130
  - lib/async/background/queue/store.rb
107
131
  - lib/async/background/runner.rb
132
+ - lib/async/background/runner/queue_execution.rb
133
+ - lib/async/background/runner/schedule.rb
108
134
  - lib/async/background/version.rb
109
135
  homepage: https://github.com/roman-haidarov/async-background
110
136
  licenses:
@@ -113,6 +139,7 @@ metadata:
113
139
  source_code_uri: https://github.com/roman-haidarov/async-background
114
140
  changelog_uri: https://github.com/roman-haidarov/async-background/blob/main/CHANGELOG.md
115
141
  bug_tracker_uri: https://github.com/roman-haidarov/async-background/issues
142
+ post_install_message:
116
143
  rdoc_options: []
117
144
  require_paths:
118
145
  - lib
@@ -127,7 +154,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
127
154
  - !ruby/object:Gem::Version
128
155
  version: '0'
129
156
  requirements: []
130
- rubygems_version: 3.6.2
157
+ rubygems_version: 3.3.27
158
+ signing_key:
131
159
  specification_version: 4
132
160
  summary: Lightweight heap-based cron/interval scheduler for Async.
133
161
  test_files: []