async-background 0.7.2 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '095ad7028f5492c5f116a86adbbacaabe39167586405f696910e6ff799900831'
4
- data.tar.gz: 2eb448ca15863b2ae6177a8ab2f3c4d348b9ecc9453f5603ec50363879c9f50d
3
+ metadata.gz: 3d7244ccfb7156c423229ed59f36df9354d293711bab53e6a87d943743bc7f44
4
+ data.tar.gz: 84752c52033b89c5b957d39976f5d025a56215b378138eda8813c05635c5d58e
5
5
  SHA512:
6
- metadata.gz: 9533784a209703347f448b7b5a497c92415a339f4a0c3d2f808f569068f5db64a833d2ae7098b3c64d4dbab497d68da22ca3cd2381d746d552fe1bd98f2f555b
7
- data.tar.gz: 4091871c888e1b80832cd764c6c44bfb6e6ca1335f21456855e303d0469ca0127ab1e463d4e23318aabc5e171010d6b3b55764c88f7fa179e650b3d934bf8b7d
6
+ metadata.gz: 6e90dd0f6be9ff7361aba7d84e7a7cdfdd712b351938c50dc8b84358d10a20f65514880a3eac3ab2ad8c0adf5164c06f9668ead03df5161fc4482b4e27aa03ec
7
+ data.tar.gz: 1867b17975c63966aeaf437414795b3b5d9083c9871a9f6c44dc6b7280651bbf46a8c929b4751349f969ffbdf72c32aeb8c6965634d149e74a0a352e4ef958df
data/CHANGELOG.md CHANGED
@@ -1,5 +1,112 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ### Dashboard SSE hardening
6
+
7
+ - Make SSE the default dashboard transport. `:polling` remains an explicit compatibility fallback.
8
+ - Replace one `PRAGMA data_version` loop **per browser connection** with one shared `EventHub` watcher per Rack app process while at least one dashboard tab is connected. It fans complete overview snapshots to every SSE body through latest-value mailboxes, so slow tabs do not accumulate unbounded event queues.
9
+ - Use complete snapshots on connect and after a change rather than a replay log: reconnecting EventSource clients cannot miss the current queue state.
10
+ - Coalesce client-side list refreshes after a burst of changes, cancel stale list fetches on tab switches, and retain previous pages when `Load more` is used.
11
+ - Add SSE retry control, 25-second heartbeat frames, `no-cache, no-transform`, and `X-Accel-Buffering: no`. Remove the hop-by-hop `Connection` response header.
12
+ - Fingerprint asset URLs and cache immutable assets by digest, so a dashboard deploy cannot leave a browser on incompatible HTML/JS/CSS.
13
+ - Add coverage for event fan-out, latest-value coalescing, clean stream shutdown, forced overview reads, and the SSE configuration constraints.
14
+
15
+ ## 1.1.0
16
+
17
+ Server-Sent Events transport for the dashboard. Replaces HTTP polling as the recommended transport.
18
+
19
+ ### Added
20
+
21
+ - **SSE transport for the dashboard.** Set `c.transport = :sse` in `Async::Background::Web.configure` and the dashboard now uses a single long-lived `text/event-stream` connection per browser tab instead of polling `/api/overview` every 2 seconds. The browser opens `EventSource(mount_path + '/api/stream')` once; the server pushes an `overview` event whenever `PRAGMA data_version` changes, and a `:keepalive` comment frame every 30 seconds. Result: 1 HTTP connection per dashboard tab regardless of how long it stays open, instead of 30 req/min per tab.
22
+
23
+ - New module `Async::Background::Web::Stream` implements the event loop as a Rack streaming body (responds to `#each`, yields SSE frames). Holds no state across requests.
24
+ - New route `GET /api/stream` returns `200 text/event-stream` when `transport == :sse`, `404` otherwise. Subject to the same auth gate as every other endpoint.
25
+ - New `Response.sse(body)` helper sets the correct headers including `x-accel-buffering: no` (disables nginx buffering for the streaming response).
26
+ - JS client (`assets.rb`) detects `state.config.transport === 'sse'` at boot and chooses `EventSource` over `setInterval(tick, ...)`. Both transports share the same `applyOverview()` and `refreshActiveList()` handlers, so the UI behaves identically.
27
+
28
+ - **`Configuration#transport`** with default `:polling` (backward compatible) and accepted values `:polling | :sse`. Validation rejects anything else with `ConfigurationError`. The chosen transport is exposed at `/api/config` so the client knows which path to take.
29
+
30
+ ### Migration
31
+
32
+ Existing deployments keep working unchanged. To opt into SSE:
33
+
34
+ ```ruby
35
+ Async::Background::Web.configure do |c|
36
+ c.queue_path = ...
37
+ c.auth = ->(env) { ... }
38
+ c.transport = :sse
39
+ end
40
+ ```
41
+
42
+ ### Server compatibility note
43
+
44
+ SSE holds the request thread/fiber open for the lifetime of the dashboard tab. **Recommended for Falcon**, which handles long-lived connections natively via fibers. **Puma works** but each open dashboard tab holds one worker thread for its lifetime — fine for an admin dashboard with a handful of operators, problematic if many concurrent operators would starve the worker pool. **Unicorn does not work** for SSE since its blocking worker model can't hold long-lived connections without timeouts; stay on `:polling` there.
45
+
46
+ ### Backend-side polling
47
+
48
+ The server still polls `PRAGMA data_version` every 500ms inside the snapshot connection to detect changes. This is a connection-local PRAGMA call, microseconds, never hits a rate limiter. Client-facing transport is push.
49
+
50
+ ### Tests
51
+
52
+ - New `spec/async/background/web/stream_spec.rb` — covers overview event on data_version change, heartbeat after idle, graceful exit on `EPIPE`/`IOError`, error frame on `ClosedError`/`UnavailableError`.
53
+ - Extended `spec/async/background/web/app_spec.rb` — `/api/stream` returns 404 on polling default, 200 text/event-stream on `:sse`, 401 without auth.
54
+ - Extended `spec/async/background/web/configuration_spec.rb` — accepts `:sse`, rejects unknown transports.
55
+
56
+ ## 1.0.0
57
+
58
+ First stable release. The queue execution contract from 0.7.2 (claim-token CAS, lifecycle columns, barrier-based shutdown drain, per-status partial indexes, versioned migrations) is now considered the public API.
59
+
60
+ ### Features
61
+
62
+ - **Web dashboard.** Rack-mountable read-only UI under `require 'async/background/web'`. Vanilla HTML/CSS/JS, no JS framework, no npm.
63
+ - Endpoints: `GET /`, `GET /assets/app.css`, `GET /assets/app.js`, `GET /api/overview`, `GET /api/executing`, `GET /api/claimed`, `GET /api/pending`, `GET /api/done`, `GET /api/failed`, `GET /api/metrics`, `GET /api/config`.
64
+ - Default transport is JSON polling (`poll_interval_ms`, default 2000). SSE adapter for Falcon is intentionally deferred to a later release; the dashboard already coalesces work via a shared overview cache, so adding SSE later is a backward-compatible change.
65
+ - Read path runs through `Async::Background::Web::Snapshot`, which opens SQLite with `file:?mode=ro`, wraps a `Mutex` around a single shared connection, and uses one read transaction per endpoint and caches each overview as one consistent snapshot.
66
+ - Distinguishes `Executing` (`status='running' AND started_at IS NOT NULL`) from `Claimed` (`status='running' AND started_at IS NULL`).
67
+ - Overview snapshot cache for `counts_cache_ttl` seconds (default 3.0) so a busy queue does not turn the dashboard into a hot reader.
68
+ - Cursor pagination for `done`/`failed`/`pending` using `(finished_at, id)` / `(run_at, id)` tuples. Stable on ties.
69
+ - Args hidden by default (`expose_args: false`); when enabled, content runs through `redact_args`. All user content rendered through `textContent`, never `innerHTML`.
70
+ - Auth hook is **mandatory**. `Configuration#validate!` rejects an unconfigured `auth`. There is no permissive default.
71
+
72
+ - Add the optional Rack dashboard for the SQLite queue.
73
+ - Make sqlite3 an explicit runtime dependency for queue/dashboard installs.
74
+
75
+ ### Configuration
76
+
77
+ ```ruby
78
+ require 'async/background/web'
79
+
80
+ Async::Background::Queue::Store.prepare_dashboard!(path: '/var/lib/app/queue.db')
81
+
82
+ Async::Background::Web.configure do |c|
83
+ c.queue_path = '/var/lib/app/queue.db'
84
+ c.auth = ->(env) { env['warden'].user&.admin? }
85
+ c.expose_args = false
86
+ c.metrics_path = '/run/app/async-background.shm'
87
+ c.total_workers = 4
88
+ c.counts_cache_ttl = 3.0
89
+ c.poll_interval_ms = 2000
90
+ c.list_limit = 50
91
+ c.mount_path = '/admin/background'
92
+ c.title = 'My App background jobs'
93
+ end
94
+
95
+ run Async::Background::Web.app
96
+ ```
97
+
98
+ ### Dependencies
99
+
100
+ - `rack` is an optional dependency. Required only when `require 'async/background/web'` is loaded. Core gem and worker processes do not require it.
101
+
102
+ ### Breaking changes from 0.7.x
103
+
104
+ None beyond what 0.7.2 already shipped. The 1.0 line locks the existing contract:
105
+
106
+ - `Queue::Store#fetch` returns `claim_token` in the result hash.
107
+ - All terminal `Queue::Store` methods (`complete`, `fail`, `retry_or_fail`) require the `claim_token:` kwarg and return CAS success boolean / `:retried` / `:failed` / `nil`.
108
+ - Schema is versioned via `PRAGMA user_version`. Use `Queue::Store.migrate!(path:)` to upgrade. Use `Queue::Store.prepare_dashboard!(path:)` from the dashboard process to lazily create dashboard-only indexes (per-status partial indexes for `done` / `failed`, plus separate `executing` and `claimed` indexes).
109
+
3
110
  ## 0.7.2
4
111
 
5
112
  - Harden queue execution, retries, shutdown, and metrics.
@@ -102,7 +209,7 @@
102
209
  - Proper job distribution validation across worker pool
103
210
  - **Test fixtures** — dedicated `ci/fixtures/jobs.rb` and `ci/fixtures/schedule.yml` for scenario testing
104
211
 
105
- ### Bug Fixes
212
+ ### Bug Fixes
106
213
  - **SQLite busy timeout** — added `PRAGMA busy_timeout = 5000` to `Queue::Store` to prevent `SQLITE_BUSY` errors under concurrent multi-process database access
107
214
  - **Enhanced Queue::Notifier error handling** — restructured IO error handling with clearer categorization:
108
215
  - `WRITE_DROPPED` for write failures (`IO::WaitWritable`, `Errno::EAGAIN`, `IOError`, `Errno::EPIPE`) — all non-fatal as job is already in store
data/README.md CHANGED
@@ -12,7 +12,7 @@ A lightweight cron, interval, and job-queue scheduler for Ruby's [Async](https:/
12
12
 
13
13
  - Ruby >= 3.3
14
14
  - `async ~> 2.0`, `fugit ~> 1.0`
15
- - `sqlite3 ~> 2.0` (optional, for the job queue)
15
+ - `sqlite3 ~> 2.0` (optional, storage)
16
16
  - `async-utilization >= 0.3, < 0.5` (optional, for metrics)
17
17
 
18
18
  ## Install
@@ -20,7 +20,7 @@ A lightweight cron, interval, and job-queue scheduler for Ruby's [Async](https:/
20
20
  ```ruby
21
21
  # Gemfile
22
22
  gem "async-background"
23
- gem "sqlite3", "~> 2.0" # optional
23
+ gem "sqlite3", "~> 2.0" # optional
24
24
  gem "async-utilization", ">= 0.3", "< 0.5" # optional
25
25
  ```
26
26
 
@@ -126,6 +126,12 @@ The dynamic queue runs alongside it:
126
126
 
127
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
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
+
129
135
  ### Schema migration during deploy
130
136
 
131
137
  Run queue migrations once in the release/pre-deploy step, before starting new web or worker
@@ -140,18 +146,18 @@ A fresh database still self-initializes on first use for local development, but
140
146
  migration is the production path. For an existing queue, finish or stop 0.7.1 producers/workers,
141
147
  run the migration once, then start 0.7.2 processes.
142
148
 
143
- ### Future dashboard indexes
149
+ ### Dashboard indexes
144
150
 
145
151
  The queue does **not** install dashboard indexes by default. They slow every enqueue even though
146
- pending rows never enter terminal or in-flight read-model indexes. When the 1.0 dashboard module
147
- is enabled, its installer will run this once in the same release step:
152
+ pending rows never enter terminal or in-flight read-model indexes. Enable them once before
153
+ mounting the dashboard:
148
154
 
149
155
  ```ruby
150
156
  Async::Background::Queue.prepare_dashboard!(path: ENV.fetch("QUEUE_DB_PATH"))
151
157
  ```
152
158
 
153
- It adds three compact indexes: one each for cursor-sorted done and failed jobs, plus one for
154
- the bounded in-flight list. It does not change queue behavior or rerun the core migration.
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.
155
161
 
156
162
  ## Metrics
157
163
 
@@ -33,12 +33,14 @@ Gem::Specification.new do |spec|
33
33
  spec.add_dependency 'async', '~> 2.0'
34
34
  spec.add_dependency 'console', '~> 1.0'
35
35
  spec.add_dependency 'fugit', '~> 1.0'
36
+ spec.add_dependency 'base64', '~> 0.2'
36
37
 
37
38
  # Optional: add to your own Gemfile if you need these features
38
- # gem 'sqlite3', '~> 2.0' # dynamic job queue
39
+ # gem 'sqlite3', '~> 2.0'
39
40
  # gem 'async-utilization', '>= 0.3', '< 0.5' # shared-memory worker metrics
40
41
 
41
42
  spec.add_development_dependency 'rake', '~> 13.0'
42
43
  spec.add_development_dependency 'rspec', '~> 3.12'
44
+ spec.add_development_dependency 'rack', '~> 3.0'
43
45
  spec.add_development_dependency 'async-utilization', '>= 0.3', '< 0.5'
44
46
  end
@@ -191,10 +191,12 @@ module Async
191
191
 
192
192
  def ensure_shm!(total_workers, path)
193
193
  required_size = self.class.segment_size * total_workers
194
+ page_size = IO::Buffer::PAGE_SIZE
195
+ mapped_size = ((required_size + page_size - 1) / page_size) * page_size
194
196
 
195
197
  File.open(path, File::CREAT | File::RDWR, 0o644) do |file|
196
198
  file.flock(File::LOCK_EX)
197
- file.truncate(required_size) if file.size < required_size
199
+ file.truncate(mapped_size) if file.size < mapped_size
198
200
  ensure
199
201
  file.flock(File::LOCK_UN) rescue nil
200
202
  end
@@ -13,7 +13,12 @@ module Async
13
13
  VERSION = 1
14
14
  MIGRATION_BUSY_TIMEOUT_MS = 30_000
15
15
  CORE_INDEXES = %w[idx_jobs_pending].freeze
16
- DASHBOARD_INDEXES = %w[idx_jobs_done_finished_at idx_jobs_failed_finished_at idx_jobs_running].freeze
16
+ DASHBOARD_INDEXES = %w[
17
+ idx_jobs_done_finished_at
18
+ idx_jobs_failed_finished_at
19
+ idx_jobs_executing_started_at
20
+ idx_jobs_claimed_locked_at
21
+ ].freeze
17
22
  REQUIRED_INDEXES = CORE_INDEXES
18
23
 
19
24
  module_function
@@ -192,13 +192,24 @@ module Async
192
192
  WHERE status = 'failed'
193
193
  SQL
194
194
 
195
- CREATE_RUNNING_INDEX = <<~SQL.freeze
196
- CREATE INDEX IF NOT EXISTS idx_jobs_running
195
+ CREATE_EXECUTING_INDEX = <<~SQL.freeze
196
+ CREATE INDEX IF NOT EXISTS idx_jobs_executing_started_at
197
+ ON jobs(started_at)
198
+ WHERE status = 'running' AND started_at IS NOT NULL
199
+ SQL
200
+
201
+ CREATE_CLAIMED_INDEX = <<~SQL.freeze
202
+ CREATE INDEX IF NOT EXISTS idx_jobs_claimed_locked_at
197
203
  ON jobs(locked_at)
198
- WHERE status = 'running'
204
+ WHERE status = 'running' AND started_at IS NULL
199
205
  SQL
200
206
 
201
- CREATE_DASHBOARD_INDEXES = [CREATE_DONE_INDEX, CREATE_FAILED_INDEX, CREATE_RUNNING_INDEX].freeze
207
+ CREATE_DASHBOARD_INDEXES = [
208
+ CREATE_DONE_INDEX,
209
+ CREATE_FAILED_INDEX,
210
+ CREATE_EXECUTING_INDEX,
211
+ CREATE_CLAIMED_INDEX
212
+ ].freeze
202
213
  end
203
214
  end
204
215
  end
@@ -13,6 +13,8 @@ module Async
13
13
  private
14
14
 
15
15
  def build_heap(config_path)
16
+ return MinHeap.new if config_path.nil?
17
+
16
18
  schedule = load_schedule(config_path)
17
19
  build_entries(schedule, monotonic_now)
18
20
  end
@@ -29,8 +29,11 @@ module Async
29
29
  :metrics,
30
30
  :queue_store
31
31
 
32
+ # `config_path: nil` explicitly disables recurring jobs. This keeps the
33
+ # dynamic SQLite queue usable on its own; a supplied path remains strict
34
+ # so a typo cannot silently disable scheduled work.
32
35
  def initialize(
33
- config_path:,
36
+ config_path: nil,
34
37
  job_count: 2,
35
38
  worker_index:,
36
39
  total_workers:,
@@ -53,8 +56,9 @@ module Async
53
56
 
54
57
  @drain_barrier = ::Async::Barrier.new
55
58
  @semaphore = ::Async::Semaphore.new(job_count, parent: @drain_barrier)
56
- @heap = build_heap(config_path)
59
+ @heap = config_path.nil? ? MinHeap.new : build_heap(config_path)
57
60
  setup_queue(queue_socket_dir, queue_db_path, queue_mmap)
61
+ validate_work_source!(config_path)
58
62
  end
59
63
 
60
64
  def run
@@ -82,6 +86,11 @@ module Async
82
86
  private
83
87
 
84
88
  def scheduler_loop(task)
89
+ # Queue-only workers have no heap entry to sleep on. Keep the runner
90
+ # alive until #stop / SIGTERM wakes this condition; the queue listener
91
+ # continues independently in its own Async task.
92
+ return shutdown.wait if heap.empty? && @listen_queue
93
+
85
94
  loop do
86
95
  entry = heap.peek
87
96
  break unless entry
@@ -93,6 +102,12 @@ module Async
93
102
  end
94
103
  end
95
104
 
105
+ def validate_work_source!(config_path)
106
+ return unless config_path.nil? && !@listen_queue
107
+
108
+ raise ConfigError, 'Runner requires config_path or queue_socket_dir'
109
+ end
110
+
96
111
  def wait_for_next_entry(task, entry)
97
112
  wait = [entry.next_run_at - monotonic_now, MIN_SLEEP_TIME].max
98
113
  wait_with_shutdown(task, wait)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Async
4
4
  module Background
5
- VERSION = '0.7.2'
5
+ VERSION = '1.0.0'
6
6
  end
7
7
  end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Async
4
+ module Background
5
+ module Web
6
+ class App
7
+ def initialize(config)
8
+ @config = config.validate!
9
+ @auth = Auth.new(@config.auth)
10
+ @snapshot = Snapshot.new(path: @config.queue_path, counts_cache_ttl: @config.counts_cache_ttl).open!
11
+ @metrics_reader = build_metrics_reader
12
+ @serializer = Serializer.new(@config)
13
+ @event_hub = build_event_hub
14
+ @router = Router.new
15
+ end
16
+
17
+ def call(env)
18
+ return Response.unauthorized unless @auth.authorized?(env)
19
+
20
+ route = @router.match(env)
21
+ return Response.not_found unless route
22
+
23
+ dispatch(route, env)
24
+ rescue RequestError => error
25
+ Response.bad_request(error.message)
26
+ rescue UnavailableError, ClosedError
27
+ Response.unavailable
28
+ rescue StandardError
29
+ # Do not turn internal class names, paths or database errors into an
30
+ # unauthenticated information disclosure channel.
31
+ Response.internal_error
32
+ end
33
+
34
+ def close
35
+ @event_hub&.close
36
+ @snapshot.close
37
+ self
38
+ end
39
+
40
+ private
41
+
42
+ def build_metrics_reader
43
+ return unless @config.metrics_enabled?
44
+
45
+ MetricsReader.new(path: @config.metrics_path, total_workers: @config.total_workers)
46
+ end
47
+
48
+ def build_event_hub
49
+ return unless @config.transport == :sse
50
+
51
+ EventHub.new(
52
+ @snapshot,
53
+ @serializer,
54
+ metrics_reader: @metrics_reader,
55
+ poll_seconds: @config.stream_poll_seconds
56
+ )
57
+ end
58
+
59
+ def dispatch(route, env)
60
+ case route
61
+ when :index then Response.html(Assets.render_index(@config))
62
+ when :javascript then Response.javascript(Assets::JS)
63
+ when :stylesheet then Response.stylesheet(Assets::CSS)
64
+ when :overview then overview_response
65
+ when :executing then in_flight_response(:executing, env)
66
+ when :claimed then in_flight_response(:claimed, env)
67
+ when :done then terminal_response(:done, env)
68
+ when :failed then terminal_response(:failed, env)
69
+ when :pending then pending_response(env)
70
+ when :metrics then metrics_response
71
+ when :config then config_response
72
+ when :stream then stream_response
73
+ else Response.not_found
74
+ end
75
+ end
76
+
77
+ def overview_response
78
+ Response.json(@serializer.overview(@snapshot.overview, metrics_payload))
79
+ end
80
+
81
+ def in_flight_response(kind, env)
82
+ request = Request.new(env, @config)
83
+ rows = kind == :executing ? @snapshot.executing(limit: request.limit) : @snapshot.claimed(limit: request.limit)
84
+ payload = kind == :executing ? @serializer.executing(rows) : @serializer.claimed(rows)
85
+ Response.json({items: payload})
86
+ end
87
+
88
+ def terminal_response(kind, env)
89
+ request = Request.new(env, @config)
90
+ cursor = request.finished_cursor
91
+ rows = kind == :done ? @snapshot.recent_done(limit: request.limit, cursor: cursor) :
92
+ @snapshot.recent_failed(limit: request.limit, cursor: cursor)
93
+ payload = kind == :done ? @serializer.done(rows) : @serializer.failed(rows)
94
+ Response.json(payload)
95
+ end
96
+
97
+ def pending_response(env)
98
+ request = Request.new(env, @config)
99
+ rows = @snapshot.pending(limit: request.limit, cursor: request.pending_cursor)
100
+ Response.json(@serializer.pending(rows))
101
+ end
102
+
103
+ def metrics_response
104
+ Response.json(metrics_payload || {available: false, workers: [], totals: MetricsReader::EMPTY_TOTALS})
105
+ end
106
+
107
+ def metrics_payload
108
+ @metrics_reader&.aggregated
109
+ end
110
+
111
+ def config_response
112
+ Response.json(
113
+ {
114
+ title: @config.title,
115
+ poll_interval_ms: @config.poll_interval_ms,
116
+ transport: @config.transport.to_s,
117
+ expose_args: @config.expose_args,
118
+ list_limit: @config.list_limit,
119
+ mount_path: @config.mount_path
120
+ }
121
+ )
122
+ end
123
+
124
+ def stream_response
125
+ return Response.not_found unless @config.transport == :sse
126
+
127
+ Response.sse(
128
+ Stream.new(
129
+ @event_hub,
130
+ heartbeat_seconds: @config.stream_heartbeat_seconds,
131
+ retry_ms: @config.stream_retry_ms
132
+ )
133
+ )
134
+ end
135
+ end
136
+ end
137
+ end
138
+ end