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:
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:
#!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.