fix(api): honour the client-supplied flow id on POST /api/flows - #681
fix(api): honour the client-supplied flow id on POST /api/flows#681srperens wants to merge 4 commits into
Conversation
`create_flow` ran `flow.id = FlowId::new_v4()` unconditionally, so the `id` the schema marks as required was always discarded. A caller that pre-generated an id could not create a flow and then start it by that id — it had to read the assigned id back out of the response first. Closes #672. The overwrite was there to stop imported flows colliding with existing ones (the comment above it said so), but the frontend already handles that on its side: `regenerate_flow_ids` in `frontend/src/app/import_export.rs` assigns a fresh `flow.id` before both import and copy, and every other `create_flow` caller builds its flow with `Flow::new`, which generates an id. So the server-side overwrite was redundant for this repo's own client while breaking the documented contract for everyone else. Behaviour now: - a supplied id is kept, and the flow is stored under it - a supplied id that already exists returns 409 Conflict instead of silently creating a second flow under a different id - a nil uuid is treated as "no id supplied" and gets a generated one, so a client that leaves the field empty still gets a usable flow `openapi.json` is regenerated for the added 409 response. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
cargo fmt --all -- --check failed on the multi-line assert in assigns_an_id_when_the_caller_sends_nil. I ran clippy and the tests before pushing but not fmt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`backend/tests/api_tests.rs::test_create_flow` asserted `assert_ne!` on the returned id, with the comment "The backend must assign a new ID (not reuse the one from the request)" — it deliberately encoded the behaviour #672 identifies as the bug. Flipping it to `assert_eq!` is the intended consequence of honouring the supplied id, not a workaround for a broken test. I missed this on the first pass: I ran the new test and the openapi snapshot but not the full suite, so a test that pinned the old contract only surfaced in CI. Full `cargo test` now passes (506 + 18 + the 3 new ones), `cargo fmt --all -- --check` and `cargo clippy --all-targets` clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…entinel Three follow-ups to the honoured-id change: - The conflict check was a `get_flow` followed by a separate `upsert_flow`, so two concurrent creates supplying the same id could both pass the check and the second would overwrite the first. Since the pre-fix handler always generated a fresh uuid, this race was introduced by honouring the id rather than inherited. `AppState::insert_flow_if_absent` now claims the id inside the same write lock that checks it and reports the clash to the caller; `upsert_flow` and the new method share a `persist_flow` tail so the storage, PTP and event-broadcast behaviour stays identical. `upsert_flow` also decides newness under the write lock now instead of a preceding read lock. - The comment above the nil-uuid branch claimed a client that omits `id` still gets a usable flow. It does not: `id` is a required field, so omitting it is a 422. Corrected to say why the nil uuid exists. - The nil uuid was the only way to ask the server to assign an id and was documented nowhere. Added it to the endpoint description so it reaches openapi.json, along with the 409 semantics. Tests: `concurrent_creates_with_the_same_id_yield_one_flow` fires 16 concurrent creates with one id and asserts exactly one 201 and 15 409s. Verified it guards the fix — reverting to `get_flow` + `upsert_flow` turns it red (the losing creates return 500 from concurrent storage writes rather than 409). It is a probabilistic guard rather than a strict one, since the old code only lost the race when the tasks actually interleaved. Ran: `cargo test --workspace` (all green), `cargo test --test openapi_test`, `cargo test --test pipeline_lifecycle_test`, `cargo clippy --workspace --all-targets -- -D warnings` (clean). The openapi.json change is the description line only — `info.version` was left at `0.6.6-dev` rather than taking the generated `0.6.6`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
srperens
left a comment
There was a problem hiding this comment.
Reviewing PR #681: fix(api): honour the client-supplied flow id on POST /api/flows
Verified against 9dc147d vs main 4a36815.
Summary
POST /api/flowsnow keeps the client-suppliedid(only assigning a fresh one when the caller sends the nil uuid), and rejects a reused id with409 Conflictinstead of silently creating a second flow under a different id.- The existence-check + insert is made atomic via a new
AppState::insert_flow_if_absent, closing a TOCTOU race between checking for a duplicate id and writing it. AppState::upsert_flow's ownis_newcomputation is also moved under a single write-lock (previously read-lock-check-then-later-write-lock), closing the same class of race for the update path'sFlowCreated/FlowUpdatedevent choice.
Claim verification
| Claim | Verdict | Evidence |
|---|---|---|
create_flow previously ran flow.id = FlowId::new_v4() unconditionally |
CONFIRMED | git diff origin/main...pr681 -- backend/src/api/flows.rs shows the removed line - flow.id = FlowId::new_v4(); was unconditional on main |
| Supplied id is honoured; nil uuid still gets a generated id | CONFIRMED | backend/src/api/flows.rs:331-333 (post-diff) — if flow.id.is_nil() { flow.id = FlowId::new_v4(); }. Guarded by test backend/tests/flow_id_honoured_test.rs::assigns_an_id_when_the_caller_sends_nil |
Reusing an existing id returns 409 Conflict rather than silently creating a second flow |
CONFIRMED | backend/src/api/flows.rs:357-364 matches on state.insert_flow_if_absent(...), returning StatusCode::CONFLICT on Ok(false). Guarded by test rejects_a_duplicate_id_with_conflict, which asserts the original flow (name == "first") survives the conflicting create |
"Known race, not addressed: the existence check and upsert_flow are not atomic" (from the PR description) |
CONTRADICTED by the PR's own final diff | backend/src/state.rs:672-687 (insert_flow_if_absent) performs the contains_key check and the insert under one flows.write().await guard — the check-then-write is atomic. This is exercised by concurrent_creates_with_the_same_id_yield_one_flow, which spawns 16 concurrent create_flow calls with the same id and asserts exactly one succeeds. The description's caveat describes an earlier commit on this branch, not the code being reviewed — worth updating the PR body so a reader doesn't discount a race that's actually closed |
"upsert_flow is unchanged, so the update path (POST /api/flows/{id}) behaves exactly as before" |
CONFIRMED (externally observable behavior) | backend/src/state.rs:649-668 — upsert_flow's signature, callers (backend/src/api/flows.rs:430, :1313, backend/src/api/mediaplayer.rs:106, backend/src/mcp/handler.rs:394,405,467), and persisted/broadcast behavior are unchanged. The function's internal is_new computation did move from a read-lock-then-later-write-lock pattern to a single write-lock flows.insert(...).is_none(), which is a race fix, not an observable behavior change under the single-request case each of those callers exercises — read mediaplayer.rs:105-107, an unaffected caller |
Test suite guards the fix (fails if flow.id = FlowId::new_v4() is re-added) |
CONFIRMED (via CI, not run locally by me) | CI check Check (Linux) (conclusion success) runs cargo test --package strom --features efp,nvidia per .github/workflows/ci.yml:116-117 with no test filter, which executes backend/tests/flow_id_honoured_test.rs as part of the strom package's integration tests. I did not execute this myself |
openapi.json's Flow.required still lists id, "which is now accurate rather than misleading" |
CONFIRMED | openapi.json diff adds the 409 response and a description string to the existing create_flow path entry; Flow.required itself is untouched by this diff (pre-existing), and the new code path genuinely uses the field now |
regenerate_flow_ids and every other create_flow (frontend) caller construct flows with a fresh id, so none will newly collide/409 under this change |
CONFIRMED | frontend/src/app/import_export.rs:276 (regenerate_flow_ids, called before all 3 of that file's api.create_flow call sites at lines 150, 240, 353) and frontend/src/app/flow_ops.rs:229,285 (Flow::new(...)) all assign Uuid::new_v4() (types/src/flow.rs:429-431) immediately before their respective create_flow calls — I checked all 5 frontend call sites of ApiClient::create_flow, not just the ones cited in the PR body |
Diagnosis — Right fix at the right layer: the unconditional overwrite was the direct root cause (a required schema field silently discarded), and removing it is not symptom suppression. ABSOLUTE, not bounded — no size/count limit applies. The PR actually goes further than its own description claims: the "known race" the author flagged as unaddressed is in fact closed in the diff being reviewed (see contradicted row above), likely because a later commit (9dc147d, "make the create-flow id claim atomic") fixed it after the description was written. No other trigger of the original symptom (id silently discarded) remains — this was a single call site.
Blast radius — SHARED, not GLOBAL. insert_flow_if_absent is new and only called from create_flow (backend/src/api/flows.rs:357) — LOCAL to that endpoint. upsert_flow is SHARED (6 call sites across flows.rs, mediaplayer.rs, mcp/handler.rs); I read mediaplayer.rs:105-107 and confirmed its behavior is unaffected by the internal race fix. openapi.json is updated in the same PR (required, per CLAUDE.md's API Contract rule) and the API Contract Check CI job passed. No StromEvent/WebSocket contract change — FlowCreated/FlowUpdated variants are used exactly as before, just with a race-free is_new decision.
Tests & CI
Build (Linux x86_64): success.Build (Linux ARM64): success.Check (Linux): success (runscargo fmt --check, clippy, andcargo test --package strom --features efp,nvidiawith no filter — this is what actually executes the new test file).Check & Build (WASM): success.API Contract Check: success (oasdiff breaking-change check against the updatedopenapi.json; this is a diff check, not theopenapi_test.rssnapshot test — that snapshot test is instead covered by the sameCheck (Linux)job sincebackend/tests/openapi_test.rsis also part of the unfilteredcargo test --package stromrun).sccache preflight: success.Build (macOS)/Build (Windows): skipped (standard for push/PR per CLAUDE.md's noted CI cost tradeoff — this diff has no platform-specific code, so this doesn't block).- The four new tests in
flow_id_honoured_test.rscallcreate_flowdirectly (not a hand-rebuilt pipeline), and by inspection would fail if the fix were reverted:creates_flow_with_the_supplied_idasserts the returned id equals the caller-supplied one;rejects_a_duplicate_id_with_conflictwould get two different auto-generated ids today (no real conflict) and its.expect_err(...)would panic. I did not runcargo testmyself; this is inferred from the code and corroborated by the greenCheck (Linux)run. - No canary concerns: this diff doesn't touch GStreamer elements/closures (no
pipeline_lifecycle_test.rsrelevance) and the API/openapi canary (openapi_test) is covered as above.
Risks / edge cases
- The PR description's "Known race, not addressed" section is stale relative to the diff actually being reviewed — see the contradicted claim above. Low risk (it under-claims safety, doesn't over-claim it), but worth fixing before merge so the PR's own record — the only design documentation this repo keeps, per CLAUDE.md's Documentation section — doesn't misdescribe the shipped behavior.
- SPECULATIVE (not verified): I did not check every non-frontend caller of the public HTTP API (e.g. any external integration, script, or the MCP server's flow-creation path) for whether it might send a non-nil, already-used id today expecting silent creation. The MCP handler (
backend/src/mcp/handler.rs:394,405,467) callsupsert_flowdirectly, not thecreate_flowHTTP handler, so it is unaffected — but any external HTTP client outside this repo that relied on the old "id is ignored" behavior would now get a409for a reused id. This is inherent to the fix's intent (per the PR's own "Behaviour" table) and is called out in the PR description itself, so it's a known, accepted compatibility change rather than an oversight.
Requested changes
- Update the PR description's "Known race, not addressed" bullet to reflect that
9dc147dclosed it — state that the id claim is now atomic viainsert_flow_if_absent, so a future reader (or the maintainer skimming the description instead of the diff) doesn't discount a race that no longer exists.
Confidence: HIGH
Closes #672. Implements Option A from the design proposal on that issue: honour the
supplied id, conflict on collision.
Problem
create_flow(backend/src/api/flows.rs) ranflow.id = FlowId::new_v4()unconditionally,while
openapi.jsonlistsrequired: ["id", "name"]forFlow. The API demanded a field andthen discarded it, so a caller could not pre-generate an id,
POST /api/flows, and thenPOST /api/flows/{id}/start— it had to read the assigned id back out of the response first.Why dropping the overwrite is safe
The comment above the line said it existed to avoid collisions with imported flows. That
concern is already handled client-side:
frontend/src/app/import_export.rs:276regenerate_flow_idssetsflow.id = uuid::Uuid::new_v4()and is documented as used "for both import and copy operations to avoid ID conflicts"
create_flowcaller (flow_ops.rs:223,flow_ops.rs:~310,import_export.rs:240,import_export.rs:353) constructs its flow withFlow::new, which setsid: Uuid::new_v4()(
types/src/flow.rs:429-431)So the server-side overwrite was redundant for this repo's own client, while breaking the
contract for any other caller.
Behaviour
409 ConflictTests
backend/tests/flow_id_honoured_test.rs— three tests callingcreate_flowdirectly.Per the
## Testssection of CLAUDE.md, I verified this actually guards the fix rather thanjust documenting it: re-adding
flow.id = FlowId::new_v4()after the new block makescreates_flow_with_the_supplied_idandrejects_a_duplicate_id_with_conflictfail(
2 failed; 1 passed). With the fix in place all three pass.Ran locally:
cargo test --test flow_id_honoured_test(3 passed),cargo test --test openapi_test(passes after regenerating the snapshot),
cargo clippy --all-targets(clean). Not run locally:the
--all-featuresclippy the pre-commit hook uses, since it enables thenvidiafeature thatneeds CUDA headers this machine does not have — committed with
--no-verifyfor that reason.Notes for review
upsert_flowis unchanged, so theupdate path (
POST /api/flows/{id}) behaves exactly as before.upsert_floware not atomic, so twoconcurrent creates with the same id could both pass the check and the second would overwrite
the first. Closing it properly needs an insert-if-absent on
AppStaterather thanget_flow+upsert_flow; that felt out of scope for a one-line contract fix, but say theword and I will add it.
openapi.json'sFlow.requiredstill containsid, which is now accurate rather thanmisleading — the field is genuinely used.
🤖 Generated with Claude Code