Ctrl K
ComunidadGuías…

portal-health-lock.mjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// portal-health-lock.mjs — a cross-process advisory lock for
// data/portal-health.tsv, so appendPortalHealth() (scan.mjs) and any
// read-modify-write cleanup of the same file (tests/portal-health-guard.mjs)
// can never interleave. A concurrent appender that lands between a cleanup's
// read and write would otherwise be silently discarded.
//
// The protocol — the lock directory, owner.json, the recover guard, stale
// recovery and the token-checked release — is lib/dir-lock.mjs, shared with
// every other lock in the core. This module picks the lock path, the timing
// defaults and their env overrides, and the timeout error.
//
// It used to carry its own copy, and that copy was the one that could free a
// peer's lock: a failed owner.json write removed whatever sat at the lock path,
// including a newer caller's live lock after an ENOENT (#5358).

import { acquireDirLock, releaseOrWarn } from './lib/dir-lock.mjs';

// Re-exported rather than redeclared: the floor is applied inside
// lockRecoveryVerdict, so a local copy of the number would be a constant this
// file no longer enforces — free to drift from the one that decides.
export { OWNERLESS_GRACE_MS } from './lib/dir-lock.mjs';

const DEFAULT_STALE_MS = 30_000;
const DEFAULT_RETRY_MS = 80;
const DEFAULT_TIMEOUT_MS = 8_000;

export class LockTimeoutError extends Error {
  constructor(lockDir, timeoutMs) {
    super(`portal-health lock timeout: ${lockDir} held > ${timeoutMs}ms`);
    this.name = 'LockTimeoutError';
    this.lockDir = lockDir;
  }
}

function lockDirFor(filePath) {
  return `${filePath}.lock`;
}

/**
 * Blocks until the lock on `filePath` is held, then returns a handle whose
 * release() frees it. Throws LockTimeoutError if the lock stays busy.
 *
 * @param {string} filePath - File the lock guards.
 * @param {object} [options]
 * @param {number} [options.timeoutMs=8000] - Max time to wait for the lock.
 * @param {number} [options.retryMs=80] - Delay between acquisition attempts.
 * @param {number} [options.staleMs=30000] - Age threshold for a lock with no readable owner.
 */
export async function acquirePortalHealthLock(filePath, options = {}) {
  // Env overrides let a caller several frames up the stack tune contention
  // timing without threading options through every signature — the same
  // escape hatch the tracker lock provides.
  const timeoutMs = options.timeoutMs ?? (Number(process.env.CAREER_OPS_PORTAL_HEALTH_LOCK_TIMEOUT_MS) || DEFAULT_TIMEOUT_MS);
  const retryMs = options.retryMs ?? (Number(process.env.CAREER_OPS_PORTAL_HEALTH_LOCK_RETRY_MS) || DEFAULT_RETRY_MS);
  const staleMs = options.staleMs ?? (Number(process.env.CAREER_OPS_PORTAL_HEALTH_LOCK_STALE_MS) || DEFAULT_STALE_MS);
  const lock = await acquireDirLock(lockDirFor(filePath), {
    timeoutMs,
    retryMs,
    staleMs,
    owner: { file: filePath },
    createParent: true,
    timeoutError: ({ lockDir }) => new LockTimeoutError(lockDir, timeoutMs),
  });
  return { lockDir: lock.lockDir, release: lock.release };
}

/** Acquires the lock on `filePath`, runs fn, and always releases it. */
export async function withPortalHealthLock(filePath, fn, options = {}) {
  const lock = await acquirePortalHealthLock(filePath, options);
  try {
    return await fn();
  } finally {
    releaseOrWarn(lock);
  }
}