fix: renew cleanup ownership and fence late completion writes

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Harold KimandPaperclip committed 2026-09-11 22:58:28 +00:00
1 parent e3b11ec91e
commit 74ec4f685b
5 files changed
+74 -4

No files matched your search

@@ -328,6 +328,9 @@ describeEmbeddedPostgres("heartbeat sweepPendingCleanupLeases", () => {
const running = first.sweepPendingCleanupLeases();
await started.promise;
try {
await db.update(environmentLeases).set({
metadata: sql`${environmentLeases.metadata} || ${JSON.stringify({ pendingCleanupRetryAfterMs: Date.now() - 1 })}::jsonb`,
}).where(eq(environmentLeases.id, leaseId));
await second.sweepPendingCleanupLeases();
expect(teardown).toHaveBeenCalledTimes(1);
const [saved] = await db.select().from(environmentLeases).where(eq(environmentLeases.id, leaseId));
@@ -335,6 +338,42 @@ describeEmbeddedPostgres("heartbeat sweepPendingCleanupLeases", () => {
} finally { finish(); await running; }
});
it("renews cleanup ownership while the provider remains blocked", async () => {
const { companyId, environmentId } = await seedCompanyAndEnvironment();
const leaseId = await insertOrphanEphemeralLease({ companyId, environmentId, updatedAt: new Date(0) });
const started = Promise.withResolvers<void>(), finish = Promise.withResolvers<void>();
const service = heartbeatService(db, { environmentRuntime: { retryPendingSandboxTeardown: async () => {
started.resolve(); await finish.promise;
} } as unknown as HeartbeatEnvironmentRuntime });
vi.useFakeTimers({ toFake: ["setInterval", "clearInterval"] });
const running = service.sweepPendingCleanupLeases();
await started.promise;
try {
await db.update(environmentLeases).set({ metadata: sql`${environmentLeases.metadata} || '{"pendingCleanupRetryAfterMs":1}'::jsonb` }).where(eq(environmentLeases.id, leaseId));
await vi.advanceTimersByTimeAsync(30000);
await vi.waitFor(async () => expect((await readMetadata(leaseId))?.pendingCleanupRetryAfterMs).toBeGreaterThan(Date.now() + 14 * 60_000));
} finally { finish.resolve(); await running; vi.useRealTimers(); }
});
it.each([false, true])("ignores a superseded cleanup completion (throws: %s)", async throws => {
const { companyId, environmentId } = await seedCompanyAndEnvironment();
const leaseId = await insertOrphanEphemeralLease({ companyId, environmentId, updatedAt: new Date(0) });
const started = Promise.withResolvers<void>(), finish = Promise.withResolvers<void>();
const service = heartbeatService(db, { environmentRuntime: { retryPendingSandboxTeardown: async () => {
started.resolve(); await finish.promise; if (throws) throw new Error("old attempt failed");
} } as unknown as HeartbeatEnvironmentRuntime });
const running = service.sweepPendingCleanupLeases();
await started.promise;
try {
await db.update(environmentLeases).set({ status: "expired", cleanupStatus: "success",
metadata: { pendingCleanupAttemptId: "newer-attempt", remoteExecutionTermination: { proof: "newer-receipt" } },
}).where(eq(environmentLeases.id, leaseId));
} finally { finish.resolve(); await running; }
const [saved] = await db.select().from(environmentLeases).where(eq(environmentLeases.id, leaseId));
expect(saved.status).toBe("expired");
expect(saved.metadata?.remoteExecutionTermination).toEqual({ proof: "newer-receipt" });
});
// Two sweep ticks can overlap. Without an atomic claim, both read the same
// attempt count, both destroy the same lease, and the retry cap counts one
// attempt for two destroys. The atomic claim must let only one sweep destroy
@@ -3132,6 +3132,8 @@ function createSandboxEnvironmentDriver(
input.lease.id,
cleanupStatus === "success" ? "expired" : "pending_cleanup",
{
...(input.lease.status === "pending_cleanup" && typeof metadata.pendingCleanupAttemptId === "string"
? { expectedPendingCleanupAttemptId: metadata.pendingCleanupAttemptId } : {}),
failureReason: input.failureReason,
cleanupStatus,
...(cleanupStatus === "success" && termination ? { remoteExecutionTermination: termination } : {}),
+5 -1
View File
@@ -1613,6 +1613,7 @@ export function environmentService(db: Db) {
id: string,
status: Extract<EnvironmentLeaseStatus, "released" | "expired" | "failed" | "retained" | "pending_cleanup"> = "released",
options?: {
expectedPendingCleanupAttemptId?: string;
failureReason?: string;
cleanupStatus?: EnvironmentLeaseCleanupStatus;
remoteExecutionTermination?: Record<string, unknown>;
@@ -1634,7 +1635,10 @@ export function environmentService(db: Db) {
? sql`coalesce(${environmentLeases.metadata}, '{}'::jsonb) || ${JSON.stringify({ remoteExecutionTermination: options.remoteExecutionTermination })}::jsonb`
: sql`${environmentLeases.metadata} - 'remoteExecutionTermination'`,
})
.where(eq(environmentLeases.id, id))
.where(and(eq(environmentLeases.id, id), options?.expectedPendingCleanupAttemptId
? and(eq(environmentLeases.status, "pending_cleanup"),
sql`${environmentLeases.metadata}->>'pendingCleanupAttemptId' = ${options.expectedPendingCleanupAttemptId}`)
: undefined))
.returning()
.then((rows) => rows[0] ?? null);
return row ? toEnvironmentLease(row) : null;
+26 -3
View File
@@ -640,6 +640,7 @@ const NATIVE_OWNERSHIP_UNVERIFIED_MESSAGE =
"Native execution ownership could not be verified; automatic recovery is blocked";
// The reaper sweeps at most this many pending_cleanup leases per tick.
const PENDING_CLEANUP_SWEEP_PAGE_SIZE = 20;
const pendingCleanupAttemptsInFlight = new Set<string>();
// Escalate and slow cleanup after this many attempts; never abandon a live lease.
const PENDING_CLEANUP_SWEEP_ATTEMPT_CAP = 5;
// The reaper stores its retry state under these keys in the lease metadata.
@@ -17736,7 +17737,8 @@ export function heartbeatService(
// sweep. The backoff equals the reaper staleness threshold, so a lease waits
// for that period between attempts. The sweep reads and writes the attempt
// count in the lease metadata. It warns once when a lease reaches the attempt
// cap and then stops the retries for that lease.
// threshold, then continues with slower retries. Live attempts renew their
// cleanup claim; after controller loss, exact-resource destruction may repeat.
async function sweepPendingCleanupLeases(opts?: {
backoffMs?: number;
}): Promise<{
@@ -17787,6 +17789,7 @@ export function heartbeatService(
let destroyed = 0;
let capped = 0;
for (const row of rows) {
if (pendingCleanupAttemptsInFlight.has(row.id)) continue;
const metadata = { ...(row.metadata ?? {}) } as Record<string, unknown>;
const attempts = readPendingCleanupRetryAttempts(metadata);
@@ -17849,6 +17852,21 @@ export function heartbeatService(
// escalation threshold while attempt identities remain unique.
const claimed = await claimPendingCleanupRetryAttempt(row.id, attempts);
if (!claimed) continue;
pendingCleanupAttemptsInFlight.add(row.id);
lease.metadata = { ...lease.metadata, pendingCleanupAttemptId: claimed };
let renewing = false;
const renewal = setInterval(() => {
if (renewing) return;
renewing = true;
void db.update(environmentLeases).set({
metadata: sql`jsonb_set(${pendingCleanupMetadataObjectSql()}, '{pendingCleanupRetryAfterMs}', to_jsonb(${Date.now() + 15 * 60_000}::bigint))`,
}).where(and(eq(environmentLeases.id, row.id), eq(environmentLeases.status, "pending_cleanup"),
sql`${environmentLeases.metadata}->>'pendingCleanupAttemptId' = ${claimed}`,
sql`${environmentLeases.metadata}->>'pendingCleanupInFlight' = 'true'`))
.catch(() => logger.warn({ leaseId: row.id }, "cleanup ownership renewal failed"))
.finally(() => { renewing = false; });
}, 30_000);
renewal.unref();
try {
if (useRecordedTeardown) {
@@ -17859,12 +17877,13 @@ export function heartbeatService(
environment,
lease,
});
await environmentsSvc.releaseLease(lease.id, "expired", {
const released = await environmentsSvc.releaseLease(lease.id, "expired", {
expectedPendingCleanupAttemptId: claimed,
cleanupStatus: "success",
failureReason: "pending_cleanup_retry",
remoteExecutionTermination: remoteTerminationReceipt(lease, receipt),
});
destroyed += 1;
if (released) destroyed += 1;
} else if (environment) {
const result = await environmentRuntime.destroyRunLease({
environment,
@@ -17883,6 +17902,7 @@ export function heartbeatService(
// recorded-data teardown path.
if (useRecordedTeardown) {
await environmentsSvc.releaseLease(lease.id, "pending_cleanup", {
expectedPendingCleanupAttemptId: claimed,
cleanupStatus: "failed",
failureReason: "pending_cleanup_retry",
});
@@ -17898,6 +17918,9 @@ export function heartbeatService(
},
"pending_cleanup lease retry failed",
);
} finally {
clearInterval(renewal);
pendingCleanupAttemptsInFlight.delete(row.id);
}
if (attempts + 1 >= PENDING_CLEANUP_SWEEP_ATTEMPT_CAP) {
capped += 1;