feat: cooperative in-flight cancellation checkpoint (opt-in) - #7
Conversation
58e72da to
4702dbd
Compare
5e7abee to
38c9a81
Compare
38c9a81 to
0009370
Compare
| // so a status write here would only lose the optimistic lock and reschedule a dead | ||
| // workflow. Keep the checkpoints, skip the write, and hand the caller the recorded | ||
| // cancellation. Not keyed on the result type: the execution may end with any outcome. | ||
| val stored: Option<WorkflowInstance>? = workflowStore.getWorkflow(workflowInstance.workflowId) |
There was a problem hiding this comment.
I think this is a nice to have but it adds one extra read to all the executions and is only useful in the very rare edge cases where the workflow is cancelled mid flight. If we don't do this, the workflow write attempt below will fail with an optimistic locking error, which will trigger a retry, which will then identify that the workflow is already cancelled and stop the execution
There was a problem hiding this comment.
Agreed on the cost. Gated it behind INFLIGHT_CANCELLATION_CHECKPOINTS in dff74b9: apps that have not opted in keep the lock-failure-then-retry path with no extra read; opted-in apps keep the clean settle, which also preserves this execution's not-yet-persisted checkpoints (the rolled-back write drops them) — that matters for compensate-on-cancel later.
Today a cancel is only observed at scheduling boundaries: a workflow cancelled while one of its actions is running keeps starting further actions. Add a cooperative check that re-reads the workflow's current status before starting each not-yet-checkpointed action and, if it is CANCELLED, stops with a non-retryable WorkflowCancelledException instead of running the next action. WorkflowExecutor settles that as CANCELLED rather than ERROR: a cancellation is not a failure, so it does not trigger the compensation chain. Opt-in and off by default via FeatureGate.INFLIGHT_CANCELLATION_CHECKPOINTS; apps that do not opt in keep today's behavior byte-for-byte. Backward compatible: the new nullable featureGate constructor parameter is paired with a non-@Inject 8-arg secondary constructor so existing callers and subclasses compile unchanged (a second @Inject constructor would break Guice). Tests: ActionExecutor unit tests for gate-enabled / no-gate / gate-off; WorkflowExecutor mapping to CANCELLED; an end-to-end SQLite test asserting a mid-flight cancel aborts the run, settles durably CANCELLED, and runs no compensation.
The comment pointed at a build target that does not exist in this repository. Describe the exclusion on its own terms: the test needs a multi-backend setup this Gradle build does not provide.
- Settle an in-flight-cancelled execution without a stale-version status write: keep the completed actions' checkpoints, skip the status write and timers, and complete the caller's future with WorkflowCancelledException instead of surfacing an optimistic-lock TRANSIENT_ERROR and a doomed re-execution. - FeatureGate.Keys carries enabledByDefault and InMemoryFeatureGate honours it (with optional overrides), so INFLIGHT_CANCELLATION_CHECKPOINTS is genuinely off under the default SkipperConfig; existing keys unchanged. - Remove the secondary 8-arg ActionExecutor constructor; test call sites pass the gate explicitly. - Trim the WorkflowCancelledException KDoc and drop implementation-tracking labels from comments. - The integ test asserts the strict end-to-end outcome: precise exception, next action never starts, no compensation.
Everything under com.airbnb.skipper.internal is engine machinery and may change in any release; the supported surface is the top-level package.
…NCELLED The first cut keyed on a CANCELLED execution result, which misses the case CI hit: cancelWorkflow removes the running task, the next lease-renewal attempt fails on it and interrupts the worker, and the execution ends TRANSIENT_ERROR. That completed the caller's future with a TransientError, and the proxy's polling then surfaced the stored CancelledWorkflow -- while the retry replayed an already-cancelled workflow. The settle check now runs for every execution outcome (one store read per execution end): if the workflow is CANCELLED, keep the completed actions' checkpoints, skip the status write and timers, and complete the caller's future with the cancellation cancelWorkflow recorded (CancelledWorkflow), so a synchronous caller sees the same exception any other observer does. WorkflowCancelledException stays the signal workflow code sees. Adds a handler unit test for the path and asserts CancelledWorkflow in the integ test.
The integ suite stubbed the FeatureGate mock after schedulerManager.start(), while the fetch loop was already invoking it. Mockito attaches a stub to the mock's last invocation from any thread, so under load the blanket isEnabled(any()) stub could land on the wrong invocation and leave gates false. That is what made the in-flight cancellation test flaky: the check saw the gate off and let the next action run. All gate stubbing now happens before start(); suites override specific keys in customizeDeps. Drops two in-test stubs that were no-ops under the blanket.
…n flag Apps that have not opted in keep today's behavior exactly: the stale write loses the optimistic lock and the retry stops at the start-of-execution gate. Only opted-in apps pay the one extra store read per execution end. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
dff74b9 to
fee2472
Compare
Opt-in cooperative in-flight cancellation. With
FeatureGate.Keys.INFLIGHT_CANCELLATION_CHECKPOINTSenabled,ActionExecutorre-reads the workflow status before each unchecked action and stops withWorkflowCancelledExceptiononce it is CANCELLED;WorkflowExecutorsettles that as CANCELLED, not ERROR, so no compensation runs. Cooperative: the action already running completes.Also:
FeatureGate.Keys.enabledByDefault, honoured byInMemoryFeatureGatewith overrides; the new key and theDISABLE_SIGNAL_PERSISTENCEkill switch are declared off, everything else stays on, so the defaultSkipperConfigbehaves as before.cancelWorkflowno longer writes its stale version (which lost the optimistic lock, became TRANSIENT_ERROR and replayed a cancelled workflow): checkpoints are kept, nothing is written or rescheduled, and the caller gets the recordedCancelledWorkflow. Gated by the same flag; opted-in apps pay one store read per execution end, others keep today's lock-failure-then-retry path.ActionExecutorloses its 8-arg constructor; subclasses pass the gate. README now declarescom.airbnb.skipper.internal.*outside the supported API.Tests:
ActionExecutorTest,WorkflowExecutorTest,WorkflowExecutionTaskHandlerTest, and the SQLite integ test.