AbsoluteJS

Pool hibernation

@absolutejs/isolated-jsc ships createHibernatingIsolatePool (introduced in 0.9.0, extended through 0.11.0): a keyed pool of isolate + context pairs that hibernates idle entries by checkpointing the context's data half via the existing context.checkpoint() primitive, then wakes them transparently on the next call by passing the checkpoint back through isolate.createContext({ checkpoint }). Far more "tenant logical contexts" than physical isolates, because the warm ones get serialized down to bytes when no one's calling them.

#Why hibernation

The existing createIsolatePool keeps active isolates around for the lifetime of the key. That's the right call for a hot tenant set, but it caps total tenancy at the host's physical isolate budget — at scale, "100 active tenants per process" stops being interesting. Hibernation lets the pool hold N tenants logically while spending isolate budget only on the ones currently calling. The idle tenants live as serialized checkpoints in a pluggable store; their next call pays an isolate spawn + a structured-clone restore (typically faster than a full warm-up), nothing more.

This is the SB-7 substrate for the eventual hosted Cloud bet. Same model AWS Lambda uses for "init code runs once per cold start" — the init (your seed) re-runs cheaply because the data is already there.

#The basic shape

pool.run(key, fn) resolves an active context — waking from a hibernated checkpoint, spawning fresh, or reusing as needed — and runs fn(context) with it. The signature matches the existing pool's run(key, fn) except the callback receives a Context, not an Isolate: the pool manages context lifecycle so it can checkpoint and restore in one atomic operation. pool.stats() returns { active, hibernated, total } and is synchronous + cheap.

TS
// @absolutejs/isolated-jsc — createHibernatingIsolatePool (0.9.0+).
import { createHibernatingIsolatePool } from '@absolutejs/isolated-jsc';

const pool = createHibernatingIsolatePool({
  isolate: { backend: 'worker' },
  maxSize: 1000,            // up to 1000 (active OR hibernated) keys
  hibernateAfterMs: 30_000, // idle 30s → hibernate
});

// First call to a key spawns a fresh isolate + context.
const initial = await pool.run('tenant-42', async (context) => {
  const fn = await context.compileCallable(`(args) => {
    this.count = (this.count || 0) + args.delta;
    return this.count;
  }`);
  return await fn.call([{ delta: 1 }]);
});
// initial === 1

// 30 seconds later (with no calls in between), the sweep checkpoints
// the context's data ({ count: 1 }) and disposes the isolate. The next
// call wakes from the checkpoint — `this.count` is restored to 1.
const resumed = await pool.run('tenant-42', async (context) => {
  const fn = await context.compileCallable(
    `(args) => this.count + args.delta`,
  );
  return await fn.call([{ delta: 1 }]);
});
// resumed === 2

pool.stats(); // { active: 1, hibernated: 0, total: 1 }

#Data vs heap boundary

This is not a JavaScriptCore heap pause/resume image. SNAPSHOT_RESEARCH.md in the package documents the boundary at length: the public JSC C API does not expose a stable pause/resume primitive for JSGlobalContextRef heap state, the call stack, the JIT cache, or closure graphs. What it does expose, and what hibernation uses, is structured-cloneable data on the context's globalThis.

Concretely: your callables RECOMPILE on wake (same as a fresh spawn — but the data they read is already there). Host Reference bindings need reinstalling via setGlobal. Pending promises and in-flight async state do not survive. Anything you can pass through structuredClone does.
TS
// What hibernation captures vs. what it doesn't.
//
// The pool calls `context.checkpoint(options?)` to serialize the
// CONTEXT'S DATA HALF — every structured-cloneable own property on the
// context's globalThis. On wake, the next call gets a fresh isolate
// whose context is seeded with that data via `createContext({ checkpoint })`.
//
// What it CAN restore:
//   - Plain JS values: numbers, strings, booleans, dates, arrays, maps,
//     sets, typed arrays, deeply nested objects.
//   - Anything that crosses structured-clone (the same rules postMessage
//     uses).
//
// What it CANNOT restore:
//   - Compiled callables, Script objects, References — code lives in
//     JIT'd machine state that doesn't survive isolate death. Your fn
//     RECOMPILES on wake (same as a fresh spawn).
//   - Pending promises, closure graphs, the call stack — anything that
//     references live JSC heap objects. SNAPSHOT_RESEARCH.md documents
//     this in detail: the public JSC C API doesn't expose a stable
//     heap pause/resume primitive.
//   - Host `Reference` callbacks installed via setGlobal — the host
//     side of the Reference still exists in your process, but the
//     sandbox-side binding is gone on the fresh isolate. Reinstall.
//
// The data/code split is the same model AWS Lambda uses for
// "init code runs once per cold start." Hibernation = fast cold start
// because the data is already there.

#Wake + claim semantics

N concurrent calls to a hibernated key share one wake — no thundering-herd of isolate spawns racing to restore the same checkpoint. The pool holds a single-flight promise on the entry and all callers await it.

Each pool.run atomically claims an in-flight slot as part of resolution, before the caller's first chance to await the context. A concurrent pool.hibernate(key) cannot race in between resolution and use; it waits for the in-flight counter to settle before checkpointing.

TS
// Single-flight wake. N concurrent calls to a hibernated key share one
// spawn — you don't get N isolates racing to restore the same
// checkpoint.
await Promise.all([
  pool.run('tenant-42', readAndBumpCounter),
  pool.run('tenant-42', readAndBumpCounter),
  pool.run('tenant-42', readAndBumpCounter),
]);
// All three calls saw the SAME context. The pool only allocated one
// isolate. Order of finally-block updates is non-deterministic, but
// each call sees a coherent snapshot of `this.*`.

// Atomic in-flight claim. A concurrent `pool.hibernate(key)` cannot
// race in between `run()` resolving the active entry and the body
// touching the context — the pool bumps an in-flight counter
// atomically as part of resolution. The hibernate call waits for
// in-flight to settle before checkpointing.
const work = pool.run('tenant-42', longRunningJob);
const hibernation = pool.hibernate('tenant-42');
// 'hibernation' resolves AFTER 'work' — checkpoint includes the final
// state from longRunningJob.

#Pluggable storage

HibernationStore is a three-method interface (get, put, delete). The default is createInMemoryHibernationStore() (one process). For production, wrap your persistent store (Redis, S3, local file cache, a database) and pass it as hibernationStore. Stores that lose the checkpoint between hibernate and wake fall back to fresh-spawn rather than throwing — TTL expiry, network partition, manual eviction all degrade safely.

pool.dispose() does not clear the store — it may be shared with other processes. Purge externally if you need to.
TS
// Pluggable storage. The default is in-memory (one process). For
// production you typically want a persistent store so warm contexts
// survive a deploy.
import type { HibernationStore, ContextCheckpoint } from '@absolutejs/isolated-jsc';

const redisStore: HibernationStore = {
  get: async (key) => {
    const raw = await redis.get(`isolated:${key}`);
    return raw === null ? undefined : JSON.parse(raw) as ContextCheckpoint;
  },
  put: async (key, checkpoint) => {
    await redis.set(`isolated:${key}`, JSON.stringify(checkpoint), 'EX', 86_400);
  },
  delete: async (key) => {
    await redis.del(`isolated:${key}`);
  },
};

const pool = createHibernatingIsolatePool({
  hibernateAfterMs: 30_000,
  hibernationStore: redisStore,
});

// `pool.dispose()` does NOT delete hibernated checkpoints from the
// store — the store may be shared with other processes. To purge,
// iterate through your store externally.

// If your store loses the checkpoint between hibernate and wake (TTL
// expiry, manual eviction, network partition), the pool falls back to
// fresh-spawn instead of throwing. The key is treated as new.

#Observability

onTransition fires on every hibernate, wake, and evict event. Errors thrown from the hook are caught + ignored (it's observational only). Pair it with pool.stats() for a complete picture: events for derivatives (rates, counts), stats for the current snapshot.

TS
// Observability hook. Useful when you want to ship hibernate/wake
// events to your metrics sink (Prometheus, Datadog, OTLP, …).
import type { HibernationEvent } from '@absolutejs/isolated-jsc';

const pool = createHibernatingIsolatePool({
  hibernateAfterMs: 60_000,
  onTransition: (event: HibernationEvent) => {
    switch (event.type) {
      case 'hibernate':
        metrics.observe('hibernate_byte_length', event.byteLength);
        metrics.increment('hibernate_count', { key: event.key });
        break;
      case 'wake':
        metrics.increment('wake_count', { key: event.key });
        break;
      case 'evict':
        metrics.increment('evict_count', {
          key: event.key,
          from: event.from, // 'active' | 'hibernated'
        });
        break;
    }
  },
});

// pool.stats() — synchronous snapshot, cheap to read.
const { active, hibernated, total } = pool.stats();
//
// total <= maxSize at all times. LRU eviction drops hibernated entries
// before active ones, so you usually shed cheap state (checkpoints in
// the store) before expensive state (live isolates).

#Operator surface (0.10.0 and 0.11.0)

The 0.9.0 API above is unchanged — run, hibernate, stats, dispose, onTransition, and the pluggable store all behave the same. 0.10.0 and 0.11.0 are additive: an operator-shaped read for the PaaS host's metering loop, graceful shutdown, predictive pre-warm, and OpenTelemetry tracing.

AdditionWhat it does
pool.metrics()0.10.0Operator-shaped snapshot: point-in-time active / hibernated / total / inFlight / draining plus cumulative hibernations, wakes, evictions, and bytesHibernated since pool start, and lastWakeMs as a coarse SLO signal.
pool.drain()0.10.0Refuses new keys while active and hibernated entries keep serving existing callers. For graceful shard shutdown: drain, wait for stats().total === 0, then dispose().
pool.warm(key)0.10.0Materializes an active context ahead of expected work — wake from hibernation or spawn fresh — without invoking user code. Removes the cold-start tail from a tenant’s first request; shares single-flight semantics with run().
tracerProvider0.11.0Optional pool option accepting any @opentelemetry/api-compatible provider (structural type via @absolutejs/telemetry, no peer-dep). Emits an isolated_jsc.run span per pool.run(key, fn) with abs.tenant, isolated_jsc.woke_from_hibernation, and isolated_jsc.wake_ms attributes. Zero-cost when omitted.

metrics() feeds a metering loop directly: bytesHibernated is storage cost, wake counts with lastWakeMs flag wake-latency tail risk, and the eviction rate flags hot/cold churn. The non-hibernating IsolatePool gained the same metrics() / drain() pattern in 0.10.0, with spawns, idleEvictions, lruEvictions, and recycles counters. With tracerProvider set, the other pool methods keep emitting through onTransition — OTel wiring stays focused on run so a customer trace has one span per work invocation.