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.)

#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).