fix: renew cleanup ownership and fence late completion writes
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
e3b11ec91e
commit
74ec4f685b
|
|
@ -970,3 +970,5 @@ in-flight deadline. It retries after restart, waits at least 30 seconds between
|
|||
failed attempts, and slows to 30 minutes after five failures. It reports that
|
||||
operator attention is needed at that threshold, while automatic cleanup continues.
|
||||
Provider outages never convert a live sandbox into an abandoned manual task.
|
||||
|
||||
A live cleanup attempt renews its durable claim every 30 seconds. Another sweep in the same controller cannot overlap it, even if the deadline passes. Completion writes require the current attempt identity. After controller loss, cleanup can repeat destruction of the exact quarantined provider resource; providers must make that operation idempotent. A timeout or claim expiry does not prove termination.
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue