@prairielearn/named-locks 1.0.0 → 1.2.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 ADDED
@@ -0,0 +1,18 @@
1
+ # @prairielearn/named-locks
2
+
3
+ ## 1.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 400a0b901: Allow locks to be automatically renewed
8
+
9
+ ## 1.1.0
10
+
11
+ ### Minor Changes
12
+
13
+ - 5ae096ba7: Export underlying Postgres client pool as `pool`
14
+
15
+ ### Patch Changes
16
+
17
+ - Updated dependencies [5ae096ba7]
18
+ - @prairielearn/postgres@1.3.0
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,17 +1,37 @@
1
1
  /// <reference types="node" />
2
- import { PoolClient } from '@prairielearn/postgres';
2
+ /// <reference types="node" />
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
+ export declare const pool: PostgresPool;
11
31
  /**
12
32
  * Initializes a new {@link PostgresPool} that will be used to acquire named locks.
13
33
  */
14
- export declare function init(pgConfig: PoolConfig, idleErrorHandler: (error: Error, client: PoolClient) => void): Promise<void>;
34
+ export declare function init(pgConfig: PoolConfig, idleErrorHandler: (error: Error, client: PoolClient) => void, namedLocksConfig?: NamedLocksConfig): Promise<void>;
15
35
  /**
16
36
  * Shuts down the database connection pool that was used to acquire locks.
17
37
  */
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 = void 0;
6
+ 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
  /*
@@ -48,20 +48,22 @@ const postgres_1 = require("@prairielearn/postgres");
48
48
  * lock already held. Since there are a finite pool of locks available, this
49
49
  * can lead to deadlocks.
50
50
  */
51
- const pool = new postgres_1.PostgresPool();
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
- await pool.initAsync(pgConfig, idleErrorHandler);
57
- await pool.queryAsync('CREATE TABLE IF NOT EXISTS named_locks (id bigserial PRIMARY KEY, name text NOT NULL UNIQUE);', {});
56
+ async function init(pgConfig, idleErrorHandler, namedLocksConfig = {}) {
57
+ renewIntervalMs = namedLocksConfig.renewIntervalMs ?? renewIntervalMs;
58
+ await exports.pool.initAsync(pgConfig, idleErrorHandler);
59
+ await exports.pool.queryAsync('CREATE TABLE IF NOT EXISTS named_locks (id bigserial PRIMARY KEY, name text NOT NULL UNIQUE);', {});
58
60
  }
59
61
  exports.init = init;
60
62
  /**
61
63
  * Shuts down the database connection pool that was used to acquire locks.
62
64
  */
63
65
  async function close() {
64
- await pool.closeAsync();
66
+ await exports.pool.closeAsync();
65
67
  }
66
68
  exports.close = close;
67
69
  /**
@@ -73,7 +75,7 @@ exports.close = close;
73
75
  * @param name The name of the lock to acquire.
74
76
  */
75
77
  async function tryLockAsync(name) {
76
- return getLock(name, { wait: false });
78
+ return getLock(name, { timeout: 0 });
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,7 +101,8 @@ 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');
106
- await pool.endTransactionAsync(lock.client, null);
104
+ clearInterval(lock.intervalId ?? undefined);
105
+ await exports.pool.endTransactionAsync(lock.client, null);
107
106
  }
108
107
  exports.releaseLockAsync = releaseLockAsync;
109
108
  exports.releaseLock = util_1.default.callbackify(releaseLockAsync);
@@ -130,15 +129,15 @@ exports.doWithLock = doWithLock;
130
129
  * @param options Optional parameters.
131
130
  */
132
131
  async function getLock(name, options) {
133
- await pool.queryAsync('INSERT INTO named_locks (name) VALUES ($name) ON CONFLICT (name) DO NOTHING;', { name });
134
- const client = await pool.beginTransactionAsync();
132
+ await exports.pool.queryAsync('INSERT INTO named_locks (name) VALUES ($name) ON CONFLICT (name) DO NOTHING;', { name });
133
+ const client = await exports.pool.beginTransactionAsync();
135
134
  let acquiredLock = false;
136
135
  try {
137
- if (options.wait && options.timeout > 0) {
136
+ if (options.timeout) {
138
137
  // SQL doesn't like us trying to use a parameterized query with
139
138
  // `SET LOCAL ...`. So, in this very specific case, we do the
140
139
  // parameterization ourselves using `escapeLiteral`.
141
- await pool.queryWithClientAsync(client, `SET LOCAL lock_timeout = ${client.escapeLiteral(options.timeout.toString())}`, {});
140
+ await exports.pool.queryWithClientAsync(client, `SET LOCAL lock_timeout = ${client.escapeLiteral(options.timeout.toString())}`, {});
142
141
  }
143
142
  // A stuck lock is a critical issue. To make them easier to debug, we'll
144
143
  // include the literal lock name in the query instead of using a
@@ -146,27 +145,34 @@ async function getLock(name, options) {
146
145
  // safe if it shows up in plaintext in logs, telemetry, error messages,
147
146
  // etc.
148
147
  const lockNameLiteral = client.escapeLiteral(name);
149
- const lock_sql = options.wait
148
+ const lock_sql = options.timeout
150
149
  ? `SELECT * FROM named_locks WHERE name = ${lockNameLiteral} FOR UPDATE;`
151
150
  : `SELECT * FROM named_locks WHERE name = ${lockNameLiteral} FOR UPDATE SKIP LOCKED;`;
152
- const result = await pool.queryWithClientAsync(client, lock_sql, { name });
151
+ const result = await exports.pool.queryWithClientAsync(client, lock_sql, { name });
153
152
  acquiredLock = result.rowCount === 1;
154
153
  }
155
154
  catch (err) {
156
155
  // Something went wrong, so we end the transaction and re-throw the
157
156
  // error.
158
- await pool.endTransactionAsync(client, err);
157
+ await exports.pool.endTransactionAsync(client, err);
159
158
  throw err;
160
159
  }
161
160
  if (!acquiredLock) {
162
161
  // We didn't acquire the lock so our parent caller will never
163
162
  // release it, so we have to end the transaction now.
164
- await pool.endTransactionAsync(client, null);
163
+ await exports.pool.endTransactionAsync(client, null);
165
164
  return null;
166
165
  }
166
+ let intervalId = null;
167
+ if (options.autoRenew) {
168
+ // Periodically "renew" the lock by making a query.
169
+ intervalId = setInterval(() => {
170
+ client.query('SELECT 1;').catch(() => { });
171
+ }, renewIntervalMs);
172
+ }
167
173
  // We successfully acquired the lock, so we return with the transaction
168
174
  // help open. The caller will be responsible for releasing the lock and
169
175
  // ending the transaction.
170
- return { client };
176
+ return { client, intervalId };
171
177
  }
172
178
  //# 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;AAEH,MAAM,IAAI,GAAG,IAAI,uBAAY,EAAE,CAAC;AAEhC;;GAEG;AACI,KAAK,UAAU,IAAI,CACxB,QAAoB,EACpB,gBAA4D;IAE5D,MAAM,IAAI,CAAC,SAAS,CAAC,QAAQ,EAAE,gBAAgB,CAAC,CAAC;IACjD,MAAM,IAAI,CAAC,UAAU,CACnB,+FAA+F,EAC/F,EAAE,CACH,CAAC;AACJ,CAAC;AATD,oBASC;AAED;;GAEG;AACI,KAAK,UAAU,KAAK;IACzB,MAAM,IAAI,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,IAAI,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,IAAI,CAAC,UAAU,CACnB,8EAA8E,EAC9E,EAAE,IAAI,EAAE,CACT,CAAC;IAEF,MAAM,MAAM,GAAG,MAAM,IAAI,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,IAAI,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,IAAI,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,IAAI,CAAC,mBAAmB,CAAC,MAAM,EAAE,GAAY,CAAC,CAAC;QACrD,MAAM,GAAG,CAAC;KACX;IAED,IAAI,CAAC,YAAY,EAAE;QACjB,6DAA6D;QAC7D,qDAAqD;QACrD,MAAM,IAAI,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.0.0",
3
+ "version": "1.2.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
15
  "@prairielearn/tsconfig": "*",
11
- "@types/node": "^18.14.2",
16
+ "@types/node": "^18.15.11",
12
17
  "typescript": "^4.9.4"
13
18
  },
14
19
  "dependencies": {
15
- "@prairielearn/postgres": "^1.2.0"
20
+ "@prairielearn/postgres": "^1.3.0"
16
21
  }
17
22
  }
package/src/index.ts CHANGED
@@ -2,24 +2,34 @@ 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
- };
22
-
23
33
  /*
24
34
  * The functions here all identify locks by "name", which is a plain
25
35
  * string. The locks use the named_locks DB table. Each lock name
@@ -63,15 +73,18 @@ type InternalLockOptions =
63
73
  * can lead to deadlocks.
64
74
  */
65
75
 
66
- const pool = new PostgresPool();
76
+ export const pool = new PostgresPool();
77
+ let renewIntervalMs = 60_000;
67
78
 
68
79
  /**
69
80
  * Initializes a new {@link PostgresPool} that will be used to acquire named locks.
70
81
  */
71
82
  export async function init(
72
83
  pgConfig: PoolConfig,
73
- idleErrorHandler: (error: Error, client: PoolClient) => void
84
+ idleErrorHandler: (error: Error, client: PoolClient) => void,
85
+ namedLocksConfig: NamedLocksConfig = {}
74
86
  ) {
87
+ renewIntervalMs = namedLocksConfig.renewIntervalMs ?? renewIntervalMs;
75
88
  await pool.initAsync(pgConfig, idleErrorHandler);
76
89
  await pool.queryAsync(
77
90
  'CREATE TABLE IF NOT EXISTS named_locks (id bigserial PRIMARY KEY, name text NOT NULL UNIQUE);',
@@ -95,7 +108,7 @@ export async function close() {
95
108
  * @param name The name of the lock to acquire.
96
109
  */
97
110
  export async function tryLockAsync(name: string): Promise<Lock | null> {
98
- return getLock(name, { wait: false });
111
+ return getLock(name, { timeout: 0 });
99
112
  }
100
113
 
101
114
  export const tryLock = util.callbackify(tryLockAsync);
@@ -107,12 +120,7 @@ export const tryLock = util.callbackify(tryLockAsync);
107
120
  * @param options
108
121
  */
109
122
  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);
123
+ const lock = await getLock(name, options);
116
124
  if (lock == null) throw new Error(`failed to acquire lock: ${name}`);
117
125
  return lock;
118
126
  }
@@ -126,6 +134,7 @@ export const waitLock = util.callbackify(waitLockAsync);
126
134
  */
127
135
  export async function releaseLockAsync(lock: Lock) {
128
136
  if (lock == null) throw new Error('lock is null');
137
+ clearInterval(lock.intervalId ?? undefined);
129
138
  await pool.endTransactionAsync(lock.client, null);
130
139
  }
131
140
 
@@ -156,7 +165,7 @@ export async function doWithLock<T>(
156
165
  * @param name The name of the lock to acquire.
157
166
  * @param options Optional parameters.
158
167
  */
159
- async function getLock(name: string, options: InternalLockOptions) {
168
+ async function getLock(name: string, options: LockOptions) {
160
169
  await pool.queryAsync(
161
170
  'INSERT INTO named_locks (name) VALUES ($name) ON CONFLICT (name) DO NOTHING;',
162
171
  { name }
@@ -166,7 +175,7 @@ async function getLock(name: string, options: InternalLockOptions) {
166
175
 
167
176
  let acquiredLock = false;
168
177
  try {
169
- if (options.wait && options.timeout > 0) {
178
+ if (options.timeout) {
170
179
  // SQL doesn't like us trying to use a parameterized query with
171
180
  // `SET LOCAL ...`. So, in this very specific case, we do the
172
181
  // parameterization ourselves using `escapeLiteral`.
@@ -183,7 +192,7 @@ async function getLock(name: string, options: InternalLockOptions) {
183
192
  // safe if it shows up in plaintext in logs, telemetry, error messages,
184
193
  // etc.
185
194
  const lockNameLiteral = client.escapeLiteral(name);
186
- const lock_sql = options.wait
195
+ const lock_sql = options.timeout
187
196
  ? `SELECT * FROM named_locks WHERE name = ${lockNameLiteral} FOR UPDATE;`
188
197
  : `SELECT * FROM named_locks WHERE name = ${lockNameLiteral} FOR UPDATE SKIP LOCKED;`;
189
198
  const result = await pool.queryWithClientAsync(client, lock_sql, { name });
@@ -202,8 +211,16 @@ async function getLock(name: string, options: InternalLockOptions) {
202
211
  return null;
203
212
  }
204
213
 
214
+ let intervalId = null;
215
+ if (options.autoRenew) {
216
+ // Periodically "renew" the lock by making a query.
217
+ intervalId = setInterval(() => {
218
+ client.query('SELECT 1;').catch(() => {});
219
+ }, renewIntervalMs);
220
+ }
221
+
205
222
  // We successfully acquired the lock, so we return with the transaction
206
223
  // help open. The caller will be responsible for releasing the lock and
207
224
  // ending the transaction.
208
- return { client };
225
+ return { client, intervalId };
209
226
  }