perf(a2a): bound task route capture parsing to O(n) with a balance scan#450
Conversation
try_capture_from_buffer ran on every on_response_body chunk while
non-streaming task-route capture was enabled, hex-decoding the entire
accumulated buffer and attempting a full serde_json::from_slice parse
on every call. Total work across k chunks was proportional to the sum
of accumulated buffer sizes, i.e. O(n^2) in response body bytes.
Add a JsonBalanceState scanner that tracks {}/[] depth incrementally,
skipping bytes inside string literals (respecting backslash escapes),
updated only with the bytes in each new chunk and persisted via
filter_metadata between calls. The expensive hex-decode + JSON parse
in try_capture_from_buffer is now only attempted once the scanner
reports the buffer holds a balanced top-level value, or at
end_of_stream as before. This bounds total scan work to O(n) while
preserving opportunistic, pre-EOS route capture (Pingora may not
deliver a separate EOS callback after the final data chunk).
Extend clear_capture_metadata to also clear the new scanner scratch
keys, and reuse it in accumulate_response_hex's overflow path to
remove the previous duplicated inline removals.
Fixes praxis-proxy#353
Signed-off-by: mkoushni <mkoushni@redhat.com>
2dfec85 to
f7ffc51
Compare
|
@aslakknutsen the doc proposal for this fix is #396 |
…omplete Address review from @aslakknutsen on praxis-proxy#450: - JsonBalanceState tracked a flat open/close depth, so `{]` (a `{` opener closed by `]`) reached depth 0 and read as `is_complete` even though the bracket types don't match. Replace the depth counter with a stack of expected closing bytes so closers are matched against their opener's type; a mismatch (or an unmatched closer) now sets a permanent `invalid` flag instead of reporting complete. - More generally, `is_complete` is a heuristic gate, not a validator: a balanced, type-matched buffer can still fail the real `serde_json` parse (trailing bytes after a complete object, a trailing comma, etc). Once that happens on a non-final chunk, the scanner is permanently "complete" and, without a gate, every later chunk would re-run the same doomed hex-decode + parse on an ever-growing buffer — reintroducing the O(n²)/Θ(n·k) cost this scanner exists to avoid. try_capture_from_buffer now sets `a2a.response.capture_gate_exhausted` on a failed non-final parse, and `should_attempt_capture` (extracted for direct unit testing) skips further attempts until `end_of_stream`. Signed-off-by: mkoushni <mkoushni@redhat.com>
once JsonBalanceState reports a structurally-closed top-level value at a given byte offset, a serde_json parse failure over the buffer up to that offset is unconditionally terminal. Every byte already accumulated is fixed; any further chunk can only append trailing content, which serde_json rejects just the same. Retrying at end_of_stream can never succeed once it has already failed here. Replace the capture_gate_exhausted latch (which kept capture armed, accumulating up to ~2x body-size metadata and hex-append work, for a guaranteed-doomed final parse at EOS) with clearing capture metadata immediately in try_capture_from_buffer on any parse failure, matching the existing success path. This also removes the need for the separate should_attempt_capture helper and gate-tracking metadata key entirely: on_response_body only needs `is_complete || end_of_stream` again, since capture_enabled itself now stays cleared after the first failed attempt. Signed-off-by: mkoushni <mkoushni@redhat.com>
Once JsonBalanceState.invalid is set (mismatched or unmatched
brackets, e.g. `{]`), is_complete never becomes true again, so
try_capture_from_buffer previously only ran at end_of_stream. Every
chunk in between still hex-accumulated into filter_metadata up to
max_response_body_bytes for a buffer already known to be permanently
unparseable via this path.
Mismatched brackets are exactly as terminal as a failed parse: no
further bytes can retroactively make an already-mismatched closer
match its opener. Check balance.invalid right after scanning and call
clear_capture_metadata immediately when set, instead of falling
through to accumulate on every remaining chunk.
Extracted the non-streaming capture branch of on_response_body into
handle_non_streaming_capture to keep the match arm under the line-count
lint after adding the invalid check.
Signed-off-by: mkoushni <mkoushni@redhat.com>
praxis-bot
left a comment
There was a problem hiding this comment.
Review: perf(a2a): bound task route capture parsing to O(n) with a balance scan
Well-structured performance fix. The JsonBalanceState scanner correctly reduces total per-response work from O(n²) to O(n) by scanning only new bytes in each chunk. The bracket-type matching (stack of expected closers rather than a simple depth counter), string-literal tracking, and escape handling are all correct. The is_done() early-exit guard ensures the O(n) bound holds.
The refactoring of try_capture_from_buffer to unconditionally clear capture state is a sound simplification — the doc comment correctly justifies why a failed parse after a balanced scan is always terminal.
The deduplication of the inline .remove() calls in accumulate_response_hex’s overflow path into clear_capture_metadata is a clean win.
Findings
| Severity | Count |
|---|---|
| Medium | 2 |
Details in inline comments.
Summary
try_capture_from_bufferinfilters/src/agentic/a2a/mod.rs(thenon-streaming A2A task-route capture path) runs on every
on_response_bodychunk while capture is enabled. Each invocationhex-decodes the entire accumulated buffer and attempts a full
serde_json::from_sliceparse over it, discarding the result wheneverthe JSON is still incomplete. For a body delivered in
kchunks, thisrepeats full decode+parse work over an ever-growing buffer on every
chunk, producing O(n²) total work in response body bytes rather than
O(n).
Parsing only at
end_of_streamis not viable: Pingora may not delivera separate EOS callback after the final data chunk, so the route must
be captured opportunistically as soon as the JSON body is complete.
Fix
Add a
JsonBalanceStatescanner that tracks{}/[]depthincrementally, skipping bytes inside string literals (respecting
backslash escapes). It is updated only with the bytes in each new
chunk and persisted via
filter_metadatabetween calls, so total scanwork across all chunks is O(n) rather than O(buffer²):
The expensive hex-decode + JSON parse in
try_capture_from_bufferisnow only attempted once the scanner reports the buffer holds a
balanced top-level value, or at
end_of_streamas before:This preserves the existing opportunistic, pre-EOS capture guarantee
while bounding total per-response scan work to O(n).
clear_capture_metadatais extended to also clear the new scanner scratch keys, and reused in
accumulate_response_hex's overflow path to remove the previousduplicated inline
.remove()calls.Scoped to the non-streaming capture path only — the SSE capture path
(
process_sse_response_chunk) already scans incrementally and isunaffected. No change to the hex-encoding scheme used for multibyte
UTF-8 chunk-boundary safety.
Implements the proposal in #396.
Test plan
JsonBalanceState/scan_json_balance: minimalobject, no-object-seen, nested objects/arrays, structural bytes
inside strings, escaped quotes inside strings, no-op once complete,
byte-by-byte accumulation matching a single-shot scan
many_single_byte_chunks_still_capture_route_before_eoscovering capture before
end_of_streamwhen a body arrives as manysingle-byte chunks
make test— full workspace suite passes (0 failed)make lint— clippy, nightly fmt-check, separator/filter-docslints all pass
Fixes #353