feat(deployment): implement recovery for interrupted rebuilds
This commit is contained in:
1 parent
f6ff607777
commit
0a16bd9b17
4 files changed
+229
-4
No files matched your search
@@ -42,6 +42,7 @@ import {
|
||||
startDeploymentLockHeartbeat,
|
||||
} from "../services/deployment-lock.service";
|
||||
import { sanitizeDeploymentError } from "../services/deployment-error.service";
|
||||
import { reconcileInterruptedRebuild } from "../services/deployment-recovery.service";
|
||||
import {
|
||||
formatDockerInspectMountBindings,
|
||||
type DockerInspectMount as DockerInspectRuntimeMount,
|
||||
@@ -1766,7 +1767,7 @@ export async function containerRoutes(app: FastifyInstance) {
|
||||
);
|
||||
const container = await prisma.container.findFirst({
|
||||
where: { id, server: { organizationId: req.organizationId! } },
|
||||
select: { id: true },
|
||||
include: { server: true },
|
||||
});
|
||||
if (!container) {
|
||||
return reply
|
||||
@@ -1774,6 +1775,11 @@ export async function containerRoutes(app: FastifyInstance) {
|
||||
.send({ success: false, error: "Container not found" });
|
||||
}
|
||||
|
||||
await reconcileInterruptedRebuild({
|
||||
container,
|
||||
inspectRuntime: ssh.dockerInspect,
|
||||
});
|
||||
|
||||
const data = await listDeployments({
|
||||
containerId: id,
|
||||
organizationId: req.organizationId!,
|
||||
@@ -3805,6 +3811,11 @@ export async function containerRoutes(app: FastifyInstance) {
|
||||
let gitAccessToken: string | undefined;
|
||||
|
||||
try {
|
||||
await reconcileInterruptedRebuild({
|
||||
container,
|
||||
inspectRuntime: ssh.dockerInspect,
|
||||
});
|
||||
|
||||
if (container.sourceType === "APP_INSTALLER") {
|
||||
const install = await prisma.appInstall.findFirst({
|
||||
where: {
|
||||
|
||||
@@ -4,6 +4,7 @@ import prisma from "../lib/prisma";
|
||||
|
||||
export const DEFAULT_DEPLOYMENT_LOCK_TTL_MS = 15 * 60 * 1000;
|
||||
export const DEFAULT_DEPLOYMENT_LOCK_HEARTBEAT_MS = 60 * 1000;
|
||||
export const DEFAULT_DEPLOYMENT_LOCK_STALE_MS = 2 * 60 * 1000;
|
||||
|
||||
export class DeploymentLockConflictError extends Error {
|
||||
constructor() {
|
||||
@@ -15,17 +16,29 @@ export class DeploymentLockConflictError extends Error {
|
||||
export async function acquireDeploymentLock(input: {
|
||||
containerId: string;
|
||||
ttlMs?: number;
|
||||
staleMs?: number;
|
||||
}) {
|
||||
const now = new Date();
|
||||
const token = randomUUID();
|
||||
const expiresAt = new Date(
|
||||
Date.now() +
|
||||
Math.max(30_000, input.ttlMs ?? DEFAULT_DEPLOYMENT_LOCK_TTL_MS),
|
||||
);
|
||||
const staleBefore = new Date(
|
||||
now.getTime() -
|
||||
Math.max(30_000, input.staleMs ?? DEFAULT_DEPLOYMENT_LOCK_STALE_MS),
|
||||
);
|
||||
|
||||
try {
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const reclaimed = await tx.deploymentLock.updateMany({
|
||||
where: { containerId: input.containerId, expiresAt: { lte: new Date() } },
|
||||
where: {
|
||||
containerId: input.containerId,
|
||||
OR: [
|
||||
{ expiresAt: { lte: now } },
|
||||
{ updatedAt: { lte: staleBefore } },
|
||||
],
|
||||
},
|
||||
data: { token, expiresAt },
|
||||
});
|
||||
|
||||
@@ -44,6 +57,24 @@ export async function acquireDeploymentLock(input: {
|
||||
}
|
||||
}
|
||||
|
||||
export function isDeploymentLockActive(
|
||||
lock: { expiresAt: Date; updatedAt: Date } | null,
|
||||
options: { now?: Date; staleMs?: number } = {},
|
||||
) {
|
||||
if (!lock) return false;
|
||||
|
||||
const now = options.now ?? new Date();
|
||||
const staleMs = Math.max(
|
||||
30_000,
|
||||
options.staleMs ?? DEFAULT_DEPLOYMENT_LOCK_STALE_MS,
|
||||
);
|
||||
|
||||
return (
|
||||
lock.expiresAt.getTime() > now.getTime() &&
|
||||
lock.updatedAt.getTime() > now.getTime() - staleMs
|
||||
);
|
||||
}
|
||||
|
||||
export async function releaseDeploymentLock(input: {
|
||||
containerId: string;
|
||||
token: string;
|
||||
@@ -126,5 +157,3 @@ export function startDeploymentLockHeartbeat(
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { Server } from "@prisma/client";
|
||||
import prisma from "../lib/prisma";
|
||||
import { isDeploymentLockActive } from "./deployment-lock.service";
|
||||
|
||||
const RUNTIME_CREATION_CLOCK_SKEW_MS = 5_000;
|
||||
const INTERRUPTED_REBUILD_ERROR =
|
||||
"Rebuild process was interrupted before completion could be confirmed";
|
||||
|
||||
export type DeploymentRecoveryInspect = {
|
||||
Id?: unknown;
|
||||
Image?: unknown;
|
||||
Created?: unknown;
|
||||
Config?: { Image?: unknown } | null;
|
||||
State?: { Running?: unknown; Status?: unknown } | null;
|
||||
};
|
||||
|
||||
export function classifyInterruptedRebuild(input: {
|
||||
startedAt: Date;
|
||||
inspect: DeploymentRecoveryInspect | null;
|
||||
}) {
|
||||
const createdAt =
|
||||
typeof input.inspect?.Created === "string"
|
||||
? new Date(input.inspect.Created)
|
||||
: null;
|
||||
const createdDuringRebuild =
|
||||
createdAt !== null &&
|
||||
Number.isFinite(createdAt.getTime()) &&
|
||||
createdAt.getTime() >=
|
||||
input.startedAt.getTime() - RUNTIME_CREATION_CLOCK_SKEW_MS;
|
||||
const stateStatus = String(input.inspect?.State?.Status ?? "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const running =
|
||||
input.inspect?.State?.Running === true || stateStatus === "running";
|
||||
|
||||
return running && createdDuringRebuild ? "SUCCESS" : "FAILED";
|
||||
}
|
||||
|
||||
export async function reconcileInterruptedRebuild(input: {
|
||||
container: {
|
||||
id: string;
|
||||
dockerId: string | null;
|
||||
name: string;
|
||||
image: string;
|
||||
server: Server;
|
||||
};
|
||||
inspectRuntime: (
|
||||
server: Server,
|
||||
runtimeRef: string,
|
||||
) => Promise<DeploymentRecoveryInspect>;
|
||||
now?: Date;
|
||||
}) {
|
||||
const now = input.now ?? new Date();
|
||||
const deployment = await prisma.deployment.findFirst({
|
||||
where: {
|
||||
containerId: input.container.id,
|
||||
trigger: "REBUILD",
|
||||
status: "RUNNING",
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { id: true, startedAt: true, createdAt: true },
|
||||
});
|
||||
|
||||
if (!deployment) return null;
|
||||
|
||||
const lock = await prisma.deploymentLock.findUnique({
|
||||
where: { containerId: input.container.id },
|
||||
select: { token: true, expiresAt: true, updatedAt: true },
|
||||
});
|
||||
if (isDeploymentLockActive(lock, { now })) return null;
|
||||
|
||||
let inspect: DeploymentRecoveryInspect | null = null;
|
||||
const runtimeRefs = Array.from(
|
||||
new Set([input.container.dockerId, input.container.name].filter(Boolean)),
|
||||
) as string[];
|
||||
|
||||
for (const runtimeRef of runtimeRefs) {
|
||||
try {
|
||||
inspect = await input.inspectRuntime(input.container.server, runtimeRef);
|
||||
break;
|
||||
} catch {
|
||||
// A rebuild commonly replaces the old Docker ID. Fall back to the
|
||||
// stable container name before deciding the interrupted outcome.
|
||||
}
|
||||
}
|
||||
|
||||
const startedAt = deployment.startedAt ?? deployment.createdAt;
|
||||
const status = classifyInterruptedRebuild({ startedAt, inspect });
|
||||
const dockerId =
|
||||
typeof inspect?.Id === "string" && inspect.Id.trim()
|
||||
? inspect.Id.trim().slice(0, 12)
|
||||
: null;
|
||||
const runtimeImage =
|
||||
typeof inspect?.Config?.Image === "string" && inspect.Config.Image.trim()
|
||||
? inspect.Config.Image.trim()
|
||||
: input.container.image;
|
||||
const imageDigest =
|
||||
typeof inspect?.Image === "string" && inspect.Image.trim()
|
||||
? inspect.Image.trim()
|
||||
: null;
|
||||
|
||||
const recovered = await prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.deployment.updateMany({
|
||||
where: { id: deployment.id, status: "RUNNING" },
|
||||
data: {
|
||||
status,
|
||||
completedAt: now,
|
||||
error: status === "SUCCESS" ? null : INTERRUPTED_REBUILD_ERROR,
|
||||
...(status === "SUCCESS"
|
||||
? { image: runtimeImage, imageDigest }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
|
||||
if (updated.count !== 1) return false;
|
||||
|
||||
if (lock) {
|
||||
await tx.deploymentLock.deleteMany({
|
||||
where: { containerId: input.container.id, token: lock.token },
|
||||
});
|
||||
}
|
||||
|
||||
if (status === "SUCCESS") {
|
||||
await tx.container.update({
|
||||
where: { id: input.container.id },
|
||||
data: {
|
||||
status: "RUNNING",
|
||||
image: runtimeImage,
|
||||
...(dockerId ? { dockerId } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
return recovered ? { deploymentId: deployment.id, status } : null;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { classifyInterruptedRebuild } from "../../src/server/services/deployment-recovery.service";
|
||||
import { isDeploymentLockActive } from "../../src/server/services/deployment-lock.service";
|
||||
|
||||
const startedAt = new Date("2026-08-22T10:00:00.000Z");
|
||||
|
||||
test("interrupted rebuild is recovered as successful for a new running container", () => {
|
||||
assert.equal(
|
||||
classifyInterruptedRebuild({
|
||||
startedAt,
|
||||
inspect: {
|
||||
Created: "2026-08-22T10:00:05.000Z",
|
||||
State: { Running: true, Status: "running" },
|
||||
},
|
||||
}),
|
||||
"SUCCESS",
|
||||
);
|
||||
});
|
||||
|
||||
test("interrupted rebuild is not reported as successful for the old runtime", () => {
|
||||
assert.equal(
|
||||
classifyInterruptedRebuild({
|
||||
startedAt,
|
||||
inspect: {
|
||||
Created: "2026-08-20T10:00:00.000Z",
|
||||
State: { Running: true, Status: "running" },
|
||||
},
|
||||
}),
|
||||
"FAILED",
|
||||
);
|
||||
});
|
||||
|
||||
test("deployment lock becomes inactive when its heartbeat is stale", () => {
|
||||
const now = new Date("2026-08-22T10:05:00.000Z");
|
||||
|
||||
assert.equal(
|
||||
isDeploymentLockActive(
|
||||
{
|
||||
expiresAt: new Date("2026-08-22T10:15:00.000Z"),
|
||||
updatedAt: new Date("2026-08-22T10:00:00.000Z"),
|
||||
},
|
||||
{ now, staleMs: 120_000 },
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user