Skip to content

feat(stream): give each tenant its own keepalive wheel - #738

Merged
taitelee merged 4 commits into
mainfrom
feat/keepalive-per-tenant
Oct 7, 2026
Merged

taitelee merged 4 commits into
mainfrom
feat/keepalive-per-tenant

Conversation

@taitelee

@taitelee taitelee commented Oct 6, 2026

Copy link
Copy Markdown
Member

Summary

stream.keepalive_interval and stream.keepalive_buckets are per tenant, but one keepalive wheel served the whole process at the shortest interval among the served tenants, so one tenant's setting, or its reload, set every tenant's cadence. Each served tenant now has a wheel of its own.

  • internal/app/keepalives.go keeps one stream.Heartbeater per served tenant, reconciled from AfterAdopt the way discoveries is: built from the tenant's own store.Keepalive(), reshaped in place on that tenant's reload, stopped and dropped when the tenant is removed or rejected.
  • The stream handler's Heartbeater is a lookup by the request's store. A tenant with no wheel is no longer served and its stream is already ending, so it goes without one. A lookup that lands after a reload adopted a tenant and before the keepalive hook ran reconciles first, so a served tenant always has a wheel.
  • The wheels turn under the keepalive component, so under Run's errgroup as the one wheel did. shortestKeepalive is deleted. A flat directory behaves as before.

A wheel runs from its tenant's adoption, not from its first stream. Idle, it is one parked goroutine (under 5 KB) and a wake of about 3 µs every keepalive_interval ÷ keepalive_buckets, ten seconds at the defaults. A thousand idle tenants cost about 5 MB and 0.03% of one core, less than a start and stop racing every stream's open and close would be worth.

Test plan

  • Exact cadence on a synctest clock: two tenants at 30 and 10 seconds are each nudged at their own interval; one tenant's reload to 1 second leaves the other's next keepalive where it was due; a tenant's own reload reshapes its wheel in place with its stream still on it
  • A flat directory's one tenant: nudged at its interval, reshaped by a reload, left alone by a rejected reload
  • A removed or rejected tenant's wheel is stopped by the time the reload returns, and no wheel outlives Run
  • Through the real wiring: open GET /v1/stream connections over a nested directory, read off the keepalive comments
  • The handler registers each stream with its own tenant's wheel and does not dereference a missing one
  • make ci

Related Issues

Closes #597
Part of #583

Follow-ups

@coderabbitai

coderabbitai Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: 0295e9fc-3fce-4f45-8a35-36d71cb49c6a

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: fac803b2-25d1-4415-9288-82bf1dd65dc0
📥 Commits

Reviewing files that changed from the base of the PR and between 77fec4f and 089e322.

📒 Files selected for processing (19)
  • AGENTS.md
  • CHANGELOG.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
  • docs/src/content/docs/settings-directory.mdx
  • internal/api/stream.go
  • internal/api/stream_test.go
  • internal/api/tenant_helpers_test.go
  • internal/app/app.go
  • internal/app/app_test.go
  • internal/app/keepalives.go
  • internal/app/wire.go
  • internal/chconn/chconn.go
  • internal/config/config.go
  • internal/settings/settings.go
  • internal/stream/heartbeat.go
  • internal/stream/hub.go

Included review availability: This review used your included allowance. Your plan provides up to 1 included review per hour; 0 remain after this review.

📜 Recent review details
🧰 Additional context used
📚 Code guidelines (4)
docs/src/content/docs/deployment.md — auto-discovered
AGENTS.md — auto-discovered
.github/copilot-instructions.md — auto-discovered
CONTRIBUTING.md — auto-discovered
📓 Path-based instructions (5)
Source excerpt: In a Docker / Podman / Kubernetes deployment, **`data_dir` must resolve to a host-backed volume**.

📄 CodeRabbit inference engine (docs/src/content/docs/deployment.md)

Files:

  • docs/src/content/docs/deployment.md
Source excerpt: Create `*_test.go` files in the same package as the code under test.

📄 CodeRabbit inference engine (AGENTS.md)

Files:

  • internal/api/tenant_helpers_test.go
  • internal/api/stream_test.go
  • internal/app/app_test.go
See [AGENTS.md](../AGENTS.md) for project conventions, architecture notes, and AI agent instructions.

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

Files:

  • AGENTS.md
Source excerpt: **In MDX, leave a blank line between a JSX tag and a code fence.**

📄 CodeRabbit inference engine (AGENTS.md)

Files:

  • docs/src/content/docs/configuration.mdx
  • docs/src/content/docs/reverse-proxy.mdx
  • docs/src/content/docs/settings-directory.mdx
Source excerpt: **Docs prose**: never hard-wrap Markdown — one paragraph is one line.

📄 CodeRabbit inference engine (CONTRIBUTING.md)

Files:

  • docs/src/content/docs/configuration.mdx
  • docs/src/content/docs/reverse-proxy.mdx
  • docs/src/content/docs/settings-directory.mdx
  • docs/src/content/docs/architecture.md
  • AGENTS.md
  • docs/src/content/docs/deployment.md
  • CHANGELOG.md
🪛 LanguageTool
docs/src/content/docs/configuration.mdx

[style] ~140-~140: Since ownership is already implied, this phrasing may be redundant.
Context: ...eepalive wheels. Every API process runs its own set of these, and each API process rece...

(PRP_OWN)


[style] ~140-~140: Since ownership is already implied, this phrasing may be redundant.
Context: ...ch API process receives every event for its own SSE clients. | | ingest | The ingest ...

(PRP_OWN)

docs/src/content/docs/architecture.md

[style] ~95-~95: This phrase is redundant. Consider writing “last”.
Context: ...nt. The SIGHUP registration is released last of all. Handler, Registry, and MQ expose...

(LAST_OF_ALL)


[style] ~95-~95: Since ownership is already implied, this phrasing may be redundant.
Context: ...ons.Listenerlets one serve the API on its own listener instead ofserver.port`. - **...

(PRP_OWN)

docs/src/content/docs/deployment.md

[style] ~574-~574: Since ownership is already implied, this phrasing may be redundant.
Context: ...cides.** A request is evaluated against its own tenant's policies.json and `pipes.jso...

(PRP_OWN)


[style] ~574-~574: Since ownership is already implied, this phrasing may be redundant.
Context: ...s-directory#clickhouse) — and discovers its own tables from its own database on its own...

(PRP_OWN)


[style] ~574-~574: Since ownership is already implied, this phrasing may be redundant.
Context: ...se) — and discovers its own tables from its own database on its own `schema.refresh_int...

(PRP_OWN)


[style] ~574-~574: Since ownership is already implied, this phrasing may be redundant.
Context: .../v1/streamconnection is authorized by its own tenant'spolicies.json` and receives i...

(PRP_OWN)


[style] ~574-~574: Since ownership is already implied, this phrasing may be redundant.
Context: ...n tenant's policies.json and receives its own tenant's rows alone, the ingest worker ...

(PRP_OWN)


[style] ~574-~574: Since ownership is already implied, this phrasing may be redundant.
Context: ...e, the ingest worker inserts a row into its own tenant's ClickHouse, a rejected row is ...

(PRP_OWN)


[style] ~574-~574: Since ownership is already implied, this phrasing may be redundant.
Context: ...ckHouse, a rejected row is parked under its own tenant's dlq.enabled and subject (`dl...

(PRP_OWN)

CHANGELOG.md

[typographical] ~13-~13: Consider using an em dash in dialogues and enumerations.
Context: - **Each tenant's streams are kept alive ...

(DASH_RULE)


[style] ~13-~13: This sentence is over 40 words long. Consider splitting it up, as shorter sentences make the text easier to read.
Context: - Each tenant's streams are kept alive at its own stream.keepalive_interval (internal/app/keepalives.go (new), internal/app/{app,wire}.go (+ tests), internal/api/stream.go (+ tests), comments in internal/stream/{heartbeat,hub}.go, internal/chconn/chconn.go, internal/config/config.go and internal/settings/settings.go, docs/src/content/docs/{architecture,deployment}.md, {settings-directory,configuration,reverse-proxy}.mdx, AGENTS.md): a story 2 follow-up of the multi-tenant epic (#583), closing #597. stream.keepalive_interval and `stre...

(TOO_LONG_SENTENCE)


[style] ~13-~13: Since ownership is already implied, this phrasing may be redundant.
Context: ...has a wheel of its own, in the shape of its own pair. A connection is nudged at its ten...

(PRP_OWN)


[style] ~13-~13: Consider shortening this phrase to just ‘whether’, unless you mean ‘regardless of whether’.
Context: ...from the reload that adopts its tenant, whether or not a stream is open: idle, it is one parke...

(WHETHER)

🔇 Additional comments (19)
AGENTS.md (1)

32-32: LGTM!

CHANGELOG.md (1)

13-13: LGTM!

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

88-88: LGTM!

Also applies to: 95-96, 109-109

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

140-140: LGTM!

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

574-574: LGTM!

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

124-124: LGTM!

docs/src/content/docs/settings-directory.mdx (1)

34-34: LGTM!

internal/app/app.go (1)

113-113: LGTM!

Also applies to: 181-181

internal/app/keepalives.go (1)

1-140: LGTM!

internal/app/wire.go (1)

781-791: LGTM!

Also applies to: 995-995

internal/app/app_test.go (1)

870-1215: LGTM!

internal/chconn/chconn.go (1)

113-114: LGTM!

internal/config/config.go (1)

175-175: LGTM!

internal/settings/settings.go (1)

222-225: LGTM!

internal/stream/heartbeat.go (1)

154-154: LGTM!

internal/stream/hub.go (1)

179-180: LGTM!

internal/api/stream.go (1)

12-25: LGTM!

Also applies to: 173-180

internal/api/stream_test.go (1)

182-247: LGTM!

internal/api/tenant_helpers_test.go (1)

74-78: LGTM!


📝 Summary

Summary by CodeRabbit

  • New Features
    • SSE connections now use keepalive intervals configured for their tenant. Updating a tenant’s settings affects only that tenant’s connections; tenants that are removed or rejected no longer retain a keepalive schedule.
  • Documentation
    • Updated configuration, deployment, and architecture guidance to describe tenant-specific keepalive behavior.

Walkthrough

The app now maintains a separate SSE keepalive wheel for each served tenant. Stream handlers select the wheel using the request tenant’s settings. Adoption, settings reloads, tenant removal, and shutdown update the corresponding wheels.

Changes

Tenant-scoped SSE keepalive behavior

Layer / File(s) Summary
Reconcile and run tenant wheels
internal/app/keepalives.go, internal/app/app.go, internal/app/wire.go, internal/app/app_test.go, AGENTS.md, CHANGELOG.md, docs/src/content/docs/*, internal/chconn/chconn.go, internal/config/config.go, internal/settings/settings.go, internal/stream/heartbeat.go, internal/stream/hub.go
The app creates or reconfigures a wheel for each served tenant and stops wheels for tenants that are no longer served. Tests cover tenant-specific cadence, reloads, tenant lifecycle, and shutdown. Documentation and comments describe the per-tenant behavior.
Select a wheel for each stream
internal/api/stream.go, internal/api/stream_test.go, internal/api/tenant_helpers_test.go, internal/app/wire.go, docs/src/content/docs/architecture.md
StreamHandler now receives a wheel lookup function that uses the request settings store. The handler registers and removes the subscriber on the returned wheel, and skips registration when no wheel is returned. Tests cover wheel selection and cleanup.

Priority: ➖ Normal

Estimated code review effort: 3 (Moderate) | ~25 minutes

Change: Bug fix · Severity of issue fixed: Medium

Sequence Diagram(s)

sequenceDiagram
  participant TenantSettings
  participant AfterAdopt
  participant keepalives
  participant StreamHandler
  participant Heartbeater
  TenantSettings->>AfterAdopt: Adopt tenant settings
  AfterAdopt->>keepalives: Reconcile tenant wheels
  StreamHandler->>keepalives: Resolve wheel for request tenant
  keepalives-->>StreamHandler: Return tenant wheel or nil
  StreamHandler->>Heartbeater: Register subscriber when a wheel is returned
Loading

Suggested reviewers: ericandrechek

Merge Risk: ⚪ Minimal · up to 089e3

No material issue remains established for the tenant-specific keepalive change; it is mergeable after normal checks.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely identifies the main change: each tenant gets its own keepalive wheel.
Description check ✅ Passed The description explains the per-tenant keepalive behavior, lifecycle, tests, and related issues. It is directly relevant to the changeset.
Linked Issues check ✅ Passed [#597] keepalives.reconcile builds or reconfigures a wheel from each served tenant’s own settings. For selects the wheel using the request store’s tenant, and StreamHandler registers and removes…
Out of Scope Changes check ✅ Passed The runtime changes, lifecycle tests, and documentation updates support the per-tenant keepalive behavior in [#597]. The internal/chconn comment edit removes an obsolete reference to the former shor…
Docstring Coverage ✅ Passed Docstring coverage is 83.33% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 24 functions across 12 files. (7 skipped: 7…
✨ Finishing Touches
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
✨ Simplify code
  • Commit to this branch
  • Create a new PR
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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.

@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 area/app Process wiring (internal/app): component build, run, release labels Oct 6, 2026
@github-actions

github-actions Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

📚 Docs preview is live → https://efd35efb-wavehouse-docs.wave-rf.workers.dev

  • Commit — 056e7a2: feat(settings): bound stream.keepalive_buckets at 100
  • Author — @taitelee
  • Committed — 2026-10-07 10:31 (UTC-04:00)
  • Deployed — 2026-10-07 10:51 EDT

@github-code-quality

github-code-quality Bot commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Code Coverage Overview

Languages: Go

Go

The overall line coverage in commit 056e7a2 in the feat/keepalive-per-t... branch remains at 93%, unchanged from commit bf88dba in the main branch.

Show a line coverage summary of the most impacted files.
File main bf88dba feat/keepalive-per-t... 056e7a2 +/-
internal/mq/nats_topology.go 92% 91% -1%
internal/mq/external.go 85% 84% -1%
internal/app/wire.go 93% 93% 0%
internal/api/stream.go 86% 87% +1%
internal/app/keepalives.go 0% 98% +98%

Updated October 07, 2026 14:52 UTC

@taitelee

taitelee commented Oct 6, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Oct 6, 2026 •

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

coderabbitai[bot]
coderabbitai Bot previously approved these changes Oct 6, 2026
@taitelee
taitelee marked this pull request as ready for review October 7, 2026 13:34
@taitelee
taitelee requested review from a team and EricAndrechek October 7, 2026 13:34
EricAndrechek
EricAndrechek previously approved these changes Oct 7, 2026
Comment thread internal/stream/heartbeat.go
@taitelee
taitelee added this pull request to the merge queue Oct 7, 2026
Merged via the queue into main with commit afd196e Oct 7, 2026
19 checks passed
@taitelee
taitelee deleted the feat/keepalive-per-tenant branch October 7, 2026 15:06
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/app Process wiring (internal/app): component build, run, release area/docs Documentation, site/, README documentation Improvements or additions to documentation go Pull requests that update go code

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

feat(stream): honor each tenant's keepalive settings on the wheel

2 participants