async-background 0.4.5 → 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: c9a1ac97495dc7d6a39adf802a847b62e3601df0fe0ef4aa70dbcc248a2f77b8
4
- data.tar.gz: 74bab717411caf3e8a7b469666e9df5f8f59d540ebd98446c5e44e167cacd3c9
3
+ metadata.gz: 5959a648b21b8312d1b2d8d566ade149e736b6386eff0cf1fb9acd09801ba748
4
+ data.tar.gz: 839fd36e05ee3ef845ba1f2afaef50a5c996edf9812240a61198cb2f203d0e9a
5
5
  SHA512:
6
- metadata.gz: fe41a129f66a72c51016397b866ee9ff31b973d5e92ee6af1f61c05ce87f8f85f9824a68499caefcf950cdb33186c74749817e0ae8b89a483ede580d412e13fc
7
- data.tar.gz: 92208ce41c49095cdf652b798ed892e4db5ae12c504cfe94c45bf124054256a94906fba14f68365f938a6d7289c76ca79d7ae7f6fac53bb76d8097d463db684f
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,11 +1,14 @@
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
10
13
  PRAGMA auto_vacuum = INCREMENTAL;
11
14
  CREATE TABLE IF NOT EXISTS jobs (
@@ -14,10 +17,11 @@ module Async
14
17
  args TEXT NOT NULL DEFAULT '[]',
15
18
  status TEXT NOT NULL DEFAULT 'pending',
16
19
  created_at REAL NOT NULL,
20
+ run_at REAL NOT NULL,
17
21
  locked_by INTEGER,
18
22
  locked_at REAL
19
23
  );
20
- CREATE INDEX IF NOT EXISTS idx_jobs_status_id ON jobs(status, id);
24
+ CREATE INDEX IF NOT EXISTS idx_jobs_status_run_at_id ON jobs(status, run_at, id);
21
25
  SQL
22
26
 
23
27
  MMAP_SIZE = 268_435_456
@@ -56,16 +60,20 @@ module Async
56
60
  @schema_checked = true
57
61
  end
58
62
 
59
- def enqueue(class_name, args = [])
63
+ def enqueue(class_name, args = [], run_at = nil)
60
64
  ensure_connection
61
- @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)
62
67
  @db.last_insert_row_id
63
68
  end
64
69
 
65
70
  def fetch(worker_id)
66
71
  ensure_connection
72
+ now = realtime_now
67
73
  @db.execute("BEGIN IMMEDIATE")
68
- row = @fetch_stmt.execute(worker_id, realtime_now).first
74
+ results = @fetch_stmt.execute(worker_id, now, now)
75
+ row = results.first
76
+ @fetch_stmt.reset!
69
77
  @db.execute("COMMIT")
70
78
  return unless row
71
79
 
@@ -75,7 +83,6 @@ module Async
75
83
  @db.execute("ROLLBACK") rescue nil
76
84
  raise
77
85
  end
78
-
79
86
  def complete(job_id)
80
87
  ensure_connection
81
88
  @complete_stmt.execute(job_id)
@@ -134,7 +141,7 @@ module Async
134
141
 
135
142
  def prepare_statements
136
143
  @enqueue_stmt = @db.prepare(
137
- "INSERT INTO jobs (class_name, args, created_at) VALUES (?, ?, ?)"
144
+ "INSERT INTO jobs (class_name, args, created_at, run_at) VALUES (?, ?, ?, ?)"
138
145
  )
139
146
 
140
147
  @fetch_stmt = @db.prepare(<<~SQL)
@@ -142,8 +149,8 @@ module Async
142
149
  SET status = 'running', locked_by = ?, locked_at = ?
143
150
  WHERE id = (
144
151
  SELECT id FROM jobs
145
- WHERE status = 'pending'
146
- ORDER BY id
152
+ WHERE status = 'pending' AND run_at <= ?
153
+ ORDER BY run_at, id
147
154
  LIMIT 1
148
155
  )
149
156
  RETURNING id, class_name, args
@@ -180,13 +187,6 @@ module Async
180
187
  end
181
188
  end
182
189
 
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
190
  end
191
191
  end
192
192
  end
@@ -7,12 +7,14 @@ 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
+ include Clock
17
+
16
18
  attr_reader :logger, :semaphore, :heap, :worker_index, :total_workers, :shutdown, :metrics, :queue_store
17
19
 
18
20
  def initialize(
@@ -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?
@@ -96,13 +98,13 @@ module Async
96
98
  job = @queue_store.fetch(worker_index)
97
99
  break unless job
98
100
 
99
- semaphore.async { run_queue_job(task, job) }
101
+ semaphore.async { |job_task| run_queue_job(job_task, job) }
100
102
  end
101
103
  end
102
104
  end
103
105
  end
104
106
 
105
- def run_queue_job(task, job)
107
+ def run_queue_job(job_task, job)
106
108
  class_name = job[:class_name]
107
109
  args = job[:args]
108
110
  klass = resolve_job_class(class_name)
@@ -110,7 +112,7 @@ module Async
110
112
  metrics.job_started(nil)
111
113
  t = monotonic_now
112
114
 
113
- task.with_timeout(DEFAULT_TIMEOUT) { klass.perform_now(*args) }
115
+ job_task.with_timeout(DEFAULT_TIMEOUT) { klass.perform_now(*args) }
114
116
 
115
117
  duration = monotonic_now - t
116
118
  metrics.job_finished(nil, duration)
@@ -140,7 +142,7 @@ module Async
140
142
  mod.const_get(name, false)
141
143
  end
142
144
 
143
- 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)
144
146
 
145
147
  klass
146
148
  end
@@ -164,8 +166,8 @@ module Async
164
166
  metrics.job_skipped(entry)
165
167
  else
166
168
  entry.running = true
167
- semaphore.async do
168
- run_job(task, entry)
169
+ semaphore.async do |job_task|
170
+ run_job(job_task, entry)
169
171
  ensure
170
172
  entry.running = false
171
173
  end
@@ -252,7 +254,7 @@ module Async
252
254
  raise ConfigError, "[#{name}] unknown class: #{class_name}"
253
255
  end
254
256
 
255
- 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)
256
258
 
257
259
  interval = config['every']&.then { |v|
258
260
  int = v.to_i
@@ -274,14 +276,10 @@ module Async
274
276
  }
275
277
  end
276
278
 
277
- def monotonic_now
278
- Process.clock_gettime(Process::CLOCK_MONOTONIC)
279
- end
280
-
281
- def run_job(task, entry)
279
+ def run_job(job_task, entry)
282
280
  metrics.job_started(entry)
283
281
  t = monotonic_now
284
- task.with_timeout(entry.timeout) { entry.job_class.perform_now }
282
+ job_task.with_timeout(entry.timeout) { entry.job_class.perform_now }
285
283
 
286
284
  duration = monotonic_now - t
287
285
  metrics.job_finished(entry, duration)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Async
4
4
  module Background
5
- VERSION = '0.4.5'
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.5
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-29 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