{"record":{"id":"e316ed16f36cfa3f","repo":"koala73/worldmonitor","slug":"recordpendingexport-lease-lost-expected-runid","errorCode":null,"errorMessage":"[_recordPendingExport] lease lost: expected runId=${args.runId}, found ${row.pendingRunId ?? \"<cleared>\"}. Refusing to persist export progress — operator/another run owns the state.","messagePattern":"\\[_recordPendingExport\\] lease lost: expected runId=(.+?), found (.+?)\\. Refusing to persist export progress — operator/another run owns the state\\.","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"convex/broadcast/rampRunner.ts","lineNumber":720,"sourceCode":" * metadata if the action dies between this point and a successful\n * `_recordWaveSent`.\n *\n * Lease-validating: throws if the lease has changed (operator\n * `forceReleaseLease` mid-flight, or a different run claimed). The throw\n * bubbles to Convex auto-Sentry; the runner stops without advancing.\n */\nexport const _recordPendingExport = internalMutation({\n  args: {\n    runId: v.string(),\n    waveLabel: v.string(),\n    segmentId: v.string(),\n    assigned: v.number(),\n  },\n  handler: async (ctx, args) => {\n    const row = await loadConfig(ctx);\n    if (!row) throw new Error(\"[_recordPendingExport] no ramp configured\");\n    if (row.pendingRunId !== args.runId) {\n      throw new Error(\n        `[_recordPendingExport] lease lost: expected runId=${args.runId}, found ${row.pendingRunId ?? \"<cleared>\"}. Refusing to persist export progress — operator/another run owns the state.`,\n      );\n    }\n    await ctx.db.patch(row._id, {\n      pendingWaveLabel: args.waveLabel,\n      pendingSegmentId: args.segmentId,\n      pendingAssigned: args.assigned,\n      pendingExportAt: Date.now(),\n    });\n    return { ok: true as const };\n  },\n});\n\n/**\n * Persist post-`createProLaunchBroadcast` progress. Called by the runner\n * AFTER `createProLaunchBroadcast` returns successfully. Lets\n * `recoverFromPartialFailure` recover the broadcastId without\n * operator-supplied metadata if the action dies between this point and a","sourceCodeStart":702,"sourceCodeEnd":738,"githubUrl":"https://github.com/koala73/worldmonitor/blob/ffec79ac339946fd2d24e85845da5755dcaa534b/convex/broadcast/rampRunner.ts#L702-L738","documentation":"_recordPendingExport validates that broadcastRampConfig.pendingRunId still equals the caller's runId before persisting export progress. A mismatch means the lease was taken over — another run claimed it, or an operator called forceReleaseLease. The handler throws to avoid overwriting another owner's progress, and the error surfaces to Convex auto-Sentry so ops can investigate.","triggerScenarios":"Two concurrent runs (cron + manual, or a Convex runtime retry) where a second _claimTierForRun overwrote pendingRunId; an operator forceReleaseLease'd mid-flight; recoverFromPartialFailure cleared the lease.","commonSituations":"Cron fired while a manual run was still executing; a transient Convex retry re-entered the action after the lease was released; operator and automation both acted on a stalled run.","solutions":["Do not retry blindly — investigate which runId currently holds the lease and whether its wave completed.","If the current holder is stale/stuck, use forceReleaseLease then recoverFromPartialFailure as appropriate.","Prevent concurrency by ensuring the cron and manual triggers are mutually exclusive."],"exampleFix":null,"handlingStrategy":"try-catch","validationCode":"// Runner-side: verify lease ownership before persisting\nconst row = await ctx.db.query('broadcastRampConfig').withIndex('by_key', (q) => q.eq('key', 'current')).first();\nif (!row || row.pendingRunId !== runId) {\n  return { aborted: 'lease-lost' }; // do not persist; do not retry\n}","typeGuard":"function leaseIsOurs(row: { pendingRunId?: string } | null, runId: string): row is { pendingRunId: string } {\n  return row !== null && row.pendingRunId === runId;\n}","tryCatchPattern":"try {\n  await ctx.runMutation(internal.broadcast.rampRunner._recordPendingExport, { runId, ... });\n} catch (e) {\n  if ((e as Error).message.includes('lease lost')) {\n    // ownership moved — stop this run; investigate current holder\n    return { aborted: 'lease-lost', detail: (e as Error).message };\n  }\n  throw e;\n}","preventionTips":["Make the cron and any manual trigger mutually exclusive to avoid overlapping claims.","Treat lease-lost as terminal for the losing run — never blind-retry.","Use forceReleaseLease deliberately; it should be the only non-runner path that clears the lease."],"tags":["convex","broadcast-ramp","lease","concurrency","runner-internal"],"backgroundTag":null,"analyzedSha":"ffec79ac339946fd2d24e85845da5755dcaa534b","analyzedAt":"2026-08-12T11:24:56.012Z","schemaVersion":2},"datasetVersion":"2026-08-13T09:17:06.757Z"}