#1162·asynq

Unique(TTL < 1s) silently creates permanent lock due to int(ttl.Seconds()) truncation to 0

Author: x-thoohCreated Jul 9, 2026Updated Jul 9, 2026

Bug: Unique(TTL < 1s) silently creates a permanent lock due to int(ttl.Seconds()) truncation

Related: #338 (closed as "documented", but no runtime validation was ever added)

Description

When Unique(ttl) is called with a TTL < 1 second (e.g. Unique(800 * time.Millisecond)), the TTL is silently truncated to 0 seconds via int(ttl.Seconds())int(0.8)0. This results in a Redis SET key val NX EX 0 command, which means the unique lock never expires — the task can never be enqueued again until the key is manually deleted or the Redis instance restarts.

Root Cause

Three Lua scripts in internal/rdb/rdb.go use EX (seconds):

lua
local ok = redis.call("SET", KEYS[1], ARGV[1], "NX", "EX", ARGV[2])
  • enqueueUniqueCmd — L158
  • addToGroupUniqueCmd — L583
  • scheduleUniqueCmd — L704

Three Go call sites pass int(ttl.Seconds()):

go
int(ttl.Seconds()),
  • EnqueueUnique — L195
  • AddToGroupUnique — L622
  • ScheduleUnique — L740

No validation is performed in the Unique() function or anywhere in the enqueue path. The GoDoc says "must be >= 1s" (added after #338), but there is no runtime check.

Steps to Reproduce

go
client.Enqueue(task, asynq.Unique(800 * time.Millisecond))
// → internal call: int((800ms).Seconds()) → int(0.8) → 0
// → Redis: SET unique:key ID NX EX 0  ← permanent lock

Check the unique key in Redis:

redis> TTL asynq:{default}:unique:tasktype:hash
(integer) -1   ← never expires

Fix

6 lines changed — backward compatible, all existing Unique(>=1s) usage behaves identically.

  1. Change EX to PX in all 3 Lua scripts
  2. Change int(ttl.Seconds()) to int(ttl.Milliseconds()) in all 3 Go call sites
diff
--- a/internal/rdb/rdb.go
+++ b/internal/rdb/rdb.go
@@ -155,7 +155,7 @@
 var enqueueUniqueCmd = redis.NewScript(`
-local ok = redis.call("SET", KEYS[1], ARGV[1], "NX", "EX", ARGV[2])
+local ok = redis.call("SET", KEYS[1], ARGV[1], "NX", "PX", ARGV[2])
 if not ok then
   return -1
 end
@@ -193,7 +193,7 @@
 	argv := []interface{}{
 		msg.ID,
-		int(ttl.Seconds()),
+		int(ttl.Milliseconds()),
 		encoded,

Repeat for addToGroupUniqueCmd (L582-597, L611-623) and scheduleUniqueCmd (L703-717, L733-743).

  1. Update the GoDoc in client.go:
diff
-// TTL duration must be greater than or equal to 1 second.
+// TTL duration must be greater than or equal to 1 millisecond.

Backward Compatibility

Usage Before After Behavior
Unique(5 * time.Second) EX 5 PX 5000 Equivalent (5s TTL)
Unique(800 * time.Millisecond) EX 0 (broken) PX 800 Fixed (800ms TTL)
Unique(1500 * time.Millisecond) EX 1 (wrong — 1s) PX 1500 Fixed (1.5s TTL)

No existing correct usage (TTL >= 1s) is affected.