async-background 1.0.0 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +319 -276
- data/README.md +91 -109
- 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 +2 -2
data/README.md
CHANGED
|
@@ -1,36 +1,67 @@
|
|
|
1
1
|
# Async::Background
|
|
2
2
|
|
|
3
|
-
A lightweight cron, interval, and job-queue scheduler for Ruby's
|
|
3
|
+
A lightweight cron, interval, and job-queue scheduler for Ruby's
|
|
4
|
+
[Async](https://github.com/socketry/async) ecosystem. Built for
|
|
5
|
+
[Falcon](https://github.com/socketry/falcon), works with any Async app.
|
|
6
|
+
|
|
7
|
+
- **Cron & interval scheduling** on a single event loop with a min-heap.
|
|
8
|
+
- **Dynamic job queue** backed by SQLite, with delayed jobs
|
|
9
|
+
(`perform_in` / `perform_at`).
|
|
10
|
+
- **Cross-process wake-ups** over Unix domain sockets — web workers can
|
|
11
|
+
enqueue and instantly wake background workers.
|
|
12
|
+
- **Multi-process safe** — deterministic worker sharding, no duplicate
|
|
13
|
+
execution.
|
|
14
|
+
- **Per-job timeouts**, skip-on-overlap, startup jitter, optional metrics.
|
|
4
15
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Why Async? Why fibers?
|
|
19
|
+
|
|
20
|
+
The whole gem is built around the assumption that Falcon's reactor schedules
|
|
21
|
+
many fibers on top of one OS thread per process — so the dashboard's SSE
|
|
22
|
+
stream, the cron scheduler, and the queue worker all share that one thread
|
|
23
|
+
cooperatively. A blocked fiber yields; a blocked thread doesn't.
|
|
24
|
+
|
|
25
|
+

|
|
26
|
+
|
|
27
|
+
That's also why the dashboard (since 1.0.1) runs its SSE loop entirely inside
|
|
28
|
+
the request fiber: zero extra threads, zero `ConditionVariable`, ~4 KB per
|
|
29
|
+
open tab.
|
|
30
|
+
|
|
31
|
+
---
|
|
10
32
|
|
|
11
33
|
## Requirements
|
|
12
34
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
35
|
+
| Dependency | Version | Required? |
|
|
36
|
+
| -------------------- | ---------------- | -------------------- |
|
|
37
|
+
| Ruby | `>= 3.3` | yes |
|
|
38
|
+
| `async` | `~> 2.0` | yes |
|
|
39
|
+
| `fugit` | `~> 1.0` | yes |
|
|
40
|
+
| `sqlite3` | `~> 2.0` | for the queue & dashboard |
|
|
41
|
+
| `async-utilization` | `>= 0.3, < 0.5` | for metrics |
|
|
42
|
+
|
|
43
|
+
---
|
|
17
44
|
|
|
18
45
|
## Install
|
|
19
46
|
|
|
20
47
|
```ruby
|
|
21
48
|
# Gemfile
|
|
22
49
|
gem "async-background"
|
|
23
|
-
|
|
24
|
-
gem "
|
|
50
|
+
|
|
51
|
+
gem "sqlite3", "~> 2.0" # if you use the queue or dashboard
|
|
52
|
+
gem "async-utilization", ">= 0.3", "< 0.5" # if you want worker metrics
|
|
25
53
|
```
|
|
26
54
|
|
|
27
|
-
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## ➡️ [Get started](docs/GET_STARTED.md)
|
|
28
58
|
|
|
29
|
-
|
|
59
|
+
A four-step walkthrough: schedule config, Falcon integration, Docker, queue,
|
|
60
|
+
delayed jobs.
|
|
30
61
|
|
|
31
62
|
---
|
|
32
63
|
|
|
33
|
-
## Quick
|
|
64
|
+
## Quick look
|
|
34
65
|
|
|
35
66
|
```ruby
|
|
36
67
|
class SendEmailJob
|
|
@@ -59,114 +90,71 @@ daily_report:
|
|
|
59
90
|
timeout: 120
|
|
60
91
|
```
|
|
61
92
|
|
|
62
|
-
| Key
|
|
63
|
-
|
|
64
|
-
| `class`
|
|
65
|
-
| `every` / `cron` |
|
|
66
|
-
| `timeout`
|
|
67
|
-
| `worker`
|
|
93
|
+
| Key | Description |
|
|
94
|
+
| ---------------- | --------------------------------------------------------------- |
|
|
95
|
+
| `class` | Job class — must include `Async::Background::Job`. |
|
|
96
|
+
| `every` / `cron` | Interval in seconds, or a cron expression. Exactly one. |
|
|
97
|
+
| `timeout` | Max execution time in seconds. Default: 30. |
|
|
98
|
+
| `worker` | Pin to a specific worker. Default: `crc32(name) % total_workers`. |
|
|
68
99
|
|
|
69
100
|
---
|
|
70
101
|
|
|
71
102
|
## Gotchas
|
|
72
103
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
#
|
|
79
|
-
services:
|
|
80
|
-
app:
|
|
81
|
-
volumes:
|
|
82
|
-
- queue-data:/app/tmp/queue # ← named volume, NOT overlay2
|
|
83
|
-
|
|
84
|
-
volumes:
|
|
85
|
-
queue-data:
|
|
86
|
-
```
|
|
87
|
-
|
|
88
|
-
Without this, you will get database crashes in multi-process mode. See [Get Started → Step 3](docs/GET_STARTED.md#step-3-docker) for details. If you can't use a named volume, pass `queue_mmap: false` to disable mmap entirely.
|
|
89
|
-
|
|
90
|
-
### Other gotchas
|
|
104
|
+
**Docker + SQLite — use a named volume.**
|
|
105
|
+
SQLite's database must not live on Docker's default `overlay2` filesystem:
|
|
106
|
+
`overlay2` breaks coherence between `write()` and `mmap()`, which corrupts
|
|
107
|
+
the WAL under concurrent access. Mount the queue directory as a named volume,
|
|
108
|
+
or pass `mmap: false` to `Store.new`. See
|
|
109
|
+
[Get Started → Docker](docs/GET_STARTED.md#step-3--docker-setup).
|
|
91
110
|
|
|
92
|
-
**Don't share SQLite connections across `fork()`.**
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
Async::Background::Queue.migrate!(path: db_path) # ← once, before fork
|
|
96
|
-
# Every process opens its own Store lazily after fork.
|
|
97
|
-
```
|
|
111
|
+
**Don't share SQLite connections across `fork()`.**
|
|
112
|
+
The gem opens connections lazily after fork. If you build a `Store` manually
|
|
113
|
+
for schema setup, close it before forking.
|
|
98
114
|
|
|
99
|
-
**Two clocks, on purpose.**
|
|
115
|
+
**Two clocks, on purpose.**
|
|
116
|
+
Interval jobs use `CLOCK_MONOTONIC` so NTP drift can't fire them twice. Cron
|
|
117
|
+
jobs use wall-clock time, because "every day at 3am" needs to mean 3am.
|
|
100
118
|
|
|
101
119
|
---
|
|
102
120
|
|
|
103
121
|
## How it works
|
|
104
122
|
|
|
123
|
+
A single Async task sleeps until the next entry is due, then dispatches it
|
|
124
|
+
under a semaphore that caps concurrency. Overlapping ticks are skipped and
|
|
125
|
+
rescheduled.
|
|
126
|
+
|
|
105
127
|
```
|
|
106
|
-
schedule.yml
|
|
128
|
+
schedule.yml → build_heap → MinHeap<Entry> → scheduler loop → Semaphore → run_job
|
|
107
129
|
```
|
|
108
130
|
|
|
109
|
-
A single Async task sleeps until the next entry is due, then dispatches it under a semaphore that caps concurrency. Overlapping ticks are skipped and rescheduled.
|
|
110
|
-
|
|
111
131
|
The dynamic queue runs alongside it:
|
|
112
132
|
|
|
113
133
|
```
|
|
114
|
-
Producer (web/console)
|
|
115
|
-
│
|
|
116
|
-
▼
|
|
117
|
-
Queue::Client
|
|
118
|
-
push / push_in / push_at
|
|
119
|
-
│
|
|
120
|
-
▼
|
|
121
|
-
Queue::Store ──── SQLite (jobs) ──── SocketWaker
|
|
122
|
-
│
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
```
|
|
126
|
-
|
|
127
|
-
Jobs are persisted in SQLite, so a missed wake-up is never a lost job — workers also poll every 5 seconds as a safety net.
|
|
128
|
-
|
|
129
|
-
### Queue-only workers
|
|
130
|
-
|
|
131
|
-
Recurring schedules are optional. A worker that serves only dynamic jobs starts with
|
|
132
|
-
`config_path: nil` and a `queue_socket_dir`; it does not need a placeholder schedule file.
|
|
133
|
-
A supplied schedule path stays strict and raises when the file is missing or empty.
|
|
134
|
-
|
|
135
|
-
### Schema migration during deploy
|
|
136
|
-
|
|
137
|
-
Run queue migrations once in the release/pre-deploy step, before starting new web or worker
|
|
138
|
-
processes. This serializes the schema upgrade with `BEGIN IMMEDIATE`, records the version in
|
|
139
|
-
SQLite, and avoids a first producer doing DDL under live queue traffic:
|
|
140
|
-
|
|
141
|
-
```ruby
|
|
142
|
-
Async::Background::Queue.migrate!(path: ENV.fetch("QUEUE_DB_PATH"))
|
|
134
|
+
Producer (web / console) Consumer (background worker)
|
|
135
|
+
│ │
|
|
136
|
+
▼ ▼
|
|
137
|
+
Queue::Client Queue::Store#fetch
|
|
138
|
+
push / push_in / push_at (run_at <= now)
|
|
139
|
+
│ ▲
|
|
140
|
+
▼ │
|
|
141
|
+
Queue::Store ──── SQLite (jobs) ──── SocketWaker │
|
|
142
|
+
│ ▲
|
|
143
|
+
└─────────► SocketNotifier ────────────────┘
|
|
144
|
+
(UNIX socket wake-up, ~80µs)
|
|
143
145
|
```
|
|
144
146
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
run the migration once, then start 0.7.2 processes.
|
|
148
|
-
|
|
149
|
-
### Dashboard indexes
|
|
150
|
-
|
|
151
|
-
The queue does **not** install dashboard indexes by default. They slow every enqueue even though
|
|
152
|
-
pending rows never enter terminal or in-flight read-model indexes. Enable them once before
|
|
153
|
-
mounting the dashboard:
|
|
154
|
-
|
|
155
|
-
```ruby
|
|
156
|
-
Async::Background::Queue.prepare_dashboard!(path: ENV.fetch("QUEUE_DB_PATH"))
|
|
157
|
-
```
|
|
147
|
+
Jobs are persisted in SQLite, so a missed wake-up is never a lost job —
|
|
148
|
+
workers also poll every 5 seconds as a safety net.
|
|
158
149
|
|
|
159
|
-
|
|
160
|
-
`executing` / `claimed` in-flight lists. It does not change queue behavior or rerun the core migration.
|
|
150
|
+
---
|
|
161
151
|
|
|
162
152
|
## Metrics
|
|
163
153
|
|
|
164
|
-
Metrics are an optional integration with `async-utilization
|
|
165
|
-
|
|
166
|
-
worker publishes counters to a shared-memory segment.
|
|
154
|
+
Metrics are an optional integration with `async-utilization`. With the gem
|
|
155
|
+
installed, each worker publishes counters to a shared-memory segment:
|
|
167
156
|
|
|
168
157
|
```ruby
|
|
169
|
-
runner.metrics.enabled?
|
|
170
158
|
runner.metrics.values
|
|
171
159
|
# => { total_runs: 142, total_successes: 140, total_failures: 2,
|
|
172
160
|
# total_timeouts: 0, total_skips: 5, active_jobs: 1, ... }
|
|
@@ -175,19 +163,13 @@ Async::Background::Metrics.read_all(total_workers: 2)
|
|
|
175
163
|
# => [{ worker: 1, ... }, { worker: 2, ... }]
|
|
176
164
|
```
|
|
177
165
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
`last_run_at`, and `last_duration_ms` are gauges. Fields can describe adjacent moments in time
|
|
183
|
-
rather than one globally atomic instant.
|
|
184
|
-
|
|
185
|
-
By default the file is `/tmp/async-background.shm`. Set `ASYNC_BACKGROUND_METRICS_PATH`
|
|
186
|
-
or pass `metrics_shm_path:` to `Runner.new` when another observer runs in a separate process
|
|
187
|
-
or container; both sides must see the same mounted file.
|
|
188
|
-
|
|
166
|
+
Without the gem, `runner.metrics.enabled?` is `false` and `read_all` returns
|
|
167
|
+
`[]` — no `LoadError` to rescue. Configuration and the cross-container
|
|
168
|
+
shared-memory path are covered in
|
|
169
|
+
[Get Started → Optional metrics](docs/GET_STARTED.md#appendix-optional-metrics).
|
|
189
170
|
|
|
171
|
+
---
|
|
190
172
|
|
|
191
173
|
## License
|
|
192
174
|
|
|
193
|
-
MIT
|
|
175
|
+
MIT.
|
|
@@ -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
|