SCUA

News

SCUA 0.30.0 is out

September 28, 2026

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's pretty, take only true or false. 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 -> int that falls off its end now stops there; add the return, 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_module is 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.