From a1a0e268ba3f7da087efafe29accd0f2ab4a25ca Mon Sep 17 00:00:00 2001 From: Yingzi Zhang <164733623+LindseyZ1205@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:41:36 -0400 Subject: [PATCH] fix(cmd): keep SetArgs ExpireAt milliseconds (#4053) SetArgs always sent ExpireAt as EXAT with Unix seconds, so any millisecond part was silently dropped. Send PXAT with Unix milliseconds when ExpireAt has millisecond precision, and keep EXAT otherwise. --- string_commands.go | 9 ++++++-- string_commands_unit_test.go | 41 ++++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/string_commands.go b/string_commands.go index 4600d91c..cbe8e0ac 100644 --- a/string_commands.go +++ b/string_commands.go @@ -516,7 +516,8 @@ type SetArgs struct { MatchDigest uint64 // Zero `TTL` or `Expiration` means that the key has no expiration time. - TTL time.Duration + TTL time.Duration + // ExpireAt is sent as EXAT, or as PXAT when it has millisecond precision. ExpireAt time.Time // When Get is true, the command returns the old value stored at key, or nil when key did not exist. @@ -538,7 +539,11 @@ func (c cmdable) SetArgs(ctx context.Context, key string, value interface{}, a S } if !a.ExpireAt.IsZero() { - args = append(args, "exat", a.ExpireAt.Unix()) + if a.ExpireAt.Nanosecond() >= int(time.Millisecond) { + args = append(args, "pxat", a.ExpireAt.UnixMilli()) + } else { + args = append(args, "exat", a.ExpireAt.Unix()) + } } if a.TTL > 0 { if usePrecise(a.TTL) { diff --git a/string_commands_unit_test.go b/string_commands_unit_test.go index 21f7198e..93276309 100644 --- a/string_commands_unit_test.go +++ b/string_commands_unit_test.go @@ -4,6 +4,7 @@ import ( "context" "reflect" "testing" + "time" ) // captureCmdable returns a cmdable that records the Cmder passed to it without @@ -176,3 +177,43 @@ func TestIncrEXFloat_Args(t *testing.T) { }) } } + +func TestSetArgs_ExpireAt(t *testing.T) { + tests := []struct { + name string + expireAt time.Time + want []interface{} + }{ + { + name: "whole_seconds", + expireAt: time.Unix(1700000000, 0), + want: []interface{}{"set", "key", "value", "exat", int64(1700000000)}, + }, + { + name: "sub_millisecond_is_truncated", + expireAt: time.Unix(1700000000, 500_000), + want: []interface{}{"set", "key", "value", "exat", int64(1700000000)}, + }, + { + name: "milliseconds", + expireAt: time.UnixMilli(1700000000123), + want: []interface{}{"set", "key", "value", "pxat", int64(1700000000123)}, + }, + { + name: "beyond_nanosecond_range", + expireAt: time.Date(2270, time.January, 2, 3, 4, 5, 123000000, time.UTC), + want: []interface{}{"set", "key", "value", "pxat", int64(9467204645123)}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var cmd Cmder + c := captureCmdable(&cmd) + c.SetArgs(context.Background(), "key", "value", SetArgs{ExpireAt: tt.expireAt}) + if got := cmd.Args(); !reflect.DeepEqual(got, tt.want) { + t.Errorf("args = %v, want %v", got, tt.want) + } + }) + } +}