Skip to content
English

InMemoryLease

InMemoryLease is the dev / test implementation of the Lease interface. It holds the lease state in process memory — so multiple InMemoryLease instances sharing the same name mutually exclude correctly, but only within a single process.

import { InMemoryLease, LeaseOptions } from 'actor-ts/coordination';
const leaseOptions = LeaseOptions.create()
.withName('my-singleton')
.withOwner('instance-1')
.withTtlMs(30_000);
const lease = new InMemoryLease(
leaseOptions,
);
await lease.acquire(); // → true (first acquire)
  • Unit tests for actors that take a Lease parameter — pass InMemoryLease to verify lease-related behavior without a real backend.
  • Dev for code that needs a lease but you don’t want to set up Kubernetes or etcd locally.
  • MultiNodeSpec tests — every “node” runs in one process, so an InMemoryLease shared across the test nodes mutually excludes correctly.

Every InMemoryLease in a process competes against one module-global store (inMemoryLeaseStore) — there is no per-instance registry to wire up. So two InMemoryLease instances that share the same name mutually exclude automatically:

const leaseOptions = LeaseOptions.create()
.withName('singleton-x')
.withOwner('node-a')
.withTtlMs(30_000);
const leaseA = new InMemoryLease(
leaseOptions,
);
const lease2Options = LeaseOptions.create()
.withName('singleton-x') // same name
.withOwner('node-b')
.withTtlMs(30_000);
const leaseB = new InMemoryLease(
lease2Options,
);
await leaseA.acquire(); // → true
await leaseB.acquire(); // → false (leaseA holds it)

This is the MultiNodeSpec pattern — every simulated node gets an InMemoryLease for the same name, and they fight for the lease like real distributed peers would.

Because the store is a single module-global, mutual exclusion holds only within one process — separate processes each get their own store and can’t see each other’s leases.

The implementation:

  • acquire() atomically CAS-es the registry slot. Returns true if it claimed; false if held by another owner.
  • TTL expiry — if the holder doesn’t renew within ttlMs, the registry releases automatically (a setTimeout-driven cleanup).
  • onLost fires if another holder takes over (via the TTL expiry mechanism) or if the registry is forcibly cleared.
  • Renewal runs on a setInterval at renewalIntervalMs (default ttl / 3).

InMemoryLease uses real timers (setInterval) and exposes no scheduler-injection hook — there is no ManualScheduler to wire in. For deterministic lease-timing tests, mock Date.now(); to reset shared lease state between tests, clear the exported inMemoryLeaseStore singleton directly (inMemoryLeaseStore._clear()).

import { describe, it, expect } from 'bun:test';
import { TestKit } from 'actor-ts/testkit';
import { InMemoryLease, LeaseOptions } from 'actor-ts/coordination';
import { StartSingletonOptions } from 'actor-ts';
describe('SingletonManager with lease', () => {
it('only one holder spawns the singleton', async () => {
const tk1 = TestKit.create('node-1');
const tk2 = TestKit.create('node-2');
const leaseOptions = LeaseOptions.create()
.withName('singleton')
.withOwner('n1')
.withTtlMs(30_000);
const lease1 = new InMemoryLease(
leaseOptions,
);
const lease2Options = LeaseOptions.create()
.withName('singleton')
.withOwner('n2')
.withTtlMs(30_000);
const lease2 = new InMemoryLease(
lease2Options,
);
// (cluster wiring + singletonActor elided)
const singleton1Options = StartSingletonOptions.create()
.withTypeName('s')
.withActor(singletonActor)
.withLease(lease1);
cluster1.singleton.start(singleton1Options);
const singleton2Options = StartSingletonOptions.create()
.withTypeName('s')
.withActor(singletonActor)
.withLease(lease2);
cluster2.singleton.start(singleton2Options);
// Only one should ever have the singleton actor child.
// ... assert via probes ...
});
});

This kind of test verifies the lease integration without depending on a real K8s API.