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 +4 -4
- data/CHANGELOG.md +67 -0
- data/README.md +36 -15
- data/async-background.gemspec +13 -7
- data/lib/async/background/queue/schema.rb +14 -14
- data/lib/async/background/queue/socket_waker.rb +115 -52
- data/lib/async/background/queue/store.rb +21 -38
- data/lib/async/background/runner/queue_execution.rb +32 -8
- data/lib/async/background/runner/schedule.rb +16 -13
- data/lib/async/background/runner.rb +128 -38
- data/lib/async/background/runtime/notification.rb +41 -0
- data/lib/async/background/runtime/semaphore.rb +147 -0
- data/lib/async/background/runtime/task.rb +145 -0
- data/lib/async/background/runtime/task_group.rb +81 -0
- data/lib/async/background/runtime.rb +243 -0
- data/lib/async/background/scheduler.rb +145 -0
- data/lib/async/background/version.rb +1 -1
- data/lib/async/background/web/app.rb +22 -26
- data/lib/async/background/web/configuration.rb +40 -93
- data/lib/async/background/web/cursor.rb +10 -21
- data/lib/async/background/web/event_hub.rb +12 -7
- data/lib/async/background/web/metrics_reader.rb +29 -35
- data/lib/async/background/web/response.rb +39 -51
- data/lib/async/background/web/serializer.rb +23 -63
- data/lib/async/background/web/snapshot.rb +32 -78
- data/lib/async/background/web/sql.rb +58 -64
- data/lib/async/background.rb +1 -2
- metadata +26 -22
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1da83cfa99bb2a77a32b8bd72dd5bee692f94f87b453706f4443cc015e065ac4
|
|
4
|
+
data.tar.gz: b0c60214b0e13a7a0c346f5e681d78b1701a7de95e5e816f84193d6593f2c282
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
4
|
-
[
|
|
5
|
-
[
|
|
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
|
|
18
|
+
## Why fibers?
|
|
19
19
|
|
|
20
|
-
The whole gem is built around the assumption that
|
|
21
|
-
|
|
22
|
-
|
|
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
|

|
|
@@ -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
|
-
| `
|
|
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 "
|
|
52
|
-
gem "
|
|
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
|
-
|
|
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
|
|
124
|
-
|
|
125
|
-
|
|
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
|
data/async-background.gemspec
CHANGED
|
@@ -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
|
|
12
|
-
spec.description = 'A production-grade lightweight scheduler built on
|
|
13
|
-
'
|
|
14
|
-
'
|
|
15
|
-
'
|
|
16
|
-
'
|
|
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
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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 = ::
|
|
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(
|
|
32
|
-
@accept_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
|
-
|
|
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.
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
|
80
|
-
|
|
65
|
+
def accept_loop
|
|
66
|
+
while @running
|
|
81
67
|
begin
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
30
|
-
@queue_waker.start_accept_loop
|
|
30
|
+
def start_queue_listener
|
|
31
|
+
@queue_waker.start_accept_loop
|
|
31
32
|
|
|
32
|
-
|
|
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
|
-
|
|
37
|
-
|
|
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 ::
|
|
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
|
-
|
|
158
|
+
return if job.nil?
|
|
135
159
|
|
|
136
|
-
|
|
160
|
+
spawn_job { |job_task| run_queue_job(job_task, job) }
|
|
137
161
|
end
|
|
138
162
|
end
|
|
139
163
|
|