Skip to content
Transactional Checkpoints

Transactional Checkpoints

Use transactional checkpoints when a workflow phase writes application data and the checkpoint that marks that phase complete must commit atomically with that write. SavePhaseCheckpointTx persists the same phase checkpoint as SavePhaseCheckpoint, but through a caller-owned *gorm.DB transaction; the library does not commit or roll back that transaction.

The window it closes

Plain SavePhaseCheckpoint is durable, but it is a separate write from your business side effect:

  1. The handler writes the business effect.
  2. The effect commits.
  3. The process crashes before SavePhaseCheckpoint commits.
  4. The job replays, LoadPhaseCheckpoint misses, and the handler executes the effect again.

That is the normal at-least-once contract. For naturally idempotent effects, this is fine and the plain checkpoint API is simpler.

For non-idempotent phases that live in the same database as the jobs storage, put the effect and checkpoint in one transaction. Then either both commits are visible on replay, or neither is.

Example

type OrderLedgerEntry struct {
	ID      uint `gorm:"primaryKey"`
	OrderID string
	Kind    string
}

queue.Register("orders.capture", func(ctx context.Context, orderID string) error {
	if _, ok := jobs.LoadPhaseCheckpoint[string](ctx, "ledger-entry"); ok {
		return nil
	}

	tx := db.WithContext(ctx).Begin()
	if tx.Error != nil {
		return tx.Error
	}
	defer tx.Rollback()

	entry := OrderLedgerEntry{OrderID: orderID, Kind: "captured"}
	if err := tx.Create(&entry).Error; err != nil {
		return err
	}

	if err := jobs.SavePhaseCheckpointTx(ctx, tx, "ledger-entry", "done"); err != nil {
		return err
	}

	return tx.Commit().Error
})

On the first run, the ledger row and the phase checkpoint are written through the same transaction. If the process exits before Commit, neither row is visible and the retry runs the phase again. If Commit succeeds and the handler crashes later, replay loads the checkpoint with LoadPhaseCheckpoint, skips the phase, and does not insert a second ledger entry.

GormStorage also verifies, inside that same transaction, that the current worker still owns the running job. If its lease moved to another worker before the commit, SavePhaseCheckpointTx returns jobs.ErrJobNotOwned; returning that error rolls the business effect and checkpoint back together instead of letting a stale execution commit over the new owner’s workflow state.

Constraints

  • This makes only this phase’s effect exactly-once. Other phases, Call() steps, and the handler as a whole remain at-least-once and must stay idempotent.
  • The transaction must be for the same GORM database used by the job storage. If it belongs to a different database, the checkpoint is committed somewhere replay never reads — the phase will silently re-run on every retry, and the library cannot detect this.
  • This is implemented by GormStorage. Custom storage backends that do not implement the optional capability return jobs.ErrStorageNoTxCheckpoint.
  • Custom transactional-checkpoint backends can implement the additive jobs.OwnedTxCheckpointer capability to get the same stale-worker fence. A v4 backend implementing only TxCheckpointer retains the legacy unfenced behavior for compatibility.
  • The checkpoint is invisible to replay until the transaction commits. That is the guarantee: rolled-back effects do not leave checkpoints behind.
  • SavePhaseCheckpointTx must be called from inside a job handler. Outside a handler it returns an error instead of silently skipping the write, because a skipped transactional checkpoint would break atomicity.
  • A phase name may be saved only once per run; a second phase reusing it gets jobctx.ErrDuplicatePhaseName. The claim is taken when the row is written and a later rollback cannot take it back, so redo a rolled-back phase by returning the error and letting the job replay, not by retrying inside the same run.

When not to bother

If the effect is already idempotent, such as an upsert guarded by a unique key, plain SavePhaseCheckpoint is usually enough and keeps the handler smaller.