fix(multiplayer): recover queue revision conflicts

This commit is contained in:
Josh Creek
2026-09-01 22:19:02 +01:00
parent 1f05f6d524
commit 7534d8436c
3 changed files with 18 additions and 0 deletions
+8
View File
@@ -298,6 +298,10 @@ static func is_retryable_mutation_response(response_code: int) -> bool:
return response_code == 0 or response_code == HTTPClient.RESPONSE_REQUEST_TIMEOUT or response_code == HTTPClient.RESPONSE_TOO_MANY_REQUESTS or response_code >= 500
static func should_recover_queue_after_conflict(operation: String, response_code: int, ticket_id: String) -> bool:
return response_code == HTTPClient.RESPONSE_CONFLICT and operation in ["queue_heartbeat", "queue_cancel"] and not ticket_id.is_empty()
static func normalize_ticket(payload: Dictionary) -> Dictionary:
var result := payload.duplicate(true)
if result.has("enqueued_at") and result["enqueued_at"] is String:
@@ -373,6 +377,7 @@ func _on_request_completed(result: HTTPRequest.Result, response_code: int, _head
_last_mutation_retryable = _last_mutation.get("operation", "") == operation and is_retryable_mutation_response(response_code)
var detail := String(parsed.get("error", "request rejected"))
var recover_proposal_after_conflict := response_code == HTTPClient.RESPONSE_CONFLICT and (operation == "proposal_accept" or operation == "proposal_decline") and not state.proposal_id.is_empty()
var recover_queue_after_conflict := should_recover_queue_after_conflict(operation, response_code, state.ticket_id)
if response_code == HTTPClient.RESPONSE_UNAUTHORIZED:
access_token = ""
auth_expired = true
@@ -396,6 +401,9 @@ func _on_request_completed(result: HTTPRequest.Result, response_code: int, _head
if recover_proposal_after_conflict:
_pending_resync_resource_id = state.proposal_id
call_deferred("_run_pending_resync")
if recover_queue_after_conflict:
_pending_resync_resource_id = state.ticket_id
call_deferred("_run_pending_resync")
return
var payload: Dictionary = parsed
_last_mutation_retryable = false
@@ -99,6 +99,14 @@ func test_retryable_mutation_policy_only_retries_safe_failures() -> void:
assert_true(not ControlPlaneClient.is_retryable_mutation_response(409), "revision/idempotency conflict is not blindly replayed")
func test_queue_revision_conflicts_schedule_authoritative_recovery() -> void:
assert_true(ControlPlaneClient.should_recover_queue_after_conflict("queue_heartbeat", 409, "ticket-1"), "stale heartbeat recovers the queue ticket")
assert_true(ControlPlaneClient.should_recover_queue_after_conflict("queue_cancel", 409, "ticket-1"), "stale cancellation recovers the queue ticket")
assert_true(not ControlPlaneClient.should_recover_queue_after_conflict("queue_create", 409, "ticket-1"), "create conflict uses its own idempotency path")
assert_true(not ControlPlaneClient.should_recover_queue_after_conflict("queue_heartbeat", 503, "ticket-1"), "transient outage remains retryable instead of being treated as a revision conflict")
assert_true(not ControlPlaneClient.should_recover_queue_after_conflict("queue_cancel", 409, ""), "missing ticket cannot trigger recovery")
func test_assignment_endpoint_split_never_accepts_url_or_bad_port() -> void:
var endpoint := ControlPlaneClient._split_assignment_endpoint("127.0.0.1:31001")
assert_eq(endpoint["host"], "127.0.0.1", "assignment host is separated from the port")
+2
View File
@@ -1558,3 +1558,5 @@ All client resync entry points now apply the open-proposal boundary: a terminal
Ticket projections now validate playlist metadata on every update, rejecting unknown values before either phase or playlist state can mutate. An adversarial higher-revision update test covers this boundary.
Client sessions now fail closed at the expiry boundary and proactively clear credentials before reconnects or authenticated requests. Boundary and malformed-expiry tests cover the lifecycle guard.
Queue heartbeat and cancellation revision conflicts now schedule the same authoritative ticket recovery as proposal conflicts, preventing stale client actions from leaving the visible queue state unresolved. Adversarial operation/status/identity coverage is included.