Skip to content

fix(stream): emit SSE heartbeats so idle streams survive proxy timeouts - #346

Merged
EricAndrechek merged 15 commits into
mainfrom
sse-heartbeat
Jun 26, 2026
Merged

fix(stream): emit SSE heartbeats so idle streams survive proxy timeouts#346
EricAndrechek merged 15 commits into
mainfrom
sse-heartbeat

Conversation

@taitelee

@taitelee taitelee commented Jun 17, 2026

Copy link
Copy Markdown
Member

Summary

GET /v1/stream wrote a single : connected comment on open and then nothing until an event arrived, so on a quiet table an intermediary's idle timeout reset the connection — Cloudflare's edge (and a Cloudflare Tunnel) dropped quiet streams about every two minutes during internal usage, and each curl/server-side reconnect re-ran NATS gap-fill (browser EventSource masked it via auto-reconnect).

This adds a periodic minimal : SSE keepalive comment to keep idle streams alive. Instead of a timer per connection, a single shared Heartbeater goroutine — started once on the server's context in main.go — drives a ring of buckets: each stream registers into the bucket that fires last (a full rotation before its first ping) and deregisters on disconnect, while every tick pushes the comment to one bucket and advances the hand. The user-facing knob is the effective per-connection period, stream.keepalive_interval (default 30s), with the per-tick interval derived as keepalive_interval ÷ keepalive_buckets, so one rotation always spans the interval; 30s clears the common 55–60s nginx/ingress-nginx/ALB/Heroku idle windows with ~2× margin. The push is a non-blocking send to each subscriber's buffered outbound queue; the owning handler goroutine performs the actual write and flush, so one slow or dead client never blocks the shared goroutine, and a failed write doubles as a liveness probe that ends the handler once the connection is gone.

The fan-out machinery lives in a new internal/stream package, one abstraction per file: Subscriber (a per-connection outbound frame queue with Send/Frames), the Bucket fan-out primitive (subscriberSet), and the Heartbeater wheel. The handler's keepalive case is now a payload-agnostic byte-pump, so the delivery-path throughput work (#294) can route projected event frames through the same queue and reuse Bucket for project-once-per-(role, table) delivery.

Config: stream.keepalive_interval / WH_STREAM_KEEPALIVE_INTERVAL (default 30s) and stream.keepalive_buckets / WH_STREAM_KEEPALIVE_BUCKETS (default 3); 0 falls back to the default and a negative value is rejected at startup. The keepalive is a minimal : comment, ignored by EventSource and spec-compliant parsers (curl just prints them), so it's transparent to clients and needs no SDK change. Also ends the SSE.PushEvent span on policy-filtered events, which previously returned early without closing it. The reverse-proxy guide gains a per-provider idle-timeout reference table (with how-to-change notes and the absolute-cap exceptions a keepalive can't fix).

Related Issues

Closes #226.

@coderabbitai

coderabbitai Bot commented Jun 17, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds stream keepalive configuration, a bucketed heartbeat wheel, StreamHandler support for periodic SSE comments, startup wiring to run the heartbeater, and documentation updates covering the new /v1/stream behavior.

Changes

SSE keepalive heartbeat

Layer / File(s) Summary
Stream keepalive configuration
internal/config/config.go, internal/config/config_test.go, config.yaml, docs/src/content/docs/configuration.mdx, CHANGELOG.md
Adds the stream config block, validates keepalive values, and documents the new settings.
Subscriber queue and bucket fan-out
internal/stream/doc.go, internal/stream/subscriber.go, internal/stream/bucket.go, internal/stream/subscriber_test.go, internal/stream/bucket_test.go
Introduces the per-connection subscriber queue, the mutex-guarded bucket fan-out contract, and tests for enqueue/drop behavior and concurrent add/remove/push paths.
Heartbeat wheel
internal/stream/heartbeat.go, internal/stream/heartbeat_test.go
Introduces the keepalive timing wheel and tests for interval derivation, bucket placement, rotation, idle delivery, shutdown, and concurrent churn.
SSE handler and process wiring
internal/api/stream.go, internal/api/stream_test.go, cmd/wavehouse/main.go
Registers per-connection subscribers with the heartbeater, writes queued heartbeat frames to the SSE response, starts the heartbeater from main, and covers idle and teardown behavior in tests.
Docs and architecture updates
docs/src/content/docs/api.md, docs/src/content/docs/deployment.md, docs/src/content/docs/reverse-proxy.mdx, docs/src/content/docs/architecture.md, AGENTS.md
Updates API, deployment, reverse-proxy, and architecture docs to describe periodic SSE keepalive comments and proxy idle-timeout behavior.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant StreamHandler
  participant Heartbeater
  participant Bucket
  participant Subscriber
  participant ResponseWriter

  Client->>StreamHandler: GET /v1/stream
  StreamHandler->>ResponseWriter: write : connected
  StreamHandler->>Heartbeater: Add(subscriber)
  loop idle period
    Heartbeater->>Bucket: Push(: heartbeat)
    Bucket->>Subscriber: Send(: heartbeat)
    Subscriber-->>StreamHandler: frame bytes from Frames()
    StreamHandler->>ResponseWriter: write frame
    StreamHandler->>ResponseWriter: Flush()
  end
  Client-->>StreamHandler: cancel context
  StreamHandler->>Heartbeater: Remove(subscriber)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • Wave-RF/WaveHouse#124: Both PRs change internal/api/hub.go broadcast behavior and the shape of messages delivered to subscribers.

Suggested reviewers

  • EricAndrechek
🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The hub broadcast/OTEL envelope refactor goes beyond the heartbeat requirement in #226 and appears unrelated to the issue. Split unrelated hub/tracing changes into a separate PR or justify them in the issue scope before merging.
Docstring Coverage ⚠️ Warning Docstring coverage is 16.22% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise, conventional, and accurately summarizes the main change: adding SSE heartbeats to avoid proxy idle timeouts.
Description check ✅ Passed The description clearly matches the changeset and explains the heartbeat, config, and stream refactor work.
Linked Issues check ✅ Passed The PR implements the requested periodic SSE comment heartbeat in the stream handler, so idle streams survive proxy timeouts.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch sse-heartbeat
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch sse-heartbeat

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@taitelee taitelee moved this from Backlog to In progress in WaveHouse Task Board Jun 17, 2026
@github-actions github-actions Bot added documentation Improvements or additions to documentation go Pull requests that update go code area/api HTTP handlers, routing, middleware area/docs Documentation, site/, README labels Jun 17, 2026
@github-actions

github-actions Bot commented Jun 17, 2026

Copy link
Copy Markdown

📚 Docs preview is livehttps://3896ebe2-wavehouse-docs.wave-rf.workers.dev

  • Commit486324d: fix(stream): count replayed frames in SSE metrics (kind=replay)
  • Author@EricAndrechek, Claude Opus 4.8 (1M context)
  • Committed — 2026-06-26 16:22 (UTC-04:00)
  • Deployed — 2026-06-26 16:29 EDT

@github-code-quality

github-code-quality Bot commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Code Coverage Overview

Languages: Go

Go

The overall coverage in the sse-heartbeat branch is 90%. The coverage in the main branch is 89%.

Show a code coverage summary of the most impacted files.
File main b650319 sse-heartbeat 486324d +/-
internal/api/stream.go 67% 66% -1%
internal/config/config.go 94% 94% 0%
cmd/wavehouse/main.go 68% 69% +1%
internal/api/hub.go 83% 85% +2%
internal/stream/heartbeat.go 0% 95% +95%
internal/stream/bucket.go 0% 100% +100%
internal/stream/metrics.go 0% 100% +100%
internal/stream/subscriber.go 0% 100% +100%

Updated June 26, 2026 20:29 UTC
Code Coverage is in Public Preview. Learn more and provide us with your feedback.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal/api/stream.go (1)

167-171: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

End SSE.PushEvent spans on filtered events.

On Line [170], the continue path bypasses pushSpan.End(), so filtered events leak spans on long-lived streams.

🩹 Proposed fix
 			_, pushSpan := tracer.Start(parentCtx, "SSE.PushEvent")
 
 			out := h.applyStreamPolicy(envelope.Payload, role, claims)
 			if out == nil {
+				pushSpan.End()
 				continue
 			}
 			id := extractEventTimestamp(out)
 			_, _ = fmt.Fprintf(w, "id: %s\ndata: %s\n\n", id, out)
 			flusher.Flush()
 			// A real delivery already kept the connection warm; restart the
 			// idle clock so heartbeats fire only on genuinely quiet streams.
 			heartbeat.Reset(interval)
 			pushSpan.End()

Also applies to: 179-179


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 4d0229d3-0016-4bd7-b1ca-5591243fe593

📥 Commits

Reviewing files that changed from the base of the PR and between e583c45 and 63ebc07.

📒 Files selected for processing (6)
  • CHANGELOG.md
  • docs/src/content/docs/api.md
  • docs/src/content/docs/deployment.md
  • docs/src/content/docs/reverse-proxy.mdx
  • internal/api/stream.go
  • internal/api/stream_test.go
📜 Review details
⏰ Context from checks skipped due to timeout of 300000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (5)
  • GitHub Check: Coverage
  • GitHub Check: Docs build
  • GitHub Check: E2E tests
  • GitHub Check: Integration tests
  • GitHub Check: Lint
🧰 Additional context used
📓 Path-based instructions (4)
docs/src/content/docs/**/*.{md,mdx}

📄 CodeRabbit inference engine (AGENTS.md)

docs/src/content/docs/**/*.{md,mdx}: Author Mermaid diagrams vertically (flowchart TB/TD) to fit page column width (~46–58rem); reserve LR for genuinely short chains (≤3–4 nodes)
Keep Mermaid node labels short; use
for a second line rather than one long line; lean on semantic node classes (wh, win, pain, fail, infra, neutral, store, client)
Never sit two large diagrams side-by-side; wrap comparisons in

to stack them vertically

Files:

  • docs/src/content/docs/deployment.md
  • docs/src/content/docs/api.md
  • docs/src/content/docs/reverse-proxy.mdx
**/*.go

📄 CodeRabbit inference engine (AGENTS.md)

**/*.go: Use Go 1.26 with strict formatting enforced by gofumpt
Use structured logging with log/slog (JSON handler)
Use Chi v5 for HTTP routing
Return errors, don't panic. Wrap with fmt.Errorf("context: %w", err)
Use package naming: lowercase, single word (or abbreviated). internal/ enforces module privacy
No global state: Dependencies are passed explicitly (constructor injection)
Comment the why, not the what. Add a comment only when the reason isn't obvious from the code; a line that matches the surrounding pattern needs none. Keep comments to 1–2 lines
DRY — one source of truth. Before adding logic, look for an existing helper, type, or constant to reuse; before duplicating a rule, factor it into one place every caller reads
Leave it neater than you found it — within reason. Fix small, safe things in passing: a stale comment, an obvious typo, a misnamed local, dead code on your path

Files:

  • internal/api/stream_test.go
  • internal/api/stream.go
internal/api/**/*.go

📄 CodeRabbit inference engine (AGENTS.md)

Chi HTTP router, JWT/JWKS middleware (from auth/), ingest/query/structured-query/SSE/schema/DLQ/policy/pipes handlers, Hub

Files:

  • internal/api/stream_test.go
  • internal/api/stream.go
**/*_test.go

📄 CodeRabbit inference engine (AGENTS.md)

**/*_test.go: Use table-driven tests with tests := []struct{ name string; ... } and t.Run(tt.name, ...)
Use shared mocks from internal/testutil/ (MockPublisher, MockCache, MockDeduplicator, MockSubscriber) instead of creating ad-hoc mocks
Use testutil.MakeJWT(t, claims) and testutil.MakeExpiredJWT(t, claims) for auth tests
Use testutil.NewTestSchemaRegistry(tables) or discovery.NewSchemaRegistryFromMap(tables) for schema-aware tests
Use policy.NewMemoryStore(p) for in-memory policy testing without NATS
Use pipes.NewMemoryStore(queries...) for in-memory pipes testing without NATS
Use testutil.AssertJSONResponse(t, rec, status, expected) and testutil.AssertJSONContains(t, rec, status, substring) for response assertions

Files:

  • internal/api/stream_test.go
🧠 Learnings (3)
📚 Learning: 2026-06-10T15:01:09.027Z
Learnt from: EricAndrechek
Repo: Wave-RF/WaveHouse PR: 312
File: docs/src/content/docs/development.md:0-0
Timestamp: 2026-06-10T15:01:09.027Z
Learning: In this repo’s Markdown review (all .md files), do not flag capitalization/style issues for literal paths starting with ".github/" (or any substring that is a path beginning with ".github/"). Treat ".github" as the correct lowercase dotfile directory name, even when it appears inside prose or code spans; automated checks such as LanguageTool’s "(GITHUB)" rule commonly produce false positives for this literal filesystem path.

Applied to files:

  • docs/src/content/docs/deployment.md
  • docs/src/content/docs/api.md
  • CHANGELOG.md
📚 Learning: 2026-05-20T01:02:00.784Z
Learnt from: EricAndrechek
Repo: Wave-RF/WaveHouse PR: 164
File: internal/api/router_test.go:289-350
Timestamp: 2026-05-20T01:02:00.784Z
Learning: In WaveHouse’s internal API tests (files matching internal/api/**/*_test.go), follow the existing separation-of-concerns convention for testing the RequireRole middleware: inject `ContextKeyRole` directly into the request `context.Context` instead of using `testutil.MakeJWT`/JWT-driven flows. Do not refactor role-gate tests to use JWT tokens—JWT parsing and token handling are covered separately in `middleware_test.go` (the dedicated JWT parsing tests), and mixing those concerns would expand the failure surface and reduce isolation.

Applied to files:

  • internal/api/stream_test.go
📚 Learning: 2026-05-23T01:23:59.268Z
Learnt from: EricAndrechek
Repo: Wave-RF/WaveHouse PR: 174
File: internal/api/ingest_test.go:111-111
Timestamp: 2026-05-23T01:23:59.268Z
Learning: In WaveHouse Go tests in internal/api/**/*_test.go, use internal/testutil.AssertJSONErrorResponse(t, w) for HTTP error-path JSON assertions. Do not use (or reintroduce) package-local assertJSONErrorResponse helpers. AssertJSONErrorResponse verifies the response Content-Type is application/json, includes the X-Content-Type-Options: nosniff header, and that the JSON body contains an "error" field.

Applied to files:

  • internal/api/stream_test.go
🔇 Additional comments (5)
internal/api/stream_test.go (1)

9-10: LGTM!

Also applies to: 208-234

CHANGELOG.md (1)

15-16: LGTM!

Also applies to: 43-44

docs/src/content/docs/api.md (1)

519-519: LGTM!

Also applies to: 535-536

docs/src/content/docs/deployment.md (1)

332-332: LGTM!

docs/src/content/docs/reverse-proxy.mdx (1)

59-59: LGTM!

Also applies to: 69-79, 136-138, 199-199

coderabbitai[bot]
coderabbitai Bot previously approved these changes Jun 17, 2026
@taitelee taitelee moved this from In progress to Ready in WaveHouse Task Board Jun 17, 2026
@github-actions github-actions Bot added the area/infra CI, build, deploy, Docker, release label Jun 18, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
internal/api/stream.go (1)

132-167: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Heartbeats are not idle-gated, so active streams still flush keepalives.

The select loop writes heartbeat bytes whenever c.hbCh fires, but real event delivery (Line 144+) never re-arms heartbeat scheduling. So even high-traffic streams still emit periodic heartbeat flushes, adding avoidable write pressure.

💡 Proposed fix (re-arm after real traffic)
 		case data := <-ch:
 			var envelope struct {
 				TraceHeaders map[string]string `json:"trace_headers"`
 				Payload      []byte            `json:"payload"`
 			}
@@
 			id := extractEventTimestamp(out)
 			_, _ = fmt.Fprintf(w, "id: %s\ndata: %s\n\n", id, out)
 			flusher.Flush()
+			if h.Heartbeater != nil {
+				// Re-arm heartbeat after actual traffic so keepalives remain idle-only.
+				h.Heartbeater.Remove(c)
+				h.Heartbeater.Add(c)
+			}
 			pushSpan.End()
internal/api/stream_test.go (1)

208-238: 🛠️ Refactor suggestion | 🟠 Major

Convert to table-driven test with idle and active-stream scenarios; avoid fixed sleep timing.

This test violates the coding guideline for **/*_test.go files, which require table-driven tests with tests := []struct{ name string; ... } and t.Run(tt.name, ...) — a pattern already used elsewhere in internal/api/ (e.g., cache_key_test.go). Additionally, it depends on fixed time.Sleep(200*time.Millisecond) which is fragile under CI load, and covers only the idle scenario; the active-stream behavior (heartbeats should NOT emit on active traffic) is not validated, risking regression.

Source: Coding guidelines


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 7ff00115-5b75-4453-aa3d-29ee7d115aaf

📥 Commits

Reviewing files that changed from the base of the PR and between 63ebc07 and 40fa291.

📒 Files selected for processing (11)
  • CHANGELOG.md
  • cmd/wavehouse/main.go
  • docs/src/content/docs/api.md
  • docs/src/content/docs/configuration.mdx
  • docs/src/content/docs/reverse-proxy.mdx
  • internal/api/heartbeat.go
  • internal/api/heartbeat_test.go
  • internal/api/stream.go
  • internal/api/stream_test.go
  • internal/config/config.go
  • internal/config/config_test.go
📜 Review details
⏰ Context from checks skipped due to timeout of 300000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (5)
  • GitHub Check: Coverage
  • GitHub Check: Docs build
  • GitHub Check: E2E tests
  • GitHub Check: Lint
  • GitHub Check: Analyze (actions)
🧰 Additional context used
📓 Path-based instructions (5)
**/*.go

📄 CodeRabbit inference engine (AGENTS.md)

**/*.go: Use Go 1.26 with strict formatting enforced by gofumpt
Use structured logging with log/slog (JSON handler)
Use Chi v5 for HTTP routing
Return errors, don't panic. Wrap with fmt.Errorf("context: %w", err)
Use package naming: lowercase, single word (or abbreviated). internal/ enforces module privacy
No global state: Dependencies are passed explicitly (constructor injection)
Comment the why, not the what. Add a comment only when the reason isn't obvious from the code; a line that matches the surrounding pattern needs none. Keep comments to 1–2 lines
DRY — one source of truth. Before adding logic, look for an existing helper, type, or constant to reuse; before duplicating a rule, factor it into one place every caller reads
Leave it neater than you found it — within reason. Fix small, safe things in passing: a stale comment, an obvious typo, a misnamed local, dead code on your path

Files:

  • internal/config/config_test.go
  • cmd/wavehouse/main.go
  • internal/api/stream_test.go
  • internal/config/config.go
  • internal/api/heartbeat_test.go
  • internal/api/heartbeat.go
  • internal/api/stream.go
internal/config/**/*.go

📄 CodeRabbit inference engine (AGENTS.md)

YAML + env var config loading (cleanenv); add field with yaml, env, and env-default tags

Files:

  • internal/config/config_test.go
  • internal/config/config.go
**/*_test.go

📄 CodeRabbit inference engine (AGENTS.md)

**/*_test.go: Use table-driven tests with tests := []struct{ name string; ... } and t.Run(tt.name, ...)
Use shared mocks from internal/testutil/ (MockPublisher, MockCache, MockDeduplicator, MockSubscriber) instead of creating ad-hoc mocks
Use testutil.MakeJWT(t, claims) and testutil.MakeExpiredJWT(t, claims) for auth tests
Use testutil.NewTestSchemaRegistry(tables) or discovery.NewSchemaRegistryFromMap(tables) for schema-aware tests
Use policy.NewMemoryStore(p) for in-memory policy testing without NATS
Use pipes.NewMemoryStore(queries...) for in-memory pipes testing without NATS
Use testutil.AssertJSONResponse(t, rec, status, expected) and testutil.AssertJSONContains(t, rec, status, substring) for response assertions

Files:

  • internal/config/config_test.go
  • internal/api/stream_test.go
  • internal/api/heartbeat_test.go
docs/src/content/docs/**/*.{md,mdx}

📄 CodeRabbit inference engine (AGENTS.md)

docs/src/content/docs/**/*.{md,mdx}: Author Mermaid diagrams vertically (flowchart TB/TD) to fit page column width (~46–58rem); reserve LR for genuinely short chains (≤3–4 nodes)
Keep Mermaid node labels short; use
for a second line rather than one long line; lean on semantic node classes (wh, win, pain, fail, infra, neutral, store, client)
Never sit two large diagrams side-by-side; wrap comparisons in

to stack them vertically

Files:

  • docs/src/content/docs/reverse-proxy.mdx
  • docs/src/content/docs/api.md
  • docs/src/content/docs/configuration.mdx
internal/api/**/*.go

📄 CodeRabbit inference engine (AGENTS.md)

Chi HTTP router, JWT/JWKS middleware (from auth/), ingest/query/structured-query/SSE/schema/DLQ/policy/pipes handlers, Hub

Files:

  • internal/api/stream_test.go
  • internal/api/heartbeat_test.go
  • internal/api/heartbeat.go
  • internal/api/stream.go
🧠 Learnings (3)
📚 Learning: 2026-05-20T01:02:00.784Z
Learnt from: EricAndrechek
Repo: Wave-RF/WaveHouse PR: 164
File: internal/api/router_test.go:289-350
Timestamp: 2026-05-20T01:02:00.784Z
Learning: In WaveHouse’s internal API tests (files matching internal/api/**/*_test.go), follow the existing separation-of-concerns convention for testing the RequireRole middleware: inject `ContextKeyRole` directly into the request `context.Context` instead of using `testutil.MakeJWT`/JWT-driven flows. Do not refactor role-gate tests to use JWT tokens—JWT parsing and token handling are covered separately in `middleware_test.go` (the dedicated JWT parsing tests), and mixing those concerns would expand the failure surface and reduce isolation.

Applied to files:

  • internal/api/stream_test.go
  • internal/api/heartbeat_test.go
📚 Learning: 2026-05-23T01:23:59.268Z
Learnt from: EricAndrechek
Repo: Wave-RF/WaveHouse PR: 174
File: internal/api/ingest_test.go:111-111
Timestamp: 2026-05-23T01:23:59.268Z
Learning: In WaveHouse Go tests in internal/api/**/*_test.go, use internal/testutil.AssertJSONErrorResponse(t, w) for HTTP error-path JSON assertions. Do not use (or reintroduce) package-local assertJSONErrorResponse helpers. AssertJSONErrorResponse verifies the response Content-Type is application/json, includes the X-Content-Type-Options: nosniff header, and that the JSON body contains an "error" field.

Applied to files:

  • internal/api/stream_test.go
  • internal/api/heartbeat_test.go
📚 Learning: 2026-06-10T15:01:09.027Z
Learnt from: EricAndrechek
Repo: Wave-RF/WaveHouse PR: 312
File: docs/src/content/docs/development.md:0-0
Timestamp: 2026-06-10T15:01:09.027Z
Learning: In this repo’s Markdown review (all .md files), do not flag capitalization/style issues for literal paths starting with ".github/" (or any substring that is a path beginning with ".github/"). Treat ".github" as the correct lowercase dotfile directory name, even when it appears inside prose or code spans; automated checks such as LanguageTool’s "(GITHUB)" rule commonly produce false positives for this literal filesystem path.

Applied to files:

  • docs/src/content/docs/api.md
  • CHANGELOG.md
🔇 Additional comments (7)
internal/config/config.go (1)

33-33: LGTM!

Also applies to: 108-111, 202-207

internal/config/config_test.go (1)

107-140: LGTM!

cmd/wavehouse/main.go (1)

362-365: LGTM!

CHANGELOG.md (1)

43-43: LGTM!

docs/src/content/docs/api.md (1)

519-519: LGTM!

docs/src/content/docs/configuration.mdx (1)

42-48: LGTM!

Also applies to: 223-226, 297-299

docs/src/content/docs/reverse-proxy.mdx (1)

71-71: LGTM!

Also applies to: 137-138, 199-199

Comment thread docs/src/content/docs/configuration.mdx Outdated
Comment thread internal/api/heartbeat_test.go Outdated
Comment thread internal/api/heartbeat.go Outdated
@github-project-automation github-project-automation Bot moved this from Ready to In review in WaveHouse Task Board Jun 18, 2026
coderabbitai[bot]
coderabbitai Bot previously approved these changes Jun 18, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
internal/config/config_test.go (1)

117-139: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Convert these subtests to a table-driven test.

The assertions are the same harness repeated three times with different inputs, which is exactly the pattern the repo asks to keep table-driven in *_test.go files.

Suggested refactor
 func TestValidate_KeepaliveValues(t *testing.T) {
 	t.Parallel()

 	base := func() Config {
 		return Config{
 			Server:     Server{Port: 8080},
 			ClickHouse: ClickHouse{HTTPScheme: "http", QueryTimeout: 30 * time.Second},
 			Schema:     Schema{RefreshInterval: 60},
 		}
 	}

-	t.Run("negative interval is rejected", func(t *testing.T) {
-		t.Parallel()
-		cfg := base()
-		cfg.Stream.KeepaliveInterval = -time.Second
-		err := cfg.Validate()
-		require.Error(t, err)
-		assert.Contains(t, err.Error(), "keepalive_interval")
-	})
-
-	t.Run("negative buckets is rejected", func(t *testing.T) {
-		t.Parallel()
-		cfg := base()
-		cfg.Stream.KeepaliveBuckets = -1
-		err := cfg.Validate()
-		require.Error(t, err)
-		assert.Contains(t, err.Error(), "keepalive_buckets")
-	})
-
-	t.Run("zero means use default, not an error", func(t *testing.T) {
-		t.Parallel()
-		cfg := base() // Stream left at zero values
-		assert.NoError(t, cfg.Validate())
-	})
+	tests := []struct {
+		name     string
+		mutate   func(*Config)
+		wantErr  string
+	}{
+		{
+			name: "negative interval is rejected",
+			mutate: func(cfg *Config) {
+				cfg.Stream.KeepaliveInterval = -time.Second
+			},
+			wantErr: "keepalive_interval",
+		},
+		{
+			name: "negative buckets is rejected",
+			mutate: func(cfg *Config) {
+				cfg.Stream.KeepaliveBuckets = -1
+			},
+			wantErr: "keepalive_buckets",
+		},
+		{
+			name:    "zero means use default, not an error",
+			mutate:  func(cfg *Config) {},
+			wantErr: "",
+		},
+	}
+
+	for _, tt := range tests {
+		tt := tt
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+			cfg := base()
+			tt.mutate(&cfg)
+
+			err := cfg.Validate()
+			if tt.wantErr == "" {
+				assert.NoError(t, err)
+				return
+			}
+			require.Error(t, err)
+			assert.Contains(t, err.Error(), tt.wantErr)
+		})
+	}
 }

As per coding guidelines, **/*_test.go: Use table-driven tests with t.Run(tt.name, ...) and add corresponding tests for each new function.

Source: Coding guidelines

internal/api/stream_test.go (1)

210-281: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Use the repo's table-driven pattern for these new SSE tests.

These scenarios are added as standalone tests, but this path's guideline requires *_test.go changes to use t.Run(tt.name, ...). As per coding guidelines, **/*_test.go: "Use table-driven tests with t.Run(tt.name, ...) and add corresponding tests for each new function."

Source: Coding guidelines


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 453ca959-1209-4d1a-8a84-83774a78bb95

📥 Commits

Reviewing files that changed from the base of the PR and between baa5107 and 86e041d.

📒 Files selected for processing (20)
  • AGENTS.md
  • CHANGELOG.md
  • cmd/wavehouse/main.go
  • config.yaml
  • docs/src/content/docs/api.md
  • docs/src/content/docs/architecture.md
  • docs/src/content/docs/configuration.mdx
  • docs/src/content/docs/deployment.md
  • docs/src/content/docs/reverse-proxy.mdx
  • internal/api/stream.go
  • internal/api/stream_test.go
  • internal/config/config.go
  • internal/config/config_test.go
  • internal/stream/bucket.go
  • internal/stream/bucket_test.go
  • internal/stream/doc.go
  • internal/stream/heartbeat.go
  • internal/stream/heartbeat_test.go
  • internal/stream/subscriber.go
  • internal/stream/subscriber_test.go
📜 Review details
⏰ Context from checks skipped due to timeout. (6)
  • GitHub Check: Integration tests
  • GitHub Check: E2E tests
  • GitHub Check: Docs build
  • GitHub Check: Coverage
  • GitHub Check: Unit tests
  • GitHub Check: Lint
🧰 Additional context used
📓 Path-based instructions (8)
**/*_test.go

📄 CodeRabbit inference engine (AGENTS.md)

Use table-driven tests with t.Run(tt.name, ...) and add corresponding tests for each new function.

Files:

  • internal/stream/subscriber_test.go
  • internal/config/config_test.go
  • internal/stream/bucket_test.go
  • internal/stream/heartbeat_test.go
  • internal/api/stream_test.go
**/*.{go,ts,tsx,js,jsx}

📄 CodeRabbit inference engine (AGENTS.md)

Use the repository’s established formatting and naming conventions, including gofumpt-compatible Go formatting and camelCase where appropriate.

Files:

  • internal/stream/subscriber_test.go
  • internal/stream/doc.go
  • internal/config/config_test.go
  • internal/stream/subscriber.go
  • internal/stream/bucket_test.go
  • internal/config/config.go
  • internal/stream/bucket.go
  • internal/stream/heartbeat.go
  • internal/stream/heartbeat_test.go
  • cmd/wavehouse/main.go
  • internal/api/stream.go
  • internal/api/stream_test.go
docs/src/content/docs/deployment.md

📄 CodeRabbit inference engine (AGENTS.md)

docs/src/content/docs/deployment.md: Document deployment-related configuration and schema changes in the deployment docs.
Update the deployment docs when the ingest/event format or ClickHouse insert columns change.

Files:

  • docs/src/content/docs/deployment.md
AGENTS.md

📄 CodeRabbit inference engine (AGENTS.md)

When changing or adding a core package or architecture invariant, update AGENTS.md so the repository instructions stay aligned with the codebase structure.

Files:

  • AGENTS.md
internal/config/config.go

📄 CodeRabbit inference engine (AGENTS.md)

internal/config/config.go: Keep configuration struct tags (yaml, env, and env-default) in sync with the documented configuration behavior.
Keep configuration struct tags and the documented configuration surface aligned.

Files:

  • internal/config/config.go
config.yaml

📄 CodeRabbit inference engine (AGENTS.md)

Keep the example/default configuration file aligned with any config option changes.

Files:

  • config.yaml
docs/src/content/docs/api.md

📄 CodeRabbit inference engine (AGENTS.md)

Update the API docs for any new or modified endpoint, response shape, or ingest/event format.

Files:

  • docs/src/content/docs/api.md
docs/src/content/docs/architecture.md

📄 CodeRabbit inference engine (AGENTS.md)

When changing or adding a core package or architecture invariant, update the architecture documentation to keep the named invariant in sync.

Files:

  • docs/src/content/docs/architecture.md
🧠 Learnings (4)
📓 Common learnings
Learnt from: CR
Repo: Wave-RF/WaveHouse

Timestamp: 2026-06-25T22:09:02.439Z
Learning: Validate locally before every push by running `make ci` the documented way; do not rely on CI as the first feedback loop.
Learnt from: CR
Repo: Wave-RF/WaveHouse

Timestamp: 2026-06-25T22:09:02.439Z
Learning: On PR branches, run the full pre-push reviewer flow (`/prepush`) and ensure every required reviewer in `scripts/pre-push-reviewers.sh` reaches `ship_it` or is deliberately skipped on the record before pushing.
Learnt from: CR
Repo: Wave-RF/WaveHouse

Timestamp: 2026-06-25T22:09:02.439Z
Learning: Every code change must include its corresponding documentation updates and a `CHANGELOG.md` update in the same PR.
Learnt from: CR
Repo: Wave-RF/WaveHouse

Timestamp: 2026-06-25T22:09:02.439Z
Learning: Address every review finding substantively; either fix it, track it in an issue, mention the bot, and resolve the thread.
Learnt from: CR
Repo: Wave-RF/WaveHouse

Timestamp: 2026-06-25T22:09:02.439Z
Learning: Agents must create draft PRs with a Conventional Commits-compliant title no longer than 72 characters, and validate the title with `scripts/lint-pr-title.sh` before creating the PR.
Learnt from: CR
Repo: Wave-RF/WaveHouse

Timestamp: 2026-06-25T22:09:02.439Z
Learning: Never force-push or rebase a PR branch; merge `origin/main` instead when syncing with upstream.
Learnt from: CR
Repo: Wave-RF/WaveHouse

Timestamp: 2026-06-25T22:09:02.439Z
Learning: Do not hand-write markers or use `--no-verify`; use the documented hooks and skip commands instead.
📚 Learning: 2026-06-10T15:01:09.027Z
Learnt from: EricAndrechek
Repo: Wave-RF/WaveHouse PR: 312
File: docs/src/content/docs/development.md:0-0
Timestamp: 2026-06-10T15:01:09.027Z
Learning: In this repo’s Markdown review (all .md files), do not flag capitalization/style issues for literal paths starting with ".github/" (or any substring that is a path beginning with ".github/"). Treat ".github" as the correct lowercase dotfile directory name, even when it appears inside prose or code spans; automated checks such as LanguageTool’s "(GITHUB)" rule commonly produce false positives for this literal filesystem path.

Applied to files:

  • docs/src/content/docs/deployment.md
  • AGENTS.md
  • CHANGELOG.md
  • docs/src/content/docs/api.md
  • docs/src/content/docs/architecture.md
📚 Learning: 2026-05-20T01:02:00.784Z
Learnt from: EricAndrechek
Repo: Wave-RF/WaveHouse PR: 164
File: internal/api/router_test.go:289-350
Timestamp: 2026-05-20T01:02:00.784Z
Learning: In WaveHouse’s internal API tests (files matching internal/api/**/*_test.go), follow the existing separation-of-concerns convention for testing the RequireRole middleware: inject `ContextKeyRole` directly into the request `context.Context` instead of using `testutil.MakeJWT`/JWT-driven flows. Do not refactor role-gate tests to use JWT tokens—JWT parsing and token handling are covered separately in `middleware_test.go` (the dedicated JWT parsing tests), and mixing those concerns would expand the failure surface and reduce isolation.

Applied to files:

  • internal/api/stream_test.go
📚 Learning: 2026-05-23T01:23:59.268Z
Learnt from: EricAndrechek
Repo: Wave-RF/WaveHouse PR: 174
File: internal/api/ingest_test.go:111-111
Timestamp: 2026-05-23T01:23:59.268Z
Learning: In WaveHouse Go tests in internal/api/**/*_test.go, use internal/testutil.AssertJSONErrorResponse(t, w) for HTTP error-path JSON assertions. Do not use (or reintroduce) package-local assertJSONErrorResponse helpers. AssertJSONErrorResponse verifies the response Content-Type is application/json, includes the X-Content-Type-Options: nosniff header, and that the JSON body contains an "error" field.

Applied to files:

  • internal/api/stream_test.go
🪛 LanguageTool
docs/src/content/docs/reverse-proxy.mdx

[style] ~105-~105: A comma is missing here.
Context: ...le configs ship ~50s) | raise both, e.g. timeout server 1h (note: `timeout tun...

(EG_NO_COMMA)

🔇 Additional comments (5)
internal/stream/subscriber.go (1)

10-45: LGTM!

internal/stream/bucket.go (1)

10-62: LGTM!

internal/stream/heartbeat_test.go (1)

100-105: 📐 Maintainability & Code Quality

No action needed The repo targets Go 1.26.4, so both for range N and t.Context() are supported here.

internal/stream/bucket_test.go (1)

71-75: 📐 Maintainability & Code Quality

No change needed — the module targets Go 1.26.4 and the builder image uses Go 1.26, so these integer-range loops are supported.

internal/api/stream_test.go (1)

214-215: 🎯 Functional Correctness

No Go toolchain change needed go.mod pins Go 1.26.4, and CI/container builds follow that version, so t.Context() and for range conns are supported.

Comment thread docs/src/content/docs/architecture.md
Comment thread docs/src/content/docs/reverse-proxy.mdx Outdated
Comment thread docs/src/content/docs/reverse-proxy.mdx Outdated
Comment thread internal/stream/heartbeat.go
Comment thread internal/stream/subscriber_test.go
@github-project-automation github-project-automation Bot moved this from Ready to In review in WaveHouse Task Board Jun 25, 2026
Addresses CodeRabbit review feedback on PR #346, plus a verification pass
(subagents re-fetched every cited vendor doc) on the "Idle timeouts by
provider" tables — the values and links were sloppy.

Citation corrections (reverse-proxy.mdx):
- Cloudflare proxied 100s → ~120s (current Proxy Read Timeout).
- Cloudflare Tunnel: the cited `keepAliveTimeout` / `--proxy-keepalive-timeout`
  tunes idle origin-pool reuse, NOT the edge idle reset that drops a quiet
  stream — reworded to the ~120s edge reset (Enterprise-only), same mechanism
  as a 524.
- Azure Front Door 60s → 30s (the 60s default isn't in any official doc; the
  docs state 30s).
- AWS API Gateway "29s hard" → "29s default max; raisable for Regional/private
  since 2024, hard only for edge-optimized".
- Traefik "none" → no response-write timeout (`writeTimeout` 0); the old claim
  was wrong and its link was a dead redirect.
- Re-pointed links that resolved but didn't document the value (ingress-nginx
  → ConfigMap, Apache → core `Timeout`, ALB/NLB/CloudFront → the pages that
  state the default, GCP → docs.cloud.google.com host, Railway → the SSE
  guide, Fastly → first-byte timeout) and added the `tcp_keepalive_time`
  man-page citation.
- Dropped two rows we can't honestly cite: Akamai (cited page states 5s, not
  120s) and Mobile/CGNAT (RFC 6888 doesn't state the 35–65s figure, and that
  figure is a UDP measurement — SSE is TCP).
- Softened the "clears CDN cases" overstatement; added Azure Front Door to
  the sub-30s list.

Code:
- NewHeartbeater: clamp `buckets` to 1 when `period < buckets`, so the
  effective period stays ≈ period in the sub-nanosecond-per-tick edge case
  instead of ballooning to period × buckets (+test).
- config_test `TestValidate_KeepaliveValues` → table-driven.
- architecture.md: add `stream/` to the package tree (a second enumeration
  still omitted it).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XwhWrnP4q4634V3bjm1ZjJ
coderabbitai[bot]
coderabbitai Bot previously approved these changes Jun 26, 2026
EricAndrechek and others added 2 commits June 26, 2026 09:09
Follow-up on the keepalive PR (#226), from review discussion on the new
stream package.

Observability:
- Add internal/stream/metrics.go: active streams (UpDownCounter), stream
  lifetime (histogram), and frames/bytes sent (counters, kind=keepalive vs
  kind=event). Nil-safe, so the handler holds one unconditionally and tests
  skip wiring it. Constructed in main.go after InitProvider; recorded at the
  handler's connect/disconnect and write sites.

Tracing cleanup:
- Drop the per-event SSE.PushEvent span. The router already excludes
  /v1/stream from HTTP tracing; a span per delivered event per subscriber is
  high-volume, low-value, and existed only to read the hub's trace_headers
  envelope. With it gone the envelope is pure overhead (base64-wraps payload
  and double-marshals every broadcast), so Hub.Broadcast now sends the raw
  event bytes and the handler's live case is symmetric with the replay path.

Hygiene:
- Tighten the stream package + handler comments to terse godoc (one-line
  summaries; "why" only where non-obvious — the ring clamp, the
  snapshot-then-send fan-out). #294 context now lives once in doc.go.
- main.go: group the heartbeater construct/field/launch lines so the
  goroutine launch isn't wedged between handler field assignments.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XwhWrnP4q4634V3bjm1ZjJ
@github-actions github-actions Bot added the area/observability Metrics, logs, traces, health, profiling label Jun 26, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal/api/stream.go (1)

124-129: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Register the keepalive subscriber before the replay path.

Lines 124-129 run only after the synchronous replayFromNATS block above. On reconnects with a slow or empty replay, the handler can still sit silent long enough for an intermediary to idle-close the stream before it ever joins the wheel. Move the NewSubscriber/Add/defer Remove block above gap fill so heartbeats cover that window too.

Suggested fix
 	h.Metrics.ConnOpened()
 	connectedAt := time.Now()
 	defer func() { h.Metrics.ConnClosed(time.Since(connectedAt)) }()
+
+	sub := stream.NewSubscriber()
+	if h.Heartbeater != nil {
+		h.Heartbeater.Add(sub)
+		defer h.Heartbeater.Remove(sub)
+	}
 
 	// Subscribe for live events.
 	ch := make(chan []byte, 64)
 	h.Hub.Subscribe(topic, ch)
 	defer h.Hub.Unsubscribe(topic, ch)
@@
-	// Register with the shared keepalive wheel so a quiet stream isn't idle-closed
-	// by a proxy/tunnel between events.
-	sub := stream.NewSubscriber()
-	if h.Heartbeater != nil {
-		h.Heartbeater.Add(sub)
-		defer h.Heartbeater.Remove(sub)
-	}
-
 	for {

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: b2c949a3-bbc6-40d4-927f-866dcc156bb9

📥 Commits

Reviewing files that changed from the base of the PR and between 86e041d and 35540eb.

📒 Files selected for processing (17)
  • AGENTS.md
  • CHANGELOG.md
  • cmd/wavehouse/main.go
  • docs/src/content/docs/architecture.md
  • docs/src/content/docs/configuration.mdx
  • docs/src/content/docs/reverse-proxy.mdx
  • internal/api/hub.go
  • internal/api/hub_test.go
  • internal/api/stream.go
  • internal/config/config_test.go
  • internal/stream/bucket.go
  • internal/stream/doc.go
  • internal/stream/heartbeat.go
  • internal/stream/heartbeat_test.go
  • internal/stream/metrics.go
  • internal/stream/metrics_test.go
  • internal/stream/subscriber.go
📜 Review details
⏰ Context from checks skipped due to timeout. (4)
  • GitHub Check: E2E tests
  • GitHub Check: Coverage
  • GitHub Check: Docs build
  • GitHub Check: Lint
⚠️ CI failures not shown inline (2)

GitHub Actions: PR housekeeping / PR housekeeping: fix(stream): emit SSE heartbeats so idle streams survive proxy timeouts

Conclusion: failure

View job details

##[group]Run # Single source of truth for the rule: scripts/lint-pr-title.sh — the
 �[36;1m# Single source of truth for the rule: scripts/lint-pr-title.sh — the�[0m
 �[36;1m# SAME validator the local agent gate runs (.claude/hooks/agent-bash-gate.sh),�[0m
 �[36;1m# so CI and local can't drift. The checkout above is ref: main, so this is�[0m
 �[36;1m# always the default-branch script. Dependabot's grouped-update titles�[0m
 �[36;1m# routinely exceed the 72-char subject cap and the format isn't�[0m
 �[36;1m# configurable, so Dependabot PRs are exempt from the length check�[0m
 �[36;1m# (the format check still applies).�[0m
 �[36;1mif [[ "$PR_AUTHOR" == "dependabot[bot]" || "$PR_AUTHOR" == "app/dependabot" ]]; then�[0m
 �[36;1m  export PR_TITLE_SKIP_LENGTH=1�[0m
 �[36;1mfi�[0m
 �[36;1m�[0m
 �[36;1mif reason=$(bash scripts/lint-pr-title.sh "$PR_TITLE" 2>&1); then�[0m
 �[36;1m  echo "passed=true" >> "$GITHUB_OUTPUT"�[0m
 �[36;1m  echo "PR title OK: $PR_TITLE"�[0m
 �[36;1melse�[0m
 �[36;1m  echo "passed=false" >> "$GITHUB_OUTPUT"�[0m
 �[36;1m  printf '%s\n' "$reason"�[0m
 �[36;1m  echo "::error::$(printf '%s' "$reason" | head -1)"�[0m

GitHub Actions: PR housekeeping / 0_PR housekeeping.txt: fix(stream): emit SSE heartbeats so idle streams survive proxy timeouts

Conclusion: failure

View job details

##[group]Run # Single source of truth for the rule: scripts/lint-pr-title.sh — the
 �[36;1m# Single source of truth for the rule: scripts/lint-pr-title.sh — the�[0m
 �[36;1m# SAME validator the local agent gate runs (.claude/hooks/agent-bash-gate.sh),�[0m
 �[36;1m# so CI and local can't drift. The checkout above is ref: main, so this is�[0m
 �[36;1m# always the default-branch script. Dependabot's grouped-update titles�[0m
 �[36;1m# routinely exceed the 72-char subject cap and the format isn't�[0m
 �[36;1m# configurable, so Dependabot PRs are exempt from the length check�[0m
 �[36;1m# (the format check still applies).�[0m
 �[36;1mif [[ "$PR_AUTHOR" == "dependabot[bot]" || "$PR_AUTHOR" == "app/dependabot" ]]; then�[0m
 �[36;1m  export PR_TITLE_SKIP_LENGTH=1�[0m
 �[36;1mfi�[0m
 �[36;1m�[0m
 �[36;1mif reason=$(bash scripts/lint-pr-title.sh "$PR_TITLE" 2>&1); then�[0m
 �[36;1m  echo "passed=true" >> "$GITHUB_OUTPUT"�[0m
 �[36;1m  echo "PR title OK: $PR_TITLE"�[0m
 �[36;1melse�[0m
 �[36;1m  echo "passed=false" >> "$GITHUB_OUTPUT"�[0m
 �[36;1m  printf '%s\n' "$reason"�[0m
 �[36;1m  echo "::error::$(printf '%s' "$reason" | head -1)"�[0m
🧰 Additional context used
📓 Path-based instructions (9)
**/*

📄 CodeRabbit inference engine (AGENTS.md)

**/*: Before pushing a PR branch, run /prepush (which discovers the required reviewers from scripts/pre-push-reviewers.sh) and satisfy every reviewer that applies to the change.
Every code change must update its corresponding documentation and CHANGELOG.md in the same PR.
Address and resolve every review finding with a substantive reply, a fix, or a tracked issue; never silently drop review comments.
Create PRs as drafts only, and ensure the PR title passes the Conventional Commits gate and stays within 72 characters.
Never force-push or rebase a PR branch; merge origin/main instead when syncing with upstream changes.
Never hand-write marker files or use --no-verify; use the documented gates and helper scripts instead.

Files:

  • internal/stream/doc.go
  • AGENTS.md
  • internal/stream/metrics.go
  • internal/stream/bucket.go
  • cmd/wavehouse/main.go
  • internal/config/config_test.go
  • internal/stream/metrics_test.go
  • internal/stream/subscriber.go
  • docs/src/content/docs/configuration.mdx
  • docs/src/content/docs/reverse-proxy.mdx
  • docs/src/content/docs/architecture.md
  • internal/api/hub.go
  • internal/stream/heartbeat_test.go
  • CHANGELOG.md
  • internal/stream/heartbeat.go
  • internal/api/stream.go
  • internal/api/hub_test.go
internal/stream/**/*.go

📄 CodeRabbit inference engine (AGENTS.md)

SSE fan-out primitives must keep the subscriber queue, bucket fan-out, heartbeater, and stream metrics behavior intact.

Files:

  • internal/stream/doc.go
  • internal/stream/metrics.go
  • internal/stream/bucket.go
  • internal/stream/metrics_test.go
  • internal/stream/subscriber.go
  • internal/stream/heartbeat_test.go
  • internal/stream/heartbeat.go
AGENTS.md

📄 CodeRabbit inference engine (AGENTS.md)

Keep CLAUDE.md as a thin pointer and avoid duplicating the guidance elsewhere.

Files:

  • AGENTS.md
cmd/wavehouse/**/*.go

📄 CodeRabbit inference engine (AGENTS.md)

cmd/wavehouse should remain a thin binary entry point and only contain wiring/bootstrap logic.

Files:

  • cmd/wavehouse/main.go
internal/config/**/*.go

📄 CodeRabbit inference engine (AGENTS.md)

Configuration structs must use the defined YAML/env/env-default tags and stay in sync with documented config options.

Files:

  • internal/config/config_test.go
**/*_test.go

📄 CodeRabbit inference engine (AGENTS.md)

Write tests in table-driven form using t.Run(tt.name, ...), and add test coverage for every new function.

Files:

  • internal/config/config_test.go
  • internal/stream/metrics_test.go
  • internal/stream/heartbeat_test.go
  • internal/api/hub_test.go
docs/src/content/docs/**/*.md

📄 CodeRabbit inference engine (AGENTS.md)

When authoring Mermaid diagrams, prefer top-down layouts, keep labels short, and avoid side-by-side large diagrams.

Files:

  • docs/src/content/docs/architecture.md
docs/src/content/docs/architecture.md

📄 CodeRabbit inference engine (AGENTS.md)

When changing core packages or architecture invariants, update the architecture documentation to preserve the named invariant index and rationale.

Files:

  • docs/src/content/docs/architecture.md
internal/api/**/*.go

📄 CodeRabbit inference engine (AGENTS.md)

Use Chi v5 routing and keep HTTP handlers, middleware, and route registration in the internal/api layer.

Files:

  • internal/api/hub.go
  • internal/api/stream.go
  • internal/api/hub_test.go
🧠 Learnings (4)
📚 Learning: 2026-06-10T15:01:09.027Z
Learnt from: EricAndrechek
Repo: Wave-RF/WaveHouse PR: 312
File: docs/src/content/docs/development.md:0-0
Timestamp: 2026-06-10T15:01:09.027Z
Learning: In this repo’s Markdown review (all .md files), do not flag capitalization/style issues for literal paths starting with ".github/" (or any substring that is a path beginning with ".github/"). Treat ".github" as the correct lowercase dotfile directory name, even when it appears inside prose or code spans; automated checks such as LanguageTool’s "(GITHUB)" rule commonly produce false positives for this literal filesystem path.

Applied to files:

  • AGENTS.md
  • docs/src/content/docs/architecture.md
  • CHANGELOG.md
📚 Learning: 2026-06-26T12:23:22.696Z
Learnt from: EricAndrechek
Repo: Wave-RF/WaveHouse PR: 346
File: internal/stream/subscriber_test.go:9-28
Timestamp: 2026-06-26T12:23:22.696Z
Learning: In this Go repository, prefer table-driven tests (e.g., `[]struct{...}` with `t.Run(...)`) only for tests that cover multiple scenarios/inputs and can be cleanly enumerated. Do not artificially rewrite a clear single-scenario sequential behavioral-flow test into a table-driven form just to fit the pattern; if there’s only one meaningful scenario, keep the test as a straightforward linear flow (as in `TestSubscriber_SendDeliversThenDropsWhenFull`).

Applied to files:

  • internal/config/config_test.go
  • internal/stream/metrics_test.go
  • internal/stream/heartbeat_test.go
  • internal/api/hub_test.go
📚 Learning: 2026-05-20T01:02:00.784Z
Learnt from: EricAndrechek
Repo: Wave-RF/WaveHouse PR: 164
File: internal/api/router_test.go:289-350
Timestamp: 2026-05-20T01:02:00.784Z
Learning: In WaveHouse’s internal API tests (files matching internal/api/**/*_test.go), follow the existing separation-of-concerns convention for testing the RequireRole middleware: inject `ContextKeyRole` directly into the request `context.Context` instead of using `testutil.MakeJWT`/JWT-driven flows. Do not refactor role-gate tests to use JWT tokens—JWT parsing and token handling are covered separately in `middleware_test.go` (the dedicated JWT parsing tests), and mixing those concerns would expand the failure surface and reduce isolation.

Applied to files:

  • internal/api/hub_test.go
📚 Learning: 2026-05-23T01:23:59.268Z
Learnt from: EricAndrechek
Repo: Wave-RF/WaveHouse PR: 174
File: internal/api/ingest_test.go:111-111
Timestamp: 2026-05-23T01:23:59.268Z
Learning: In WaveHouse Go tests in internal/api/**/*_test.go, use internal/testutil.AssertJSONErrorResponse(t, w) for HTTP error-path JSON assertions. Do not use (or reintroduce) package-local assertJSONErrorResponse helpers. AssertJSONErrorResponse verifies the response Content-Type is application/json, includes the X-Content-Type-Options: nosniff header, and that the JSON body contains an "error" field.

Applied to files:

  • internal/api/hub_test.go
🪛 LanguageTool
docs/src/content/docs/reverse-proxy.mdx

[style] ~105-~105: A comma is missing here.
Context: ...ng); examples ship ~50s | set both, e.g. timeout server 1h (timeout tunnel i...

(EG_NO_COMMA)

🔇 Additional comments (8)
internal/config/config_test.go (1)

117-151: LGTM!

docs/src/content/docs/configuration.mdx (1)

49-49: LGTM!

docs/src/content/docs/reverse-proxy.mdx (1)

71-75: LGTM!

Also applies to: 94-94, 103-118, 120-121, 131-135, 247-247

docs/src/content/docs/architecture.md (1)

64-65: LGTM!

Also applies to: 79-94

AGENTS.md (1)

29-44: LGTM!

CHANGELOG.md (1)

43-43: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Match the changelog to the actual keepalive payload.

The implementation sends a bare : SSE comment frame, not : heartbeat, so this entry currently describes the wrong wire output.

Suggested fix
-- **SSE streams emit a periodic keepalive comment so quiet connections survive proxy and tunnel idle timeouts** (`internal/stream/` (new package: `subscriber.go`, `bucket.go`, `heartbeat.go` + tests), `internal/api/{stream,stream_test}.go`, `internal/config/{config,config_test}.go`, `cmd/wavehouse/main.go`, `config.yaml`, `docs/src/content/docs/{reverse-proxy.mdx,api.md,configuration.mdx,architecture.md,deployment.md}`): closes `#226`. The stream handler (`internal/stream/metrics.go`, `internal/api/{hub,hub_test}.go` also touched) wrote a single `: connected` comment on open and then sent nothing until an event arrived, so on a quiet table an intermediary's idle timeout reset the connection — Cloudflare's edge (and a Cloudflare Tunnel) dropped quiet streams about every two minutes in dogfooding, and every `curl`/server-side reconnect re-ran NATS gap-fill (browser `EventSource` masked it by auto-reconnecting). A single shared `Heartbeater` goroutine now drives keepalives for every live connection: connections are spread across a ring of buckets and one bucket is pushed a minimal `:` SSE keepalive comment per tick — the writes don't all fire at the same instant, and the per-connection period comes from one timer instead of a `time.Ticker` per connection.
+- **SSE streams emit a periodic keepalive comment so quiet connections survive proxy and tunnel idle timeouts** (`internal/stream/` (new package: `subscriber.go`, `bucket.go`, `heartbeat.go` + tests), `internal/api/{stream,stream_test}.go`, `internal/config/{config,config_test}.go`, `cmd/wavehouse/main.go`, `config.yaml`, `docs/src/content/docs/{reverse-proxy.mdx,api.md,configuration.mdx,architecture.md,deployment.md}`): closes `#226`. The stream handler (`internal/stream/metrics.go`, `internal/api/{hub,hub_test}.go` also touched) wrote a single `: connected` comment on open and then sent nothing until an event arrived, so on a quiet table an intermediary's idle timeout reset the connection — Cloudflare's edge (and a Cloudflare Tunnel) dropped quiet streams about every two minutes in dogfooding, and every `curl`/server-side reconnect re-ran NATS gap-fill (browser `EventSource` masked it by auto-reconnecting). A single shared `Heartbeater` goroutine now drives keepalives for every live connection: connections are spread across a ring of buckets and one bucket is pushed a minimal `:` SSE keepalive comment per tick — the writes don't all fire at the same instant, and the per-connection period comes from one timer instead of a `time.Ticker` per connection.
			> Likely an incorrect or invalid review comment.
internal/api/hub.go (1)

57-67: LGTM!

internal/stream/metrics.go (1)

12-70: LGTM!

Comment thread internal/api/hub_test.go Outdated
Comment thread internal/api/stream.go
Comment thread internal/stream/metrics_test.go Outdated
Review follow-up (CodeRabbit + Eric) on the SSE metrics commit.

- stream.go: the live-event write now returns on error like the keepalive
  write, so a vanished client ends the handler (and stops counting bytes that
  never reached the socket) instead of lingering until another signal.
- Centralize the #294 forward-reference in doc.go; the per-symbol comments in
  subscriber.go / bucket.go / heartbeat.go and the two handler cases now
  describe only today's behavior, and the remaining over-long comments are
  trimmed.
- hub_test.go: assert the exact raw payload bytes, so the raw-broadcast
  contract can't silently regress to a re-wrapped envelope.
- metrics_test.go: assert the collected datapoints — active back to 0, the
  duration histogram count/sum, and per-kind frame/byte counts — not just the
  instrument names.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XwhWrnP4q4634V3bjm1ZjJ
coderabbitai[bot]
coderabbitai Bot previously approved these changes Jun 26, 2026
@EricAndrechek

Copy link
Copy Markdown
Member

Follow-up commits on the keepalive work35540eb2, 07ddccb9

Two rounds landed on top of the keepalive wheel:

  1. Stream observability + tracing cleanup (35540eb2): added SSE metrics in internal/stream/metrics.gowavehouse_sse_active_streams, wavehouse_sse_stream_duration_seconds, and wavehouse_sse_frames_sent_total / wavehouse_sse_bytes_sent_total (labeled kind=keepalive/event), recorded at the handler's connect/disconnect and write sites. Dropped the per-event SSE.PushEvent span (the router already excludes /v1/stream from the HTTP tracer) and, with it, the hub's {trace_headers, payload} envelope — Hub.Broadcast now forwards the raw event bytes, so the live path is symmetric with replay. Long-lived streams are observed via metrics, not per-event traces.

  2. Review follow-up (07ddccb9): the live-event write now returns on error like the keepalive write; the #294 forward-reference is centralized in doc.go; comments tightened; and the hub/metrics tests strengthened (exact-byte + collected-datapoint assertions).

@coderabbitai — re your outside-diff note on registering the keepalive subscriber before the replay path: declined, with reasoning. Keepalives are only written inside the select loop, which runs after the synchronous gap-fill, so moving registration earlier writes no keepalive during replay (it would at most buffer one frame in the cap-1 queue). And there's no real idle window to cover: an empty gap-fill returns within the ~500ms FetchMaxWait, and a non-empty one is filled with per-event writes. Genuine coverage during a long replay would mean interleaving gap-fill with the select loop, which overlaps the #294 delivery-path rework and is out of scope here.

CI is green and all review threads are resolved.

@coderabbitai

coderabbitai Bot commented Jun 26, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

@EricAndrechek Thanks for the detailed follow-up — that rationale makes sense.

You’re right that in internal/api/stream.go, keepalives are only actually written from the select loop after the synchronous replay/gap-fill completes, so registering the internal/stream.Subscriber earlier would not provide real replay-time coverage. At most it could queue a single heartbeat in the cap-1 outbound buffer, and with the empty replay path bounded by the ~500ms FetchMaxWait (or otherwise actively writing replay events), there isn’t a meaningful idle window there to protect.

The metrics/tracing cleanup and the live-path write-error symmetry also look good. I’m happy to withdraw that earlier outside-diff concern.


✏️ Learnings added
Learnt from: EricAndrechek
URL: https://github.com/Wave-RF/WaveHouse/pull/346

Timestamp: 2026-06-26T15:07:28.734Z
Learning: In the Go SSE implementation in `internal/api/stream.go`, keepalive frames from `internal/stream.Heartbeater` are only written from the post-replay select loop. The replay/gap-fill step is synchronous before entering that loop, so registering the `internal/stream.Subscriber` before replay does not materially improve idle-time coverage during replay; it can at most buffer one heartbeat in the subscriber's capacity-1 queue. Covering a genuinely long replay would require interleaving replay with the select loop and is tied to the broader delivery-path rework tracked by Issue `#294`.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

@github-project-automation github-project-automation Bot moved this from In review to In progress in WaveHouse Task Board Jun 26, 2026
EricAndrechek
EricAndrechek previously approved these changes Jun 26, 2026
Comment thread internal/api/stream.go Outdated
Addresses @taitelee's review on stream.go: the gap-fill/replay writes
weren't recording FrameSent, so the frame/byte totals silently undercounted
whatever a reconnect replays.

- Add a kind=replay label and record each replayed frame, so reconnect
  catch-up volume is counted but stays separable from live-tail delivery.
- Extract the two identical replay callbacks into one sendReplay closure,
  which also stops the gap-fill on a write error (client gone) instead of
  writing into a dead socket — the same asymmetry the keepalive/event cases
  already handle.
- Doc-sync: architecture.md + CHANGELOG now list all three kinds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XwhWrnP4q4634V3bjm1ZjJ
@EricAndrechek
EricAndrechek dismissed stale reviews from coderabbitai[bot] and themself via 486324d June 26, 2026 20:26
@EricAndrechek
EricAndrechek added this pull request to the merge queue Jun 26, 2026
Merged via the queue into main with commit 725fdee Jun 26, 2026
20 checks passed
@EricAndrechek
EricAndrechek deleted the sse-heartbeat branch June 26, 2026 20:46
@github-project-automation github-project-automation Bot moved this from In progress to Done in WaveHouse Task Board Jun 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/api HTTP handlers, routing, middleware area/docs Documentation, site/, README area/infra CI, build, deploy, Docker, release area/observability Metrics, logs, traces, health, profiling documentation Improvements or additions to documentation go Pull requests that update go code

Projects

Archived in project

Development

Successfully merging this pull request may close these issues.

SSE: emit periodic keepalive heartbeats so idle streams survive proxy timeouts

2 participants