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
@@ -11,10 +11,10 @@ module Async
11
11
 
12
12
  def authorized?(env)
13
13
  !!@callable.call(env)
14
- rescue StandardError => error
14
+ rescue StandardError => e
15
15
  @logger&.warn(
16
- "[async-background-web] auth callable raised: " \
17
- "#{error.class}: #{error.message}"
16
+ '[async-background-web] auth callable raised: ' \
17
+ "#{e.class}: #{e.message}"
18
18
  )
19
19
  false
20
20
  end
@@ -57,37 +57,37 @@ module Async
57
57
  [:queue_path, ->(value, _) { !value.nil? && !value.to_s.empty? }, 'queue_path must be set'],
58
58
  [:auth, ->(value, _) { !value.nil? }, 'auth must be configured (gem ships no permissive default)'],
59
59
  [:auth, ->(value, _) { value.respond_to?(:call) },
60
- 'auth must respond to #call(env) and return truthy on success'],
60
+ 'auth must respond to #call(env) and return truthy on success'],
61
61
  [:list_limit, ->(value, _) { value.is_a?(Integer) && value.between?(1, MAX_LIST_LIMIT) },
62
- "list_limit must be an Integer between 1 and #{MAX_LIST_LIMIT}"],
62
+ "list_limit must be an Integer between 1 and #{MAX_LIST_LIMIT}"],
63
63
  [:counts_cache_ttl, ->(value, _) { value.is_a?(Numeric) && value >= 0 },
64
- 'counts_cache_ttl must be a non-negative Numeric'],
64
+ 'counts_cache_ttl must be a non-negative Numeric'],
65
65
  [:poll_interval_ms, ->(value, _) { value.is_a?(Integer) && value >= 200 },
66
- 'poll_interval_ms must be an Integer >= 200'],
66
+ 'poll_interval_ms must be an Integer >= 200'],
67
67
  [:transport, ->(value, _) { TRANSPORTS.include?(value) },
68
- "transport must be one of #{TRANSPORTS.inspect}"],
68
+ "transport must be one of #{TRANSPORTS.inspect}"],
69
69
  [:stream_poll_seconds, ->(value, _) { value.is_a?(Numeric) && value >= 0.1 },
70
- 'stream_poll_seconds must be a Numeric >= 0.1'],
70
+ 'stream_poll_seconds must be a Numeric >= 0.1'],
71
71
  [:stream_heartbeat_seconds, ->(value, _) { value.is_a?(Numeric) && value >= 5 },
72
- 'stream_heartbeat_seconds must be a Numeric >= 5'],
72
+ 'stream_heartbeat_seconds must be a Numeric >= 5'],
73
73
  [:stream_retry_ms, ->(value, _) { value.is_a?(Integer) && value >= 500 },
74
- 'stream_retry_ms must be an Integer >= 500'],
74
+ 'stream_retry_ms must be an Integer >= 500'],
75
75
  [:redact_args, ->(value, config) { !config.expose_args || value.nil? || value.respond_to?(:call) },
76
- 'redact_args must respond to #call(args)'],
77
- [:total_workers, ->(value, config) {
76
+ 'redact_args must respond to #call(args)'],
77
+ [:total_workers, lambda { |value, config|
78
78
  !config.metrics_enabled? || (value.is_a?(Integer) && value.positive?)
79
79
  }, 'metrics_path requires total_workers to be a positive Integer'],
80
80
  [:mount_path, ->(value, _) { value.is_a?(String) }, 'mount_path must be a String'],
81
81
  [:mount_path, ->(value, _) { value.empty? || value.start_with?('/') },
82
- 'mount_path must start with "/" or be empty'],
82
+ 'mount_path must start with "/" or be empty'],
83
83
  [:mount_path, ->(value, _) { value.empty? || !value.end_with?('/') },
84
- 'mount_path must not end with "/"'],
84
+ 'mount_path must not end with "/"'],
85
85
  [:mount_path, ->(value, _) { value.empty? || !value.match?(/[[:cntrl:]]/) },
86
- 'mount_path must not contain control characters'],
86
+ 'mount_path must not contain control characters'],
87
87
  [:mount_path, ->(value, _) { value.empty? || !value.match?(/\s/) },
88
- 'mount_path must not contain whitespace'],
88
+ 'mount_path must not contain whitespace'],
89
89
  [:logger, ->(value, _) { value.nil? || (value.respond_to?(:warn) && value.respond_to?(:error)) },
90
- 'logger must respond to #warn and #error']
90
+ 'logger must respond to #warn and #error']
91
91
  ].freeze
92
92
 
93
93
  def validate!
@@ -23,24 +23,31 @@ module Async
23
23
  timestamp, id = decode(value)
24
24
  return unless timestamp
25
25
 
26
- {key => timestamp, :id => id}
26
+ { key => timestamp, :id => id }
27
27
  end
28
28
 
29
29
  def decode(value)
30
30
  return if value.nil? || value.to_s.empty?
31
31
 
32
- timestamp_raw, id_raw, extra = Base64.urlsafe_decode64(value.to_s).split(':', 3)
32
+ decode_fields(Base64.urlsafe_decode64(value.to_s))
33
+ rescue ArgumentError, TypeError
34
+ raise RequestError, 'invalid cursor'
35
+ end
36
+
37
+ def decode_fields(payload)
38
+ timestamp_raw, id_raw, extra = payload.split(':', 3)
33
39
  raise RequestError, 'invalid cursor' if timestamp_raw.nil? || id_raw.nil? || extra
34
40
 
35
41
  timestamp = Float(timestamp_raw)
36
42
  id = Integer(id_raw)
37
- raise RequestError, 'invalid cursor' unless timestamp.finite? && id.positive?
38
-
43
+ validate_fields!(timestamp, id)
39
44
  [timestamp, id]
40
- rescue ArgumentError, TypeError
41
- raise RequestError, 'invalid cursor'
42
45
  end
43
- private_class_method :decode, :decode_as
46
+
47
+ def validate_fields!(timestamp, id)
48
+ raise RequestError, 'invalid cursor' unless timestamp.finite? && id.positive?
49
+ end
50
+ private_class_method :decode, :decode_as, :decode_fields, :validate_fields!
44
51
  end
45
52
  end
46
53
  end
@@ -23,12 +23,10 @@ module Async
23
23
  LATEST_FIELD = :last_run_at
24
24
  LATEST_COMPANION = :last_duration_ms
25
25
 
26
- EMPTY_TOTALS = (
27
- SUMMED_FIELDS.to_h { |field| [field, 0] }
28
- .merge(LATEST_FIELD => 0, LATEST_COMPANION => nil)
29
- ).freeze
26
+ EMPTY_TOTALS = SUMMED_FIELDS.to_h { |field| [field, 0] }
27
+ .merge(LATEST_FIELD => 0, LATEST_COMPANION => nil).freeze
30
28
 
31
- UNAVAILABLE = {available: false, workers: EMPTY_WORKERS, totals: EMPTY_TOTALS}.freeze
29
+ UNAVAILABLE = { available: false, workers: EMPTY_WORKERS, totals: EMPTY_TOTALS }.freeze
32
30
 
33
31
  def initialize(path:, total_workers:, ttl: DEFAULT_TTL)
34
32
  @path = path
@@ -60,7 +58,7 @@ module Async
60
58
  return unavailable unless Metrics.available? && File.file?(@path)
61
59
 
62
60
  workers = Metrics.read_all(total_workers: @total_workers, path: @path)
63
- {available: true, workers: workers, totals: aggregate(workers)}
61
+ { available: true, workers: workers, totals: aggregate(workers) }
64
62
  rescue StandardError
65
63
  unavailable
66
64
  end
@@ -49,7 +49,7 @@ module Async
49
49
  ERROR_BODY = ERRORS.to_h { |name, (_, code)| [name, JSON.generate(error: code).freeze] }.freeze
50
50
 
51
51
  def headers(content_type, cache_control, security = BASE_SECURITY_HEADERS)
52
- {'content-type' => content_type, 'cache-control' => cache_control}.merge(security)
52
+ { 'content-type' => content_type, 'cache-control' => cache_control }.merge(security)
53
53
  end
54
54
 
55
55
  def no_store_headers(content_type) = headers(content_type, NO_STORE)
@@ -55,7 +55,7 @@ module Async
55
55
  pending: %i[created_at run_at].freeze
56
56
  }.freeze
57
57
 
58
- CURSOR_KEY = {done: :finished_at, failed: :finished_at, pending: :run_at}.freeze
58
+ CURSOR_KEY = { done: :finished_at, failed: :finished_at, pending: :run_at }.freeze
59
59
 
60
60
  def item(kind, row)
61
61
  args, args_count = args_for(row[:args_raw])
@@ -76,7 +76,7 @@ module Async
76
76
  last = items.last
77
77
  cursor = last && Cursor.encode(last[CURSOR_KEY.fetch(kind)], last[:id])
78
78
 
79
- {items: items, next_cursor: cursor}
79
+ { items: items, next_cursor: cursor }
80
80
  end
81
81
 
82
82
  def args_for(raw)
@@ -13,11 +13,11 @@ module Async
13
13
  include Clock
14
14
 
15
15
  CacheEntry = Data.define(:value, :created_at)
16
- RAW_COLUMNS = {'args' => :args_raw, 'options' => :options_raw}.freeze
16
+ RAW_COLUMNS = { 'args' => :args_raw, 'options' => :options_raw }.freeze
17
17
 
18
18
  ROW_KEYS = SQL::EXTRA_COLUMNS.to_h do |status, extra|
19
19
  columns = SQL::BASE_COLUMNS + extra
20
- [status, columns.map { |column| RAW_COLUMNS.fetch(column, column.to_sym) }.freeze]
20
+ [status, columns.map { |column| RAW_COLUMNS.fetch(column) { column.to_sym } }.freeze]
21
21
  end.freeze
22
22
 
23
23
  PAGING = {
@@ -109,8 +109,8 @@ module Async
109
109
  SQLite3::Database.new(database_uri, uri: true)
110
110
  rescue LoadError
111
111
  raise
112
- rescue StandardError => error
113
- raise UnavailableError, "cannot open queue database: #{error.message}"
112
+ rescue StandardError => e
113
+ raise UnavailableError, "cannot open queue database: #{e.message}"
114
114
  end
115
115
 
116
116
  def database_uri
@@ -192,7 +192,7 @@ module Async
192
192
  require 'sqlite3'
193
193
  rescue LoadError
194
194
  raise LoadError,
195
- "sqlite3 gem is required for Async::Background::Web. " \
195
+ 'sqlite3 gem is required for Async::Background::Web. ' \
196
196
  "Add `gem 'sqlite3', '~> 2.0'` to your Gemfile."
197
197
  end
198
198
  end
@@ -4,11 +4,11 @@ module Async
4
4
  module Background
5
5
  module Web
6
6
  module SQL
7
- BEGIN_READ_TRANSACTION = 'BEGIN'.freeze
8
- COMMIT = 'COMMIT'.freeze
9
- ROLLBACK = 'ROLLBACK'.freeze
10
- QUERY_ONLY = 'PRAGMA query_only = ON'.freeze
11
- BUSY_TIMEOUT = 'PRAGMA busy_timeout = 2000'.freeze
7
+ BEGIN_READ_TRANSACTION = 'BEGIN'
8
+ COMMIT = 'COMMIT'
9
+ ROLLBACK = 'ROLLBACK'
10
+ QUERY_ONLY = 'PRAGMA query_only = ON'
11
+ BUSY_TIMEOUT = 'PRAGMA busy_timeout = 2000'
12
12
  BASE_COLUMNS = %w[id class_name args options].freeze
13
13
 
14
14
  STATUS_PREDICATE = {
@@ -35,8 +35,8 @@ module Async
35
35
  failed: ['finished_at', :desc]
36
36
  }.freeze
37
37
 
38
- ORDER_SQL = {asc: 'ORDER BY %<column>s, id', desc: 'ORDER BY %<column>s DESC, id DESC'}.freeze
39
- SEEK_SQL = {asc: '(%<column>s, id) > (?, ?)', desc: '(%<column>s, id) < (?, ?)'}.freeze
38
+ ORDER_SQL = { asc: 'ORDER BY %<column>s, id', desc: 'ORDER BY %<column>s DESC, id DESC' }.freeze
39
+ SEEK_SQL = { asc: '(%<column>s, id) > (?, ?)', desc: '(%<column>s, id) < (?, ?)' }.freeze
40
40
 
41
41
  def self.columns_for(status)
42
42
  (BASE_COLUMNS + EXTRA_COLUMNS.fetch(status)).join(', ')
@@ -26,41 +26,13 @@ module Async
26
26
  end
27
27
 
28
28
  yield frame
29
- last_yield = monotonic_now
30
- unavailable_announced = false
31
-
32
- loop do
33
- sleep_for_poll
34
-
35
- begin
36
- new_version = @hub.current_version
37
-
38
- if new_version != version
39
- version = new_version
40
- yield @hub.frame_for(version)
41
- last_yield = monotonic_now
42
- elsif (monotonic_now - last_yield) >= @heartbeat_seconds
43
- yield EventHub::HEARTBEAT_FRAME
44
- last_yield = monotonic_now
45
- end
46
-
47
- unavailable_announced = false
48
- rescue ClosedError
49
- break
50
- rescue UnavailableError
51
- unless unavailable_announced
52
- yield EventHub::UNAVAILABLE_FRAME
53
- unavailable_announced = true
54
- last_yield = monotonic_now
55
- end
56
- end
57
- end
29
+ stream_updates(version, monotonic_now) { |event| yield event }
58
30
  rescue Errno::EPIPE, Errno::ECONNRESET, IOError
59
31
  nil
60
- rescue StandardError => error
32
+ rescue StandardError => e
61
33
  @logger&.error(
62
- "[async-background-web] SSE stream terminated: " \
63
- "#{error.class}: #{error.message}"
34
+ '[async-background-web] SSE stream terminated: ' \
35
+ "#{e.class}: #{e.message}"
64
36
  )
65
37
  nil
66
38
  end
@@ -76,6 +48,54 @@ module Async
76
48
  def sleep_for_poll
77
49
  sleep(@poll_seconds)
78
50
  end
51
+
52
+ def stream_updates(version, last_yield)
53
+ unavailable_announced = false
54
+ loop do
55
+ sleep_for_poll
56
+ state = next_state(version, last_yield, unavailable_announced) { |event| yield event }
57
+ break unless state
58
+
59
+ version, last_yield, unavailable_announced = state
60
+ end
61
+ end
62
+
63
+ def next_state(version, last_yield, unavailable_announced)
64
+ current_version = @hub.current_version
65
+ return updated_state(current_version) { |event| yield event } if current_version != version
66
+ return heartbeat_state(version) { |event| yield event } if heartbeat_due?(last_yield)
67
+
68
+ [version, last_yield, false]
69
+ rescue ClosedError
70
+ nil
71
+ rescue UnavailableError
72
+ announce_unavailable(version, last_yield, unavailable_announced) { |event| yield event }
73
+ end
74
+
75
+ def updated_state(version)
76
+ yield @hub.frame_for(version)
77
+ [version, monotonic_now, false]
78
+ end
79
+
80
+ def heartbeat_state(version)
81
+ yield EventHub::HEARTBEAT_FRAME
82
+ [version, monotonic_now, false]
83
+ end
84
+
85
+ def heartbeat_due?(last_yield)
86
+ (monotonic_now - last_yield) >= @heartbeat_seconds
87
+ end
88
+
89
+ def announce_unavailable(version, last_yield, announced)
90
+ return [version, last_yield, true] if announced
91
+
92
+ yield_unavailable { |event| yield event }
93
+ [version, monotonic_now, true]
94
+ end
95
+
96
+ def yield_unavailable
97
+ yield EventHub::UNAVAILABLE_FRAME
98
+ end
79
99
  end
80
100
  end
81
101
  end
@@ -37,7 +37,7 @@ module Async
37
37
 
38
38
  def configuration
39
39
  @configuration or raise NotConfiguredError,
40
- 'Async::Background::Web is not configured. Call Async::Background::Web.configure.'
40
+ 'Async::Background::Web is not configured. Call Async::Background::Web.configure.'
41
41
  end
42
42
 
43
43
  def reset!
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: async-background
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.0
4
+ version: 1.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Roman Hajdarov
@@ -157,6 +157,7 @@ files:
157
157
  - lib/async/background/queue/sql.rb
158
158
  - lib/async/background/queue/store.rb
159
159
  - lib/async/background/runner.rb
160
+ - lib/async/background/runner/lifecycle.rb
160
161
  - lib/async/background/runner/queue_execution.rb
161
162
  - lib/async/background/runner/schedule.rb
162
163
  - lib/async/background/runtime.rb
@@ -169,6 +170,9 @@ files:
169
170
  - lib/async/background/web.rb
170
171
  - lib/async/background/web/app.rb
171
172
  - lib/async/background/web/assets.rb
173
+ - lib/async/background/web/assets/app.css
174
+ - lib/async/background/web/assets/app.js
175
+ - lib/async/background/web/assets/index.html
172
176
  - lib/async/background/web/auth.rb
173
177
  - lib/async/background/web/configuration.rb
174
178
  - lib/async/background/web/cursor.rb