async-background 1.0.2 → 1.1.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 233f46d3617b40d9a8a27f83689050b0127603679342ddfa80a18eea0137651c
4
- data.tar.gz: 5d70fe357702dff97b51734c1fddf64178f3ea0d9d9d88449fe3af9b32fc59d3
3
+ metadata.gz: 1da83cfa99bb2a77a32b8bd72dd5bee692f94f87b453706f4443cc015e065ac4
4
+ data.tar.gz: b0c60214b0e13a7a0c346f5e681d78b1701a7de95e5e816f84193d6593f2c282
5
5
  SHA512:
6
- metadata.gz: 148770cb2522083b5944a5ee0f5830a1e1453f9c06647feaf0b92842e6008e2654aefd7e8c4a4a97509fc823702d64176a7bf4e30e8a08d409308f57792bbb1d
7
- data.tar.gz: 99e64f1f1066528429617270d54ae64109896d1318634f5b4b001a9d10e4ed19b88239dc4b7a089c885d4f481d2799b22b70495b763d64df51702c82b15865f8
6
+ metadata.gz: 6cdc11bc1e0bbfb5d76e8d26bb80a71d498c3a04a0fbd1a7e3212640c3f0ae1513fb8bb18784e1dfdd7b96aafc1771ed8cf0db00ac1fedde8e63579943df1e2b
7
+ data.tar.gz: ed97b4b602abb304db44199247b533d2fce410ab8a3a80182e9a08ac65eff680d0df198f529ef53b87e947ccf8114b4e89e333f5695928fc1b0137b6dff90c53
data/CHANGELOG.md CHANGED
@@ -1,5 +1,72 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.1.0
4
+
5
+ The gem no longer depends on `async`. The same worker runs under any
6
+ `Fiber::Scheduler` the host installs — Falcon/Async, Itsi, or another
7
+ implementation.
8
+
9
+ ### Breaking
10
+
11
+ - `Runner#run` no longer starts a reactor. It needs an active
12
+ `Fiber.scheduler` and raises `Runtime::SchedulerRequired` without one:
13
+
14
+ ```ruby
15
+ Async { runner.run } # Falcon / Async
16
+ Async::Background::Scheduler.run { runner.run } # async or itsi via ENV
17
+ ```
18
+
19
+ - Job timeouts raise `Async::Background::Runtime::TimeoutError` instead of
20
+ `Async::TimeoutError`. Update rescue sites and any stored `error_class`.
21
+ - `Runner#drain_jobs` is bounded (`drain_timeout:` default 30s). In-flight jobs
22
+ that outlive it are cancelled. Pass `drain_timeout: nil` for an unbounded wait.
23
+ - `TaskGroup#stop_all(grace = nil)` returns whether the group drained, not a
24
+ count of cancelled tasks.
25
+ - `Async::Background::Error` is the base class for `Runtime::Error` and
26
+ `ConfigError`. Both are still `StandardError`.
27
+
28
+ ### Added
29
+
30
+ - `Async::Background::Runtime` — `Task`, `TaskGroup`, `Semaphore`,
31
+ `Notification`, `with_timeout`, `native_timeouts?`, `with_error_handler`.
32
+ - `Async::Background::Scheduler` — optional bootstrap
33
+ (`require "async/background/scheduler"`) that installs `async` or
34
+ `itsi-scheduler` from `ASYNC_BACKGROUND_SCHEDULER`. `Scheduler.preload!`
35
+ loads the gem before fork. `Scheduler.run` on Itsi uses the current thread;
36
+ `ASYNC_BACKGROUND_SCHEDULER_THREAD=1` restores a dedicated thread.
37
+
38
+ ### Fixed
39
+
40
+ - The saturated-queue wake-up fires from `TaskGroup#on_release`, after the job
41
+ has left `@jobs`. Signalling from the task's `ensure` left the listener
42
+ seeing a full group on any scheduler that resumes `unblock` immediately.
43
+ - `Runtime.with_timeout` calls `scheduler.timeout_after` when the hook exists.
44
+ The `::Timeout.timeout` fallback uses `Thread#raise` and can hit the wrong
45
+ fiber; a missing hook now warns once per scheduler class.
46
+ - `SocketWaker#close` stops the accept loop before the self-connect that
47
+ unblocks it, and hangs up tracked client sockets. A fiber parked in
48
+ `IO#wait_readable` is not cancellable via `Task#stop` on a scheduler without
49
+ `#fiber_interrupt`.
50
+ - `Runtime.error_handler` is scoped per runner (`on_error:` /
51
+ `with_error_handler`). A process-global handler was overwritten by the next
52
+ runner and never restored.
53
+ - A failure awaited via `Task#wait` is no longer also sent to the error handler.
54
+ - `Semaphore.new(0)` raises `ArgumentError` instead of deadlocking on acquire.
55
+
56
+ ### Changed
57
+
58
+ - `async` is a development dependency. Runtime gems are `console`, `fugit`
59
+ and `base64`.
60
+ - `SocketWaker#start_accept_loop` no longer needs a parent task (the
61
+ argument is still accepted and ignored).
62
+ - Shutdown closes the waker before the store, so the listener cannot be inside
63
+ `Store#fetch` when the connection disappears.
64
+
65
+ ### Unchanged
66
+
67
+ - Dashboard, `perform_async` / `perform_in` / `perform_at`, SQLite schema,
68
+ and the cross-process wake protocol.
69
+
3
70
  ## 1.0.2
4
71
 
5
72
  Queue maintenance bug fix plus profiler-driven work on the hot paths that
data/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # Async::Background
2
2
 
3
- A lightweight cron, interval, and job-queue scheduler for Ruby's
4
- [Async](https://github.com/socketry/async) ecosystem. Built for
5
- [Falcon](https://github.com/socketry/falcon), works with any Async app.
3
+ A lightweight cron, interval, and job-queue scheduler for Ruby fibers.
4
+ Works with any `Fiber::Scheduler` — [Falcon](https://github.com/socketry/falcon) /
5
+ [Async](https://github.com/socketry/async), [Itsi](https://itsi.fyi), or your own.
6
6
 
7
7
  - **Cron & interval scheduling** on a single event loop with a min-heap.
8
8
  - **Dynamic job queue** backed by SQLite, with delayed jobs
@@ -15,11 +15,11 @@ A lightweight cron, interval, and job-queue scheduler for Ruby's
15
15
 
16
16
  ---
17
17
 
18
- ## Why Async? Why fibers?
18
+ ## Why fibers?
19
19
 
20
- The whole gem is built around the assumption that Falcon's reactor schedules
21
- many fibers on top of one OS thread per process — so the dashboard's SSE
22
- stream, the cron scheduler, and the queue worker all share that one thread
20
+ The whole gem is built around the assumption that the host schedules many
21
+ fibers on top of one OS thread per process — so the dashboard's SSE stream,
22
+ the cron scheduler, and the queue worker all share that one thread
23
23
  cooperatively. A blocked fiber yields; a blocked thread doesn't.
24
24
 
25
25
  ![Threads vs fibers under different Ruby web servers](docs/fibers-vs-threads.svg)
@@ -30,13 +30,31 @@ open tab.
30
30
 
31
31
  ---
32
32
 
33
+ ## Fiber scheduler
34
+
35
+ `Runner#run` does not start a reactor. The host must install a
36
+ `Fiber.scheduler` first:
37
+
38
+ ```ruby
39
+ Async { runner.run } # Falcon / Async
40
+
41
+ require "async/background/scheduler"
42
+ Async::Background::Scheduler.run { runner.run } # ASYNC_BACKGROUND_SCHEDULER=async|itsi
43
+ ```
44
+
45
+ Without a scheduler it raises `Runtime::SchedulerRequired`.
46
+ Worker setup for Falcon and Itsi is in [Get started](docs/GET_STARTED.md).
47
+
48
+ ---
49
+
33
50
  ## Requirements
34
51
 
35
52
  | Dependency | Version | Required? |
36
53
  | -------------------- | ---------------- | -------------------- |
37
54
  | Ruby | `>= 3.3` | yes |
38
- | `async` | `~> 2.0` | yes |
55
+ | a `Fiber::Scheduler` | `async ~> 2.0`, `itsi-scheduler`, … | yes — installed by the host, not by this gem |
39
56
  | `fugit` | `~> 1.0` | yes |
57
+ | `console` | `~> 1.0` | yes |
40
58
  | `sqlite3` | `~> 2.0` | for the queue & dashboard |
41
59
  | `async-utilization` | `>= 0.3, < 0.5` | for metrics |
42
60
 
@@ -48,16 +66,16 @@ open tab.
48
66
  # Gemfile
49
67
  gem "async-background"
50
68
 
51
- gem "sqlite3", "~> 2.0" # if you use the queue or dashboard
52
- gem "async-utilization", ">= 0.3", "< 0.5" # if you want worker metrics
69
+ gem "async", "~> 2.0" # or `itsi-scheduler`
70
+ gem "sqlite3", "~> 2.0" # queue / dashboard
71
+ gem "async-utilization", ">= 0.3", "< 0.5" # optional metrics
53
72
  ```
54
73
 
55
74
  ---
56
75
 
57
76
  ## ➡️ [Get started](docs/GET_STARTED.md)
58
77
 
59
- A four-step walkthrough: schedule config, Falcon integration, Docker, queue,
60
- delayed jobs.
78
+ Schedule config, Falcon or Itsi workers, Docker, queue, delayed jobs.
61
79
 
62
80
  ---
63
81
 
@@ -120,9 +138,12 @@ jobs use wall-clock time, because "every day at 3am" needs to mean 3am.
120
138
 
121
139
  ## How it works
122
140
 
123
- A single Async task sleeps until the next entry is due, then dispatches it
124
- under a semaphore that caps concurrency. Overlapping ticks are skipped and
125
- rescheduled.
141
+ A single fiber sleeps until the next entry is due, then dispatches it under a
142
+ semaphore that caps concurrency. Overlapping ticks are skipped and rescheduled.
143
+ Every dispatched job is tracked in a `Runtime::TaskGroup`, so shutdown drains
144
+ in-flight work before the SQLite store and the wake-up socket are closed. The
145
+ drain is bounded by `drain_timeout:` (30s by default; pass `nil` for the old
146
+ unbounded wait) so one wedged job cannot turn SIGTERM into SIGKILL.
126
147
 
127
148
  ```
128
149
  schedule.yml → build_heap → MinHeap<Entry> → scheduler loop → Semaphore → run_job
@@ -8,12 +8,13 @@ Gem::Specification.new do |spec|
8
8
  spec.authors = ['Roman Hajdarov']
9
9
  spec.email = ['romnhajdarov@gmail.com']
10
10
 
11
- spec.summary = 'Lightweight heap-based cron/interval scheduler for Async.'
12
- spec.description = 'A production-grade lightweight scheduler built on top of Async. ' \
13
- 'Single event loop with min-heap timer, skip-overlapping execution, ' \
14
- 'jitter, monotonic clock intervals, semaphore concurrency control, ' \
15
- 'and deterministic worker sharding. Designed for Falcon but works ' \
16
- 'with any Async-based application.'
11
+ spec.summary = 'Lightweight heap-based cron/interval scheduler for any Fiber scheduler.'
12
+ spec.description = 'A production-grade lightweight background job scheduler built on the ' \
13
+ 'Fiber::Scheduler interface itself, not on any one implementation: it ' \
14
+ 'runs under Async/Falcon, Itsi, or anything else that installs a ' \
15
+ 'scheduler. Single event loop with min-heap timer, skip-overlapping ' \
16
+ 'execution, jitter, monotonic clock intervals, semaphore concurrency ' \
17
+ 'control, and deterministic worker sharding.'
17
18
 
18
19
  spec.homepage = 'https://github.com/roman-haidarov/async-background'
19
20
  spec.license = 'MIT'
@@ -30,15 +31,20 @@ Gem::Specification.new do |spec|
30
31
 
31
32
  spec.required_ruby_version = '>= 3.3'
32
33
 
33
- spec.add_dependency 'async', '~> 2.0'
34
+ # No scheduler dependency on purpose. The gem uses the Fiber::Scheduler
35
+ # interface (Fiber.schedule, #block/#unblock, Timeout) and requires the host
36
+ # to install an implementation.
34
37
  spec.add_dependency 'console', '~> 1.0'
35
38
  spec.add_dependency 'fugit', '~> 1.0'
36
39
  spec.add_dependency 'base64', '~> 0.2'
37
40
 
38
41
  # Optional: add to your own Gemfile if you need these features
42
+ # gem 'async', '~> 2.0' # or any other Fiber scheduler
43
+ # gem 'itsi-scheduler' # ... such as this one
39
44
  # gem 'sqlite3', '~> 2.0'
40
45
  # gem 'async-utilization', '>= 0.3', '< 0.5' # shared-memory worker metrics
41
46
 
47
+ spec.add_development_dependency 'async', '~> 2.0'
42
48
  spec.add_development_dependency 'rake', '~> 13.0'
43
49
  spec.add_development_dependency 'rspec', '~> 3.12'
44
50
  spec.add_development_dependency 'rack', '~> 3.0'
@@ -29,11 +29,9 @@ module Async
29
29
 
30
30
  enable_incremental_vacuum!(db) unless jobs_table?(db)
31
31
 
32
- with_migration_timeout(db) do
33
- immediate_transaction(db) do
34
- reject_future_version!(db)
35
- upgrade!(db) unless current?(db)
36
- end
32
+ synchronized_change(db) do
33
+ reject_future_version!(db)
34
+ upgrade!(db) unless current?(db)
37
35
  end
38
36
  end
39
37
 
@@ -41,20 +39,20 @@ module Async
41
39
  migrate!(db)
42
40
  return if dashboard_indexes_current?(db)
43
41
 
44
- with_migration_timeout(db) do
45
- immediate_transaction(db) do
46
- create_dashboard_indexes!(db) unless dashboard_indexes_current?(db)
47
- end
42
+ synchronized_change(db) do
43
+ create_dashboard_indexes!(db) unless dashboard_indexes_current?(db)
48
44
  end
49
45
  end
50
46
 
47
+ def synchronized_change(db, &change)
48
+ with_migration_timeout(db) { immediate_transaction(db, &change) }
49
+ end
50
+
51
51
  def current?(db)
52
52
  jobs_table?(db) && version(db) == VERSION && core_indexes_current?(db)
53
53
  end
54
54
 
55
- def dashboard_indexes_current?(db)
56
- (DASHBOARD_INDEXES - index_names(db)).empty?
57
- end
55
+ def dashboard_indexes_current?(db) = indexes_present?(db, DASHBOARD_INDEXES)
58
56
 
59
57
  def version(db)
60
58
  db.get_first_value(SQL::USER_VERSION).to_i
@@ -101,8 +99,10 @@ module Async
101
99
  SQL::CREATE_DASHBOARD_INDEXES.each { |statement| db.execute(statement) }
102
100
  end
103
101
 
104
- def core_indexes_current?(db)
105
- (CORE_INDEXES - index_names(db)).empty?
102
+ def core_indexes_current?(db) = indexes_present?(db, CORE_INDEXES)
103
+
104
+ def indexes_present?(db, names)
105
+ (names - index_names(db)).empty?
106
106
  end
107
107
 
108
108
  def jobs_table?(db)
@@ -1,104 +1,167 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'socket'
4
- require 'async/notification'
5
4
  require 'fileutils'
5
+ require_relative '../runtime'
6
6
 
7
7
  module Async
8
8
  module Background
9
9
  module Queue
10
10
  class SocketWaker
11
+ CLOSE_GRACE = 2
12
+
11
13
  attr_reader :path
12
14
 
13
15
  def initialize(path)
14
16
  @path = path
15
17
  @server = nil
16
- @notification = ::Async::Notification.new
18
+ @notification = Runtime::Notification.new
17
19
  @running = false
18
20
  @accept_task = nil
21
+ @clients = Runtime::TaskGroup.new
22
+ @sockets = {}
19
23
  end
20
24
 
21
25
  def open!
22
- cleanup_stale_socket
23
26
  ensure_directory
27
+ cleanup_stale_socket
24
28
  @server = UNIXServer.new(@path)
25
- File.chmod(0600, @path)
26
29
  @running = true
30
+ self
27
31
  rescue Errno::EADDRINUSE
28
32
  raise "Socket #{@path} is already in use by another process"
29
33
  end
30
34
 
31
- def start_accept_loop(parent_task)
32
- @accept_task = parent_task.async do |task|
33
- while @running
34
- begin
35
- client = @server.accept_nonblock
36
- handle_client(task, client)
37
- rescue IO::WaitReadable
38
- @server.wait_readable
39
- rescue Errno::EBADF, IOError
40
- break
41
- rescue => e
42
- Console.logger.error(self) { "SocketWaker accept error: #{e.class} #{e.message}" }
43
- end
44
- end
45
- rescue => e
46
- Console.logger.error(self) { "SocketWaker loop crashed: #{e.class} #{e.message}\n#{e.backtrace.join("\n")}" }
47
- ensure
48
- @accept_task = nil
49
- end
35
+ def start_accept_loop(_parent_task = nil)
36
+ @accept_task = Runtime.spawn(name: 'socket-waker-accept') { accept_loop }
50
37
  end
51
38
 
52
39
  def wait(timeout: nil)
53
- if timeout
54
- ::Async::Task.current.with_timeout(timeout) { @notification.wait }
55
- else
56
- @notification.wait
57
- end
58
- rescue ::Async::TimeoutError
59
- # Timeout is normal - listener will fall back to polling
40
+ @notification.wait(timeout)
60
41
  end
61
42
 
62
43
  def signal
63
- @notification.signal
44
+ @notification.signal_all
45
+ true
64
46
  end
65
47
 
66
48
  def close
49
+ return unless @running || @server
50
+
67
51
  @running = false
68
- if @accept_task && !@accept_task.finished?
69
- @accept_task.stop rescue nil
70
- end
52
+ stop_accept_loop
53
+ @notification.signal_all
54
+ hang_up_clients
55
+ stop_clients
71
56
 
72
57
  @server&.close rescue nil
73
58
  @server = nil
59
+ @accept_task = nil
74
60
  File.unlink(@path) rescue nil
75
61
  end
76
62
 
77
63
  private
78
64
 
79
- def handle_client(parent_task, client)
80
- parent_task.async do
65
+ def accept_loop
66
+ while @running
81
67
  begin
82
- loop do
83
- begin
84
- client.read_nonblock(256)
85
- @notification.signal
86
- rescue IO::WaitReadable
87
- client.wait_readable
88
- retry
89
- rescue EOFError, Errno::ECONNRESET
90
- break
91
- end
92
- end
93
- rescue => e
94
- Console.logger.warn(self) { "SocketWaker client handler error: #{e.class} #{e.message}" }
95
- ensure
96
- client.close rescue nil
97
- @notification.signal
68
+ client = @server.accept_nonblock
69
+ rescue IO::WaitReadable
70
+ @server.wait_readable
71
+ next
72
+ rescue Errno::EBADF, IOError
73
+ break
74
+ rescue StandardError => e
75
+ Console.logger.error(self) { "SocketWaker accept error: #{e.class} #{e.message}" }
76
+ next
98
77
  end
78
+
79
+ break unless @running
80
+
81
+ handle_client(client)
82
+ end
83
+ rescue StandardError => e
84
+ Console.logger.error(self) { "SocketWaker loop crashed: #{e.class} #{e.message}\n#{e.backtrace.join("\n")}" }
85
+ end
86
+
87
+ def handle_client(client)
88
+ @sockets[client] = true
89
+
90
+ @clients.spawn(name: 'socket-waker-client') do
91
+ loop do
92
+ client.read_nonblock(256)
93
+ @notification.signal_all
94
+ rescue IO::WaitReadable
95
+ client.wait_readable
96
+ retry
97
+ rescue EOFError, Errno::ECONNRESET, Errno::EBADF, IOError
98
+ break
99
+ end
100
+ rescue StandardError => e
101
+ Console.logger.warn(self) { "SocketWaker client handler error: #{e.class} #{e.message}" }
102
+ ensure
103
+ @sockets.delete(client)
104
+ client.close rescue nil
105
+ @notification.signal_all
106
+ end
107
+ end
108
+
109
+ def stop_accept_loop
110
+ task = @accept_task or return
111
+
112
+ wake_accept_loop
113
+ return if await(task, CLOSE_GRACE)
114
+
115
+ task.stop
116
+ await(task, CLOSE_GRACE)
117
+ end
118
+
119
+ def wake_accept_loop
120
+ return unless @server
121
+
122
+ UNIXSocket.open(@path) { |s| s.write_nonblock("\x00") rescue nil }
123
+ rescue StandardError
124
+ nil
125
+ end
126
+
127
+ def hang_up_clients
128
+ @sockets.keys.each do |socket|
129
+ socket.close
130
+ rescue StandardError
131
+ nil
99
132
  end
100
133
  end
101
134
 
135
+ def stop_clients
136
+ return if @clients.empty?
137
+
138
+ return if await_group(@clients, CLOSE_GRACE)
139
+
140
+ @clients.stop_all(CLOSE_GRACE)
141
+ end
142
+
143
+ def await(task, grace)
144
+ return true unless Runtime.scheduler
145
+
146
+ task.wait(grace)
147
+ true
148
+ rescue Runtime::TimeoutError
149
+ false
150
+ rescue Exception # rubocop:disable Lint/RescueException
151
+ true
152
+ end
153
+
154
+ def await_group(group, grace)
155
+ return true unless Runtime.scheduler
156
+
157
+ group.wait(grace)
158
+ true
159
+ rescue Runtime::TimeoutError
160
+ false
161
+ rescue StandardError
162
+ true
163
+ end
164
+
102
165
  def cleanup_stale_socket
103
166
  return unless File.exist?(@path)
104
167
 
@@ -360,22 +360,26 @@ module Async
360
360
  Job::Options.new(**options)
361
361
  end
362
362
 
363
+ STATEMENTS = {
364
+ :@enqueue_stmt => SQL::INSERT_JOB,
365
+ :@fetch_stmt => SQL::FETCH_NEXT_JOB,
366
+ :@mark_started_stmt => SQL::MARK_STARTED,
367
+ :@complete_stmt => SQL::COMPLETE_JOB,
368
+ :@fail_stmt => SQL::FAIL_JOB,
369
+ :@retry_state_stmt => SQL::RETRY_STATE,
370
+ :@lease_check_stmt => SQL::LEASE_ALIVE,
371
+ :@retry_stmt => SQL::RETRY_JOB,
372
+ :@requeue_stmt => SQL::RECOVER_WORKER,
373
+ :@cleanup_done_stmt => SQL::CLEANUP_DONE,
374
+ :@cleanup_failed_stmt => SQL::CLEANUP_FAILED,
375
+ :@next_pending_stmt => SQL::NEXT_PENDING_RUN_AT,
376
+ :@begin_stmt => SQL::BEGIN_IMMEDIATE,
377
+ :@commit_stmt => SQL::COMMIT,
378
+ :@rollback_stmt => SQL::ROLLBACK
379
+ }.freeze
380
+
363
381
  def prepare_statements
364
- @enqueue_stmt = @db.prepare(SQL::INSERT_JOB)
365
- @fetch_stmt = @db.prepare(SQL::FETCH_NEXT_JOB)
366
- @mark_started_stmt = @db.prepare(SQL::MARK_STARTED)
367
- @complete_stmt = @db.prepare(SQL::COMPLETE_JOB)
368
- @fail_stmt = @db.prepare(SQL::FAIL_JOB)
369
- @retry_state_stmt = @db.prepare(SQL::RETRY_STATE)
370
- @lease_check_stmt = @db.prepare(SQL::LEASE_ALIVE)
371
- @retry_stmt = @db.prepare(SQL::RETRY_JOB)
372
- @requeue_stmt = @db.prepare(SQL::RECOVER_WORKER)
373
- @cleanup_done_stmt = @db.prepare(SQL::CLEANUP_DONE)
374
- @cleanup_failed_stmt = @db.prepare(SQL::CLEANUP_FAILED)
375
- @next_pending_stmt = @db.prepare(SQL::NEXT_PENDING_RUN_AT)
376
- @begin_stmt = @db.prepare(SQL::BEGIN_IMMEDIATE)
377
- @commit_stmt = @db.prepare(SQL::COMMIT)
378
- @rollback_stmt = @db.prepare(SQL::ROLLBACK)
382
+ STATEMENTS.each { |name, sql| instance_variable_set(name, @db.prepare(sql)) }
379
383
  end
380
384
 
381
385
  def finalize_statements
@@ -384,32 +388,11 @@ module Async
384
388
  end
385
389
 
386
390
  def statements
387
- [
388
- @enqueue_stmt,
389
- @fetch_stmt,
390
- @mark_started_stmt,
391
- @complete_stmt,
392
- @fail_stmt,
393
- @retry_state_stmt,
394
- @lease_check_stmt,
395
- @retry_stmt,
396
- @requeue_stmt,
397
- @cleanup_done_stmt,
398
- @cleanup_failed_stmt,
399
- @next_pending_stmt,
400
- @begin_stmt,
401
- @commit_stmt,
402
- @rollback_stmt
403
- ]
391
+ STATEMENTS.keys.map { |name| instance_variable_get(name) }
404
392
  end
405
393
 
406
394
  def clear_statements
407
- @enqueue_stmt = @fetch_stmt = @mark_started_stmt = nil
408
- @complete_stmt = @fail_stmt = @retry_state_stmt = @lease_check_stmt = nil
409
- @retry_stmt = @requeue_stmt = nil
410
- @cleanup_done_stmt = @cleanup_failed_stmt = nil
411
- @next_pending_stmt = nil
412
- @begin_stmt = @commit_stmt = @rollback_stmt = nil
395
+ STATEMENTS.each_key { |name| instance_variable_set(name, nil) }
413
396
  end
414
397
 
415
398
  def maybe_cleanup
@@ -9,6 +9,7 @@ module Async
9
9
  private
10
10
 
11
11
  def setup_queue(queue_socket_dir, queue_db_path, queue_mmap)
12
+ @queue_saturated = false
12
13
  @listen_queue = !!queue_socket_dir && !isolated_worker?
13
14
  return unless @listen_queue
14
15
 
@@ -26,20 +27,36 @@ module Async
26
27
  recover_queue_jobs
27
28
  end
28
29
 
29
- def start_queue_listener(task)
30
- @queue_waker.start_accept_loop(task)
30
+ def start_queue_listener
31
+ @queue_waker.start_accept_loop
31
32
 
32
- task.async do
33
+ @services.spawn(name: 'queue-listener') do
33
34
  logger.info { "Async::Background queue: listening on worker #{worker_index}" }
34
35
 
36
+ failures = 0
37
+
35
38
  while running?
36
- @queue_waker.wait(timeout: next_wait_timeout)
37
- dispatch_available_queue_jobs
39
+ begin
40
+ @queue_waker.wait(timeout: next_wait_timeout)
41
+ break unless running?
42
+
43
+ dispatch_available_queue_jobs
44
+ failures = 0
45
+ rescue StandardError => error
46
+ failures += 1
47
+ backoff = [QUEUE_ERROR_BACKOFF * failures, QUEUE_POLL_INTERVAL].min
48
+ logger.error('Async::Background') do
49
+ "queue listener: #{error.class} #{error.message}; retrying in #{backoff}s"
50
+ end
51
+ shutdown.wait(backoff)
52
+ end
38
53
  end
39
54
  end
40
55
  end
41
56
 
42
57
  def next_wait_timeout
58
+ return QUEUE_POLL_INTERVAL if @queue_saturated
59
+
43
60
  next_due = @queue_store.next_pending_run_at
44
61
  return QUEUE_POLL_INTERVAL unless next_due
45
62
 
@@ -68,7 +85,7 @@ module Async
68
85
  complete_queue_job!(job, class_name, claim_token, started_at)
69
86
  rescue ConfigError => error
70
87
  record_invalid_queue_job!(job, class_name, claim_token, error)
71
- rescue ::Async::TimeoutError => error
88
+ rescue Runtime::TimeoutError => error
72
89
  handle_queue_failure(
73
90
  job,
74
91
  options,
@@ -129,11 +146,18 @@ module Async
129
146
  end
130
147
 
131
148
  def dispatch_available_queue_jobs
149
+ @queue_saturated = false
150
+
132
151
  while running?
152
+ if @jobs.size >= @semaphore.limit
153
+ @queue_saturated = true
154
+ return
155
+ end
156
+
133
157
  job = @queue_store.fetch(worker_index)
134
- break unless job
158
+ return if job.nil?
135
159
 
136
- semaphore.async { |job_task| run_queue_job(job_task, job) }
160
+ spawn_job { |job_task| run_queue_job(job_task, job) }
137
161
  end
138
162
  end
139
163