@yingyeothon/actor-system
v2.2.0
Published
Lightweight actor system: per-actor message queues, locking, and awaitable message processing.
Readme
@yingyeothon/actor-system
Lightweight actor system built on three pluggable abstractions — a per-actor message queue, a per-actor lock, and an awaiter that lets senders wait for their message's completion. Any backing store (in-memory, Redis, DynamoDB, ...) can drive it by implementing a handful of small interfaces; in-memory implementations are included.
Three pluggable pieces, and three ways in that differ only in how long the caller waits.
flowchart LR
E["enqueue"] --> Q[("queue")]
P["post"] --> Q
S["send"] --> Q
Q --> D["tryToProcess or eventLoop<br/>holds the lock for the whole call"]
D --> H["your handler"]
H --> A[("awaiter")]
A -.->|"only post and send wait"| SInstall
npm install @yingyeothon/actor-systemUsage
ESM:
import {
AwaitPolicy,
createInMemoryAwaiter,
createInMemoryLock,
createInMemoryQueue,
send,
singleConsumer,
tryToProcess,
} from "@yingyeothon/actor-system";
interface AdderMessage {
delta: number;
}
let value = 0;
const env = {
...singleConsumer,
id: "adder",
queue: createInMemoryQueue(),
lock: createInMemoryLock(),
awaiter: createInMemoryAwaiter(),
onMessage: ({ delta }: AdderMessage) => {
value += delta;
},
};
// Enqueue a message and process the queue in this thread,
// waiting until this message has been committed.
await send(env, { item: { delta: 1 }, awaitPolicy: AwaitPolicy.Commit });
// Or only enqueue (post) and let a dedicated processor drain the queue.
await tryToProcess(env, { aliveMillis: 10_000, shiftable: true });CJS:
const {
eventLoop,
createInMemoryLock,
createInMemoryQueue,
} = require("@yingyeothon/actor-system");
eventLoop({
id: "loop-1",
queue: createInMemoryQueue(),
lock: createInMemoryLock(),
loop: async (poll) => {
for (const item of await poll()) {
console.log(item);
}
},
}).then((processed) => console.log({ processed }));Concepts:
enqueueappends a message to an actor's queue;postenqueues and optionally waits for another processor to complete it;sendenqueues and then tries to process the queue in the calling thread.AwaitPolicyselects how long a sender waits:Forget(not at all),Act(until its handler ran), orCommit(until the whole processing pass, includingonCommit, finished).tryToProcessacquires the actor's lock and drains the queue, either message-by-message (singleConsumer+onMessage) or all-at-once (bulkConsumer+onMessages). WithaliveMillis/shiftableit cooperates with limited-lifetime containers such as AWS Lambda by invokingshiftwhen time runs out.eventLoophands the lock plus apolldrain function to a user-supplied loop.- Every options object accepts an optional
logger?: Loggerfrom@yingyeothon/logger, defaulting tonullLogger.
Delivery semantics: pick deliberately
The two entry points differ, and neither is a superset of the other:
| entry point | drain | on a crash mid-batch |
| -------------- | --------------------------------------- | ------------------------------------------------------------------------------------ |
| tryToProcess | peek → handle → pop | at-least-once: the message is still queued and is handled again |
| eventLoop | flush, then hand the batch to your loop | at-most-once: the batch left the queue before your loop acted on it, and is gone |
A game that cannot lose input needs an ack of its own above eventLoop, or
tryToProcess.
Lock ownership
Both entry points hold the actor's lock for the whole call — tryToProcess
across every drain cycle, not just one. Actor state lives in the owner's heap
while a shift payload carries only an actorId, so releasing between cycles
would let ownership migrate to a process holding different state. Two
consequences:
- A competing invocation that asked to stay alive (
aliveMillis, nooneShot) waits atidleIntervalMillisuntil the owner finishes or its own time runs out; a one-shot call gives up on the first miss. - Pass
lockRenewIntervalMilliswhenever the lease is shorter thanaliveMillis. The lock no longer re-stamps itself between cycles, so without a heartbeat it expires mid-run and a second invocation starts draining the same queue. - A
shifthappens after the release, so the successor can acquire.
Both entry points take lockRenewIntervalMillis, which heartbeats the lease
through lock.renew while work runs. That is what lets a lease be short (so
a crashed actor frees itself in seconds) without expiring under a long game.
An expired lease is not by itself a loss. The lease is a deadline for a successor — it exists so a crashed actor frees its id quickly — so a holder whose renewal comes back false re-acquires and carries on. That is what makes a short lease safe: a failover or a network gap longer than the lease costs a live session nothing, because nobody took anything from it.
Only a re-acquisition that fails means another process owns the actor,
and that is acted on rather than logged: eventLoop's poll rejects from
that moment (so this loop cannot consume the new owner's messages) and calls
the optional onLockLost; tryToProcess stops its drain loop and returns
what it had already processed. A renewal that merely failed to reach the
lock store is neither — the next beat tries again.
Public API
enqueue(env, input)— queue a message, filling inmessageId(random UUID),awaitPolicy(Forget), andawaitTimeoutMillis(0); resolves with the message plus thequeueDepthafter the push, so a producer can notice that nobody is consuming without a second round trippost(env, input)— enqueue and await completion according to the message'sAwaitPolicysend(env, input, options?)— enqueue, try to process in this thread, and await completiontryToProcess(env, options?)— lock and drain the actor's queue; returns theAwaiterMeta[]of processed messageseventLoop(env)— lock the actor and run a user loop with a queue-drainingpoll;falseif the lock was held.onAcquiredruns once, after the lock is taken, which is the only point that means "this invocation owns the actor";lockRenewIntervalMillisheartbeats the leasecreateInMemoryQueue()— in-process queue implementationcreateInMemoryLock()— in-process lock implementationcreateInMemoryAwaiter()— in-process awaiter implementationAwaitPolicy—Forget|Act|CommitenumsingleConsumer— spreadable consume-type marker for message-by-message processingbulkConsumer— spreadable consume-type marker for all-at-once processing- Message types:
AwaiterMeta,UserMessage,UserMessageItem,UserMessageMeta,EnqueuedMessage - System interface types:
QueueProducer,QueueSingleConsumer,QueueBulkConsumer,QueueLength,LockAcquire,LockRelease,LockRenew,AwaiterWait,AwaiterResolve,ActorShift,InMemoryQueue,InMemoryLock,InMemoryAwaiter - Options types:
ActorProperty,ActorLogger,ActorErrorHandler,ActorSingleMessageHandler,ActorMessageBulkConsumer,ActorEnqueueOptions,ActorPostOptions,ActorSendOptions,ActorProcessOptions,ActorLoopOptions,ActorSingleOptions,ActorBulkOptions,ActorEventLoopOptions,TryToProcessOptions—loggerfields takeLoggerfrom@yingyeothon/logger
Behavior changes
QueueProducer.pushresolvesnumber, notvoid. It is the queue depth after the push, which a RedisRPUSHreturns for free and which is the cheapest way for a producer to notice that nobody is consuming. Any customQueueProducerimplementation must be updated.- The lock is held across drain cycles and
shifthappens after the release — see Lock ownership above. - A failed
lock.releaseno longer failseventLoop. It is retried once and then reported aterror("cannot release lock"), because a throw from thefinallyreplaced a finished run's outcome — or the throw that ended it — with a store error. The retry is there because a lock configured without an expiry has no fallback: a release that is simply dropped leaves the actor id unstartable.eventLoopstill resolvestrue, which has always meant "this invocation ran the actor".
Migrating from the legacy package
- Everything is exported from the package root as named exports; deep imports such as
@yingyeothon/actor-system/lib/queue/producerare gone — import the same names from the root instead. - The package no longer depends on
uuid(usescrypto.randomUUID()). - Logging is unified on
@yingyeothon/logger: the package-localActorSystemLoggertype is gone — useLogger(orLogWriter) from@yingyeothon/logger— andnoopLoggeris replaced bynullLoggerfrom the same package. - Classes became factories:
new InMemoryQueue()→createInMemoryQueue(),new InMemoryLock()→createInMemoryLock(),new InMemoryAwaiter()→createInMemoryAwaiter(). The class names remain as interface types describing the returned objects. *Environment(and*Env) type names became*Options:ActorEnqueueEnvironment→ActorEnqueueOptions,ActorPostEnvironment→ActorPostOptions,ActorSendEnvironment→ActorSendOptions,ActorProcessEnvironment→ActorProcessOptions,ActorLoopEnvironment→ActorLoopOptions,ActorEventLoopEnvironment→ActorEventLoopOptions,ActorSingleEnv→ActorSingleOptions,ActorBulkEnv→ActorBulkOptions. The formerActorProcessOptions(theoneShot/aliveMillis/shiftableflags oftryToProcess) is nowTryToProcessOptions.- The misspelled
ActroEventLoopEnvironmenttype is nowActorEventLoopOptions.
