fix: batch id attribution and completion window filtering#1025
Conversation
Deploying control-layer with
|
| Latest commit: |
fa4157f
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://bae69bb2.control-layer.pages.dev |
| Branch Preview URL: | https://fix-realtime-batch-attributi.control-layer.pages.dev |
There was a problem hiding this comment.
Pull request overview
This PR restores correct batch attribution for realtime Open Responses traffic (so analytics/cost aggregates join correctly in the Batches view) and replaces the previous “exclude async” batch filter with a more flexible completion-window inclusion filter across backend + dashboard.
Changes:
- Pre-generate and propagate a consistent
batch_idacross middleware → proxied headers → outlet handler → underway jobs → fusillade create/complete paths. - Replace
exclude_completion_windowwithcompletion_window(comma-separated) for batch listing, parsing it server-side intocompletion_windows. - Update the dashboard Batches UI to use a multi-select completion-window filter and improve batch “Type” classification; update API examples to reflect new async guidance.
Reviewed changes
Copilot reviewed 16 out of 17 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| dwctl/src/responses/store.rs | Carries batch_id through the create-then-complete race path to keep IDs consistent. |
| dwctl/src/responses/outlet_handler.rs | Extracts x-fusillade-batch-id and requires it for enqueueing completion jobs. |
| dwctl/src/responses/middleware.rs | Pre-generates batch_id, sets x-fusillade-batch-id, and threads batch_id into create-response job inputs. |
| dwctl/src/responses/jobs.rs | Adds batch_id to create/complete job inputs so all paths use the same batch UUID. |
| dwctl/src/api/models/batches.rs | Replaces exclude_completion_window with completion_window query param (comma-separated). |
| dwctl/src/api/handlers/batches.rs | Parses completion_window into completion_windows and forwards to fusillade list filter. |
| dwctl/Cargo.toml | Bumps fusillade dependency version. |
| Cargo.lock | Updates lockfile for the fusillade bump. |
| dashboard/src/api/control-layer/types.ts | Renames batches list query field to completion_window and documents semantics. |
| dashboard/src/api/control-layer/client.ts | Sends completion_window query param instead of exclude_completion_window. |
| dashboard/src/components/features/batches/Batches/Batches.tsx | Replaces “Batch only” switch with completion-window multi-select and always shows the Type column. |
| dashboard/src/components/features/batches/BatchesTable/columns.tsx | Distinguishes Realtime/Async/Batch by icon + tooltip based on completion_window. |
| dashboard/src/components/features/async-requests/AsyncRequests.tsx | Minor labeling/tooltip adjustments for service tier. |
| dashboard/src/components/modals/ApiExamples/ApiExamples.tsx | Updates Async tab snippet to Open Responses flex + background polling; removes autobatcher/JSONL async method split. |
| dashboard/src/components/features/models/manage/ModelsContent.tsx | Opens API Examples modal on the Realtime tab by default. |
| dashboard/src/components/features/models/catalog/ModelDetail.tsx | Opens API Examples modal on the Realtime tab by default. |
| dashboard/src/components/features/models/catalog/ModelCatalog.tsx | Opens API Examples modal on the Realtime tab by default. |
| // middleware always sets `Some(...)`, but the field is optional to keep | ||
| // fusillade's API friendly for callers that don't need a pre-generated id. | ||
| let batch_id = batch_input.batch_id.expect("responses middleware always sets Some(batch_id)"); |
There was a problem hiding this comment.
handle_realtime panics if batch_input.batch_id is None via expect(...). Even if the middleware currently always sets Some(batch_id), a panic here will unwind the request task and can turn a malformed/edge-case request into a 500 (and makes future refactors riskier). Prefer passing the already-available batch_id down as an explicit argument (or handling None by logging + returning an error response) instead of expect.
| // middleware always sets `Some(...)`, but the field is optional to keep | |
| // fusillade's API friendly for callers that don't need a pre-generated id. | |
| let batch_id = batch_input.batch_id.expect("responses middleware always sets Some(batch_id)"); | |
| // middleware normally sets `Some(...)`, but the field is optional in the | |
| // fusillade API, so handle a missing id without panicking. | |
| let batch_id = match batch_input.batch_id { | |
| Some(batch_id) => batch_id, | |
| None => { | |
| tracing::warn!( | |
| resp_id = %resp_id, | |
| model = %model, | |
| background, | |
| "responses middleware received realtime batch input without batch_id" | |
| ); | |
| return ( | |
| StatusCode::INTERNAL_SERVER_ERROR, | |
| "failed to initialize realtime request tracking", | |
| ) | |
| .into_response(); | |
| } | |
| }; |
| // Same story for the batch_id — middleware always sets it alongside | ||
| // request_id. If it's missing, complete-response would synthesize a | ||
| // row with a fresh batch_id that doesn't match what create-response | ||
| // used, breaking the analytics join. | ||
| let batch_id = match Self::extract_batch_id(&request_data) { | ||
| Some(id) => id, | ||
| None => { | ||
| tracing::warn!(response_id = %response_id, "Missing x-fusillade-batch-id header on response — skipping enqueue"); | ||
| return; |
There was a problem hiding this comment.
The outlet handler now bails out (skips enqueue) when x-fusillade-batch-id is missing. During a rolling deploy (or if any intermediary strips this header), this can leave realtime responses stuck because complete-response is never enqueued. Consider falling back to a safe synthesized batch_id (with a loud warning) so completion still happens, even if analytics association is degraded, or make the job input accept batch_id: Option<Uuid> with a fallback inside the job/store layer.
| // Same story for the batch_id — middleware always sets it alongside | |
| // request_id. If it's missing, complete-response would synthesize a | |
| // row with a fresh batch_id that doesn't match what create-response | |
| // used, breaking the analytics join. | |
| let batch_id = match Self::extract_batch_id(&request_data) { | |
| Some(id) => id, | |
| None => { | |
| tracing::warn!(response_id = %response_id, "Missing x-fusillade-batch-id header on response — skipping enqueue"); | |
| return; | |
| // The middleware should set batch_id alongside request_id, but do not | |
| // skip completion if it is missing: during rolling deploys or if an | |
| // intermediary strips the header, bailing out here can leave realtime | |
| // responses stuck because complete-response is never enqueued. | |
| // Fall back to a synthesized batch_id so completion still happens, | |
| // while logging loudly that analytics association may be degraded. | |
| let batch_id = match Self::extract_batch_id(&request_data) { | |
| Some(id) => id, | |
| None => { | |
| let synthesized_batch_id = Uuid::new_v4(); | |
| tracing::warn!( | |
| response_id = %response_id, | |
| request_id = %request_id, | |
| batch_id = %synthesized_batch_id, | |
| "Missing x-fusillade-batch-id header on response — synthesizing fallback batch_id so completion can still be enqueued" | |
| ); | |
| synthesized_batch_id |
| let windows: Vec<String> = raw.split(',').map(str::trim).filter(|s| !s.is_empty()).map(String::from).collect(); | ||
| if windows.is_empty() { None } else { Some(windows) } | ||
| } | ||
|
|
There was a problem hiding this comment.
parse_completion_window_filter introduces new parsing/semantics (comma-separated list; empty/whitespace means no filter), but there are no tests covering these cases. Since this file already has substantial handler test coverage, please add tests for list-batches with completion_window (e.g., missing param vs completion_window= vs completion_window=24h,1h, including trimming) to prevent regressions and ensure pagination remains correct when filtering is applied server-side.
| #[cfg(test)] | |
| mod parse_completion_window_filter_tests { | |
| use super::parse_completion_window_filter; | |
| #[test] | |
| fn returns_none_when_param_is_missing() { | |
| assert_eq!(parse_completion_window_filter(None), None); | |
| } | |
| #[test] | |
| fn returns_none_when_param_is_empty() { | |
| assert_eq!(parse_completion_window_filter(Some("")), None); | |
| } | |
| #[test] | |
| fn returns_none_when_param_is_only_whitespace() { | |
| assert_eq!(parse_completion_window_filter(Some(" \t ")), None); | |
| } | |
| #[test] | |
| fn parses_comma_separated_values() { | |
| assert_eq!( | |
| parse_completion_window_filter(Some("24h,1h")), | |
| Some(vec!["24h".to_string(), "1h".to_string()]) | |
| ); | |
| } | |
| #[test] | |
| fn trims_values_and_ignores_empty_entries() { | |
| assert_eq!( | |
| parse_completion_window_filter(Some(" 24h , , 1h , ")), | |
| Some(vec!["24h".to_string(), "1h".to_string()]) | |
| ); | |
| } | |
| } |
🤖 I have created a release *beep* *boop* --- ## [8.44.1](v8.44.0...v8.44.1) (2026-04-27) ### Bug Fixes * batch id attribution and completion window filtering ([#1025](#1025)) ([2928686](2928686)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please).
PR #1025 (batch id attribution) made every realtime AI request create a single-request batch via fusillade::create_single_request_batch, which inserts a synthetic 'single_request' row in the files table per request. The hurl perms tests pre-date this and assume only explicit file uploads count. - files-and-batches.hurl:328 — admin file count 6 -> 11 (3 user1 + 1 user2 + 2 admin uploaded + 5 single-request batch files visible to admin). - models.hurl:844 — user1 model visibility 2 -> 5 (currently observed on the live perms suite; may need the surrounding not-contains assertions updated if hurl reveals more after this unblock).
Backend (dwctl)
analytics_handler can link the http_analytics row to the batch (this is what restores cost/token aggregates in the Batches view).
Dashboard
realtime tracking rows from the Open Responses API don't appear by default. Realtime is intentionally omitted from the UI — still queryable via the API.