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 +4 -4
- data/lib/async/background/metrics.rb +3 -2
- data/lib/async/background/queue/client.rb +40 -0
- data/lib/async/background/queue/notifier.rb +62 -0
- data/lib/async/background/queue/store.rb +193 -0
- data/lib/async/background/runner.rb +130 -33
- data/lib/async/background/version.rb +1 -1
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c9a1ac97495dc7d6a39adf802a847b62e3601df0fe0ef4aa70dbcc248a2f77b8
|
|
4
|
+
data.tar.gz: 74bab717411caf3e8a7b469666e9df5f8f59d540ebd98446c5e44e167cacd3c9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
283
|
+
t = monotonic_now
|
|
187
284
|
task.with_timeout(entry.timeout) { entry.job_class.perform_now }
|
|
188
285
|
|
|
189
|
-
duration =
|
|
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"
|
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.
|
|
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-
|
|
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
|