@yingyeothon/actor-system-redis
v2.2.0
Published
Redis-backed queue, lock, and awaiter for @yingyeothon/actor-system (formerly @yingyeothon/actor-system-redis-support).
Readme
@yingyeothon/actor-system-redis
Redis-backed queue, lock, and awaiter implementations for @yingyeothon/actor-system, built on the minimal @yingyeothon/naive-redis client. It lets multiple processes (for example, concurrent AWS Lambda invocations) share one actor's message queue, exclusive lock, and message-completion signals through a single Redis server.
The keys this subsystem owns, and the one segment that differs from lambda-gamebase's layout.
flowchart LR
P["keyPrefix"] --> Q["keyPrefix + queue: + actorId<br/>a Redis list, TTL required"]
P --> L["lock key<br/>random token, lockTimeout required"]
P --> A["awaiter key"]
N["note: createActorSubsystem in lambda-gamebase<br/>appends no queue: segment"]Install
npm install @yingyeothon/actor-system-redisUsage
ESM:
import { post, singleConsumer, tryToProcess } from "@yingyeothon/actor-system";
import { createRedisSubsystem } from "@yingyeothon/actor-system-redis";
import { createRedisConnection } from "@yingyeothon/naive-redis";
const connection = createRedisConnection({ host: "localhost", port: 6379 });
const env = {
...singleConsumer,
...createRedisSubsystem({
connection,
keyPrefix: "my-app:",
lockTimeout: 30_000,
queueTtlSeconds: 900, // every runtime key carries a TTL
}),
id: "adder",
onMessage: ({ delta }: { delta: number }) => {
total += delta;
},
};
let total = 0;
await post(env, { item: { delta: 1 } });
await tryToProcess(env);Each part can also be used on its own:
import {
createRedisAwaiter,
createRedisLock,
createRedisQueue,
} from "@yingyeothon/actor-system-redis";
const queue = createRedisQueue({
connection,
keyPrefix: "queue:",
ttlSeconds: 900, // required: an abandoned queue disappears instead of growing
});
await queue.push("actor-1", { hello: "world" });
console.log(await queue.size("actor-1")); // 1
console.log(await queue.pop("actor-1")); // { hello: "world" }
const lock = createRedisLock({ connection, lockTimeout: 30_000 });
if (await lock.tryAcquire("actor-1")) {
try {
// ...exclusive work...
} finally {
await lock.release("actor-1");
}
}
const awaiter = createRedisAwaiter({ connection });
await awaiter.resolve("actor-1", "message-1");
console.log(await awaiter.wait("actor-1", "message-1", 1000)); // trueCJS:
const { createRedisSubsystem } = require("@yingyeothon/actor-system-redis");
const { createRedisConnection } = require("@yingyeothon/naive-redis");
const connection = createRedisConnection({ host: "localhost" });
const { queue, lock, awaiter } = createRedisSubsystem({
connection,
lockTimeout: 30_000,
queueTtlSeconds: 900,
});Public API
createRedisQueue— creates a Redis list-backed queue implementingQueueProducer,QueueSingleConsumer,QueueBulkConsumer, andQueueLength(push,pop,peek,flush,size); values are encoded with aCodec<string>(defaultjsonCodec)RedisQueue— the return type ofcreateRedisQueue(type)RedisQueueOptions—{ connection, keyPrefix?, codec?, logger?, ttlSeconds }(type).pushresolves with the queue depth after the push, whichRPUSHgives back for free, so a producer can notice that nobody is consuming without a second round trip.ttlSeconds(seconds, required, a positive integer or the factory throws) is re-applied on every push; without it an abandoned queue grows forever, and on a sharedallkeys-lruRedis that evicts someone else's keys firstcreateRedisLock— creates aSET NX-based per-actor lock implementingLockAcquire,LockRelease, andLockRenew. Every acquisition writes a random token as the value and keeps it in process, soreleasecompares before deleting andrenewcompares before extending: a holder whose lease expired cannot delete the lock its successor took, and a process that never acquired cannot touch it at all.renewreturning false means the lock is gonelockTimeout(milliseconds) is required: a lock that never expires deadlocks its actor forever when the holder crashes, so no-expiry has to be an explicit choice — pass a non-positive value to make itRedisLock— the return type ofcreateRedisLock(type)RedisLockOptions—{ connection, keyPrefix?, logger?, lockTimeout }(type)createRedisAwaiter— creates an awaiter implementingAwaiterResolveandAwaiterWait;resolvewrites a 1-secondactorId/messageIdmarker key andwaitpolls it every 50ms until it appears or the timeout elapses (resolveswallows Redis errors;waitpropagates them). Short in-request waits only: the marker lives 1 second, so a resolver that fires while a slow waiter is between polls can be missed entirely. A failed poll is not an answer —waitkeeps polling until its deadline and rejects only if it never reached Redis once, so a blip inside the deadline does not end the wait earlyRedisAwaiter— the return type ofcreateRedisAwaiter(type)RedisAwaiterOptions—{ connection, keyPrefix?, logger? }(type)createRedisSubsystem— builds{ queue, lock, awaiter }sharing one connection, appendingqueue:,lock:, andawaiter:to the given key prefixRedisSubsystem— the return type ofcreateRedisSubsystem(type)RedisSubsystemOptions—{ connection, keyPrefix?, logger?, lockTimeout, queueTtlSeconds }(type);queueTtlSecondsis forwarded as the queue'sttlSecondsand is required for the same reason
Every factory accepts an optional logger?: Logger (from @yingyeothon/logger, default nullLogger). All methods are own properties, so results can be spread into an actor environment ({ ...singleConsumer, ...createRedisSubsystem(...), ...actor }).
Behavior changes
lockTimeoutis required. It used to default to-1, i.e. a lock that never expires, which deadlocks its actor forever when the holder crashes. ExistingcreateRedisLock({ connection })andcreateRedisSubsystem({ connection })calls no longer compile; pass a finite millisecond lease, or a non-positive value to choose no expiry deliberately.releaseis conditional and can return false. The lock value is now a per-acquisition token andreleasecompares before deleting, so a holder whose lease expired can no longer delete its successor's lock — and a process that never acquired getsfalseinstead of silently deleting someone else's key. Code that usedreleaseto break a stale lock must useredisDelon the lock key and mean it.renewis new, andRedisLocknow extendsLockRenew. A long-running loop should heartbeat it; seeeventLoop'slockRenewIntervalMillis.pushresolves the queue depth instead ofvoid.ttlSeconds/queueTtlSecondsare required and must be a positive integer;createRedisQueuethrows otherwise. Every runtime key carries a TTL: a queue pushed by something other than the gateway must still expire, and on a sharedallkeys-lruRedis a key that never expires evicts someone else's before anyone notices.
Migrating from the legacy package
- The npm package was renamed:
@yingyeothon/actor-system-redis-support→@yingyeothon/actor-system-redis. - Classes became factory functions returning interfaces:
new RedisQueue(options)→createRedisQueue(options),new RedisLock(options)→createRedisLock(options),new RedisAwaiter(options)→createRedisAwaiter(options).RedisQueue,RedisLock, andRedisAwaiterremain as the returned interface types. newRedisSubsystem→createRedisSubsystem(theRedisSubsystemreturn type is unchanged).- Get connections from
@yingyeothon/naive-redis's root export:createRedisConnection(legacyredisConnect, previouslyconnectfromnaive-redis/lib/connection). - Deep imports are gone; everything is exported from the package root, and option interfaces are exported (
RedisQueueOptions,RedisLockOptions,RedisAwaiterOptions,RedisSubsystemOptions). - Key layouts are unchanged. Runtime behavior and defaults are not — see Behavior changes above.
