@rivetkit/core
Version:
455 lines (390 loc) • 11.4 kB
text/typescript
import * as crypto from "node:crypto";
import * as fsSync from "node:fs";
import * as fs from "node:fs/promises";
import * as path from "node:path";
import * as cbor from "cbor-x";
import invariant from "invariant";
import { lookupInRegistry } from "@/actor/definition";
import { ActorAlreadyExists } from "@/actor/errors";
import {
createGenericConnDrivers,
GenericConnGlobalState,
} from "@/actor/generic-conn-driver";
import type { AnyActorInstance } from "@/actor/instance";
import type { ActorKey } from "@/actor/mod";
import { generateRandomString } from "@/actor/utils";
import type { AnyClient } from "@/client/client";
import {
type ActorDriver,
serializeEmptyPersistData,
} from "@/driver-helpers/mod";
import type { RegistryConfig } from "@/registry/config";
import type { RunConfig } from "@/registry/run-config";
import { logger } from "./log";
import {
ensureDirectoryExists,
ensureDirectoryExistsSync,
getStoragePath,
} from "./utils";
// Actor handler to track running instances
interface ActorEntry {
id: string;
state?: ActorState;
/** Promise for loading the actor state. */
loadPromise?: Promise<ActorEntry>;
actor?: AnyActorInstance;
/** Promise for starting the actor. */
startPromise?: PromiseWithResolvers<void>;
genericConnGlobalState: GenericConnGlobalState;
/** Promise for ongoing write operations to prevent concurrent writes */
writePromise?: Promise<void>;
}
/**
* Interface representing a actor's state
*/
export interface ActorState {
id: string;
name: string;
key: ActorKey;
createdAt?: Date;
persistedData: Uint8Array;
}
/**
* Global state for the file system driver
*/
export class FileSystemGlobalState {
#storagePath: string;
#stateDir: string;
#dbsDir: string;
#persist: boolean;
#actors = new Map<string, ActorEntry>();
#actorCountOnStartup: number = 0;
get storagePath() {
return this.#storagePath;
}
get actorCountOnStartup() {
return this.#actorCountOnStartup;
}
constructor(persist: boolean = true, customPath?: string) {
this.#persist = persist;
this.#storagePath = persist ? getStoragePath(customPath) : "/tmp";
this.#stateDir = path.join(this.#storagePath, "state");
this.#dbsDir = path.join(this.#storagePath, "databases");
if (this.#persist) {
// Ensure storage directories exist synchronously during initialization
ensureDirectoryExistsSync(this.#stateDir);
ensureDirectoryExistsSync(this.#dbsDir);
try {
const actorIds = fsSync.readdirSync(this.#stateDir);
this.#actorCountOnStartup = actorIds.length;
} catch (error) {
logger().error("failed to count actors", { error });
}
logger().debug("file system driver ready", {
dir: this.#storagePath,
actorCount: this.#actorCountOnStartup,
});
// Cleanup stale temp files on startup
try {
this.#cleanupTempFilesSync();
} catch (err) {
logger().error("failed to cleanup temp files", { error: err });
}
} else {
logger().debug("memory driver ready");
}
}
getActorStatePath(actorId: string): string {
return path.join(this.#stateDir, actorId);
}
getActorDbPath(actorId: string): string {
return path.join(this.#dbsDir, `${actorId}.db`);
}
async *getActorsIterator(params: {
cursor?: string;
}): AsyncGenerator<ActorState> {
const actorIds = fsSync
.readdirSync(this.#stateDir)
.filter((id) => !id.includes(".tmp"))
.sort();
const startIndex = params.cursor ? actorIds.indexOf(params.cursor) + 1 : 0;
for (let i = startIndex; i < actorIds.length; i++) {
const actorId = actorIds[i];
if (!actorId) {
continue;
}
try {
const state = await this.loadActorStateOrError(actorId);
yield state;
} catch (error) {
logger().error("failed to load actor state", { actorId, error });
}
}
}
/**
* Ensures an entry exists for this actor.
*
* Used for #createActor and #loadActor.
*/
#upsertEntry(actorId: string): ActorEntry {
let entry = this.#actors.get(actorId);
if (entry) {
return entry;
}
entry = {
id: actorId,
genericConnGlobalState: new GenericConnGlobalState(),
};
this.#actors.set(actorId, entry);
return entry;
}
/**
* Creates a new actor and writes to file system.
*/
async createActor(
actorId: string,
name: string,
key: ActorKey,
input: unknown | undefined,
): Promise<ActorEntry> {
if (this.#actors.has(actorId)) {
throw new ActorAlreadyExists(name, key);
}
const entry = this.#upsertEntry(actorId);
entry.state = {
id: actorId,
name,
key,
persistedData: serializeEmptyPersistData(input),
};
await this.writeActor(actorId);
return entry;
}
/**
* Loads the actor from disk or returns the existing actor entry. This will return an entry even if the actor does not actually exist.
*/
async loadActor(actorId: string): Promise<ActorEntry> {
const entry = this.#upsertEntry(actorId);
// Check if already loaded
if (entry.state) {
return entry;
}
// If not persisted, then don't load from FS
if (!this.#persist) {
return entry;
}
// If state is currently being loaded, wait for it
if (entry.loadPromise) {
await entry.loadPromise;
return entry;
}
// Start loading state
entry.loadPromise = this.loadActorState(entry);
return entry.loadPromise;
}
private async loadActorState(entry: ActorEntry) {
const stateFilePath = this.getActorStatePath(entry.id);
// Read & parse file
try {
const stateData = await fs.readFile(stateFilePath);
const state = cbor.decode(stateData) as ActorState;
const stats = await fs.stat(stateFilePath);
state.createdAt = stats.birthtime;
// Cache the loaded state in handler
entry.state = state;
return entry;
} catch (innerError: any) {
// File does not exist, meaning the actor does not exist
if (innerError.code === "ENOENT") {
entry.loadPromise = undefined;
return entry;
}
// For other errors, throw
const error = new Error(`Failed to load actor state: ${innerError}`);
throw error;
}
}
async loadOrCreateActor(
actorId: string,
name: string,
key: ActorKey,
input: unknown | undefined,
): Promise<ActorEntry> {
// Attempt to load actor
const entry = await this.loadActor(actorId);
// If no state for this actor, then create & write state
if (!entry.state) {
entry.state = {
id: actorId,
name,
key,
persistedData: serializeEmptyPersistData(input),
};
await this.writeActor(actorId);
}
return entry;
}
/**
* Save actor state to disk
*/
async writeActor(actorId: string): Promise<void> {
if (!this.#persist) {
return;
}
const entry = this.#actors.get(actorId);
invariant(entry?.state, "missing actor state");
const state = entry.state;
// Get the current write promise for this actor (or resolved promise if none)
const currentWrite = entry.writePromise || Promise.resolve();
// Chain our write after the current one
const newWrite = currentWrite
.then(() => this.#performWrite(actorId, state))
.catch((err) => {
// Log but don't prevent future writes
logger().error("write failed", { actorId, error: err });
throw err;
});
// Update the actor's write promise
entry.writePromise = newWrite;
// Wait for our write to complete
try {
await newWrite;
} finally {
// Clean up if we're the last write
if (entry.writePromise === newWrite) {
entry.writePromise = undefined;
}
}
}
/**
* Perform the actual write operation with atomic writes
*/
async #performWrite(actorId: string, state: ActorState): Promise<void> {
const dataPath = this.getActorStatePath(actorId);
// Generate unique temp filename to prevent any race conditions
const tempPath = `${dataPath}.tmp.${crypto.randomUUID()}`;
try {
// Create directory if needed
await ensureDirectoryExists(path.dirname(dataPath));
// Perform atomic write
const serializedState = cbor.encode(state);
await fs.writeFile(tempPath, serializedState);
await fs.rename(tempPath, dataPath);
} catch (error) {
// Cleanup temp file on error
try {
await fs.unlink(tempPath);
} catch {
// Ignore cleanup errors
}
logger().error("failed to save actor state", { actorId, error });
throw new Error(`Failed to save actor state: ${error}`);
}
}
async startActor(
registryConfig: RegistryConfig,
runConfig: RunConfig,
inlineClient: AnyClient,
actorDriver: ActorDriver,
actorId: string,
): Promise<AnyActorInstance> {
// Get the actor metadata
const entry = await this.loadActor(actorId);
if (!entry.state) {
throw new Error(`Actor does exist and cannot be started: ${actorId}`);
}
// Actor already starting
if (entry.startPromise) {
await entry.startPromise.promise;
invariant(entry.actor, "actor should have loaded");
return entry.actor;
}
// Actor already loaded
if (entry.actor) {
return entry.actor;
}
// Create start promise
entry.startPromise = Promise.withResolvers();
try {
// Create actor
const definition = lookupInRegistry(registryConfig, entry.state.name);
entry.actor = definition.instantiate();
// Start actor
const connDrivers = createGenericConnDrivers(
entry.genericConnGlobalState,
);
await entry.actor.start(
connDrivers,
actorDriver,
inlineClient,
actorId,
entry.state.name,
entry.state.key,
"unknown",
);
// Finish
entry.startPromise.resolve();
entry.startPromise = undefined;
return entry.actor;
} catch (innerError) {
const error = new Error(
`Failed to start actor ${actorId}: ${innerError}`,
);
entry.startPromise?.reject(error);
entry.startPromise = undefined;
throw error;
}
}
async loadActorStateOrError(actorId: string): Promise<ActorState> {
const state = (await this.loadActor(actorId)).state;
if (!state) throw new Error(`Actor does not exist: ${actorId}`);
return state;
}
getActorOrError(actorId: string): ActorEntry {
const entry = this.#actors.get(actorId);
if (!entry) throw new Error(`No entry for actor: ${actorId}`);
return entry;
}
async createDatabase(actorId: string): Promise<string | undefined> {
return this.getActorDbPath(actorId);
}
getOrCreateInspectorAccessToken(): string {
const tokenPath = path.join(this.#storagePath, "inspector-token");
if (fsSync.existsSync(tokenPath)) {
return fsSync.readFileSync(tokenPath, "utf-8");
}
const newToken = generateRandomString();
fsSync.writeFileSync(tokenPath, newToken);
return newToken;
}
/**
* Cleanup stale temp files on startup (synchronous)
*/
#cleanupTempFilesSync(): void {
try {
const files = fsSync.readdirSync(this.#stateDir);
const tempFiles = files.filter((f) => f.includes(".tmp."));
const oneHourAgo = Date.now() - 3600000; // 1 hour in ms
for (const tempFile of tempFiles) {
try {
const fullPath = path.join(this.#stateDir, tempFile);
const stat = fsSync.statSync(fullPath);
// Remove if older than 1 hour
if (stat.mtimeMs < oneHourAgo) {
fsSync.unlinkSync(fullPath);
logger().info("cleaned up stale temp file", { file: tempFile });
}
} catch (err) {
logger().debug("failed to cleanup temp file", {
file: tempFile,
error: err,
});
}
}
} catch (err) {
logger().error("failed to read actors directory for cleanup", {
error: err,
});
}
}
}