bunsane 0.2.9 → 0.2.10
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/App.ts +97 -0
- package/core/remote/CircuitBreaker.ts +115 -0
- package/core/remote/OutboxWorker.ts +176 -0
- package/core/remote/RemoteManager.ts +400 -0
- package/core/remote/RpcCaller.ts +310 -0
- package/core/remote/StreamConsumer.ts +535 -0
- package/core/remote/decorators.ts +121 -0
- package/core/remote/health.ts +139 -0
- package/core/remote/index.ts +37 -0
- package/core/remote/metrics.ts +99 -0
- package/core/remote/outboxSchema.ts +41 -0
- package/core/remote/types.ts +151 -0
- package/core/scheduler/DistributedLock.ts +309 -266
- package/package.json +1 -1
- package/tests/helpers/MockRedisClient.ts +113 -0
- package/tests/helpers/MockRedisStreamServer.ts +448 -0
- package/tests/integration/remote/dlq.test.ts +175 -0
- package/tests/integration/remote/event-dispatch.test.ts +114 -0
- package/tests/integration/remote/outbox.test.ts +130 -0
- package/tests/integration/remote/rpc.test.ts +177 -0
- package/tests/unit/remote/CircuitBreaker.test.ts +159 -0
- package/tests/unit/remote/RemoteError.test.ts +55 -0
- package/tests/unit/remote/decorators.test.ts +195 -0
- package/tests/unit/remote/metrics.test.ts +115 -0
- package/tests/unit/remote/mockRedisStreamServer.test.ts +104 -0
|
@@ -1,266 +1,309 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Distributed Lock using PostgreSQL Advisory Locks
|
|
3
|
-
*
|
|
4
|
-
* PostgreSQL advisory locks are
|
|
5
|
-
*
|
|
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
|
-
export interface
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
export const DEFAULT_LOCK_CONFIG: DistributedLockConfig = {
|
|
47
|
-
enabled: true,
|
|
48
|
-
lockKeyPrefix: 0x42554E53, // "BUNS" in hex as a namespace prefix
|
|
49
|
-
enableLogging: false,
|
|
50
|
-
lockTimeout: 0,
|
|
51
|
-
retryInterval: 100,
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Distributed Lock using PostgreSQL Advisory Locks
|
|
3
|
+
*
|
|
4
|
+
* PostgreSQL advisory locks are session-level (bound to the connection that
|
|
5
|
+
* acquired them). Bun's SQL pool hands out a different connection per query,
|
|
6
|
+
* so naively calling `pg_try_advisory_lock` on the pooled client leaves the
|
|
7
|
+
* lock stranded on whichever connection was used — `pg_advisory_unlock` on a
|
|
8
|
+
* different connection silently returns `false` and the lock is held until
|
|
9
|
+
* that connection eventually closes.
|
|
10
|
+
*
|
|
11
|
+
* Fix: reserve a dedicated connection via `sql.reserve()` once per instance
|
|
12
|
+
* and route every lock/unlock query through it. All locks owned by this
|
|
13
|
+
* instance live in one PostgreSQL session, so unlock always hits the session
|
|
14
|
+
* that acquired the lock. If the process crashes, PostgreSQL terminates the
|
|
15
|
+
* session and every held lock is released automatically — no cleanup needed.
|
|
16
|
+
*
|
|
17
|
+
* The reservation is lazy (acquired on first use) and released when either
|
|
18
|
+
* `releaseAll()` is called or no locks remain outstanding, so idle instances
|
|
19
|
+
* do not permanently consume a pool slot.
|
|
20
|
+
*
|
|
21
|
+
* @see https://www.postgresql.org/docs/current/explicit-locking.html#ADVISORY-LOCKS
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { ReservedSQL } from "bun";
|
|
25
|
+
import db from "../../database";
|
|
26
|
+
import { logger } from "../Logger";
|
|
27
|
+
|
|
28
|
+
const loggerInstance = logger.child({ scope: "DistributedLock" });
|
|
29
|
+
|
|
30
|
+
export interface LockResult {
|
|
31
|
+
acquired: boolean;
|
|
32
|
+
lockKey: bigint;
|
|
33
|
+
taskId: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface DistributedLockConfig {
|
|
37
|
+
enabled: boolean;
|
|
38
|
+
lockKeyPrefix: number;
|
|
39
|
+
enableLogging: boolean;
|
|
40
|
+
/** Timeout for lock acquisition attempts in ms (0 = no retry) */
|
|
41
|
+
lockTimeout: number;
|
|
42
|
+
/** Retry interval when lockTimeout > 0 */
|
|
43
|
+
retryInterval: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const DEFAULT_LOCK_CONFIG: DistributedLockConfig = {
|
|
47
|
+
enabled: true,
|
|
48
|
+
lockKeyPrefix: 0x42554E53, // "BUNS" in hex as a namespace prefix
|
|
49
|
+
enableLogging: false,
|
|
50
|
+
lockTimeout: 0,
|
|
51
|
+
retryInterval: 100,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export class DistributedLock {
|
|
55
|
+
private config: DistributedLockConfig;
|
|
56
|
+
private heldLocks: Set<string> = new Set();
|
|
57
|
+
private reservedConn: ReservedSQL | null = null;
|
|
58
|
+
private reservePromise: Promise<ReservedSQL> | null = null;
|
|
59
|
+
|
|
60
|
+
constructor(config: Partial<DistributedLockConfig> = {}) {
|
|
61
|
+
this.config = { ...DEFAULT_LOCK_CONFIG, ...config };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private generateLockKey(taskId: string): bigint {
|
|
65
|
+
let hash = 0;
|
|
66
|
+
for (let i = 0; i < taskId.length; i++) {
|
|
67
|
+
const char = taskId.charCodeAt(i);
|
|
68
|
+
hash = ((hash << 5) - hash) + char;
|
|
69
|
+
hash = hash & hash;
|
|
70
|
+
}
|
|
71
|
+
hash = Math.abs(hash);
|
|
72
|
+
|
|
73
|
+
const prefix = BigInt(this.config.lockKeyPrefix);
|
|
74
|
+
const hashBigInt = BigInt(hash >>> 0);
|
|
75
|
+
return (prefix << 32n) | hashBigInt;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Lazily reserve one dedicated connection that owns every advisory lock
|
|
80
|
+
* this instance takes. Concurrent callers share the same reservation via
|
|
81
|
+
* `reservePromise`.
|
|
82
|
+
*/
|
|
83
|
+
private async ensureReserved(): Promise<ReservedSQL> {
|
|
84
|
+
if (this.reservedConn) return this.reservedConn;
|
|
85
|
+
if (!this.reservePromise) {
|
|
86
|
+
this.reservePromise = db.reserve().then((conn) => {
|
|
87
|
+
this.reservedConn = conn;
|
|
88
|
+
this.reservePromise = null;
|
|
89
|
+
return conn;
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
return this.reservePromise;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Release the pinned connection back to the pool. Only safe when no
|
|
97
|
+
* advisory locks are currently held on this instance — otherwise the
|
|
98
|
+
* session would be closed and locks forfeited.
|
|
99
|
+
*/
|
|
100
|
+
private releaseReservation(): void {
|
|
101
|
+
if (!this.reservedConn) return;
|
|
102
|
+
try {
|
|
103
|
+
this.reservedConn.release();
|
|
104
|
+
} catch (error) {
|
|
105
|
+
loggerInstance.warn(
|
|
106
|
+
`Failed to release reserved connection: ${error instanceof Error ? error.message : String(error)}`
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
this.reservedConn = null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Try to acquire a distributed lock for a task. Non-blocking when
|
|
114
|
+
* `lockTimeout` is 0 (default); retries every `retryInterval` ms up to
|
|
115
|
+
* `lockTimeout` otherwise.
|
|
116
|
+
*/
|
|
117
|
+
async tryAcquire(taskId: string): Promise<LockResult> {
|
|
118
|
+
if (!this.config.enabled) {
|
|
119
|
+
return { acquired: true, lockKey: 0n, taskId };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const lockKey = this.generateLockKey(taskId);
|
|
123
|
+
|
|
124
|
+
if (this.heldLocks.has(taskId)) {
|
|
125
|
+
if (this.config.enableLogging) {
|
|
126
|
+
loggerInstance.debug(
|
|
127
|
+
`Lock for ${taskId} already held locally, returning acquired`
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
return { acquired: true, lockKey, taskId };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const startTime = Date.now();
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
const conn = await this.ensureReserved();
|
|
137
|
+
|
|
138
|
+
let acquired = await this.attemptLock(conn, lockKey);
|
|
139
|
+
|
|
140
|
+
if (!acquired && this.config.lockTimeout > 0) {
|
|
141
|
+
while (
|
|
142
|
+
!acquired &&
|
|
143
|
+
Date.now() - startTime < this.config.lockTimeout
|
|
144
|
+
) {
|
|
145
|
+
await this.sleep(this.config.retryInterval);
|
|
146
|
+
acquired = await this.attemptLock(conn, lockKey);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (acquired) {
|
|
151
|
+
this.heldLocks.add(taskId);
|
|
152
|
+
if (this.config.enableLogging) {
|
|
153
|
+
loggerInstance.debug(
|
|
154
|
+
`Acquired lock for task ${taskId} (key: ${lockKey})`
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
return { acquired: true, lockKey, taskId };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// No locks taken on this attempt — if nothing else is held,
|
|
161
|
+
// return the reserved connection to the pool.
|
|
162
|
+
if (this.heldLocks.size === 0) {
|
|
163
|
+
this.releaseReservation();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (this.config.enableLogging) {
|
|
167
|
+
loggerInstance.debug(
|
|
168
|
+
`Failed to acquire lock for task ${taskId} (key: ${lockKey}) — another instance is executing`
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
return { acquired: false, lockKey, taskId };
|
|
172
|
+
} catch (error) {
|
|
173
|
+
loggerInstance.error(
|
|
174
|
+
`Error acquiring lock for task ${taskId}: ${error instanceof Error ? error.message : String(error)}`
|
|
175
|
+
);
|
|
176
|
+
if (this.heldLocks.size === 0) {
|
|
177
|
+
this.releaseReservation();
|
|
178
|
+
}
|
|
179
|
+
return { acquired: false, lockKey, taskId };
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Release a single distributed lock. When the last lock is released the
|
|
185
|
+
* reserved connection is returned to the pool.
|
|
186
|
+
*/
|
|
187
|
+
async release(taskId: string): Promise<boolean> {
|
|
188
|
+
if (!this.config.enabled) {
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (!this.heldLocks.has(taskId)) {
|
|
193
|
+
if (this.config.enableLogging) {
|
|
194
|
+
loggerInstance.warn(
|
|
195
|
+
`Lock for task ${taskId} was not held or already released`
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const lockKey = this.generateLockKey(taskId);
|
|
202
|
+
|
|
203
|
+
if (!this.reservedConn) {
|
|
204
|
+
loggerInstance.warn(
|
|
205
|
+
`No reserved connection available for ${taskId}; dropping from heldLocks`
|
|
206
|
+
);
|
|
207
|
+
this.heldLocks.delete(taskId);
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
try {
|
|
212
|
+
const result = await this.reservedConn`
|
|
213
|
+
SELECT pg_advisory_unlock(${lockKey}::bigint) as pg_advisory_unlock
|
|
214
|
+
`;
|
|
215
|
+
const released = result[0]?.pg_advisory_unlock ?? false;
|
|
216
|
+
|
|
217
|
+
this.heldLocks.delete(taskId);
|
|
218
|
+
|
|
219
|
+
if (released && this.config.enableLogging) {
|
|
220
|
+
loggerInstance.debug(
|
|
221
|
+
`Released lock for task ${taskId} (key: ${lockKey})`
|
|
222
|
+
);
|
|
223
|
+
} else if (!released) {
|
|
224
|
+
loggerInstance.warn(
|
|
225
|
+
`pg_advisory_unlock returned false for task ${taskId} (key: ${lockKey})`
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (this.heldLocks.size === 0) {
|
|
230
|
+
this.releaseReservation();
|
|
231
|
+
}
|
|
232
|
+
return released;
|
|
233
|
+
} catch (error) {
|
|
234
|
+
loggerInstance.error(
|
|
235
|
+
`Error releasing lock for task ${taskId}: ${error instanceof Error ? error.message : String(error)}`
|
|
236
|
+
);
|
|
237
|
+
this.heldLocks.delete(taskId);
|
|
238
|
+
if (this.heldLocks.size === 0) {
|
|
239
|
+
this.releaseReservation();
|
|
240
|
+
}
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Release all held locks. Safe to call during shutdown.
|
|
247
|
+
*/
|
|
248
|
+
async releaseAll(): Promise<void> {
|
|
249
|
+
const tasks = Array.from(this.heldLocks);
|
|
250
|
+
for (const taskId of tasks) {
|
|
251
|
+
await this.release(taskId);
|
|
252
|
+
}
|
|
253
|
+
// release() returns the reservation once heldLocks empties, but if
|
|
254
|
+
// nothing was held we still need to clean up any pending reservation.
|
|
255
|
+
if (this.heldLocks.size === 0) {
|
|
256
|
+
this.releaseReservation();
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
isHeld(taskId: string): boolean {
|
|
261
|
+
return this.heldLocks.has(taskId);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
getHeldLockCount(): number {
|
|
265
|
+
return this.heldLocks.size;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
updateConfig(config: Partial<DistributedLockConfig>): void {
|
|
269
|
+
this.config = { ...this.config, ...config };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
getConfig(): DistributedLockConfig {
|
|
273
|
+
return { ...this.config };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
private async attemptLock(
|
|
277
|
+
conn: ReservedSQL,
|
|
278
|
+
lockKey: bigint
|
|
279
|
+
): Promise<boolean> {
|
|
280
|
+
const result = await conn`
|
|
281
|
+
SELECT pg_try_advisory_lock(${lockKey}::bigint) as pg_try_advisory_lock
|
|
282
|
+
`;
|
|
283
|
+
return result[0]?.pg_try_advisory_lock ?? false;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
private sleep(ms: number): Promise<void> {
|
|
287
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
let distributedLockInstance: DistributedLock | null = null;
|
|
292
|
+
|
|
293
|
+
export function getDistributedLock(
|
|
294
|
+
config?: Partial<DistributedLockConfig>
|
|
295
|
+
): DistributedLock {
|
|
296
|
+
if (!distributedLockInstance) {
|
|
297
|
+
distributedLockInstance = new DistributedLock(config);
|
|
298
|
+
} else if (config) {
|
|
299
|
+
distributedLockInstance.updateConfig(config);
|
|
300
|
+
}
|
|
301
|
+
return distributedLockInstance;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export function resetDistributedLock(): void {
|
|
305
|
+
if (distributedLockInstance) {
|
|
306
|
+
distributedLockInstance.releaseAll().catch(() => {});
|
|
307
|
+
distributedLockInstance = null;
|
|
308
|
+
}
|
|
309
|
+
}
|