@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 +12 -0
- package/README.md +79 -0
- package/dist/index.d.ts +28 -2
- package/dist/index.js +35 -12
- package/dist/index.js.map +1 -1
- package/package.json +9 -4
- package/src/index.ts +62 -21
package/CHANGELOG.md
CHANGED
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, {
|
|
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
|
|
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.
|
|
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.
|
|
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;
|
|
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.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.
|
|
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.
|
|
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
|
|
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(
|
|
98
|
-
|
|
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
|
|
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:
|
|
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.
|
|
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.
|
|
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
|
}
|