@prairielearn/named-locks 1.0.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/.turbo/turbo-build.log +0 -0
- package/dist/index.d.ts +49 -0
- package/dist/index.js +172 -0
- package/dist/index.js.map +1 -0
- package/package.json +17 -0
- package/src/index.ts +209 -0
- package/tsconfig.json +8 -0
|
File without changes
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
import { PoolClient } from '@prairielearn/postgres';
|
|
3
|
+
import { PoolConfig } from 'pg';
|
|
4
|
+
interface Lock {
|
|
5
|
+
client: PoolClient;
|
|
6
|
+
}
|
|
7
|
+
interface LockOptions {
|
|
8
|
+
/** How many milliseconds to wait (anything other than a positive number means forever) */
|
|
9
|
+
timeout?: number;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Initializes a new {@link PostgresPool} that will be used to acquire named locks.
|
|
13
|
+
*/
|
|
14
|
+
export declare function init(pgConfig: PoolConfig, idleErrorHandler: (error: Error, client: PoolClient) => void): Promise<void>;
|
|
15
|
+
/**
|
|
16
|
+
* Shuts down the database connection pool that was used to acquire locks.
|
|
17
|
+
*/
|
|
18
|
+
export declare function close(): Promise<void>;
|
|
19
|
+
/**
|
|
20
|
+
* Try to acquire a lock and either succeed if it's available or
|
|
21
|
+
* return immediately if not. If a lock is acquired then it must
|
|
22
|
+
* must later be released by releaseLock(). If a lock is not acquired
|
|
23
|
+
* (it is null) then releaseLock() should not be called.
|
|
24
|
+
*
|
|
25
|
+
* @param name The name of the lock to acquire.
|
|
26
|
+
*/
|
|
27
|
+
export declare function tryLockAsync(name: string): Promise<Lock | null>;
|
|
28
|
+
export declare const tryLock: (arg1: string, callback: (err: NodeJS.ErrnoException, result: Lock | null) => void) => void;
|
|
29
|
+
/**
|
|
30
|
+
* Wait until a lock can be successfully acquired.
|
|
31
|
+
*
|
|
32
|
+
* @param name The name of the lock to acquire.
|
|
33
|
+
* @param options
|
|
34
|
+
*/
|
|
35
|
+
export declare function waitLockAsync(name: string, options: LockOptions): Promise<Lock>;
|
|
36
|
+
export declare const waitLock: (arg1: string, arg2: LockOptions, callback: (err: NodeJS.ErrnoException | null, result: Lock) => void) => void;
|
|
37
|
+
/**
|
|
38
|
+
* Release a lock.
|
|
39
|
+
*
|
|
40
|
+
* @param lock A previously-acquired lock.
|
|
41
|
+
*/
|
|
42
|
+
export declare function releaseLockAsync(lock: Lock): Promise<void>;
|
|
43
|
+
export declare const releaseLock: (arg1: Lock, callback: (err: NodeJS.ErrnoException) => void) => void;
|
|
44
|
+
/**
|
|
45
|
+
* Acquires the given lock, executes the provided function with the lock held,
|
|
46
|
+
* and releases the lock once the function has executed.
|
|
47
|
+
*/
|
|
48
|
+
export declare function doWithLock<T>(name: string, options: LockOptions, func: () => Promise<T>): Promise<T>;
|
|
49
|
+
export {};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
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;
|
|
7
|
+
const util_1 = __importDefault(require("util"));
|
|
8
|
+
const postgres_1 = require("@prairielearn/postgres");
|
|
9
|
+
/*
|
|
10
|
+
* The functions here all identify locks by "name", which is a plain
|
|
11
|
+
* string. The locks use the named_locks DB table. Each lock name
|
|
12
|
+
* corresponds to a unique table row. To take a lock, we:
|
|
13
|
+
* 1. make sure a row exists for the lock name
|
|
14
|
+
* 2. start a transaction
|
|
15
|
+
* 3. acquire a "FOR UPDATE" row lock on the DB row for the named
|
|
16
|
+
* lock (this blocks all other locks on the same row). See
|
|
17
|
+
* https://www.postgresql.org/docs/current/explicit-locking.html#LOCKING-ROWS
|
|
18
|
+
* 4. return to the caller with the transaction held open
|
|
19
|
+
*
|
|
20
|
+
* The caller then does some work and finally calls releaseLock(),
|
|
21
|
+
* which ends the transaction, thereby releasing the row lock.
|
|
22
|
+
*
|
|
23
|
+
* The flow above will wait indefinitely for the lock to become
|
|
24
|
+
* available. To implement optional timeouts, we set the DB variable
|
|
25
|
+
* `lock_timeout`:
|
|
26
|
+
* https://www.postgresql.org/docs/current/runtime-config-client.html#GUC-LOCK-TIMEOUT
|
|
27
|
+
* If we timeout then we will return an error to the caller.
|
|
28
|
+
*
|
|
29
|
+
* To implement a no-waiting tryLock() we use the PostgreSQL "SKIP
|
|
30
|
+
* LOCKED" feature:
|
|
31
|
+
* https://www.postgresql.org/docs/current/sql-select.html#SQL-FOR-UPDATE-SHARE
|
|
32
|
+
* If we fail to acquire the lock then we immediately release the
|
|
33
|
+
* transaction and return to the caller with `lock = null`. In this
|
|
34
|
+
* case the caller should not call releaseLock().
|
|
35
|
+
*
|
|
36
|
+
* The lock object returned by functions in this module are of the
|
|
37
|
+
* form `{client, done}`, where `client` and `done` are sqldb
|
|
38
|
+
* transaction objects. These are simply the objects we need to end
|
|
39
|
+
* the transaction, which will release the lock.
|
|
40
|
+
*
|
|
41
|
+
* Importantly, we use a separate pool of database connections for acquiring
|
|
42
|
+
* and holding locks. This ensures that we don't end up with a deadlock.
|
|
43
|
+
* For instance, if we have a pool of 10 clients and there are 10 locks held at
|
|
44
|
+
* once, if any code inside of a lock tries to acquire another database client,
|
|
45
|
+
* we'd deadlock if we weren't separating the two pools of connections.
|
|
46
|
+
*
|
|
47
|
+
* You should NEVER try to acquire a lock in code that is executing with another
|
|
48
|
+
* lock already held. Since there are a finite pool of locks available, this
|
|
49
|
+
* can lead to deadlocks.
|
|
50
|
+
*/
|
|
51
|
+
const pool = new postgres_1.PostgresPool();
|
|
52
|
+
/**
|
|
53
|
+
* Initializes a new {@link PostgresPool} that will be used to acquire named locks.
|
|
54
|
+
*/
|
|
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);', {});
|
|
58
|
+
}
|
|
59
|
+
exports.init = init;
|
|
60
|
+
/**
|
|
61
|
+
* Shuts down the database connection pool that was used to acquire locks.
|
|
62
|
+
*/
|
|
63
|
+
async function close() {
|
|
64
|
+
await pool.closeAsync();
|
|
65
|
+
}
|
|
66
|
+
exports.close = close;
|
|
67
|
+
/**
|
|
68
|
+
* Try to acquire a lock and either succeed if it's available or
|
|
69
|
+
* return immediately if not. If a lock is acquired then it must
|
|
70
|
+
* must later be released by releaseLock(). If a lock is not acquired
|
|
71
|
+
* (it is null) then releaseLock() should not be called.
|
|
72
|
+
*
|
|
73
|
+
* @param name The name of the lock to acquire.
|
|
74
|
+
*/
|
|
75
|
+
async function tryLockAsync(name) {
|
|
76
|
+
return getLock(name, { wait: false });
|
|
77
|
+
}
|
|
78
|
+
exports.tryLockAsync = tryLockAsync;
|
|
79
|
+
exports.tryLock = util_1.default.callbackify(tryLockAsync);
|
|
80
|
+
/**
|
|
81
|
+
* Wait until a lock can be successfully acquired.
|
|
82
|
+
*
|
|
83
|
+
* @param name The name of the lock to acquire.
|
|
84
|
+
* @param options
|
|
85
|
+
*/
|
|
86
|
+
async function waitLockAsync(name, options) {
|
|
87
|
+
const internalOptions = {
|
|
88
|
+
wait: true,
|
|
89
|
+
timeout: options.timeout || 0,
|
|
90
|
+
};
|
|
91
|
+
const lock = await getLock(name, internalOptions);
|
|
92
|
+
if (lock == null)
|
|
93
|
+
throw new Error(`failed to acquire lock: ${name}`);
|
|
94
|
+
return lock;
|
|
95
|
+
}
|
|
96
|
+
exports.waitLockAsync = waitLockAsync;
|
|
97
|
+
exports.waitLock = util_1.default.callbackify(waitLockAsync);
|
|
98
|
+
/**
|
|
99
|
+
* Release a lock.
|
|
100
|
+
*
|
|
101
|
+
* @param lock A previously-acquired lock.
|
|
102
|
+
*/
|
|
103
|
+
async function releaseLockAsync(lock) {
|
|
104
|
+
if (lock == null)
|
|
105
|
+
throw new Error('lock is null');
|
|
106
|
+
await pool.endTransactionAsync(lock.client, null);
|
|
107
|
+
}
|
|
108
|
+
exports.releaseLockAsync = releaseLockAsync;
|
|
109
|
+
exports.releaseLock = util_1.default.callbackify(releaseLockAsync);
|
|
110
|
+
/**
|
|
111
|
+
* Acquires the given lock, executes the provided function with the lock held,
|
|
112
|
+
* and releases the lock once the function has executed.
|
|
113
|
+
*/
|
|
114
|
+
async function doWithLock(name, options, func) {
|
|
115
|
+
const lock = await waitLockAsync(name, options);
|
|
116
|
+
try {
|
|
117
|
+
return await func();
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
await releaseLockAsync(lock);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
exports.doWithLock = doWithLock;
|
|
124
|
+
/**
|
|
125
|
+
* Internal helper function to get a lock with optional
|
|
126
|
+
* waiting. Do not call directly, but use tryLock() or waitLock()
|
|
127
|
+
* instead.
|
|
128
|
+
*
|
|
129
|
+
* @param name The name of the lock to acquire.
|
|
130
|
+
* @param options Optional parameters.
|
|
131
|
+
*/
|
|
132
|
+
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();
|
|
135
|
+
let acquiredLock = false;
|
|
136
|
+
try {
|
|
137
|
+
if (options.wait && options.timeout > 0) {
|
|
138
|
+
// SQL doesn't like us trying to use a parameterized query with
|
|
139
|
+
// `SET LOCAL ...`. So, in this very specific case, we do the
|
|
140
|
+
// parameterization ourselves using `escapeLiteral`.
|
|
141
|
+
await pool.queryWithClientAsync(client, `SET LOCAL lock_timeout = ${client.escapeLiteral(options.timeout.toString())}`, {});
|
|
142
|
+
}
|
|
143
|
+
// A stuck lock is a critical issue. To make them easier to debug, we'll
|
|
144
|
+
// include the literal lock name in the query instead of using a
|
|
145
|
+
// parameterized query. The lock name should never include PII, so it's
|
|
146
|
+
// safe if it shows up in plaintext in logs, telemetry, error messages,
|
|
147
|
+
// etc.
|
|
148
|
+
const lockNameLiteral = client.escapeLiteral(name);
|
|
149
|
+
const lock_sql = options.wait
|
|
150
|
+
? `SELECT * FROM named_locks WHERE name = ${lockNameLiteral} FOR UPDATE;`
|
|
151
|
+
: `SELECT * FROM named_locks WHERE name = ${lockNameLiteral} FOR UPDATE SKIP LOCKED;`;
|
|
152
|
+
const result = await pool.queryWithClientAsync(client, lock_sql, { name });
|
|
153
|
+
acquiredLock = result.rowCount === 1;
|
|
154
|
+
}
|
|
155
|
+
catch (err) {
|
|
156
|
+
// Something went wrong, so we end the transaction and re-throw the
|
|
157
|
+
// error.
|
|
158
|
+
await pool.endTransactionAsync(client, err);
|
|
159
|
+
throw err;
|
|
160
|
+
}
|
|
161
|
+
if (!acquiredLock) {
|
|
162
|
+
// We didn't acquire the lock so our parent caller will never
|
|
163
|
+
// release it, so we have to end the transaction now.
|
|
164
|
+
await pool.endTransactionAsync(client, null);
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
// We successfully acquired the lock, so we return with the transaction
|
|
168
|
+
// help open. The caller will be responsible for releasing the lock and
|
|
169
|
+
// ending the transaction.
|
|
170
|
+
return { client };
|
|
171
|
+
}
|
|
172
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@prairielearn/named-locks",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"main": "./dist/index.js",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"build": "tsc",
|
|
7
|
+
"dev": "tsc --watch --preserveWatchOutput"
|
|
8
|
+
},
|
|
9
|
+
"devDependencies": {
|
|
10
|
+
"@prairielearn/tsconfig": "*",
|
|
11
|
+
"@types/node": "^18.14.2",
|
|
12
|
+
"typescript": "^4.9.4"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@prairielearn/postgres": "^1.2.0"
|
|
16
|
+
}
|
|
17
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import util from 'util';
|
|
2
|
+
import { PostgresPool, PoolClient } from '@prairielearn/postgres';
|
|
3
|
+
import { PoolConfig } from 'pg';
|
|
4
|
+
|
|
5
|
+
interface Lock {
|
|
6
|
+
client: PoolClient;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface LockOptions {
|
|
10
|
+
/** How many milliseconds to wait (anything other than a positive number means forever) */
|
|
11
|
+
timeout?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type InternalLockOptions =
|
|
15
|
+
| {
|
|
16
|
+
wait: false;
|
|
17
|
+
}
|
|
18
|
+
| {
|
|
19
|
+
wait: true;
|
|
20
|
+
timeout: number;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/*
|
|
24
|
+
* The functions here all identify locks by "name", which is a plain
|
|
25
|
+
* string. The locks use the named_locks DB table. Each lock name
|
|
26
|
+
* corresponds to a unique table row. To take a lock, we:
|
|
27
|
+
* 1. make sure a row exists for the lock name
|
|
28
|
+
* 2. start a transaction
|
|
29
|
+
* 3. acquire a "FOR UPDATE" row lock on the DB row for the named
|
|
30
|
+
* lock (this blocks all other locks on the same row). See
|
|
31
|
+
* https://www.postgresql.org/docs/current/explicit-locking.html#LOCKING-ROWS
|
|
32
|
+
* 4. return to the caller with the transaction held open
|
|
33
|
+
*
|
|
34
|
+
* The caller then does some work and finally calls releaseLock(),
|
|
35
|
+
* which ends the transaction, thereby releasing the row lock.
|
|
36
|
+
*
|
|
37
|
+
* The flow above will wait indefinitely for the lock to become
|
|
38
|
+
* available. To implement optional timeouts, we set the DB variable
|
|
39
|
+
* `lock_timeout`:
|
|
40
|
+
* https://www.postgresql.org/docs/current/runtime-config-client.html#GUC-LOCK-TIMEOUT
|
|
41
|
+
* If we timeout then we will return an error to the caller.
|
|
42
|
+
*
|
|
43
|
+
* To implement a no-waiting tryLock() we use the PostgreSQL "SKIP
|
|
44
|
+
* LOCKED" feature:
|
|
45
|
+
* https://www.postgresql.org/docs/current/sql-select.html#SQL-FOR-UPDATE-SHARE
|
|
46
|
+
* If we fail to acquire the lock then we immediately release the
|
|
47
|
+
* transaction and return to the caller with `lock = null`. In this
|
|
48
|
+
* case the caller should not call releaseLock().
|
|
49
|
+
*
|
|
50
|
+
* The lock object returned by functions in this module are of the
|
|
51
|
+
* form `{client, done}`, where `client` and `done` are sqldb
|
|
52
|
+
* transaction objects. These are simply the objects we need to end
|
|
53
|
+
* the transaction, which will release the lock.
|
|
54
|
+
*
|
|
55
|
+
* Importantly, we use a separate pool of database connections for acquiring
|
|
56
|
+
* and holding locks. This ensures that we don't end up with a deadlock.
|
|
57
|
+
* For instance, if we have a pool of 10 clients and there are 10 locks held at
|
|
58
|
+
* once, if any code inside of a lock tries to acquire another database client,
|
|
59
|
+
* we'd deadlock if we weren't separating the two pools of connections.
|
|
60
|
+
*
|
|
61
|
+
* You should NEVER try to acquire a lock in code that is executing with another
|
|
62
|
+
* lock already held. Since there are a finite pool of locks available, this
|
|
63
|
+
* can lead to deadlocks.
|
|
64
|
+
*/
|
|
65
|
+
|
|
66
|
+
const pool = new PostgresPool();
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Initializes a new {@link PostgresPool} that will be used to acquire named locks.
|
|
70
|
+
*/
|
|
71
|
+
export async function init(
|
|
72
|
+
pgConfig: PoolConfig,
|
|
73
|
+
idleErrorHandler: (error: Error, client: PoolClient) => void
|
|
74
|
+
) {
|
|
75
|
+
await pool.initAsync(pgConfig, idleErrorHandler);
|
|
76
|
+
await pool.queryAsync(
|
|
77
|
+
'CREATE TABLE IF NOT EXISTS named_locks (id bigserial PRIMARY KEY, name text NOT NULL UNIQUE);',
|
|
78
|
+
{}
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Shuts down the database connection pool that was used to acquire locks.
|
|
84
|
+
*/
|
|
85
|
+
export async function close() {
|
|
86
|
+
await pool.closeAsync();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Try to acquire a lock and either succeed if it's available or
|
|
91
|
+
* return immediately if not. If a lock is acquired then it must
|
|
92
|
+
* must later be released by releaseLock(). If a lock is not acquired
|
|
93
|
+
* (it is null) then releaseLock() should not be called.
|
|
94
|
+
*
|
|
95
|
+
* @param name The name of the lock to acquire.
|
|
96
|
+
*/
|
|
97
|
+
export async function tryLockAsync(name: string): Promise<Lock | null> {
|
|
98
|
+
return getLock(name, { wait: false });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export const tryLock = util.callbackify(tryLockAsync);
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Wait until a lock can be successfully acquired.
|
|
105
|
+
*
|
|
106
|
+
* @param name The name of the lock to acquire.
|
|
107
|
+
* @param options
|
|
108
|
+
*/
|
|
109
|
+
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);
|
|
116
|
+
if (lock == null) throw new Error(`failed to acquire lock: ${name}`);
|
|
117
|
+
return lock;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export const waitLock = util.callbackify(waitLockAsync);
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Release a lock.
|
|
124
|
+
*
|
|
125
|
+
* @param lock A previously-acquired lock.
|
|
126
|
+
*/
|
|
127
|
+
export async function releaseLockAsync(lock: Lock) {
|
|
128
|
+
if (lock == null) throw new Error('lock is null');
|
|
129
|
+
await pool.endTransactionAsync(lock.client, null);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export const releaseLock = util.callbackify(releaseLockAsync);
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Acquires the given lock, executes the provided function with the lock held,
|
|
136
|
+
* and releases the lock once the function has executed.
|
|
137
|
+
*/
|
|
138
|
+
export async function doWithLock<T>(
|
|
139
|
+
name: string,
|
|
140
|
+
options: LockOptions,
|
|
141
|
+
func: () => Promise<T>
|
|
142
|
+
): Promise<T> {
|
|
143
|
+
const lock = await waitLockAsync(name, options);
|
|
144
|
+
try {
|
|
145
|
+
return await func();
|
|
146
|
+
} finally {
|
|
147
|
+
await releaseLockAsync(lock);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Internal helper function to get a lock with optional
|
|
153
|
+
* waiting. Do not call directly, but use tryLock() or waitLock()
|
|
154
|
+
* instead.
|
|
155
|
+
*
|
|
156
|
+
* @param name The name of the lock to acquire.
|
|
157
|
+
* @param options Optional parameters.
|
|
158
|
+
*/
|
|
159
|
+
async function getLock(name: string, options: InternalLockOptions) {
|
|
160
|
+
await pool.queryAsync(
|
|
161
|
+
'INSERT INTO named_locks (name) VALUES ($name) ON CONFLICT (name) DO NOTHING;',
|
|
162
|
+
{ name }
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
const client = await pool.beginTransactionAsync();
|
|
166
|
+
|
|
167
|
+
let acquiredLock = false;
|
|
168
|
+
try {
|
|
169
|
+
if (options.wait && options.timeout > 0) {
|
|
170
|
+
// SQL doesn't like us trying to use a parameterized query with
|
|
171
|
+
// `SET LOCAL ...`. So, in this very specific case, we do the
|
|
172
|
+
// parameterization ourselves using `escapeLiteral`.
|
|
173
|
+
await pool.queryWithClientAsync(
|
|
174
|
+
client,
|
|
175
|
+
`SET LOCAL lock_timeout = ${client.escapeLiteral(options.timeout.toString())}`,
|
|
176
|
+
{}
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// A stuck lock is a critical issue. To make them easier to debug, we'll
|
|
181
|
+
// include the literal lock name in the query instead of using a
|
|
182
|
+
// parameterized query. The lock name should never include PII, so it's
|
|
183
|
+
// safe if it shows up in plaintext in logs, telemetry, error messages,
|
|
184
|
+
// etc.
|
|
185
|
+
const lockNameLiteral = client.escapeLiteral(name);
|
|
186
|
+
const lock_sql = options.wait
|
|
187
|
+
? `SELECT * FROM named_locks WHERE name = ${lockNameLiteral} FOR UPDATE;`
|
|
188
|
+
: `SELECT * FROM named_locks WHERE name = ${lockNameLiteral} FOR UPDATE SKIP LOCKED;`;
|
|
189
|
+
const result = await pool.queryWithClientAsync(client, lock_sql, { name });
|
|
190
|
+
acquiredLock = result.rowCount === 1;
|
|
191
|
+
} catch (err) {
|
|
192
|
+
// Something went wrong, so we end the transaction and re-throw the
|
|
193
|
+
// error.
|
|
194
|
+
await pool.endTransactionAsync(client, err as Error);
|
|
195
|
+
throw err;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (!acquiredLock) {
|
|
199
|
+
// We didn't acquire the lock so our parent caller will never
|
|
200
|
+
// release it, so we have to end the transaction now.
|
|
201
|
+
await pool.endTransactionAsync(client, null);
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// We successfully acquired the lock, so we return with the transaction
|
|
206
|
+
// help open. The caller will be responsible for releasing the lock and
|
|
207
|
+
// ending the transaction.
|
|
208
|
+
return { client };
|
|
209
|
+
}
|