Skip to content

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 } from 'actor-ts/coordination';
const lease = new InMemoryLease({
name: 'my-singleton',
owner: 'instance-1',
ttlMs: 30_000,
});
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.

To use the same lease across multiple InMemoryLease instances within one process, they need to share the registry:

import { InMemoryLeaseRegistry } from 'actor-ts/coordination';
const registry = new InMemoryLeaseRegistry();
const leaseA = new InMemoryLease({
name: 'singleton-x',
owner: 'node-a',
ttlMs: 30_000,
registry,
});
const leaseB = new InMemoryLease({
name: 'singleton-x', // same name
owner: 'node-b',
ttlMs: 30_000,
registry, // same registry → mutually exclusive
});
await leaseA.acquire(); // → true
await leaseB.acquire(); // → false (leaseA holds it)

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

Without an explicit registry, every InMemoryLease gets its own private registry — effectively no mutual exclusion.

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

For deterministic tests, you may want to inject a ManualScheduler into the registry — but the framework’s InMemoryLease currently uses real timers. For very-deterministic lease-timing tests, mock Date.now() and use direct registry manipulation.

import { describe, it, expect } from 'bun:test';
import { TestKit, InMemoryLease, InMemoryLeaseRegistry } from 'actor-ts/testkit';
import { ClusterSingletonManager, Props } 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 registry = new InMemoryLeaseRegistry();
const lease1 = new InMemoryLease({
name: 'singleton', owner: 'n1', ttlMs: 30_000, registry,
});
const lease2 = new InMemoryLease({
name: 'singleton', owner: 'n2', ttlMs: 30_000, registry,
});
// (cluster wiring elided)
tk1.system.actorOf(
ClusterSingletonManager.props({
cluster: cluster1, typeName: 's', singletonProps: ..., lease: lease1,
}),
'singleton-manager-s',
);
tk2.system.actorOf(
ClusterSingletonManager.props({
cluster: cluster2, typeName: 's', singletonProps: ..., lease: lease2,
}),
'singleton-manager-s',
);
// 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.