UNPKG

@rivetkit/redis

Version:

_Lightweight Libraries for Backends_

242 lines (207 loc) 6.24 kB
import * as cbor from "cbor-x"; import dedent from "dedent"; import type Redis from "ioredis"; import type { RedisDriverConfig } from "./config"; import type { AttemptAcquireLease, CoordinateDriver, ExtendLeaseOutput, GetActorLeaderOutput, NodeMessageCallback, StartActorAndAcquireLeaseOutput, } from "./coordinate/driver"; import type { NodeMessage } from "./coordinate/node/protocol"; import { KEYS, PUBSUB } from "./keys"; // Define custom commands for ioredis declare module "ioredis" { interface RedisCommander { actorPeerAcquireLease( nodeKey: string, nodeId: string, leaseDuration: number, ): Promise<string>; actorPeerExtendLease( nodeKey: string, nodeId: string, leaseDuration: number, ): Promise<number>; actorPeerReleaseLease(nodeKey: string, nodeId: string): Promise<number>; } interface ChainableCommander { actorPeerAcquireLease( nodeKey: string, nodeId: string, leaseDuration: number, ): this; } } export class RedisCoordinateDriver implements CoordinateDriver { #driverConfig: RedisDriverConfig; #redis: Redis; #nodeSub?: Redis; constructor(driverConfig: RedisDriverConfig, redis: Redis) { this.#driverConfig = driverConfig; this.#redis = redis; // Define Redis Lua scripts for atomic operations this.#defineRedisScripts(); } async createNodeSubscriber( selfNodeId: string, callback: NodeMessageCallback, ): Promise<void> { // Create a dedicated Redis connection for subscriptions this.#nodeSub = this.#redis.duplicate(); // Configure message handler this.#nodeSub.on( "messageBuffer", (_channel: string, messageRaw: Buffer) => { const message = cbor.decode(messageRaw); callback(message); }, ); // Subscribe to node-specific channel await this.#nodeSub.subscribe( PUBSUB.node(this.#driverConfig.keyPrefix, selfNodeId), ); } async publishToNode( targetNodeId: string, message: NodeMessage, ): Promise<void> { await this.#redis.publish( PUBSUB.node(this.#driverConfig.keyPrefix, targetNodeId), cbor.encode(message), ); } async getActorLeader(actorId: string): Promise<GetActorLeaderOutput> { // Get current leader from Redis const [metadata, nodeId] = await this.#redis.mget([ // TODO: Use exists in pipeline instead of getting all data KEYS.ACTOR.metadata(this.#driverConfig.keyPrefix, actorId), KEYS.ACTOR.LEASE.node(this.#driverConfig.keyPrefix, actorId), ]); if (!metadata) { return { actor: undefined }; } return { actor: { leaderNodeId: nodeId || undefined, }, }; } async startActorAndAcquireLease( actorId: string, selfNodeId: string, leaseDuration: number, ): Promise<StartActorAndAcquireLeaseOutput> { // Execute multi to get actor info and attempt to acquire lease in a single operation const execRes = await this.#redis .multi() .getBuffer(KEYS.ACTOR.metadata(this.#driverConfig.keyPrefix, actorId)) .actorPeerAcquireLease( KEYS.ACTOR.LEASE.node(this.#driverConfig.keyPrefix, actorId), selfNodeId, leaseDuration, ) .exec(); if (!execRes) { throw new Error("Redis transaction failed"); } const [[getErr, getRes], [leaseErr, leaseRes]] = execRes; if (getErr) throw new Error(`Redis GET error: ${getErr}`); if (leaseErr) throw new Error(`Redis acquire lease error: ${leaseErr}`); const metadataRaw = getRes as Buffer | null; const leaderNodeId = leaseRes as unknown as string; if (!metadataRaw) { return { actor: undefined }; } // Parse metadata if present if (!metadataRaw) throw new Error("Actor should have metadata if initialized."); const metadata = cbor.decode(metadataRaw); return { actor: { name: metadata.name, key: metadata.key, leaderNodeId, }, }; } async extendLease( actorId: string, selfNodeId: string, leaseDuration: number, ): Promise<ExtendLeaseOutput> { const res = await this.#redis.actorPeerExtendLease( KEYS.ACTOR.LEASE.node(this.#driverConfig.keyPrefix, actorId), selfNodeId, leaseDuration, ); return { leaseValid: res === 1, }; } async attemptAcquireLease( actorId: string, selfNodeId: string, leaseDuration: number, ): Promise<AttemptAcquireLease> { const newLeaderNodeId = await this.#redis.actorPeerAcquireLease( KEYS.ACTOR.LEASE.node(this.#driverConfig.keyPrefix, actorId), selfNodeId, leaseDuration, ); return { newLeaderNodeId, }; } async releaseLease(actorId: string, nodeId: string): Promise<void> { await this.#redis.actorPeerReleaseLease( KEYS.ACTOR.LEASE.node(this.#driverConfig.keyPrefix, actorId), nodeId, ); } #defineRedisScripts() { // Add custom Lua script commands to Redis this.#redis.defineCommand("actorPeerAcquireLease", { numberOfKeys: 1, lua: dedent` -- Get the current value of the key local currentValue = redis.call("get", KEYS[1]) -- Return the current value if an entry already exists if currentValue then return currentValue end -- Create an entry for the provided key redis.call("set", KEYS[1], ARGV[1], "PX", ARGV[2]) -- Return the value to indicate the entry was added return ARGV[1] `, }); this.#redis.defineCommand("actorPeerExtendLease", { numberOfKeys: 1, lua: dedent` -- Return 0 if an entry exists with a different lease holder if redis.call("get", KEYS[1]) ~= ARGV[1] then return 0 end -- Update the entry for the provided key redis.call("set", KEYS[1], ARGV[1], "PX", ARGV[2]) -- Return 1 to indicate the entry was updated return 1 `, }); this.#redis.defineCommand("actorPeerReleaseLease", { numberOfKeys: 1, lua: dedent` -- Only remove the entry for this lock value if redis.call("get", KEYS[1]) == ARGV[1] then redis.pcall("del", KEYS[1]) return 1 end -- Return 0 if no entry was removed. return 0 `, }); } }