async-background 1.1.0 → 1.1.1

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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +14 -0
  3. data/lib/async/background/job.rb +75 -28
  4. data/lib/async/background/metrics.rb +41 -33
  5. data/lib/async/background/min_heap.rb +16 -12
  6. data/lib/async/background/queue/client.rb +11 -8
  7. data/lib/async/background/queue/options.rb +3 -3
  8. data/lib/async/background/queue/schema.rb +5 -1
  9. data/lib/async/background/queue/socket_notifier.rb +14 -4
  10. data/lib/async/background/queue/socket_waker.rb +27 -7
  11. data/lib/async/background/queue/sql.rb +31 -31
  12. data/lib/async/background/queue/store.rb +23 -7
  13. data/lib/async/background/runner/lifecycle.rb +131 -0
  14. data/lib/async/background/runner/queue_execution.rb +72 -52
  15. data/lib/async/background/runner/schedule.rb +4 -4
  16. data/lib/async/background/runner.rb +57 -126
  17. data/lib/async/background/runtime/notification.rb +2 -1
  18. data/lib/async/background/runtime/semaphore.rb +15 -11
  19. data/lib/async/background/runtime/task.rb +18 -17
  20. data/lib/async/background/runtime/task_group.rb +2 -2
  21. data/lib/async/background/runtime.rb +56 -53
  22. data/lib/async/background/scheduler.rb +4 -4
  23. data/lib/async/background/version.rb +1 -1
  24. data/lib/async/background/web/app.rb +26 -19
  25. data/lib/async/background/web/assets/app.css +174 -0
  26. data/lib/async/background/web/assets/app.js +486 -0
  27. data/lib/async/background/web/assets/index.html +29 -0
  28. data/lib/async/background/web/assets.rb +4 -697
  29. data/lib/async/background/web/auth.rb +3 -3
  30. data/lib/async/background/web/configuration.rb +15 -15
  31. data/lib/async/background/web/cursor.rb +14 -7
  32. data/lib/async/background/web/metrics_reader.rb +4 -6
  33. data/lib/async/background/web/response.rb +1 -1
  34. data/lib/async/background/web/serializer.rb +2 -2
  35. data/lib/async/background/web/snapshot.rb +5 -5
  36. data/lib/async/background/web/sql.rb +7 -7
  37. data/lib/async/background/web/stream.rb +52 -32
  38. data/lib/async/background/web.rb +1 -1
  39. metadata +5 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1da83cfa99bb2a77a32b8bd72dd5bee692f94f87b453706f4443cc015e065ac4
4
- data.tar.gz: b0c60214b0e13a7a0c346f5e681d78b1701a7de95e5e816f84193d6593f2c282
3
+ metadata.gz: d9bd77f8af5601609ef43abb3b108ce225e0b20357e712023e2118a06c7e3496
4
+ data.tar.gz: a0e71bfec3a834da9d7a2df0fbd161ee42bbedff00ea1e90f62bee89d9c1edb3
5
5
  SHA512:
6
- metadata.gz: 6cdc11bc1e0bbfb5d76e8d26bb80a71d498c3a04a0fbd1a7e3212640c3f0ae1513fb8bb18784e1dfdd7b96aafc1771ed8cf0db00ac1fedde8e63579943df1e2b
7
- data.tar.gz: ed97b4b602abb304db44199247b533d2fce410ab8a3a80182e9a08ac65eff680d0df198f529ef53b87e947ccf8114b4e89e333f5695928fc1b0137b6dff90c53
6
+ metadata.gz: 3f7d7bd4011648a53092257c35906face2cb05bb46c24233cd9739fd37fa07b19d6476e1ad3a433891abce6146fde4c6fcbaf7fff1a73721fe2fde0dcdde1243
7
+ data.tar.gz: 25070911a4a50ed9142f582bffe6f385aeb236885e417409324804b1525cdea5e30889dc92d14f15c0480386a790732cf84aab54858d73c1b0177eff65af8f9c
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.1.1
4
+
5
+ ### Changed
6
+
7
+ - Refactored runner lifecycle and queue execution, runtime task coordination,
8
+ job option normalization, metrics access, and web request handling into
9
+ smaller focused components.
10
+ - Moved dashboard HTML, CSS, and JavaScript into separate packaged assets.
11
+ - No public API or queue schema changes are intended.
12
+
13
+ ### Validation
14
+
15
+ - Full spec suite: 431 examples, 0 failures.
16
+
3
17
  ## 1.1.0
4
18
 
5
19
  The gem no longer depends on `async`. The same worker runs under any
@@ -8,42 +8,81 @@ module Async
8
8
  BACKOFFS = %i[fixed linear exponential].freeze
9
9
  DEFAULT_JITTER_FOR = { fixed: 0.0, linear: 0.0, exponential: 0.5 }.freeze
10
10
 
11
+ NormalizedOptions = Data.define(:timeout, :retries, :retry_delay, :backoff, :attempt, :jitter)
12
+ private_constant :NormalizedOptions
13
+
11
14
  Options = Data.define(:timeout, :retry, :retry_delay, :backoff, :attempt, :jitter) do
12
15
  def initialize(timeout: DEFAULT_TIMEOUT, retry: 0, retry_delay: nil, backoff: :fixed, attempt: nil, jitter: nil)
13
- timeout = Integer(timeout)
14
- retries = Integer(binding.local_variable_get(:retry) || 0)
15
- retry_delay = Float(retry_delay) unless retry_delay.nil?
16
- backoff = backoff.to_sym
17
- attempt = Integer(attempt) unless attempt.nil?
18
- jitter = Float(jitter) unless jitter.nil?
19
-
20
- raise ArgumentError, "timeout must be > 0" unless timeout.positive?
21
- raise ArgumentError, "retry must be >= 0" if retries.negative?
22
- raise ArgumentError, "attempt must be >= 0" if attempt && attempt.negative?
23
- raise ArgumentError, "backoff must be one of #{BACKOFFS.inspect}" unless BACKOFFS.include?(backoff)
24
- if retries.positive?
25
- raise ArgumentError, "retry_delay is required when retry > 0" if retry_delay.nil?
26
- raise ArgumentError, "retry_delay must be > 0" unless retry_delay.positive?
27
- end
28
- raise ArgumentError, "jitter must be in [0, 1]" if jitter && !(0.0..1.0).cover?(jitter)
29
-
30
- super(timeout:, retry: retries, retry_delay:, backoff:, attempt:, jitter:)
16
+ retries = Integer(binding.local_variable_get(:retry) || 0)
17
+ settings = NormalizedOptions.new(
18
+ Integer(timeout), retries, retry_delay.nil? ? nil : Float(retry_delay), backoff.to_sym,
19
+ attempt.nil? ? nil : Integer(attempt), jitter.nil? ? nil : Float(jitter)
20
+ )
21
+ validate_options!(settings)
22
+ super(timeout: settings.timeout, retry: settings.retries, retry_delay: settings.retry_delay,
23
+ backoff: settings.backoff, attempt: settings.attempt, jitter: settings.jitter)
31
24
  end
32
25
 
33
26
  def retry? = self.retry.positive?
34
27
  def attempts_made = attempt || 0
35
28
  def next_attempt = attempts_made + 1
36
29
  def with_attempt(n) = with(attempt: Integer(n))
30
+
37
31
  def next_retry_delay(for_attempt = next_attempt, rng: Random)
38
- raise ArgumentError, "attempt must be > 0" unless for_attempt.positive?
39
- raise ArgumentError, "retry_delay must be configured" if retry_delay.nil?
32
+ raise ArgumentError, 'attempt must be > 0' unless for_attempt.positive?
33
+ raise ArgumentError, 'retry_delay must be configured' if retry_delay.nil?
40
34
 
41
- base = case backoff
42
- when :fixed then retry_delay
43
- when :linear then retry_delay * for_attempt
44
- when :exponential then retry_delay * (2**(for_attempt - 1))
45
- end
35
+ apply_jitter(base_retry_delay(for_attempt), rng)
36
+ end
37
+
38
+ private
39
+
40
+ def validate_options!(settings)
41
+ validate_timeout!(settings.timeout)
42
+ validate_retry_count!(settings.retries)
43
+ validate_attempt!(settings.attempt)
44
+ validate_backoff!(settings.backoff)
45
+ validate_retry_delay!(settings)
46
+ validate_jitter!(settings.jitter)
47
+ end
46
48
 
49
+ def validate_timeout!(timeout)
50
+ raise ArgumentError, 'timeout must be > 0' unless timeout.positive?
51
+ end
52
+
53
+ def validate_retry_count!(retries)
54
+ raise ArgumentError, 'retry must be >= 0' if retries.negative?
55
+ end
56
+
57
+ def validate_attempt!(attempt)
58
+ raise ArgumentError, 'attempt must be >= 0' if attempt&.negative?
59
+ end
60
+
61
+ def validate_backoff!(backoff)
62
+ raise ArgumentError, "backoff must be one of #{BACKOFFS.inspect}" unless BACKOFFS.include?(backoff)
63
+ end
64
+
65
+ def validate_retry_delay!(settings)
66
+ return unless settings.retries.positive?
67
+
68
+ raise ArgumentError, 'retry_delay is required when retry > 0' if settings.retry_delay.nil?
69
+ raise ArgumentError, 'retry_delay must be > 0' unless settings.retry_delay.positive?
70
+ end
71
+
72
+ def validate_jitter!(jitter)
73
+ raise ArgumentError, 'jitter must be in [0, 1]' if jitter && !(0.0..1.0).cover?(jitter)
74
+ end
75
+
76
+ def base_retry_delay(for_attempt)
77
+ multiplier = case backoff
78
+ when :fixed then 1
79
+ when :linear then for_attempt
80
+ when :exponential then 2**(for_attempt - 1)
81
+ end
82
+ retry_delay * multiplier
83
+ end
84
+
85
+ def apply_jitter(base, rng)
47
86
  factor = jitter || DEFAULT_JITTER_FOR[backoff]
48
87
  factor.zero? ? base : base * (1 + rng.rand * factor)
49
88
  end
@@ -56,9 +95,17 @@ module Async
56
95
  module ClassMethods
57
96
  def perform_now(*args) = new.perform(*args)
58
97
 
59
- def perform_async(*args, options: EMPTY_OPTIONS) = Async::Background::Queue.enqueue(self, *args, options: options)
60
- def perform_in(delay, *args, options: EMPTY_OPTIONS) = Async::Background::Queue.enqueue_in(delay, self, *args, options: options)
61
- def perform_at(time, *args, options: EMPTY_OPTIONS) = Async::Background::Queue.enqueue_at(time, self, *args, options: options)
98
+ def perform_async(*args, options: EMPTY_OPTIONS)
99
+ Async::Background::Queue.enqueue(self, *args, options: options)
100
+ end
101
+
102
+ def perform_in(delay, *args, options: EMPTY_OPTIONS)
103
+ Async::Background::Queue.enqueue_in(delay, self, *args, options: options)
104
+ end
105
+
106
+ def perform_at(time, *args, options: EMPTY_OPTIONS)
107
+ Async::Background::Queue.enqueue_at(time, self, *args, options: options)
108
+ end
62
109
 
63
110
  def options(**values)
64
111
  @options = Options.new(**values).to_h.compact
@@ -29,53 +29,57 @@ module Async
29
29
 
30
30
  validate_worker!(worker_index, total_workers)
31
31
  initialize_registry!(worker_index, total_workers, shm_path)
32
- rescue LoadError => error
33
- mark_unavailable!(error)
32
+ rescue LoadError => e
33
+ mark_unavailable!(e)
34
34
  end
35
35
 
36
36
  def enabled? = @enabled
37
37
 
38
- def job_started(_entry)
39
- return unless enabled?
38
+ module JobEvents
39
+ def job_started(_entry)
40
+ return unless enabled?
40
41
 
41
- increment(:total_runs)
42
- increment(:active_jobs)
43
- set(:last_run_at, Process.clock_gettime(Process::CLOCK_REALTIME).to_i)
44
- end
42
+ increment(:total_runs)
43
+ increment(:active_jobs)
44
+ set(:last_run_at, Process.clock_gettime(Process::CLOCK_REALTIME).to_i)
45
+ end
45
46
 
46
- def job_succeeded(_entry, duration)
47
- return unless enabled?
47
+ def job_succeeded(_entry, duration)
48
+ return unless enabled?
48
49
 
49
- increment(:total_successes)
50
- set(:last_duration_ms, duration_to_milliseconds(duration))
51
- end
50
+ increment(:total_successes)
51
+ set(:last_duration_ms, duration_to_milliseconds(duration))
52
+ end
52
53
 
53
- def job_finished(entry, duration)
54
- job_succeeded(entry, duration)
55
- job_stopped(entry)
56
- end
54
+ def job_finished(entry, duration)
55
+ job_succeeded(entry, duration)
56
+ job_stopped(entry)
57
+ end
57
58
 
58
- def job_failed(_entry, _error)
59
- increment(:total_failures) if enabled?
60
- end
59
+ def job_failed(_entry, _error)
60
+ increment(:total_failures) if enabled?
61
+ end
61
62
 
62
- def job_timed_out(_entry)
63
- increment(:total_timeouts) if enabled?
64
- end
63
+ def job_timed_out(_entry)
64
+ increment(:total_timeouts) if enabled?
65
+ end
65
66
 
66
- def job_stopped(_entry)
67
- decrement(:active_jobs) if enabled?
68
- end
67
+ def job_stopped(_entry)
68
+ decrement(:active_jobs) if enabled?
69
+ end
69
70
 
70
- def job_skipped(_entry)
71
- increment(:total_skips) if enabled?
71
+ def job_skipped(_entry)
72
+ increment(:total_skips) if enabled?
73
+ end
72
74
  end
75
+ private_constant :JobEvents
76
+ include JobEvents
73
77
 
74
78
  def values
75
79
  enabled? ? registry.values : {}
76
80
  end
77
81
 
78
- class << self
82
+ module Reader
79
83
  def available?
80
84
  load_utilization!
81
85
  true
@@ -138,6 +142,8 @@ module Async
138
142
  end
139
143
  end
140
144
 
145
+ extend Reader
146
+
141
147
  private
142
148
 
143
149
  def initialize_registry!(worker_index, total_workers, path)
@@ -145,9 +151,7 @@ module Async
145
151
  ensure_shm!(total_workers, path)
146
152
 
147
153
  @registry = ::Async::Utilization::Registry.new
148
- unless @registry.respond_to?(:metric)
149
- raise LoadError, 'async-utilization >= 0.3 is required for metrics'
150
- end
154
+ raise LoadError, 'async-utilization >= 0.3 is required for metrics' unless @registry.respond_to?(:metric)
151
155
 
152
156
  attach_observer!(worker_index, path)
153
157
  @metric_handles = SCHEMA_FIELDS.keys.to_h { |name| [name, @registry.metric(name)] }.freeze
@@ -196,7 +200,11 @@ module Async
196
200
  file.flock(File::LOCK_EX)
197
201
  file.truncate(mapped_size) if file.size < mapped_size
198
202
  ensure
199
- file.flock(File::LOCK_UN) rescue nil
203
+ begin
204
+ file.flock(File::LOCK_UN)
205
+ rescue StandardError
206
+ nil
207
+ end
200
208
  end
201
209
  end
202
210
 
@@ -41,7 +41,7 @@ module Async
41
41
  private
42
42
 
43
43
  def sift_up(i)
44
- while i > 0
44
+ while i.positive?
45
45
  parent = (i - 1) >> 1
46
46
  break if @data[parent].next_run_at <= @data[i].next_run_at
47
47
 
@@ -50,20 +50,24 @@ module Async
50
50
  end
51
51
  end
52
52
 
53
- def sift_down(i)
54
- while true
55
- smallest = i
56
- left = (i << 1) + 1
57
- right = left + 1
53
+ def sift_down(index)
54
+ loop do
55
+ child = smallest_child(index)
56
+ break if child == index
58
57
 
59
- smallest = left if left < @data.size && @data[left].next_run_at < @data[smallest].next_run_at
60
- smallest = right if right < @data.size && @data[right].next_run_at < @data[smallest].next_run_at
58
+ swap(index, child)
59
+ index = child
60
+ end
61
+ end
61
62
 
62
- break if smallest == i
63
+ def smallest_child(index)
64
+ left = (index << 1) + 1
65
+ right = left + 1
66
+ smallest = index
63
67
 
64
- swap(i, smallest)
65
- i = smallest
66
- end
68
+ smallest = left if left < @data.size && @data[left].next_run_at < @data[smallest].next_run_at
69
+ smallest = right if right < @data.size && @data[right].next_run_at < @data[smallest].next_run_at
70
+ smallest
67
71
  end
68
72
 
69
73
  def swap(a, b)
@@ -43,17 +43,20 @@ module Async
43
43
 
44
44
  def enqueue(job_class, *args, options: EMPTY_OPTIONS)
45
45
  ensure_configured!
46
- default_client.push(resolve_class_name(job_class), normalized_args(args), nil, options: build_options(job_class, options))
46
+ default_client.push(resolve_class_name(job_class), normalized_args(args), nil,
47
+ options: build_options(job_class, options))
47
48
  end
48
49
 
49
50
  def enqueue_in(delay, job_class, *args, options: EMPTY_OPTIONS)
50
51
  ensure_configured!
51
- default_client.push_in(delay, resolve_class_name(job_class), normalized_args(args), options: build_options(job_class, options))
52
+ default_client.push_in(delay, resolve_class_name(job_class), normalized_args(args),
53
+ options: build_options(job_class, options))
52
54
  end
53
55
 
54
56
  def enqueue_at(time, job_class, *args, options: EMPTY_OPTIONS)
55
57
  ensure_configured!
56
- default_client.push_at(time, resolve_class_name(job_class), normalized_args(args), options: build_options(job_class, options))
58
+ default_client.push_at(time, resolve_class_name(job_class), normalized_args(args),
59
+ options: build_options(job_class, options))
57
60
  end
58
61
 
59
62
  private
@@ -79,7 +82,7 @@ module Async
79
82
  end
80
83
 
81
84
  def ensure_configured!
82
- raise "Async::Background::Queue not configured" unless default_client
85
+ raise 'Async::Background::Queue not configured' unless default_client
83
86
  end
84
87
 
85
88
  def normalized_args(args) = args.empty? ? EMPTY_ARGS : args
@@ -95,10 +98,10 @@ module Async
95
98
  return EMPTY_OPTIONS unless job_class.respond_to?(:resolve_options)
96
99
 
97
100
  options = if job_class.respond_to?(:queue_options)
98
- job_class.queue_options
99
- else
100
- job_class.resolve_options
101
- end
101
+ job_class.queue_options
102
+ else
103
+ job_class.resolve_options
104
+ end
102
105
 
103
106
  options.empty? ? EMPTY_OPTIONS : options
104
107
  end
@@ -6,9 +6,9 @@ module Async
6
6
  EMPTY_ARGS = [].freeze
7
7
  EMPTY_OPTIONS = {}.freeze
8
8
 
9
- SYNCHRONOUS_LEVELS = {normal: 'NORMAL', full: 'FULL', extra: 'EXTRA'}.freeze
9
+ SYNCHRONOUS_LEVELS = { normal: 'NORMAL', full: 'FULL', extra: 'EXTRA' }.freeze
10
10
  WAL_AUTOCHECKPOINT_RANGE = 100..10_000
11
- DEFAULT_STORE_OPTIONS = {mmap: true, synchronous: :normal, wal_autocheckpoint: 1_000}.freeze
11
+ DEFAULT_STORE_OPTIONS = { mmap: true, synchronous: :normal, wal_autocheckpoint: 1_000 }.freeze
12
12
  DEFAULTS = DEFAULT_STORE_OPTIONS
13
13
  MMAP_SIZE = 268_435_456
14
14
 
@@ -45,7 +45,7 @@ module Async
45
45
  private
46
46
 
47
47
  def validate_mmap!(value)
48
- return if value == true || value == false
48
+ return if [true, false].include?(value)
49
49
 
50
50
  raise ArgumentError, "mmap must be true or false, got #{value.inspect}"
51
51
  end
@@ -156,7 +156,11 @@ module Async
156
156
  db.execute(SQL::COMMIT)
157
157
  result
158
158
  rescue StandardError
159
- db.execute(SQL::ROLLBACK) rescue nil
159
+ begin
160
+ db.execute(SQL::ROLLBACK)
161
+ rescue StandardError
162
+ nil
163
+ end
160
164
  raise
161
165
  end
162
166
  end
@@ -19,7 +19,7 @@ module Async
19
19
  ].freeze
20
20
 
21
21
  DEAD_WORKER_TTL = 5.0
22
- WAKE_BYTE = "\x01".freeze
22
+ WAKE_BYTE = "\x01"
23
23
 
24
24
  def initialize(socket_dir:, total_workers:)
25
25
  @socket_dir = socket_dir
@@ -64,7 +64,11 @@ module Async
64
64
  begin
65
65
  socket.write_nonblock(WAKE_BYTE)
66
66
  ensure
67
- socket.close rescue nil
67
+ begin
68
+ socket.close
69
+ rescue StandardError
70
+ nil
71
+ end
68
72
  end
69
73
  true
70
74
  rescue IO::WaitWritable
@@ -72,9 +76,15 @@ module Async
72
76
  rescue *UNAVAILABLE
73
77
  mark_dead(index, now)
74
78
  false
75
- rescue => e
79
+ rescue StandardError => e
76
80
  mark_dead(index, now)
77
- Console.logger.warn(self) { "SocketNotifier#notify_one(#{index + 1}) failed: #{e.class} #{e.message}" } rescue nil
81
+ begin
82
+ Console.logger.warn(self) do
83
+ "SocketNotifier#notify_one(#{index + 1}) failed: #{e.class} #{e.message}"
84
+ end
85
+ rescue StandardError
86
+ nil
87
+ end
78
88
  false
79
89
  end
80
90
 
@@ -54,10 +54,18 @@ module Async
54
54
  hang_up_clients
55
55
  stop_clients
56
56
 
57
- @server&.close rescue nil
57
+ begin
58
+ @server&.close
59
+ rescue StandardError
60
+ nil
61
+ end
58
62
  @server = nil
59
63
  @accept_task = nil
60
- File.unlink(@path) rescue nil
64
+ begin
65
+ File.unlink(@path)
66
+ rescue StandardError
67
+ nil
68
+ end
61
69
  end
62
70
 
63
71
  private
@@ -101,7 +109,11 @@ module Async
101
109
  Console.logger.warn(self) { "SocketWaker client handler error: #{e.class} #{e.message}" }
102
110
  ensure
103
111
  @sockets.delete(client)
104
- client.close rescue nil
112
+ begin
113
+ client.close
114
+ rescue StandardError
115
+ nil
116
+ end
105
117
  @notification.signal_all
106
118
  end
107
119
  end
@@ -119,13 +131,17 @@ module Async
119
131
  def wake_accept_loop
120
132
  return unless @server
121
133
 
122
- UNIXSocket.open(@path) { |s| s.write_nonblock("\x00") rescue nil }
134
+ UNIXSocket.open(@path) do |s|
135
+ s.write_nonblock("\x00")
136
+ rescue StandardError
137
+ nil
138
+ end
123
139
  rescue StandardError
124
140
  nil
125
141
  end
126
142
 
127
143
  def hang_up_clients
128
- @sockets.keys.each do |socket|
144
+ @sockets.each_key do |socket|
129
145
  socket.close
130
146
  rescue StandardError
131
147
  nil
@@ -166,11 +182,15 @@ module Async
166
182
  return unless File.exist?(@path)
167
183
 
168
184
  begin
169
- UNIXSocket.open(@path) { |s| s.close }
185
+ UNIXSocket.open(@path, &:close)
170
186
 
171
187
  raise "Socket #{@path} is already in use by another process (worker_index conflict?)"
172
188
  rescue Errno::ECONNREFUSED, Errno::ENOENT
173
- File.unlink(@path) rescue nil
189
+ begin
190
+ File.unlink(@path)
191
+ rescue StandardError
192
+ nil
193
+ end
174
194
  end
175
195
  end
176
196