async-background 0.4.4 → 0.5.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: db310afc2038735e979a5041ff92998de5ff27655457569e588ab952c8e2cde0
4
- data.tar.gz: b079e4f9769ffb917afa2b6f7eac860e92a7dd93f6b5b73a5b32b76fbbb9e683
3
+ metadata.gz: 5959a648b21b8312d1b2d8d566ade149e736b6386eff0cf1fb9acd09801ba748
4
+ data.tar.gz: 839fd36e05ee3ef845ba1f2afaef50a5c996edf9812240a61198cb2f203d0e9a
5
5
  SHA512:
6
- metadata.gz: 99e68ef3c3ee9899f8072d2eb7cc2e6a0da4745b637bc849835927a5390cabcdd81f3beb30cc3587618da230000d9f6b193101bed31f7a910ef62855e79cd24e
7
- data.tar.gz: a8d683c78a0720c04e9c2b9b70a3f5f90c9caf34b495867e8c78ef14bc1270b3001ca8a04ca01dbd70e784d9a4f1b62d7a459845f7436997fccbbf88cb75ce15
6
+ metadata.gz: 408c3267a1acd854448a4dec42d0a3ab30a3ed8e40d40e5c841aca6c29cd2dc3378683ef7c5b305fa419b4fefea80292968efa67e1ff6eb8a4de5d7a5c403e00
7
+ data.tar.gz: 7d7af316122b5208e7adaaf681be8075821936fda225e78def6f8a9b76cf5350e50cd0bf6015888e585c149101c741de2d5f52ba82ffa4aa35734a3d86183511
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Async
4
+ module Background
5
+ # Shared clock helpers used across Runner, Queue::Store, and Queue::Client.
6
+ #
7
+ # monotonic_now — CLOCK_MONOTONIC, for in-process intervals and durations
8
+ # (immune to NTP drift / wall-clock jumps)
9
+ #
10
+ # realtime_now — CLOCK_REALTIME, for persisted timestamps (SQLite run_at,
11
+ # created_at, locked_at) and human-readable metrics
12
+ #
13
+ module Clock
14
+ private
15
+
16
+ def monotonic_now
17
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
18
+ end
19
+
20
+ def realtime_now
21
+ Process.clock_gettime(Process::CLOCK_REALTIME)
22
+ end
23
+ end
24
+ end
25
+ end
@@ -3,8 +3,6 @@
3
3
  module Async
4
4
  module Background
5
5
  class Entry
6
- MIN_SLEEP_TIME = 0.1
7
-
8
6
  attr_reader :name, :job_class, :interval, :cron, :timeout
9
7
  attr_accessor :next_run_at, :running
10
8
 
@@ -25,7 +23,7 @@ module Async
25
23
  else
26
24
  now_wall = Time.now
27
25
  wait = cron.next_time(now_wall).to_f - now_wall.to_f
28
- @next_run_at = monotonic_now + [wait, MIN_SLEEP_TIME].max
26
+ @next_run_at = monotonic_now + [wait, Async::Background::MIN_SLEEP_TIME].max
29
27
  end
30
28
  end
31
29
  end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Async
4
+ module Background
5
+ module Job
6
+ def self.included(base)
7
+ base.extend(ClassMethods)
8
+ end
9
+
10
+ module ClassMethods
11
+ def perform_now(*args)
12
+ new.perform(*args)
13
+ end
14
+
15
+ def perform_async(*args)
16
+ Async::Background::Queue.enqueue(self, *args)
17
+ end
18
+
19
+ def perform_in(delay, *args)
20
+ Async::Background::Queue.enqueue_in(delay, self, *args)
21
+ end
22
+
23
+ def perform_at(time, *args)
24
+ Async::Background::Queue.enqueue_at(time, self, *args)
25
+ end
26
+ end
27
+
28
+ def perform(*args)
29
+ raise NotImplementedError, "#{self.class} must implement #perform"
30
+ end
31
+ end
32
+ end
33
+ end
@@ -1,38 +1,64 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative '../clock'
4
+
3
5
  module Async
4
6
  module Background
5
7
  module Queue
6
- # Usage:
7
- # Async::Background::Queue.enqueue(SendEmailJob, user_id, "welcome")
8
- #
9
8
  class Client
9
+ include Clock
10
+
10
11
  def initialize(store:, notifier: nil)
11
12
  @store = store
12
13
  @notifier = notifier
13
14
  end
14
15
 
15
- def push(class_name, args = [])
16
- id = @store.enqueue(class_name, args)
16
+ def push(class_name, args = [], run_at = nil)
17
+ id = @store.enqueue(class_name, args, run_at)
17
18
  @notifier&.notify
18
19
  id
19
20
  end
21
+
22
+ def push_in(delay, class_name, args = [])
23
+ run_at = realtime_now + delay.to_f
24
+ push(class_name, args, run_at)
25
+ end
26
+
27
+ def push_at(time, class_name, args = [])
28
+ run_at = time.respond_to?(:to_f) ? time.to_f : time
29
+ push(class_name, args, run_at)
30
+ end
20
31
  end
21
32
 
22
33
  class << self
23
34
  attr_accessor :default_client
24
35
 
25
36
  def enqueue(job_class, *args)
37
+ ensure_configured!
38
+ default_client.push(resolve_class_name(job_class), args)
39
+ end
40
+
41
+ def enqueue_in(delay, job_class, *args)
42
+ ensure_configured!
43
+ default_client.push_in(delay, resolve_class_name(job_class), args)
44
+ end
45
+
46
+ def enqueue_at(time, job_class, *args)
47
+ ensure_configured!
48
+ default_client.push_at(time, resolve_class_name(job_class), args)
49
+ end
50
+
51
+ private
52
+
53
+ def ensure_configured!
26
54
  raise "Async::Background::Queue not configured" unless default_client
55
+ end
27
56
 
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
57
+ def resolve_class_name(job_class)
58
+ return job_class if job_class.is_a?(String)
59
+ return job_class.name if job_class.respond_to?(:perform_now)
34
60
 
35
- default_client.push(class_name, args)
61
+ raise ArgumentError, "#{job_class} must include Async::Background::Job"
36
62
  end
37
63
  end
38
64
  end
@@ -4,6 +4,8 @@ module Async
4
4
  module Background
5
5
  module Queue
6
6
  class Notifier
7
+ IO_ERRORS = [IO::WaitReadable, EOFError, IOError].freeze
8
+
7
9
  attr_reader :reader, :writer
8
10
 
9
11
  def initialize
@@ -51,7 +53,7 @@ module Async
51
53
  def drain
52
54
  loop do
53
55
  @reader.read_nonblock(256)
54
- rescue IO::WaitReadable, EOFError
56
+ rescue *IO_ERRORS
55
57
  break
56
58
  end
57
59
  nil
@@ -1,41 +1,50 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'json'
4
+ require_relative '../clock'
4
5
 
5
6
  module Async
6
7
  module Background
7
8
  module Queue
8
9
  class Store
10
+ include Clock
11
+
9
12
  SCHEMA = <<~SQL
13
+ PRAGMA auto_vacuum = INCREMENTAL;
10
14
  CREATE TABLE IF NOT EXISTS jobs (
11
15
  id INTEGER PRIMARY KEY,
12
16
  class_name TEXT NOT NULL,
13
17
  args TEXT NOT NULL DEFAULT '[]',
14
18
  status TEXT NOT NULL DEFAULT 'pending',
15
19
  created_at REAL NOT NULL,
20
+ run_at REAL NOT NULL,
16
21
  locked_by INTEGER,
17
22
  locked_at REAL
18
23
  );
19
- CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status);
24
+ CREATE INDEX IF NOT EXISTS idx_jobs_status_run_at_id ON jobs(status, run_at, id);
20
25
  SQL
21
26
 
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
27
+ MMAP_SIZE = 268_435_456
28
+ PRAGMAS = ->(mmap_size) {
29
+ <<~SQL
30
+ PRAGMA journal_mode = WAL;
31
+ PRAGMA synchronous = NORMAL;
32
+ PRAGMA mmap_size = #{mmap_size};
33
+ PRAGMA cache_size = -16000;
34
+ PRAGMA temp_store = MEMORY;
35
+ PRAGMA busy_timeout = 5000;
36
+ PRAGMA journal_size_limit = 67108864;
37
+ SQL
38
+ }.freeze
31
39
 
32
40
  CLEANUP_INTERVAL = 300
33
41
  CLEANUP_AGE = 3600
34
42
 
35
43
  attr_reader :path
36
44
 
37
- def initialize(path: self.class.default_path)
45
+ def initialize(path: self.class.default_path, mmap: true)
38
46
  @path = path
47
+ @mmap = mmap
39
48
  @db = nil
40
49
  @schema_checked = false
41
50
  @last_cleanup_at = nil
@@ -44,28 +53,36 @@ module Async
44
53
  def ensure_database!
45
54
  require_sqlite3
46
55
  db = SQLite3::Database.new(@path)
47
- db.execute_batch(PRAGMAS)
56
+ db.execute_batch(PRAGMAS.call(@mmap ? MMAP_SIZE : 0))
48
57
  db.execute_batch(SCHEMA)
49
58
  db.execute("PRAGMA wal_checkpoint(TRUNCATE)")
50
59
  db.close
51
60
  @schema_checked = true
52
61
  end
53
62
 
54
- def enqueue(class_name, args = [])
63
+ def enqueue(class_name, args = [], run_at = nil)
55
64
  ensure_connection
56
- @enqueue_stmt.execute(class_name, JSON.generate(args), realtime_now)
65
+ run_at ||= realtime_now
66
+ @enqueue_stmt.execute(class_name, JSON.generate(args), realtime_now, run_at)
57
67
  @db.last_insert_row_id
58
68
  end
59
69
 
60
70
  def fetch(worker_id)
61
71
  ensure_connection
62
- row = @fetch_stmt.execute(worker_id, realtime_now).first
72
+ now = realtime_now
73
+ @db.execute("BEGIN IMMEDIATE")
74
+ results = @fetch_stmt.execute(worker_id, now, now)
75
+ row = results.first
76
+ @fetch_stmt.reset!
77
+ @db.execute("COMMIT")
63
78
  return unless row
64
79
 
65
80
  maybe_cleanup
66
81
  { id: row[0], class_name: row[1], args: JSON.parse(row[2]) }
82
+ rescue
83
+ @db.execute("ROLLBACK") rescue nil
84
+ raise
67
85
  end
68
-
69
86
  def complete(job_id)
70
87
  ensure_connection
71
88
  @complete_stmt.execute(job_id)
@@ -86,7 +103,7 @@ module Async
86
103
  return unless @db && !@db.closed?
87
104
 
88
105
  finalize_statements
89
- @db.execute("PRAGMA optimize")
106
+ @db.execute("PRAGMA optimize") rescue nil
90
107
  @db.close
91
108
  @db = nil
92
109
  end
@@ -111,7 +128,7 @@ module Async
111
128
  require_sqlite3
112
129
  finalize_statements
113
130
  @db = SQLite3::Database.new(@path)
114
- @db.execute_batch(PRAGMAS)
131
+ @db.execute_batch(PRAGMAS.call(@mmap ? MMAP_SIZE : 0))
115
132
 
116
133
  unless @schema_checked
117
134
  @db.execute_batch(SCHEMA)
@@ -124,7 +141,7 @@ module Async
124
141
 
125
142
  def prepare_statements
126
143
  @enqueue_stmt = @db.prepare(
127
- "INSERT INTO jobs (class_name, args, created_at) VALUES (?, ?, ?)"
144
+ "INSERT INTO jobs (class_name, args, created_at, run_at) VALUES (?, ?, ?, ?)"
128
145
  )
129
146
 
130
147
  @fetch_stmt = @db.prepare(<<~SQL)
@@ -132,8 +149,8 @@ module Async
132
149
  SET status = 'running', locked_by = ?, locked_at = ?
133
150
  WHERE id = (
134
151
  SELECT id FROM jobs
135
- WHERE status = 'pending'
136
- ORDER BY id
152
+ WHERE status = 'pending' AND run_at <= ?
153
+ ORDER BY run_at, id
137
154
  LIMIT 1
138
155
  )
139
156
  RETURNING id, class_name, args
@@ -151,10 +168,10 @@ module Async
151
168
  end
152
169
 
153
170
  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)
171
+ %i[enqueue_stmt fetch_stmt complete_stmt fail_stmt requeue_stmt cleanup_stmt].each do |name|
172
+ stmt = instance_variable_get(:"@#{name}")
156
173
  stmt&.close rescue nil
157
- instance_variable_set(name, nil)
174
+ instance_variable_set(:"@#{name}", nil)
158
175
  end
159
176
  end
160
177
 
@@ -170,13 +187,6 @@ module Async
170
187
  end
171
188
  end
172
189
 
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
190
  end
181
191
  end
182
192
  end
@@ -7,17 +7,19 @@ module Async
7
7
  module Background
8
8
  class ConfigError < StandardError; end
9
9
 
10
- DEFAULT_TIMEOUT = 30
11
- MIN_SLEEP_TIME = 0.1
12
- MAX_JITTER = 5
10
+ DEFAULT_TIMEOUT = 30
11
+ MIN_SLEEP_TIME = 0.1
12
+ MAX_JITTER = 5
13
13
  QUEUE_POLL_INTERVAL = 5
14
14
 
15
15
  class Runner
16
- attr_reader :logger, :semaphore, :heap, :worker_index, :total_workers, :shutdown, :metrics
16
+ include Clock
17
+
18
+ attr_reader :logger, :semaphore, :heap, :worker_index, :total_workers, :shutdown, :metrics, :queue_store
17
19
 
18
20
  def initialize(
19
21
  config_path:, job_count: 2, worker_index:, total_workers:,
20
- queue_notifier: nil, queue_db_path: nil
22
+ queue_notifier: nil, queue_db_path: nil, queue_mmap: true
21
23
  )
22
24
  @logger = Console.logger
23
25
  @worker_index = worker_index
@@ -31,7 +33,7 @@ module Async
31
33
  @semaphore = ::Async::Semaphore.new(job_count)
32
34
  @heap = build_heap(config_path)
33
35
 
34
- setup_queue(queue_notifier, queue_db_path)
36
+ setup_queue(queue_notifier, queue_db_path, queue_mmap)
35
37
  end
36
38
 
37
39
  def run
@@ -53,7 +55,7 @@ module Async
53
55
  @running = false
54
56
  logger.info { "Async::Background: stopping gracefully" }
55
57
  shutdown.signal
56
- @queue_notifier&.notify # unblock queue listener from @reader.wait_readable
58
+ @queue_notifier&.notify
57
59
  end
58
60
 
59
61
  def running?
@@ -62,7 +64,7 @@ module Async
62
64
 
63
65
  private
64
66
 
65
- def setup_queue(queue_notifier, queue_db_path)
67
+ def setup_queue(queue_notifier, queue_db_path, queue_mmap)
66
68
  @listen_queue = false
67
69
  return unless queue_notifier
68
70
 
@@ -76,7 +78,10 @@ module Async
76
78
 
77
79
  @listen_queue = true
78
80
  @queue_notifier = queue_notifier
79
- @queue_store = Queue::Store.new(path: queue_db_path || Queue::Store.default_path)
81
+ @queue_store = Queue::Store.new(
82
+ path: queue_db_path || Queue::Store.default_path,
83
+ mmap: queue_mmap
84
+ )
80
85
 
81
86
  recovered = @queue_store.recover(worker_index)
82
87
  logger.info { "Async::Background queue: recovered #{recovered} stale jobs" } if recovered > 0
@@ -93,13 +98,13 @@ module Async
93
98
  job = @queue_store.fetch(worker_index)
94
99
  break unless job
95
100
 
96
- semaphore.async { run_queue_job(task, job) }
101
+ semaphore.async { |job_task| run_queue_job(job_task, job) }
97
102
  end
98
103
  end
99
104
  end
100
105
  end
101
106
 
102
- def run_queue_job(task, job)
107
+ def run_queue_job(job_task, job)
103
108
  class_name = job[:class_name]
104
109
  args = job[:args]
105
110
  klass = resolve_job_class(class_name)
@@ -107,7 +112,7 @@ module Async
107
112
  metrics.job_started(nil)
108
113
  t = monotonic_now
109
114
 
110
- task.with_timeout(DEFAULT_TIMEOUT) { klass.perform_now(*args) }
115
+ job_task.with_timeout(DEFAULT_TIMEOUT) { klass.perform_now(*args) }
111
116
 
112
117
  duration = monotonic_now - t
113
118
  metrics.job_finished(nil, duration)
@@ -137,7 +142,7 @@ module Async
137
142
  mod.const_get(name, false)
138
143
  end
139
144
 
140
- raise ConfigError, "#{class_name} must implement .perform_now" unless klass.respond_to?(:perform_now)
145
+ raise ConfigError, "#{class_name} must include Async::Background::Job" unless klass.respond_to?(:perform_now)
141
146
 
142
147
  klass
143
148
  end
@@ -161,8 +166,8 @@ module Async
161
166
  metrics.job_skipped(entry)
162
167
  else
163
168
  entry.running = true
164
- semaphore.async do
165
- run_job(task, entry)
169
+ semaphore.async do |job_task|
170
+ run_job(job_task, entry)
166
171
  ensure
167
172
  entry.running = false
168
173
  end
@@ -249,7 +254,7 @@ module Async
249
254
  raise ConfigError, "[#{name}] unknown class: #{class_name}"
250
255
  end
251
256
 
252
- raise ConfigError, "[#{name}] #{class_name} must implement .perform_now" unless job_class.respond_to?(:perform_now)
257
+ raise ConfigError, "[#{name}] #{class_name} must include Async::Background::Job" unless job_class.respond_to?(:perform_now)
253
258
 
254
259
  interval = config['every']&.then { |v|
255
260
  int = v.to_i
@@ -271,14 +276,10 @@ module Async
271
276
  }
272
277
  end
273
278
 
274
- def monotonic_now
275
- Process.clock_gettime(Process::CLOCK_MONOTONIC)
276
- end
277
-
278
- def run_job(task, entry)
279
+ def run_job(job_task, entry)
279
280
  metrics.job_started(entry)
280
281
  t = monotonic_now
281
- task.with_timeout(entry.timeout) { entry.job_class.perform_now }
282
+ job_task.with_timeout(entry.timeout) { entry.job_class.perform_now }
282
283
 
283
284
  duration = monotonic_now - t
284
285
  metrics.job_finished(entry, duration)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Async
4
4
  module Background
5
- VERSION = '0.4.4'
5
+ VERSION = '0.5.0'
6
6
  end
7
7
  end
@@ -7,7 +7,10 @@ require 'fugit'
7
7
  require 'tmpdir'
8
8
 
9
9
  require_relative 'background/version'
10
+ require_relative 'background/clock'
10
11
  require_relative 'background/min_heap'
11
12
  require_relative 'background/entry'
12
13
  require_relative 'background/metrics'
13
14
  require_relative 'background/runner'
15
+ require_relative 'background/queue/client'
16
+ require_relative 'background/job'
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.4
4
+ version: 0.5.0
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-28 00:00:00.000000000 Z
11
+ date: 2026-03-31 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: async
@@ -91,7 +91,9 @@ extensions: []
91
91
  extra_rdoc_files: []
92
92
  files:
93
93
  - lib/async/background.rb
94
+ - lib/async/background/clock.rb
94
95
  - lib/async/background/entry.rb
96
+ - lib/async/background/job.rb
95
97
  - lib/async/background/metrics.rb
96
98
  - lib/async/background/min_heap.rb
97
99
  - lib/async/background/queue/client.rb