Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 56 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ const { Runtime, actor } = require("@js-ak/remote-objects"); // CJS
## Core ideas

- **`runtime.spawn(Class, ...args)`** creates an actor on a worker and returns a typed proxy
- **`runtime.getOrSpawn(key, Class, ...args)`** returns one actor per key (same proxy until `destroy`); placement is `hash(key) % workers`
- **Methods are always async** from the caller’s side (even if the class method is sync)
- **Actors are sticky** — an instance stays on one worker until `destroy` / `dispose`
- **`return this`** becomes an actor reference (same proxy identity), not a cloned object
Expand Down Expand Up @@ -69,7 +70,7 @@ actor(Database, __filename);
module.exports = { Database };
```

`spawn` auto-registers the class on first use (or call `runtime.register(Database)` explicitly).
`spawn` and `getOrSpawn` auto-register the class on first use (or call `runtime.register(Database)` explicitly).

## Options

Expand All @@ -81,7 +82,7 @@ new Runtime({
});
```

Debug events include `register`, `spawn`, `destroy`, `call:start`, `call:end`, `call:timeout`, `worker:error`, `bridge:call`, `bridge:result`, `dispose`. Spawn/call events carry `actorId` as `"workerId:objectId"`.
Debug events include `register`, `spawn`, `destroy`, `call:start`, `call:end`, `call:timeout`, `worker:error`, `bridge:call`, `bridge:result`, `dispose`. Spawn/call events carry `actorId` as `"workerId:objectId"`. **`getOrSpawn` emits the same `spawn` event** when it creates a new actor (cache hits do not spawn again).

## Lifecycle

Expand All @@ -92,7 +93,7 @@ await runtime.dispose(); // close actors, drain, terminate workers
await runtime.dispose({ closeActors: false });
```

After `dispose`, further `spawn` / method calls fail with a clear error.
After `dispose`, further `spawn`, `getOrSpawn`, or method calls fail with a clear error.

## Passing actors as arguments

Expand Down Expand Up @@ -145,6 +146,54 @@ Use sticky actors when a worker should own long-lived state (DB pools, SDK clien

Same-actor calls are serialized (mailbox). Different actors may run in parallel on the pool.

## When to use what

You do not need to predict every app up front — pick a pattern from what you are doing:

| You want… | Use |
|-----------|-----|
| Long-lived client state (DB pool, SDK session, in-memory cache) | One actor per resource; or **`getOrSpawn(key, ...)`** for one actor per tenant/shard |
| More throughput on CPU or I/O | `workers: 2+` and **separate** actor instances (each spawn → least-loaded worker) |
| Strict ordering for one object | One actor — overlapping calls on the same proxy are queued (mailbox) |
| Parallel work on the same class | Multiple `spawn`s, or `getOrSpawn` + extra `spawn`s (see `examples/db.ts`) |
| Progress / one-off handlers during a call | Callback in **args** (released when the call finishes) |
| Long-lived handler returned from a method | Callback in **return value** (released on `destroy` of the owning actor) |
| Many rows or chunked I/O | Streams as args or results (backpressure built in) |
| Compose actors (even on different workers) | Pass actor proxies as method arguments |
| Shut down one resource | `destroy(proxy)` — runs `close`/`dispose` on the actor by default |
| Shut down the whole runtime | `dispose()` — drain in-flight work, then terminate workers |

**Worker count**

- **`workers: 1`** — simplest mental model; all actors share one thread. Good for getting started or when isolation from the main thread is enough.
- **`workers: 2+`** — use when you want parallel actors on separate threads. Each new spawn is placed on the worker with the fewest live actors and in-flight requests; after that the actor stays sticky on that worker.

**One actor vs many**

- **One proxy, many calls** — state and side effects stay in one place; calls do not overlap on that instance.
- **Many proxies, same class** — independent state and true parallelism (e.g. two query actors, two connection pools).

**One actor per tenant / key**

Use **`getOrSpawn(key, Class, ...args)`** — the runtime keeps one proxy per key until `destroy`. The worker is chosen by `hash(key) % workers` and stays stable for that key. Reusing a key with a different class or constructor args throws.

```ts
const db = await runtime.getOrSpawn(`tenant:${tenantId}`, Database, creds);
// later, same process → same proxy / same pool
const same = await runtime.getOrSpawn(`tenant:${tenantId}`, Database, creds);
expect(same).toBe(db);

await runtime.destroy(db); // drops the key; next getOrSpawn creates a fresh actor
```

For **many independent instances** of the same class (parallel pools), use **`spawn`** instead. **`spawn` does not register a key** — `spawn(Database, creds)` and `getOrSpawn("db", Database, creds)` are always separate actors.

**Not a fit**

- Fire-and-forget stateless jobs on a pile of plain data → a task pool (e.g. Piscina) may be simpler.
- Shared mutable state on the main thread with no isolation → you do not need actors at all.
- Moving an existing actor to another worker after spawn → not supported; spawn again if you need a new placement.

## Compared to similar tools

| | remote-objects | Comlink | Piscina |
Expand All @@ -170,6 +219,10 @@ const handle = getActorHandle(counter); // { workerId, objectId } | undefined
- Overlapping calls to the **same** actor are queued (mailbox); different actors may run in parallel
- Actors are not migrated between workers; identity is fixed at spawn
- Circular structures in encoded plain objects are rejected with a clear error
- **`getOrSpawn` keys are per-runtime (in-memory)** — not shared across processes or runtimes
- **`spawn` and `getOrSpawn` use separate registries** — a keyed name does not attach to an actor created with `spawn`
- **Key placement is `hash(key) % workers`** — changing the worker pool size can move a key to a different worker on the next create (after `destroy`)
- **Repeated `getOrSpawn` compares constructor args** using deep equality for primitives, arrays, and plain objects only (not `Date`, `Map`, class instances, etc.)

## License

Expand Down
59 changes: 56 additions & 3 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ const { Runtime, actor } = require("@js-ak/remote-objects"); // CJS
## Core ideas

- **`runtime.spawn(Class, ...args)`** creates an actor on a worker and returns a typed proxy
- **`runtime.getOrSpawn(key, Class, ...args)`** returns one actor per key (same proxy until `destroy`); placement is `hash(key) % workers`
- **Methods are always async** from the caller’s side (even if the class method is sync)
- **Actors are sticky** — an instance stays on one worker until `destroy` / `dispose`
- **`return this`** becomes an actor reference (same proxy identity), not a cloned object
Expand Down Expand Up @@ -69,7 +70,7 @@ actor(Database, __filename);
module.exports = { Database };
```

`spawn` auto-registers the class on first use (or call `runtime.register(Database)` explicitly).
`spawn` and `getOrSpawn` auto-register the class on first use (or call `runtime.register(Database)` explicitly).

## Options

Expand All @@ -81,7 +82,7 @@ new Runtime({
});
```

Debug events include `register`, `spawn`, `destroy`, `call:start`, `call:end`, `call:timeout`, `worker:error`, `bridge:call`, `bridge:result`, `dispose`. Spawn/call events carry `actorId` as `"workerId:objectId"`.
Debug events include `register`, `spawn`, `destroy`, `call:start`, `call:end`, `call:timeout`, `worker:error`, `bridge:call`, `bridge:result`, `dispose`. Spawn/call events carry `actorId` as `"workerId:objectId"`. **`getOrSpawn` emits the same `spawn` event** when it creates a new actor (cache hits do not spawn again).

## Lifecycle

Expand All @@ -92,7 +93,7 @@ await runtime.dispose(); // close actors, drain, terminate workers
await runtime.dispose({ closeActors: false });
```

After `dispose`, further `spawn` / method calls fail with a clear error.
After `dispose`, further `spawn`, `getOrSpawn`, or method calls fail with a clear error.

## Passing actors as arguments

Expand Down Expand Up @@ -145,6 +146,54 @@ Use sticky actors when a worker should own long-lived state (DB pools, SDK clien

Same-actor calls are serialized (mailbox). Different actors may run in parallel on the pool.

## When to use what

You do not need to predict every app up front — pick a pattern from what you are doing:

| You want… | Use |
|-----------|-----|
| Long-lived client state (DB pool, SDK session, in-memory cache) | One actor per resource; or **`getOrSpawn(key, ...)`** for one actor per tenant/shard |
| More throughput on CPU or I/O | `workers: 2+` and **separate** actor instances (each spawn → least-loaded worker) |
| Strict ordering for one object | One actor — overlapping calls on the same proxy are queued (mailbox) |
| Parallel work on the same class | Multiple `spawn`s, or `getOrSpawn` + extra `spawn`s (see `examples/db.ts`) |
| Progress / one-off handlers during a call | Callback in **args** (released when the call finishes) |
| Long-lived handler returned from a method | Callback in **return value** (released on `destroy` of the owning actor) |
| Many rows or chunked I/O | Streams as args or results (backpressure built in) |
| Compose actors (even on different workers) | Pass actor proxies as method arguments |
| Shut down one resource | `destroy(proxy)` — runs `close`/`dispose` on the actor by default |
| Shut down the whole runtime | `dispose()` — drain in-flight work, then terminate workers |

**Worker count**

- **`workers: 1`** — simplest mental model; all actors share one thread. Good for getting started or when isolation from the main thread is enough.
- **`workers: 2+`** — use when you want parallel actors on separate threads. Each new spawn is placed on the worker with the fewest live actors and in-flight requests; after that the actor stays sticky on that worker.

**One actor vs many**

- **One proxy, many calls** — state and side effects stay in one place; calls do not overlap on that instance.
- **Many proxies, same class** — independent state and true parallelism (e.g. two query actors, two connection pools).

**One actor per tenant / key**

Use **`getOrSpawn(key, Class, ...args)`** — the runtime keeps one proxy per key until `destroy`. The worker is chosen by `hash(key) % workers` and stays stable for that key. Reusing a key with a different class or constructor args throws.

```ts
const db = await runtime.getOrSpawn(`tenant:${tenantId}`, Database, creds);
// later, same process → same proxy / same pool
const same = await runtime.getOrSpawn(`tenant:${tenantId}`, Database, creds);
expect(same).toBe(db);

await runtime.destroy(db); // drops the key; next getOrSpawn creates a fresh actor
```

For **many independent instances** of the same class (parallel pools), use **`spawn`** instead. **`spawn` does not register a key** — `spawn(Database, creds)` and `getOrSpawn("db", Database, creds)` are always separate actors.

**Not a fit**

- Fire-and-forget stateless jobs on a pile of plain data → a task pool (e.g. Piscina) may be simpler.
- Shared mutable state on the main thread with no isolation → you do not need actors at all.
- Moving an existing actor to another worker after spawn → not supported; spawn again if you need a new placement.

## Compared to similar tools

| | remote-objects | Comlink | Piscina |
Expand All @@ -170,6 +219,10 @@ const handle = getActorHandle(counter); // { workerId, objectId } | undefined
- Overlapping calls to the **same** actor are queued (mailbox); different actors may run in parallel
- Actors are not migrated between workers; identity is fixed at spawn
- Circular structures in encoded plain objects are rejected with a clear error
- **`getOrSpawn` keys are per-runtime (in-memory)** — not shared across processes or runtimes
- **`spawn` and `getOrSpawn` use separate registries** — a keyed name does not attach to an actor created with `spawn`
- **Key placement is `hash(key) % workers`** — changing the worker pool size can move a key to a different worker on the next create (after `destroy`)
- **Repeated `getOrSpawn` compares constructor args** using deep equality for primitives, arrays, and plain objects only (not `Date`, `Map`, class instances, etc.)

## License

Expand Down
10 changes: 8 additions & 2 deletions src/examples/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,26 +11,32 @@

const runtime = new Runtime({ debug: true, workers: 2 });

const db = await runtime.spawn(Database, creds);
// one pool for the app — reuse via getOrSpawn("main") from anywhere in-process
const db = await runtime.getOrSpawn("main", Database, creds);

try {
const one = await db.queryOne<{ n: number; }>("SELECT 1::int AS n");

console.log("select 1 ->", one);

Check warning on line 20 in src/examples/db.ts

View workflow job for this annotation

GitHub Actions / test (24)

Unexpected console statement

Check warning on line 20 in src/examples/db.ts

View workflow job for this annotation

GitHub Actions / test (20)

Unexpected console statement

Check warning on line 20 in src/examples/db.ts

View workflow job for this annotation

GitHub Actions / test (22)

Unexpected console statement

const version = await db.queryOne<{ version: string; }>("SELECT version()");

console.log("version ->", version?.version);

Check warning on line 24 in src/examples/db.ts

View workflow job for this annotation

GitHub Actions / test (24)

Unexpected console statement

Check warning on line 24 in src/examples/db.ts

View workflow job for this annotation

GitHub Actions / test (20)

Unexpected console statement

Check warning on line 24 in src/examples/db.ts

View workflow job for this annotation

GitHub Actions / test (22)

Unexpected console statement

// two actorsround-robin across workers, each with its own pool
// second spawnseparate pool for parallel queries (load-balanced worker)
const db2 = await runtime.spawn(Database, creds);
const [a, b] = await Promise.all([
db.queryOne("SELECT 42::int AS worker_probe"),
db2.queryOne("SELECT 7::int AS worker_probe"),
]);

console.log("parallel ->", { a, b });

Check warning on line 33 in src/examples/db.ts

View workflow job for this annotation

GitHub Actions / test (24)

Unexpected console statement

Check warning on line 33 in src/examples/db.ts

View workflow job for this annotation

GitHub Actions / test (20)

Unexpected console statement

Check warning on line 33 in src/examples/db.ts

View workflow job for this annotation

GitHub Actions / test (22)

Unexpected console statement

// same key → same proxy as db (spawn above does not register "main")
const same = await runtime.getOrSpawn("main", Database, creds);

console.log("getOrSpawn reuse ->", same === db);

Check warning on line 38 in src/examples/db.ts

View workflow job for this annotation

GitHub Actions / test (24)

Unexpected console statement

Check warning on line 38 in src/examples/db.ts

View workflow job for this annotation

GitHub Actions / test (20)

Unexpected console statement

Check warning on line 38 in src/examples/db.ts

View workflow job for this annotation

GitHub Actions / test (22)

Unexpected console statement

await db2.close();
} finally {
await db.close();
Expand Down
3 changes: 2 additions & 1 deletion src/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
* Public API for `@js-ak/remote-objects`.
*
* Actor-style remote objects on Node.js worker threads — write normal classes,
* bind them with {@link actor}, spawn via {@link Runtime}, call through typed proxies.
* bind them with {@link actor}, spawn or {@link Runtime.getOrSpawn} via
* {@link Runtime}, call through typed proxies.
*/
export type {
ActorClass,
Expand Down
63 changes: 57 additions & 6 deletions src/lib/protocol/callback-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export type CallbackOwner = "host" | number;
*/
export class CallbackRegistry {
private readonly entries = new Map<number, CallbackEntry>();
/** Reverse index: actor objectId → callback ids bound to it. */
private readonly boundByObject = new Map<number, Set<number>>();
private nextId = 1;
private readonly owner: CallbackOwner;

Expand Down Expand Up @@ -49,6 +51,9 @@ export class CallbackRegistry {
}

this.entries.set(callbackId, entry);
if (entry.boundObjectId !== undefined) {
this.trackBound(entry.boundObjectId, callbackId);
}

return callbackRef(this.owner, callbackId);
}
Expand Down Expand Up @@ -84,7 +89,7 @@ export class CallbackRegistry {
*/
release(callbackIds: Iterable<number>): void {
for (const id of callbackIds) {
this.entries.delete(id);
this.removeEntry(id);
}
}

Expand All @@ -97,25 +102,71 @@ export class CallbackRegistry {
const entry = this.entries.get(id);

if (entry?.callScoped) {
this.entries.delete(id);
this.removeEntry(id);
}
}
}

/**
* Drops callbacks returned by a given actor (on destroy).
* Uses a reverse index for O(k) cleanup where k is callbacks bound to the actor.
* @param objectId - Actor that owned the returned callbacks
*/
releaseBoundToObject(objectId: number): void {
for (const [id, entry] of this.entries) {
if (entry.boundObjectId === objectId) {
this.entries.delete(id);
}
const ids = this.boundByObject.get(objectId);

if (!ids) return;
for (const id of ids) {
this.entries.delete(id);
}
this.boundByObject.delete(objectId);
}

/** Removes every registered callback. */
clear(): void {
this.entries.clear();
this.boundByObject.clear();
}

/**
* Drops one entry and keeps {@link boundByObject} in sync.
* @param callbackId - Id to remove
*/
private removeEntry(callbackId: number): void {
const entry = this.entries.get(callbackId);

if (!entry) return;
if (entry.boundObjectId !== undefined) {
this.untrackBound(entry.boundObjectId, callbackId);
}
this.entries.delete(callbackId);
}

/**
* @param objectId - Actor that owns returned callbacks
* @param callbackId - Registered callback id
*/
private trackBound(objectId: number, callbackId: number): void {
let ids = this.boundByObject.get(objectId);

if (!ids) {
ids = new Set();
this.boundByObject.set(objectId, ids);
}
ids.add(callbackId);
}

/**
* @param objectId - Actor that owned the callback
* @param callbackId - Registered callback id
*/
private untrackBound(objectId: number, callbackId: number): void {
const ids = this.boundByObject.get(objectId);

if (!ids) return;
ids.delete(callbackId);
if (ids.size === 0) {
this.boundByObject.delete(objectId);
}
}
}
Loading
Loading