Skip to content

[SLOP(claude-opus-4-8)] feat(pegboard-envoy): rate-limit envoy ws ingress and cap get_pages, trim unused metrics - #5327

Open
MasterPtato wants to merge 1 commit into
stack/slop-claude-opus-4-8-feat-util-add-rate-limiter-primitive-and-ingress-throttles-for-actor-create-and-gateway-websocket-pnyswwtmfrom
stack/slop-claude-opus-4-8-feat-pegboard-envoy-rate-limit-envoy-ws-ingress-and-cap-get_pages-trim-unused-metrics-tnzyzqrl
Open

[SLOP(claude-opus-4-8)] feat(pegboard-envoy): rate-limit envoy ws ingress and cap get_pages, trim unused metrics#5327
MasterPtato wants to merge 1 commit into
stack/slop-claude-opus-4-8-feat-util-add-rate-limiter-primitive-and-ingress-throttles-for-actor-create-and-gateway-websocket-pnyswwtmfrom
stack/slop-claude-opus-4-8-feat-pegboard-envoy-rate-limit-envoy-ws-ingress-and-cap-get_pages-trim-unused-metrics-tnzyzqrl

Conversation

@MasterPtato

@MasterPtato MasterPtato commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

No description provided.

@MasterPtato

MasterPtato commented Jun 24, 2026

Copy link
Copy Markdown
Contributor Author

Stack for rivet-dev/rivet

Get stack: forklift get 5327
Push local edits: forklift submit
Merge when ready: forklift merge 5327

change tnzyzqrl

@claude

claude Bot commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

PR Review

This adds a leaky-bucket rate limiter to the pegboard-envoy WS ingress path (mirroring the gateway limiter), caps get_pages request size, and drops the prepopulate() metric-initialization helpers.

Findings

engine/packages/pegboard-envoy/src/ws_to_tunnel_task.rs (task_inner, around line 486) - Rate-limit tokens are silently lost to unrelated worker completions, over-throttling ingress below the configured rate

recv = async {
    rate_limit.acquire().await;
    recv_msg(&mut ws_rx, &mut ws_to_tunnel_abort_rx, &mut term_signal).await
} => { ... }
worker = task_manager.join_next(), if !task_manager.workers.is_empty() => { ... }

rate_limit state (requests_remaining, accum_drip, last_acquire) lives in the outer loop, not inside this future. When the bucket has tokens, acquire() decrements synchronously and returns Ready in the same poll, so the combined future proceeds straight into recv_msg(...).await, which then goes Pending waiting on the socket. Because tokio::select! inside loop recreates every branch each iteration, if worker = task_manager.join_next() resolves first (which it does routinely: tunnel_message_task, kv_task, etc. are spawned per request/key and complete often on a busy multiplexed envoy connection), the still-pending recv future is dropped. The token it already consumed is gone; no message was ever processed for it.

Under real traffic this means the bucket drains from background task churn unrelated to actual WS message volume, so the effective inbound rate ends up throttled well below envoy_websocket_rate_limit_requests / drip_rate_us, scaling with how busy the connection background workers are rather than with actual message volume. That is the opposite of the intended behavior (a large burst allowance for a connection multiplexing an entire runner).

The gateway2 version of this same refactor (pegboard-gateway2/src/ws_to_tunnel_task.rs) does not have this problem because its only sibling branch is ws_to_tunnel_abort_rx.changed(), which fires at most once per connection (at shutdown), so at most one token is ever wasted there.

A fix likely needs to track whether a token has already been consumed for the in-flight recv attempt (e.g. a bool in the outer loop scope) so a cancelled-and-retried iteration does not call acquire() again, or otherwise decouple waiting for a permit from the recv attempt potentially being cancelled by an unrelated branch.

engine/packages/pegboard-envoy/src/ws_to_tunnel_task.rs (task_inner, around line 486) - Abort/term signals still cannot interrupt a sleeping acquire()

ws_to_tunnel_abort_rx and term_signal are only polled inside recv_msg, which runs after rate_limit.acquire() resolves. Since acquire() and recv_msg() are sequenced in one future, while the leaky bucket is asleep waiting for a token neither signal can preempt it; the outer select can only escape via the unrelated worker branch, which does not check for abort/term either. With the default envoy_websocket_rate_limit_drip_rate_us of 200, the delay is negligible, but it couples graceful-shutdown/abort responsiveness to the rate-limit config: an operator raising the drip rate to throttle an overloaded runner harder would also slow down how quickly that connection responds to shutdown. Worth hoisting abort/term as outer select! branches (as gateway2 does) rather than nesting them inside recv_msg.

Test coverage

No new tests cover the envoy rate limiter or the MAX_GET_PAGES_PER_REQUEST cap (engine/packages/pegboard-envoy/tests/ has no assertions touching either). Given this is explicitly a defense against an untrusted runner (per CLAUDE.md trust boundary between envoy and pegboard-envoy), and given the token-loss bug above, a test that drives concurrent worker completions alongside WS messages and asserts on effective throughput would have caught this.

Minor / non-blocking

  • pegboard-gateway2/src/ws_to_tunnel_task.rs: ctx.config().pegboard() is now called twice (once per rate-limit field) where it was previously a single local binding. Purely cosmetic, but slightly obscures that both fields come from the same config snapshot.
  • MAX_GET_PAGES_PER_REQUEST = 8192 is a reasonable cap with good headroom over the cited production max (around 1024), and checking it before validate_sqlite_actor_for_request avoids unnecessary work on an oversized request. Returning SqliteErrorResponse and keeping the connection open (rather than closing it) means a misbehaving runner can keep resending oversized requests within its rate-limit burst, but the new rate limiter now bounds that, which is a reasonable mitigation.
  • Removing prepopulate() looks fine: it was seeding series with empty-string namespace_id/pool_name labels that do not correspond to any real namespace, so it was not giving real per-namespace dashboards a meaningful zero baseline anyway.
  • PegboardEnvoyWs::new(&ctx) avoids a redundant clone at the call site (previously cloned once at the call site and again inside the constructor); the current version clones exactly once.

@NathanFlurry NathanFlurry changed the title [SLOP(claude-opus-4-8)] feat(pegboard-envoy): rate-limit envoy ws ingress and cap get_pages, trim unused metrics feat(pegboard-envoy): rate-limit envoy ws ingress and cap get_pages, trim unused metrics Jun 26, 2026
@MasterPtato MasterPtato changed the title feat(pegboard-envoy): rate-limit envoy ws ingress and cap get_pages, trim unused metrics [SLOP(claude-opus-4-8)] feat(pegboard-envoy): rate-limit envoy ws ingress and cap get_pages, trim unused metrics Jun 29, 2026
@MasterPtato
MasterPtato changed the base branch from stack/slop-claude-opus-4-8-feat-util-add-rate-limiter-primitive-and-ingress-throttles-for-actor-create-and-gateway-websocket-pnyswwtm to main August 7, 2026 00:39
@MasterPtato
MasterPtato force-pushed the stack/slop-claude-opus-4-8-feat-pegboard-envoy-rate-limit-envoy-ws-ingress-and-cap-get_pages-trim-unused-metrics-tnzyzqrl branch from e9df8e7 to 5c7f16e Compare August 7, 2026 01:27
@MasterPtato
MasterPtato changed the base branch from main to stack/slop-claude-opus-4-8-feat-util-add-rate-limiter-primitive-and-ingress-throttles-for-actor-create-and-gateway-websocket-pnyswwtm August 7, 2026 01:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant