Skip to content

fix: batch id attribution and completion window filtering#1025

Merged
sejori merged 9 commits into
mainfrom
fix/realtime-batch-attribution-and-filtering
Apr 27, 2026
Merged

fix: batch id attribution and completion window filtering#1025
sejori merged 9 commits into
mainfrom
fix/realtime-batch-attribution-and-filtering

Conversation

@sejori
Copy link
Copy Markdown
Contributor

@sejori sejori commented Apr 25, 2026

Backend (dwctl)

  • dwctl/src/api/models/batches.rs — ListBatchesQuery.exclude_completion_window: Option replaced with completion_window: Option (comma-separated values, forwarded as-is).
  • dwctl/src/api/handlers/batches.rs — new parse_completion_window_filter helper splits the param into a Vec and passes it to fusillade::ListBatchesFilter.completion_windows. Empty/whitespace param means "no filter".
  • dwctl/src/responses/middleware.rs — pre-generates batch_id next to request_id, populates the new CreateSingleRequestBatchInput.batch_id field, and inserts x-fusillade-batch-id on the proxied request alongside x-fusillade-request-id so
    analytics_handler can link the http_analytics row to the batch (this is what restores cost/token aggregates in the Batches view).
  • dwctl/src/responses/jobs.rs — CreateResponseInput and CompleteResponseInput carry batch_id so the underway job paths use the same id as the middleware/headers.
  • dwctl/src/responses/store.rs — CreateContext carries batch_id; the synthesize-on-race path in complete_response_idempotent uses it.
  • dwctl/src/responses/outlet_handler.rs — extracts x-fusillade-batch-id from request headers and passes it on the enqueued CompleteResponseInput. Bails out with a warning if missing (the middleware always sets both headers together).
  • Cargo.toml — temporary [patch.crates-io] fusillade = { path = "../fusillade" } for local dev. Remove before merging and bump fusillade to a new (breaking) major version.

Dashboard

  • dashboard/src/api/control-layer/types.ts + client.ts — BatchesListQuery.exclude_completion_window replaced with completion_window (comma-separated string), sent as ?completion_window=24h,1h.
  • dashboard/.../Batches/Batches.tsx — hideAsync Switch + "Batch only" toggle replaced with a multi-select Popover (mirrors AsyncRequests) offering Batch (24h) and Async (configurable async window, default 1h). Defaults to ["24h"] so
    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.
  • dashboard/.../BatchesTable/columns.tsx — Type column now distinguishes three classes via icon + tooltip: Zap "Realtime" (0s), FastForward "Async" (async window), Box "Batch" (everything else). Always shown.

@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages Bot commented Apr 25, 2026

Deploying control-layer with  Cloudflare Pages  Cloudflare Pages

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

View logs

@sejori sejori marked this pull request as ready for review April 27, 2026 11:00
Copilot AI review requested due to automatic review settings April 27, 2026 11:00
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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_id across middleware → proxied headers → outlet handler → underway jobs → fusillade create/complete paths.
  • Replace exclude_completion_window with completion_window (comma-separated) for batch listing, parsing it server-side into completion_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.

Comment thread dwctl/src/responses/middleware.rs Outdated
Comment on lines +240 to +242
// 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)");
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
// 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();
}
};

Copilot uses AI. Check for mistakes.
Comment on lines +88 to +96
// 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;
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
// 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

Copilot uses AI. Check for mistakes.
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) }
}

Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
#[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()])
);
}
}

Copilot uses AI. Check for mistakes.
@sejori sejori merged commit 2928686 into main Apr 27, 2026
7 checks passed
@sejori sejori deleted the fix/realtime-batch-attribution-and-filtering branch April 27, 2026 11:56
sejori pushed a commit that referenced this pull request Apr 27, 2026
🤖 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).
sejori added a commit that referenced this pull request Apr 30, 2026
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).
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.

2 participants