With --io=async, network calls in SCUA 0.30.0 now wait
at the same time, so four queries on four pooled connections come back
in the time of one. Scripts and the tasks they spawn can wait on the
network alongside each other too, and --workers=auto picks
how many cores your partitions run on. There are also RSA and ECDSA
signatures, AES encryption, and new ways to write Results and records,
which have their own post. A few
changes are worth reading before you upgrade.
#Network calls now wait alongside other work
A partition that keeps a pool of connections, to a database or a cache, can now send a query on each and wait for all the answers at once:
import net
import bytes
import str
import sys
-- A small pool of connections to a server that takes 300 ms to answer each query.
partition Db
state pool = []
on Open(port, size)
for i in 0:size do
match net.dial("127.0.0.1", port)
Ok(conn) -> pool.push(conn)
Error(e) -> print(`dial failed: {e}`)
end
end
end
on Report()
let queries = ["users", "orders", "stock", "prices"]
let arms = []
for i in 0:len(queries) do
let conn = pool[i]
let q = queries[i]
arms.push(fn()
net.write(conn, `{q}\n`)?
return bytes.to_string(net.read(conn, 64)?)
end)
end
for reply in wait_all(arms) do
match reply
Ok(text) -> print(str.trim(text))
Error(e) -> print(`failed: {e}`)
end
end
end
end
let db = Db()
tell db.Open(tonumber(sys.args()[0]), 4)
tell db.Report()
$ scua --io=async --allow-net=127.0.0.1 pool.scua 18401
echo:users
echo:orders
echo:stock
echo:prices
The server here is a small test server on the same machine, and it
waits 0.3 seconds before each answer. With --io=async the
whole run takes 0.32 seconds on an Apple M4 Max, because a
net.read with nothing to read yet parks the arm that made
it and the other three carry on. Without the flag, each read holds the
program until its answer arrives, and the same run takes 1.23
seconds.
net.dial parks while it connects, and
net.write parks while the other end isn't taking data. Ten
dials that each waited a full second to connect (to an address that
never answers, with a one-second limit) finished together in 1.01
seconds. Reads and writes on a TLS connection park the same way: four
queries, each on its own TLS connection to the same kind of slow server,
took 0.34 seconds. The TLS handshake itself still runs in place.
Calls on one connection in the same direction still finish in the
order they were made, so two readers of one connection get the same
bytes they would without the flag. A time limit set with
net.set_timeout counts real time.
#Scripts and spawned tasks can now wait at the same time
The top level of a script can now overlap its waits as well, and so
can the tasks it starts with spawn. That covers the usual
shape of a small tool, which calls out to a few services and prints what
comes back:
import net
import bytes
import str
import sys
let port = tonumber(sys.args()[0])
fn lookup(name)
let conn = net.dial("127.0.0.1", port)?
net.write(conn, `{name}\n`)?
let reply = bytes.to_string(net.read(conn, 64)?)?
net.close(conn)
return Ok(str.trim(reply))
end
let names = ["alpha", "beta", "gamma", "delta", "epsilon"]
for r in map_all(names, lookup) do
match r
Ok(text) -> print(text)
Error(e) -> print(`failed: {e}`)
end
end
$ scua --io=async --allow-net=127.0.0.1 fetch_all.scua 18401
echo:alpha
echo:beta
echo:gamma
echo:delta
echo:epsilon
Five lookups against the same 0.3-second server take 0.32 seconds.
Three spawned tasks that each make one round trip finish in
0.32 seconds, and the same holds for http and
exec: three http.get calls to a page that
takes 0.3 seconds, or three sleep 0.3 commands, finish in
0.33 seconds.
Tasks that run at the same time resume in the order their I/O
finishes, so lines they print can come out in a different order from run
to run. The values are the same. If a test compares printed output,
collect the results with wait_all or map_all
and print them in one place, as the example above does.
--workers=auto
picks how many cores to use
Partitions share nothing and talk only by message, so SCUA can run
them on several threads at once. --workers=auto asks for
one thread per full-speed core, up to 8. It leaves out the efficiency
cores on Apple silicon, and inside a Linux container it stays within the
container's CPU limit. For a run you can't add a flag to, set
SCUA_WORKERS to a number or to auto.
Eight partitions that each count the primes in half a million numbers
finish in 0.35 seconds with --workers=auto, against 1.61
seconds on one thread. On this Mac, which has 10 performance cores and 4
efficiency cores, auto means 8 threads. A program where two
partitions pass one message back and forth has nothing to gain from more
threads, and it costs about the same on any number of them:
-- Two partitions pass one message back and forth, 200,000 times in all.
partition Player
state other = nil
on Meet(p) other = p end
on Ball(n)
if n == 200000 then
print(`{n} messages`)
else
tell other.Ball(n + 1)
end
end
end
let a = Player()
let b = Player()
tell a.Meet(b)
tell b.Meet(a)
tell a.Ball(1)
$ scua --workers=auto pingpong.scua
200000 messages
That takes 0.08 seconds on one thread, and 0.1 seconds on four
threads, on all fourteen, or with auto. A round of work
wakes only as many threads as there are partitions with something to
do.
The default is still one thread, where every run prints the same lines in the same order. With more than one, messages from one sender still arrive in the order they were sent, but lines printed by different partitions can interleave differently from run to run.
#Smaller additions
crypto.sign, crypto.verify and
crypto.keypair take the algorithm names JWT uses:
RS256 to RS512, PS256 to
PS512, ES256 and ES384. A script
can check a token signed by Google, Auth0, Okta or Microsoft Entra, and
sign its own:
import crypto
import base64
-- A new ES256 key, the kind a JWT header names as "alg": "ES256".
let key = crypto.keypair(crypto.random_bytes(32), "ES256")
let body = `{base64.encode("{\"alg\":\"ES256\"}", { url_safe = true, pad = false })}.{base64.encode("{\"sub\":\"ana\"}", { url_safe = true, pad = false })}`
let sig = crypto.sign(body, key.secret, "ES256")
print(len(sig))
print(crypto.verify(body, sig, key.public, "ES256"))
print(crypto.verify(body .. "x", sig, key.public, "ES256"))
64
true
false
crypto.aes_gcm_encrypt and
crypto.aes_gcm_decrypt encrypt and authenticate data, and
RSA-OAEP, AES key wrap and ECDH on P-256, P-384 and X25519 cover
encrypted JWTs. hash.equal compares two signatures in
constant time, which is how to check a webhook signature against the one
you compute with hash.hmac.
scua blob-info shows what a saved actor or session
holds, including each record type and its fields, without running
anything. Point it at a save before you deploy a change to a record and
it tells you which types that save depends on.
#What changes when you upgrade
The changes to conditions, Ok and Error as
names, and turning data into records are in the syntax post, each with what to write
now. The rest:
- A saved actor or session from 0.29 or earlier can't be loaded by
0.30. Finish or export it before you upgrade; the changelog has a
step-by-step way to carry actor state across as data. A revive that a
changed record type refuses now reports the kind
identity_refused. - A cluster whose nodes store records in a shared table needs every node upgraded at the same time. Until then, store plain tables, which every release reads.
- Options a built-in reads as a switch, such as
json.encode'spretty, take onlytrueorfalse. Anything else is an error that names the option. - A declared return type is now checked while the program runs, with
the rules parameters already had. A function declared
-> intthat falls off its end now stops there; add thereturn, or declare-> int?. - Two top-level functions with the same name are an error, unless each is a method on a different record type. Rename one.
- For embedders: a module a host registers with
scua_register_moduleis read-only to scripts. Pass a plain table as a value where scripts need to change it.
#What's next
Running partitions on several cores stays something you ask for in
this release. There is more to come on that once
--workers=auto has seen some use.
The full changelog has the rest.