pgbus 0.13.3 → 0.13.4

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: 2e9af048ef1aa2d0fecab62f5471ca9ab6c3e2595353c7eb1a13cde57c37564a
4
- data.tar.gz: 29a35e789733ac394136eaea8fc2b7f2e709617ceee818dd2b2752d9b017d45d
3
+ metadata.gz: 9f59e5211aa8a7dedbbca32416bf5f82943848229ea5cf0a967f3681f14c4613
4
+ data.tar.gz: 8db3311f9117fbb1980982bb4f0de9a25ac610ff59898e8877631fdbc5c92f5f
5
5
  SHA512:
6
- metadata.gz: 392a67ed233feb5ffe0a98cf597efe3962b9e7c81d07a7e351f6c4e8aab8c03b3edc8e55204c3f0d0a4b82287b7f523cada5ca79e0684752f50c8f3272c9bdc0
7
- data.tar.gz: 95b48a0ae66aaa239da08fa3c235a412241ddcfacea8384d84a66ca298308678062dc0192592b07484596fe12e27145a71474d5547b5046370bfca2785ae0f03
6
+ metadata.gz: 478a033b2857060eec4240e8556e789af8055fdc34fc3c17639646159e168289e1f7e0ea80443137b9980294dde3d114488abbb76317e7a2f079d2df22e79fb5
7
+ data.tar.gz: 9e9ae02d538d96d30f141bed4a5da3e0420841da0364b452d0aa347b6ce242f9eae605514c148805542bdd24b44f949592e0d155f58b7a20f765e70231fd04b3
data/CHANGELOG.md CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  ### Fixed
4
4
 
5
+ - **SSE delivery no longer strips newlines from broadcast payloads — multiline payloads are framed as consecutive `data:` lines per the SSE spec (issue #392).** `Streams::Envelope.message` collapsed `\r`/`\n` in the payload to nothing before writing the single `data:` line, silently corrupting any whitespace-significant broadcast (pre-formatted `<pre>` content, textarea seeds, JSON-in-data frames) on **both** the ephemeral and durable delivery paths — HTML's whitespace tolerance is why it went unnoticed. A multiline payload is now split on `\r\n`/`\r`/`\n` into consecutive `data:` lines, which EventSource clients rejoin with `\n`, making delivery lossless (a trailing newline survives via an empty final `data:` line; `\r` variants normalize to `\n` — SSE line terminators cannot be carried raw). The original injection defense is preserved: every payload line carries the `data:` prefix followed by one space, so a crafted payload still cannot forge `id:`/`event:` fields, and single-line fields (event names, comments) still strip newlines. The `<pgbus-stream-source>` element's fetch-path parser had the matching client-side bug — it joined `data:` lines without `\n` *and* `trim()`ed payload whitespace — and now follows EventSource semantics (join with `\n`, strip only the single leading space). Refs #392.
6
+
7
+ - **Ephemeral broadcasts over the PG NOTIFY payload cap no longer fail — loudly on the sync path, silently in the coalescer — they auto-degrade to a durable publish (issue #391).** Ephemeral frames ride the NOTIFY payload itself, which PostgreSQL caps below 8000 bytes. Any rendered-component broadcast (a progress card with Tailwind classes easily exceeds it) previously raised `PGMQ::Errors::ConnectionError: … payload string too long` — an error class that sent diagnosis toward the connection, not the payload — and on the `coalesce:` path that raise happened inside the coalescer's flush thread, reaching no caller, no ErrorReporter, no log: small frames delivered, big frames vanished, and the operator saw "SSE works but updates don't arrive". Three changes: **(1)** `Stream#broadcast` now measures the wrapped JSON before the NOTIFY and publishes an over-budget frame durably instead (payload stored in PGMQ, the queue's insert trigger fires the NOTIFY as a bare wake on the same channel the subscriber already LISTENs on) — delivery semantics preserved on both the sync and coalesced paths, warn-logged and instrumented (`pgbus.stream.broadcast` with `ephemeral_fallback: true`). **(2)** Direct `Client#notify_stream` callers get publish-time validation: a typed `Pgbus::Streams::PayloadTooLarge` raised at the call site for payloads exceeding `Pgbus::Client::NotifyStream::NOTIFY_PAYLOAD_LIMIT_BYTES` (7999 bytes, the largest accepted payload), naming the stream, the byte count, and the durable-mode escape hatch. **(3)** The coalescer's flush thread routes every flush error through `ErrorReporter` (same report-don't-log reasoning as #352) — a background thread swallowing delivery failures is invisible to APM by construction. ⚠️ **Upgrade note for 0.13 installs:** `streams_default_broadcast_mode` defaults to `:ephemeral`, and that default is a **behavior change** for apps broadcasting rendered components (what `broadcast_render`-style usage produces) — before this fix, any frame over ~8KB was silently lost or misdiagnosed. Durable is the right mode for turbo-stream UI regardless (since-id replay needs the archive): pin `config.streams_default_broadcast_mode = :durable`, or use `streams_durable_patterns` for the streams that need it; the auto-fallback now covers whatever stays ephemeral. Refs #391.
8
+
5
9
  - **Queue-age metrics no longer count vt-parked (scheduled/retrying) messages — one delayed job stops reading as a degraded queue (issue #389).** ⚠️ **Behavior change on the AppSignal `pgbus_queue_latency` gauge.** pgmq's `oldest_msg_age_sec` is computed from `enqueued_at` and ignores `vt`, but a job enqueued with `wait:` or parked on a long retry backoff lives in the queue table with a future `vt` — that *is* the delayed-delivery mechanism. So a single parked message made the age metric grow at wall-clock rate for hours on an otherwise drained queue, and any latency alert thresholding on it fired continuously ("oldest message is 17045s old" on a healthy queue with depth 1, `read_ct` 0). Every metrics surface now also exposes **`oldest_claimable_age_sec`** — `now() - min(vt)` over rows with `vt <= now()`, i.e. the age of the oldest message actually *eligible for pickup*: an immediately-enqueued message contributes from enqueue time (matching the old number on a plain backlog), a scheduled/backoff-parked message contributes nothing until due, an in-flight message (vt pushed forward) is excluded, and nil means "no claimable backlog" even when the table is non-empty. Surfaces: `Web::DataSource` (dashboard, JSON API, MCP `pgbus_queues` tool), a new Prometheus gauge `pgbus_queue_oldest_claimable_age_seconds`, a new AppSignal gauge `pgbus_queue_oldest_claimable_age_seconds`, `Pgbus::Client#oldest_claimable_ages` (raw-SQL reader, since pgmq's `metrics_result` type is frozen upstream), and a CLAIMABLE column in `pgbus queues`. The AppSignal **`pgbus_queue_latency` gauge now derives from the claimable age** and always emits — `(claimable_age || 0) * 1000`, 0 = no claimable backlog — so existing latency alerts stop false-firing with no dashboard changes; the raw `pgbus_queue_oldest_message_age_seconds` gauge keeps its enqueue-time semantics everywhere. The dashboard queue tables additionally split depth into **Parked** (`depth − visible`) and show the claimable age in place of the raw age, so a queue holding only backoff retries reads visibly healthy. Refs #389.
6
10
 
7
11
  ### Added
@@ -225,13 +225,17 @@ class PgbusStreamSourceElement extends HTMLElement {
225
225
 
226
226
  let id = null
227
227
  let event = "message"
228
- let data = ""
228
+ const dataLines = []
229
229
 
230
230
  for (const line of block.split("\n")) {
231
231
  if (line.startsWith("id:")) id = line.slice(3).trim()
232
232
  else if (line.startsWith("event:")) event = line.slice(6).trim()
233
- else if (line.startsWith("data:")) data += line.slice(5).trim()
233
+ // Per the SSE spec: strip only a single leading space after the colon
234
+ // (never trim — payload whitespace is significant, issue #392) and
235
+ // rejoin consecutive data: lines with \n, matching native EventSource.
236
+ else if (line.startsWith("data:")) dataLines.push(line.slice(5).replace(/^ /, ""))
234
237
  }
238
+ const data = dataLines.join("\n")
235
239
 
236
240
  if (id !== null) this.lastEventId = id
237
241
 
@@ -14,15 +14,24 @@ module Pgbus
14
14
  # no orphan tables.
15
15
  #
16
16
  # The payload is JSON-serialized into the NOTIFY's optional payload
17
- # parameter (max 8000 bytes in Postgres). Broadcasts exceeding this
18
- # limit will raise a PG::ProgramLimitExceeded error callers needing
19
- # large payloads should use durable mode (which inserts into PGMQ).
17
+ # parameter. Postgres caps NOTIFY payloads at < 8000 bytes; oversized
18
+ # payloads raise a typed Pgbus::Streams::PayloadTooLarge here, at the
19
+ # call site, instead of surfacing as a misleading
20
+ # PGMQ::Errors::ConnectionError ("payload string too long") from deep
21
+ # inside the driver (issue #391). Callers needing large payloads should
22
+ # use durable mode (which inserts into PGMQ).
20
23
  module NotifyStream
24
+ # PostgreSQL rejects NOTIFY payloads of 8000 bytes or more
25
+ # ("payload string too long"), so 7999 is the largest deliverable
26
+ # payload.
27
+ NOTIFY_PAYLOAD_LIMIT_BYTES = 7999
28
+
21
29
  def notify_stream(stream_name, payload)
22
30
  full_name = config.queue_name(stream_name)
23
31
  sanitized = QueueNameValidator.sanitize!(full_name)
24
32
  channel = "pgmq.q_#{sanitized}.INSERT"
25
33
  json = payload.is_a?(String) ? payload : JSON.generate(payload)
34
+ validate_notify_payload_size!(stream_name, json)
26
35
 
27
36
  Instrumentation.instrument("pgbus.stream.notify", stream: stream_name, bytes: json.bytesize) do
28
37
  with_stale_connection_retry do
@@ -34,6 +43,19 @@ module Pgbus
34
43
  end
35
44
  end
36
45
  end
46
+
47
+ private
48
+
49
+ def validate_notify_payload_size!(stream_name, json)
50
+ return if json.bytesize <= NOTIFY_PAYLOAD_LIMIT_BYTES
51
+
52
+ raise Pgbus::Streams::PayloadTooLarge,
53
+ "Ephemeral broadcast on stream #{stream_name.inspect} is #{json.bytesize} bytes; " \
54
+ "PostgreSQL caps NOTIFY payloads at #{NOTIFY_PAYLOAD_LIMIT_BYTES} bytes. " \
55
+ "Use durable mode for large payloads (payload stored in PGMQ, NOTIFY as wake) — " \
56
+ "e.g. broadcast(..., durable: true), a streams_durable_patterns match, or " \
57
+ "streams_default_broadcast_mode = :durable."
58
+ end
37
59
  end
38
60
  end
39
61
  end
@@ -73,6 +73,12 @@ module Pgbus
73
73
  return unless entry
74
74
 
75
75
  @flush.call(stream_name: stream_name, target: target, payload: entry.payload, opts: entry.opts)
76
+ rescue StandardError => e
77
+ # The flush runs on the scheduler's thread — a raise here reaches no
78
+ # caller, so a swallowed error is invisible to APM by construction
79
+ # (issue #391: an oversized ephemeral frame died here without a
80
+ # trace). Route through ErrorReporter so configured reporters see it.
81
+ ErrorReporter.report(e, { component: "streams.coalescer", stream: stream_name, target: target })
76
82
  end
77
83
 
78
84
  # Default scheduler backed by Concurrent::ScheduledTask. Kept as a
@@ -12,13 +12,17 @@ module Pgbus
12
12
  # - `comment(text)` — a heartbeat or sentinel that the SSE parser ignores
13
13
  # - `retry_directive(ms)` — tells `EventSource` how long to wait before reconnecting
14
14
  #
15
- # All frames end with `\n\n` (the SSE event terminator). `data:` lines must not
16
- # contain newlines the SSE spec uses `\n` as the field terminator, so a multi-line
17
- # payload would arrive as multiple events. We strip `\r` and `\n` from data and
18
- # comment text rather than splitting into multiple `data:` lines, because Turbo
19
- # Stream HTML is already flat and the simpler encoding is easier to debug.
15
+ # All frames end with `\n\n` (the SSE event terminator). A multiline payload is
16
+ # framed as consecutive `data:` lines (issue #392) the client rejoins them with
17
+ # `\n`, so delivery is lossless. `\r\n` and lone `\r` are also SSE line terminators,
18
+ # so they become `data:` line breaks too (rejoined as `\n`; SSE cannot represent a
19
+ # raw `\r`). Every payload line carries the `data: ` prefix, so a crafted payload
20
+ # cannot inject forged id:/event: fields. Single-line fields (`event:`, comments)
21
+ # still strip newlines — there a `\r`/`\n` would terminate the field early and
22
+ # permit SSE field injection.
20
23
  module Envelope
21
24
  NEWLINES = /[\r\n]+/
25
+ DATA_LINE_BREAK = /\r\n|\r|\n/
22
26
 
23
27
  RESPONSE_HEADERS = "HTTP/1.1 200 OK\r\n" \
24
28
  "content-type: text/event-stream\r\n" \
@@ -31,11 +35,13 @@ module Pgbus
31
35
  raise ArgumentError, "id is required" if id.nil?
32
36
  raise ArgumentError, "event is required" if event.nil? || event.to_s.empty?
33
37
 
34
- # Strip newlines from BOTH event and data, not just data: each is
35
- # interpolated into its own SSE field line, so an unescaped \r/\n in
36
- # either would terminate the field early and let a crafted value
37
- # inject extra SSE fields (a forged id:/data:) into the frame.
38
- "id: #{id}\nevent: #{strip_newlines(event.to_s)}\ndata: #{strip_newlines(data.to_s)}\n\n"
38
+ # The event name is a single SSE field line, so newlines are stripped
39
+ # an unescaped \r/\n would terminate the field early and let a crafted
40
+ # value inject extra SSE fields (a forged id:/data:) into the frame.
41
+ # The payload is framed as one `data:` line per payload line instead:
42
+ # every line carries the `data: ` prefix, which is both spec-correct
43
+ # (the client rejoins with \n) and injection-safe.
44
+ "id: #{id}\nevent: #{strip_newlines(event.to_s)}\n#{data_lines(data.to_s)}\n"
39
45
  end
40
46
 
41
47
  def self.comment(text)
@@ -71,7 +77,16 @@ module Pgbus
71
77
  str.gsub(NEWLINES, "")
72
78
  end
73
79
 
74
- private_class_method :strip_newlines
80
+ # One `data: <line>\n` per payload line. The -1 limit keeps trailing
81
+ # empty strings, so a payload ending in \n round-trips as an empty
82
+ # final `data:` line (the client's rejoin restores the newline).
83
+ def self.data_lines(str)
84
+ lines = str.split(DATA_LINE_BREAK, -1)
85
+ lines = [""] if lines.empty? # "".split → [] — an empty payload still gets its data: line
86
+ lines.map { |line| "data: #{line}\n" }.join
87
+ end
88
+
89
+ private_class_method :strip_newlines, :data_lines
75
90
  end
76
91
  end
77
92
  end
data/lib/pgbus/streams.rb CHANGED
@@ -15,6 +15,18 @@ module Pgbus
15
15
  # this specifically can rescue Pgbus::Streams::StreamNameTooLong.
16
16
  class StreamNameTooLong < ArgumentError; end
17
17
 
18
+ # Raised when an ephemeral broadcast's JSON payload exceeds PostgreSQL's
19
+ # NOTIFY payload budget (< 8000 bytes). Ephemeral frames ride the NOTIFY
20
+ # itself, so the cap is a hard PostgreSQL limit — durable mode (payload
21
+ # stored in PGMQ, NOTIFY as a bare wake) has no such cap.
22
+ #
23
+ # Stream#broadcast never raises this: an oversized ephemeral frame
24
+ # auto-degrades to a durable publish (issue #391). The error exists for
25
+ # direct Client#notify_stream callers, where the previous failure mode
26
+ # was a misleading PGMQ::Errors::ConnectionError ("payload string too
27
+ # long") that pointed diagnosis at the connection instead of the payload.
28
+ class PayloadTooLarge < Pgbus::Error; end
29
+
18
30
  # The default SSE `event:` name for a broadcast frame. Turbo's
19
31
  # StreamObserver consumes frames the client re-dispatches as the
20
32
  # `message` DOM event; the client maps this SSE event name to
@@ -261,11 +273,47 @@ module Pgbus
261
273
 
262
274
  private
263
275
 
276
+ # Ephemeral frames ride the NOTIFY payload itself, which PostgreSQL
277
+ # caps below 8000 bytes. A frame over the cap auto-degrades to a
278
+ # durable publish (issue #391): payload stored in PGMQ, the queue's
279
+ # insert trigger fires the NOTIFY as a bare wake on the same channel
280
+ # the subscriber already LISTENs on — delivery semantics preserved,
281
+ # cap irrelevant. The JSON is generated once here and passed
282
+ # pre-serialized to notify_stream so the size check costs no extra
283
+ # allocation on the hot path.
264
284
  def broadcast_ephemeral(wrapped)
265
- @client.notify_stream(@name, wrapped)
285
+ json = JSON.generate(wrapped)
286
+ return durable_fallback(wrapped, json.bytesize) if json.bytesize > Client::NotifyStream::NOTIFY_PAYLOAD_LIMIT_BYTES
287
+
288
+ @client.notify_stream(@name, json)
266
289
  nil
267
290
  end
268
291
 
292
+ # The durable degrade path for an oversized ephemeral frame. Stays
293
+ # fire-and-forget like the ephemeral path it replaces: no after_commit
294
+ # deferral (pg_notify runs on the PGMQ pool connection, outside the
295
+ # request's AR transaction, so the ephemeral path never deferred
296
+ # either). Returns the msg_id like any durable publish.
297
+ def durable_fallback(wrapped, bytes)
298
+ Pgbus.logger.warn do
299
+ "[Pgbus::Streams] ephemeral broadcast on #{@name.inspect} is #{bytes} bytes " \
300
+ "(NOTIFY cap is #{Client::NotifyStream::NOTIFY_PAYLOAD_LIMIT_BYTES}); " \
301
+ "publishing durably instead. Consider durable mode for this stream " \
302
+ "(streams_durable_patterns or durable: true) to skip this check."
303
+ end
304
+ ensure_queue!
305
+ instrument_payload = {
306
+ stream: @name,
307
+ visible_to: wrapped["visible_to"],
308
+ deferred: false,
309
+ bytes: wrapped["html"].bytesize,
310
+ ephemeral_fallback: true
311
+ }
312
+ Instrumentation.instrument("pgbus.stream.broadcast", instrument_payload) do
313
+ @client.send_stream_message(@name, wrapped)
314
+ end
315
+ end
316
+
269
317
  # Submits a frame to the process-wide coalescer instead of
270
318
  # broadcasting now. Requires a target (the dedupe key — there's no
271
319
  # way to last-write-win without one). The window is `coalesce` in ms
data/lib/pgbus/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pgbus
4
- VERSION = "0.13.3"
4
+ VERSION = "0.13.4"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pgbus
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.13.3
4
+ version: 0.13.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson