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.
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 [Async](https://github.com/socketry/async) ecosystem. Built for [Falcon](https://github.com/socketry/falcon), works with any Async app.
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
- - **Cron & interval scheduling** on a single event loop with a min-heap
6
- - **Dynamic job queue** backed by SQLite, with delayed jobs (`perform_in` / `perform_at`)
7
- - **Cross-process wake-ups** over Unix domain sockets — web workers can enqueue and instantly wake background workers
8
- - **Multi-process safe** — deterministic worker sharding, no duplicate execution
9
- - **Per-job timeouts**, skip-on-overlap, startup jitter, optional metrics
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
+ ![Threads vs fibers under different Ruby web servers](docs/fibers-vs-threads.svg)
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
- - Ruby >= 3.3
14
- - `async ~> 2.0`, `fugit ~> 1.0`
15
- - `sqlite3 ~> 2.0` (optional, storage)
16
- - `async-utilization >= 0.3, < 0.5` (optional, for metrics)
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
- gem "sqlite3", "~> 2.0" # optional
24
- gem "async-utilization", ">= 0.3", "< 0.5" # optional
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
- ## ➡️ [Get Started](docs/GET_STARTED.md)
55
+ ---
56
+
57
+ ## ➡️ [Get started](docs/GET_STARTED.md)
28
58
 
29
- Full setup walkthrough: schedule config, Falcon integration, Docker, queue, delayed jobs.
59
+ A four-step walkthrough: schedule config, Falcon integration, Docker, queue,
60
+ delayed jobs.
30
61
 
31
62
  ---
32
63
 
33
- ## Quick Look
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 | Description |
63
- |---|---|
64
- | `class` | Job class — must include `Async::Background::Job` |
65
- | `every` / `cron` | One of: interval in seconds, or cron expression |
66
- | `timeout` | Max execution time in seconds (default: 30) |
67
- | `worker` | Pin to a specific worker. Default: `crc32(name) % total_workers` |
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
- ### Docker: SQLite requires a named volume
74
-
75
- The SQLite database **must not** live on Docker's `overlay2` filesystem. The `overlay2` driver breaks coherence between `write()` and `mmap()`, which corrupts SQLite WAL under concurrent access.
76
-
77
- ```yaml
78
- # docker-compose.yml
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()`.** The gem opens connections lazily after fork, but if you create a `Queue::Store` manually for schema setup, close it before forking:
93
-
94
- ```ruby
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.** Interval jobs use `CLOCK_MONOTONIC` (immune to NTP drift). Cron jobs use wall-clock time, because "every day at 3am" needs to mean 3am.
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 ─► build_heap ─► MinHeap<Entry> ─► scheduler loop ─► Semaphore ─► run_job
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) Consumer (background worker)
115
-
116
-
117
- Queue::Client Queue::Store#fetch
118
- push / push_in / push_at (run_at <= now)
119
-
120
-
121
- Queue::Store ──── SQLite (jobs) ──── SocketWaker
122
-
123
- └───────► SocketNotifier ───────────────┘
124
- (UNIX socket wake-up, ~80µs)
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
- A fresh database still self-initializes on first use for local development, but explicit
146
- migration is the production path. For an existing queue, finish or stop 0.7.1 producers/workers,
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
- It adds four compact indexes: cursor-sorted `done` / `failed` history plus separate
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` (`>= 0.3`, `< 0.5`). The
165
- background worker remains fully functional when that gem is absent. With it installed, each
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
- `Metrics.read_all` returns `[]` until the optional gem is installed and a worker has created
179
- the file, so an observer can render an unavailable state without rescuing `LoadError`. Its
180
- snapshot is lock-free best effort: cumulative fields (`total_runs`, `total_successes`,
181
- `total_failures`, `total_timeouts`, `total_skips`) are counters; `active_jobs`,
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.
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Async
4
4
  module Background
5
- VERSION = '1.0.0'
5
+ VERSION = '1.0.1'
6
6
  end
7
7
  end
@@ -6,7 +6,8 @@ module Async
6
6
  class App
7
7
  def initialize(config)
8
8
  @config = config.validate!
9
- @auth = Auth.new(@config.auth)
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
- return if mount_path.is_a?(String)
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 a String'
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
- 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)
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
- @condition = ConditionVariable.new
70
- @subscribers = {}
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 subscribe
78
- subscription = Subscription.new
79
- frame, data_version = current_overview
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
- @subscribers[subscription.object_id] = subscription
85
- @last_data_version ||= data_version
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 unsubscribe(subscription)
37
+ def initial_frame
94
38
  @mutex.synchronize do
95
- @subscribers.delete(subscription.object_id)
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
- subscribers = @subscribers.values
110
- @subscribers.clear
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
- 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
55
+ def closed?
56
+ @mutex.synchronize { @closed }
151
57
  end
152
58
 
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
59
+ private
168
60
 
169
- def current_overview
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
- ["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
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