SCUA

How-to

Hibernate a session

A long-lived session — an agent conversation, a player, a workflow — spends almost all of its life waiting for the next message. Keeping a warm process (or container) alive for every idle session costs tens to hundreds of megabytes each, around the clock. SCUA's alternative: freeze the session to a blob, drop it from memory entirely, and rehydrate it when something happens. While it waits, a session costs a few hundred kilobytes of object storage and zero compute — and when it wakes, it resumes exactly where it left off.

This works because a partition already is one relocatable blob (that's Persist and migrate); hibernation is that machinery pointed at a session lifecycle. The session script needs nothing special — it never learns it was frozen.

Everything on this page is driven from a C host through include/scua.h, and all of it runs in the worked example examples/embed_hibernate.c (zig build embed-hibernate from a source checkout). If you haven't embedded SCUA before, read Embed SCUA in a host program first.

#The session is an actor — and an actor freezes whole

A session lives naturally as an actor: a handler function plus its private state, driven one message at a time.

fn handler(state, msg)
  let n = state.count + 1
  print(`turn {n}: "{msg}"`)
  return { count = n, last = msg }    -- the returned state is what gets frozen
end

fn start()
  let a = actor(handler, { count = 0, last = "" })
  tell(a, "hello")
end

Two C calls freeze and thaw it:

  • scua_persist_actor(p, actor_id, &blob, &len) serializes one actor — its state, its mailbox bookkeeping, and (see below) even a call it was parked on — into a self-contained blob. It's a snapshot, not a teardown: the live actor keeps running if you want it to.
  • scua_load_actor(p, blob, len) loads that blob into a partition with the same script loaded, and returns the actor's new id.

The blob carries data, never code. On rehydration you load the same script first (scua_load_script), then the actor blob. Capability grants and the I/O platform are host-side state and don't persist either — re-grant and re-install them as part of rehydration. If your script declares record types, the blob is stamped with their schema fingerprint and a mismatched program refuses the load (-SCUA_ERR_FORMAT) instead of misreading frozen state.

#Idle hibernation: freeze between turns

The everyday case. After the actor has handled its mail (a turn boundary), freeze it, write the blob to your store, and free the whole partition:

/* ...the session handled its message (scua_pcall / scua_io_poll ran to quiescence)... */
uint8_t *blob; size_t len;
scua_persist_actor(p, session_actor_id, &blob, &len);
store_put(session_id, blob, len);          /* S3, a KV store, a file — your call */
scua_blob_free(blob, len);
scua_partition_free(p);                    /* the session is now 0 bytes resident */

Hours later, a message arrives for that session:

scua_partition *p = scua_partition_new();
scua_load_script(p, SESSION_SCRIPT, strlen(SESSION_SCRIPT), 0, 0, err, sizeof err);
int id = scua_load_actor(p, blob, len);    /* state, count, history — all back */
scua_actor_tell_text(p, id, "are you still you?");   /* queue the message (runs nothing yet) */
scua_io_poll(p, err, sizeof err);                    /* run the turn */
/* ...freeze again, drop again... */

The handler sees state.count == 1 and answers turn 2. It cannot tell that a freeze, a file, and a fresh partition happened in between — that's the entire property.

Mid-call hibernation: don't wait resident, wake with the answer

The sharper case: the handler asks a slow question — model inference, a long job — through a parked capability call (the embedder async platform):

fn handler(state, question)
  match model.ask(question)          -- parks; the host performs the real call
    Ok(answer)  -> use(answer)
    Error(e)    -> reconcile(e)      -- "interrupted": see the snapshot-survival page
  end
end

model here is the host's own module. Register ask with scua_register_module, and inside the native call scua_return_pending(ctx, "model", "ask", idempotent, safe_echo): when it returns 1 the turn has parked exactly as http.get parks under the platform, with the same submit callback, the same token completes, and the same wake calls below; when it returns 0 parking isn't available (no platform, or not a handler turn), so take a blocking path instead. Built-in capability calls (http.get, fs.read, …) park the same way with no extra work. And one more thing to re-establish on rehydration, alongside grants and the platform: re-register your modules — natives are process state and never ride the blob; one you don't re-register resolves nil and fails cleanly if called.

If the answer takes 30 seconds, holding a resident partition for 30 seconds of waiting defeats the point. Instead: freeze the actor mid-park (scua_persist_actor works exactly the same while a call is parked — the park travels in the blob), drop everything, and when your answer arrives, rehydrate and deliver it into the parked call:

/* the answer you kicked off out-of-band has landed; you own it */
int id = scua_load_actor(p, blob, len);          /* same script + grants + platform first */

uint64_t park;
scua_io_parks(p, &park, 1);                       /* the reloaded, waiting call — exactly 1 here */
scua_io_park_read(p, park, mod, sizeof mod, op, sizeof op, echo, sizeof echo);  /* optional: "model ask" */

scua_io_wake_http(p, park, 200, answer, strlen(answer));  /* hand it the result you hold */
scua_io_poll(p, err, sizeof err);                 /* the handler resumes in Ok(answer) */

The handler wakes up inside its Ok(answer) arm as if the call had taken five milliseconds, when in fact it hibernated through the wait — possibly on a different machine.

The one rule: wake before you poll. A reloaded parked call is governed by the reload rule: the first drain — any scua_io_poll, or a scua_pcall that runs turns — settles it as Error("interrupted"), permanently, and a later wake is a silent no-op (your script then pays for the operation twice via its reconcile arm). scua_load_actor and scua_actor_tell_text don't drain, so the safe rehydration order is always: load → wake → poll. All the scua_io_wake_* result shapes mirror scua_io_complete_* (_http, _text, _bytes, _nil, _error), and a duplicate wake after settlement is a defined no-op — retrying hosts are safe.

Only wake with a result you own. Waking is you asserting "this operation completed, and this is its outcome". That's true when your process armed the call, or when you read the outcome from a durable job store you share with whoever did. If you can't establish that — a different host picked up the blob, the job's fate is unknown — don't fabricate an Ok. Just poll: the handler gets Error("interrupted") and its reconcile arm asks the durable side what really happened, which is the always-safe default. (A held failure is still a result — deliver it with scua_io_wake_error rather than leaving the script to guess.)

#Editing your code while sessions are frozen

A frozen session holds references to your functions, so the obvious worry is what happens when you deploy. Mostly: nothing. Adding, removing, renaming and reordering functions all leave saved sessions loadable, and editing a function's body binds them to the new code — that's the point of hibernation, and it's what makes a bugfix deploy safe with sessions on disk.

Where a save genuinely can't be honoured, the load refuses and names the function rather than reporting the file as corrupt:

`restock` is not in this program — it was renamed or removed since the save was written

One asymmetry is worth knowing. Editing a named function's body is fine. Editing an anonymous one — let f = fn(x) ... end — invalidates saved references to it:

fn restock(shop)  -- edit this body freely; saved sessions rebind to it
  ...
end

let pick = fn(item) return item.qty end   -- editing THIS body invalidates saves that hold it

An anonymous function has no name to be recognised by; it's identified by its position among its siblings. So a save can't prove which of two lambdas it meant, and guessing is how you end up running the wrong one. If a function is stored in session state and you expect to edit it, give it a name.

Records are checked by name. A saved session or actor records each record type it holds by the module that declares it and its name, and the load finds each one in the new program the same way, so reordering declarations or adding unrelated records changes nothing. A record type the save holds whose fields changed, or that was renamed or moved to another module, refuses the load, naming it and showing the saved and current field lists:

record `Cart` changed its fields since this save: saved {items:{Item};total:int;}, now {items:{Item};total:int;coupon:string?;}

Finally, a session that was paused inside a function you then edited has that paused turn dropped — the session itself lives, and the load tells you how many turns it dropped. A paused turn can't resume under a changed body, because it would continue from a position in code that no longer exists. If your data can't tolerate a dropped turn (a half-applied transfer, say, rather than a wave timer), ask for the load to be refused instead.

What a load did, in numbers. After scua_load_actor, or scua_load_script over a partition from scua_load, scua_last_load_report fills a scua_load_report: functions matched exactly, rebound onto edited code (and across how many distinct functions), paused turns dropped, record types rebound by name, and new state fields filled in. The same line goes to your log sinks at info. Log it per load and aggregate it per deploy: a count that is not zero where you expected zero is the first thing to look at when behaviour changes after a release.

scua_load_report r = { .struct_size = sizeof r, .version = 1 };
if (scua_last_load_report(p, &r) == SCUA_OK)
    log_info("load: %u rebound, %u turns dropped, %u record types", r.functions_rebound,
             r.turns_discarded, r.types_rebound);

To see what a blob holds before you deploy anything, run scua blob-info on it (see the CLI reference).

#What it costs — honestly

Measured on the real C-ABI path (zig build bench-hibernate, Apple-silicon dev machine, local-file store; your store's PUT latency will dominate for big sessions):

history (records) blob at rest freeze (compact+serialize) rehydrate (load actor)
16 ~95 KB ~0.2 ms ~0.09 ms
4,096 ~1.1 MB ~0.5 ms ~0.15 ms
32,768 ~8 MB ~2.6 ms ~0.65 ms

Three things to know before you build on it:

  • Freezing costs O(accumulated state), not O(the last turn). Every freeze recompacts the session's whole live set. A long conversation pays more per message to freeze — budget for it, or freeze on idle rather than after every turn.
  • The store round-trip is the real suspend cost. Compaction is sub-millisecond to a few milliseconds; a network PUT of a multi-megabyte blob is not. Count both.
  • Resume also recompiles the script (code never rides the blob). It's fast (~0.2 ms above), and a host that rehydrates many sessions of the same script amortizes it naturally.

Against the alternative — tens to hundreds of MB of warm process per idle session — the blob-at-rest column is the whole argument.

#See also

  • Write handlers that survive snapshots — the reload rule and the reconcile-on-resume habit; mid-call hibernation is that page's happy path, and its Error("interrupted") arm is your safety net whenever a wake isn't possible.
  • Persist and migrate — the persistence model this rides on (one relocatable blob, no custom serialization).
  • Embed SCUA in a host program — the C-ABI basics, grants, and the async I/O platform used here.
  • examples/embed_hibernate.c — both flows above, runnable end to end (zig build embed-hibernate).