Skip to content

Unique Jobs

Sometimes you want to guarantee that the same job is not enqueued twice while a first copy is still pending or running. Classic examples:

  • "Send the welcome email for user 42" -- you don't want two of those queued if the user clicks signup twice.
  • "Reconcile account 1234" -- only one in flight at a time per account.

TaskRunner implements this with a distributed lock held from Dispatch until the worker finishes processing. The lock is keyed by task name (and optionally a payload-derived key), so uniqueness can be global to a task or scoped per-key.

How it works

sequenceDiagram
    participant P as Producer
    participant TR as TaskRunner
    participant LK as redsync lock<br/>(Redis SET NX PX)
    participant Q as Stream

    P->>TR: Dispatch("sendEmail", "order-123")
    TR->>TR: task.Unique == true
    TR->>LK: acquire taskrunner:unique:sendEmail:order-123<br/>TTL = UniqueFor seconds
    alt lock acquired
        LK-->>TR: ok
        TR->>Q: XADD message (with UniqueLockValue)
        Q-->>P: dispatched
    else lock held by another
        LK-->>TR: ErrLockNotObtained
        TR-->>P: ErrTaskAlreadyDispatched
    end
    Note over LK: lock auto-expires after UniqueFor seconds
    participant W as Worker
    W->>Q: XREADGROUP -> message
    W->>W: run task.Action
    W->>LK: release with stored UniqueLockValue
    LK-->>W: released (or expired)
    W->>Q: XACK

The lock is taken at dispatch time, before the XADD. The lock value (a random token) is embedded in the TaskMessage so the worker that eventually processes the message can release it. Release uses a value-guarded delete (via redsync), so only the worker holding the correct token can unlock.

Configuring a unique task

A task becomes unique by setting three fields:

tr.RegisterTask(&runner.Task{
    Name:      "sendEmail",
    MaxRetry:  3,
    Unique:    true,        // (1) enable uniqueness
    UniqueFor: 60,          // (2) lock TTL in seconds (REQUIRED when Unique)
    UniqueKey: func(payload any) string {   // (3) scope per-key (optional)
        return payload.(string)             // e.g. the email/order ID
    },
    Action: func(ctx context.Context, payload any) error {
        return sendEmail(payload.(string))
    },
})
Field Required Meaning
Unique yes Enables the lock-acquire path in Dispatch.
UniqueFor yes (when Unique) Lock TTL in seconds. Dispatch fails with ErrUniqueForIsRequired if this is 0.
UniqueKey no A func(payload any) string deriving the scope key from the payload.

The lock key

The Redis key is taskrunner:unique:<taskName>:<uniqueKey>. If UniqueKey is nil, the key collapses to just taskrunner:unique:<taskName> -- meaning only one message of that task name can be in flight at a time, regardless of payload. A warning is logged in this case.

// runner/task.go
func (t *Task) lockKey(payload any) string {
    var lockKey string
    if t.UniqueKey != nil {
        if lockKey = t.UniqueKey(payload); len(lockKey) == 0 {
            lockKey = t.Name
        }
    }
    if len(lockKey) != 0 {
        lockKey = t.Name + ":" + lockKey
    } else {
        lockKey = t.Name
        log.Warnf("The unique key for Task %s is `nil`...", t.Name)
    }
    return lockKey
}

Dispatching unique jobs

// First dispatch succeeds -- acquires the lock and XADDs.
err := tr.Dispatch(context.Background(), "sendEmail", "order-123")
// err == nil

// Second dispatch within 60s: lock is held -> ErrTaskAlreadyDispatched.
err = tr.Dispatch(context.Background(), "sendEmail", "order-123")
// err == runner.ErrTaskAlreadyDispatched

// Different key: succeeds independently.
err = tr.Dispatch(context.Background(), "sendEmail", "order-456")
// err == nil

Lifecycle of the lock

The lock is released by the worker in afterProcess -- a deferred call that runs when the worker finishes (success or failure):

func (t *TaskRunner) afterProcess(message TaskMessage) {
    if message.Unique {
        if err := t.releaseLock(message.UniqueKey, message.UniqueLockValue); err != nil {
            t.captureError(err)
        }
    }
}

If the worker crashes before releasing, the lock simply expires after UniqueFor seconds, at which point a new dispatch is allowed again. This is the intended behaviour -- uniqueness is a time-windowed guarantee, not a permanent one.

UniqueFor vs your task runtime

UniqueFor must be longer than the worst-case task runtime (including retries), otherwise the lock can expire while the first job is still processing, allowing a duplicate to slip in.

timeline
    title UniqueFor too short -> duplicate slips in
    t0 : Dispatch A : lock acquired (TTL = UniqueFor)
    t1 : Worker starts A (A is slow)
    t2 : UniqueFor expires : lock released, but A still running
    t3 : Dispatch B : lock re-acquired : B enqueued
    t4 : A finishes : B also runs : DUPLICATE WORK

Rule of thumb: set UniqueFor to comfortably exceed your task's p99 runtime multiplied by its MaxRetry. If a task can take up to 45s with up to 3 retries, set UniqueFor to at least 2-3 minutes.

Error reference

Error When
runner.ErrUniqueForIsRequired Dispatch of a unique task with UniqueFor == 0.
runner.ErrTaskAlreadyDispatched Dispatch could not acquire the lock (held by another in-flight job).
runner.ErrTaskUnlockFailed Worker's release call returned false (lock already expired or value mismatch). Treated as best-effort; the expiry is the safety net.

A note on redsync semantics

The underlying locker (internal/locker, wrapping go-redsync) implements the Distributed Mutex (Redlock) algorithm over a single Redis. The acquire uses SET NX PX with retries; the release uses a Lua value-compare-and-delete. Because TaskRunner targets a single Redis (not a multi-node Redlock quorum), the guarantees are those of a single Redis instance: correct under the Redis single-threaded model, with the usual caveats about failover during a partition.