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.
@@ -1,98 +1,159 @@
1
- /**
2
- * withLock — run a function while holding a PostgreSQL advisory 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
6
- * `fn` throws. Only one holder of a given `key` runs `fn` at a time, across
7
- * every process pointed at the same database. When the lock is unavailable the
8
- * call returns `{ acquired: false }` without running `fn` (unless `wait` is
9
- * set, in which case it polls until the lock frees or the deadline passes).
10
- *
11
- * Two layers of exclusion:
12
- * - Across processes: PostgreSQL `pg_advisory_lock`, owned by the singleton's
13
- * pinned connection (one PG session per instance).
14
- * - Within a process: an in-memory `Set`. PostgreSQL advisory locks are
15
- * *reentrant per session*, so two concurrent callers sharing this instance's
16
- * session would both win the pg lock the Set makes same-process contention
17
- * exclusive too.
18
- *
19
- * Notes:
20
- * - Not reentrant. Calling `withLock(key, …)` for a key already held by this
21
- * process returns `{ acquired: false }` (or waits, then gives up).
22
- * - Shares the scheduler's singleton + PG session. Keys live under the same
23
- * namespace prefix as scheduler task ids pick keys unlikely to collide.
24
- * - Honors the singleton's `enabled` config: if distributed locking was
25
- * disabled (`getDistributedLock({ enabled: false })`), `tryAcquire` always
26
- * reports success and no real lock is taken.
27
- *
28
- * @example
29
- * const res = await withLock("rebuild-search-index", async () => {
30
- * await rebuildIndex();
31
- * return "done";
32
- * });
33
- * if (!res.acquired) {
34
- * // another instance is already rebuilding — skip
35
- * } else {
36
- * console.log(res.result); // "done"
37
- * }
38
- */
39
- import { getDistributedLock } from "./DistributedLock";
40
-
41
- export interface WithLockOptions {
42
- /** Max ms to wait for the lock before giving up. 0 (default) = try once. */
43
- wait?: number;
44
- /** Poll interval while waiting, in ms. Default 100. */
45
- retryInterval?: number;
46
- }
47
-
48
- export type LockOutcome<T> =
49
- | { acquired: false; result?: undefined }
50
- | { acquired: true; result: T };
51
-
52
- /** In-process holders, keyed by lock key (see "Within a process" above). */
53
- const localHeld = new Set<string>();
54
-
55
- const sleep = (ms: number): Promise<void> =>
56
- new Promise((resolve) => setTimeout(resolve, ms));
57
-
58
- export async function withLock<T>(
59
- key: string,
60
- fn: () => Promise<T> | T,
61
- options: WithLockOptions = {}
62
- ): Promise<LockOutcome<T>> {
63
- const { wait = 0, retryInterval = 100 } = options;
64
- const deadline = wait > 0 ? Date.now() + wait : 0;
65
-
66
- // In-process gate. The has-check that exits the loop and the subsequent
67
- // add() run without an await between them, so this is atomic on JS's single
68
- // thread concurrent same-key callers cannot both pass.
69
- while (localHeld.has(key)) {
70
- if (!deadline || Date.now() >= deadline) {
71
- return { acquired: false };
72
- }
73
- await sleep(retryInterval);
74
- }
75
- localHeld.add(key);
76
-
77
- try {
78
- const lock = getDistributedLock();
79
-
80
- let acquired = (await lock.tryAcquire(key)).acquired;
81
- while (!acquired && deadline && Date.now() < deadline) {
82
- await sleep(retryInterval);
83
- acquired = (await lock.tryAcquire(key)).acquired;
84
- }
85
-
86
- if (!acquired) {
87
- return { acquired: false };
88
- }
89
-
90
- try {
91
- return { acquired: true, result: await fn() };
92
- } finally {
93
- await lock.release(key);
94
- }
95
- } finally {
96
- localHeld.delete(key);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bunsane",
3
- "version": "0.5.6",
3
+ "version": "0.5.7",
4
4
  "author": {
5
5
  "name": "yaaruu"
6
6
  },
@@ -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 {