Signals & Queries

Communicate with a running workflow — push data in with signals, read state out with queries.

Workflows often need to talk to the outside world while they run: receive an approval, get a result that arrives asynchronously, or expose their current status. Signals and queries are how.

Signals

A signal pushes data into a running workflow, usually to wake it from a waitUntil. Annotate a method with @SignalMethod. Signals typically just update @StateField values.

class ApprovalWorkflow : Workflow() {
  @StateField var isApproved: Boolean? = null

  @WorkflowMethod
  suspend fun process(request: ApprovalRequest): String {
    waitUntil({ isApproved != null }, Duration.ofDays(7))
    return if (isApproved == true) "Approved" else "Rejected"
  }

  @SignalMethod
  fun setApproval(approved: Boolean) { this.isApproved = approved }
}
public class ApprovalWorkflow extends Workflow {
  @StateField Boolean isApproved;

  @WorkflowMethod(returnType = String.class)
  public CompletableFuture<String> process(ApprovalRequest request) {
    waitUntil(() -> isApproved != null, Duration.ofDays(7));
    return CompletableFuture.completedFuture(
        Boolean.TRUE.equals(isApproved) ? "Approved" : "Rejected");
  }

  @SignalMethod
  public void setApproval(boolean approved) { this.isApproved = approved; }
}

Send a signal by getting a handle to the instance and calling the method:

val workflow = workflowFactory<ApprovalWorkflow>(workflowId)
workflow.setApproval(true)
ApprovalWorkflow workflow = workflowFactory.invoke(ApprovalWorkflow.class, workflowId);
workflow.setApproval(true);

Rules for signals:

  • At most one parameter, which must be serializable.
  • They run synchronously on the caller’s thread — so the caller learns immediately if the signal fails and can retry. The workflow then continues asynchronously.
  • Keep them lightweight: validate fast and mutate @StateFields. Don’t invoke actions or perform I/O from a signal.

Durable signals

By default a signal is not persisted — if it throws or the process crashes mid-execution and the caller doesn’t retry, it is lost. For business-critical signals, opt into persistence:

@SignalMethod(persist = true)
fun setApproval(decision: ApprovalDecision) { this.decision = decision }
@SignalMethod(persist = true)
public void setApproval(ApprovalDecision decision) { this.decision = decision; }

Skipper writes a durable record before executing the signal. Records that never reach the EXECUTED state can be inspected and replayed through the admin API:

GET  /skipper/admin/workflows/{id}/signals
POST /skipper/admin/workflows/{id}/signals/{signalId}/replay

Replaying re-applies the signal’s effects, so signal idempotency remains your responsibility.

Queries

A query reads a workflow’s state without modifying it. Annotate a method with @QueryMethod — typically a simple accessor or a value derived from state.

class OrderWorkflow : Workflow() {
  @StateField var status: String = "PENDING"

  @QueryMethod
  fun getStatus(): String = status
}
public class OrderWorkflow extends Workflow {
  @StateField String status = "PENDING";

  @QueryMethod
  public String getStatus() { return status; }
}
val status = workflowFactory<OrderWorkflow>(workflowId).getStatus()
String status = workflowFactory.invoke(OrderWorkflow.class, workflowId).getStatus();

Rules for queries:

  • No parameters.
  • Must not modify state (any mutation is not persisted) and must not perform I/O.
  • Run synchronously on the caller’s thread.