Francis
GitHub

Waiting and pausing

WaitForEvent, RaiseEvent, Suspend, and Resume

Workflows can be suspended waiting for something to happen, or the entire instance could be paused by an operator.

Waiting for an event#

workflow.WaitForEvent parks the instance until an external event arrives or its timeout elapses. It has no task and no handler.

workflow.Step("request-review", workflow.WithRun(openReviewTicket)),

// Parks the instance until a manager approves, or three days pass
workflow.WaitForEvent("approval",
	workflow.WithEventTimeout(72*time.Hour),
),

workflow.Step("approved", workflow.WithRun(readApproval)),

The event arrives from outside, as a single call.

err := svc.RaiseEvent(ctx, tenantID, "approval", approvalPayload{Approved: true, By: user})

The payload becomes the step’s output, so the next step reads it like any other:

func readApproval(ctx context.Context, t workflow.Task) (any, error) {
	var payload approvalPayload
	err := t.DecodeOutput("approval", &payload)
	if err != nil {
		return nil, errors.Join(actor.ErrJobPermanentFailure, err)
	}

	return payload.Approved, nil
}

Notes:

  • The event name defaults to the step’s name, and WithEventName sets it explicitly. No two steps may listen for the same name. RaiseEvent returns ErrNoSuchEvent for a name nothing in the definition waits for.
  • Events raised before the step is reached, or after it completed, are discarded.
  • If the timeout elapses first, the instance rolls back, with event "approval" timed out as the cause.

A rejection is not a failure. It is passed to the step after the wait as a value, which can decide what to do with it:

workflow.Step("approved", workflow.WithRun(readApproval)),

// Skipped when the manager said no
workflow.Step("verify",
	workflow.WithRun(verifyTenant),
	workflow.WithSkipIf("approved", false),
),

Francis itself does not have an opinion on what causes an external event to be received. Common patterns that your application can implement include:

  • Invoking RaiseEvent in an API handler, for example invoked by a user as approval (“human in the loop”)
  • Exposing a HTTP endpoint that can be invoked as webhook, triggered by an external event

Suspending an instance#

Suspend pauses an instance without losing its place, while Resume continues it.

err := svc.Suspend(ctx, id, "downstream maintenance")
// ... later ...
err = svc.Resume(ctx, id)

Both are durable and a call on an instance already in the requested state (suspended or resumed) does nothing.

While suspended:

  • Nothing new is started: no next step, attempt, or compensation.
  • In-flight tasks continue until done: a task that has already started runs to completion and its result is recorded.
  • Deadlines are paused. Francis restores whatever was left of the instance, step, and event timeouts on resume, so a two-day suspension does not eat a thirty-minute timeout.
  • Events are accepted. A RaiseEvent for the open WaitForEvent step is recorded, and the step completes on resume. However, a RaiseEvent does not automatically cause the workflow to be resumed.
  • Workflows can be canceled: Cancel on a suspended instance resumes it straight into compensating (but does not start the compensation). Suspending during a rollback pauses it at the step it had reached.
  • Children are not affected. A suspended parent’s children keep running, and their results wait for the resume. Suspend a child explicitly if that is not what you want: GetStatus on the parent lists every child’s instance ID.

GetStatus reports the suspension, its reason, and what the instance goes back to:

status, err := svc.GetStatus(ctx, id)
if status.Suspended != nil {
	// status.Suspended.Reason, .At, .ResumeTo
}

List(&workflow.ListOptions{Status: workflow.StatusSuspended}) finds everything currently paused.

Which one to use#

  • Use a wait step when the workflow needs something from outside: an approval, a callback, a third party confirming. It is part of the graph, has its own timeout, and its payload feeds the steps after it.
  • Use suspend when something operational is wrong, e.g. a dependency under maintenance, a bad deployment, or an incident. It is not part of the graph, and any instance can be suspended at any point.
Edit this page on GitHub