refactor: better effect error handling
This commit is contained in:
parent
8cabaef17c
commit
87394d690c
5 changed files with 220 additions and 109 deletions
|
|
@ -4,7 +4,7 @@ import { fromAny, fromPartial } from "@total-typescript/shoehorn";
|
|||
import { Effect } from "effect";
|
||||
import { agentManager, type ProcessWithAgentRuntime } from "../agents-manager";
|
||||
import type { AgentManagerRuntime } from "../controller/server";
|
||||
import type { BackupRunPayload, VolumeCommand } from "@zerobyte/contracts/agent-protocol";
|
||||
import type { BackupRunPayload, VolumeCommand, VolumeCommandResponsePayload } from "@zerobyte/contracts/agent-protocol";
|
||||
|
||||
const setAgentRuntime = (agentManagerRuntime: Partial<AgentManagerRuntime> | null) => {
|
||||
(process as ProcessWithAgentRuntime).__zerobyteAgentRuntime = {
|
||||
|
|
@ -53,7 +53,7 @@ test("runVolumeCommand sends the command to the selected agent", async () => {
|
|||
commandId: "command-1",
|
||||
status: "success",
|
||||
command: { name: "volume.mount", result: { status: "mounted" } },
|
||||
}),
|
||||
} satisfies VolumeCommandResponsePayload),
|
||||
);
|
||||
setAgentRuntime({ runVolumeCommand });
|
||||
|
||||
|
|
|
|||
|
|
@ -29,8 +29,9 @@ type SessionState = {
|
|||
lastPongAt: number | null;
|
||||
};
|
||||
|
||||
type InFlightVolumeCommand = {
|
||||
type PendingCommand = {
|
||||
deferred: Deferred.Deferred<VolumeCommandResponsePayload, Error>;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type ControllerAgentSessionEvent =
|
||||
|
|
@ -56,7 +57,7 @@ export const createControllerAgentSession = (
|
|||
Effect.gen(function* () {
|
||||
let isClosed = false;
|
||||
const outboundQueue = yield* Queue.bounded<ControllerWireMessage>(64);
|
||||
const inFlightVolumeCommands = yield* Ref.make(new Map<string, InFlightVolumeCommand>());
|
||||
const pendingCommands = yield* Ref.make(new Map<string, PendingCommand>());
|
||||
const state = yield* Ref.make<SessionState>({
|
||||
isReady: false,
|
||||
lastSeenAt: null,
|
||||
|
|
@ -75,25 +76,25 @@ export const createControllerAgentSession = (
|
|||
|
||||
const updateState = (update: (current: SessionState) => SessionState) => Ref.update(state, update);
|
||||
|
||||
const setInFlightVolumeCommand = (commandId: string, inFlight: InFlightVolumeCommand) =>
|
||||
Ref.update(inFlightVolumeCommands, (current) => new Map(current).set(commandId, inFlight));
|
||||
const setPendingCommand = (commandId: string, pending: PendingCommand) =>
|
||||
Ref.update(pendingCommands, (current) => new Map(current).set(commandId, pending));
|
||||
|
||||
const removeInFlightVolumeCommand = (commandId: string) =>
|
||||
Ref.modify(inFlightVolumeCommands, (current) => {
|
||||
const inFlight = current.get(commandId) ?? null;
|
||||
const removePendingCommand = (commandId: string) =>
|
||||
Ref.modify(pendingCommands, (current) => {
|
||||
const pending = current.get(commandId) ?? null;
|
||||
const next = new Map(current);
|
||||
next.delete(commandId);
|
||||
return [inFlight, next];
|
||||
return [pending, next];
|
||||
});
|
||||
|
||||
const rejectInFlightVolumeCommands = Effect.gen(function* () {
|
||||
const inFlightCommands = yield* Ref.get(inFlightVolumeCommands);
|
||||
yield* Ref.set(inFlightVolumeCommands, new Map());
|
||||
const rejectPendingCommands = Effect.gen(function* () {
|
||||
const pendingCommandEntries = yield* Ref.get(pendingCommands);
|
||||
yield* Ref.set(pendingCommands, new Map());
|
||||
|
||||
for (const [commandId, inFlight] of inFlightCommands) {
|
||||
for (const pending of pendingCommandEntries.values()) {
|
||||
yield* Deferred.fail(
|
||||
inFlight.deferred,
|
||||
new Error(`Agent session closed before volume command ${commandId} completed`),
|
||||
pending.deferred,
|
||||
new Error(`Agent session closed before ${pending.description} completed`),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
@ -101,7 +102,7 @@ export const createControllerAgentSession = (
|
|||
const releaseSession = Effect.gen(function* () {
|
||||
const disconnectedAt = Date.now();
|
||||
yield* updateState((current) => ({ ...current, isReady: false, lastSeenAt: disconnectedAt }));
|
||||
yield* rejectInFlightVolumeCommands;
|
||||
yield* rejectPendingCommands;
|
||||
yield* onEvent({ type: "agent.disconnected" });
|
||||
|
||||
yield* Queue.shutdown(outboundQueue);
|
||||
|
|
@ -167,13 +168,13 @@ export const createControllerAgentSession = (
|
|||
|
||||
const handleVolumeCommandResult = (payload: VolumeCommandResponsePayload) =>
|
||||
Effect.gen(function* () {
|
||||
const inFlight = yield* removeInFlightVolumeCommand(payload.commandId);
|
||||
if (!inFlight) {
|
||||
const pending = yield* removePendingCommand(payload.commandId);
|
||||
if (!pending) {
|
||||
yield* logger.effect.warn(`Received response for unknown volume command ${payload.commandId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
yield* Deferred.succeed(inFlight.deferred, payload);
|
||||
yield* Deferred.succeed(pending.deferred, payload);
|
||||
});
|
||||
|
||||
const handleAgentMessage = (message: AgentMessage) =>
|
||||
|
|
@ -227,12 +228,13 @@ export const createControllerAgentSession = (
|
|||
runVolumeCommand: (command) =>
|
||||
Effect.gen(function* () {
|
||||
const commandId = Bun.randomUUIDv7();
|
||||
const description = `volume command ${command.name}`;
|
||||
const deferred = yield* Deferred.make<VolumeCommandResponsePayload, Error>();
|
||||
yield* setInFlightVolumeCommand(commandId, { deferred });
|
||||
yield* setPendingCommand(commandId, { deferred, description });
|
||||
|
||||
const queued = yield* offerOutbound(createControllerMessage("volume.command", { commandId, command }));
|
||||
if (!queued) {
|
||||
yield* removeInFlightVolumeCommand(commandId);
|
||||
yield* removePendingCommand(commandId);
|
||||
return yield* Effect.fail(new Error(`Failed to queue volume command ${command.name}`));
|
||||
}
|
||||
|
||||
|
|
@ -241,7 +243,7 @@ export const createControllerAgentSession = (
|
|||
duration: "60 seconds",
|
||||
onTimeout: () => new Error(`Volume command ${command.name} timed out`),
|
||||
}),
|
||||
Effect.ensuring(removeInFlightVolumeCommand(commandId)),
|
||||
Effect.ensuring(removePendingCommand(commandId)),
|
||||
);
|
||||
}),
|
||||
isReady: () => Ref.get(state).pipe(Effect.map((current) => current.isReady)),
|
||||
|
|
|
|||
|
|
@ -1,10 +1,5 @@
|
|||
import { Effect } from "effect";
|
||||
import {
|
||||
createAgentMessage,
|
||||
type VolumeCommand,
|
||||
type VolumeCommandPayload,
|
||||
type VolumeCommandResult,
|
||||
} from "@zerobyte/contracts/agent-protocol";
|
||||
import { Effect, Data } from "effect";
|
||||
import { createAgentMessage, type VolumeCommand, type VolumeCommandPayload } from "@zerobyte/contracts/agent-protocol";
|
||||
import { toMessage } from "@zerobyte/core/utils";
|
||||
import { createVolumeBackend, getStatFs, getVolumePath, type AgentVolume, type BackendConfig } from "../volume-host";
|
||||
import { browseFilesystem, listVolumeFiles, testVolumeConnection } from "../volume-host/operations";
|
||||
|
|
@ -12,65 +7,87 @@ import type { ControllerCommandContext } from "../context";
|
|||
|
||||
type VolumeBackedCommand = Extract<VolumeCommand, { volume: unknown }>;
|
||||
|
||||
class VolumeCommandError extends Data.TaggedError("StopAgentManagerServerError")<{
|
||||
cause: unknown;
|
||||
}> {}
|
||||
|
||||
const asVolume = (volume: VolumeBackedCommand["volume"]): AgentVolume => ({
|
||||
...volume,
|
||||
config: volume.config as BackendConfig,
|
||||
provisioningId: volume.provisioningId ?? null,
|
||||
});
|
||||
|
||||
const runBackendOperation = async (
|
||||
const runBackendOperation = (
|
||||
command: Extract<VolumeCommand, { volume: unknown }>,
|
||||
operation: "mount" | "unmount" | "checkHealth",
|
||||
) => {
|
||||
const backend = createVolumeBackend(asVolume(command.volume));
|
||||
return backend[operation]();
|
||||
};
|
||||
) =>
|
||||
Effect.tryPromise({
|
||||
try: () => {
|
||||
const backend = createVolumeBackend(asVolume(command.volume));
|
||||
return backend[operation]();
|
||||
},
|
||||
catch: (error) => new VolumeCommandError({ cause: error }),
|
||||
});
|
||||
|
||||
const executeVolumeCommand = async (command: VolumeCommand): Promise<VolumeCommandResult> => {
|
||||
switch (command.name) {
|
||||
case "volume.mount":
|
||||
return { name: command.name, result: await runBackendOperation(command, "mount") };
|
||||
case "volume.unmount":
|
||||
return { name: command.name, result: await runBackendOperation(command, "unmount") };
|
||||
case "volume.checkHealth":
|
||||
return { name: command.name, result: await runBackendOperation(command, "checkHealth") };
|
||||
case "volume.statfs":
|
||||
return { name: command.name, result: await getStatFs(getVolumePath(asVolume(command.volume))) };
|
||||
case "volume.listFiles":
|
||||
return {
|
||||
name: command.name,
|
||||
result: await listVolumeFiles(asVolume(command.volume), command.subPath, command.offset, command.limit),
|
||||
};
|
||||
case "volume.testConnection":
|
||||
return { name: command.name, result: await testVolumeConnection(command.backendConfig as BackendConfig) };
|
||||
case "filesystem.browse":
|
||||
return { name: command.name, result: await browseFilesystem(command.path) };
|
||||
}
|
||||
};
|
||||
const executeVolumeCommand = (command: VolumeCommand) =>
|
||||
Effect.gen(function* () {
|
||||
switch (command.name) {
|
||||
case "volume.mount":
|
||||
return { name: command.name, result: yield* runBackendOperation(command, "mount") };
|
||||
case "volume.unmount":
|
||||
return { name: command.name, result: yield* runBackendOperation(command, "unmount") };
|
||||
case "volume.checkHealth":
|
||||
return { name: command.name, result: yield* runBackendOperation(command, "checkHealth") };
|
||||
case "volume.statfs": {
|
||||
const result = yield* Effect.tryPromise({
|
||||
try: () => getStatFs(getVolumePath(asVolume(command.volume))),
|
||||
catch: (error) => new VolumeCommandError({ cause: error }),
|
||||
});
|
||||
return { name: command.name, result };
|
||||
}
|
||||
case "volume.listFiles": {
|
||||
const result = yield* Effect.tryPromise({
|
||||
try: () => listVolumeFiles(asVolume(command.volume), command.subPath, command.offset, command.limit),
|
||||
catch: (error) => new VolumeCommandError({ cause: error }),
|
||||
});
|
||||
|
||||
export const handleVolumeCommand = (context: ControllerCommandContext, payload: VolumeCommandPayload) => {
|
||||
return Effect.promise(async () => {
|
||||
try {
|
||||
const command = await executeVolumeCommand(payload.command);
|
||||
await Effect.runPromise(
|
||||
context.offerOutbound(
|
||||
createAgentMessage("volume.commandResult", {
|
||||
commandId: payload.commandId,
|
||||
status: "success",
|
||||
command,
|
||||
}),
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
await Effect.runPromise(
|
||||
context.offerOutbound(
|
||||
createAgentMessage("volume.commandResult", {
|
||||
commandId: payload.commandId,
|
||||
status: "error",
|
||||
error: toMessage(error),
|
||||
}),
|
||||
),
|
||||
);
|
||||
return { name: command.name, result };
|
||||
}
|
||||
case "volume.testConnection": {
|
||||
const result = yield* testVolumeConnection(command.backendConfig as BackendConfig);
|
||||
return { name: command.name, result };
|
||||
}
|
||||
case "filesystem.browse":
|
||||
const result = yield* Effect.tryPromise({
|
||||
try: () => browseFilesystem(command.path),
|
||||
catch: (error) => new VolumeCommandError({ cause: error }),
|
||||
});
|
||||
return { name: command.name, result };
|
||||
}
|
||||
});
|
||||
|
||||
export const handleVolumeCommand = (context: ControllerCommandContext, payload: VolumeCommandPayload) => {
|
||||
return Effect.gen(function* () {
|
||||
const command = yield* executeVolumeCommand(payload.command);
|
||||
|
||||
yield* context.offerOutbound(
|
||||
createAgentMessage("volume.commandResult", {
|
||||
commandId: payload.commandId,
|
||||
status: "success",
|
||||
command,
|
||||
}),
|
||||
);
|
||||
|
||||
return command;
|
||||
}).pipe(
|
||||
Effect.tapError((error) => {
|
||||
return context.offerOutbound(
|
||||
createAgentMessage("volume.commandResult", {
|
||||
commandId: payload.commandId,
|
||||
status: "error",
|
||||
error: toMessage(error?.cause),
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
|
|
|||
59
apps/agent/src/volume-host/backends/utils.test.ts
Normal file
59
apps/agent/src/volume-host/backends/utils.test.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
vi.mock("node:fs/promises", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:fs/promises")>();
|
||||
|
||||
return {
|
||||
...actual,
|
||||
access: vi.fn(actual.access),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../fs", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../fs")>();
|
||||
|
||||
return {
|
||||
...actual,
|
||||
getMountForPath: vi.fn(actual.getMountForPath),
|
||||
};
|
||||
});
|
||||
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as volumeFs from "../fs";
|
||||
import { assertMounted } from "./utils";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("assertMounted", () => {
|
||||
test("throws when the path is not accessible", async () => {
|
||||
vi.mocked(fs.access).mockRejectedValueOnce(new Error("missing"));
|
||||
|
||||
await expect(assertMounted("/tmp/volume", (fstype) => fstype.startsWith("nfs"))).rejects.toThrow(
|
||||
"Volume is not mounted",
|
||||
);
|
||||
});
|
||||
|
||||
test("throws when the mount filesystem does not match", async () => {
|
||||
vi.mocked(fs.access).mockResolvedValueOnce(undefined);
|
||||
vi.mocked(volumeFs.getMountForPath).mockResolvedValueOnce({
|
||||
mountPoint: "/tmp/volume",
|
||||
fstype: "cifs",
|
||||
});
|
||||
|
||||
await expect(assertMounted("/tmp/volume", (fstype) => fstype.startsWith("nfs"))).rejects.toThrow(
|
||||
"Path /tmp/volume is not mounted as correct fstype (found cifs).",
|
||||
);
|
||||
});
|
||||
|
||||
test("accepts a matching mounted filesystem", async () => {
|
||||
vi.mocked(fs.access).mockResolvedValueOnce(undefined);
|
||||
vi.mocked(volumeFs.getMountForPath).mockResolvedValueOnce({
|
||||
mountPoint: "/tmp/volume",
|
||||
fstype: "nfs4",
|
||||
});
|
||||
|
||||
await expect(assertMounted("/tmp/volume", (fstype) => fstype.startsWith("nfs"))).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -4,6 +4,7 @@ import * as path from "node:path";
|
|||
import { toMessage } from "@zerobyte/core/utils";
|
||||
import { createVolumeBackend, getVolumePath, isNodeJSErrnoException } from ".";
|
||||
import type { AgentVolume, BackendConfig } from "./types";
|
||||
import { Data, Effect } from "effect";
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 500;
|
||||
const MAX_PAGE_SIZE = 500;
|
||||
|
|
@ -122,36 +123,68 @@ export const browseFilesystem = async (browsePath: string) => {
|
|||
};
|
||||
};
|
||||
|
||||
export const testVolumeConnection = async (backendConfig: BackendConfig) => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "zerobyte-test-"));
|
||||
try {
|
||||
const mockVolume: AgentVolume = {
|
||||
id: 0,
|
||||
shortId: "test",
|
||||
name: "test-connection",
|
||||
config: backendConfig,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
lastHealthCheck: Date.now(),
|
||||
type: backendConfig.backend,
|
||||
status: "unmounted",
|
||||
lastError: null,
|
||||
provisioningId: null,
|
||||
autoRemount: true,
|
||||
agentId: "local",
|
||||
organizationId: "test-org",
|
||||
};
|
||||
class TempDirError extends Data.TaggedError("TempDirError")<{
|
||||
cause: unknown;
|
||||
}> {}
|
||||
|
||||
const backend = createVolumeBackend(mockVolume, tempDir);
|
||||
const { error } = await backend.mount();
|
||||
class CleanupError extends Data.TaggedError("CleanupError")<{
|
||||
cause: unknown;
|
||||
tempDir: string;
|
||||
}> {}
|
||||
|
||||
await backend.unmount();
|
||||
class MountError extends Data.TaggedError("MountError")<{
|
||||
cause: unknown;
|
||||
}> {}
|
||||
|
||||
return {
|
||||
success: !error,
|
||||
message: error ? toMessage(error) : "Connection successful",
|
||||
};
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
};
|
||||
const createTempDir = Effect.acquireRelease(
|
||||
Effect.tryPromise({
|
||||
try: () => fs.mkdtemp(path.join(os.tmpdir(), "zerobyte-test-")),
|
||||
catch: (error) => new TempDirError({ cause: error }),
|
||||
}),
|
||||
(tempDir) =>
|
||||
Effect.tryPromise({
|
||||
try: () => fs.rm(tempDir, { recursive: true, force: true }),
|
||||
catch: (error) => new CleanupError({ cause: error, tempDir }),
|
||||
}).pipe(Effect.orDie),
|
||||
);
|
||||
|
||||
export const testVolumeConnection = (backendConfig: BackendConfig) =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const tempDir = yield* createTempDir;
|
||||
|
||||
const mockVolume: AgentVolume = {
|
||||
id: 0,
|
||||
shortId: "test",
|
||||
name: "test-connection",
|
||||
config: backendConfig,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
lastHealthCheck: Date.now(),
|
||||
type: backendConfig.backend,
|
||||
status: "unmounted",
|
||||
lastError: null,
|
||||
provisioningId: null,
|
||||
autoRemount: true,
|
||||
agentId: "local",
|
||||
organizationId: "test-org",
|
||||
};
|
||||
|
||||
const backend = createVolumeBackend(mockVolume, tempDir);
|
||||
|
||||
const mountResult = yield* Effect.tryPromise({
|
||||
try: () => backend.mount(),
|
||||
catch: (error) => new MountError({ cause: error }),
|
||||
});
|
||||
|
||||
yield* Effect.tryPromise({
|
||||
try: () => backend.unmount(),
|
||||
catch: () => undefined,
|
||||
});
|
||||
|
||||
return {
|
||||
success: !mountResult.error,
|
||||
message: mountResult.error ? toMessage(mountResult.error) : "Connection successful",
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue