Skip to content

perf(a2a): bound task route capture parsing to O(n) with a balance scan#450

Merged
aslakknutsen merged 6 commits into
praxis-proxy:mainfrom
mkoushni:perf/353-a2a-json-balance-scan-o1
Jul 22, 2026
Merged

perf(a2a): bound task route capture parsing to O(n) with a balance scan#450
aslakknutsen merged 6 commits into
praxis-proxy:mainfrom
mkoushni:perf/353-a2a-json-balance-scan-o1

Conversation

@mkoushni

Copy link
Copy Markdown
Contributor

Summary

try_capture_from_buffer in filters/src/agentic/a2a/mod.rs (the
non-streaming A2A task-route capture path) runs on every
on_response_body chunk while capture is enabled. Each invocation
hex-decodes the entire accumulated buffer and attempts a full
serde_json::from_slice parse over it, discarding the result whenever
the JSON is still incomplete. For a body delivered in k chunks, this
repeats 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_stream is not viable: Pingora may not deliver
a 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 JsonBalanceState scanner that tracks {}/[] depth
incrementally, skipping bytes inside string literals (respecting
backslash escapes). It is updated only with the bytes in each new
chunk and persisted via filter_metadata between calls, so total scan
work across all chunks is O(n) rather than O(buffer²):

fn scan_json_balance(state: &mut JsonBalanceState, chunk: &[u8]) {
    if state.is_complete() {
        return;
    }
    for &byte in chunk {
        // track in_string / escaped / depth incrementally
        // ...
    }
}

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:

if balance.is_complete() || end_of_stream {
    try_capture_from_buffer(ctx, store, &self.config.task_routing, end_of_stream);
}

This preserves the existing opportunistic, pre-EOS capture guarantee
while bounding total per-response scan work to O(n). clear_capture_metadata
is extended to also clear the new scanner scratch keys, and reused in
accumulate_response_hex's overflow path to remove the previous
duplicated inline .remove() calls.

Scoped to the non-streaming capture path only — the SSE capture path
(process_sse_response_chunk) already scans incrementally and is
unaffected. No change to the hex-encoding scheme used for multibyte
UTF-8 chunk-boundary safety.

Implements the proposal in #396.

Test plan

  • Unit tests for JsonBalanceState/scan_json_balance: minimal
    object, 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
  • New regression test many_single_byte_chunks_still_capture_route_before_eos
    covering capture before end_of_stream when a body arrives as many
    single-byte chunks
  • Existing A2A response-body unit test coverage passes unmodified
  • make test — full workspace suite passes (0 failed)
  • make lint — clippy, nightly fmt-check, separator/filter-docs
    lints all pass

Fixes #353

@mkoushni
mkoushni requested review from a team and aslakknutsen July 21, 2026 08:11
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>
@mkoushni
mkoushni force-pushed the perf/353-a2a-json-balance-scan-o1 branch from 2dfec85 to f7ffc51 Compare July 21, 2026 08:17
@mkoushni

Copy link
Copy Markdown
Contributor Author

@aslakknutsen the doc proposal for this fix is #396

Comment thread filters/src/agentic/a2a/mod.rs
Comment thread filters/src/agentic/a2a/mod.rs Outdated
mkoushni added 2 commits July 21, 2026 15:35
…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>
@mkoushni
mkoushni requested a review from aslakknutsen July 21, 2026 13:35
Comment thread filters/src/agentic/a2a/mod.rs Outdated
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>
@mkoushni
mkoushni requested a review from aslakknutsen July 21, 2026 14:08
Comment thread filters/src/agentic/a2a/mod.rs Outdated
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 praxis-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

Comment thread filters/src/agentic/a2a/mod.rs
Comment thread filters/src/agentic/a2a/mod.rs
@aslakknutsen
aslakknutsen added this pull request to the merge queue Jul 22, 2026
Merged via the queue into praxis-proxy:main with commit 6884279 Jul 22, 2026
19 checks passed
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.

perf(a2a): O(n²) hex-decode and JSON parse on every response body chunk

3 participants