@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 +18 -0
- package/README.md +79 -0
- package/dist/index.d.ts +22 -2
- package/dist/index.js +28 -22
- package/dist/index.js.map +1 -1
- package/package.json +8 -3
- package/src/index.ts +39 -22
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
57
|
-
await pool.
|
|
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, {
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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;
|
|
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.
|
|
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.
|
|
16
|
+
"@types/node": "^18.15.11",
|
|
12
17
|
"typescript": "^4.9.4"
|
|
13
18
|
},
|
|
14
19
|
"dependencies": {
|
|
15
|
-
"@prairielearn/postgres": "^1.
|
|
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, {
|
|
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
|
|
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:
|
|
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.
|
|
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.
|
|
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
|
}
|