Skip to content

Steps ​

A Step is a durable unit of work inside a handler. Runcoil records its outcome, so a retry does not repeat work that already finished.

Wrap a side effect in step.Do ​

Wrap each call that has a side effect, such as a payment or an email, in step.Do. This code is from the onboarding example. It creates an account, then sends a welcome email:

go
func (o *onboarding) createAccount(ctx context.Context, in onboardingInput) (onboardingOutput, error) {
	created, err := step.Do(ctx, "create-account", func(ctx context.Context) (account, error) {
		return o.accounts.Create(ctx, in.AccountID, in.Name, in.Email)
	})
	if err != nil {
		return onboardingOutput{}, err
	}

	messageID, err := step.Do(ctx, "send-welcome-email", func(ctx context.Context) (string, error) {
		return o.emails.SendWelcome(ctx, created)
	})
	if err != nil {
		return onboardingOutput{}, err
	}

	return onboardingOutput{AccountID: created.ID, MessageID: messageID}, nil
}

step.Do takes a context, a Step ID, and a function. It returns the result of the function. The ID names the Step in the record, so use a fixed string. If you use the same ID twice in one Run, Runcoil stores the second as a numbered Step, such as send-welcome-email#2, and logs a warning.

Record, then continue ​

The handler runs straight through. When the function of a Step succeeds, the SDK reports the outcome to the server. The server stores it in PostgreSQL. Only then does step.Do return, and the handler continues.

A Step record holds the ID, the status, the output or the error, and an index that the server assigns.

Replay from the top ​

When an attempt fails, the Run is tried again later. The new attempt runs the handler from the top. A Step that has a record returns its stored result without calling its function. A Step with no record runs.

accounts / create-account · Attempt 1 and Attempt 2

The createAccount handler of the onboarding example calls step.Do twice: create-account and send-welcome-email. In Attempt 1, create-account is recorded and send-welcome-email fails. Attempt 2 starts from the top, takes the stored result of create-account without running it, runs send-welcome-email, and the Run succeeds.

  1. Attempt 1 starts. The handler calls step.Do for create-account, and the Step runs.
  2. The server records the outcome of create-account before the handler continues.
  3. The handler reaches send-welcome-email, and the Step runs.
  4. send-welcome-email fails. Attempt 1 ends and the server schedules a retry.
  5. Attempt 2 runs the handler from the top. create-account has a record, so step.Do returns the stored result and does not run the Step again.
  6. send-welcome-email has no record, so it runs.
  7. send-welcome-email succeeds and is recorded, the handler returns, and the Run succeeds.

The diagram follows the createAccount handler above. In Attempt 1, create-account is recorded and send-welcome-email fails. Attempt 2 returns the stored result of create-account and runs send-welcome-email again.

Code outside Steps runs again on every attempt. Keep it free of side effects, and make its conditions depend only on the input and on Step results.

To watch this on your machine, start the onboarding worker so that the first email delivery for each account fails:

sh
EMAIL_FAIL_ONCE=true go run ./examples/onboarding

Invoke a Run with a new idempotency key. The account Step returns its recorded result on the retry, and the email delivery is tried again.

Check the error of a Step ​

step.Do returns an error only when the Step has failed for good. Its tries are used up, or its error was permanent. The usual if err != nil is all a handler needs.

Before that point, a failing Step ends the attempt. The SDK stops the handler with a panic that it recovers, and the server schedules a retry. See Retries.

The error of a failed Step is a *step.Error, and errors.As finds it. The server stores only its text. After a replay, errors.Is does not match your own error values.

Follow the two handler rules ​

A handler that calls step.Do must follow two rules.

  1. Call step.Do on the handler's own goroutine. The SDK ends an attempt by panicking inside step.Do. Only the handler's goroutine recovers that panic.
  2. Re-panic any value you do not recognize. If your code recovers panics, it must raise again a value it did not create. The SDK stops a handler this way when the server schedules a retry. A recover that swallows the value lets the handler run on.

A Step function must also return promptly when its context is cancelled.

Side effects are not transactional ​

Recording happens after the function succeeds. If the process dies between the two, the function can run again on the next attempt. Steps give durable progress. They do not make external operations transactional.

For that reason, make each side effect safe to repeat. Use a stable business ID, and enforce uniqueness in your storage or in your delivery provider. In the onboarding example, the account service and the email service both deduplicate by ID.

Test a handler ​

Outside a worker, step.Do calls the function directly and records nothing. This lets you test application behavior without a server. It does not simulate retry or replay.

Limits ​

The data of a Step is JSON. The server limits a Step output to 1 MiB and the data of a Run to 16 MiB. An output that is too large, cannot be encoded, or cannot be decoded gives a final Step error.

Runcoil is pre-release. The API may change before 1.0.