Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions src/main/java/com/airbnb/skipper/FeatureGate.kt
Original file line number Diff line number Diff line change
Expand Up @@ -80,5 +80,16 @@ interface FeatureGate {
* only at the next scheduling boundary. Off by default.
*/
INFLIGHT_CANCELLATION_CHECKPOINTS("inflight_cancellation_checkpoints", enabledByDefault = false),

/**
* Interrupt the thread running a synchronous action when its workflow is cancelled. A cancel
* taken in another process reaches the action within about one
* [SkipperConfig.inflightCancellationPollInterval]. An action that ignores the interrupt still
* completes. The interrupt can close an NIO channel the action is blocked on, and a library not
* written for interrupts can put a broken connection back in a pool other workflows share, so
* check what your actions block on before opting in. Requires
* [INFLIGHT_CANCELLATION_CHECKPOINTS], which stops actions that start after the cancel.
*/
INFLIGHT_CANCELLATION_INTERRUPT("inflight_cancellation_interrupt", enabledByDefault = false),
}
}
6 changes: 6 additions & 0 deletions src/main/java/com/airbnb/skipper/SkipperConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,12 @@ class SkipperConfig(
* E.g. if the lease ends at 10:00 and the grace period is 5 seconds, the lease manager will attempt to renew the lease at 9:59:55.
*/
var leaseRenewalGracePeriod: Duration = Duration.ofSeconds(5),
/**
* How often this process checks whether another process cancelled a workflow it is running an action
* for, so it can interrupt that action. Used only when
* [FeatureGate.Keys.INFLIGHT_CANCELLATION_INTERRUPT] is on.
*/
var inflightCancellationPollInterval: Duration = Duration.ofSeconds(5),
/**
* Legacy dev-mode isolation flag. The open-source core does not act on this field directly; it
* is retained for backward compatibility and is consulted only by integration layers that
Expand Down
23 changes: 20 additions & 3 deletions src/main/java/com/airbnb/skipper/factory/SkipperRuntime.kt
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import com.airbnb.skipper.admin.AdminResource
import com.airbnb.skipper.internal.ActionErrorMapper
import com.airbnb.skipper.internal.ActionExecutor
import com.airbnb.skipper.internal.CompensationExecutor
import com.airbnb.skipper.internal.InFlightActions
import com.airbnb.skipper.internal.InFlightCancelWatcher
import com.airbnb.skipper.internal.NoOpEventPublisher
import com.airbnb.skipper.internal.SkipperEngine
import com.airbnb.skipper.internal.SkipperSchedulerManager
Expand Down Expand Up @@ -175,6 +177,18 @@ class SkipperRuntime
config.requestContextMiddleware.create(config)
}

val inFlightActions: Provider<InFlightActions> = singleton { InFlightActions() }

val inFlightCancelWatcher: Provider<InFlightCancelWatcher> = singleton {
InFlightCancelWatcher(
inFlightActions.get(),
workflowStore.get(),
featureGate.get(),
metrics.get(),
config.inflightCancellationPollInterval,
)
}

val actionExecutor: Provider<ActionExecutor> = singleton {
ActionExecutor(
workflowStore.get(),
Expand All @@ -185,7 +199,8 @@ class SkipperRuntime
executionMetricsCollector.get(),
GlobalTracer.get(),
config.contextPropagator,
featureGate.get()
featureGate.get(),
inFlightActions.get(),
)
}

Expand Down Expand Up @@ -261,7 +276,8 @@ class SkipperRuntime
featureGate.get(),
NoOpEventPublisher(),
metrics.get(),
injector
injector,
inFlightActions.get(),
)
}

Expand Down Expand Up @@ -376,7 +392,8 @@ class SkipperRuntime
knobs.get(),
clusterMembershipManager.get(),
BucketPartitioner(),
config.gracefulShutdownTimeout
config.gracefulShutdownTimeout,
inFlightCancelWatcher.get(),
)
}
}
57 changes: 53 additions & 4 deletions src/main/java/com/airbnb/skipper/internal/ActionExecutor.kt
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ open class ActionExecutor
private val tracer: Tracer,
private val contextPropagator: ContextPropagator,
private val featureGate: FeatureGate?,
private val inFlightActions: InFlightActions,
) {
/**
* Executes an action method.
Expand Down Expand Up @@ -179,6 +180,7 @@ open class ActionExecutor
"actionExecutionTime",
)
val span = executionMetricsCollector.createActionSpan(request)
var interruptedByCancel = false
try {
timer.time().use { _ ->
tracer.activateSpan(span).use { _ ->
Expand Down Expand Up @@ -225,13 +227,42 @@ open class ActionExecutor
*userArgs,
)
} else {
result =
if (request.arg != null) {
request.proxyMethod.invoke(request.actionObject, *request.arg)
// Lets a cancel interrupt this thread while the action runs.
val registration =
if (featureGate?.isEnabled(FeatureGate.Keys.INFLIGHT_CANCELLATION_INTERRUPT) == true) {
inFlightActions.enter(request.executionContext.workflow.workflowId)
} else {
request.proxyMethod.invoke(request.actionObject)
null
}
result =
try {
if (request.arg != null) {
request.proxyMethod.invoke(request.actionObject, *request.arg)
} else {
request.proxyMethod.invoke(request.actionObject)
}
} finally {
interruptedByCancel = registration != null && inFlightActions.exit(registration)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if we need to evaluate wether the action is async and exit the inFlightAction only after the async execution completed rather than synchronously here, otherwise we'll exit immeidately and the action might still be executing async?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed it’s a gap. Keeping the thread registered until the future completes would interrupt the wrong thread, though — by then it’s back in the workflow method. The right handle for an async action is the future: register it and cancel(true) it. Follow-up PR.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sounds good. should we leave a TODO here for the record?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added the TODO at the future-returning branch in 1d688a1.

}
if (interruptedByCancel) {
// The cancel's interrupt arrived, but the action completed anyway.
metrics
.counter(
ImmutableMap.of(ACTION_CLASS_TAG, request.baseActionClass.simpleName),
METRIC_COMPONENT_NAME,
"inflightCancellationIgnored",
)
.inc()
log.warn(
"action {}#{} of cancelled workflow {} completed despite the interrupt",
request.baseActionClass.simpleName,
request.actionMethodName,
request.executionContext.workflow.workflowId,
)
}
}
// TODO: a cancel cannot interrupt the work behind a returned future, because the
// action leaves InFlightActions when its method returns. Cancel the future instead.
if (result is CompletableFuture<*>) {
shouldIncrementIteration = false
shouldAddCheckpoint = false
Expand Down Expand Up @@ -372,6 +403,24 @@ open class ActionExecutor
}
}
} catch (e: Throwable) {
if (interruptedByCancel) {
// A cancel interrupted this action, so end as cancelled rather than as a
// retryable error, with no checkpoint.
shouldAddCheckpoint = false
shouldIncrementIteration = false
metrics
.counter(
ImmutableMap.of(ACTION_CLASS_TAG, request.baseActionClass.simpleName),
METRIC_COMPONENT_NAME,
"inflightCancellationInterrupted",
)
.inc()
throw WorkflowCancelledException(
"workflow " + request.executionContext.workflow.workflowId +
" was CANCELLED; interrupted action " + request.baseActionClass.simpleName +
"#" + request.actionMethodName,
)
}
// All errors thrown by the action method are considered retryable unless explicitly
// marked
// as NonRetryable errors.
Expand Down
63 changes: 63 additions & 0 deletions src/main/java/com/airbnb/skipper/internal/InFlightActions.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package com.airbnb.skipper.internal

import java.util.concurrent.ConcurrentHashMap
import javax.inject.Singleton

/**
* The threads running actions, per workflow id, so cancelling a workflow can interrupt them in this
* process. Best effort: an action that never blocks, or swallows the interrupt, runs to completion.
* A workflow can run several actions at once (a signal handler runs beside the workflow method), so
* each thread gets its own [Registration]. [exit] and [interrupt] update a workflow's entry one at a
* time, so an interrupt never lands on a thread that has already left its action.
*/
@Singleton
class InFlightActions {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

curious, did you consider doing the cancellation at the workflow level rather than the action level? is this to allow graceful handling of the action cancellation at the workflow layer? otherwise at the workflow level we can reuse the existing workflows in flight registry we currently use for lease renewal

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I started from the lease-renewal registry and it can’t do this job: the future it holds is the skipper-main thread’s submit handle, blocked in handleTask(...).get(), while the action runs on the task-handler pool via supplyAsync. Cancelling it interrupts the waiting thread (the engine logs “task execution interrupted”) and the action keeps running. It’s also keyed by task id and only populated when AUTOMATIC_LEASE_RENEWAL is on.

A workflow-level thread registration would reach the action thread, since sync actions run on it, but the interrupt could then land between actions — in the checkpoint write, the status re-read, or the persist — turning store operations into failures. Bracketing only the action invocation closes the delivery window before any store call and lets the executor attribute the failure to the cancel rather than to the action. So it’s about keeping the interrupt out of engine and store code, not graceful handling at the workflow layer.

class Registration internal constructor(internal val workflowId: String) {
internal val thread: Thread = Thread.currentThread()
@Volatile internal var interruptRequested = false
}

private val entries = ConcurrentHashMap<String, MutableList<Registration>>()

/** Records the calling thread as running an action for [workflowId]. */
fun enter(workflowId: String): Registration {
val registration = Registration(workflowId)
entries.compute(workflowId) { _, list -> (list ?: mutableListOf()).also { it.add(registration) } }
return registration
}

/**
* Forgets the registration and returns whether a cancel interrupted it. If so, it clears the
* thread's interrupt flag so the interrupt does not leak into what the thread runs next.
*/
fun exit(registration: Registration): Boolean {
entries.computeIfPresent(registration.workflowId) { _, list ->
list.remove(registration)
if (list.isEmpty()) null else list
}
if (registration.interruptRequested) Thread.interrupted() // reads and clears the flag
return registration.interruptRequested
}

/** The workflows with an action running here right now. */
fun workflowIds(): Set<String> = entries.keys.toSet()

/**
* Interrupts each thread running an action for [workflowId] here, skipping any already interrupted,
* so an action is interrupted at most once. Returns whether this call interrupted any.
*/
fun interrupt(workflowId: String): Boolean {
var delivered = false
entries.computeIfPresent(workflowId) { _, list ->
for (registration in list) {
if (!registration.interruptRequested) {
registration.interruptRequested = true
registration.thread.interrupt()
delivered = true
}
}
list
}
return delivered
}
}
55 changes: 55 additions & 0 deletions src/main/java/com/airbnb/skipper/internal/InFlightCancelWatcher.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package com.airbnb.skipper.internal

import com.airbnb.skipper.FeatureGate
import com.airbnb.skipper.Metrics
import com.airbnb.skipper.WorkflowInstance
import com.airbnb.skipper.internal.storage.WorkflowStore
import java.time.Duration
import org.slf4j.LoggerFactory

/**
* Interrupts the actions running here whose workflow another process cancelled. Every [pollInterval]
* the scheduler manager calls [pollOnce], which reads the stored workflow of each in-flight action and
* interrupts on CANCELLED. It reads nothing unless both in-flight cancellation flags are on.
*/
class InFlightCancelWatcher(
private val inFlightActions: InFlightActions,
private val workflowStore: WorkflowStore,
private val featureGate: FeatureGate,
private val metrics: Metrics,
val pollInterval: Duration,
) {
init {
require(pollInterval.toMillis() > 0) { "inflightCancellationPollInterval must be at least 1 ms" }
}

fun pollOnce() {
if (!featureGate.isEnabled(FeatureGate.Keys.INFLIGHT_CANCELLATION_INTERRUPT) ||
!featureGate.isEnabled(FeatureGate.Keys.INFLIGHT_CANCELLATION_CHECKPOINTS)
) {
return
}
for (workflowId in inFlightActions.workflowIds()) {
try {
val stored = workflowStore.getWorkflow(workflowId)
if (stored.isDefined && stored.get().status == WorkflowInstance.Status.CANCELLED &&
inFlightActions.interrupt(workflowId)
) {
metrics.counter(METRICS_COMPONENT, "watcherInterrupts").inc()
log.info("workflowId={} was cancelled elsewhere; interrupted its running action", workflowId)
}
} catch (
@Suppress("TooGenericExceptionCaught") e: Exception
) {
// One unreadable workflow must not stop the others from being checked.
log.warn("could not check whether workflowId={} was cancelled", workflowId, e)
metrics.counter(METRICS_COMPONENT, "watcherErrors").inc()
}
}
}

private companion object {
private val log = LoggerFactory.getLogger(InFlightCancelWatcher::class.java)
private const val METRICS_COMPONENT = "inflightCancellation"
}
}
12 changes: 12 additions & 0 deletions src/main/java/com/airbnb/skipper/internal/SkipperEngine.kt
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ open class SkipperEngine
private val eventPublisher: EventPublisher,
private val metrics: Metrics,
private val callbackHandlerInjector: SkipperInjector,
private val inFlightActions: InFlightActions,
) {
/**
* Creates and starts the execution of a new workflow instance.
Expand Down Expand Up @@ -654,6 +655,17 @@ open class SkipperEngine
if (task.isDefined) {
persistentScheduler.remove(task.get())
}
// Needs the checkpoints gate too: it ends the interrupted execution as CANCELLED without a
// status write, and stops any action that starts after this interrupt.
if (featureGate.isEnabled(FeatureGate.Keys.INFLIGHT_CANCELLATION_INTERRUPT) &&
featureGate.isEnabled(FeatureGate.Keys.INFLIGHT_CANCELLATION_CHECKPOINTS)
) {
val interrupted = inFlightActions.interrupt(workflowId)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this will not work as expected. this will only work in the case where the process that serves this cancellation request is the same process that happens to host the workflow executions. In practice where we'd have multiple process, this will rarely happen, so we'd need either a broadcast mechanism or a way to route the cancellation request to the process that is actively executing the workflow

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — as built this only fires when the cancel lands on the pod running the workflow, so in a multi-pod service it rarely will. The cheapest cross-process delivery is to have the executing process poll the persisted CANCELLED status for workflows with an action currently in flight and interrupt locally when it sees it: no broadcast or pod routing, the store row stays the only cross-pod truth, and latency is the poll interval. The cost is read amplification — one status read per in-flight action per interval, only when the flag is on. Does that trade sound right to you, or would you rather route or broadcast?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the polling approach is clean 👍

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implemented in 1d688a1. The scheduler manager runs one more loop: every inflightCancellationPollInterval (default 5 s) it reads the stored workflow of each action in flight here and interrupts on CANCELLED, so a cancel taken by any pod reaches the action within one interval. Cost: one workflow-row read per in-flight workflow per interval while the flags are on; an action is interrupted at most once. Covered by an integ test that cancels through the store only and asserts the blocked action is cut short.

metrics
.counter(mapOf("interrupted" to interrupted.toString()), METRICS_COMPONENT, "cancelInterrupts")
.inc()
log.info("cancelled workflowId={}: running action interrupted={}", workflowId, interrupted)
}
try {
if (instance.callbackHandler != null) {
val handler = callbackHandlerInjector.getInstance(instance.callbackHandler)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ class SkipperSchedulerManager
private val clusterMembershipManager: ClusterMembershipManager,
private val partitioner: BucketPartitioner,
@param:Named(GRACEFUL_SHUTDOWN_TIMEOUT) private val gracefulShutdownTimeout: Duration,
private val cancelWatcher: InFlightCancelWatcher,
) {
private val stop = AtomicBoolean(false)

Expand All @@ -91,6 +92,7 @@ class SkipperSchedulerManager
}
loopFutures.add(executor.submit { startFetchingTasks() })
loopFutures.add(executor.submit { renewExpiringLeases() })
loopFutures.add(executor.submit { watchForCancels() })
clusterMembershipManager.start()
registerQueueMonitoring()
started.set(true)
Expand Down Expand Up @@ -366,6 +368,24 @@ class SkipperSchedulerManager
}
}

@Suppress("TooGenericExceptionCaught")
private fun watchForCancels() {
log.info("starting skipper scheduler manager.watchForCancels")
while (!stop.get() && !Thread.currentThread().isInterrupted) {
try {
Thread.sleep(cancelWatcher.pollInterval.toMillis())
cancelWatcher.pollOnce()
} catch (e: Exception) {
if (stop.get()) {
log.info("stopping skipper scheduler manager.watchForCancels")
break
}
log.error("unexpected error while watching for cancels", e)
metrics.counter(METRICS_COMPONENT, "watchForCancelsErrors").inc()
}
}
}

@Throws(Exception::class)
fun stop() {
log.info("stopping skipper scheduler manager")
Expand Down
Loading
Loading