{"version":3,"file":"runOnce.mjs","names":[],"sources":["../../../src/utils/runOnce.ts"],"sourcesContent":["import { rmSync } from 'node:fs';\nimport { mkdir, readFile, stat, unlink, writeFile } from 'node:fs/promises';\nimport { dirname } from 'node:path';\nimport packageJson from '@intlayer/core/package.json' with { type: 'json' };\n\n/**\n * Lifecycle of the run described by a sentinel file.\n *\n * - `running` — a process owns the sentinel and its callback is still in flight.\n *   Other processes must wait instead of starting a concurrent run.\n * - `done` — the callback completed; the sentinel is now a plain cache marker.\n */\ntype SentinelStatus = 'running' | 'done';\n\ntype SentinelData = {\n  version: string;\n  timestamp: number;\n  status: SentinelStatus;\n  /** PID of the process that owns the sentinel, used to detect abandoned locks. */\n  pid: number;\n};\n\n/** Sentinel state as read from disk, enriched with the file's modification time. */\ntype SentinelState = SentinelData & { mtimeMs: number };\n\n/**\n * Context handed to the callback so it can interact with the lock it runs under.\n */\nexport type RunOnceContext = {\n  /**\n   * Re-create the sentinel file after an operation that may have deleted it —\n   * typically cleaning the output directory, which wipes the cache directory the\n   * sentinel lives in. Without this, concurrent processes would see no lock and\n   * start a competing run while this one is still writing.\n   */\n  renewLock: () => Promise<void>;\n};\n\ntype RunOnceOptions = {\n  /**\n   * The function to execute when the sentinel is not found or is older than the cache timeout.\n   */\n  onIsCached?: () => void | Promise<void>;\n  /**\n   * The time window in milliseconds during which the sentinel is considered valid.\n   *\n   * @default 60000 = 1 minute\n   */\n  cacheTimeoutMs?: number;\n  /**\n   * If true, the callback will always run. If undefined, the callback will run only if the sentinel is older than the cache timeout.\n   *\n   * @default false\n   */\n  forceRun?: boolean;\n  /**\n   * How long to wait for another process to release the sentinel before\n   * considering its run abandoned and taking the lock over.\n   *\n   * @default 300000 = 5 minutes\n   */\n  lockWaitTimeoutMs?: number;\n};\n\nconst DEFAULT_RUN_ONCE_OPTIONS = {\n  cacheTimeoutMs: 60 * 1000, // 1 minute in milliseconds,\n  lockWaitTimeoutMs: 5 * 60 * 1000, // 5 minutes in milliseconds\n} satisfies RunOnceOptions;\n\n/** Delay between two reads of a sentinel owned by another process. */\nconst LOCK_POLL_INTERVAL_MS = 50;\n\nconst delay = (durationMs: number): Promise<void> =>\n  new Promise((resolve) => setTimeout(resolve, durationMs));\n\n/**\n * Sentinels owned by this process, released synchronously on exit so a crash or\n * a Ctrl-C never leaves a `running` lock that would stall the next run.\n */\nconst ownedSentinelFilePaths = new Set<string>();\n\nprocess.on('exit', () => {\n  for (const sentinelFilePath of ownedSentinelFilePaths) {\n    try {\n      rmSync(sentinelFilePath, { force: true });\n    } catch {}\n  }\n});\n\n/**\n * Reads the sentinel file, returning `undefined` when it does not exist or\n * cannot be parsed.\n *\n * Sentinels written by older versions carry no `status`; they always describe a\n * finished run, so they are reported as `done`.\n */\nconst readSentinelState = async (\n  sentinelFilePath: string\n): Promise<SentinelState | undefined> => {\n  try {\n    const [sentinelStats, raw] = await Promise.all([\n      stat(sentinelFilePath),\n      readFile(sentinelFilePath, 'utf8'),\n    ]);\n\n    const parsed = JSON.parse(raw) as Partial<SentinelData>;\n\n    return {\n      version: parsed.version ?? '',\n      timestamp: parsed.timestamp ?? 0,\n      status: parsed.status ?? 'done',\n      pid: parsed.pid ?? 0,\n      mtimeMs: sentinelStats.mtime.getTime(),\n    };\n  } catch {\n    return undefined;\n  }\n};\n\n/**\n * Whether the process that wrote the sentinel is still alive. An unknown PID\n * (legacy sentinel) is assumed alive so the staleness timeout stays the only\n * way to reclaim it.\n */\nconst isOwnerProcessAlive = (pid: number): boolean => {\n  if (!pid || pid === process.pid) return true;\n\n  try {\n    // Signal 0 performs an existence check without delivering a signal.\n    process.kill(pid, 0);\n    return true;\n  } catch (error) {\n    // EPERM means the process exists but belongs to another user.\n    return (error as NodeJS.ErrnoException).code === 'EPERM';\n  }\n};\n\nconst serializeSentinel = (timestamp: number, status: SentinelStatus): string =>\n  JSON.stringify({\n    version: packageJson.version,\n    timestamp,\n    status,\n    pid: process.pid,\n  } satisfies SentinelData);\n\n/**\n * Attempts to take ownership of the sentinel.\n *\n * `wx` makes the creation atomic, so exactly one process can win even when\n * several start at the same moment.\n *\n * @returns `true` when this process now owns the sentinel, `false` when another\n * process created it first.\n */\nconst acquireSentinel = async (\n  sentinelFilePath: string,\n  timestamp: number\n): Promise<boolean> => {\n  const data = serializeSentinel(timestamp, 'running');\n\n  for (let attempt = 0; attempt < 2; attempt++) {\n    try {\n      // Ensure the directory exists before writing the file\n      await mkdir(dirname(sentinelFilePath), { recursive: true });\n\n      await writeFile(sentinelFilePath, data, { flag: 'wx' });\n      return true;\n    } catch (error) {\n      const code = (error as NodeJS.ErrnoException).code;\n\n      if (code === 'EEXIST') return false;\n      // The directory was removed between the mkdir and the write (e.g. a\n      // concurrent output-directory clean); retry once.\n      if (code === 'ENOENT' && attempt === 0) continue;\n\n      throw error;\n    }\n  }\n\n  return false;\n};\n\n/**\n * Rewrites the sentinel owned by this process, overwriting any existing file.\n */\nconst writeOwnedSentinel = async (\n  sentinelFilePath: string,\n  timestamp: number,\n  status: SentinelStatus\n): Promise<void> => {\n  try {\n    await mkdir(dirname(sentinelFilePath), { recursive: true });\n    await writeFile(sentinelFilePath, serializeSentinel(timestamp, status));\n  } catch {}\n};\n\nconst removeSentinel = async (sentinelFilePath: string): Promise<void> => {\n  try {\n    await unlink(sentinelFilePath);\n  } catch {}\n};\n\n/**\n * Ensures a callback function runs only once within a specified time window across multiple processes.\n * Uses a sentinel file to coordinate execution and prevent duplicate work.\n *\n * Processes that lose the race for the sentinel wait for the owner to finish\n * rather than running a competing copy of the callback — concurrent runs would\n * otherwise write to (and clean) the same output directory at the same time.\n *\n * @param sentinelFilePath - Path to the sentinel file used for coordination\n * @param callback - The function to execute (should be async)\n * @param options - The options for the runOnce function\n *\n * @example\n * ```typescript\n * await runOnce(\n *   '/tmp/intlayer-sentinel',\n *   async ({ renewLock }) => {\n *     await cleanOutputDir(configuration); // may delete the sentinel\n *     await renewLock();\n *     await prepareIntlayer();\n *   },\n *   { cacheTimeoutMs: 30 * 1000 } // 30 seconds cache\n * );\n * ```\n *\n * @throws {Error} When there are unexpected filesystem errors\n */\nexport const runOnce = async (\n  sentinelFilePath: string,\n  callback: (context: RunOnceContext) => void | Promise<void>,\n  options?: RunOnceOptions\n) => {\n  const { onIsCached, cacheTimeoutMs, forceRun, lockWaitTimeoutMs } = {\n    ...DEFAULT_RUN_ONCE_OPTIONS,\n    ...(options ?? {}),\n  };\n  const currentTimestamp = Date.now();\n  const waitDeadline = currentTimestamp + lockWaitTimeoutMs;\n\n  // Acquisition loop: read the sentinel, then either return early (fresh cache),\n  // wait for the current owner, or take the lock. Every branch either returns or\n  // makes progress, so the loop always terminates.\n  while (true) {\n    const sentinelState = await readSentinelState(sentinelFilePath);\n\n    if (sentinelState) {\n      const sentinelAge = Date.now() - sentinelState.mtimeMs;\n\n      if (sentinelState.status === 'running') {\n        const isAbandoned =\n          sentinelAge > lockWaitTimeoutMs ||\n          !isOwnerProcessAlive(sentinelState.pid);\n\n        if (!isAbandoned && Date.now() < waitDeadline) {\n          await delay(LOCK_POLL_INTERVAL_MS);\n          continue;\n        }\n\n        // The owner died or overran the timeout: reclaim the sentinel.\n        await removeSentinel(sentinelFilePath);\n        continue;\n      }\n\n      const isCacheValid =\n        !forceRun &&\n        sentinelAge <= cacheTimeoutMs &&\n        sentinelState.version === packageJson.version;\n\n      if (isCacheValid) {\n        await onIsCached?.();\n        return;\n      }\n\n      await removeSentinel(sentinelFilePath);\n    }\n\n    const hasAcquiredSentinel = await acquireSentinel(\n      sentinelFilePath,\n      currentTimestamp\n    );\n\n    if (hasAcquiredSentinel) break;\n\n    // Another process won the race in the meantime: loop back and wait for it.\n    // The delay also guarantees the loop yields, so a sentinel being repeatedly\n    // created and removed can never turn into a busy wait.\n    await delay(LOCK_POLL_INTERVAL_MS);\n  }\n\n  ownedSentinelFilePaths.add(sentinelFilePath);\n\n  const renewLock = () =>\n    writeOwnedSentinel(sentinelFilePath, currentTimestamp, 'running');\n\n  try {\n    await callback({ renewLock });\n\n    // Mark the run as finished, re-creating the sentinel if the callback\n    // deleted it (e.g. by cleaning the output directory).\n    await writeOwnedSentinel(sentinelFilePath, currentTimestamp, 'done');\n  } catch {\n    await removeSentinel(sentinelFilePath); // Remove sentinel file if an error occurs\n  } finally {\n    ownedSentinelFilePaths.delete(sentinelFilePath);\n  }\n};\n"],"mappings":"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"}