mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 17:45:53 +01:00
fix: renew cleanup ownership and fence late completion writes
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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 } : {}),
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user