mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-15 01:52:55 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - A native run holds an execution lease on its `native_run_finalizations` row. The lease lasts 20 minutes, and the owner renews it every 5 minutes. > - When a laptop sleeps for longer than the lease, the wall clock and the database clock move past `lease_expires_at`. The server's monotonic timers barely move. > - After the wake, the next renewal finds its own lease expired. It interrupts a healthy turn with `native_session_lease_lost`, and the user must click Retry. Nobody else took the lease. > - Several background consumers act on an expired lease without changing its owner. So expiry alone cannot simply be ignored for the owner. > - This pull request makes takeover explicit. The same owner can take back its own lapsed lease only when no other actor has recorded anything on the row or the run. > - The benefit is that a run interrupted only by host sleep continues with no Retry. A real takeover still fences the late owner out. ## Linked Issues or Issue Description **What happened?** The restart-chaos time fault TM2 stops the server and runner tree and ages every database time by one hour, as a stand-in for a laptop that slept. After the thaw, the next 5-minute renewal of the native execution lease found the lease expired. It interrupted the turn (`turn.interrupted` at 300 s, `native_session_lease_lost`). The failure settlement was then refused with the same code, and the UI showed "Couldn't finish this attempt" with Retry. The owner, attempt, boot and pid were unchanged. Evidence: wave `w-20261110T051535-s304` t01 (TM2, 1 h) failed R3. t02 in the same wave (TM1, a 113 s server-only freeze with no clock jump) passed, so the failure follows the clock jump and not the pause. **Is this a harness artifact?** No. TM2 ages the stored rows, and a real sleep moves the clock forward. Both produce the same comparison: a stored `lease_expires_at` is earlier than `clock_timestamp()` after the wake. The server's renewal timer and local deadline use the monotonic clock. On macOS and Linux that clock does not count suspended time, so they do not notice the sleep in either case. The first database check after the wake is the renewal predicate `lease_expires_at > clock_timestamp()`, and it fails in both cases. **Expected behavior** A run whose lease lapsed only because time jumped, and that no other actor claimed, keeps running and finishes without Retry. **Steps to reproduce** `node cli/node_modules/tsx/dist/cli.mjs tests/restart-chaos/main.ts --env local --adapters codex --workloads B5 --faults TM2 --time-age-hours 1 --concurrency 1 --tasks 2 --seed 304` with the harness from #20458. Refs #20458, #20500 ## What Changed - `native-execution-lease.ts`: add `nativeExecutionLeaseHeldCondition` and `lockNativeExecutionLeaseForReacquire`. A lease is held when it is live. For a local run, it is also held when it lapsed and is untouched. "Untouched" means all of these are true: - the claimed generation matches; - the phase is `observed` and no result is accepted; - `recovery_state`, `recovery_request_id`, `failure_code` and `next_attempt_at` are null; - `controller_host_hash` is not the board's attested-stopped stamp; - the run is still `running`, without the ownership-unverified error code and without a `nativeStartupCleanup` marker. - The caller's WHERE already matches the owner token, attempt and controller boot/pid/start. - The lock helper locks the coordinator row. If the lease lapsed, it also takes a share lock on the run row. The predicate then reads the latest committed run, and an in-flight run write cannot slip past it. - Renewal (`startNativeSessionExecutionLeaseRenewal` / `renewNativeSessionExecutionLease`) takes back a lapsed lease when the executor passes its claimed generation. Remote targets never do. A deadlock (40P01) on that rare path retries at the next tick, as a statement timeout already does. - `native-session-executor.ts`: the executor passes its generation to renewal. Failure settlement and the final release use the same lock and condition. After a result is accepted, a lapsed lease can only be relinquished (set to null), never renewed, because workspace finalization may act on it. - New `native-execution-lease-reacquire.test.ts` (embedded Postgres, 24 cases). - `doc/plans/2026-11-09-explicit-native-lease-claim-tokens.md`: step 2, which adds explicit claim tokens for the consumers this PR does not yet move. Consumer inventory (readers of `native_run_finalizations.lease_expires_at` that can act on a lapsed lease): - Restart recovery and takeover (`claimNativeRestartRecoveries`): claims first. It writes `recovery_state`, `lease_owner` or `controller_*` under the coordinator lock. - Finalizer and cleanup claims: claim first (`lease_owner`). - Failed-run owner release, environment settlement, observed-workspace preservation, retained-session cleanup: require a terminal run status, `committed`, or a recovery state. The rule refuses all of these. - Finalization reconciler, live recovery: require `retryable_failure`, a field the rule checks. - Board controller-stopped confirmation: writes `controller_host_hash`, which the rule checks. - Startup cleanup promotion: writes the run's `nativeStartupCleanup` marker, which the rule checks. It also applies only to runner-task leases. - Another server's orphan sweep: writes the run's ownership-unverified code. The rule checks it when the marker lands first. The reverse order is deferred (see Risks). - Workspace finalization copyback: acts on a lapsed post-result lease without a coordinator write. Post-result leases are therefore never renewed (deferred to step 2). - Accepted-result and token lease settlement, checkpoints, cancellation delivery, admission and the other readers: act on live leases only, or are read-only. ## Verification - `cd server && npx vitest run src/services/native-runtime/native-execution-lease-reacquire.test.ts` passes (24/24) on this branch. With the source files from `origin/master`, 5 fail, including "lets the same owner take back a lease that lapsed untouched" and "renews from the shared timer after the sleep instead of interrupting the turn". - (a) The owner takes back its lapsed lease when no other actor claimed it. Also covered: the shared 5-minute timer, rows saved by the previous release, and the post-result relinquish. - (b) 14 cases where a consumer claimed first fence the owner out and leave the row unchanged. A release over a recorded recovery claim is refused. A different boot (a deploy mid-run), a different generation, a missing generation and a remote target are refused. - (c) Races have exactly one winner: a claimant and the owner lock the coordinator in each order, an in-flight run status write races the owner, and 8 simultaneous starts each have one winner. - Existing suites pass: `native-execution-lease.test.ts`, `native-session-executor.test.ts`, `native-runner-restart-recovery`, `native-runner-restart-lease-renewal`, `native-runner-restart-preparation` and `native-runner-restart-checkpoint-cost` integration tests. - Live proof: `B5 × TM2 --time-age-hours 1 --tasks 2 --seed 304` with `--server-from` this branch. Wave `w-20261110T083444-s304` passed 2/2, with no Retry and no `native_session_lease_lost`. - Both runs ran past the 300 s renewal after the one-hour aging, at the exact point where `origin/master` fails. t01 ran 310 s and t02 ran 319 s. Each was a single run that succeeded. - The harness used the aging exclusion from #20500 (merged): process start identity is not aged. Without that exclusion, TM2 also moves `controller_process_started_at`. The live owner then fails its identity check, which a real sleep never causes. An earlier wave on the unpatched harness, `w-20261110T072106-s304`, failed 0/2 for that reason only. ## Risks - Previous release: no persisted shape changes and no migration. Rows saved by the previous release have the same columns, and a null `controller_host_hash` is accepted. A deploy that lands mid-run cannot take back the old server's lease, because the boot and pid differ. - On the normal path, a live lease takes the same single coordinator lock as before. The run share lock is taken only after a lapse, and nothing is added before `turn.start`. - Deferred to step 2 (see the plan): - The orphan sweep of another server process can read the row before the owner takes the lease back and write after. That run would carry an ownership-unverified error while it is live. - Shared-workspace holder detection reads a silent run with lapsed leases as gone. - Post-result leases are not renewed. - Remote leases are unchanged. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, used in Claude Code with tool use (shell, file editing, sub-agent code audit). ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [ ] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude <noreply@anthropic.com>
334 lines
18 KiB
TypeScript
334 lines
18 KiB
TypeScript
import { notifyLiveRunRecovery } from "../live-run-recovery-notifications.js";
|
|
import type { Db } from "@paperclipai/db";
|
|
import { heartbeatRuns, livenessDb, nativeRunFinalizations } from "@paperclipai/db";
|
|
import { and, eq, gt, isNull, or, sql, type SQL } from "drizzle-orm";
|
|
import { currentNativeControllerIdentity, type NativeControllerIdentity } from "./native-restart-recovery.js";
|
|
import { OPERATOR_ATTESTED_CONTROLLER_HOST } from "./native-controller-host-recovery.js";
|
|
import { NATIVE_OWNERSHIP_UNVERIFIED_ERROR_CODE } from "./native-runner-ownership.js";
|
|
import { REMOTE_CONTROLLER_LEASE_MS } from "./native-runner-recovery-contract.js";
|
|
|
|
export const NATIVE_SESSION_EXECUTION_LEASE_TTL_MS = 20 * 60_000;
|
|
const NATIVE_SESSION_EXECUTION_LEASE_RENEW_INTERVAL_MS = 5 * 60_000;
|
|
const RENEWAL_STATEMENT_TIMEOUT_MS = 10_000;
|
|
|
|
/**
|
|
* Takeover of a native execution lease is explicit, not inferred from expiry.
|
|
*
|
|
* A lease can lapse with its owner alive: a host sleep jumps the wall clock
|
|
* (and the database clock) past `lease_expires_at` while this process's
|
|
* monotonic timers barely move. Expiry then means only "nobody renewed", not
|
|
* "the owner is gone". The same owner may take its own lapsed lease back when
|
|
* no other actor has recorded anything on the row or run since.
|
|
*
|
|
* Every actor that acts on a lapsed executor lease while the run is still
|
|
* running first writes one of the fields checked here, under the coordinator
|
|
* row lock: restart recovery and takeover (`recovery_state`, `lease_owner`,
|
|
* `controller_*`), the finalizer and cleanup (`lease_owner`), failure
|
|
* settlement (`phase`, `failure_code`, `next_attempt_at`, run status), the
|
|
* board's controller-stopped confirmation (`controller_host_hash`), another
|
|
* server's orphan sweep (the run's ownership-unverified error code) and startup
|
|
* cleanup (the run's `nativeStartupCleanup` marker). Exactly one side wins:
|
|
* either that write commits first and the owner stays fenced out, or the
|
|
* owner's lease is live again and those actors defer to it as before.
|
|
*
|
|
* Pre-result only: phase `observed` without an accepted result. After a result
|
|
* is accepted, workspace finalization may act on a lapsed lease without such a
|
|
* write, so a lapsed post-result lease is only ever relinquished, never renewed.
|
|
* Local runs only: a remote runner is fenced by its own controller lease.
|
|
*
|
|
* Callers lock with `lockNativeExecutionLeaseForReacquire` first, so the run
|
|
* row this reads is the latest committed version and stays so until commit.
|
|
*/
|
|
function nativeExecutionLeaseUntouchedCondition(stage: "observed" | "accepted"): SQL {
|
|
const coordinatorStage = stage === "observed"
|
|
? sql`${nativeRunFinalizations.phase} = 'observed' and ${nativeRunFinalizations.resultId} is null`
|
|
: sql`${nativeRunFinalizations.phase} = 'workspace_finalizing' and ${nativeRunFinalizations.resultId} is not null`;
|
|
return sql`(${coordinatorStage}
|
|
and ${nativeRunFinalizations.recoveryState} is null
|
|
and ${nativeRunFinalizations.recoveryRequestId} is null
|
|
and ${nativeRunFinalizations.failureCode} is null
|
|
and ${nativeRunFinalizations.nextAttemptAt} is null
|
|
and ${nativeRunFinalizations.controllerHostHash} is distinct from ${OPERATOR_ATTESTED_CONTROLLER_HOST}
|
|
and exists (select 1 from ${heartbeatRuns}
|
|
where ${heartbeatRuns.id} = ${nativeRunFinalizations.runId}
|
|
and ${heartbeatRuns.companyId} = ${nativeRunFinalizations.companyId}
|
|
and ${heartbeatRuns.status} = 'running'
|
|
and ${heartbeatRuns.errorCode} is distinct from ${NATIVE_OWNERSHIP_UNVERIFIED_ERROR_CODE}
|
|
and (${heartbeatRuns.resultJson} is null or ${heartbeatRuns.resultJson} -> 'nativeStartupCleanup' is null)))`;
|
|
}
|
|
|
|
/**
|
|
* The executor's own lease is held: live, or (local runs only) lapsed and
|
|
* untouched since, so the same owner takes it back in this statement. The
|
|
* caller's WHERE must also match lease owner, attempt and controller identity;
|
|
* `controllerGeneration` is required to take back a lapsed lease.
|
|
*/
|
|
export function nativeExecutionLeaseHeldCondition(input: {
|
|
remote: boolean; controllerGeneration?: number; relinquishAccepted?: boolean;
|
|
}): SQL {
|
|
const live = gt(nativeRunFinalizations.leaseExpiresAt, sql`clock_timestamp()`);
|
|
if (input.remote || input.controllerGeneration === undefined) return live;
|
|
const untouched = input.relinquishAccepted
|
|
? or(nativeExecutionLeaseUntouchedCondition("observed"), nativeExecutionLeaseUntouchedCondition("accepted"))!
|
|
: nativeExecutionLeaseUntouchedCondition("observed");
|
|
return or(live, and(eq(nativeRunFinalizations.controllerGeneration, input.controllerGeneration), untouched))!;
|
|
}
|
|
|
|
/**
|
|
* Lock order for `nativeExecutionLeaseHeldCondition`: the coordinator row, then,
|
|
* only when its lease has lapsed, the run row (shared). The share lock waits for
|
|
* any in-flight run write and holds the run until commit, so the next statement
|
|
* reads the run's latest committed state. A live lease takes no extra lock.
|
|
* Returns whether the lease was still live.
|
|
*/
|
|
export async function lockNativeExecutionLeaseForReacquire(tx: Pick<Db, "execute">, input: {
|
|
runId: string; companyId: string; issueId: string;
|
|
/**
|
|
* Lock only a row the caller's UPDATE could match: this owner's, and with
|
|
* `preResultOnly` one without an accepted result. A row it cannot match
|
|
* stays unlocked, as the UPDATE alone would leave it.
|
|
*/
|
|
leaseOwner?: string; preResultOnly?: boolean;
|
|
}): Promise<boolean> {
|
|
const rows = await tx.execute<{ live: boolean }>(sql`select coalesce(${nativeRunFinalizations.leaseExpiresAt} > clock_timestamp(), false) as live
|
|
from ${nativeRunFinalizations}
|
|
where ${nativeRunFinalizations.runId} = ${input.runId}
|
|
and ${nativeRunFinalizations.companyId} = ${input.companyId}
|
|
and ${nativeRunFinalizations.issueId} = ${input.issueId}
|
|
${input.leaseOwner === undefined ? sql`` : sql`and ${nativeRunFinalizations.leaseOwner} = ${input.leaseOwner}`}
|
|
${input.preResultOnly ? sql`and ${nativeRunFinalizations.resultId} is null` : sql``}
|
|
for update`);
|
|
const row = (rows as unknown as Array<{ live: boolean }> | undefined)?.[0];
|
|
if (!row) return false;
|
|
if (row.live) return true;
|
|
await tx.execute(sql`select 1 from ${heartbeatRuns}
|
|
where ${heartbeatRuns.id} = ${input.runId} and ${heartbeatRuns.companyId} = ${input.companyId}
|
|
for share`);
|
|
return false;
|
|
}
|
|
|
|
/** Preparation can perform provider I/O only under its current recovery claim. */
|
|
export async function assertNativeSessionPreparationAuthority(input: {
|
|
db: Db; companyId: string; runId: string; leaseOwner: string | undefined;
|
|
controllerGeneration?: number; providerAttempt?: number;
|
|
}) {
|
|
if (!input.leaseOwner) throw new Error("native_session_lease_lost");
|
|
const controller = await currentNativeControllerIdentity();
|
|
const [owned] = await input.db.select({ runId: nativeRunFinalizations.runId })
|
|
.from(nativeRunFinalizations).innerJoin(heartbeatRuns, eq(heartbeatRuns.id, nativeRunFinalizations.runId))
|
|
.where(and(eq(nativeRunFinalizations.companyId, input.companyId), eq(nativeRunFinalizations.runId, input.runId),
|
|
eq(heartbeatRuns.companyId, input.companyId), eq(heartbeatRuns.status, "running"),
|
|
eq(nativeRunFinalizations.leaseOwner, input.leaseOwner),
|
|
eq(nativeRunFinalizations.controllerBootId, controller.bootId), eq(nativeRunFinalizations.controllerPid, controller.pid),
|
|
eq(nativeRunFinalizations.controllerProcessStartedAt, controller.processStartedAt),
|
|
gt(nativeRunFinalizations.leaseExpiresAt, sql`clock_timestamp()`),
|
|
...(input.controllerGeneration === undefined ? [] : [eq(nativeRunFinalizations.controllerGeneration, input.controllerGeneration)]),
|
|
...(input.providerAttempt === undefined ? [] : [eq(nativeRunFinalizations.attempt, input.providerAttempt)]),
|
|
)).limit(1);
|
|
if (!owned) throw new Error("native_session_lease_lost");
|
|
}
|
|
|
|
export async function nativeRecoveryClaimRelinquished(input: {
|
|
db: Db; companyId: string; runId: string; leaseOwner: string;
|
|
}): Promise<boolean> {
|
|
const [row] = await input.db.select({
|
|
status: heartbeatRuns.status, resultId: nativeRunFinalizations.resultId,
|
|
leaseOwner: nativeRunFinalizations.leaseOwner, recoveryState: nativeRunFinalizations.recoveryState,
|
|
leaseLive: sql<boolean>`coalesce(${nativeRunFinalizations.leaseExpiresAt} > clock_timestamp(), false)`,
|
|
lastDisposition: sql<string | null>`${nativeRunFinalizations.recoveryHistory} -> -1 ->> 'disposition'`,
|
|
}).from(nativeRunFinalizations).innerJoin(heartbeatRuns, eq(heartbeatRuns.id, nativeRunFinalizations.runId))
|
|
.where(and(eq(nativeRunFinalizations.companyId, input.companyId), eq(nativeRunFinalizations.runId, input.runId),
|
|
eq(heartbeatRuns.companyId, input.companyId))).limit(1);
|
|
if (!row || row.status !== "running" || row.resultId) return false;
|
|
if (row.leaseOwner !== null && row.leaseOwner !== input.leaseOwner) return true;
|
|
return row.leaseOwner === input.leaseOwner && !row.leaseLive && row.recoveryState === "awaiting_runner_reattach"
|
|
&& row.lastDisposition === "restart_suspended";
|
|
}
|
|
|
|
/**
|
|
* Preparation failed after a fresh claim and before any provider turn. Release
|
|
* only this exact claim, so ordinary failure settlement can retain the source
|
|
* as it does for failures before the claim. Any other owner or a result keeps it.
|
|
*/
|
|
export async function releaseUnstartedNativeExecutionClaim(input: {
|
|
db: Db; runId: string; companyId: string; issueId: string; leaseOwner: string; attempt: number;
|
|
}): Promise<boolean> {
|
|
const released = await input.db.update(nativeRunFinalizations)
|
|
.set({ leaseOwner: null, leaseExpiresAt: null, updatedAt: new Date() })
|
|
.where(and(eq(nativeRunFinalizations.runId, input.runId), eq(nativeRunFinalizations.companyId, input.companyId),
|
|
eq(nativeRunFinalizations.issueId, input.issueId), eq(nativeRunFinalizations.leaseOwner, input.leaseOwner),
|
|
eq(nativeRunFinalizations.attempt, input.attempt), eq(nativeRunFinalizations.phase, "observed"),
|
|
isNull(nativeRunFinalizations.resultId)))
|
|
.returning({ runId: nativeRunFinalizations.runId });
|
|
return released.length > 0;
|
|
}
|
|
|
|
export async function renewNativeSessionExecutionLease(input: {
|
|
db: Db;
|
|
runId: string;
|
|
companyId: string;
|
|
issueId: string;
|
|
leaseOwner: string;
|
|
attempt: number;
|
|
controller?: NativeControllerIdentity;
|
|
leaseTtlMs?: number;
|
|
/** Local runs only: lets this exact owner take back a lease that lapsed untouched (host sleep). */
|
|
reacquire?: { controllerGeneration: number };
|
|
}, { statementTimeoutMs = RENEWAL_STATEMENT_TIMEOUT_MS } = {}): Promise<void> {
|
|
await renewNativeSessionExecutionLeaseDeadline(input, { statementTimeoutMs });
|
|
}
|
|
|
|
async function renewNativeSessionExecutionLeaseDeadline(
|
|
input: Parameters<typeof renewNativeSessionExecutionLease>[0],
|
|
{ statementTimeoutMs = RENEWAL_STATEMENT_TIMEOUT_MS } = {},
|
|
): Promise<number> {
|
|
const controller =
|
|
input.controller ?? (await currentNativeControllerIdentity());
|
|
const leaseTtlMs = input.leaseTtlMs ?? NATIVE_SESSION_EXECUTION_LEASE_TTL_MS;
|
|
if (
|
|
!Number.isInteger(leaseTtlMs) ||
|
|
leaseTtlMs < 1_000 ||
|
|
leaseTtlMs > NATIVE_SESSION_EXECUTION_LEASE_TTL_MS
|
|
) {
|
|
throw new Error("native_session_lease_ttl_invalid");
|
|
}
|
|
if (!Number.isSafeInteger(statementTimeoutMs) || statementTimeoutMs < 1 || statementTimeoutMs > RENEWAL_STATEMENT_TIMEOUT_MS) {
|
|
throw new Error("native_session_renewal_timeout_invalid");
|
|
}
|
|
// Use the existing private pool; application queries must not consume the
|
|
// remote controller's short renewal window. The timeout cancels actual SQL.
|
|
let confirmedDeadline = 0;
|
|
const [updated] = await livenessDb(input.db).transaction(async tx => {
|
|
await tx.execute(sql`select set_config('statement_timeout', ${`${statementTimeoutMs}ms`}, true)`);
|
|
// Check expiry after acquiring the row lock. An UPDATE alone can evaluate
|
|
// its predicate before waiting and revive an owner that expired in the wait.
|
|
await lockNativeExecutionLeaseForReacquire(tx, input);
|
|
// The database extends from UPDATE time. Exclude connection/lock waits,
|
|
// but conservatively include UPDATE/commit response time without assuming
|
|
// that this server's wall clock agrees with the database's clock.
|
|
confirmedDeadline = performance.now() + leaseTtlMs;
|
|
await notifyLiveRunRecovery(tx, { companyId: input.companyId, runId: input.runId });
|
|
return tx.update(nativeRunFinalizations)
|
|
.set({
|
|
leaseExpiresAt: sql`clock_timestamp() + (${leaseTtlMs} * interval '1 millisecond')`,
|
|
updatedAt: sql`clock_timestamp()`,
|
|
})
|
|
.where(
|
|
and(
|
|
eq(nativeRunFinalizations.runId, input.runId),
|
|
eq(nativeRunFinalizations.companyId, input.companyId),
|
|
eq(nativeRunFinalizations.issueId, input.issueId),
|
|
eq(nativeRunFinalizations.leaseOwner, input.leaseOwner),
|
|
eq(nativeRunFinalizations.attempt, input.attempt),
|
|
eq(nativeRunFinalizations.controllerBootId, controller.bootId),
|
|
eq(nativeRunFinalizations.controllerPid, controller.pid),
|
|
eq(
|
|
nativeRunFinalizations.controllerProcessStartedAt,
|
|
controller.processStartedAt,
|
|
),
|
|
nativeExecutionLeaseHeldCondition({ remote: !input.reacquire,
|
|
controllerGeneration: input.reacquire?.controllerGeneration }),
|
|
),
|
|
)
|
|
.returning({ runId: nativeRunFinalizations.runId });
|
|
});
|
|
if (!updated) throw new Error("native_session_lease_lost");
|
|
return confirmedDeadline;
|
|
}
|
|
|
|
type RenewalInput = {
|
|
db: Db; runId: string; companyId: string; issueId: string;
|
|
leaseOwner: string; attempt: number; controller: NativeControllerIdentity;
|
|
remote?: boolean;
|
|
/** The claimed generation. Local runs then take back a lease that lapsed untouched (host sleep). */
|
|
controllerGeneration?: number;
|
|
confirmedDeadline?: number;
|
|
onLost: () => Promise<unknown>;
|
|
};
|
|
export interface NativeExecutionLeaseRenewal { stop(): Promise<void>; remainingMs(): number; }
|
|
|
|
// Preparation and execution share one renewal timer. Each caller releases its
|
|
// own reference; the final release drains any SQL already in flight.
|
|
const renewals = new WeakMap<Db, Map<string, {
|
|
input: RenewalInput; references: number; timer: ReturnType<typeof setInterval>;
|
|
pending: Promise<void>; busy: boolean; lost: Error | null;
|
|
deadline: ReturnType<typeof setTimeout>; loss: Promise<unknown>;
|
|
confirmedDeadline: number;
|
|
}>>();
|
|
|
|
export function startNativeSessionExecutionLeaseRenewal(input: RenewalInput): NativeExecutionLeaseRenewal {
|
|
let owners = renewals.get(input.db);
|
|
if (!owners) { owners = new Map(); renewals.set(input.db, owners); }
|
|
let state = owners.get(input.leaseOwner);
|
|
if (state) {
|
|
if (state.input.runId !== input.runId || state.input.companyId !== input.companyId
|
|
|| state.input.issueId !== input.issueId || state.input.attempt !== input.attempt
|
|
|| state.input.controller.bootId !== input.controller.bootId || state.input.remote !== input.remote) {
|
|
throw new Error("native_session_renewal_binding_changed");
|
|
}
|
|
if (state.lost) throw state.lost;
|
|
state.references += 1;
|
|
} else {
|
|
const ttlMs = input.remote ? REMOTE_CONTROLLER_LEASE_MS : NATIVE_SESSION_EXECUTION_LEASE_TTL_MS;
|
|
const lose = (error: unknown) => {
|
|
if (state!.lost) return;
|
|
state!.lost = error instanceof Error ? error : new Error("native_session_lease_lost");
|
|
clearInterval(state!.timer);
|
|
clearTimeout(state!.deadline);
|
|
state!.loss = Promise.resolve().then(input.onLost).catch(() => undefined);
|
|
};
|
|
const deadline = (delay: number) => {
|
|
const timer = setTimeout(() => lose(new Error("native_session_lease_renewal_deadline")), Math.max(0, delay));
|
|
timer.unref?.();
|
|
return timer;
|
|
};
|
|
const renew = () => {
|
|
if (state!.lost || state!.busy) return;
|
|
state!.busy = true;
|
|
state!.pending = renewNativeSessionExecutionLeaseDeadline({ ...input, leaseTtlMs: ttlMs,
|
|
reacquire: !input.remote && input.controllerGeneration !== undefined
|
|
? { controllerGeneration: input.controllerGeneration } : undefined })
|
|
.then(confirmedDeadline => {
|
|
// A late database response cannot restore local authority after loss
|
|
// or recreate a timer after the final caller has stopped.
|
|
if (state!.lost || state!.references === 0) return;
|
|
clearTimeout(state!.deadline);
|
|
state!.confirmedDeadline = confirmedDeadline;
|
|
state!.deadline = deadline(confirmedDeadline - performance.now());
|
|
})
|
|
.catch((error: unknown) => {
|
|
// A cancelled statement can retry at the next tick, but does not
|
|
// extend the ownership deadline. Never queue overlapping attempts.
|
|
for (let cause = error, depth = 0; cause && depth < 4; cause = (cause as { cause?: unknown }).cause, depth++) {
|
|
// 40P01: taking back a lapsed lease locks the run after the
|
|
// coordinator; a writer holding them in the other order can deadlock.
|
|
const code = (cause as { code?: unknown }).code;
|
|
if (code === "57014" || code === "40P01") return;
|
|
}
|
|
lose(error);
|
|
}).finally(() => { state!.busy = false; });
|
|
};
|
|
const timer = setInterval(renew, input.remote ? REMOTE_CONTROLLER_LEASE_MS / 3 : NATIVE_SESSION_EXECUTION_LEASE_RENEW_INTERVAL_MS);
|
|
timer.unref?.();
|
|
const confirmedDeadline = input.confirmedDeadline ?? performance.now() + ttlMs;
|
|
state = { input, references: 1, timer, pending: Promise.resolve(), busy: false, lost: null,
|
|
deadline: deadline(confirmedDeadline - performance.now()), confirmedDeadline, loss: Promise.resolve() };
|
|
owners.set(input.leaseOwner, state);
|
|
}
|
|
const owned = state;
|
|
let stopped = false;
|
|
return { remainingMs: () => owned.lost ? 0 : Math.max(0, owned.confirmedDeadline - performance.now()), stop: async () => {
|
|
if (!stopped) {
|
|
stopped = true;
|
|
if (--owned.references === 0) {
|
|
clearInterval(owned.timer);
|
|
clearTimeout(owned.deadline);
|
|
owners!.delete(input.leaseOwner);
|
|
}
|
|
}
|
|
await owned.pending;
|
|
await owned.loss;
|
|
if (owned.lost) throw owned.lost;
|
|
} };
|
|
}
|