/ src / utils / cron-locks.js
cron-locks.js
  1  /**
  2   * Cron Lock Management Utility
  3   *
  4   * Replaces config-table-based locking with dedicated cron_locks table.
  5   * Provides functions for acquiring, releasing, and checking lock status.
  6   *
  7   * Lock lifecycle:
  8   *  1. acquireLock() - Attempt to acquire lock (returns true/false)
  9   *  2. Lock held during cron job execution
 10   *  3. releaseLock() - Release lock when job completes (success or failure)
 11   *  4. Stale lock detection - Automatically clears locks older than threshold
 12   *
 13   * Usage:
 14   *   import { acquireLock, releaseLock, checkAndClearStaleLock } from './utils/cron-locks.js';
 15   *
 16   *   const lockKey = 'cron_scoring_running';
 17   *   if (!await checkAndClearStaleLock(lockKey, 'Scoring')) return;
 18   *   if (!await acquireLock(lockKey, 'Scoring stage')) return;
 19   *   try {
 20   *     // Run cron job
 21   *   } finally {
 22   *     await releaseLock(lockKey);
 23   *   }
 24   */
 25  
 26  import { run, getOne } from './db.js';
 27  
 28  /**
 29   * Acquire a lock for a cron job
 30   * @param {string} lockKey - Unique lock identifier
 31   * @param {string|null} description - Optional description of the lock
 32   * @returns {Promise<boolean>} True if lock acquired, false if already held
 33   */
 34  export async function acquireLock(lockKey, description = null) {
 35    try {
 36      await run('INSERT INTO ops.cron_locks (lock_key, description) VALUES ($1, $2)', [
 37        lockKey,
 38        description,
 39      ]);
 40      return true;
 41    } catch (err) {
 42      // Lock already exists (UNIQUE constraint violation)
 43      if (err.code === '23505') return false;
 44      throw err;
 45    }
 46  }
 47  
 48  /**
 49   * Release a lock for a cron job
 50   * @param {string} lockKey - Unique lock identifier
 51   * @returns {Promise<void>}
 52   */
 53  export async function releaseLock(lockKey) {
 54    await run('DELETE FROM ops.cron_locks WHERE lock_key = $1', [lockKey]);
 55  }
 56  
 57  /**
 58   * Check if a lock is stale (older than threshold)
 59   * @param {string} lockKey - Unique lock identifier
 60   * @param {number} staleThresholdMs - Threshold in milliseconds (default: 10 minutes)
 61   * @returns {Promise<string|null>} 'stale', 'active', or null if no lock exists
 62   */
 63  export async function checkStaleLock(lockKey, staleThresholdMs = 10 * 60 * 1000) {
 64    const lock = await getOne('SELECT updated_at FROM ops.cron_locks WHERE lock_key = $1', [lockKey]);
 65  
 66    if (!lock) return null;
 67  
 68    const lockAge = Date.now() - new Date(lock.updated_at).getTime();
 69    return lockAge > staleThresholdMs ? 'stale' : 'active';
 70  }
 71  
 72  /**
 73   * Clear a stale lock
 74   * @param {string} lockKey - Unique lock identifier
 75   * @returns {Promise<void>}
 76   */
 77  export async function clearStaleLock(lockKey) {
 78    await run('DELETE FROM ops.cron_locks WHERE lock_key = $1', [lockKey]);
 79  }
 80  
 81  /**
 82   * Check for stale lock and clear if needed
 83   * @param {string} lockKey - Unique lock identifier
 84   * @param {string} stageName - Name of the stage (for logging)
 85   * @param {number} staleThresholdMs - Threshold in milliseconds (default: 10 minutes)
 86   * @returns {Promise<boolean>} True if can proceed, false if lock is active
 87   */
 88  export async function checkAndClearStaleLock(lockKey, stageName, staleThresholdMs = 10 * 60 * 1000) {
 89    const lockStatus = await checkStaleLock(lockKey, staleThresholdMs);
 90  
 91    if (lockStatus === 'active') {
 92      // Lock is active, cannot proceed
 93      return false;
 94    }
 95  
 96    if (lockStatus === 'stale') {
 97      // Lock is stale, clear it and allow proceeding
 98      const lock = await getOne('SELECT updated_at FROM ops.cron_locks WHERE lock_key = $1', [lockKey]);
 99      const lockAge = Date.now() - new Date(lock.updated_at).getTime();
100      const ageMinutes = (lockAge / 1000 / 60).toFixed(1);
101      console.log(
102        `⚠️  Stale lock detected for ${stageName} (${ageMinutes} min old), clearing and proceeding`
103      );
104      await clearStaleLock(lockKey);
105    }
106  
107    // No lock or stale lock cleared, can proceed
108    return true;
109  }