[#112] Deliver CancelOpRequest arriving before the operation registers#114
Conversation
…IdentityPlatform#112) A CancelOpRequest processed before its OperationRequest registered in localRequests removed nothing and was silently dropped: the operation ran to completion and a subscription operation kept streaming events forever. Park such cancels in the connection group and apply them at registration. Registration is now an explicit step after construction, so a concurrent cancel can no longer observe a partially constructed processor, and the subscription/batch processors hand their Subscription over under a lock, closing it instead of throwing NPE when the cancel wins the race.
maximthomas
left a comment
There was a problem hiding this comment.
Two things worth addressing before merge.
1. register() can still return true for an already-cancelled request
RemoteConnectionGroup — this interleaving is reachable:
A: localRequests.putIfAbsent(id, req) // registers
B: pendingCancels.put(id, expiry)
B: localRequests.remove(id) -> req // non-null
B: pendingCancels.remove(id) // B consumes the park
B: req.cancel() // cancel delivered, OK
A: pendingCancels.remove(id) -> null // A sees nothing
A: return req -> register() == true -> execute() runs
The cancel is still delivered, so the headline guarantee holds and this is not a regression against master. But the invariant stated in the PR description and in the register() javadoc — "returns false when a cancel for this request id was already received" — does not hold here, and for a subscription op the connector actually subscribes before attachSubscription tears it back down.
The AtomicBoolean cancelled you added already carries the answer; make the decision depend on the request's own state instead of on winning the map removal:
// LocalRequest
public final boolean isCancelled() {
return cancelled.get();
}// RemoteConnectionGroup.receiveRequest
if (null != pendingCancels.remove(localRequest.getRequestId())) {
localRequest.cancel();
}
return localRequest.isCancelled() ? null : localRequest;This also lets LocalRequestRegistrationTest assert the invariant directly rather than inferring it.
2. No concurrent test for a concurrency fix
The five new tests cover the state machine well, but all five are single-threaded — none exercises the race the PR exists to fix, and none would have caught the window above. A stress test is cheap here:
// N iterations; per round, two threads race register() against receiveRequestCancel(id).
// Assert after each round: request.isCancelled() is true, and tryCancel() ran exactly once.Minor
- Parked cancels linger up to 60 s when a cancel loses the race with completion.
tryCancelRemoteonly fires fromPromiseImpl.tryCancelwhile the promise is pending, so this is low-volume and self-purging — but the park is unconditional, so every cancel that arrives after the server already produced its result holds an entry for the full TTL. A line in the javadoc acknowledging that would help the next reader. receiveRequestreturningnullis a semantic change to published API. All in-treeLocalRequestconstruction sites are converted, but an out-of-tree subclass relying on constructor registration would compile unchanged and silently never register (no cancel delivery, noreceiveRequestUpdaterouting). Worth a release note.
A CancelOpRequest processed entirely between the registration's putIfAbsent and its pending-cancel check cancelled the request directly, yet register() still returned true and the operation was executed. Decide from the request's own cancelled state instead of from winning the pendingCancels removal, and never leave a request reported dead in localRequests. Covered by a deterministic replay test (fails on the previous logic) and a 1000-round two-thread stress test racing register() against receiveRequestCancel().
|
Thanks for the careful review — all four points are addressed in 73542c9. 1. 2. Concurrent coverage — two new tests:
One note on the stress test's assertions: " 3. Parked-cancel TTL — documented on 4. A side effect worth flagging: |
Part 1 of #112: the
CancelOpRequestregistration race.Problem
Operation execution is submitted to the shared pool and the
LocalRequestregistered itself in its constructor on that pool thread. ACancelOpRequestfor the same message id processed before that registration removed nothing fromlocalRequestsand was silently dropped: the operation ran to completion (wasted work for search/sync), and for subscription operations — where the cancel is the unsubscribe mechanism — the subscription kept streaming events until the connection died.Two related defects surfaced during the fix:
LocalRequestpublishedthisfrom its constructor, so a concurrent cancel could observe a partially constructed processor (subclass fields stillnull) and NPE intryCancel().Subscriptiononly insideexecuteOperation, so even a correctly delivered early cancel hitsubscription.close()onnull.Fix
RemoteConnectionGroup: a cancel that finds no registered request is parked (request id → expiry, TTL 60 s) and applied when the operation registers. Park-then-lookup vs publish-then-check write the two maps in opposite order, so whichever side runs second sees the other — the cancel is never lost regardless of processing order. Request ids are never reused within a group, so a parked cancel can only match the operation it was sent for.LocalRequest: registration moved out of the constructor into an explicitregister()that returnsfalsewhen a parked cancel already consumed the request — the operation is then never started.cancel()is single-shot, making a duplicate delivery during the race harmless.OpenICFServerAdapter.processOperationRequest: all 16 dispatch branches now only build the processor; registration and execution happen in one place viaprocessor.register() && processor.execute(...).ConnectorEventSubscriptionApiOpImpl,SyncEventSubscriptionApiOpImpl,BatchApiOpImpl: theSubscriptionhand-over happens under a lock with acancelledflag — a cancel racing withsubscribe()/executeBatch()closes the subscription as soon as it exists instead of throwing NPE.Tests
New
LocalRequestRegistrationTestcovers: no registration from the constructor, register-then-cancel, cancel-before-registration is parked and applied, parked cancel does not affect other ids, cancel delivered exactly once, a deterministic replay of the interleaving wherereceiveRequestCanceldelivers the cancel directly between the registration'sputIfAbsentand its pending-cancel check, and a 1000-round two-thread stress test racingregister()againstreceiveRequestCancel(id).Verified: connector-framework-rpc 13/13, connector-framework-server 25/25, connector-server-jetty 30/30.
Compatibility note
LocalRequestsubclasses are no longer registered by the constructor — callregister()after construction and execute the operation only when it returnstrue. Out-of-tree subclasses relying on constructor registration compile unchanged but will not receive responses or cancel messages until updated.RemoteConnectionGroup.receiveRequestnow returnsnull(and leaves the request unregistered) when a pending cancel already consumed the request.