Normally setting TTL to 0 with EXPIRE or to a past timestamp with EXPIREAT on master would delete the key on both master and slave, but using lua script to do the same task will only delete the keys on master, but not slave.

Steps to reproduce,

  1. set some keys to master, both master and slave have some number of keys
for i in {0..100}; do redis-cli set key$i val$i > /dev/null done

MASTER
# Keyspace
db0:keys=101,expires=0,avg_ttl=0

SLAVE:
# Keyspace
db0:keys=101,expires=0,avg_ttl=0
  1. set TTL to 0 on all keys with lua, keys are deleted on master, but still stay on slave
for i in {0..100}; do redis-cli eval "redis.call('expire', 'key$i', 0)" 0 > /dev/null; done

MASTER: 
# Keyspace

SLAVE:
# Keyspace
db0:keys=101,expires=101,avg_ttl=0

When this happens, the keys on slave will stay indefinitely and cannot be deleted since the keys are removed on master already.

I checked the source code of 4.0.10, when EXPIRE or EXPIREAT is called without using LUA, in expireGenericCommand() when the client sets the TTL to 0 or a past timestamp on the key, redis on master will delete the key immediately, then rewrites the client command to DEL with rewriteClientCommandVector(), the DEL command is then sent to slave by propagate()

if (when <= mstime() && !server.loading && !server.masterhost) {
...
       int deleted = server.lazyfree_lazy_expire ? dbAsyncDelete(c->db,key) :
                                                    dbSyncDelete(c->db,key);
        serverAssertWithInfo(c,key,deleted);
        server.dirty++;

        /* Replicate/AOF this as an explicit DEL or UNLINK. */
        aux = server.lazyfree_lazy_expire ? shared.unlink : shared.del;
        rewriteClientCommandVector(c,2,aux,key);

But if EXPIRE or EXPIREAT is called with LUA, e.g. eval "redis.call('expire', 'key', 0)" 0, redis on master creates a fake client lua_client to execute the actual EXPIRE command, "expire key 0", then rewriteClientCommandVector() rewrites DEL command to the fake client, not the real client. The command in real client is still EVAL. Later when propagate() is called, it sends the command EVAL of the real client to slave, the DEL in the fake client is not replicated to the slave, causing the keys to stay indefinitely on slave. I found this behavior in both 4.0.10 and 3.2.x

Could you check whether this is expected behavior or an issue ? Thanks.

Comment From: soloestoy

It's a critical bug, please pay attention @antirez .

Moreover, the bug is not only between master and slave, but also between memory and AOF.

If we apply a script like that:

eval "redis.call('set','foo','bar') redis.call('expire','foo',0) redis.call('lpush','foo','bar')" 0

After loading from AOF, will lost list foo.

Comment From: oranagra

I've tested this just now. with default configuration it no longer happens, but when setting config set lua-replicate-commands no it does. this was a severe bug for years, and luckily now it's not as bad (since it doesn't happen by default). It'll implicitly be solved when we implement #8370 / #5292 in redis 7.0

Comment From: oranagra

no longer relevant, script propagation was deprecated in #9812