#1167·asynq

[BUG] ResultWriter.Write creates an orphan task hash when the task no longer exists

Author: IceLockeCreated Jul 15, 2026Updated Jul 16, 2026
Labelsbug

Describe the bug ResultWriter.Write succeeds even when the task it is associated with no longer exists in Redis.

The current rdb.WriteResult implementation performs an unconditional HSET on the task key. Redis creates a new hash that state and msg is missing, and becomes an orphaned task which can't be rescheduled or inspected by Inspector.GetTaskInfo because the blank state is invalid and returned as an error. Besides, Inspector.DeleteTask can't delete this task since it lacks state...

If the unique option is enabled for deduplication, the client can't even enqueue a new task with the same key.

I think a check of task existence should be performed before writing result back to Redis.

Environment (please complete the following information):

  • OS: Linux;
  • Go: 1.24.12 and 1.25.5;
  • asynq: v0.26.0 and current master at d135f1439bee74e989b7f9b41ecd542cc87f024a;
  • Redis: reproduced with Redis 8.6.2 in standalone mode;
  • Redis Cluster: reproduced/verified against a three-master Redis 7 cluster. The behavior is not Redis-version-specific: creating a hash on a missing key is the documented behavior of HSET.

To Reproduce Create result_writer_test.go under the repo root:

go
package asynq

import (
    "context"
    "errors"
    "testing"
    "time"

    "github.com/google/uuid"
    "github.com/hibiken/asynq/internal/base"
    "github.com/hibiken/asynq/internal/rdb"
)

func TestResultWriterWriteRejectsMissingTask(t *testing.T) {
    redisClient := setup(t)
    defer redisClient.Close()

    const queue = "default"
    taskID := uuid.NewString()
    if err := redisClient.SAdd(context.Background(), base.AllQueues, queue).Err(); err != nil {
        t.Fatalf("failed to register queue: %v", err)
    }
    writer := &ResultWriter{
        id:     taskID,
        qname:  queue,
        broker: rdb.NewRDB(redisClient),
        ctx:    context.Background(),
    }

    n, err := writer.Write([]byte("result"))

    if !errors.Is(err, ErrTaskNotFound) {
        t.Errorf("ResultWriter.Write error = %v, want ErrTaskNotFound", err)
    }
    if n != 0 {
        t.Errorf("ResultWriter.Write returned %d, want 0", n)
    }
    if got := redisClient.Exists(context.Background(), base.TaskKey(queue, taskID)).Val(); got != 0 {
        t.Errorf("missing task key exists after failed result write: got %d, want 0", got)
    }

    inspector := NewInspectorFromRedisClient(redisClient)
    if _, err := inspector.GetTaskInfo(queue, taskID); !errors.Is(err, ErrTaskNotFound) {
        t.Errorf("Inspector.GetTaskInfo error = %v, want ErrTaskNotFound", err)
    }
    if err := inspector.DeleteTask(queue, taskID); !errors.Is(err, ErrTaskNotFound) {
        t.Errorf("Inspector.DeleteTask error = %v, want ErrTaskNotFound", err)
    }

    client := NewClientFromRedisClient(redisClient)
    if _, err := client.Enqueue(
        NewTask("task", nil),
        Queue(queue),
        TaskID(taskID),
        Unique(time.Minute),
    ); err != nil {
        t.Errorf("Client.Enqueue with TaskID and Unique failed: %v", err)
    }
}

Run

bash
go test ./ -run '^TestResultWriterWriteRejectsMissingTask$' -count=1 -v

And we can see

=== RUN   TestResultWriterWriteRejectsMissingTask
    result_writer_test.go:37: ResultWriter.Write error = <nil>, want ErrTaskNotFound
    result_writer_test.go:40: ResultWriter.Write returned 6, want 0
    result_writer_test.go:43: missing task key exists after failed result write: got 1, want 0
    result_writer_test.go:48: Inspector.GetTaskInfo error = asynq: FAILED_PRECONDITION: FAILED_PRECONDITION: "" is not supported task state, want ErrTaskNotFound
    result_writer_test.go:51: Inspector.DeleteTask error = asynq: UNKNOWN: ERR user_script:21: attempt to concatenate local 'state' (a boolean value), want ErrTaskNotFound
    result_writer_test.go:61: Client.Enqueue with TaskID and Unique failed: task ID conflicts with another task
--- FAIL: TestResultWriterWriteRejectsMissingTask (0.00s)
FAIL

Expected behavior When the task key exists:

  • ResultWriter.Write writes the result field;
  • it returns len(data), nil;
  • existing behavior remains unchanged.

When the task key does not exist:

  • the existence check and result write are atomic;
  • ResultWriter.Write returns an error for which errors.Is(err, ErrTaskNotFound) is true;
  • it returns a zero byte count;
  • no Redis key is created.

Additional context This can happen when a handler continues running after its task record has been lost, for example during a Redis restart, restore, failover, eviction, or external mutation.

Redis durability and high availability reduce the probability of this state but cannot eliminate it completely. In particular, the Redis Sentinel documentation notes that acknowledged writes are not guaranteed to be retained during failures because Redis replication is asynchronous: https://redis.io/docs/latest/operate/oss_and_stack/management/sentinel/