Session state sits in Redis. Each session key has a 15 minute TTL, 900 seconds. A normal end deletes those keys immediately. The TTL only covers orphans: a crash, a dropped connection, any case where nothing calls cleanup.

One of the keys is a counter, session:{id}:billableSecondsPending. It accumulates billable seconds while a session runs. A separate path collects that counter. Some of those keys outlived the session. They sat at 0, with no expiration, and Redis kept them.

Increment

The increment is a Lua script. INCRBY and EXPIRE run in the same script, so a crash between the two calls cannot leave a key with no TTL.

-- KEYS[1] = session:{id}:billableSecondsPending -- ARGV[1] = seconds to add local n = redis.call('INCRBY', KEYS[1], ARGV[1]) redis.call('EXPIRE', KEYS[1], 900) return n 

The sketches here are simplified. They are not the production source.

Collect

Collect reads the counter and resets it to 0. Two concurrent collects must not both take the same balance, so the read and the reset were one command. That command was GETSET:

GETSET session:{id}:billableSecondsPending 0 

One round trip. It returns the old value and writes 0. It also replaces the string, and GETSET has no option to keep the expiration. After it runs, the key is persistent.

Redis documents that behavior. Internal ticket #601 records GETSET as the cause of the leak. The mistake was using the command on a key whose lifetime is the TTL.

Why it stayed hidden

On a live session the next increment runs EXPIRE and puts the TTL back. Check the key after that increment and the expiration is there.

The leak needs a specific order. Collect runs, then the session dies before another increment. No EXPIRE follows. The key holds 0, the session is gone, and nothing removes it.

The replacement

Collect is its own Lua script. GET the key. If it is missing, return without writing. If it exists, SET it to 0 with KEEPTTL, which keeps the remaining TTL.

-- KEYS[1] = session:{id}:billableSecondsPending local prev = redis.call('GET', KEYS[1]) if not prev then return false end redis.call('SET', KEYS[1], '0', 'KEEPTTL') return prev 

The missing-key branch is the part that matters as much as KEEPTTL. SET with KEEPTTL on a key that is not there still creates the key, and creates it with no TTL. Skip the check and a collect that runs after expiry writes a fresh persistent 0. The leak comes back earlier.

The script is atomic, so an increment cannot land between the GET and the SET. Two concurrent collects still cannot both take the same balance.

GETSET has been deprecated since Redis 6.2, in favor of SET with the GET option. That swap does not keep the TTL. Without KEEPTTL, or a new expiry, SET clears the expiration that was already there. SET key 0 GET drops the TTL the same way GETSET key 0 does.

The increment script is the side that sets and refreshes the TTL. The collect script does not touch it, and it does not create a key the increment did not create first.

I build LiveSuggest, a meeting assistant that stays on the screen. No bot joins the call. This counter is part of its session state.