bunsane 0.5.6 → 0.5.7
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/core/SchedulerManager.ts +1 -0
- package/core/scheduler/DistributedLock.ts +147 -140
- package/core/scheduler/index.ts +13 -0
- package/core/scheduler/lockCoordinator.ts +1 -0
- package/core/scheduler/locks/AdvisoryLockBackend.ts +276 -0
- package/core/scheduler/locks/InProcessLockBackend.ts +85 -0
- package/core/scheduler/locks/LockBackend.ts +91 -0
- package/core/scheduler/locks/PostgresLeaseLockBackend.ts +159 -0
- package/core/scheduler/locks/index.ts +81 -0
- package/core/scheduler/withLock.ts +159 -98
- package/package.json +1 -1
- package/types/scheduler.types.ts +13 -0
|
@@ -1,98 +1,159 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* withLock — run a function while holding a
|
|
3
|
-
*
|
|
4
|
-
* Thin convenience wrapper over the shared {@link DistributedLock} singleton.
|
|
5
|
-
* Acquires the lock for `key`, runs `fn`, and always releases it — even if
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* -
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* }
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* }
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
1
|
+
/**
|
|
2
|
+
* withLock — run a function while holding a distributed lock.
|
|
3
|
+
*
|
|
4
|
+
* Thin convenience wrapper over the shared {@link DistributedLock} singleton.
|
|
5
|
+
* Acquires the lock for `key`, runs `fn`, and always releases it — even if `fn`
|
|
6
|
+
* throws. Only one holder of a given `key` runs `fn` at a time, across every
|
|
7
|
+
* process pointed at the same lock store (backend-dependent: see DistributedLock).
|
|
8
|
+
*
|
|
9
|
+
* Two layers of exclusion:
|
|
10
|
+
* - Across processes: the configured {@link LockBackend} (postgres-lease by
|
|
11
|
+
* default — pooler-safe).
|
|
12
|
+
* - Within a process: an in-memory `Set`, so concurrent same-key callers
|
|
13
|
+
* sharing this process are mutually exclusive without a backend round trip.
|
|
14
|
+
*
|
|
15
|
+
* Lease heartbeat: lease backends expire a lock after `leaseTtlMs`. For `fn`
|
|
16
|
+
* that outlives the lease, withLock renews it every ~ttl/3 while `fn` runs. If
|
|
17
|
+
* a renewal ever fails, the lease was lost (stolen) mid-execution — this is
|
|
18
|
+
* logged as ERROR and counted (see {@link DistributedLock.getLostLeaseCount}).
|
|
19
|
+
*
|
|
20
|
+
* Contention is NOT silent by default-of-last-resort: the return type forces a
|
|
21
|
+
* `.acquired` check, and callers can opt into a thrown {@link
|
|
22
|
+
* LockUnavailableError} (`throwOnContention`) or an `onContended` callback so a
|
|
23
|
+
* dropped critical section can never be ignored by accident (BUNSANE-2).
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* const res = await withLock("rebuild-search-index", async () => {
|
|
27
|
+
* await rebuildIndex();
|
|
28
|
+
* return "done";
|
|
29
|
+
* });
|
|
30
|
+
* if (!res.acquired) {
|
|
31
|
+
* // another instance is already rebuilding — skip
|
|
32
|
+
* }
|
|
33
|
+
*
|
|
34
|
+
* @example // fail loudly instead of returning {acquired:false}
|
|
35
|
+
* await withLock("k", fn, { throwOnContention: true }); // throws LockUnavailableError
|
|
36
|
+
*/
|
|
37
|
+
import { getDistributedLock } from "./DistributedLock";
|
|
38
|
+
import { logger } from "../Logger";
|
|
39
|
+
|
|
40
|
+
const loggerInstance = logger.child({ scope: "withLock" });
|
|
41
|
+
|
|
42
|
+
/** Thrown by {@link withLock} on contention when `throwOnContention` is set. */
|
|
43
|
+
export class LockUnavailableError extends Error {
|
|
44
|
+
constructor(public readonly key: string) {
|
|
45
|
+
super(
|
|
46
|
+
`Lock unavailable for key "${key}" — another holder is executing the critical section`
|
|
47
|
+
);
|
|
48
|
+
this.name = "LockUnavailableError";
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface WithLockOptions {
|
|
53
|
+
/** Max ms to wait for the lock before giving up. 0 (default) = try once. */
|
|
54
|
+
wait?: number;
|
|
55
|
+
/** Poll interval while waiting, in ms. Default 100. */
|
|
56
|
+
retryInterval?: number;
|
|
57
|
+
/**
|
|
58
|
+
* Throw {@link LockUnavailableError} on contention instead of returning
|
|
59
|
+
* `{acquired:false}`. Use when skipping the work silently would lose it.
|
|
60
|
+
* @default false
|
|
61
|
+
*/
|
|
62
|
+
throwOnContention?: boolean;
|
|
63
|
+
/**
|
|
64
|
+
* Invoked when the lock could not be acquired (after `wait` elapses).
|
|
65
|
+
* Runs before the contention return/throw — use it to record/forward the
|
|
66
|
+
* dropped work explicitly. Errors thrown here propagate to the caller.
|
|
67
|
+
*/
|
|
68
|
+
onContended?: (key: string) => void | Promise<void>;
|
|
69
|
+
/**
|
|
70
|
+
* Lease lifetime in ms for this lock. Drives both the acquired lease TTL
|
|
71
|
+
* and the heartbeat cadence (~ttl/3). Defaults to the DistributedLock
|
|
72
|
+
* configured lease TTL.
|
|
73
|
+
*/
|
|
74
|
+
leaseTtlMs?: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export type LockOutcome<T> =
|
|
78
|
+
| { acquired: false; result?: undefined }
|
|
79
|
+
| { acquired: true; result: T };
|
|
80
|
+
|
|
81
|
+
/** In-process holders, keyed by lock key (see "Within a process" above). */
|
|
82
|
+
const localHeld = new Set<string>();
|
|
83
|
+
|
|
84
|
+
const sleep = (ms: number): Promise<void> =>
|
|
85
|
+
new Promise((resolve) => setTimeout(resolve, ms));
|
|
86
|
+
|
|
87
|
+
export async function withLock<T>(
|
|
88
|
+
key: string,
|
|
89
|
+
fn: () => Promise<T> | T,
|
|
90
|
+
options: WithLockOptions = {}
|
|
91
|
+
): Promise<LockOutcome<T>> {
|
|
92
|
+
const { wait = 0, retryInterval = 100, throwOnContention = false, onContended } =
|
|
93
|
+
options;
|
|
94
|
+
const deadline = wait > 0 ? Date.now() + wait : 0;
|
|
95
|
+
|
|
96
|
+
const contended = async (): Promise<LockOutcome<T>> => {
|
|
97
|
+
if (onContended) await onContended(key);
|
|
98
|
+
if (throwOnContention) throw new LockUnavailableError(key);
|
|
99
|
+
return { acquired: false };
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
// In-process gate. The has-check that exits the loop and the subsequent
|
|
103
|
+
// add() run without an await between them, so this is atomic on JS's single
|
|
104
|
+
// thread — concurrent same-key callers cannot both pass.
|
|
105
|
+
while (localHeld.has(key)) {
|
|
106
|
+
if (!deadline || Date.now() >= deadline) {
|
|
107
|
+
return contended();
|
|
108
|
+
}
|
|
109
|
+
await sleep(retryInterval);
|
|
110
|
+
}
|
|
111
|
+
localHeld.add(key);
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
const lock = getDistributedLock();
|
|
115
|
+
const ttlMs = options.leaseTtlMs ?? lock.getLeaseTtlMs();
|
|
116
|
+
|
|
117
|
+
let acquired = (await lock.tryAcquire(key, ttlMs)).acquired;
|
|
118
|
+
while (!acquired && deadline && Date.now() < deadline) {
|
|
119
|
+
await sleep(retryInterval);
|
|
120
|
+
acquired = (await lock.tryAcquire(key, ttlMs)).acquired;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (!acquired) {
|
|
124
|
+
return contended();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Heartbeat: keep the lease alive for the duration of fn. Interval is
|
|
128
|
+
// ttl/3 (≥1s) so a renewal lands well before expiry. unref so a stray
|
|
129
|
+
// timer never keeps the process alive during shutdown.
|
|
130
|
+
const heartbeatMs = Math.max(1000, Math.floor(ttlMs / 3));
|
|
131
|
+
const heartbeat = setInterval(() => {
|
|
132
|
+
lock.renew(key).then(
|
|
133
|
+
(ok) => {
|
|
134
|
+
if (!ok) {
|
|
135
|
+
// DistributedLock.renew already logged ERROR + counted;
|
|
136
|
+
// stop pinging a lease we no longer own.
|
|
137
|
+
clearInterval(heartbeat);
|
|
138
|
+
loggerInstance.error(
|
|
139
|
+
`Lost lease for "${key}" mid-execution — critical section no longer protected`
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
() => {
|
|
144
|
+
/* transient renew error already logged; retry next tick */
|
|
145
|
+
}
|
|
146
|
+
);
|
|
147
|
+
}, heartbeatMs);
|
|
148
|
+
(heartbeat as { unref?: () => void }).unref?.();
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
return { acquired: true, result: await fn() };
|
|
152
|
+
} finally {
|
|
153
|
+
clearInterval(heartbeat);
|
|
154
|
+
await lock.release(key);
|
|
155
|
+
}
|
|
156
|
+
} finally {
|
|
157
|
+
localHeld.delete(key);
|
|
158
|
+
}
|
|
159
|
+
}
|
package/package.json
CHANGED
package/types/scheduler.types.ts
CHANGED
|
@@ -210,6 +210,19 @@ export interface SchedulerConfig {
|
|
|
210
210
|
* @default 100
|
|
211
211
|
*/
|
|
212
212
|
lockRetryInterval?: number;
|
|
213
|
+
/**
|
|
214
|
+
* Which lock backend to use for distributed locking.
|
|
215
|
+
* - `'auto'` — safest correct default for the deployment.
|
|
216
|
+
* - `'in-process'` — single instance only (no cross-process exclusion).
|
|
217
|
+
* - `'postgres'` — pooler-safe lease table (recommended for pgbouncer).
|
|
218
|
+
* - `'redis'` — Redis `SET NX PX` lease (opt-in).
|
|
219
|
+
* - `'advisory'` — PostgreSQL session advisory locks; ONLY safe on a
|
|
220
|
+
* session-pinned connection (breaks behind a
|
|
221
|
+
* transaction pooler — see BUNSANE-1).
|
|
222
|
+
* Overridable via `BUNSANE_LOCK_BACKEND`.
|
|
223
|
+
* @default 'auto'
|
|
224
|
+
*/
|
|
225
|
+
lockBackend?: "auto" | "in-process" | "postgres" | "redis" | "advisory";
|
|
213
226
|
}
|
|
214
227
|
|
|
215
228
|
export interface DistributedLockMetrics {
|