async-background 0.7.2 → 1.0.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.
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Async
4
+ module Background
5
+ module Web
6
+ class Auth
7
+ def initialize(callable, logger: nil)
8
+ @callable = callable
9
+ @logger = logger
10
+ end
11
+
12
+ def authorized?(env)
13
+ !!@callable.call(env)
14
+ rescue StandardError => error
15
+ @logger&.warn(
16
+ "[async-background-web] auth callable raised: " \
17
+ "#{error.class}: #{error.message}"
18
+ )
19
+ false
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,172 @@
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
+ :logger
36
+
37
+ def initialize
38
+ @queue_path = Queue::Store.default_path
39
+ @auth = nil
40
+ @expose_args = false
41
+ @redact_args = DEFAULT_REDACT
42
+ @metrics_path = nil
43
+ @total_workers = nil
44
+ @counts_cache_ttl = DEFAULT_COUNTS_TTL
45
+ @list_limit = DEFAULT_LIST_LIMIT
46
+ @poll_interval_ms = DEFAULT_POLL_INTERVAL_MS
47
+ @transport = DEFAULT_TRANSPORT
48
+ @stream_poll_seconds = DEFAULT_STREAM_POLL_SECONDS
49
+ @stream_heartbeat_seconds = DEFAULT_STREAM_HEARTBEAT_SECONDS
50
+ @stream_retry_ms = DEFAULT_STREAM_RETRY_MS
51
+ @title = 'Async::Background'
52
+ @mount_path = ''
53
+ @logger = nil
54
+ end
55
+
56
+ def validate!
57
+ validate_queue_path!
58
+ validate_auth!
59
+ validate_list_limit!
60
+ validate_cache_ttl!
61
+ validate_poll_interval!
62
+ validate_transport!
63
+ validate_stream!
64
+ validate_redactor!
65
+ validate_metrics!
66
+ validate_mount_path!
67
+ validate_logger!
68
+ self
69
+ end
70
+
71
+ # Strict request-path parsing. Silently changing a malformed requested
72
+ # page size to the default makes API clients repeat or skip work.
73
+ def limit_for(requested)
74
+ return list_limit if requested.nil? || requested.empty?
75
+
76
+ value = Integer(requested, 10)
77
+ raise RequestError, 'limit must be a positive integer' unless value.positive?
78
+
79
+ [value, MAX_LIST_LIMIT].min
80
+ rescue ArgumentError, TypeError
81
+ raise RequestError, 'limit must be a positive integer'
82
+ end
83
+
84
+ def metrics_enabled?
85
+ !metrics_path.nil?
86
+ end
87
+
88
+ private
89
+
90
+ def validate_queue_path!
91
+ raise ConfigurationError, 'queue_path must be set' if queue_path.nil? || queue_path.to_s.empty?
92
+ end
93
+
94
+ def validate_auth!
95
+ raise ConfigurationError, 'auth must be configured (gem ships no permissive default)' if auth.nil?
96
+
97
+ return if auth.respond_to?(:call)
98
+
99
+ raise ConfigurationError, 'auth must respond to #call(env) and return truthy on success'
100
+ end
101
+
102
+ def validate_list_limit!
103
+ return if list_limit.is_a?(Integer) && list_limit.between?(1, MAX_LIST_LIMIT)
104
+
105
+ raise ConfigurationError, "list_limit must be an Integer between 1 and #{MAX_LIST_LIMIT}"
106
+ end
107
+
108
+ def validate_cache_ttl!
109
+ return if counts_cache_ttl.is_a?(Numeric) && counts_cache_ttl >= 0
110
+
111
+ raise ConfigurationError, 'counts_cache_ttl must be a non-negative Numeric'
112
+ end
113
+
114
+ def validate_poll_interval!
115
+ return if poll_interval_ms.is_a?(Integer) && poll_interval_ms >= 200
116
+
117
+ raise ConfigurationError, 'poll_interval_ms must be an Integer >= 200'
118
+ end
119
+
120
+ def validate_transport!
121
+ return if TRANSPORTS.include?(transport)
122
+
123
+ raise ConfigurationError, "transport must be one of #{TRANSPORTS.inspect}"
124
+ end
125
+
126
+ def validate_stream!
127
+ unless stream_poll_seconds.is_a?(Numeric) && stream_poll_seconds >= 0.1
128
+ raise ConfigurationError, 'stream_poll_seconds must be a Numeric >= 0.1'
129
+ end
130
+
131
+ unless stream_heartbeat_seconds.is_a?(Numeric) && stream_heartbeat_seconds >= 5
132
+ raise ConfigurationError, 'stream_heartbeat_seconds must be a Numeric >= 5'
133
+ end
134
+
135
+ return if stream_retry_ms.is_a?(Integer) && stream_retry_ms >= 500
136
+
137
+ raise ConfigurationError, 'stream_retry_ms must be an Integer >= 500'
138
+ end
139
+
140
+ def validate_redactor!
141
+ return unless expose_args && redact_args && !redact_args.respond_to?(:call)
142
+
143
+ raise ConfigurationError, 'redact_args must respond to #call(args)'
144
+ end
145
+
146
+ def validate_metrics!
147
+ return unless metrics_enabled?
148
+ return if total_workers.is_a?(Integer) && total_workers.positive?
149
+
150
+ raise ConfigurationError, 'metrics_path requires total_workers to be a positive Integer'
151
+ end
152
+
153
+ def validate_mount_path!
154
+ raise ConfigurationError, 'mount_path must be a String' unless mount_path.is_a?(String)
155
+ return if mount_path.empty?
156
+
157
+ raise ConfigurationError, 'mount_path must start with "/" or be empty' unless mount_path.start_with?('/')
158
+ raise ConfigurationError, 'mount_path must not end with "/"' if mount_path.end_with?('/')
159
+ raise ConfigurationError, 'mount_path must not contain control characters' if mount_path.match?(/[[:cntrl:]]/)
160
+ raise ConfigurationError, 'mount_path must not contain whitespace' if mount_path.match?(/\s/)
161
+ end
162
+
163
+ def validate_logger!
164
+ return if logger.nil?
165
+ return if logger.respond_to?(:warn) && logger.respond_to?(:error)
166
+
167
+ raise ConfigurationError, 'logger must respond to #warn and #error'
168
+ end
169
+ end
170
+ end
171
+ end
172
+ 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,71 @@
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
+ def initialize(snapshot, serializer, metrics_reader: nil)
13
+ @snapshot = snapshot
14
+ @serializer = serializer
15
+ @metrics_reader = metrics_reader
16
+ @mutex = Mutex.new
17
+ @cached_version = nil
18
+ @cached_frame = nil
19
+ @closed = false
20
+ end
21
+
22
+ def current_version
23
+ @mutex.synchronize { raise ClosedError, 'event hub is closed' if @closed }
24
+ @snapshot.data_version
25
+ end
26
+
27
+ def frame_for(version)
28
+ @mutex.synchronize do
29
+ raise ClosedError, 'event hub is closed' if @closed
30
+ return @cached_frame if @cached_version == version && @cached_frame
31
+
32
+ refresh_frame_locked!
33
+ @cached_frame
34
+ end
35
+ end
36
+
37
+ def initial_frame
38
+ @mutex.synchronize do
39
+ raise ClosedError, 'event hub is closed' if @closed
40
+
41
+ refresh_frame_locked!
42
+ [@cached_version, @cached_frame]
43
+ end
44
+ end
45
+
46
+ def close
47
+ @mutex.synchronize do
48
+ @closed = true
49
+ @cached_frame = nil
50
+ @cached_version = nil
51
+ end
52
+ self
53
+ end
54
+
55
+ def closed?
56
+ @mutex.synchronize { @closed }
57
+ end
58
+
59
+ private
60
+
61
+ def refresh_frame_locked!
62
+ overview = @snapshot.overview(force: true)
63
+ metrics = @metrics_reader&.aggregated
64
+ payload = @serializer.overview(overview, metrics)
65
+ @cached_version = payload.fetch(:data_version)
66
+ @cached_frame = "event: overview\ndata: #{JSON.generate(payload)}\n\n"
67
+ end
68
+ end
69
+ end
70
+ end
71
+ 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,107 @@
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
+ BASE_SECURITY_HEADERS = {
19
+ 'x-content-type-options' => 'nosniff',
20
+ 'referrer-policy' => 'no-referrer',
21
+ 'cross-origin-resource-policy' => 'same-origin'
22
+ }.freeze
23
+
24
+ HTML_SECURITY_HEADERS = BASE_SECURITY_HEADERS.merge(
25
+ 'x-frame-options' => 'DENY',
26
+ 'content-security-policy' =>
27
+ "default-src 'none'; " \
28
+ "script-src 'self'; " \
29
+ "style-src 'self'; " \
30
+ "img-src 'self' data:; " \
31
+ "connect-src 'self'; " \
32
+ "frame-ancestors 'none'; " \
33
+ "base-uri 'none'; " \
34
+ "form-action 'none'"
35
+ ).freeze
36
+
37
+ UNAUTHORIZED_BODY = JSON.generate(error: 'unauthorized').freeze
38
+ NOT_FOUND_BODY = JSON.generate(error: 'not_found').freeze
39
+ BAD_REQUEST_BODY = JSON.generate(error: 'invalid_request').freeze
40
+ UNAVAILABLE_BODY = JSON.generate(error: 'service_unavailable').freeze
41
+ INTERNAL_ERROR_BODY = JSON.generate(error: 'internal_error').freeze
42
+ EVENT_STREAM_TYPE = 'text/event-stream; charset=utf-8'
43
+
44
+ def sse(body)
45
+ [200, sse_headers, body]
46
+ end
47
+
48
+ def json(payload, status: 200)
49
+ [status, no_store_headers(JSON_TYPE), [JSON.generate(payload)]]
50
+ end
51
+
52
+ def html(body)
53
+ [200, html_headers, [body]]
54
+ end
55
+
56
+ def javascript(body)
57
+ [200, asset_headers(JAVASCRIPT_TYPE), [body]]
58
+ end
59
+
60
+ def stylesheet(body)
61
+ [200, asset_headers(CSS_TYPE), [body]]
62
+ end
63
+
64
+ def unauthorized
65
+ [401, no_store_headers(JSON_TYPE), [UNAUTHORIZED_BODY]]
66
+ end
67
+
68
+ def not_found
69
+ [404, no_store_headers(JSON_TYPE), [NOT_FOUND_BODY]]
70
+ end
71
+
72
+ def bad_request(message = nil)
73
+ body = message.nil? ? BAD_REQUEST_BODY : JSON.generate(error: 'invalid_request', message: message)
74
+ [400, no_store_headers(JSON_TYPE), [body]]
75
+ end
76
+
77
+ def unavailable
78
+ [503, no_store_headers(JSON_TYPE), [UNAVAILABLE_BODY]]
79
+ end
80
+
81
+ def internal_error
82
+ [500, no_store_headers(JSON_TYPE), [INTERNAL_ERROR_BODY]]
83
+ end
84
+
85
+ def no_store_headers(content_type)
86
+ {'content-type' => content_type, 'cache-control' => NO_STORE}.merge(BASE_SECURITY_HEADERS)
87
+ end
88
+
89
+ def html_headers
90
+ {'content-type' => HTML_TYPE, 'cache-control' => NO_STORE}.merge(HTML_SECURITY_HEADERS)
91
+ end
92
+
93
+ def asset_headers(content_type)
94
+ {'content-type' => content_type, 'cache-control' => ASSET_CACHE}.merge(BASE_SECURITY_HEADERS)
95
+ end
96
+
97
+ def sse_headers
98
+ {
99
+ 'content-type' => EVENT_STREAM_TYPE,
100
+ 'cache-control' => 'no-cache, no-transform',
101
+ 'x-accel-buffering' => 'no'
102
+ }.merge(BASE_SECURITY_HEADERS)
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,32 @@
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
+ ALLOWED_METHODS = %w[GET HEAD].freeze
23
+
24
+ def match(env)
25
+ return unless ALLOWED_METHODS.include?(env['REQUEST_METHOD'])
26
+
27
+ GET_ROUTES[env['PATH_INFO'] || '/']
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end