A Primer on Scripting with Lua
"never let yourself be so far deceived as to doubt that."
The Castle by Franz Kafka
Prologue
I was much eager to obtain a good quick start guide on programming in Lua in Redis and it turned out I have to create my own...
Lua is full of memories not because I love moon... It is an artefact with pecular language constructs which are fossil of technology evolution and stigmata from epoch elapsed. Those treasures were buried deep in Lua, deep in Redis.
I. Brief History
Lua (/ˈluːə/ LOO-ə; from Portuguese: lua [ˈlu(w)ɐ] meaning moon) is a lightweight, high-level, multi-paradigm programming language designed mainly for embedded use in applications.[3]
Lua syntax for control structures was mostly borrowed from Modula (
if,while,repeat/until), but also had taken influence from CLU (multiple assignments and multiple returns from function calls, as a simpler alternative to reference parameters or explicit pointers), C++ ("neat idea of allowing a local variable to be declared only where we need it"[6]), SNOBOL and AWK (associative arrays).In an article published in Dr. Dobb's Journal, Lua's creators also state that LISP and Scheme with their single, ubiquitous data-structure mechanism (the list) were a major influence on their decision to develop the table as the primary data structure of Lua.[8]
Lua semantics have been increasingly influenced by Scheme over time,[6] especially with the introduction of anonymous functions and full lexical scoping. Several features were added in new Lua versions.
Lua lets you run part of your application logic inside Redis. Such scripts can perform conditional updates across multiple keys, possibly combining several different data types atomically.
Scripts are executed in Redis by an embedded execution engine. Presently, Redis supports a single scripting engine, the Lua 5.1 interpreter. Please refer to the Redis Lua API Reference page for complete documentation.
I. Content
1. Comments
Before writing any code, it's crucial to know how to write comments both for mindful and mindless people. In Lua, comment has two forms:
-- This is a single line, SQL-like comment.
--[[
This comment spans more than one lines.
The syntax is bizarre, pretty much a
-- follows by a multi-line string.
]]2. Variables
In Lua, variable definition not precedes with keyword local is conceived to be global scope and by convention global variable starts with capital letter, although you are not restricted to do so.
Under Redis, all variable definitions MUST be local scope., you just can't use:
Variable = "value"Which triggers a:
Error: ERR Error running script: @globals:9: Script attempted to create global variable 'Variable' stack traceback: [G]: in function 'error' @globals:9: in function <@globals:5> @user_script:1: in main chunk [G]: ?This is the first pitfall you may come across. The proper way is:
local variable = "value"3. Use end to mark end of scope
for i = 1, 5 do
end
while x < 10 do
x = x + 1
end
repeat
x = x + 1
until x > 10
function greet(name)
print("Hello, " .. name)
end
do
local temp = 42
print(temp)
end
if x > 0 then
print("Positive")
elseif x < 0 then
print("Negative")
else
print("Zero")
endWith the exception of repeat until:
repeat
x = x + 1
until x > 10This is the second pitfall if you are from C family.
4. Use goto to quit nested loop
::start::
while true do
local x = math.random()
if x > 0.9 then
goto done
end
end
::done::Although it is uncommon today, goto is one of the important trait of programming languages of early age.
5. Use call and pcall to interact with Redis
It is possible to call Redis commands from a Lua script either via redis.call() or redis.pcall().
The two are nearly identical. Both execute a Redis command along with its provided arguments, if these represent a well-formed command. However, the difference between the two functions lies in the manner in which runtime errors (such as syntax errors, for example) are handled. Errors raised from calling redis.call() function are returned directly to the client that had executed it. Conversely, errors encountered when calling the redis.pcall() function are returned to the script's execution context instead for possible handling.
For example, consider the following:
> EVAL "return redis.call('SET', KEYS[1], ARGV[1])" 1 foo bar
OKThe above script accepts one key name and one value as its input arguments. When executed, the script calls the SET command to set the input key, foo, with the string value "bar".
6. Parallel assignment
Function in Lua can returns more than one value.
local function myfunc()
return 100, 'lua', { "a", "table" }
end
local score, name, output = myfunc()Swapping values without introducing extra variable.
local a, b = 100, 200
a, b = b, aThis feature is not available in most languages. You can effectively define and initialize many variables in one line, like a guru.
7. .. and ... syntax
.. is string concatenate operator in Lua, this is akin to || in SQL statement. Lua has table.concat function but not string.concat.
local name = 'Lua'
local message = 'Hi ' ..name.. ' nice to neet you.'... is for variable number of arguments in function definition.
local function sum(...)
local args = {...}
local total = 0
for i=1, #args do
total = total + args[i]
end
return total
end
local n = sum(1, 2, 3, 4, 5)In the above example, ... stands for 1, 2, 3, 4, 5 five individual arguments. Use of {...} turns the arguments to a table. By dint of {...}, you can process unknown number of arguments in a funciton. By the way, Lua doesn't support default parameters in the function signature like Python or JavaScript. But you can easily simulate them using idiomatic Lua patterns.
function greet(name)
name = name or "stranger"
print("Hello, " .. name)
end
greet() -- Hello, stranger
greet("Iong") -- Hello, IongThis is the third pitfall you may come across.
8. "Strange Case of Lua Table"
The one and only one data structure in Lua is table, which has two favours:
- Array style table
local table1 = { "iong_dev", "active" }Or explicitly specify the index:
local table1 = { [1] = "iong_dev", [2] = "active" }- Dictionary style table
local table2 = { name = "iong_dev", status = "active" }The difference is subtle and intricacy elusive...
The unpack function returns the elements of a table as separate values, allowing you to easily use them in a function call or assign them to multiple variables.
Syntax
unpack(tableData, start, end)tableData- The table containing the elements to be unpacked.
start- The index of the first element to unpack. Defaults to 1.
end- The index of the last element to unpack. If omitted, unpacks all elements from start to the end of the table.
Return
The function returns the elements of the table as separate values. If the specified indices are out of range, it returns nil.
Description
The unpack() function is particularly useful for passing table elements as arguments to functions that expect multiple parameters. It allows you to work with table data in a more flexible way.
This function looks for numeric index starting from 1, which doesn't exist in dictionary style table. A call to
redis.log(redis.LOG_NOTICE, unpack(table1))Will output: 'iong_dev active' in redis.log. Whereas a call to
redis.log(redis.LOG_NOTICE, unpack(table2))Results in an error.
node:internal/modules/run_main:104
triggerUncaughtException(
^
[SimpleError: ERR redis.log() requires two arguments or more. script: 9a81afe7c8515723aefe02c8e6f7e1a87be3d5f2, on @user_script:18.]This is because unpack(table2) returns nil which triggers the error. Similarly, array style table has length; dictionary style table HAS NOT... Therefore,
return { #table1, #table2 }Returns [ 2, 0 ] to the client. To set myhash with
local table3 = { 'name', 'iong_dev', 'status', 'active', 'age', 59 }
redis.call('HSET', 'myhash', unpack(table3))Which effectively do a
HSET myhash name iong_dev status active age 59To encode with cjson.encode so that will see better
redis.log(redis.LOG_NOTICE, cjson.encode(table1))Will output: '["iong_dev","active"]' in redis.log
redis.log(redis.LOG_NOTICE, cjson.encode(table2))Will output: '{"name":"iong_dev","status":"active"}' in redis.log
Last but not least, to return a dictionary style table using RESP3, instead of using
redis.setresp(3)
return { name = "iong_dev", status = "active" }Which always gives [], an empty array! You should use
redis.setresp(3)
return { map={ name = "iong_dev", status = "active" } }Which gives
[Object: null prototype] { name: 'iong_dev', status: 'active' }A real javascript objct! Be sure to precede the script call with
await redis.sendCommand(['HELLO', '3'])As a bonus, what is the expected behaviour of this code?
local table4 = { name = "iong_dev", status = "active",
[1]="berto_dev", [2]="inactive" }
redis.log(redis.LOG_NOTICE, unpack(table4))
redis.log(redis.LOG_NOTICE, #table4)9. Output
You can use redis.log to output message to redis.log.
redis.log(redis.LOG_WARNING, 'Something is terribly wrong')Will produce a line similar to the following in your server's log:
[32343] 22 Mar 15:21:39 # Something is terribly wrongThis is possible provided that redis.conf is properly set up and you have access to redis.log file.
redis.log
# Specify the server verbosity level.
# This can be one of:
# debug (a lot of information, useful for development/testing)
# verbose (many rarely useful info, but not a mess like the debug level)
# notice (moderately verbose, what you want in production probably)
# warning (only very important / critical messages are logged)
# nothing (nothing is logged)
loglevel notice
# Specify the log file name. Also the empty string can be used to force
# Redis to log on the standard output. Note that if you use standard
# output for logging but daemonize, logs will be sent to /dev/null
logfile "C:\\redis\\redis.log"10. Return
Instead of returning a string literal, the standard way to reply 'Ok':
redis.status_reply('Ok')The standard way to reply an error:
redis.error_reply('ERR My very special table error')Note: By convention, Redis uses the first word of an error string as a unique error code for specific errors or ERR for general-purpose errors. Scripts are advised to follow this convention, as shown in the example above, but this is not mandatory.
III. Retrospection
I am going to discuss two unique characteristic in Lua which are not present in modern programming languages.
1. Parallel assignment
Lua allows multiple assignments. Therefore, the syntax for assignment defines a list of variables on the left side and a list of expressions on the right side. The elements in both lists are separated by commas:
stat ::= varlist `=´ explist
varlist ::= var {`,´ var}
explist ::= exp {`,´ exp}Before the assignment, the list of values is adjusted to the length of the list of variables. If there are more values than needed, the excess values are thrown away. If there are fewer values than needed, the list is extended with as many nil's as needed. If the list of expressions ends with a function call, then all values returned by that call enter the list of values, before the adjustment (except when the call is enclosed in parentheses; see §2.5).
The assignment statement first evaluates all its expressions and only then are the assignments performed. Thus the code
i = 3
i, a[i] = i+1, 20sets a[3] to 20, without affecting a[4] because the i in a[i] is evaluated (to 3) before it is assigned 4. Similarly, the line
x, y = y, xexchanges the values of x and y, and
x, y, z = y, z, xcyclically permutes the values of x, y, and z.
But what about the order of execution of each pairs of assignment,
local a, b, c = func1(), func2(), func3()It is natural to envisaged as,
local a = func1()
local b = func2()
local c = func3()The assignment is done on a first, then on b and at last on c... As far as the documentation is concerned, and for sake of correctness, you should consider it non-deterministic order of execution. In the other words, It is unsafe to assume order of any sort. Parallel assignment has to be used with care if execution of func1(), func2() and func3() may interfer with each other. In extreme case, what is the expected output of the following code?
local a, b, c = 1, 2, 3
local func1 = function() b = b + 10 c = c + 10 return a end
local func2 = function() a = a + 10 c = c + 10 return b end
local func3 = function() a = a + 10 b = b + 10 return c end
a, b, c = func1(), func2(), func3()
return { a, b, c }And it gives:
1,12,23To compare with:
local a, b, c = 1, 2, 3
local func1 = function() b = b + 10 c = c + 10 return a end
local func2 = function() a = a + 10 c = c + 10 return b end
local func3 = function() a = a + 10 b = b + 10 return c end
a = func1()
b = func2()
c = func3()
return { a, b, c }And it gives:
21,22,232. Coroutines
Lua supports coroutines, also called collaborative multithreading. A coroutine in Lua represents an independent thread of execution. Unlike threads in multithread systems, however, a coroutine only suspends its execution by explicitly calling a yield function.
You create a coroutine with a call to coroutine.create. Its sole argument is a function that is the main function of the coroutine. The
createfunction only creates a new coroutine and returns a handle to it (an object of type thread); it does not start the coroutine execution.When you first call coroutine.resume, passing as its first argument a thread returned by coroutine.create, the coroutine starts its execution, at the first line of its main function. Extra arguments passed to coroutine.resume are passed on to the coroutine main function. After the coroutine starts running, it runs until it terminates or yields.
A coroutine can terminate its execution in two ways: normally, when its main function returns (explicitly or implicitly, after the last instruction); and abnormally, if there is an unprotected error. In the first case, coroutine.resume returns true, plus any values returned by the coroutine main function. In case of errors, coroutine.resume returns false plus an error message.
A coroutine yields by calling coroutine.yield. When a coroutine yields, the corresponding coroutine.resume returns immediately, even if the yield happens inside nested function calls (that is, not in the main function, but in a function directly or indirectly called by the main function). In the case of a yield, coroutine.resume also returns true, plus any values passed to coroutine.yield. The next time you resume the same coroutine, it continues its execution from the point where it yielded, with the call to coroutine.yield returning any extra arguments passed to coroutine.resume.
Like coroutine.create, the coroutine.wrap function also creates a coroutine, but instead of returning the coroutine itself, it returns a function that, when called, resumes the coroutine. Any arguments passed to this function go as extra arguments to coroutine.resume. coroutine.wrap returns all the values returned by coroutine.resume, except the first one (the boolean error code). Unlike coroutine.resume, coroutine.wrap does not catch errors; any error is propagated to the caller.
coroutine.lua
local ret, msg, output = true, '', {}
local function taskA(x)
table.insert(output, ' x = ' .. x)
local y = coroutine.yield(x)
table.insert(output, ' y = ' .. y)
local z = coroutine.yield(y)
table.insert(output, ' z = ' .. z)
return z
end
local function print(r, m)
table.insert(output, ' ret = '.. tostring(r))
table.insert(output, ' msg = '.. tostring(m))
end
local co = coroutine.create(taskA)
ret, msg = coroutine.resume(co, "fossil")
print(ret, msg)
ret, msg = coroutine.resume(co, "stigmata")
print(ret, msg)
ret, msg = coroutine.resume(co, "delirium")
print(ret, msg)
ret, msg = coroutine.resume(co, "ecstasy")
print(ret, msg)
return outputOutput:
x = fossil,
ret = true, msg = fossil, y = stigmata,
ret = true, msg = stigmata, z = delirium,
ret = true, msg = delirium,
ret = false, msg = can not resume a dead threadThe use of coroutine.resume and coroutine.yield to pass in and out values is elusive, unintelligible and beyond imagination at first sight. While the above example is trivial, let's look at another.
prodcons.lua
local ret, msg, joblist, output = true, '', 'prodcons:joblist', {}
local timestamp = unpack(redis.call('TIME'))
math.randomseed(tonumber(timestamp))
local function producer()
while true do
local n = math.random(1, 10)
for j=1, n do
redis.call('LPUSH', joblist, math.random(1, 9999))
end
coroutine.yield(n)
end
end
local function consumer()
while true do
local n = math.random(1, 15)
for j=1, n do
local val = redis.call('RPOP', joblist)
if (val == false) then
coroutine.yield((j-1)..'/'..n)
n = 0
break
end
end
if n~=0 then
coroutine.yield(n)
end
end
end
local function print(m)
table.insert(output, m)
end
local coprod = coroutine.create(producer)
local cocons = coroutine.create(consumer)
print('initial len = '..redis.call('LLEN', joblist))
for i=1, 10 do
ret, msg = coroutine.resume(coprod)
print(' ret ='..tostring(ret)..', produced = '..msg)
print(' len = '..redis.call('LLEN', joblist))
ret, msg = coroutine.resume(cocons)
print(' ret ='..tostring(ret)..', consumed = '..msg)
print(' len = '..redis.call('LLEN', joblist))
end
return outputProducer-Consumer problem is canonical programming example of cooperation. A fast producer incurs backlog; a fast consumer incurs starvation. In our example, we use a random n to control number of jobs to process, ie. producer pushes n number of 1~9999 into list and yield whereas consumer pulls n numbers out and yield. I deliberately let consumer runs faster than producer, consumer has two yield path, ie. either n is reached or list is empty.
Output:
initial len = 3,
ret =true, produced = 4, len = 7,
ret =true, consumed = 7/11, len = 0,
ret =true, produced = 1, len = 1,
ret =true, consumed = 1, len = 0,
ret =true, produced = 6, len = 6,
ret =true, consumed = 6/14, len = 0,
ret =true, produced = 9, len = 9,
ret =true, consumed = 9/11, len = 0,
ret =true, produced = 10, len = 10,
ret =true, consumed = 6, len = 4,
ret =true, produced = 4, len = 8,
ret =true, consumed = 8/13, len = 0,
ret =true, produced = 8, len = 8,
ret =true, consumed = 5, len = 3,
ret =true, produced = 5, len = 8,
ret =true, consumed = 5, len = 3,
ret =true, produced = 8, len = 11,
ret =true, consumed = 3, len = 8,
ret =true, produced = 9, len = 17,
ret =true, consumed = 15, len = 2Ideally, producer and consumer are working synchronously but in practical producer are always faster and more than one consumers should be employed. Coroutine was once an important concept where computer was expensive and multitasking operating system was not popular. Later on coroutine was replaced by multithreading and one has reminisced it ever since.
3.Metatables and Metamethods
Every value in Lua can have a metatable. This metatable is an ordinary Lua table that defines the behavior of the original value under certain events. You can change several aspects of the behavior of a value by setting specific fields in its metatable. For instance, when a non-numeric value is the operand of an addition, Lua checks for a function in the field
__addof the value's metatable. If it finds one, Lua calls this function to perform the addition.The key for each event in a metatable is a string with the event name prefixed by two underscores; the corresponding value is called a metavalue. For most events, the metavalue must be a function, which is then called a metamethod. In the previous example, the key is the string "
__add" and the metamethod is the function that performs the addition. Unless stated otherwise, a metamethod can in fact be any callable value, which is either a function or a value with a__callmetamethod.You can query the metatable of any value using the getmetatable function. Lua queries metamethods in metatables using a raw access (see rawget).
You can replace the metatable of tables using the setmetatable function. You cannot change the metatable of other types from Lua code, except by using the debug library (§6.10).
Metatable is used to define how operations on tables should be carried out in regards to adding, subtracting, multiplying, dividing, concatenating, or comparing tables.
Lua is not an OOP language and it doesn't allow you to define classes but you can fake it using tables and metatables.
See more on Lua Tutorial
By default, Redis uses RESP2 to communicate with clients, A call to HGETALL returns array of string, like so:
id,42,name,Alberto Iong,role,adminBy enabling RESP3, Redis will return a javascript object.
resp3.lua
--[[
HSET users:001 id 42 name "Alberto Iong" role admin
]]
redis.setresp(3)
local user = redis.call('HGETALL', 'users:001')
return userOutput:
{ id: "42", role: "admin", name: "Alberto Iong" }Use metatable to change the default behaviour.
metatable.lua
-- Step 1: Define User prototype
local User = {}
-- Step 2: Custom __index function
User.__index = function(table, key)
return table['map'][key]
end
-- Optional method: custom string representation
function User:__tostring()
return "👤 " .. (self.name or "Unknown") .. " [" .. (self.role or "guest") .. "] [" .. (self.id or 'N/A') .. "]"
end
-- Constructor
function User:new(data)
return setmetatable(data, self)
end
redis.setresp(3)
local user = User:new(redis.call('HGETALL', 'users:001'))
--return user.name
return tostring(user)Output:
👤 Iong [admin] [42]See more on How up Setup Redis to use RESP3 – Support Portal.pdf
IV. Bibliography
- Programming in Lua (first edition)
- Lua 5.1 Reference Manual
- Lua Primer
- Lua Online Compiler & Interpreter
- The Castle by Franz Kafka
Epilogue
Lua bears pecular traits of languages which are not derived from C family. Typically, one-based array, parallel assignment and coroutine (協同子程序) are hardly seen on modern programming languages.
Lua borrows syntax from Modula which was descendant of the Pascal language. The begin/doand end syntax to define a block are characteristic infrequently seen.
Table, the only composite data structure, borrows from Lisp and provides unparalleled flexibility to handle structured data.