Skip to content

Redis Functions

A script is sent with EVALSHA, and lives in a cache the server is free to drop. Since Redis 7 there is an alternative: a function library. It is loaded once with FUNCTION LOAD, persists and replicates with the data, and each function in it is called by name with FCALL.

The same Python compiles to either. A body follows exactly the rules of a script's.

from redis_lua_py import Key, Library, redis

limits = Library("limits")


@limits.function
def hit(key: Key, ttl: int) -> int:
    current = redis.incr(key)
    if current == 1:
        redis.expire(key, ttl)
    return current


@limits.function(flags=["no-writes"])
def peek(key: Key) -> int:
    return int(redis.get(key) or 0)
hit(client, key="user:42", ttl=60)  # FCALL hit 1 user:42 60
peek(client, key="user:42")  # FCALL_RO peek 1 user:42

A function is called like a script: pass a sync client and you get a value, an async one and you get an awaitable, and bind works the same way. With a coredis client, the call goes through its fcall or fcall_ro, and loading through its function_load.

Loading

There is nothing to do up front. When a call finds that the server does not have the library, it loads it with FUNCTION LOAD REPLACE and tries again.

To load it explicitly, at deploy time for instance:

limits.load(client)

Load before a pipeline

A call queued in a pipeline cannot load the library halfway through execute(). Call load() before queueing calls to a library the server may not have yet. The same goes for a coredis pipeline, which runs when its async with block ends: await limits.load(client) before entering it.

On Redis Cluster, redis-py and coredis send FUNCTION LOAD to every primary, and route FCALL on the function's keys, as they do a script.

What the library looks like

limits.lua is the whole library, exactly as FUNCTION LOAD receives it:

#!lua name=limits
-- Generated by redis-lua-py. Do not edit.
-- Python truthiness: 0, '', empty tables and nil are all false.
local function __truthy(v)
  if v == nil or v == false then return false end
  if v == 0 or v == '' then return false end
  if type(v) == 'table' and next(v) == nil then return false end
  return true
end
-- Python's `a or b`: a when it is truthy by Python's rules, otherwise b.
local function __or(a, b)
  if __truthy(a) then return a end
  return b
end
-- hit, from limits.py:6
redis.register_function{
  function_name = 'hit',
  callback = function(KEYS, ARGV)
    local key = KEYS[1]
    local ttl = tonumber(ARGV[1])
    local current = redis.call('INCR', key)
    if current == 1 then
      redis.call('EXPIRE', key, ttl)
    end
    return current
  end,
}
-- peek, from limits.py:14
redis.register_function{
  function_name = 'peek',
  callback = function(KEYS, ARGV)
    local key = KEYS[1]
    return tonumber(__or(redis.call('GET', key), 0))
  end,
  flags = {'no-writes'},
}

A helper any function needs is emitted once, at the top, where every callback can see it. The callback names its parameters KEYS and ARGV, so a function body compiles to exactly what the same body would as a script.

Library and function names take letters, digits and underscores, which is all Redis accepts. Function names are global on the server, so two libraries cannot both define a hit.

Flags

flags takes the flags Redis defines: no-writes, allow-oom, allow-stale, no-cluster and allow-cross-slot-keys. A no-writes function is called with FCALL_RO, which a replica accepts.

Scripts take the same flags. Redis 7 reads them from a #!lua line at the very top of the script, which is where they go:

@script(flags=["no-writes"])
def read(key: Key) -> bytes:
    return redis.get(key)
#!lua flags=no-writes
-- read
-- Generated by redis-lua-py from app/cache.py:12. Do not edit.
local key = KEYS[1]
return redis.call('GET', key)

A misspelled flag is refused at import, like everything else.

Testing

fakeredis has neither FUNCTION nor script flags. Test a library against a real server, or check its source as a golden file: limits.lua is the whole of it, as a script's .lua is.