From ac1338dd8e84f0a39a87dd95dc788cc26fa3fd86 Mon Sep 17 00:00:00 2001 From: heeeione <68272931+heeoneie@users.noreply.github.com> Date: Tue, 15 Sep 2026 15:57:08 +0900 Subject: [PATCH 1/4] =?UTF-8?q?Round=20the=20Redis=20key=E2=80=93value=20T?= =?UTF-8?q?TL=20up=20to=20whole=20seconds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `RedisKvStore.set()` passed `options.ttl.total("second")` straight to Redis `SETEX`, which accepts only whole seconds. Any `Temporal.Duration` that was not an exact number of seconds was rejected with `ERR value is not an integer or out of range`, and a zero duration with `ERR invalid expire time in 'setex' command`, so the write failed rather than storing the value with an approximated expiry. `KvStoreSetOptions.ttl` is public API and accepts any duration, so application code reaches this. Round up rather than to the nearest second. Every other `KvStore` implementation keeps a value for at least as long as it was asked to — SQLite and Deno KV to the millisecond, PostgreSQL to the full interval — and the Cloudflare Workers adapter already clamps upward with `Math.max(ttl, 60)` where the backend cannot express a short expiry. Expiring early is also the direction that can change behaviour rather than cost a refetch: a TTL used to suppress duplicate work, as the inbox listener does, would start letting duplicates through. Clamp the result to 1, the smallest expiry `SETEX` accepts, so a sub-second or zero duration stores the value instead of failing. Cover the conversion with a test that records what `set()` hands to `SETEX`, which runs without a Redis server, and the end-to-end behaviour with a `REDIS_URL`-gated test. Both use `node:test` directly: a test registered through `@fedify/fixture` does not run under Node.js at all, since its registration path needs `require`, so a test written that way would have reported success on Node.js without executing. Fixes https://github.com/fedify-dev/fedify/issues/1028 Assisted-by: Claude Code:claude-opus-5 --- changes.d/redis/ttl-whole-seconds.md | 7 ++ packages/redis/src/kv.test.ts | 108 +++++++++++++++++++++++++++ packages/redis/src/kv.ts | 19 ++++- 3 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 changes.d/redis/ttl-whole-seconds.md diff --git a/changes.d/redis/ttl-whole-seconds.md b/changes.d/redis/ttl-whole-seconds.md new file mode 100644 index 000000000..70e143f3d --- /dev/null +++ b/changes.d/redis/ttl-whole-seconds.md @@ -0,0 +1,7 @@ + - Fixed `RedisKvStore.set()` failing when the `ttl` option was not a whole + number of seconds. The duration was handed to Redis `SETEX` unchanged, and + `SETEX` accepts only whole seconds, so the write was rejected with + `ERR value is not an integer or out of range` instead of being stored with + a rounded expiry. The TTL is now rounded up to the next whole second, and + never below one second, which is the smallest expiry Redis can express. + [[#1028] by Heewon Chae] diff --git a/packages/redis/src/kv.test.ts b/packages/redis/src/kv.test.ts index 729854b1c..65752fc18 100644 --- a/packages/redis/src/kv.test.ts +++ b/packages/redis/src/kv.test.ts @@ -1,8 +1,13 @@ import { test } from "@fedify/fixture"; import { RedisKvStore } from "@fedify/redis/kv"; +import * as temporal from "@js-temporal/polyfill"; +import type { Redis as RedisClient, RedisKey } from "ioredis"; import { Redis } from "ioredis"; import assert from "node:assert/strict"; import process from "node:process"; +import { test as nodeTest } from "node:test"; + +const Temporal = globalThis.Temporal ?? temporal.Temporal; const redisUrl = process.env.REDIS_URL; const ignore = redisUrl == null; @@ -144,3 +149,106 @@ test("RedisKvStore.list() - empty prefix", { ignore }, async () => { redis.disconnect(); } }); + +// Regression tests for `RedisKvStore.set()` handing Redis `SETEX` a TTL that +// is not a whole number of seconds. +// +// `options.ttl.total("second")` was passed straight through, so any duration +// that is not an exact number of seconds — which `KvStoreSetOptions.ttl` +// accepts, since it is any `Temporal.Duration` — made the server reject the +// write with `ERR value is not an integer or out of range` rather than storing +// the value with a rounded expiry. A zero duration failed too, with `ERR +// invalid expire time in 'setex' command`. +// +// See: https://github.com/fedify-dev/fedify/issues/1028 + +/** + * A stand-in for the Redis client that records the arguments `set()` hands to + * `SETEX`. It exists so the conversion can be checked on every runtime, + * including the ones with no `REDIS_URL`; the end-to-end behaviour is covered + * by the `REDIS_URL`-gated test below. + */ +function recordingRedis(): { + setexCalls: { key: RedisKey; seconds: unknown }[]; + redis: RedisClient; +} { + const setexCalls: { key: RedisKey; seconds: unknown }[] = []; + const client = { + setex(key: RedisKey, seconds: unknown, _value: unknown): Promise<"OK"> { + setexCalls.push({ key, seconds }); + return Promise.resolve("OK"); + }, + }; + return { setexCalls, redis: client as unknown as RedisClient }; +} + +nodeTest("RedisKvStore.set() rounds a TTL up to whole seconds", async () => { + const cases: [Temporal.Duration, number, string][] = [ + [Temporal.Duration.from({ seconds: 1 }), 1, "a whole second is unchanged"], + [ + Temporal.Duration.from({ minutes: 5 }), + 300, + "whole seconds are unchanged", + ], + [Temporal.Duration.from({ milliseconds: 1500 }), 2, "1.5s rounds up"], + [ + Temporal.Duration.from({ milliseconds: 1400 }), + 2, + "1.4s rounds up, not to the nearest second", + ], + [ + Temporal.Duration.from({ milliseconds: 500 }), + 1, + "a sub-second TTL becomes the smallest expiry", + ], + [ + Temporal.Duration.from({ milliseconds: 1 }), + 1, + "a near-zero TTL stays at least 1", + ], + [Temporal.Duration.from({ seconds: 0 }), 1, "a zero TTL stays at least 1"], + ]; + for (const [ttl, expected, why] of cases) { + const { setexCalls, redis } = recordingRedis(); + const store = new RedisKvStore(redis, { keyPrefix: "fedify_test::" }); + await store.set(["foo"], "bar", { ttl }); + assert.strictEqual(setexCalls.length, 1); + assert.strictEqual(setexCalls[0].seconds, expected, why); + assert( + Number.isInteger(setexCalls[0].seconds), + "SETEX only accepts whole seconds", + ); + } +}); + +nodeTest( + "RedisKvStore.set() stores a sub-second TTL", + { skip: ignore }, + async () => { + if (ignore) return; // Bun does not support the skip option + const { redis, keyPrefix, store, cleanup } = getRedis(); + try { + // Before the fix this threw `ERR value is not an integer or out of + // range`. + await store.set(["foo", "sub"], "bar", { + ttl: Temporal.Duration.from({ milliseconds: 500 }), + }); + assert.strictEqual(await store.get(["foo", "sub"]), "bar"); + assert.strictEqual( + await redis.ttl(`${keyPrefix}foo::sub`), + 1, + "a sub-second TTL should be stored as the smallest expiry Redis accepts", + ); + + // A whole number of seconds keeps its value, so the rounding does not + // change what already worked. + await store.set(["foo", "whole"], "bar", { + ttl: Temporal.Duration.from({ seconds: 30 }), + }); + assert.strictEqual(await redis.ttl(`${keyPrefix}foo::whole`), 30); + } finally { + await cleanup(); + redis.disconnect(); + } + }, +); diff --git a/packages/redis/src/kv.ts b/packages/redis/src/kv.ts index 758ee3bf4..54ff62a9c 100644 --- a/packages/redis/src/kv.ts +++ b/packages/redis/src/kv.ts @@ -8,6 +8,23 @@ import type { Cluster, Redis, RedisKey } from "ioredis"; import { Buffer } from "node:buffer"; import { type Codec, JsonCodec } from "./codec.ts"; +/** + * Turns a TTL into the whole number of seconds Redis `SETEX` requires. + * + * Redis expiries have one-second granularity, so a duration that is not a + * whole number of seconds has to be approximated. It is rounded up rather + * than to the nearest second: every other {@link KvStore} implementation keeps + * a value for at least as long as it was asked to, and expiring early is the + * direction that can change behaviour rather than just cost a refetch — a TTL + * used to suppress duplicate work would start letting duplicates through. + * + * The result is clamped to 1, the smallest expiry `SETEX` accepts, so a + * sub-second duration stores the value instead of being rejected. + */ +function expirySeconds(ttl: Temporal.Duration): number { + return Math.max(1, Math.ceil(ttl.total("second"))); +} + /** * Options for {@link RedisKvStore} class. */ @@ -101,7 +118,7 @@ export class RedisKvStore implements KvStore { if (options?.ttl != null) { await this.#redis.setex( serializedKey, - options.ttl.total("second"), + expirySeconds(options.ttl), encodedValue, ); } else { From 2983d72580b151b6d724b1fdb803c44a7cce26e4 Mon Sep 17 00:00:00 2001 From: heeeione <68272931+heeoneie@users.noreply.github.com> Date: Tue, 15 Sep 2026 16:04:27 +0900 Subject: [PATCH 2/4] Reference the pull request in the changelog fragment The fragment could only cite the issue when it was written, since the pull request did not exist yet. Released entries in this changelog carry both numbers. Assisted-by: Claude Code:claude-opus-5 --- CHANGES.md | 12 ++++++++++++ changes.d/redis/ttl-whole-seconds.md | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index 102e01106..37dc29a37 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -8,6 +8,18 @@ Version 2.0.28 To be released. +### @fedify/redis + + - Fixed `RedisKvStore.set()` failing when the `ttl` option was not a whole + number of seconds. The duration was handed to Redis `SETEX` unchanged, and + `SETEX` accepts only whole seconds, so the write was rejected with + `ERR value is not an integer or out of range` instead of being stored with + a rounded expiry. The TTL is now rounded up to the next whole second, and + never below one second, which is the smallest expiry Redis can express. + [[#1028] by Heewon Chae\] + +[#1028]: https://github.com/fedify-dev/fedify/issues/1028 + Version 2.0.27 -------------- diff --git a/changes.d/redis/ttl-whole-seconds.md b/changes.d/redis/ttl-whole-seconds.md index 70e143f3d..790d6bb17 100644 --- a/changes.d/redis/ttl-whole-seconds.md +++ b/changes.d/redis/ttl-whole-seconds.md @@ -4,4 +4,4 @@ `ERR value is not an integer or out of range` instead of being stored with a rounded expiry. The TTL is now rounded up to the next whole second, and never below one second, which is the smallest expiry Redis can express. - [[#1028] by Heewon Chae] + [[#1028], [#1034] by Heewon Chae] From 4efb8fdc5a4a82d2eb757180a2f9f2a9718e184d Mon Sep 17 00:00:00 2001 From: heeeione <68272931+heeoneie@users.noreply.github.com> Date: Wed, 16 Sep 2026 12:58:25 +0900 Subject: [PATCH 3/4] Qualify the rounding as a SETEX limit and cover non-positive TTLs Per review on the pull request. The one-second granularity belongs to `SETEX`, not to Redis, which can express a millisecond expiry through `SET` with `PX`. Say so in the helper's documentation and in the changelog rather than attributing the limit to the server. Document the non-positive cases explicitly. A zero or negative duration stores the value for one second, which is a policy choice and not a consequence of the rounding: the other `KvStore` implementations read a non-positive TTL as already expired, while this one keeps the value for the shortest lifetime `SETEX` can express, on the grounds that storing it briefly is closer to the request than failing the write. The note goes on `set()` as well as the helper, since the helper is not exported and its documentation is not user-visible. Add negative durations to the regression table and to the gated end-to-end test. The zero case alone did not pin them: an implementation that clamped zero to one second and let negatives through would have passed the table as it stood, and reached `SETEX` with an expiry it rejects. Regenerate the changelog against the current branch tip, which has moved to 2.0.28 since 2.0.27 was released. Assisted-by: Claude Code:claude-opus-5 --- CHANGES.md | 11 +++++---- changes.d/redis/ttl-whole-seconds.md | 10 ++++---- packages/redis/src/kv.test.ts | 32 +++++++++++++++++++++++++ packages/redis/src/kv.ts | 36 +++++++++++++++++++++------- 4 files changed, 73 insertions(+), 16 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 37dc29a37..50cfd46f6 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -12,13 +12,16 @@ To be released. - Fixed `RedisKvStore.set()` failing when the `ttl` option was not a whole number of seconds. The duration was handed to Redis `SETEX` unchanged, and - `SETEX` accepts only whole seconds, so the write was rejected with + `SETEX` takes only whole seconds, so the write was rejected with `ERR value is not an integer or out of range` instead of being stored with - a rounded expiry. The TTL is now rounded up to the next whole second, and - never below one second, which is the smallest expiry Redis can express. - [[#1028] by Heewon Chae\] + a rounded expiry. The TTL is now rounded up to the next whole second. A + zero or negative duration, which `SETEX` also rejects, now stores the value + for one second, the shortest expiry that command can express. The + one-second granularity is `SETEX`'s rather than Redis's; `SET` with `PX` + supports millisecond expiries. [[#1028], [#1034] by Heewon Chae\] [#1028]: https://github.com/fedify-dev/fedify/issues/1028 +[#1034]: https://github.com/fedify-dev/fedify/issues/1034 Version 2.0.27 diff --git a/changes.d/redis/ttl-whole-seconds.md b/changes.d/redis/ttl-whole-seconds.md index 790d6bb17..841b5832e 100644 --- a/changes.d/redis/ttl-whole-seconds.md +++ b/changes.d/redis/ttl-whole-seconds.md @@ -1,7 +1,9 @@ - Fixed `RedisKvStore.set()` failing when the `ttl` option was not a whole number of seconds. The duration was handed to Redis `SETEX` unchanged, and - `SETEX` accepts only whole seconds, so the write was rejected with + `SETEX` takes only whole seconds, so the write was rejected with `ERR value is not an integer or out of range` instead of being stored with - a rounded expiry. The TTL is now rounded up to the next whole second, and - never below one second, which is the smallest expiry Redis can express. - [[#1028], [#1034] by Heewon Chae] + a rounded expiry. The TTL is now rounded up to the next whole second. A + zero or negative duration, which `SETEX` also rejects, now stores the value + for one second, the shortest expiry that command can express. The + one-second granularity is `SETEX`'s rather than Redis's; `SET` with `PX` + supports millisecond expiries. [[#1028], [#1034] by Heewon Chae] diff --git a/packages/redis/src/kv.test.ts b/packages/redis/src/kv.test.ts index 65752fc18..07e444502 100644 --- a/packages/redis/src/kv.test.ts +++ b/packages/redis/src/kv.test.ts @@ -207,6 +207,21 @@ nodeTest("RedisKvStore.set() rounds a TTL up to whole seconds", async () => { "a near-zero TTL stays at least 1", ], [Temporal.Duration.from({ seconds: 0 }), 1, "a zero TTL stays at least 1"], + [ + Temporal.Duration.from({ milliseconds: -1 }), + 1, + "a negative sub-second TTL stays at least 1", + ], + [ + Temporal.Duration.from({ seconds: -30 }), + 1, + "a negative TTL stays at least 1", + ], + [ + Temporal.Duration.from({ hours: -1 }), + 1, + "a large negative TTL stays at least 1", + ], ]; for (const [ttl, expected, why] of cases) { const { setexCalls, redis } = recordingRedis(); @@ -218,6 +233,10 @@ nodeTest("RedisKvStore.set() rounds a TTL up to whole seconds", async () => { Number.isInteger(setexCalls[0].seconds), "SETEX only accepts whole seconds", ); + assert( + (setexCalls[0].seconds as number) > 0, + "SETEX rejects a non-positive expiry", + ); } }); @@ -240,6 +259,19 @@ nodeTest( "a sub-second TTL should be stored as the smallest expiry Redis accepts", ); + // A negative duration is stored for one second rather than rejected. + // `SETEX` refuses a non-positive expiry outright, so without the floor + // this throws `ERR invalid expire time in 'setex' command`. + await store.set(["foo", "negative"], "bar", { + ttl: Temporal.Duration.from({ seconds: -30 }), + }); + assert.strictEqual(await store.get(["foo", "negative"]), "bar"); + assert.strictEqual( + await redis.ttl(`${keyPrefix}foo::negative`), + 1, + "a negative TTL should be stored as the smallest expiry Redis accepts", + ); + // A whole number of seconds keeps its value, so the rounding does not // change what already worked. await store.set(["foo", "whole"], "bar", { diff --git a/packages/redis/src/kv.ts b/packages/redis/src/kv.ts index 54ff62a9c..012450764 100644 --- a/packages/redis/src/kv.ts +++ b/packages/redis/src/kv.ts @@ -11,15 +11,26 @@ import { type Codec, JsonCodec } from "./codec.ts"; /** * Turns a TTL into the whole number of seconds Redis `SETEX` requires. * - * Redis expiries have one-second granularity, so a duration that is not a - * whole number of seconds has to be approximated. It is rounded up rather - * than to the nearest second: every other {@link KvStore} implementation keeps - * a value for at least as long as it was asked to, and expiring early is the - * direction that can change behaviour rather than just cost a refetch — a TTL - * used to suppress duplicate work would start letting duplicates through. + * The one-second granularity is `SETEX`'s, not Redis's: Redis can express a + * millisecond expiry through `SET` with `PX`, and `SETEX` is simply the + * command this adapter uses. Within that command a duration which is not a + * whole number of seconds has to be approximated. * - * The result is clamped to 1, the smallest expiry `SETEX` accepts, so a - * sub-second duration stores the value instead of being rejected. + * It is rounded up rather than to the nearest second, because every other + * {@link KvStore} implementation keeps a value for at least as long as it was + * asked to, and expiring early is the direction that can change behaviour + * rather than just cost a refetch — a TTL used to suppress duplicate work + * would start letting duplicates through. + * + * The result is clamped to 1, the smallest expiry `SETEX` accepts. A + * sub-second duration therefore stores the value for one second instead of + * being rejected, and so do **zero and negative durations**, which `SETEX` + * rejects outright. That last part is a policy choice rather than a + * consequence of the rounding: the other {@link KvStore} implementations read + * a non-positive TTL as already expired, whereas this one keeps the value for + * the shortest lifetime the command can express. Storing it briefly is closer + * to the caller's request than failing the write, which is what happened + * before. */ function expirySeconds(ttl: Temporal.Duration): number { return Math.max(1, Math.ceil(ttl.total("second"))); @@ -108,6 +119,15 @@ export class RedisKvStore implements KvStore { return this.#codec.decode(encodedValue) as T; } + /** + * {@inheritDoc KvStore.set} + * + * The `ttl` option is stored through Redis `SETEX`, which takes a whole + * number of seconds, so a duration with a finer resolution is rounded up to + * the next second. A zero or negative duration stores the value for one + * second, the shortest expiry the command can express, rather than failing + * the write or deleting the key. + */ async set( key: KvKey, value: unknown, From 5445a3c7631689493980feae3dafb40ecae03214 Mon Sep 17 00:00:00 2001 From: Heewon Chae <68272931+heeoneie@users.noreply.github.com> Date: Fri, 18 Sep 2026 17:05:55 +0900 Subject: [PATCH 4/4] Make the gated TTL assertions tolerate elapsed time Per review on the pull request. The end-to-end test compared `TTL` against an exact integer. `TTL` rounds the remaining lifetime to whole seconds, so a delay of just over half a second reports `0` for a key written with `SETEX 1` and `29` for one written with `SETEX 30`, failing the assertions on a slow run even though the conversion is correct. Measured against Redis 7.4.11: 600 ms after a `SETEX 1` write the key still exists, while `TTL` returns `0` and `PTTL` returns `387`. Read `PTTL` instead and bound it on both sides. The upper bound, the requested lifetime in milliseconds, rules out a longer expiry than intended; the lower bound subtracts the time the write and the read took, which is an upper bound on the key's age, so it cannot be crossed by a correct implementation however slow the run. Reading the expiry straight after the write leaves only the two Redis commands inside that window. The exact conversion stays pinned by the server-free unit test, which is where it belongs. Assisted-by: Claude Code:claude-opus-5 --- packages/redis/src/kv.test.ts | 73 ++++++++++++++++++++++++++++++----- 1 file changed, 63 insertions(+), 10 deletions(-) diff --git a/packages/redis/src/kv.test.ts b/packages/redis/src/kv.test.ts index 07e444502..b63cf27b6 100644 --- a/packages/redis/src/kv.test.ts +++ b/packages/redis/src/kv.test.ts @@ -240,6 +240,45 @@ nodeTest("RedisKvStore.set() rounds a TTL up to whole seconds", async () => { } }); +/** + * Asserts that a key written with a `seconds`-long expiry still has a + * plausible amount of that lifetime left. + * + * `PTTL` is read rather than `TTL` because `TTL` rounds the remaining + * lifetime to whole seconds: just over half a second after a `SETEX 1` write + * it reports `0` for a key that is still there, and `29` for one written with + * `SETEX 30`. Asserting an exact `TTL` therefore fails on a slow run even + * though the conversion is correct. + * + * `elapsedMs` must span everything from before the write to after the read, + * so it is an upper bound on how long the key has been alive, and the + * remaining lifetime cannot have fallen below `seconds * 1000 - elapsedMs`. + * The upper bound is what rules out a longer expiry than intended. + */ +function assertExpiresIn( + remainingMs: number, + seconds: number, + elapsedMs: number, + why: string, +): void { + assert( + remainingMs > 0, + `${why}: expected a live key with an expiry, but PTTL returned ` + + `${remainingMs}`, + ); + assert( + remainingMs <= seconds * 1000, + `${why}: expected at most ${seconds}s left, but PTTL returned ` + + `${remainingMs}ms`, + ); + assert( + remainingMs >= seconds * 1000 - elapsedMs, + `${why}: expected at least ${seconds * 1000 - elapsedMs}ms left ` + + `(${seconds}s minus the ${elapsedMs}ms the write and read took), but ` + + `PTTL returned ${remainingMs}ms`, + ); +} + nodeTest( "RedisKvStore.set() stores a sub-second TTL", { skip: ignore }, @@ -248,36 +287,50 @@ nodeTest( const { redis, keyPrefix, store, cleanup } = getRedis(); try { // Before the fix this threw `ERR value is not an integer or out of - // range`. + // range`. The expiry is read straight after the write, so only the two + // Redis commands sit inside the window the bounds have to tolerate. + let startedAt = Date.now(); await store.set(["foo", "sub"], "bar", { ttl: Temporal.Duration.from({ milliseconds: 500 }), }); - assert.strictEqual(await store.get(["foo", "sub"]), "bar"); - assert.strictEqual( - await redis.ttl(`${keyPrefix}foo::sub`), + let remaining = await redis.pttl(`${keyPrefix}foo::sub`); + assertExpiresIn( + remaining, 1, - "a sub-second TTL should be stored as the smallest expiry Redis accepts", + Date.now() - startedAt, + "a sub-second TTL should be stored as the smallest expiry SETEX accepts", ); + assert.strictEqual(await store.get(["foo", "sub"]), "bar"); // A negative duration is stored for one second rather than rejected. // `SETEX` refuses a non-positive expiry outright, so without the floor // this throws `ERR invalid expire time in 'setex' command`. + startedAt = Date.now(); await store.set(["foo", "negative"], "bar", { ttl: Temporal.Duration.from({ seconds: -30 }), }); - assert.strictEqual(await store.get(["foo", "negative"]), "bar"); - assert.strictEqual( - await redis.ttl(`${keyPrefix}foo::negative`), + remaining = await redis.pttl(`${keyPrefix}foo::negative`); + assertExpiresIn( + remaining, 1, - "a negative TTL should be stored as the smallest expiry Redis accepts", + Date.now() - startedAt, + "a negative TTL should be stored as the smallest expiry SETEX accepts", ); + assert.strictEqual(await store.get(["foo", "negative"]), "bar"); // A whole number of seconds keeps its value, so the rounding does not // change what already worked. + startedAt = Date.now(); await store.set(["foo", "whole"], "bar", { ttl: Temporal.Duration.from({ seconds: 30 }), }); - assert.strictEqual(await redis.ttl(`${keyPrefix}foo::whole`), 30); + remaining = await redis.pttl(`${keyPrefix}foo::whole`); + assertExpiresIn( + remaining, + 30, + Date.now() - startedAt, + "a whole number of seconds should be stored unchanged", + ); } finally { await cleanup(); redis.disconnect();