async-background 1.0.0 → 1.0.2
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 +4 -4
- data/CHANGELOG.md +405 -276
- data/README.md +91 -109
- data/lib/async/background/metrics.rb +1 -3
- data/lib/async/background/queue/socket_notifier.rb +44 -13
- data/lib/async/background/queue/socket_waker.rb +9 -6
- data/lib/async/background/queue/sql.rb +2 -1
- data/lib/async/background/queue/store.rb +85 -45
- data/lib/async/background/runner.rb +4 -14
- data/lib/async/background/version.rb +1 -1
- data/lib/async/background/web/app.rb +39 -18
- data/lib/async/background/web/auth.rb +7 -2
- data/lib/async/background/web/configuration.rb +17 -3
- data/lib/async/background/web/event_hub.rb +25 -148
- data/lib/async/background/web/response.rb +31 -9
- data/lib/async/background/web/router.rb +3 -1
- data/lib/async/background/web/stream.rb +54 -15
- metadata +3 -3
|
@@ -47,11 +47,7 @@ module Async
|
|
|
47
47
|
@total_workers = total_workers
|
|
48
48
|
@running = true
|
|
49
49
|
@shutdown = ::Async::Condition.new
|
|
50
|
-
@metrics = Metrics.new(
|
|
51
|
-
worker_index: worker_index,
|
|
52
|
-
total_workers: total_workers,
|
|
53
|
-
shm_path: metrics_shm_path
|
|
54
|
-
)
|
|
50
|
+
@metrics = Metrics.new(worker_index: worker_index, total_workers: total_workers, shm_path: metrics_shm_path)
|
|
55
51
|
logger.info { "Async::Background worker_index=#{worker_index}/#{total_workers}, job_count=#{job_count}" }
|
|
56
52
|
|
|
57
53
|
@drain_barrier = ::Async::Barrier.new
|
|
@@ -91,7 +87,7 @@ module Async
|
|
|
91
87
|
# continues independently in its own Async task.
|
|
92
88
|
return shutdown.wait if heap.empty? && @listen_queue
|
|
93
89
|
|
|
94
|
-
|
|
90
|
+
while running?
|
|
95
91
|
entry = heap.peek
|
|
96
92
|
break unless entry
|
|
97
93
|
|
|
@@ -123,12 +119,7 @@ module Async
|
|
|
123
119
|
end
|
|
124
120
|
|
|
125
121
|
def dispatch_entry(entry)
|
|
126
|
-
|
|
127
|
-
skip_entry(entry)
|
|
128
|
-
else
|
|
129
|
-
execute_entry(entry)
|
|
130
|
-
end
|
|
131
|
-
|
|
122
|
+
entry.running ? skip_entry(entry) : execute_entry(entry)
|
|
132
123
|
entry.reschedule(monotonic_now)
|
|
133
124
|
heap.replace_top(entry)
|
|
134
125
|
end
|
|
@@ -182,12 +173,11 @@ module Async
|
|
|
182
173
|
|
|
183
174
|
def start_signal_watcher(task)
|
|
184
175
|
task.async(transient: true) do
|
|
185
|
-
|
|
176
|
+
while running?
|
|
186
177
|
@signal_r.wait_readable
|
|
187
178
|
@signal_r.read_nonblock(256) rescue nil
|
|
188
179
|
shutdown.signal
|
|
189
180
|
@queue_waker&.signal
|
|
190
|
-
break unless running?
|
|
191
181
|
end
|
|
192
182
|
end
|
|
193
183
|
end
|
|
@@ -6,7 +6,8 @@ module Async
|
|
|
6
6
|
class App
|
|
7
7
|
def initialize(config)
|
|
8
8
|
@config = config.validate!
|
|
9
|
-
@
|
|
9
|
+
@logger = @config.logger
|
|
10
|
+
@auth = Auth.new(@config.auth, logger: @logger)
|
|
10
11
|
@snapshot = Snapshot.new(path: @config.queue_path, counts_cache_ttl: @config.counts_cache_ttl).open!
|
|
11
12
|
@metrics_reader = build_metrics_reader
|
|
12
13
|
@serializer = Serializer.new(@config)
|
|
@@ -15,30 +16,45 @@ module Async
|
|
|
15
16
|
end
|
|
16
17
|
|
|
17
18
|
def call(env)
|
|
19
|
+
head = env['REQUEST_METHOD'] == 'HEAD'
|
|
20
|
+
response = handle(env, head: head)
|
|
21
|
+
return response unless head
|
|
22
|
+
|
|
23
|
+
status, headers, _body = response
|
|
24
|
+
[status, headers, []]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def close
|
|
28
|
+
@event_hub&.close
|
|
29
|
+
@snapshot.close
|
|
30
|
+
self
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def handle(env, head:)
|
|
18
36
|
return Response.unauthorized unless @auth.authorized?(env)
|
|
19
37
|
|
|
20
38
|
route = @router.match(env)
|
|
21
39
|
return Response.not_found unless route
|
|
22
40
|
|
|
41
|
+
if head && route == :stream
|
|
42
|
+
return @config.transport == :sse ? [200, Response.sse_headers, []] : Response.not_found
|
|
43
|
+
end
|
|
44
|
+
|
|
23
45
|
dispatch(route, env)
|
|
24
46
|
rescue RequestError => error
|
|
25
47
|
Response.bad_request(error.message)
|
|
26
48
|
rescue UnavailableError, ClosedError
|
|
27
49
|
Response.unavailable
|
|
28
|
-
rescue StandardError
|
|
50
|
+
rescue StandardError => error
|
|
29
51
|
# Do not turn internal class names, paths or database errors into an
|
|
30
|
-
# unauthenticated information disclosure channel
|
|
52
|
+
# unauthenticated information disclosure channel — but do surface
|
|
53
|
+
# them to the operator via the configured logger.
|
|
54
|
+
log_internal_error(env, error)
|
|
31
55
|
Response.internal_error
|
|
32
56
|
end
|
|
33
57
|
|
|
34
|
-
def close
|
|
35
|
-
@event_hub&.close
|
|
36
|
-
@snapshot.close
|
|
37
|
-
self
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
private
|
|
41
|
-
|
|
42
58
|
def build_metrics_reader
|
|
43
59
|
return unless @config.metrics_enabled?
|
|
44
60
|
|
|
@@ -48,12 +64,7 @@ module Async
|
|
|
48
64
|
def build_event_hub
|
|
49
65
|
return unless @config.transport == :sse
|
|
50
66
|
|
|
51
|
-
EventHub.new(
|
|
52
|
-
@snapshot,
|
|
53
|
-
@serializer,
|
|
54
|
-
metrics_reader: @metrics_reader,
|
|
55
|
-
poll_seconds: @config.stream_poll_seconds
|
|
56
|
-
)
|
|
67
|
+
EventHub.new(@snapshot, @serializer, metrics_reader: @metrics_reader)
|
|
57
68
|
end
|
|
58
69
|
|
|
59
70
|
def dispatch(route, env)
|
|
@@ -128,10 +139,20 @@ module Async
|
|
|
128
139
|
Stream.new(
|
|
129
140
|
@event_hub,
|
|
130
141
|
heartbeat_seconds: @config.stream_heartbeat_seconds,
|
|
131
|
-
retry_ms: @config.stream_retry_ms
|
|
142
|
+
retry_ms: @config.stream_retry_ms,
|
|
143
|
+
poll_seconds: @config.stream_poll_seconds,
|
|
144
|
+
logger: @logger
|
|
132
145
|
)
|
|
133
146
|
)
|
|
134
147
|
end
|
|
148
|
+
|
|
149
|
+
def log_internal_error(env, error)
|
|
150
|
+
@logger&.error(
|
|
151
|
+
"[async-background-web] internal error on " \
|
|
152
|
+
"#{env['REQUEST_METHOD']} #{env['PATH_INFO']}: " \
|
|
153
|
+
"#{error.class}: #{error.message}"
|
|
154
|
+
)
|
|
155
|
+
end
|
|
135
156
|
end
|
|
136
157
|
end
|
|
137
158
|
end
|
|
@@ -4,13 +4,18 @@ module Async
|
|
|
4
4
|
module Background
|
|
5
5
|
module Web
|
|
6
6
|
class Auth
|
|
7
|
-
def initialize(callable)
|
|
7
|
+
def initialize(callable, logger: nil)
|
|
8
8
|
@callable = callable
|
|
9
|
+
@logger = logger
|
|
9
10
|
end
|
|
10
11
|
|
|
11
12
|
def authorized?(env)
|
|
12
13
|
!!@callable.call(env)
|
|
13
|
-
rescue StandardError
|
|
14
|
+
rescue StandardError => error
|
|
15
|
+
@logger&.warn(
|
|
16
|
+
"[async-background-web] auth callable raised: " \
|
|
17
|
+
"#{error.class}: #{error.message}"
|
|
18
|
+
)
|
|
14
19
|
false
|
|
15
20
|
end
|
|
16
21
|
end
|
|
@@ -31,7 +31,8 @@ module Async
|
|
|
31
31
|
:stream_heartbeat_seconds,
|
|
32
32
|
:stream_retry_ms,
|
|
33
33
|
:title,
|
|
34
|
-
:mount_path
|
|
34
|
+
:mount_path,
|
|
35
|
+
:logger
|
|
35
36
|
|
|
36
37
|
def initialize
|
|
37
38
|
@queue_path = Queue::Store.default_path
|
|
@@ -49,6 +50,7 @@ module Async
|
|
|
49
50
|
@stream_retry_ms = DEFAULT_STREAM_RETRY_MS
|
|
50
51
|
@title = 'Async::Background'
|
|
51
52
|
@mount_path = ''
|
|
53
|
+
@logger = nil
|
|
52
54
|
end
|
|
53
55
|
|
|
54
56
|
def validate!
|
|
@@ -62,6 +64,7 @@ module Async
|
|
|
62
64
|
validate_redactor!
|
|
63
65
|
validate_metrics!
|
|
64
66
|
validate_mount_path!
|
|
67
|
+
validate_logger!
|
|
65
68
|
self
|
|
66
69
|
end
|
|
67
70
|
|
|
@@ -148,9 +151,20 @@ module Async
|
|
|
148
151
|
end
|
|
149
152
|
|
|
150
153
|
def validate_mount_path!
|
|
151
|
-
|
|
154
|
+
raise ConfigurationError, 'mount_path must be a String' unless mount_path.is_a?(String)
|
|
155
|
+
return if mount_path.empty?
|
|
152
156
|
|
|
153
|
-
raise ConfigurationError, 'mount_path must be
|
|
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'
|
|
154
168
|
end
|
|
155
169
|
end
|
|
156
170
|
end
|
|
@@ -9,184 +9,61 @@ module Async
|
|
|
9
9
|
HEARTBEAT_FRAME = ":keepalive\n\n"
|
|
10
10
|
UNAVAILABLE_FRAME = "event: unavailable\ndata: #{JSON.generate(error: 'unavailable')}\n\n".freeze
|
|
11
11
|
|
|
12
|
-
|
|
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)
|
|
12
|
+
def initialize(snapshot, serializer, metrics_reader: nil)
|
|
63
13
|
@snapshot = snapshot
|
|
64
14
|
@serializer = serializer
|
|
65
15
|
@metrics_reader = metrics_reader
|
|
66
|
-
@poll_seconds = poll_seconds
|
|
67
|
-
@sleeper = sleeper || ->(seconds) { sleep(seconds) }
|
|
68
16
|
@mutex = Mutex.new
|
|
69
|
-
@
|
|
70
|
-
@
|
|
17
|
+
@cached_version = nil
|
|
18
|
+
@cached_frame = nil
|
|
71
19
|
@closed = false
|
|
72
|
-
@monitor = nil
|
|
73
|
-
@last_data_version = nil
|
|
74
|
-
@unavailable = false
|
|
75
20
|
end
|
|
76
21
|
|
|
77
|
-
def
|
|
78
|
-
|
|
79
|
-
|
|
22
|
+
def current_version
|
|
23
|
+
@mutex.synchronize { raise ClosedError, 'event hub is closed' if @closed }
|
|
24
|
+
@snapshot.data_version
|
|
25
|
+
end
|
|
80
26
|
|
|
27
|
+
def frame_for(version)
|
|
81
28
|
@mutex.synchronize do
|
|
82
29
|
raise ClosedError, 'event hub is closed' if @closed
|
|
30
|
+
return @cached_frame if @cached_version == version && @cached_frame
|
|
83
31
|
|
|
84
|
-
|
|
85
|
-
@
|
|
86
|
-
start_monitor_unless_running!
|
|
87
|
-
@condition.signal
|
|
32
|
+
refresh_frame_locked!
|
|
33
|
+
@cached_frame
|
|
88
34
|
end
|
|
89
|
-
|
|
90
|
-
[subscription, frame]
|
|
91
35
|
end
|
|
92
36
|
|
|
93
|
-
def
|
|
37
|
+
def initial_frame
|
|
94
38
|
@mutex.synchronize do
|
|
95
|
-
@
|
|
39
|
+
raise ClosedError, 'event hub is closed' if @closed
|
|
40
|
+
|
|
41
|
+
refresh_frame_locked!
|
|
42
|
+
[@cached_version, @cached_frame]
|
|
96
43
|
end
|
|
97
|
-
subscription.close
|
|
98
|
-
nil
|
|
99
44
|
end
|
|
100
45
|
|
|
101
46
|
def close
|
|
102
|
-
monitor = nil
|
|
103
|
-
subscribers = nil
|
|
104
|
-
|
|
105
47
|
@mutex.synchronize do
|
|
106
|
-
return if @closed
|
|
107
|
-
|
|
108
48
|
@closed = true
|
|
109
|
-
|
|
110
|
-
@
|
|
111
|
-
monitor = @monitor
|
|
112
|
-
@condition.broadcast
|
|
49
|
+
@cached_frame = nil
|
|
50
|
+
@cached_version = nil
|
|
113
51
|
end
|
|
114
|
-
|
|
115
|
-
subscribers.each(&:close)
|
|
116
|
-
monitor&.join(1) unless monitor == Thread.current
|
|
117
|
-
nil
|
|
52
|
+
self
|
|
118
53
|
end
|
|
119
54
|
|
|
120
|
-
|
|
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
|
|
55
|
+
def closed?
|
|
56
|
+
@mutex.synchronize { @closed }
|
|
151
57
|
end
|
|
152
58
|
|
|
153
|
-
|
|
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
|
|
59
|
+
private
|
|
168
60
|
|
|
169
|
-
def
|
|
61
|
+
def refresh_frame_locked!
|
|
170
62
|
overview = @snapshot.overview(force: true)
|
|
171
63
|
metrics = @metrics_reader&.aggregated
|
|
172
64
|
payload = @serializer.overview(overview, metrics)
|
|
173
|
-
|
|
174
|
-
|
|
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
|
|
65
|
+
@cached_version = payload.fetch(:data_version)
|
|
66
|
+
@cached_frame = "event: overview\ndata: #{JSON.generate(payload)}\n\n"
|
|
190
67
|
end
|
|
191
68
|
end
|
|
192
69
|
end
|
|
@@ -15,9 +15,27 @@ module Async
|
|
|
15
15
|
CSS_TYPE = 'text/css; charset=utf-8'
|
|
16
16
|
NO_STORE = 'no-store'
|
|
17
17
|
ASSET_CACHE = 'public, max-age=31536000, immutable'
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
21
39
|
BAD_REQUEST_BODY = JSON.generate(error: 'invalid_request').freeze
|
|
22
40
|
UNAVAILABLE_BODY = JSON.generate(error: 'service_unavailable').freeze
|
|
23
41
|
INTERNAL_ERROR_BODY = JSON.generate(error: 'internal_error').freeze
|
|
@@ -32,7 +50,7 @@ module Async
|
|
|
32
50
|
end
|
|
33
51
|
|
|
34
52
|
def html(body)
|
|
35
|
-
[200,
|
|
53
|
+
[200, html_headers, [body]]
|
|
36
54
|
end
|
|
37
55
|
|
|
38
56
|
def javascript(body)
|
|
@@ -44,11 +62,11 @@ module Async
|
|
|
44
62
|
end
|
|
45
63
|
|
|
46
64
|
def unauthorized
|
|
47
|
-
[401, no_store_headers(
|
|
65
|
+
[401, no_store_headers(JSON_TYPE), [UNAUTHORIZED_BODY]]
|
|
48
66
|
end
|
|
49
67
|
|
|
50
68
|
def not_found
|
|
51
|
-
[404, no_store_headers(
|
|
69
|
+
[404, no_store_headers(JSON_TYPE), [NOT_FOUND_BODY]]
|
|
52
70
|
end
|
|
53
71
|
|
|
54
72
|
def bad_request(message = nil)
|
|
@@ -65,11 +83,15 @@ module Async
|
|
|
65
83
|
end
|
|
66
84
|
|
|
67
85
|
def no_store_headers(content_type)
|
|
68
|
-
{'content-type' => content_type, 'cache-control' => NO_STORE}
|
|
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)
|
|
69
91
|
end
|
|
70
92
|
|
|
71
93
|
def asset_headers(content_type)
|
|
72
|
-
{'content-type' => content_type, 'cache-control' => ASSET_CACHE}
|
|
94
|
+
{'content-type' => content_type, 'cache-control' => ASSET_CACHE}.merge(BASE_SECURITY_HEADERS)
|
|
73
95
|
end
|
|
74
96
|
|
|
75
97
|
def sse_headers
|
|
@@ -77,7 +99,7 @@ module Async
|
|
|
77
99
|
'content-type' => EVENT_STREAM_TYPE,
|
|
78
100
|
'cache-control' => 'no-cache, no-transform',
|
|
79
101
|
'x-accel-buffering' => 'no'
|
|
80
|
-
}
|
|
102
|
+
}.merge(BASE_SECURITY_HEADERS)
|
|
81
103
|
end
|
|
82
104
|
end
|
|
83
105
|
end
|
|
@@ -19,8 +19,10 @@ module Async
|
|
|
19
19
|
'/api/stream' => :stream
|
|
20
20
|
}.freeze
|
|
21
21
|
|
|
22
|
+
ALLOWED_METHODS = %w[GET HEAD].freeze
|
|
23
|
+
|
|
22
24
|
def match(env)
|
|
23
|
-
return unless env['REQUEST_METHOD']
|
|
25
|
+
return unless ALLOWED_METHODS.include?(env['REQUEST_METHOD'])
|
|
24
26
|
|
|
25
27
|
GET_ROUTES[env['PATH_INFO'] || '/']
|
|
26
28
|
end
|
|
@@ -1,41 +1,80 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative '../clock'
|
|
4
|
+
|
|
3
5
|
module Async
|
|
4
6
|
module Background
|
|
5
7
|
module Web
|
|
6
8
|
class Stream
|
|
7
|
-
|
|
9
|
+
include Clock
|
|
10
|
+
|
|
11
|
+
def initialize(hub, heartbeat_seconds:, retry_ms:, poll_seconds:, logger: nil)
|
|
8
12
|
@hub = hub
|
|
9
13
|
@heartbeat_seconds = heartbeat_seconds
|
|
10
14
|
@retry_ms = retry_ms
|
|
15
|
+
@poll_seconds = poll_seconds
|
|
16
|
+
@logger = logger
|
|
11
17
|
end
|
|
12
18
|
|
|
13
19
|
def each
|
|
14
|
-
subscription, initial_frame = @hub.subscribe
|
|
15
20
|
yield "retry: #{@retry_ms}\n\n"
|
|
16
|
-
|
|
21
|
+
|
|
22
|
+
version, frame = initial_state
|
|
23
|
+
if version.nil?
|
|
24
|
+
yield EventHub::UNAVAILABLE_FRAME
|
|
25
|
+
return
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
yield frame
|
|
29
|
+
last_yield = monotonic_now
|
|
30
|
+
unavailable_announced = false
|
|
17
31
|
|
|
18
32
|
loop do
|
|
19
|
-
|
|
20
|
-
|
|
33
|
+
sleep_for_poll
|
|
34
|
+
|
|
35
|
+
begin
|
|
36
|
+
new_version = @hub.current_version
|
|
21
37
|
|
|
22
|
-
|
|
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
|
|
23
57
|
end
|
|
24
|
-
rescue Errno::EPIPE, IOError
|
|
58
|
+
rescue Errno::EPIPE, Errno::ECONNRESET, IOError
|
|
25
59
|
nil
|
|
26
|
-
rescue
|
|
27
|
-
|
|
60
|
+
rescue StandardError => error
|
|
61
|
+
@logger&.error(
|
|
62
|
+
"[async-background-web] SSE stream terminated: " \
|
|
63
|
+
"#{error.class}: #{error.message}"
|
|
64
|
+
)
|
|
28
65
|
nil
|
|
29
|
-
ensure
|
|
30
|
-
@hub.unsubscribe(subscription) if subscription
|
|
31
66
|
end
|
|
32
67
|
|
|
33
68
|
private
|
|
34
69
|
|
|
35
|
-
def
|
|
36
|
-
|
|
37
|
-
rescue
|
|
38
|
-
nil
|
|
70
|
+
def initial_state
|
|
71
|
+
@hub.initial_frame
|
|
72
|
+
rescue ClosedError, UnavailableError
|
|
73
|
+
[nil, nil]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def sleep_for_poll
|
|
77
|
+
sleep(@poll_seconds)
|
|
39
78
|
end
|
|
40
79
|
end
|
|
41
80
|
end
|
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: 1.0.
|
|
4
|
+
version: 1.0.2
|
|
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-
|
|
11
|
+
date: 2026-08-15 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: async
|
|
@@ -198,7 +198,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
198
198
|
- !ruby/object:Gem::Version
|
|
199
199
|
version: '0'
|
|
200
200
|
requirements: []
|
|
201
|
-
rubygems_version: 3.
|
|
201
|
+
rubygems_version: 3.4.22
|
|
202
202
|
signing_key:
|
|
203
203
|
specification_version: 4
|
|
204
204
|
summary: Lightweight heap-based cron/interval scheduler for Async.
|