SCUA

News

SCUA 0.29.0 is out

September 25, 2026

A partition now works through several messages in a turn, so one partition collecting from twenty others handles a million messages in about 0.4 seconds. The JIT now compiles loops over number arrays that branch and flip signs, and network connections can give up after a time limit. A record type you declare is checked at every level, whichever file declared it. A few changes are worth reading before you upgrade.

#Partitions now handle several messages per turn

Twenty partitions each send 50,000 messages to one collector, which checks that every sender's messages arrive in order:

partition Collector
  state last = []
  state got = 0
  state bad = 0
  on Start(n) last = fill(n, 0) end
  on Item(s, k)
    if k != last[s] + 1 then bad = bad + 1 end
    last[s] = k
    got = got + 1
    if got == 1000000 then print(`{got} messages, {bad} out of order`) end
  end
end

partition Sender
  on Run(c, s)
    for k in 1:50001 do tell c.Item(s, k) end
  end
end

let c = Collector()
tell c.Start(20)
for s in 0:20 do
  let p = Sender()
  tell p.Run(c, s)
end
1000000 messages, 0 out of order

The whole program takes about 0.4 seconds on an M4 Max laptop. A partition with mail waiting keeps taking messages until it has worked through a batch or a handler has to wait for something, and only then gives the next partition a turn. That is the shape of a busy server, where one partition hears from many others. Messages from one sender still arrive in the order they were sent.

#The JIT now speeds up loops over number arrays

This loop moves 100,000 particles for 200 steps and bounces any that leave the box:

let n = 100000
let w = { x = [], v = [] }
for i in 0:n do
  w.x.push(i * 0.5)
  w.v.push(((i * 7919) % 200 - 100) * 0.01)
end

for step in 0:200 do
  for i in 0:n do
    let v = w.v[i]
    if w.x[i] > 50000.0 or w.x[i] < 0.0 then v = -v end
    w.v[i] = v
    w.x[i] = w.x[i] + v
  end
end

let total = 0.0
for i in 0:n do total = total + w.x[i] end
print(`average position {total / n}`)
average position 24998.755952

With the JIT on it runs in 0.02 seconds on the same laptop, about 25 times faster than with the JIT off. The arrays can sit in a table field, as they do here and as a partition's state arrays do, and the loop body can be mostly one if. The JIT compiles the loop even when the branch inside has not run yet, which is the usual case for a bounce or an error check.

#Network connections now have time limits

Connecting gives up after 30 seconds by default, and that includes the TLS handshake. timeout_ms sets a different limit for one call, and 0 asks for none. Reads and writes wait for as long as it takes, because some connections are meant to stay idle for a long time, until you set a limit on the connection with net.set_timeout:

import net

fn fetch(host, port)
  let conn = net.dial(host, port, { timeout_ms = 2s })?
  net.set_timeout(conn, 500ms)?
  net.write(conn, "PING\r\n")?
  return net.read(conn, 1024)
end

match fetch("127.0.0.1", 47123)
  Ok(reply) -> print(`reply: {reply}`)
  Error(why) -> print(`gave up: {why}`)
end

Against a server that accepts the connection and never answers:

gave up: net.read: timed out after 500ms

Running out of time is an ordinary error you can handle, and a connection whose read timed out can still be used.

#Records now check every level of the types you declared

A module declares an invoice, with a rule on each of its lines:

-- invoice.scua
record Line { sku: string, qty: int where qty > 0 }
record Invoice { id: string, lines: { Line } }
return {}

A program that decodes JSON into invoice.Invoice has every line checked against that rule:

import invoice
import json

fn load(text)
  let inv: invoice.Invoice = json.decode(text)?
  return Ok(inv)
end

let good = "{\"id\": \"A-17\", \"lines\": [{\"sku\": \"pen\", \"qty\": 3}]}"
let bad = "{\"id\": \"A-18\", \"lines\": [{\"sku\": \"pen\", \"qty\": 3}, {\"sku\": \"ink\", \"qty\": 0}]}"

for text in [good, bad] do
  try
    match load(text)
      Ok(inv) -> print(`{inv.id}: {len(inv.lines)} line(s)`)
      Error(e) -> print(`not JSON: {e}`)
    end
  rescue e
    print(e.message)
  end
end
A-17: 1 line(s)
field 'qty' of invoice.Line violates its `where` constraint (got 0)

The check reaches every nested record, whether the value came from JSON or was written out in code, and whether the types were declared in this file, in the module you imported, or in a module that module imports. A module's record can also be given a local name with type Line = invoice.Line, and an enum case that carries one of a module's records can be built from any file.

A parameter with a declared type is now checked while the program runs as well. fn f(x: int) stops at the call if x arrives holding a float or a string, however it got there.

#Smaller additions

hash.hmac computes the keyed signature behind two-factor login codes, JWT and webhook signatures, with SHA-1, SHA-256, SHA-384 or SHA-512. This is the RFC 6238 test code, computed from its published secret:

import hash
import bytes
import math

-- The RFC 6238 test secret, 59 seconds after the epoch: counter 1.
let mac = hash.hmac("sha1", "12345678901234567890", bytes.from_array([0, 0, 0, 0, 0, 0, 0, 1]))
let off = mac[19] & 15
let code = ((mac[off] & 127) << 24) | (mac[off + 1] << 16) | (mac[off + 2] << 8) | mac[off + 3]
print(code % 100000000)

print(math.checked_add(9223372036854775807, 1))
94287082
Error(math.checked_add: the result does not fit in an int)

Int arithmetic wraps around at the edge of its range. math.checked_add, checked_sub and checked_mul return an error when the result does not fit, for sizes and offsets that come from a file or the network. math also has log1p, expm1, nextafter and float_epsilon.

Slicing and searching a bytes value costs only the part you ask for, so a large buffer can be read piece by piece, and bytes.u8 reads a single byte alongside u16le and the other fixed-width readers.

str.upper, str.lower and the letter tests now work in every script: str.lower("ÉCOLE") is "école". For words a protocol defines in ASCII, such as header names and HTML tags, use the new str.ascii_lower and str.ascii_upper, which change only A to Z.

#What changes when you upgrade

  • A float that holds a whole number now seeds rand.seed exactly like the int, so rand.seed(1.0) gives the same numbers as rand.seed(1). It used to give an unrelated sequence. A fraction, NaN or infinity is now an error.
  • str.upper, str.lower, str.title and the letter tests used to change and recognise English letters only. They now handle every script, and a few letters fold onto plain ASCII ones: İ lowercases to i. Compare protocol words with str.ascii_lower.
  • A parameter's declared type used to be checked only at calls the compiler could see. It is now checked while the program runs. Caught with try, the error is a record, so read e.message.
  • A call to a function that a module re-exports used to compile with arguments missing, and the missing ones were nil. It is now checked like any other call. To keep short calls working, give the trailing parameters defaults: fn text(s, status = nil).
  • A partition saved with actors.freeze or a hibernation snapshot by 0.28.0 cannot be loaded by 0.29.0, and the attempt says so. Durable saves are not affected.
  • Inside filter and with, a variable of your own that is also a column name used to be read as the variable. It is now an error that names both spellings; write row.region for the column.
  • Declaring the same state name twice in one partition used to compile, and the second replaced the first. It is now an error.
  • A program holding nested data its own record types say is invalid used to run. It now stops with the where error.
  • The order in which output from different partitions interleaves may differ from 0.28.0, which never promised one.

#What's next

Partitions share nothing and talk only by message, and that is what lets them run on several cores at once. Making that the default is next, once this release has had some real use.

The full changelog has the rest.