async-background 0.7.1 → 1.0.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.
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Async
4
+ module Background
5
+ module Web
6
+ class Auth
7
+ def initialize(callable)
8
+ @callable = callable
9
+ end
10
+
11
+ def authorized?(env)
12
+ !!@callable.call(env)
13
+ rescue StandardError
14
+ false
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../queue/store'
4
+
5
+ module Async
6
+ module Background
7
+ module Web
8
+ class Configuration
9
+ DEFAULT_LIST_LIMIT = 50
10
+ MAX_LIST_LIMIT = 200
11
+ DEFAULT_COUNTS_TTL = 3.0
12
+ DEFAULT_POLL_INTERVAL_MS = 2000
13
+ DEFAULT_STREAM_POLL_SECONDS = 0.5
14
+ DEFAULT_STREAM_HEARTBEAT_SECONDS = 25.0
15
+ DEFAULT_STREAM_RETRY_MS = 5000
16
+ TRANSPORTS = %i[polling sse].freeze
17
+ DEFAULT_TRANSPORT = :sse
18
+ DEFAULT_REDACT = ->(args) { args.is_a?(Array) ? args.map { '***' } : args }
19
+
20
+ attr_accessor :queue_path,
21
+ :auth,
22
+ :expose_args,
23
+ :redact_args,
24
+ :metrics_path,
25
+ :total_workers,
26
+ :counts_cache_ttl,
27
+ :list_limit,
28
+ :poll_interval_ms,
29
+ :transport,
30
+ :stream_poll_seconds,
31
+ :stream_heartbeat_seconds,
32
+ :stream_retry_ms,
33
+ :title,
34
+ :mount_path
35
+
36
+ def initialize
37
+ @queue_path = Queue::Store.default_path
38
+ @auth = nil
39
+ @expose_args = false
40
+ @redact_args = DEFAULT_REDACT
41
+ @metrics_path = nil
42
+ @total_workers = nil
43
+ @counts_cache_ttl = DEFAULT_COUNTS_TTL
44
+ @list_limit = DEFAULT_LIST_LIMIT
45
+ @poll_interval_ms = DEFAULT_POLL_INTERVAL_MS
46
+ @transport = DEFAULT_TRANSPORT
47
+ @stream_poll_seconds = DEFAULT_STREAM_POLL_SECONDS
48
+ @stream_heartbeat_seconds = DEFAULT_STREAM_HEARTBEAT_SECONDS
49
+ @stream_retry_ms = DEFAULT_STREAM_RETRY_MS
50
+ @title = 'Async::Background'
51
+ @mount_path = ''
52
+ end
53
+
54
+ def validate!
55
+ validate_queue_path!
56
+ validate_auth!
57
+ validate_list_limit!
58
+ validate_cache_ttl!
59
+ validate_poll_interval!
60
+ validate_transport!
61
+ validate_stream!
62
+ validate_redactor!
63
+ validate_metrics!
64
+ validate_mount_path!
65
+ self
66
+ end
67
+
68
+ # Strict request-path parsing. Silently changing a malformed requested
69
+ # page size to the default makes API clients repeat or skip work.
70
+ def limit_for(requested)
71
+ return list_limit if requested.nil? || requested.empty?
72
+
73
+ value = Integer(requested, 10)
74
+ raise RequestError, 'limit must be a positive integer' unless value.positive?
75
+
76
+ [value, MAX_LIST_LIMIT].min
77
+ rescue ArgumentError, TypeError
78
+ raise RequestError, 'limit must be a positive integer'
79
+ end
80
+
81
+ def metrics_enabled?
82
+ !metrics_path.nil?
83
+ end
84
+
85
+ private
86
+
87
+ def validate_queue_path!
88
+ raise ConfigurationError, 'queue_path must be set' if queue_path.nil? || queue_path.to_s.empty?
89
+ end
90
+
91
+ def validate_auth!
92
+ raise ConfigurationError, 'auth must be configured (gem ships no permissive default)' if auth.nil?
93
+
94
+ return if auth.respond_to?(:call)
95
+
96
+ raise ConfigurationError, 'auth must respond to #call(env) and return truthy on success'
97
+ end
98
+
99
+ def validate_list_limit!
100
+ return if list_limit.is_a?(Integer) && list_limit.between?(1, MAX_LIST_LIMIT)
101
+
102
+ raise ConfigurationError, "list_limit must be an Integer between 1 and #{MAX_LIST_LIMIT}"
103
+ end
104
+
105
+ def validate_cache_ttl!
106
+ return if counts_cache_ttl.is_a?(Numeric) && counts_cache_ttl >= 0
107
+
108
+ raise ConfigurationError, 'counts_cache_ttl must be a non-negative Numeric'
109
+ end
110
+
111
+ def validate_poll_interval!
112
+ return if poll_interval_ms.is_a?(Integer) && poll_interval_ms >= 200
113
+
114
+ raise ConfigurationError, 'poll_interval_ms must be an Integer >= 200'
115
+ end
116
+
117
+ def validate_transport!
118
+ return if TRANSPORTS.include?(transport)
119
+
120
+ raise ConfigurationError, "transport must be one of #{TRANSPORTS.inspect}"
121
+ end
122
+
123
+ def validate_stream!
124
+ unless stream_poll_seconds.is_a?(Numeric) && stream_poll_seconds >= 0.1
125
+ raise ConfigurationError, 'stream_poll_seconds must be a Numeric >= 0.1'
126
+ end
127
+
128
+ unless stream_heartbeat_seconds.is_a?(Numeric) && stream_heartbeat_seconds >= 5
129
+ raise ConfigurationError, 'stream_heartbeat_seconds must be a Numeric >= 5'
130
+ end
131
+
132
+ return if stream_retry_ms.is_a?(Integer) && stream_retry_ms >= 500
133
+
134
+ raise ConfigurationError, 'stream_retry_ms must be an Integer >= 500'
135
+ end
136
+
137
+ def validate_redactor!
138
+ return unless expose_args && redact_args && !redact_args.respond_to?(:call)
139
+
140
+ raise ConfigurationError, 'redact_args must respond to #call(args)'
141
+ end
142
+
143
+ def validate_metrics!
144
+ return unless metrics_enabled?
145
+ return if total_workers.is_a?(Integer) && total_workers.positive?
146
+
147
+ raise ConfigurationError, 'metrics_path requires total_workers to be a positive Integer'
148
+ end
149
+
150
+ def validate_mount_path!
151
+ return if mount_path.is_a?(String)
152
+
153
+ raise ConfigurationError, 'mount_path must be a String'
154
+ end
155
+ end
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+
5
+ module Async
6
+ module Background
7
+ module Web
8
+ module Cursor
9
+ module_function
10
+
11
+ def encode_finished(finished_at, id)
12
+ encode(finished_at, id)
13
+ end
14
+
15
+ def encode_pending(run_at, id)
16
+ encode(run_at, id)
17
+ end
18
+
19
+ def decode_finished(value)
20
+ timestamp, id = decode(value)
21
+ return unless timestamp
22
+
23
+ {finished_at: timestamp, id: id}
24
+ end
25
+
26
+ def decode_pending(value)
27
+ timestamp, id = decode(value)
28
+ return unless timestamp
29
+
30
+ {run_at: timestamp, id: id}
31
+ end
32
+
33
+ def encode(timestamp, id)
34
+ return if timestamp.nil? || id.nil?
35
+
36
+ Base64.urlsafe_encode64("#{Float(timestamp)}:#{Integer(id)}", padding: false)
37
+ end
38
+ private_class_method :encode
39
+
40
+ def decode(value)
41
+ return if value.nil? || value.to_s.empty?
42
+
43
+ timestamp_raw, id_raw, extra = Base64.urlsafe_decode64(value.to_s).split(':', 3)
44
+ raise RequestError, 'invalid cursor' if timestamp_raw.nil? || id_raw.nil? || extra
45
+
46
+ timestamp = Float(timestamp_raw)
47
+ id = Integer(id_raw)
48
+ raise RequestError, 'invalid cursor' unless timestamp.finite? && id.positive?
49
+
50
+ [timestamp, id]
51
+ rescue ArgumentError, TypeError
52
+ raise RequestError, 'invalid cursor'
53
+ end
54
+ private_class_method :decode
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Async
4
+ module Background
5
+ module Web
6
+ class Error < StandardError; end
7
+ class ConfigurationError < Error; end
8
+ class NotConfiguredError < Error; end
9
+ class RequestError < Error; end
10
+ class UnavailableError < Error; end
11
+ class ClosedError < Error; end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Async
6
+ module Background
7
+ module Web
8
+ class EventHub
9
+ HEARTBEAT_FRAME = ":keepalive\n\n"
10
+ UNAVAILABLE_FRAME = "event: unavailable\ndata: #{JSON.generate(error: 'unavailable')}\n\n".freeze
11
+
12
+ class Subscription
13
+ def initialize(clock: nil)
14
+ @clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
15
+ @mutex = Mutex.new
16
+ @condition = ConditionVariable.new
17
+ @frame = nil
18
+ @closed = false
19
+ end
20
+
21
+ def publish(frame)
22
+ @mutex.synchronize do
23
+ return false if @closed
24
+
25
+ @frame = frame
26
+ @condition.signal
27
+ true
28
+ end
29
+ end
30
+
31
+ def pop(timeout:)
32
+ deadline = @clock.call + timeout
33
+
34
+ @mutex.synchronize do
35
+ while @frame.nil? && !@closed
36
+ remaining = deadline - @clock.call
37
+ break if remaining <= 0
38
+
39
+ @condition.wait(@mutex, remaining)
40
+ end
41
+
42
+ frame = @frame
43
+ @frame = nil
44
+ frame
45
+ end
46
+ end
47
+
48
+ def close
49
+ @mutex.synchronize do
50
+ return if @closed
51
+
52
+ @closed = true
53
+ @condition.broadcast
54
+ end
55
+ end
56
+
57
+ def closed?
58
+ @mutex.synchronize { @closed }
59
+ end
60
+ end
61
+
62
+ def initialize(snapshot, serializer, metrics_reader: nil, poll_seconds:, sleeper: nil)
63
+ @snapshot = snapshot
64
+ @serializer = serializer
65
+ @metrics_reader = metrics_reader
66
+ @poll_seconds = poll_seconds
67
+ @sleeper = sleeper || ->(seconds) { sleep(seconds) }
68
+ @mutex = Mutex.new
69
+ @condition = ConditionVariable.new
70
+ @subscribers = {}
71
+ @closed = false
72
+ @monitor = nil
73
+ @last_data_version = nil
74
+ @unavailable = false
75
+ end
76
+
77
+ def subscribe
78
+ subscription = Subscription.new
79
+ frame, data_version = current_overview
80
+
81
+ @mutex.synchronize do
82
+ raise ClosedError, 'event hub is closed' if @closed
83
+
84
+ @subscribers[subscription.object_id] = subscription
85
+ @last_data_version ||= data_version
86
+ start_monitor_unless_running!
87
+ @condition.signal
88
+ end
89
+
90
+ [subscription, frame]
91
+ end
92
+
93
+ def unsubscribe(subscription)
94
+ @mutex.synchronize do
95
+ @subscribers.delete(subscription.object_id)
96
+ end
97
+ subscription.close
98
+ nil
99
+ end
100
+
101
+ def close
102
+ monitor = nil
103
+ subscribers = nil
104
+
105
+ @mutex.synchronize do
106
+ return if @closed
107
+
108
+ @closed = true
109
+ subscribers = @subscribers.values
110
+ @subscribers.clear
111
+ monitor = @monitor
112
+ @condition.broadcast
113
+ end
114
+
115
+ subscribers.each(&:close)
116
+ monitor&.join(1) unless monitor == Thread.current
117
+ nil
118
+ end
119
+
120
+ private
121
+
122
+ def start_monitor_unless_running!
123
+ return if @monitor&.alive?
124
+
125
+ @monitor = Thread.new { monitor_loop }
126
+ @monitor.name = 'async-background-web-events' if @monitor.respond_to?(:name=)
127
+ @monitor.abort_on_exception = false
128
+ end
129
+
130
+ def monitor_loop
131
+ loop do
132
+ break unless wait_for_subscribers
133
+
134
+ begin
135
+ detect_change
136
+ rescue ClosedError, UnavailableError
137
+ notify_unavailable
138
+ end
139
+
140
+ @sleeper.call(@poll_seconds)
141
+ end
142
+ ensure
143
+ @mutex.synchronize { @monitor = nil if @monitor == Thread.current }
144
+ end
145
+
146
+ def wait_for_subscribers
147
+ @mutex.synchronize do
148
+ @condition.wait(@mutex) while !@closed && @subscribers.empty?
149
+ !@closed
150
+ end
151
+ end
152
+
153
+ def detect_change
154
+ version = @snapshot.data_version
155
+ previous_version = @mutex.synchronize { @last_data_version }
156
+ if version == previous_version
157
+ @mutex.synchronize { @unavailable = false }
158
+ return
159
+ end
160
+
161
+ frame, observed_version = current_overview
162
+ @mutex.synchronize do
163
+ @last_data_version = observed_version
164
+ @unavailable = false
165
+ end
166
+ broadcast(frame)
167
+ end
168
+
169
+ def current_overview
170
+ overview = @snapshot.overview(force: true)
171
+ metrics = @metrics_reader&.aggregated
172
+ payload = @serializer.overview(overview, metrics)
173
+ ["event: overview\ndata: #{JSON.generate(payload)}\n\n", payload.fetch(:data_version)]
174
+ end
175
+
176
+ def notify_unavailable
177
+ should_broadcast = @mutex.synchronize do
178
+ next false if @unavailable
179
+
180
+ @unavailable = true
181
+ true
182
+ end
183
+ broadcast(UNAVAILABLE_FRAME) if should_broadcast
184
+ end
185
+
186
+ def broadcast(frame)
187
+ subscribers = @mutex.synchronize { @subscribers.values.dup }
188
+ subscribers.each { |subscription| subscription.publish(frame) }
189
+ nil
190
+ end
191
+ end
192
+ end
193
+ end
194
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../clock'
4
+ require_relative '../metrics'
5
+
6
+ module Async
7
+ module Background
8
+ module Web
9
+ class MetricsReader
10
+ include Clock
11
+
12
+ DEFAULT_TTL = 1.0
13
+ EMPTY_WORKERS = [].freeze
14
+ EMPTY_TOTALS = {
15
+ total_runs: 0,
16
+ total_successes: 0,
17
+ total_failures: 0,
18
+ total_timeouts: 0,
19
+ total_skips: 0,
20
+ active_jobs: 0,
21
+ last_run_at: 0,
22
+ last_duration_ms: nil
23
+ }.freeze
24
+
25
+ def initialize(path:, total_workers:, ttl: DEFAULT_TTL)
26
+ @path = path
27
+ @total_workers = total_workers
28
+ @ttl = ttl
29
+ @mutex = Mutex.new
30
+ @cache = nil
31
+ @cached_at = nil
32
+ end
33
+
34
+ def aggregated
35
+ @mutex.synchronize do
36
+ now = monotonic_now
37
+ return @cache if cache_current?(now)
38
+
39
+ @cache = read_metrics.freeze
40
+ @cached_at = now
41
+ @cache
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ def cache_current?(now)
48
+ @cache && @cached_at && (now - @cached_at) < @ttl
49
+ end
50
+
51
+ def read_metrics
52
+ return unavailable unless Metrics.available? && File.file?(@path)
53
+
54
+ workers = Metrics.read_all(total_workers: @total_workers, path: @path)
55
+ {available: true, workers: workers, totals: aggregate(workers)}
56
+ rescue StandardError
57
+ unavailable
58
+ end
59
+
60
+ def unavailable
61
+ {available: false, workers: EMPTY_WORKERS, totals: EMPTY_TOTALS}
62
+ end
63
+
64
+ def aggregate(workers)
65
+ totals = {
66
+ total_runs: 0,
67
+ total_successes: 0,
68
+ total_failures: 0,
69
+ total_timeouts: 0,
70
+ total_skips: 0,
71
+ active_jobs: 0,
72
+ last_run_at: 0,
73
+ last_duration_ms: nil
74
+ }
75
+
76
+ workers.each do |worker|
77
+ totals[:total_runs] += worker[:total_runs].to_i
78
+ totals[:total_successes] += worker[:total_successes].to_i
79
+ totals[:total_failures] += worker[:total_failures].to_i
80
+ totals[:total_timeouts] += worker[:total_timeouts].to_i
81
+ totals[:total_skips] += worker[:total_skips].to_i
82
+ totals[:active_jobs] += worker[:active_jobs].to_i
83
+
84
+ last_run_at = worker[:last_run_at].to_i
85
+ next unless last_run_at > totals[:last_run_at]
86
+
87
+ totals[:last_run_at] = last_run_at
88
+ totals[:last_duration_ms] = worker[:last_duration_ms]
89
+ end
90
+
91
+ totals.freeze
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Async
4
+ module Background
5
+ module Web
6
+ class Request
7
+ def initialize(env, config)
8
+ @config = config
9
+ @params = parse(env['QUERY_STRING'])
10
+ end
11
+
12
+ def limit
13
+ @config.limit_for(@params['limit'])
14
+ end
15
+
16
+ def finished_cursor
17
+ Cursor.decode_finished(@params['cursor'])
18
+ end
19
+
20
+ def pending_cursor
21
+ Cursor.decode_pending(@params['cursor'])
22
+ end
23
+
24
+ private
25
+
26
+ def parse(query)
27
+ return {} if query.nil? || query.empty?
28
+
29
+ Rack::Utils.parse_query(query)
30
+ rescue StandardError
31
+ raise RequestError, 'invalid query string'
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Async
6
+ module Background
7
+ module Web
8
+ module Response
9
+ module_function
10
+
11
+ JSON_TYPE = 'application/json; charset=utf-8'
12
+ HTML_TYPE = 'text/html; charset=utf-8'
13
+ TEXT_TYPE = 'text/plain; charset=utf-8'
14
+ JAVASCRIPT_TYPE = 'application/javascript; charset=utf-8'
15
+ CSS_TYPE = 'text/css; charset=utf-8'
16
+ NO_STORE = 'no-store'
17
+ ASSET_CACHE = 'public, max-age=31536000, immutable'
18
+
19
+ UNAUTHORIZED_BODY = 'unauthorized'
20
+ NOT_FOUND_BODY = 'not found'
21
+ BAD_REQUEST_BODY = JSON.generate(error: 'invalid_request').freeze
22
+ UNAVAILABLE_BODY = JSON.generate(error: 'service_unavailable').freeze
23
+ INTERNAL_ERROR_BODY = JSON.generate(error: 'internal_error').freeze
24
+ EVENT_STREAM_TYPE = 'text/event-stream; charset=utf-8'
25
+
26
+ def sse(body)
27
+ [200, sse_headers, body]
28
+ end
29
+
30
+ def json(payload, status: 200)
31
+ [status, no_store_headers(JSON_TYPE), [JSON.generate(payload)]]
32
+ end
33
+
34
+ def html(body)
35
+ [200, no_store_headers(HTML_TYPE), [body]]
36
+ end
37
+
38
+ def javascript(body)
39
+ [200, asset_headers(JAVASCRIPT_TYPE), [body]]
40
+ end
41
+
42
+ def stylesheet(body)
43
+ [200, asset_headers(CSS_TYPE), [body]]
44
+ end
45
+
46
+ def unauthorized
47
+ [401, no_store_headers(TEXT_TYPE), [UNAUTHORIZED_BODY]]
48
+ end
49
+
50
+ def not_found
51
+ [404, no_store_headers(TEXT_TYPE), [NOT_FOUND_BODY]]
52
+ end
53
+
54
+ def bad_request(message = nil)
55
+ body = message.nil? ? BAD_REQUEST_BODY : JSON.generate(error: 'invalid_request', message: message)
56
+ [400, no_store_headers(JSON_TYPE), [body]]
57
+ end
58
+
59
+ def unavailable
60
+ [503, no_store_headers(JSON_TYPE), [UNAVAILABLE_BODY]]
61
+ end
62
+
63
+ def internal_error
64
+ [500, no_store_headers(JSON_TYPE), [INTERNAL_ERROR_BODY]]
65
+ end
66
+
67
+ def no_store_headers(content_type)
68
+ {'content-type' => content_type, 'cache-control' => NO_STORE}
69
+ end
70
+
71
+ def asset_headers(content_type)
72
+ {'content-type' => content_type, 'cache-control' => ASSET_CACHE}
73
+ end
74
+
75
+ def sse_headers
76
+ {
77
+ 'content-type' => EVENT_STREAM_TYPE,
78
+ 'cache-control' => 'no-cache, no-transform',
79
+ 'x-accel-buffering' => 'no'
80
+ }
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Async
4
+ module Background
5
+ module Web
6
+ class Router
7
+ GET_ROUTES = {
8
+ '/' => :index,
9
+ '/assets/app.js' => :javascript,
10
+ '/assets/app.css' => :stylesheet,
11
+ '/api/overview' => :overview,
12
+ '/api/executing' => :executing,
13
+ '/api/claimed' => :claimed,
14
+ '/api/done' => :done,
15
+ '/api/failed' => :failed,
16
+ '/api/pending' => :pending,
17
+ '/api/metrics' => :metrics,
18
+ '/api/config' => :config,
19
+ '/api/stream' => :stream
20
+ }.freeze
21
+
22
+ def match(env)
23
+ return unless env['REQUEST_METHOD'] == 'GET'
24
+
25
+ GET_ROUTES[env['PATH_INFO'] || '/']
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end