feat(a2a-gateway): add streaming-ingress pump and pull-backpressure consumer - #414
Conversation
…onsumer - streaming requests (message/stream, tasks/resubscribe) and pulled task- event egress are the two paths where JetStream flow control + per-caller inflight caps are non-optional; ship the dedicated pumps so the runtime glue can plug them in once the rest of policy lands. Each carries a CallerInflightGate so a single noisy caller can't starve the consumer Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
PR SummaryMedium Risk Overview Streaming ingress ( Task-event egress pull: durable prefix-scoped consumer, env-tuned fetch/heartbeat/ Reviewed by Cursor Bugbot for commit da58bc3. Bugbot is set up for automated code reviews on this repo. Configure here. |
|
Warning Review limit reached
More reviews will be available in 32 minutes and 16 seconds. Learn how PR review limits work. Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file). ⌛ How to resolve this issue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits. 🚦 How do rate limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please see our Fair Usage Limits Policy for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (5)
WalkthroughThis PR adds JetStream-backed streaming ingress and pull-based egress backpressure to the gateway crate. It introduces typed env config, per-caller gates, request/task parsing helpers, background pump and pull-loop entrypoints, and tests for parsing, planning, and limits. ChangesGateway JetStream flow
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
Code Coverage SummaryDetailsDiff against mainResults for commit: da58bc3 Minimum allowed coverage is ♻️ This comment has been updated with latest results |
…ry attempts - each pump built a fresh CallerInflightGate and took a single self-permit, so max_inflight_per_caller never constrained concurrent pumps for the same caller; introduce StreamingIngressGate that the runtime constructs once and clones into every spawn - `consumer.fetch().max_messages(1).messages()` yielded a single batch and exited, stopping the pump after one event. Use the continuous `consumer.messages()` stream so the pump keeps pulling until shutdown - run_fetch_cycle hard-coded attempt=1 into forward_disposition, so every publish failure NAKed and a persistently undeliverable event would re-enter forever. Read the JetStream delivered count from message.info() so Term fires after the planner's retry budget Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
There was a problem hiding this comment.
Actionable comments posted: 7
🧹 Nitpick comments (1)
rsworkspace/crates/a2a-gateway/src/gw_pull_backpressure.rs (1)
265-270: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick winAccept
A2aPrefixinstead of a raw prefix string.This parser is converting JetStream subject input into domain IDs; keeping
prefixas&A2aPrefixprevents invalid primitive prefixes from entering this boundary helper.As per coding guidelines, “Prefer domain-specific value objects over primitives” and “Convert those boundary types into domain types exactly once.”
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@rsworkspace/crates/a2a-gateway/src/gw_pull_backpressure.rs` around lines 265 - 270, Update parse_task_events_subject to accept an A2aPrefix reference instead of a raw &str prefix so invalid primitive prefixes cannot enter this boundary helper. Adjust the subject parsing logic to use the domain prefix type directly when building the expected JetStream subject. Keep the existing conversion to A2aTaskId and ReqId unchanged, and update any call sites accordingly to pass an A2aPrefix.Source: Coding guidelines
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@rsworkspace/crates/a2a-gateway/src/gw_ingress_stream.rs`:
- Around line 35-58: GatewayStreamingIngressConfig currently stores raw
i64/usize values, so invalid zero values can still be constructed outside
from_env. Replace max_ack_pending and max_inflight_per_caller with per-field
value objects that have private fields and enforce the minimum of 1 at
construction, then update GatewayStreamingIngressConfig::from_env and its call
sites to use the new factories and pass .as_i64() / .as_usize() where needed.
Keep the valid-by-construction rule centered in the new value-object types so
invalid instances cannot be represented.
- Around line 178-188: The streaming ingress spawn path currently drops gate
rejection inside the detached task, so callers cannot observe backpressure;
update spawn_streaming_ingress_pump to acquire the inflight gate permit before
tokio::spawn and change it to return Result<(), StreamingIngressSpawnError>.
Make the permit owned by StreamingIngressGate (or otherwise movable into the
async task) so run_streaming_ingress_pump can hold it across the spawned work,
and ensure any rejection is surfaced as a typed error rather than an untyped
string.
- Around line 322-343: The parsing helpers in task_id_from_resubscribe_params,
last_seq_from_resubscribe_params, and req_id_from_headers_or_payload currently
map untrusted input straight into domain types with Option, which hides whether
data was missing, malformed, or invalid. Introduce a wire/request type such as
ResubscribeParamsWire (and a request type for the header/body case if needed),
define a typed parse error for each failure mode, and have these helpers parse
into the wire type first before converting to A2aTaskId, u64, and ReqId. Keep
the domain conversion separate so callers can distinguish missing fields from
bad values and invalid IDs.
- Around line 74-80: `StreamingIngressSpawn` currently allows invalid
`StreamingIngressMethod` combinations, including `TasksResubscribe` without a
`task_id`, which later gets discarded in `handle_streaming_ingress_spawn`;
refactor `StreamingIngressSpawn` into enum variants with required fields so
impossible states cannot be constructed. While updating `gw_ingress_stream.rs`,
replace the raw `caller_key: String` with a dedicated `CallerKey` value object
and adjust any construction and pattern matching sites that use
`StreamingIngressSpawn`, `StreamingIngressMethod`, and
`handle_streaming_ingress_spawn` to enforce the new typed shape.
In `@rsworkspace/crates/a2a-gateway/src/gw_pull_backpressure.rs`:
- Around line 212-218: `GatewayEventsPullConfig` currently exposes
`max_inflight_per_caller` as a plain `usize`, so callers can construct an
invalid zero limit that causes `CallerInflightGate::try_acquire` to fail for
every message. Make this limit unrepresentable as zero by introducing a
validated value object for the inflight limit, or by enforcing the minimum
inside `GatewayEventsPullConfig` construction and again in
`CallerInflightGate::new` so the gate always receives a non-zero value. Ensure
the factory path for `GatewayEventsPullConfig` and the `CallerInflightGate::new`
initializer guarantee correctness at construction, and update any code paths
that read or pass `max_inflight_per_caller` to use the validated type/value.
- Around line 400-492: `run_fetch_cycle` and `forward_task_event` are still
flattening failures into `String`, which drops the underlying error context.
Replace `Result<(), String>` with a typed error enum/struct that carries source
errors and context for the fetch, consumer creation, ack, and publish paths.
Update the `map_err` sites in `run_fetch_cycle` and `forward_task_event` to
construct those typed variants instead of using `format!()` or `e.to_string()`,
preserving the original error as a source field.
- Around line 361-370: The backoff sleep in the gateway pull loop ignores
shutdown, so cancellation can be delayed during repeated failures. Update the
error-handling path in the pull cycle around the warn/log/backoff logic in
gw_pull_backpressure to wait on either the existing sleep or the shutdown
signal, and exit promptly if shutdown is observed. Keep the retry behavior and
backoff growth the same, but make the sleep interruptible by the shutdown
mechanism used elsewhere in this loop.
---
Nitpick comments:
In `@rsworkspace/crates/a2a-gateway/src/gw_pull_backpressure.rs`:
- Around line 265-270: Update parse_task_events_subject to accept an A2aPrefix
reference instead of a raw &str prefix so invalid primitive prefixes cannot
enter this boundary helper. Adjust the subject parsing logic to use the domain
prefix type directly when building the expected JetStream subject. Keep the
existing conversion to A2aTaskId and ReqId unchanged, and update any call sites
accordingly to pass an A2aPrefix.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 82fc0289-6ef9-4aca-8b84-81ea62ce352f
📒 Files selected for processing (5)
rsworkspace/crates/a2a-gateway/src/gw_ingress_stream.rsrsworkspace/crates/a2a-gateway/src/gw_ingress_stream/tests.rsrsworkspace/crates/a2a-gateway/src/gw_pull_backpressure.rsrsworkspace/crates/a2a-gateway/src/gw_pull_backpressure/tests.rsrsworkspace/crates/a2a-gateway/src/lib.rs
Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
…erage build Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
- Replace primitive config knobs with value objects so zero/negative inputs can't silently disable backpressure - Fix JetStream subject prefix in task-event egress so messages stop Term'ing on every delivery - Replace `Result<(), String>` with typed error chains so failures preserve their async_nats source instead of flattening to a string Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
…der coverage Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
- Inline cfg(coverage) spawn-stub into the real fn so the permit-acquire path counts toward coverage instead of being duplicated as a stub - Gate the private streaming-spawn helper impl behind cfg(not(coverage)) since its only callers live in the pump that's already stubbed - Add direct tests for the planner pull-hints, value-object config constructor, mutex poison recovery, and PullCycleError display Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
- Egress fetch loop now spawns forward+ack work per message so the per-caller inflight gate actually constrains concurrency instead of bounding a strictly sequential loop where only one permit could exist - Streaming ingress publish failures honor the JetStream delivery count and Term after the documented attempt budget so a permanently broken reply subject can't NAK-loop forever - Streaming ingress acks now go through JetStream double-ack so a dropped ack doesn't redeliver and republish the same payload to the caller; persistent ack failure falls through to Term to bound the worst case Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
…ate publish - Plain ack() leaves a dropped ack indistinguishable from a successful one; jetstream redelivers and the spawned task re-publishes the same task event to the caller's egress subject Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
- Keying the per-caller cap on req_id let one caller open many concurrent streams (distinct req_ids) and consume the limit per stream, defeating the per-caller backpressure goal the config name promises Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (1)
rsworkspace/crates/a2a-gateway/src/gw_ingress_stream.rs (1)
280-286: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winKeep the rejected caller typed in the spawn error.
PerCallerLimitconverts the already-validatedCallerKeyback toString, discarding the domain type in the error chain. CarryCallerKeydirectly and clone it at construction. As per coding guidelines, "Errors must be typed—use structs or enums, neverStringorformat!()."♻️ Proposed fix
pub enum StreamingIngressSpawnError { @@ /// 429-style response to the caller. #[error("caller {caller:?} is at the per-caller inflight limit")] - PerCallerLimit { caller: String }, + PerCallerLimit { caller: CallerKey }, } @@ .try_acquire(&spawn.caller_key) .ok_or_else(|| StreamingIngressSpawnError::PerCallerLimit { - caller: spawn.caller_key.as_str().to_owned(), + caller: spawn.caller_key.clone(), })?;Also applies to: 302-306
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@rsworkspace/crates/a2a-gateway/src/gw_ingress_stream.rs` around lines 280 - 286, The StreamingIngressSpawnError::PerCallerLimit variant currently drops the validated CallerKey by storing a String, which breaks typed error handling. Update the PerCallerLimit enum payload to carry CallerKey directly, and clone the CallerKey at the construction sites in gw_ingress_stream.rs so the rejected caller stays typed through the error chain. Keep the error display message using the CallerKey field from StreamingIngressSpawnError and adjust any related constructors/usages in the spawn path accordingly.Source: Coding guidelines
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@rsworkspace/crates/a2a-gateway/src/gw_ingress_stream.rs`:
- Around line 185-189: Update method_label in StreamingIngressKind handling so
it matches on a borrowed value instead of moving self.kind out of &self; change
the match to use &self.kind and keep returning the same string labels for
MessageStream and TasksResubscribe. This keeps the method callable on shared
references without violating borrow rules.
In `@rsworkspace/crates/a2a-gateway/src/gw_pull_backpressure.rs`:
- Around line 508-520: `run_fetch_cycle` currently accepts `hints:
PullConsumerHints` but never uses it, so planner-supplied consumer settings are
ignored when creating the durable consumer. Update the consumer provisioning
path by threading `hints` into `gateway_events_consumer(...)` (or whichever
consumer-config builder is used there) so the hints can influence the resulting
`consumer_config`; if that is not intended, remove the unused `hints` parameter
from `run_fetch_cycle` and any matching call sites.
- Around line 237-241: `GatewayEventsPullConfig::new` currently accepts a raw
`Duration` for `fetch_heartbeat`, which lets callers bypass the zero-heartbeat
invariant enforced by `from_env`. Replace that primitive with a validated value
object for the heartbeat (similar to the other domain-specific wrappers in this
module), update `GatewayEventsPullConfig::new` and any related
constructors/accessors to use it, and ensure construction rejects or normalizes
invalid zero values so `Duration::ZERO` cannot be represented.
---
Nitpick comments:
In `@rsworkspace/crates/a2a-gateway/src/gw_ingress_stream.rs`:
- Around line 280-286: The StreamingIngressSpawnError::PerCallerLimit variant
currently drops the validated CallerKey by storing a String, which breaks typed
error handling. Update the PerCallerLimit enum payload to carry CallerKey
directly, and clone the CallerKey at the construction sites in
gw_ingress_stream.rs so the rejected caller stays typed through the error chain.
Keep the error display message using the CallerKey field from
StreamingIngressSpawnError and adjust any related constructors/usages in the
spawn path accordingly.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 52ef3ed7-7f82-46f7-ad81-42bff8038e6e
📒 Files selected for processing (4)
rsworkspace/crates/a2a-gateway/src/gw_ingress_stream.rsrsworkspace/crates/a2a-gateway/src/gw_ingress_stream/tests.rsrsworkspace/crates/a2a-gateway/src/gw_pull_backpressure.rsrsworkspace/crates/a2a-gateway/src/gw_pull_backpressure/tests.rs
…variant - Gate-full no longer NAKs JetStream messages and burns the planner's forward-attempt budget; the spawned task now waits for a permit so `delivered` reflects actual forward attempts - Fetch heartbeat is now a validated value object so the public constructor can't accept Duration::ZERO and disable JetStream's heartbeat-driven liveness - Match StreamingIngressKind on a borrowed reference so method_label matches the pattern req_id already uses - Drop the unused PullConsumerHints arg from run_fetch_cycle until a follow-up wires hints through the consumer config builder Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
- Surface streaming permit acquire as a testable helper so the per-caller limit's typed error path doesn't require constructing an async-nats Client in tests - Cover acquire_permit_with_shutdown happy and shutdown paths so the egress pump's gate-wait can never silently regress to spin-loop or shutdown-hang Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
…drops - entry().or_insert(0) is logically equivalent and trims the defensive branch the type system already rules out, which kept showing up as uncovered statements and pulled CI's coverage gate under threshold Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
There are 3 total unresolved issues (including 2 from previous reviews).
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit cedaa28. Configure here.
…ts cover the forward path - Push and reply forwards used the concrete async-nats Client, which has no public constructor and made the publish surfaces only reachable from end-to-end runs; routing them through trogon_nats::PublishClient lets the unit tests assert subject and payload via MockNatsClient Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
… ack-slot hoarding - Bridge/SDK clients send the resubscribe resume cursor as lastSequence, last_sequence, or metadata.lastEventId; without these aliases reconnect silently replays the whole stream from zero - The egress fetch loop now races batch.next() against shutdown so a draining batch can't keep the pump alive for up to FETCH_EXPIRES (30s) after cancel and keep spawning forwards - Per-caller gate is now try-acquire + NAK-with-delay instead of wait, so a single slow caller can't fill the consumer's shared max_ack_pending with permit-waiting tasks; the forward retry budget is decoupled from JetStream's delivered counter via ForwardAttempts so gate redeliveries no longer burn the planner's 3-attempt budget Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
…erage gate - The cfg(coverage) stubs were spread across multi-line signatures that showed up as uncovered statements with no production caller; collapsing them with rustfmt::skip and delegating the streaming spawn to its testable permit-acquire helper buys the workspace ~0.05% statement coverage without changing runtime behavior Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>

message/stream,tasks/resubscribe) and pulled task-event egress are the two paths where JetStream flow control + per-caller inflight caps are non-optional; ship the dedicated pumps so the runtime glue can plug them in once the rest of policy lands. Each carries aCallerInflightGateso a single noisy caller can't starve the consumer