fix(restore): do not wait for lock before returning response
This commit is contained in:
parent
44c4e80526
commit
d528836d61
2 changed files with 187 additions and 70 deletions
|
|
@ -14,11 +14,13 @@ import { db } from "~/server/db/db";
|
||||||
import { agentsTable, repositoriesTable, type RepositoryInsert } from "~/server/db/schema";
|
import { agentsTable, repositoriesTable, type RepositoryInsert } from "~/server/db/schema";
|
||||||
import { generateShortId } from "~/server/utils/id";
|
import { generateShortId } from "~/server/utils/id";
|
||||||
import { restic } from "~/server/core/restic";
|
import { restic } from "~/server/core/restic";
|
||||||
import { agentManager } from "~/server/modules/agents/agents-manager";
|
import { agentManager, type RestoreExecutionResult } from "~/server/modules/agents/agents-manager";
|
||||||
import { createTestSession } from "~/test/helpers/auth";
|
import { createTestSession } from "~/test/helpers/auth";
|
||||||
import { createTestBackupSchedule } from "~/test/helpers/backup";
|
import { createTestBackupSchedule } from "~/test/helpers/backup";
|
||||||
import { cache, cacheKeys } from "~/server/utils/cache";
|
import { cache, cacheKeys } from "~/server/utils/cache";
|
||||||
import { ResticError } from "@zerobyte/core/restic/server";
|
import { ResticError } from "@zerobyte/core/restic/server";
|
||||||
|
import { repoMutex } from "~/server/core/repository-mutex";
|
||||||
|
import { taskStore } from "~/server/modules/tasks/tasks.store";
|
||||||
import { repositoriesService } from "../repositories.service";
|
import { repositoriesService } from "../repositories.service";
|
||||||
|
|
||||||
const createTestRepository = async (organizationId: string, overrides: Partial<RepositoryInsert> = {}) => {
|
const createTestRepository = async (organizationId: string, overrides: Partial<RepositoryInsert> = {}) => {
|
||||||
|
|
@ -416,8 +418,26 @@ describe("repositoriesService.restoreSnapshot", () => {
|
||||||
let originalEnableLocalAgent: boolean;
|
let originalEnableLocalAgent: boolean;
|
||||||
const createPendingRestoreStart = () => ({
|
const createPendingRestoreStart = () => ({
|
||||||
status: "started" as const,
|
status: "started" as const,
|
||||||
result: new Promise<never>(() => {}),
|
result: new Promise<RestoreExecutionResult>(() => {}),
|
||||||
});
|
});
|
||||||
|
const resolveWithin = async <T>(promise: Promise<T>, timeoutMs: number) => {
|
||||||
|
return await new Promise<T>((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
reject(new Error(`Expected promise to resolve within ${timeoutMs}ms`));
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
promise.then(
|
||||||
|
(value) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve(value);
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
reject(error);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
originalEnableLocalAgent = config.flags.enableLocalAgent;
|
originalEnableLocalAgent = config.flags.enableLocalAgent;
|
||||||
|
|
@ -451,6 +471,7 @@ describe("repositoriesService.restoreSnapshot", () => {
|
||||||
return {
|
return {
|
||||||
organizationId,
|
organizationId,
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
|
repositoryId: repository.id,
|
||||||
repositoryShortId: repository.shortId,
|
repositoryShortId: repository.shortId,
|
||||||
restoreMock,
|
restoreMock,
|
||||||
};
|
};
|
||||||
|
|
@ -485,20 +506,22 @@ describe("repositoriesService.restoreSnapshot", () => {
|
||||||
await fs.rm(targetPath, { recursive: true, force: true });
|
await fs.rm(targetPath, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(restoreMock).toHaveBeenCalledWith(
|
await waitForExpect(() => {
|
||||||
"local",
|
expect(restoreMock).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
"local",
|
||||||
payload: expect.objectContaining({
|
expect.objectContaining({
|
||||||
snapshotId: "snapshot-restore",
|
payload: expect.objectContaining({
|
||||||
target: targetPath,
|
snapshotId: "snapshot-restore",
|
||||||
repositoryConfig: expect.objectContaining({ backend: "local" }),
|
target: targetPath,
|
||||||
options: expect.objectContaining({
|
repositoryConfig: expect.objectContaining({ backend: "local" }),
|
||||||
organizationId,
|
options: expect.objectContaining({
|
||||||
basePath: "/var/lib/zerobyte/volumes/vol123/_data",
|
organizationId,
|
||||||
|
basePath: "/var/lib/zerobyte/volumes/vol123/_data",
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
}),
|
);
|
||||||
);
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("rejects starting a second active restore for the same snapshot", async () => {
|
test("rejects starting a second active restore for the same snapshot", async () => {
|
||||||
|
|
@ -524,6 +547,73 @@ describe("repositoriesService.restoreSnapshot", () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("returns a restore id while waiting for the repository mutex", async () => {
|
||||||
|
const { organizationId, userId, repositoryId, repositoryShortId, restoreMock } =
|
||||||
|
await setupRestoreSnapshotScenario();
|
||||||
|
const targetPath = await fs.mkdtemp(nodePath.join(process.cwd(), "restore-target-"));
|
||||||
|
await withContext({ organizationId, userId }, () =>
|
||||||
|
repositoriesService.getSnapshotDetails(repositoryShortId, "snapshot-restore"),
|
||||||
|
);
|
||||||
|
|
||||||
|
let finishRestore: (result: RestoreExecutionResult) => void = () => {};
|
||||||
|
const restoreResult = new Promise<RestoreExecutionResult>((resolve) => {
|
||||||
|
finishRestore = resolve;
|
||||||
|
});
|
||||||
|
restoreMock.mockResolvedValueOnce({ status: "started", result: restoreResult });
|
||||||
|
|
||||||
|
const releaseExclusive = await repoMutex.acquireExclusive(repositoryId, "check");
|
||||||
|
let restoreId = "";
|
||||||
|
try {
|
||||||
|
const restoreStart = withContext({ organizationId, userId }, () =>
|
||||||
|
repositoriesService.restoreSnapshot(repositoryShortId, "snapshot-restore", {
|
||||||
|
targetPath,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await resolveWithin(restoreStart, 1000);
|
||||||
|
restoreId = result.restoreId;
|
||||||
|
|
||||||
|
expect(result.status).toBe("started");
|
||||||
|
expect(restoreMock).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
const task = taskStore.findActiveByResource({
|
||||||
|
organizationId,
|
||||||
|
kind: "restore",
|
||||||
|
resourceType: "repository",
|
||||||
|
resourceId: repositoryShortId,
|
||||||
|
});
|
||||||
|
expect(task?.id).toBe(restoreId);
|
||||||
|
expect(task?.status).toBe("queued");
|
||||||
|
} finally {
|
||||||
|
releaseExclusive();
|
||||||
|
await fs.rm(targetPath, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
await waitForExpect(() => {
|
||||||
|
expect(restoreMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
finishRestore({
|
||||||
|
status: "completed",
|
||||||
|
result: {
|
||||||
|
message_type: "summary",
|
||||||
|
files_skipped: 0,
|
||||||
|
files_restored: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitForExpect(() => {
|
||||||
|
expect(
|
||||||
|
taskStore.findActiveByResource({
|
||||||
|
organizationId,
|
||||||
|
kind: "restore",
|
||||||
|
resourceType: "repository",
|
||||||
|
resourceId: repositoryShortId,
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("routes restore to the requested target agent", async () => {
|
test("routes restore to the requested target agent", async () => {
|
||||||
const organizationId = session.organizationId;
|
const organizationId = session.organizationId;
|
||||||
const agentId = `agent-${randomUUID()}`;
|
const agentId = `agent-${randomUUID()}`;
|
||||||
|
|
@ -572,14 +662,16 @@ describe("repositoriesService.restoreSnapshot", () => {
|
||||||
await fs.rm(targetPath, { recursive: true, force: true });
|
await fs.rm(targetPath, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(restoreMock).toHaveBeenCalledWith(
|
await waitForExpect(() => {
|
||||||
agentId,
|
expect(restoreMock).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
agentId,
|
||||||
payload: expect.objectContaining({
|
expect.objectContaining({
|
||||||
target: targetPath,
|
payload: expect.objectContaining({
|
||||||
|
target: targetPath,
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
}),
|
);
|
||||||
);
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("rejects a target agent outside the current organization", async () => {
|
test("rejects a target agent outside the current organization", async () => {
|
||||||
|
|
@ -701,20 +793,22 @@ describe("repositoriesService.restoreSnapshot", () => {
|
||||||
await fs.rm(targetPath, { recursive: true, force: true });
|
await fs.rm(targetPath, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(restoreMock).toHaveBeenCalledWith(
|
await waitForExpect(() => {
|
||||||
"local",
|
expect(restoreMock).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
"local",
|
||||||
payload: expect.objectContaining({
|
expect.objectContaining({
|
||||||
snapshotId: "snapshot-restore",
|
payload: expect.objectContaining({
|
||||||
target: targetPath,
|
snapshotId: "snapshot-restore",
|
||||||
repositoryConfig: expect.objectContaining({ backend: "local" }),
|
target: targetPath,
|
||||||
options: expect.objectContaining({
|
repositoryConfig: expect.objectContaining({ backend: "local" }),
|
||||||
organizationId,
|
options: expect.objectContaining({
|
||||||
basePath: "/",
|
organizationId,
|
||||||
|
basePath: "/",
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
}),
|
);
|
||||||
);
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -78,45 +78,68 @@ const executeControllerRestore = async (
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const executeAgentRestore = async (
|
||||||
|
request: RestoreExecutionRequest,
|
||||||
|
signal: AbortSignal,
|
||||||
|
): Promise<RestoreExecutionResult> => {
|
||||||
|
if (signal.aborted) {
|
||||||
|
return { status: "cancelled", message: "Restore was cancelled" };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = await createRestoreRunPayload(request);
|
||||||
|
const started = await agentManager.startRestore(request.executionAgentId, {
|
||||||
|
payload,
|
||||||
|
signal,
|
||||||
|
onStarted: request.onStarted,
|
||||||
|
onProgress: request.onProgress,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (started.status === "unavailable") {
|
||||||
|
return started;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await started.result;
|
||||||
|
} catch (error) {
|
||||||
|
if (signal.aborted) {
|
||||||
|
return { status: "cancelled", message: "Restore was cancelled" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: "failed", error: toMessage(error) };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const executeRestoreWithRepositoryLock = async (
|
||||||
|
request: RestoreExecutionRequest,
|
||||||
|
signal: AbortSignal,
|
||||||
|
): Promise<RestoreExecutionResult> => {
|
||||||
|
let releaseLock: (() => void) | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
releaseLock = await repoMutex.acquireShared(request.repositoryId, `restore:${request.restoreId}`, signal);
|
||||||
|
|
||||||
|
if (shouldRunInController(request.executionAgentId)) {
|
||||||
|
return await executeControllerRestore(request, signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await executeAgentRestore(request, signal);
|
||||||
|
} catch (error) {
|
||||||
|
if (signal.aborted) {
|
||||||
|
return { status: "cancelled", message: "Restore was cancelled" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: "failed", error: toMessage(error) };
|
||||||
|
} finally {
|
||||||
|
releaseLock?.();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const restoreExecutor = {
|
export const restoreExecutor = {
|
||||||
start: async (request: RestoreExecutionRequest): Promise<RestoreExecutionHandle> => {
|
start: (request: RestoreExecutionRequest): RestoreExecutionHandle => {
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
|
|
||||||
let releaseLock: (() => void) | null = null;
|
return {
|
||||||
try {
|
result: executeRestoreWithRepositoryLock(request, abortController.signal),
|
||||||
releaseLock = await repoMutex.acquireShared(
|
};
|
||||||
request.repositoryId,
|
|
||||||
`restore:${request.restoreId}`,
|
|
||||||
abortController.signal,
|
|
||||||
);
|
|
||||||
|
|
||||||
let result: Promise<RestoreExecutionResult>;
|
|
||||||
if (shouldRunInController(request.executionAgentId)) {
|
|
||||||
result = executeControllerRestore(request, abortController.signal);
|
|
||||||
} else {
|
|
||||||
const payload = await createRestoreRunPayload(request);
|
|
||||||
const started = await agentManager.startRestore(request.executionAgentId, {
|
|
||||||
payload,
|
|
||||||
signal: abortController.signal,
|
|
||||||
onStarted: request.onStarted,
|
|
||||||
onProgress: request.onProgress,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (started.status === "unavailable") {
|
|
||||||
throw started.error;
|
|
||||||
}
|
|
||||||
|
|
||||||
result = started.result;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
result: result.finally(() => {
|
|
||||||
releaseLock?.();
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
releaseLock?.();
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue