@prairielearn/named-locks 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @prairielearn/named-locks
2
2
 
3
+ ## 1.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 4cc962358: Add `tryWithLock` function
8
+
9
+ ## 1.2.0
10
+
11
+ ### Minor Changes
12
+
13
+ - 400a0b901: Allow locks to be automatically renewed
14
+
3
15
  ## 1.1.0
4
16
 
5
17
  ### Minor Changes
package/README.md ADDED
@@ -0,0 +1,79 @@
1
+ # `@prairielearn/named-locks`
2
+
3
+ Uses Postgres row-level locks to grant exclusive access to resources.
4
+
5
+ ## Usage
6
+
7
+ You must first call `init` with Postgres connection details and an idle error handler:
8
+
9
+ ```ts
10
+ import { init } from '@prairielearn/named-locks';
11
+
12
+ await init(
13
+ {
14
+ user: 'postgres',
15
+ // ... other Postgres config
16
+ },
17
+ (err) => {
18
+ throw err;
19
+ }
20
+ );
21
+ ```
22
+
23
+ You can then obtain a lock and do work while it is held. The lock will be automatically released once the provided function either resolves or rejects.
24
+
25
+ ```ts
26
+ import { doWithLock } from '@prairielearn/named-locks';
27
+
28
+ await doWithLock('name', {}, async () => {
29
+ console.log('Doing some work');
30
+ });
31
+ ```
32
+
33
+ Optionally, you may configure the lock to automatically "renew" itself by periodically making no-op queries in the background. This is useful for operations that may take longer than the configured Postgres idle session timeout.
34
+
35
+ ```ts
36
+ import { doWithLock } from '@prairielearn/named-locks';
37
+
38
+ await doWithLock(
39
+ 'name',
40
+ {
41
+ autoRenew: true,
42
+ },
43
+ async () => {
44
+ console.log('Doing some work');
45
+ }
46
+ );
47
+ ```
48
+
49
+ ### Advanced usage
50
+
51
+ Normally, it's preferable to use `doWithLock`, as it will automatically release the lock in case of failure. However, for advanced usage, you can manually acquire and release a lock.
52
+
53
+ ```ts
54
+ import { waitLockAsync, releaseLockAsync } from '@prairielearn/named-locks';
55
+
56
+ const lock = await waitLockAsync('name', {});
57
+ try {
58
+ console.log('Doing some work');
59
+ } finally {
60
+ await releaseLockAsync(lock);
61
+ }
62
+ ```
63
+
64
+ If you only want to acquire a lock if it's immediately available, you can use `tryLockAsync` instead:
65
+
66
+ ```ts
67
+ import { tryLockAsync, releaseLockAsync } from '@prairielearn/named-locks';
68
+
69
+ const lock = await waitLockAsync('name', {});
70
+ if (lock) {
71
+ try {
72
+ console.log('Doing some work');
73
+ } finally {
74
+ await releaseLockAsync(lock);
75
+ }
76
+ } else {
77
+ console.log('Lock not acquired');
78
+ }
79
+ ```
package/dist/index.d.ts CHANGED
@@ -1,18 +1,38 @@
1
1
  /// <reference types="node" />
2
+ /// <reference types="node" />
2
3
  import { PostgresPool, PoolClient } from '@prairielearn/postgres';
3
4
  import { PoolConfig } from 'pg';
5
+ interface NamedLocksConfig {
6
+ /**
7
+ * How often to renew the lock in milliseconds. Defaults to 60 seconds.
8
+ * Auto-renewal must be explicitly enabled on each lock where it is desired.
9
+ *
10
+ */
11
+ renewIntervalMs?: number;
12
+ }
4
13
  interface Lock {
5
14
  client: PoolClient;
15
+ intervalId: NodeJS.Timeout | null;
6
16
  }
7
17
  interface LockOptions {
8
18
  /** How many milliseconds to wait (anything other than a positive number means forever) */
9
19
  timeout?: number;
20
+ /**
21
+ * Whether or not this lock should automatically renew itself periodically.
22
+ * By default, locks will not renew themselves.
23
+ *
24
+ * This is mostly useful for locks that may be help for longer than the idle
25
+ * session timeout that's configured for the Postgres database. The lock is
26
+ * "renewed" by making a no-op query.
27
+ */
28
+ autoRenew?: boolean;
10
29
  }
30
+ type TryLockOptions = Omit<LockOptions, 'timeout'>;
11
31
  export declare const pool: PostgresPool;
12
32
  /**
13
33
  * Initializes a new {@link PostgresPool} that will be used to acquire named locks.
14
34
  */
15
- export declare function init(pgConfig: PoolConfig, idleErrorHandler: (error: Error, client: PoolClient) => void): Promise<void>;
35
+ export declare function init(pgConfig: PoolConfig, idleErrorHandler: (error: Error, client: PoolClient) => void, namedLocksConfig?: NamedLocksConfig): Promise<void>;
16
36
  /**
17
37
  * Shuts down the database connection pool that was used to acquire locks.
18
38
  */
@@ -25,7 +45,7 @@ export declare function close(): Promise<void>;
25
45
  *
26
46
  * @param name The name of the lock to acquire.
27
47
  */
28
- export declare function tryLockAsync(name: string): Promise<Lock | null>;
48
+ export declare function tryLockAsync(name: string, options?: TryLockOptions): Promise<Lock | null>;
29
49
  export declare const tryLock: (arg1: string, callback: (err: NodeJS.ErrnoException, result: Lock | null) => void) => void;
30
50
  /**
31
51
  * Wait until a lock can be successfully acquired.
@@ -47,4 +67,10 @@ export declare const releaseLock: (arg1: Lock, callback: (err: NodeJS.ErrnoExcep
47
67
  * and releases the lock once the function has executed.
48
68
  */
49
69
  export declare function doWithLock<T>(name: string, options: LockOptions, func: () => Promise<T>): Promise<T>;
70
+ /**
71
+ * Tries to acquire the given lock, executes the provided function with the lock held,
72
+ * and releases the lock once the function has executed. If the lock cannot be acquired,
73
+ * the function is not executed and null is returned.
74
+ */
75
+ export declare function tryWithLock<T>(name: string, options: TryLockOptions, func: () => Promise<T>): Promise<T | null>;
50
76
  export {};
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.doWithLock = exports.releaseLock = exports.releaseLockAsync = exports.waitLock = exports.waitLockAsync = exports.tryLock = exports.tryLockAsync = exports.close = exports.init = exports.pool = void 0;
6
+ exports.tryWithLock = exports.doWithLock = exports.releaseLock = exports.releaseLockAsync = exports.waitLock = exports.waitLockAsync = exports.tryLock = exports.tryLockAsync = exports.close = exports.init = exports.pool = void 0;
7
7
  const util_1 = __importDefault(require("util"));
8
8
  const postgres_1 = require("@prairielearn/postgres");
9
9
  /*
@@ -49,10 +49,12 @@ const postgres_1 = require("@prairielearn/postgres");
49
49
  * can lead to deadlocks.
50
50
  */
51
51
  exports.pool = new postgres_1.PostgresPool();
52
+ let renewIntervalMs = 60000;
52
53
  /**
53
54
  * Initializes a new {@link PostgresPool} that will be used to acquire named locks.
54
55
  */
55
- async function init(pgConfig, idleErrorHandler) {
56
+ async function init(pgConfig, idleErrorHandler, namedLocksConfig = {}) {
57
+ renewIntervalMs = namedLocksConfig.renewIntervalMs ?? renewIntervalMs;
56
58
  await exports.pool.initAsync(pgConfig, idleErrorHandler);
57
59
  await exports.pool.queryAsync('CREATE TABLE IF NOT EXISTS named_locks (id bigserial PRIMARY KEY, name text NOT NULL UNIQUE);', {});
58
60
  }
@@ -72,8 +74,8 @@ exports.close = close;
72
74
  *
73
75
  * @param name The name of the lock to acquire.
74
76
  */
75
- async function tryLockAsync(name) {
76
- return getLock(name, { wait: false });
77
+ async function tryLockAsync(name, options = {}) {
78
+ return getLock(name, { timeout: 0, ...options });
77
79
  }
78
80
  exports.tryLockAsync = tryLockAsync;
79
81
  exports.tryLock = util_1.default.callbackify(tryLockAsync);
@@ -84,11 +86,7 @@ exports.tryLock = util_1.default.callbackify(tryLockAsync);
84
86
  * @param options
85
87
  */
86
88
  async function waitLockAsync(name, options) {
87
- const internalOptions = {
88
- wait: true,
89
- timeout: options.timeout || 0,
90
- };
91
- const lock = await getLock(name, internalOptions);
89
+ const lock = await getLock(name, options);
92
90
  if (lock == null)
93
91
  throw new Error(`failed to acquire lock: ${name}`);
94
92
  return lock;
@@ -103,6 +101,7 @@ exports.waitLock = util_1.default.callbackify(waitLockAsync);
103
101
  async function releaseLockAsync(lock) {
104
102
  if (lock == null)
105
103
  throw new Error('lock is null');
104
+ clearInterval(lock.intervalId ?? undefined);
106
105
  await exports.pool.endTransactionAsync(lock.client, null);
107
106
  }
108
107
  exports.releaseLockAsync = releaseLockAsync;
@@ -121,6 +120,23 @@ async function doWithLock(name, options, func) {
121
120
  }
122
121
  }
123
122
  exports.doWithLock = doWithLock;
123
+ /**
124
+ * Tries to acquire the given lock, executes the provided function with the lock held,
125
+ * and releases the lock once the function has executed. If the lock cannot be acquired,
126
+ * the function is not executed and null is returned.
127
+ */
128
+ async function tryWithLock(name, options, func) {
129
+ const lock = await tryLockAsync(name, options);
130
+ if (lock == null)
131
+ return null;
132
+ try {
133
+ return await func();
134
+ }
135
+ finally {
136
+ await releaseLockAsync(lock);
137
+ }
138
+ }
139
+ exports.tryWithLock = tryWithLock;
124
140
  /**
125
141
  * Internal helper function to get a lock with optional
126
142
  * waiting. Do not call directly, but use tryLock() or waitLock()
@@ -134,7 +150,7 @@ async function getLock(name, options) {
134
150
  const client = await exports.pool.beginTransactionAsync();
135
151
  let acquiredLock = false;
136
152
  try {
137
- if (options.wait && options.timeout > 0) {
153
+ if (options.timeout) {
138
154
  // SQL doesn't like us trying to use a parameterized query with
139
155
  // `SET LOCAL ...`. So, in this very specific case, we do the
140
156
  // parameterization ourselves using `escapeLiteral`.
@@ -146,7 +162,7 @@ async function getLock(name, options) {
146
162
  // safe if it shows up in plaintext in logs, telemetry, error messages,
147
163
  // etc.
148
164
  const lockNameLiteral = client.escapeLiteral(name);
149
- const lock_sql = options.wait
165
+ const lock_sql = options.timeout
150
166
  ? `SELECT * FROM named_locks WHERE name = ${lockNameLiteral} FOR UPDATE;`
151
167
  : `SELECT * FROM named_locks WHERE name = ${lockNameLiteral} FOR UPDATE SKIP LOCKED;`;
152
168
  const result = await exports.pool.queryWithClientAsync(client, lock_sql, { name });
@@ -164,9 +180,16 @@ async function getLock(name, options) {
164
180
  await exports.pool.endTransactionAsync(client, null);
165
181
  return null;
166
182
  }
183
+ let intervalId = null;
184
+ if (options.autoRenew) {
185
+ // Periodically "renew" the lock by making a query.
186
+ intervalId = setInterval(() => {
187
+ client.query('SELECT 1;').catch(() => { });
188
+ }, renewIntervalMs);
189
+ }
167
190
  // We successfully acquired the lock, so we return with the transaction
168
191
  // help open. The caller will be responsible for releasing the lock and
169
192
  // ending the transaction.
170
- return { client };
193
+ return { client, intervalId };
171
194
  }
172
195
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;AAAA,gDAAwB;AACxB,qDAAkE;AAqBlE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyCG;AAEU,QAAA,IAAI,GAAG,IAAI,uBAAY,EAAE,CAAC;AAEvC;;GAEG;AACI,KAAK,UAAU,IAAI,CACxB,QAAoB,EACpB,gBAA4D;IAE5D,MAAM,YAAI,CAAC,SAAS,CAAC,QAAQ,EAAE,gBAAgB,CAAC,CAAC;IACjD,MAAM,YAAI,CAAC,UAAU,CACnB,+FAA+F,EAC/F,EAAE,CACH,CAAC;AACJ,CAAC;AATD,oBASC;AAED;;GAEG;AACI,KAAK,UAAU,KAAK;IACzB,MAAM,YAAI,CAAC,UAAU,EAAE,CAAC;AAC1B,CAAC;AAFD,sBAEC;AAED;;;;;;;GAOG;AACI,KAAK,UAAU,YAAY,CAAC,IAAY;IAC7C,OAAO,OAAO,CAAC,IAAI,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC;AACxC,CAAC;AAFD,oCAEC;AAEY,QAAA,OAAO,GAAG,cAAI,CAAC,WAAW,CAAC,YAAY,CAAC,CAAC;AAEtD;;;;;GAKG;AACI,KAAK,UAAU,aAAa,CAAC,IAAY,EAAE,OAAoB;IACpE,MAAM,eAAe,GAAG;QACtB,IAAI,EAAE,IAAI;QACV,OAAO,EAAE,OAAO,CAAC,OAAO,IAAI,CAAC;KAC9B,CAAC;IAEF,MAAM,IAAI,GAAG,MAAM,OAAO,CAAC,IAAI,EAAE,eAAe,CAAC,CAAC;IAClD,IAAI,IAAI,IAAI,IAAI;QAAE,MAAM,IAAI,KAAK,CAAC,2BAA2B,IAAI,EAAE,CAAC,CAAC;IACrE,OAAO,IAAI,CAAC;AACd,CAAC;AATD,sCASC;AAEY,QAAA,QAAQ,GAAG,cAAI,CAAC,WAAW,CAAC,aAAa,CAAC,CAAC;AAExD;;;;GAIG;AACI,KAAK,UAAU,gBAAgB,CAAC,IAAU;IAC/C,IAAI,IAAI,IAAI,IAAI;QAAE,MAAM,IAAI,KAAK,CAAC,cAAc,CAAC,CAAC;IAClD,MAAM,YAAI,CAAC,mBAAmB,CAAC,IAAI,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC;AACpD,CAAC;AAHD,4CAGC;AAEY,QAAA,WAAW,GAAG,cAAI,CAAC,WAAW,CAAC,gBAAgB,CAAC,CAAC;AAE9D;;;GAGG;AACI,KAAK,UAAU,UAAU,CAC9B,IAAY,EACZ,OAAoB,EACpB,IAAsB;IAEtB,MAAM,IAAI,GAAG,MAAM,aAAa,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC;IAChD,IAAI;QACF,OAAO,MAAM,IAAI,EAAE,CAAC;KACrB;YAAS;QACR,MAAM,gBAAgB,CAAC,IAAI,CAAC,CAAC;KAC9B;AACH,CAAC;AAXD,gCAWC;AAED;;;;;;;GAOG;AACH,KAAK,UAAU,OAAO,CAAC,IAAY,EAAE,OAA4B;IAC/D,MAAM,YAAI,CAAC,UAAU,CACnB,8EAA8E,EAC9E,EAAE,IAAI,EAAE,CACT,CAAC;IAEF,MAAM,MAAM,GAAG,MAAM,YAAI,CAAC,qBAAqB,EAAE,CAAC;IAElD,IAAI,YAAY,GAAG,KAAK,CAAC;IACzB,IAAI;QACF,IAAI,OAAO,CAAC,IAAI,IAAI,OAAO,CAAC,OAAO,GAAG,CAAC,EAAE;YACvC,+DAA+D;YAC/D,6DAA6D;YAC7D,oDAAoD;YACpD,MAAM,YAAI,CAAC,oBAAoB,CAC7B,MAAM,EACN,4BAA4B,MAAM,CAAC,aAAa,CAAC,OAAO,CAAC,OAAO,CAAC,QAAQ,EAAE,CAAC,EAAE,EAC9E,EAAE,CACH,CAAC;SACH;QAED,wEAAwE;QACxE,gEAAgE;QAChE,uEAAuE;QACvE,uEAAuE;QACvE,OAAO;QACP,MAAM,eAAe,GAAG,MAAM,CAAC,aAAa,CAAC,IAAI,CAAC,CAAC;QACnD,MAAM,QAAQ,GAAG,OAAO,CAAC,IAAI;YAC3B,CAAC,CAAC,0CAA0C,eAAe,cAAc;YACzE,CAAC,CAAC,0CAA0C,eAAe,0BAA0B,CAAC;QACxF,MAAM,MAAM,GAAG,MAAM,YAAI,CAAC,oBAAoB,CAAC,MAAM,EAAE,QAAQ,EAAE,EAAE,IAAI,EAAE,CAAC,CAAC;QAC3E,YAAY,GAAG,MAAM,CAAC,QAAQ,KAAK,CAAC,CAAC;KACtC;IAAC,OAAO,GAAG,EAAE;QACZ,mEAAmE;QACnE,SAAS;QACT,MAAM,YAAI,CAAC,mBAAmB,CAAC,MAAM,EAAE,GAAY,CAAC,CAAC;QACrD,MAAM,GAAG,CAAC;KACX;IAED,IAAI,CAAC,YAAY,EAAE;QACjB,6DAA6D;QAC7D,qDAAqD;QACrD,MAAM,YAAI,CAAC,mBAAmB,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC;QAC7C,OAAO,IAAI,CAAC;KACb;IAED,uEAAuE;IACvE,uEAAuE;IACvE,0BAA0B;IAC1B,OAAO,EAAE,MAAM,EAAE,CAAC;AACpB,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,17 +1,22 @@
1
1
  {
2
2
  "name": "@prairielearn/named-locks",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "main": "./dist/index.js",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/PrairieLearn/PrairieLearn.git",
8
+ "directory": "packages/named-locks"
9
+ },
5
10
  "scripts": {
6
11
  "build": "tsc",
7
12
  "dev": "tsc --watch --preserveWatchOutput"
8
13
  },
9
14
  "devDependencies": {
10
- "@prairielearn/tsconfig": "*",
11
- "@types/node": "^18.14.2",
15
+ "@prairielearn/tsconfig": "^0.0.0",
16
+ "@types/node": "^18.15.11",
12
17
  "typescript": "^4.9.4"
13
18
  },
14
19
  "dependencies": {
15
- "@prairielearn/postgres": "^1.3.0"
20
+ "@prairielearn/postgres": "^1.6.0"
16
21
  }
17
22
  }
package/src/index.ts CHANGED
@@ -2,23 +2,35 @@ import util from 'util';
2
2
  import { PostgresPool, PoolClient } from '@prairielearn/postgres';
3
3
  import { PoolConfig } from 'pg';
4
4
 
5
+ interface NamedLocksConfig {
6
+ /**
7
+ * How often to renew the lock in milliseconds. Defaults to 60 seconds.
8
+ * Auto-renewal must be explicitly enabled on each lock where it is desired.
9
+ *
10
+ */
11
+ renewIntervalMs?: number;
12
+ }
13
+
5
14
  interface Lock {
6
15
  client: PoolClient;
16
+ intervalId: NodeJS.Timeout | null;
7
17
  }
8
18
 
9
19
  interface LockOptions {
10
20
  /** How many milliseconds to wait (anything other than a positive number means forever) */
11
21
  timeout?: number;
22
+ /**
23
+ * Whether or not this lock should automatically renew itself periodically.
24
+ * By default, locks will not renew themselves.
25
+ *
26
+ * This is mostly useful for locks that may be help for longer than the idle
27
+ * session timeout that's configured for the Postgres database. The lock is
28
+ * "renewed" by making a no-op query.
29
+ */
30
+ autoRenew?: boolean;
12
31
  }
13
32
 
14
- type InternalLockOptions =
15
- | {
16
- wait: false;
17
- }
18
- | {
19
- wait: true;
20
- timeout: number;
21
- };
33
+ type TryLockOptions = Omit<LockOptions, 'timeout'>;
22
34
 
23
35
  /*
24
36
  * The functions here all identify locks by "name", which is a plain
@@ -64,14 +76,17 @@ type InternalLockOptions =
64
76
  */
65
77
 
66
78
  export const pool = new PostgresPool();
79
+ let renewIntervalMs = 60_000;
67
80
 
68
81
  /**
69
82
  * Initializes a new {@link PostgresPool} that will be used to acquire named locks.
70
83
  */
71
84
  export async function init(
72
85
  pgConfig: PoolConfig,
73
- idleErrorHandler: (error: Error, client: PoolClient) => void
86
+ idleErrorHandler: (error: Error, client: PoolClient) => void,
87
+ namedLocksConfig: NamedLocksConfig = {}
74
88
  ) {
89
+ renewIntervalMs = namedLocksConfig.renewIntervalMs ?? renewIntervalMs;
75
90
  await pool.initAsync(pgConfig, idleErrorHandler);
76
91
  await pool.queryAsync(
77
92
  'CREATE TABLE IF NOT EXISTS named_locks (id bigserial PRIMARY KEY, name text NOT NULL UNIQUE);',
@@ -94,8 +109,11 @@ export async function close() {
94
109
  *
95
110
  * @param name The name of the lock to acquire.
96
111
  */
97
- export async function tryLockAsync(name: string): Promise<Lock | null> {
98
- return getLock(name, { wait: false });
112
+ export async function tryLockAsync(
113
+ name: string,
114
+ options: TryLockOptions = {}
115
+ ): Promise<Lock | null> {
116
+ return getLock(name, { timeout: 0, ...options });
99
117
  }
100
118
 
101
119
  export const tryLock = util.callbackify(tryLockAsync);
@@ -107,12 +125,7 @@ export const tryLock = util.callbackify(tryLockAsync);
107
125
  * @param options
108
126
  */
109
127
  export async function waitLockAsync(name: string, options: LockOptions): Promise<Lock> {
110
- const internalOptions = {
111
- wait: true,
112
- timeout: options.timeout || 0,
113
- };
114
-
115
- const lock = await getLock(name, internalOptions);
128
+ const lock = await getLock(name, options);
116
129
  if (lock == null) throw new Error(`failed to acquire lock: ${name}`);
117
130
  return lock;
118
131
  }
@@ -126,6 +139,7 @@ export const waitLock = util.callbackify(waitLockAsync);
126
139
  */
127
140
  export async function releaseLockAsync(lock: Lock) {
128
141
  if (lock == null) throw new Error('lock is null');
142
+ clearInterval(lock.intervalId ?? undefined);
129
143
  await pool.endTransactionAsync(lock.client, null);
130
144
  }
131
145
 
@@ -148,6 +162,25 @@ export async function doWithLock<T>(
148
162
  }
149
163
  }
150
164
 
165
+ /**
166
+ * Tries to acquire the given lock, executes the provided function with the lock held,
167
+ * and releases the lock once the function has executed. If the lock cannot be acquired,
168
+ * the function is not executed and null is returned.
169
+ */
170
+ export async function tryWithLock<T>(
171
+ name: string,
172
+ options: TryLockOptions,
173
+ func: () => Promise<T>
174
+ ): Promise<T | null> {
175
+ const lock = await tryLockAsync(name, options);
176
+ if (lock == null) return null;
177
+ try {
178
+ return await func();
179
+ } finally {
180
+ await releaseLockAsync(lock);
181
+ }
182
+ }
183
+
151
184
  /**
152
185
  * Internal helper function to get a lock with optional
153
186
  * waiting. Do not call directly, but use tryLock() or waitLock()
@@ -156,7 +189,7 @@ export async function doWithLock<T>(
156
189
  * @param name The name of the lock to acquire.
157
190
  * @param options Optional parameters.
158
191
  */
159
- async function getLock(name: string, options: InternalLockOptions) {
192
+ async function getLock(name: string, options: LockOptions) {
160
193
  await pool.queryAsync(
161
194
  'INSERT INTO named_locks (name) VALUES ($name) ON CONFLICT (name) DO NOTHING;',
162
195
  { name }
@@ -166,7 +199,7 @@ async function getLock(name: string, options: InternalLockOptions) {
166
199
 
167
200
  let acquiredLock = false;
168
201
  try {
169
- if (options.wait && options.timeout > 0) {
202
+ if (options.timeout) {
170
203
  // SQL doesn't like us trying to use a parameterized query with
171
204
  // `SET LOCAL ...`. So, in this very specific case, we do the
172
205
  // parameterization ourselves using `escapeLiteral`.
@@ -183,7 +216,7 @@ async function getLock(name: string, options: InternalLockOptions) {
183
216
  // safe if it shows up in plaintext in logs, telemetry, error messages,
184
217
  // etc.
185
218
  const lockNameLiteral = client.escapeLiteral(name);
186
- const lock_sql = options.wait
219
+ const lock_sql = options.timeout
187
220
  ? `SELECT * FROM named_locks WHERE name = ${lockNameLiteral} FOR UPDATE;`
188
221
  : `SELECT * FROM named_locks WHERE name = ${lockNameLiteral} FOR UPDATE SKIP LOCKED;`;
189
222
  const result = await pool.queryWithClientAsync(client, lock_sql, { name });
@@ -202,8 +235,16 @@ async function getLock(name: string, options: InternalLockOptions) {
202
235
  return null;
203
236
  }
204
237
 
238
+ let intervalId = null;
239
+ if (options.autoRenew) {
240
+ // Periodically "renew" the lock by making a query.
241
+ intervalId = setInterval(() => {
242
+ client.query('SELECT 1;').catch(() => {});
243
+ }, renewIntervalMs);
244
+ }
245
+
205
246
  // We successfully acquired the lock, so we return with the transaction
206
247
  // help open. The caller will be responsible for releasing the lock and
207
248
  // ending the transaction.
208
- return { client };
249
+ return { client, intervalId };
209
250
  }