santifer/career-ops · error · SeedError
LOCK_TIMEOUT
LOCK_TIMEOUT
Error message
Timed out waiting for follow-ups lock at ${lockDir} What it means
acquireFollowupsLock uses a lock directory with retry/backoff to serialize concurrent mutations of data/follow-ups.md. When it cannot acquire the lock within the timeout window, it throws a SeedError with code LOCK_TIMEOUT. This protects the follow-ups file from concurrent-corruption during writes by seedFollowup or other tooling.
Solutions
- Wait briefly and re-run the command — the competing process may finish and release the lock.
- Check whether another career-ops process (seed, merge, tracker write) is still running and let it finish or stop it.
- If no other process is running, remove the stale lock directory referenced in the message and re-run.
- Avoid parallel seedFollowup invocations; run seeding sequentially (locks exist precisely because concurrent writes are unsafe).
Example fix
// before: parallel fan-out causes lock contention
await Promise.all(rows.map(r => seedFollowup(r.num)));
// after: sequential seeding avoids timeout
for (const r of rows) {
await seedFollowup(r.num);
} Defensive patterns
Strategy: retry
Validate before calling
import { existsSync } from 'node:fs';
const lockDir = join(process.cwd(), 'data', '.followups.lock');
if (existsSync(lockDir)) {
const ageMs = Date.now() - (await stat(lockDir)).mtimeMs;
if (ageMs < 60_000) await new Promise(r => setTimeout(r, Math.min(60_000 - ageMs, 5000)));
} Try / catch
try {
await seedFollowup(appNum);
} catch (e) {
if (e.code === 'LOCK_TIMEOUT') {
console.error('Follow-ups lock busy; retrying once after a delay...');
await new Promise(r => setTimeout(r, 5000));
await seedFollowup(appNum);
} else throw e;
} Prevention
- Run follow-up seeding sequentially, never in Promise.all fan-outs.
- Ensure the process releases the lock on exit (avoid SIGKILL; handle SIGINT cleanup).
- Clean stale lock directories after crashed runs before re-running.
- Keep the lock directory on a local filesystem, not a slow network mount.
When it happens
Trigger: Another process holds the follow-ups lock past the timeout budget; a crashed run left a stale lock directory behind; many seedFollowup invocations run in parallel and each retries until the overall deadline expires.
Common situations: Batch mode fanning out several seedFollowup calls concurrently; a prior run was killed (Ctrl-C or OOM) without releasing the lock, leaving a stale lockdir; an extremely slow filesystem making lock create/check exceed backoff windows.
Understand the failure class
Background: Request timed out: what client-side request timeouts mean across libraries (Request timed out, TIMED_OUT, APITimeoutError) — this error's family across 39 libraries.
- Timeouts: ETIMEDOUT, deadlines, and hung requests — what actually expires when a request times out.
Related errors
- pipeline lock timeout
- portal-health lock timeout
- ⚠️ Could not release report reservation
- apify: invalid timeoutMs
- apify: JD cache write failed for
AI-assisted analysis of santifer/career-ops@aac998c7ed (2026-09-16).
Data as JSON: /api/errors/1211e5a89a8e8459.
Report an issue: GitHub.
Appendix: source
Thrown at followup-seed.mjs:436
// STALE only. VANISHED means the lock was absent when we looked, and
// by the time this line runs another acquirer may have won the mkdir
// and be partway through writing owner.json — deleting on that answer
// destroys a live lock and kills its winner with ENOENT.
if (lockRecoveryVerdict(lockDir, staleMs) === RECOVER_STALE) {
if (rmLockArtifactSync(lockDir)) continue;
// rm hit contention: another process is touching the stale lock at
// this instant — back off instead of treating the collision as fatal.
}
} finally {
rmLockArtifactSync(recoverGuardDir);
}
}
await sleep(backoffMs());
}
}
throw new SeedError('LOCK_TIMEOUT', `Timed out waiting for follow-ups lock at ${lockDir}`);
}
// --- Atomic write (mirrors writeFileAtomic in tracker.mjs / merge-tracker.mjs) --
function writeFileAtomic(filePath, content) {
const tmpPath = join(dirname(filePath), `.${basename(filePath)}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`);
try {
writeFileSync(tmpPath, content);
renameSyncWithRetry(tmpPath, filePath);
} catch (err) {
rmSync(tmpPath, { force: true });
throw err;
}
}
function appendPins(existingContent, pinLines) {
const joined = pinLines.join('\n');
if (existingContent == null) {View on GitHub (pinned to aac998c7ed)