fix: make role assignment retries idempotent

This commit is contained in:
2026-08-22 23:34:52 +09:00
parent 703398bd2c
commit 2cfbb1caea
2 changed files with 62 additions and 5 deletions
+47
View File
@@ -15645,6 +15645,38 @@ mod tests {
} }
} }
#[tokio::test]
async fn role_assignment_endpoint_replays_same_operation_result() {
let dir = tempfile::tempdir().unwrap();
let api = test_api(dir.path()).await;
let ticket = browser_ticket_backend(&api)
.unwrap()
.create(ticket::NewTicket::new("Idempotent assignment"))
.unwrap();
let request = || SetTicketRoleAssignmentRequest {
operation_id: "same-role-operation".to_string(),
principal: TicketAssignmentPrincipal::WorkspaceAgent {
agent_key: "workspace-orchestrator".to_string(),
},
expected_assignment_id: None,
};
let path = || {
AxumPath((
TEST_WORKSPACE_ID.to_string(),
ticket.id.clone(),
"orchestrator".to_string(),
))
};
let Json(first) = scoped_set_ticket_assignment(State(api.clone()), path(), Json(request()))
.await
.unwrap();
let Json(replay) = scoped_set_ticket_assignment(State(api), path(), Json(request()))
.await
.unwrap();
assert_eq!(replay.assignment, first.assignment);
}
#[tokio::test] #[tokio::test]
async fn ticket_assignment_endpoints_read_and_clear_current_assignment() { async fn ticket_assignment_endpoints_read_and_clear_current_assignment() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
@@ -15765,6 +15797,21 @@ mod tests {
.await .await
.unwrap(); .unwrap();
assert_eq!(cleared.assignment, None); assert_eq!(cleared.assignment, None);
let Json(replayed_clear) = scoped_clear_ticket_assignment(
State(api),
AxumPath((
TEST_WORKSPACE_ID.to_string(),
ticket_id,
"coder".to_string(),
)),
Query(ClearTicketRoleAssignmentQuery {
operation_id: Some("clear-current".to_string()),
assignment_id: Some("assignment-api-1".to_string()),
}),
)
.await
.unwrap();
assert_eq!(replayed_clear.assignment, None);
} }
#[tokio::test] #[tokio::test]
+15 -5
View File
@@ -3618,11 +3618,9 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
for value in [ for value in [
record.workspace_id.as_str(), record.workspace_id.as_str(),
record.ticket_id.as_str(), record.ticket_id.as_str(),
record.assignment_id.as_str(),
record.role.as_str(), record.role.as_str(),
principal_json.as_str(), principal_json.as_str(),
record.assigned_by.as_str(), record.assigned_by.as_str(),
record.assigned_at.as_str(),
expected_assignment_id.unwrap_or(""), expected_assignment_id.unwrap_or(""),
if allow_reassign { "reassign" } else { "assign" }, if allow_reassign { "reassign" } else { "assign" },
] { ] {
@@ -3857,10 +3855,8 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
"ticket-role-assignment:manual-start:v1", "ticket-role-assignment:manual-start:v1",
record.workspace_id.as_str(), record.workspace_id.as_str(),
record.ticket_id.as_str(), record.ticket_id.as_str(),
record.assignment_id.as_str(),
principal_json.as_str(), principal_json.as_str(),
record.assigned_by.as_str(), record.assigned_by.as_str(),
record.assigned_at.as_str(),
] { ] {
hasher.update(value.as_bytes()); hasher.update(value.as_bytes());
hasher.update([0]); hasher.update([0]);
@@ -4069,7 +4065,6 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
assignment_id, assignment_id,
principal_json.as_str(), principal_json.as_str(),
actor, actor,
occurred_at,
reason.unwrap_or(""), reason.unwrap_or(""),
] { ] {
hasher.update(value.as_bytes()); hasher.update(value.as_bytes());
@@ -4124,6 +4119,8 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
"operation `{operation_id}` was already used for different Ticket assignment input" "operation `{operation_id}` was already used for different Ticket assignment input"
))); )));
} }
tx.commit()?;
return Ok(true);
} }
let deleted = tx.execute( let deleted = tx.execute(
"DELETE FROM ticket_current_worker_assignments "DELETE FROM ticket_current_worker_assignments
@@ -10645,6 +10642,19 @@ INSERT INTO worker_registry (
.start_ready_ticket_with_coder_assignment(&coder, "event-coder", "op-coder") .start_ready_ticket_with_coder_assignment(&coder, "event-coder", "op-coder")
.unwrap(); .unwrap();
assert_eq!(started, coder); assert_eq!(started, coder);
let replay_input = TicketRoleAssignmentRecord {
assignment_id: "coder-regenerated-result".to_string(),
assigned_at: "2026-09-01T00:01:59Z".to_string(),
..coder.clone()
};
let replayed = store
.start_ready_ticket_with_coder_assignment(
&replay_input,
"event-coder-regenerated",
"op-coder",
)
.unwrap();
assert_eq!(replayed, coder);
let ticket = ticket::TicketBackend::show(&backend, ticket.id.clone().into()).unwrap(); let ticket = ticket::TicketBackend::show(&backend, ticket.id.clone().into()).unwrap();
assert_eq!( assert_eq!(
ticket.meta.workflow_state, ticket.meta.workflow_state,