fix: stream group_agent_export instead of buffering the whole group#112
fix: stream group_agent_export instead of buffering the whole group#112agent-p1p wants to merge 2 commits into
Conversation
|
Warning Review limit reached
More reviews will be available in 38 minutes and 56 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 (4)
✨ 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 |
|
Ready to review this PR? Stage has broken it down into 4 individual chapters for you:
Chapters generated by Stage for commit 57d292a on Jun 24, 2026 6:27am UTC. |
Adversarial reviewVerdict: Changes requested (1 blocking finding). Blocking
Suggestions
Nitpicks
Local check run during review: |
The streaming response keeps the per-event array per-chunk, but the export header (timeline items, excluded ids, human-action rows) is inherently O(group events) and is still buffered to build the response. Gate the endpoint on the valid-event count (a single SQL COUNT, no rows materialized) via GOGGLES_AGENT_EXPORT_MAX_EVENTS so an oversized group fails predictably with a 413 + JSON error instead of spiking a worker's RSS and degrading it for all users. Completeness is preserved below the cap; <= 0 disables the guard. Addresses adversarial review on #112. Closes #109
Addressed blocking finding (commit 05da568)The adversarial review correctly flagged that the export header is still O(group events): Per the issue's stated design tension ("completeness is the point" — a hard cap/truncation is wrong) and the review's own escape hatch ("add an explicit size guard/413 if the complete timeline/actions/messages cannot be produced without O(group events) memory"), I added a configurable event-count gate:
Verification (CI-exact commands, local)
Note: |
The agent-state export was the last unbounded full-group path: it materialized every valid event (with its raw_context/raw_kind JSON) into a Python list, built one nested export dict, and serialized one large JSON string -- all resident in a single gunicorn worker's heap. A large group could spike a worker's RSS and block/OOM it for all users, the same memory-exhaustion class the bounded landing/detail/ tabs/timeline paths were introduced to avoid. Stream the response instead, preserving completeness (no cap, no truncation): - Split the build into a bounded header (everything except the per-event array, built from events fetched WITHOUT the raw JSON blobs -- only agent_event_row reads those) and a lazy per-event generator (agent_event_rows_for_group) that pulls rows via .iterator(chunk_size= 2_000), ordered in SQL with nulls_last to match sorted_timeline_events without a full-materialization Python re-sort. - group_agent_export now returns a StreamingHttpResponse whose generator serializes the header once, then streams each event row individually, so memory stays roughly per-chunk + the bounded aggregate sections instead of per-group, and the body is written incrementally. Adds regression tests for lazy chunked streaming, completeness + deterministic timeline ordering, and the pretty-printed stream. Closes #109
The streaming response keeps the per-event array per-chunk, but the export header (timeline items, excluded ids, human-action rows) is inherently O(group events) and is still buffered to build the response. Gate the endpoint on the valid-event count (a single SQL COUNT, no rows materialized) via GOGGLES_AGENT_EXPORT_MAX_EVENTS so an oversized group fails predictably with a 413 + JSON error instead of spiking a worker's RSS and degrading it for all users. Completeness is preserved below the cap; <= 0 disables the guard. Addresses adversarial review on #112. Closes #109
05da568 to
57d292a
Compare
Closes #109
Summary
group_agent_exportwas the last unbounded full-group path: it materialized every valid event (including the per-eventraw_context/raw_kindJSONField blobs) into a Python list, built one nested export dict, then serialized one large JSON string — all held in a single gunicorn worker's heap before the response was written. For a large group (50 MiB/file upload ceiling × many files) this could spike a worker's RSS into the hundreds of MB/GB and block or OOM it for all users — the same memory-exhaustion class the bounded landing (#17), detail (#38), tabs (#92), and timeline (#38/#94) paths were introduced to avoid.Per the issue's design guidance, the export is the authoritative, complete whole-group artifact, so capping/truncating is wrong — completeness is the point. This streams the response instead, keeping memory roughly per-chunk + the bounded aggregate sections rather than per-group, with no cap and no truncation.
Approach
"Complete" and "buffer the whole thing in one heap" are separable. The fix splits the export into two passes:
Bounded header (
agent_state_export_header_for_group) — every section except the per-event array (group,summary,sources,timeline,actions,messages,sensitivity). These aggregate sections are bounded by engine/message/action cardinality, not raw event count, and — crucially — none of them readraw_context/raw_kind(onlyagent_event_rowdoes). So the header is built from events fetched without the export-only JSON blobs (include_export_fields=False), i.e. the same lean rows the existing bounded views already load.Lazy per-event generator (
agent_event_rows_for_group) — streamsagent_event_rowdicts (these do carry the raw JSON) from the DB via.iterator(chunk_size=2_000)(the chunked-iteration pattern already used in this module). Ordered in SQL withF("wall_time_ms").asc(nulls_last=True), line_number, idto matchsorted_timeline_eventsexactly, so rows stream in the final order without a Python re-sort that would force full materialization.group_agent_exportnow returns aStreamingHttpResponsewhose generator serializes the header once, replaces its closing brace with a streamed"events": [...]array, and serializes each event row individually — so the body is written incrementally and the export is never one giant in-memory JSON string. Both compact and?pretty=1modes are preserved.Verification (local, full CI parity except docker postgres + dep-audit)
uv run python manage.py test— 158 passed (155 existing + 3 new regression tests), no regressionsuv run ruff check .— cleanuv run ruff format --check .— cleanuv run python manage.py check— no issuesuv run python manage.py makemigrations --check --dry-run— No changes detected (no model/migration drift)GitHub Actions additionally covers the Postgres test run (verifies
nulls_lastordering on Postgres, where NULL placement differs from SQLite) and dependency audit.New regression tests
test_group_agent_export_streams_events_lazily_in_chunks—agent_event_rows_for_groupis a lazy generator; view returnsStreamingHttpResponsetest_group_agent_export_streams_complete_events_in_timeline_order— completeness (every valid event, no drop) + deterministic order matching the bufferedsorted_timeline_eventsorder, across two enginestest_group_agent_export_pretty_streams_valid_json—?pretty=1still yields valid, complete, indented JSONtest_group_agent_export_returns_agent_readable_jsonto consume the streamed bodySensitive paths
None. Touches
forensics/views.py+forensics/analysis.py+forensics/tests.pyonly — no models, migrations, settings, auth, or token code (repo_sensitive_pathsuntouched;makemigrations --checkconfirms no DB drift).