{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,UAAU,EAAE,MAAM,IAAI,CAAC;AAErC,OAAO,EAAE,KAAK,UAAU,EAAE,YAAY,EAAE,MAAM,wBAAwB,CAAC;AAEvE,UAAU,gBAAgB;IACxB;;;;OAIG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AAOD,UAAU,WAAW;IACnB,0FAA0F;IAC1F,OAAO,CAAC,EAAE,MAAM,CAAC;IAEjB;;;;;;;OAOG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAED,UAAU,eAAe,CAAC,CAAC,CAAE,SAAQ,WAAW;IAC9C,aAAa,CAAC,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC;CACtC;AA6CD,eAAO,MAAM,IAAI,cAAqB,CAAC;AAGvC;;GAEG;AACH,wBAAsB,IAAI,CACxB,QAAQ,EAAE,UAAU,EACpB,gBAAgB,EAAE,CAAC,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,UAAU,KAAK,IAAI,EAC5D,gBAAgB,GAAE,gBAAqB,iBAQxC;AAED;;GAEG;AACH,wBAAsB,KAAK,kBAE1B;AAED;;;;;;;GAOG;AACH,wBAAsB,UAAU,CAAC,CAAC,EAAE,CAAC,GAAG,KAAK,EAC3C,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,eAAe,CAAC,CAAC,CAAC,EAC3B,IAAI,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GACrB,OAAO,CAAC,CAAC,GAAG,CAAC,CAAC,CAgBhB","sourcesContent":["import { type PoolConfig } from 'pg';\n\nimport { type PoolClient, PostgresPool } from '@prairielearn/postgres';\n\ninterface NamedLocksConfig {\n  /**\n   * How often to renew the lock in milliseconds. Defaults to 60 seconds.\n   * Auto-renewal must be explicitly enabled on each lock where it is desired.\n   *\n   */\n  renewIntervalMs?: number;\n}\n\ninterface Lock {\n  client: PoolClient;\n  intervalId: NodeJS.Timeout | null;\n}\n\ninterface LockOptions {\n  /** How many milliseconds to wait (anything other than a positive number means forever) */\n  timeout?: number;\n\n  /**\n   * Whether or not this lock should automatically renew itself periodically.\n   * By default, locks will not renew themselves.\n   *\n   * This is mostly useful for locks that may be held for longer than the idle\n   * session timeout that's configured for the Postgres database. The lock is\n   * \"renewed\" by making a no-op query.\n   */\n  autoRenew?: boolean;\n}\n\ninterface WithLockOptions<T> extends LockOptions {\n  onNotAcquired?: () => Promise<T> | T;\n}\n\n/*\n * The functions here all identify locks by \"name\", which is a plain\n * string. The locks use the named_locks DB table. Each lock name\n * corresponds to a unique table row. To take a lock, we:\n *     1. make sure a row exists for the lock name\n *     2. start a transaction\n *     3. acquire a \"FOR UPDATE\" row lock on the DB row for the named\n *        lock (this blocks all other locks on the same row). See\n *        https://www.postgresql.org/docs/current/explicit-locking.html#LOCKING-ROWS\n *     4. return to the caller with the transaction held open\n *\n * The caller then does some work and finally calls releaseLock(),\n * which ends the transaction, thereby releasing the row lock.\n *\n * The flow above will wait indefinitely for the lock to become\n * available. To implement optional timeouts, we set the DB variable\n * `lock_timeout`:\n * https://www.postgresql.org/docs/current/runtime-config-client.html#GUC-LOCK-TIMEOUT\n * If we timeout then we will return an error to the caller.\n *\n * To implement a no-waiting tryLock() we use the PostgreSQL \"SKIP\n * LOCKED\" feature:\n * https://www.postgresql.org/docs/current/sql-select.html#SQL-FOR-UPDATE-SHARE\n * If we fail to acquire the lock then we immediately release the\n * transaction and return to the caller with `lock = null`. In this\n * case the caller should not call releaseLock().\n *\n * The lock object returned by functions in this module are of the\n * form `{client, done}`, where `client` and `done` are sqldb\n * transaction objects. These are simply the objects we need to end\n * the transaction, which will release the lock.\n *\n * Importantly, we use a separate pool of database connections for acquiring\n * and holding locks. This ensures that we don't end up with a deadlock.\n * For instance, if we have a pool of 10 clients and there are 10 locks held at\n * once, if any code inside of a lock tries to acquire another database client,\n * we'd deadlock if we weren't separating the two pools of connections.\n *\n * You should NEVER try to acquire a lock in code that is executing with another\n * lock already held. Since there are a finite pool of locks available, this\n * can lead to deadlocks.\n */\n\nexport const pool = new PostgresPool();\nlet renewIntervalMs = 60_000;\n\n/**\n * Initializes a new {@link PostgresPool} that will be used to acquire named locks.\n */\nexport async function init(\n  pgConfig: PoolConfig,\n  idleErrorHandler: (error: Error, client: PoolClient) => void,\n  namedLocksConfig: NamedLocksConfig = {},\n) {\n  renewIntervalMs = namedLocksConfig.renewIntervalMs ?? renewIntervalMs;\n  await pool.initAsync(pgConfig, idleErrorHandler);\n  await pool.execute(\n    'CREATE TABLE IF NOT EXISTS named_locks (id bigserial PRIMARY KEY, name text NOT NULL UNIQUE);',\n    {},\n  );\n}\n\n/**\n * Shuts down the database connection pool that was used to acquire locks.\n */\nexport async function close() {\n  await pool.closeAsync();\n}\n\n/**\n * Acquires the given lock, executes the provided function with the lock held,\n * and releases the lock once the function has executed.\n *\n * If the lock cannot be acquired, the function is not executed. If an `onNotAcquired`\n * function was provided, this function is called and its return value is returned.\n * Otherwise, an error is thrown to indicate that the lock could not be acquired.\n */\nexport async function doWithLock<T, U = never>(\n  name: string,\n  options: WithLockOptions<U>,\n  func: () => Promise<T>,\n): Promise<T | U> {\n  const lock = await getLock(name, { timeout: 0, ...options });\n\n  if (!lock) {\n    if (options.onNotAcquired) {\n      return await options.onNotAcquired();\n    } else {\n      throw new Error(`failed to acquire lock: ${name}`);\n    }\n  }\n\n  try {\n    return await func();\n  } finally {\n    await releaseLock(lock);\n  }\n}\n\n/**\n * Internal helper function to get a lock with optional waiting.\n * Do not call directly; use `doWithLock()` instead.\n *\n * @param name The name of the lock to acquire.\n * @param options Optional parameters.\n */\nasync function getLock(name: string, options: LockOptions) {\n  await pool.execute(\n    'INSERT INTO named_locks (name) VALUES ($name) ON CONFLICT (name) DO NOTHING;',\n    { name },\n  );\n\n  const client = await pool.beginTransactionAsync();\n\n  let acquiredLock = false;\n  try {\n    if (options.timeout) {\n      // SQL doesn't like us trying to use a parameterized query with\n      // `SET LOCAL ...`. So, in this very specific case, we do the\n      // parameterization ourselves using `escapeLiteral`.\n      await pool.queryWithClientAsync(\n        client,\n        `SET LOCAL lock_timeout = ${client.escapeLiteral(options.timeout.toString())}`,\n        {},\n      );\n    }\n\n    // A stuck lock is a critical issue. To make them easier to debug, we'll\n    // include the literal lock name in the query instead of using a\n    // parameterized query. The lock name should never include PII, so it's\n    // safe if it shows up in plaintext in logs, telemetry, error messages,\n    // etc.\n    const lockNameLiteral = client.escapeLiteral(name);\n    const lock_sql = options.timeout\n      ? `SELECT * FROM named_locks WHERE name = ${lockNameLiteral} FOR UPDATE;`\n      : `SELECT * FROM named_locks WHERE name = ${lockNameLiteral} FOR UPDATE SKIP LOCKED;`;\n    const result = await pool.queryWithClientAsync(client, lock_sql, {});\n    acquiredLock = result.rowCount === 1;\n  } catch (err) {\n    // Something went wrong, so we end the transaction and re-throw the\n    // error.\n    await pool.endTransactionAsync(client, err as Error);\n    throw err;\n  }\n\n  if (!acquiredLock) {\n    // We didn't acquire the lock so our parent caller will never\n    // release it, so we have to end the transaction now.\n    await pool.endTransactionAsync(client, null);\n    return null;\n  }\n\n  let intervalId = null;\n  if (options.autoRenew) {\n    // Periodically \"renew\" the lock by making a query.\n    intervalId = setInterval(() => {\n      client.query('SELECT 1;').catch(() => {});\n    }, renewIntervalMs);\n  }\n\n  // We successfully acquired the lock, so we return with the transaction\n  // help open. The caller will be responsible for releasing the lock and\n  // ending the transaction.\n  return { client, intervalId };\n}\n\n/**\n * Release a lock.\n *\n * @param lock A previously-acquired lock.\n */\nasync function releaseLock(lock: Lock) {\n  if (lock == null) throw new Error('lock is null');\n  clearInterval(lock.intervalId ?? undefined);\n  await pool.endTransactionAsync(lock.client, null);\n}\n"]}