@fedify/sqlite 2.0.0-dev.215 → 2.0.0-dev.226
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/deno.json +4 -3
- package/dist/dist/sqlite.node.d.cts +2 -0
- package/dist/kv.d.cts +3 -3
- package/dist/mod.cjs +3 -1
- package/dist/mod.d.cts +2 -1
- package/dist/mod.d.ts +2 -1
- package/dist/mod.js +2 -1
- package/dist/mq.cjs +369 -0
- package/dist/mq.d.cts +112 -0
- package/dist/mq.d.ts +113 -0
- package/dist/mq.js +368 -0
- package/package.json +14 -3
- package/src/mod.ts +1 -0
- package/src/mq.test.ts +23 -0
- package/src/mq.ts +579 -0
- package/tsdown.config.ts +7 -1
package/src/mq.ts
ADDED
|
@@ -0,0 +1,579 @@
|
|
|
1
|
+
import { type PlatformDatabase, SqliteDatabase } from "#sqlite";
|
|
2
|
+
import type {
|
|
3
|
+
MessageQueue,
|
|
4
|
+
MessageQueueEnqueueOptions,
|
|
5
|
+
MessageQueueListenOptions,
|
|
6
|
+
} from "@fedify/fedify";
|
|
7
|
+
import { getLogger } from "@logtape/logtape";
|
|
8
|
+
import type { SqliteDatabaseAdapter } from "./adapter.ts";
|
|
9
|
+
|
|
10
|
+
const logger = getLogger(["fedify", "sqlite", "mq"]);
|
|
11
|
+
|
|
12
|
+
class EnqueueEvent extends Event {
|
|
13
|
+
readonly delayMs: number;
|
|
14
|
+
constructor(delayMs: number) {
|
|
15
|
+
super("enqueue");
|
|
16
|
+
this.delayMs = delayMs;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Options for the SQLite message queue.
|
|
22
|
+
*/
|
|
23
|
+
export interface SqliteMessageQueueOptions {
|
|
24
|
+
/**
|
|
25
|
+
* The table name to use for the message queue.
|
|
26
|
+
* Only letters, digits, and underscores are allowed.
|
|
27
|
+
* `"fedify_message"` by default.
|
|
28
|
+
* @default `"fedify_message"`
|
|
29
|
+
*/
|
|
30
|
+
tableName?: string;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Whether the table has been initialized. `false` by default.
|
|
34
|
+
* @default `false`
|
|
35
|
+
*/
|
|
36
|
+
initialized?: boolean;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The poll interval for the message queue.
|
|
40
|
+
* @default `{ seconds: 5 }`
|
|
41
|
+
*/
|
|
42
|
+
pollInterval?: Temporal.Duration | Temporal.DurationLike;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Maximum number of retries for SQLITE_BUSY errors.
|
|
46
|
+
* @default `5`
|
|
47
|
+
*/
|
|
48
|
+
maxRetries?: number;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Initial retry delay in milliseconds for SQLITE_BUSY errors.
|
|
52
|
+
* Uses exponential backoff.
|
|
53
|
+
* @default `100`
|
|
54
|
+
*/
|
|
55
|
+
retryDelayMs?: number;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* SQLite journal mode to use.
|
|
59
|
+
* WAL (Write-Ahead Logging) mode is recommended for better concurrency
|
|
60
|
+
* in multi-process environments.
|
|
61
|
+
* Note: WAL mode is persistent per database file, not per connection.
|
|
62
|
+
* @default `"WAL"`
|
|
63
|
+
*/
|
|
64
|
+
journalMode?: "WAL" | "DELETE" | "TRUNCATE" | "PERSIST" | "MEMORY";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* A message queue that uses SQLite as the underlying storage.
|
|
69
|
+
*
|
|
70
|
+
* This implementation is designed for single-node deployments and uses
|
|
71
|
+
* polling to check for new messages. It is not suitable for high-throughput
|
|
72
|
+
* scenarios or distributed environments.
|
|
73
|
+
*
|
|
74
|
+
* @example
|
|
75
|
+
* ```ts ignore
|
|
76
|
+
* import { createFederation } from "@fedify/fedify";
|
|
77
|
+
* import { SqliteMessageQueue } from "@fedify/sqlite";
|
|
78
|
+
* import { DatabaseSync } from "node:sqlite";
|
|
79
|
+
*
|
|
80
|
+
* const db = new DatabaseSync(":memory:");
|
|
81
|
+
* const federation = createFederation({
|
|
82
|
+
* // ...
|
|
83
|
+
* queue: new SqliteMessageQueue(db),
|
|
84
|
+
* });
|
|
85
|
+
* ```
|
|
86
|
+
*/
|
|
87
|
+
export class SqliteMessageQueue implements MessageQueue, Disposable {
|
|
88
|
+
static readonly #defaultTableName = "fedify_message";
|
|
89
|
+
static readonly #tableNameRegex = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;
|
|
90
|
+
// In-memory event emitter for notifying listeners when messages are enqueued.
|
|
91
|
+
// Scoped per table name to allow multiple queues to coexist.
|
|
92
|
+
static readonly #notifyChannels = new Map<string, EventTarget>();
|
|
93
|
+
// Track active instance IDs per table name for accurate cleanup
|
|
94
|
+
static readonly #activeInstances = new Map<string, Set<string>>();
|
|
95
|
+
|
|
96
|
+
static #getNotifyChannel(tableName: string): EventTarget {
|
|
97
|
+
let channel = SqliteMessageQueue.#notifyChannels.get(tableName);
|
|
98
|
+
if (channel == null) {
|
|
99
|
+
channel = new EventTarget();
|
|
100
|
+
SqliteMessageQueue.#notifyChannels.set(tableName, channel);
|
|
101
|
+
}
|
|
102
|
+
return channel;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
readonly #db: SqliteDatabaseAdapter;
|
|
106
|
+
readonly #tableName: string;
|
|
107
|
+
readonly #pollIntervalMs: number;
|
|
108
|
+
readonly #instanceId: string;
|
|
109
|
+
readonly #maxRetries: number;
|
|
110
|
+
readonly #retryDelayMs: number;
|
|
111
|
+
readonly #journalMode: string;
|
|
112
|
+
#initialized: boolean;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* SQLite message queue does not provide native retry mechanisms.
|
|
116
|
+
*/
|
|
117
|
+
readonly nativeRetrial = false;
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Creates a new SQLite message queue.
|
|
121
|
+
* @param db The SQLite database to use. Supports `node:sqlite`, `bun:sqlite`.
|
|
122
|
+
* @param options The options for the message queue.
|
|
123
|
+
*/
|
|
124
|
+
constructor(
|
|
125
|
+
readonly db: PlatformDatabase,
|
|
126
|
+
readonly options: SqliteMessageQueueOptions = {},
|
|
127
|
+
) {
|
|
128
|
+
this.#db = new SqliteDatabase(db);
|
|
129
|
+
this.#initialized = options.initialized ?? false;
|
|
130
|
+
this.#tableName = options.tableName ?? SqliteMessageQueue.#defaultTableName;
|
|
131
|
+
this.#instanceId = crypto.randomUUID();
|
|
132
|
+
this.#pollIntervalMs = Temporal.Duration.from(
|
|
133
|
+
options.pollInterval ?? { seconds: 5 },
|
|
134
|
+
).total("millisecond");
|
|
135
|
+
this.#maxRetries = options.maxRetries ?? 5;
|
|
136
|
+
this.#retryDelayMs = options.retryDelayMs ?? 100;
|
|
137
|
+
this.#journalMode = options.journalMode ?? "WAL";
|
|
138
|
+
|
|
139
|
+
if (!SqliteMessageQueue.#tableNameRegex.test(this.#tableName)) {
|
|
140
|
+
throw new Error(
|
|
141
|
+
`Invalid table name for the message queue: ${this.#tableName}.\
|
|
142
|
+
Only letters, digits, and underscores are allowed.`,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Register this instance ID for this table
|
|
147
|
+
this.#registerInstance();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
#registerInstance(): void {
|
|
151
|
+
let instances = SqliteMessageQueue.#activeInstances.get(this.#tableName);
|
|
152
|
+
if (instances == null) {
|
|
153
|
+
instances = new Set();
|
|
154
|
+
SqliteMessageQueue.#activeInstances.set(this.#tableName, instances);
|
|
155
|
+
}
|
|
156
|
+
instances.add(this.#instanceId);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* {@inheritDoc MessageQueue.enqueue}
|
|
161
|
+
*/
|
|
162
|
+
enqueue(
|
|
163
|
+
// deno-lint-ignore no-explicit-any
|
|
164
|
+
message: any,
|
|
165
|
+
options?: MessageQueueEnqueueOptions,
|
|
166
|
+
): Promise<void> {
|
|
167
|
+
this.initialize();
|
|
168
|
+
|
|
169
|
+
const id = crypto.randomUUID();
|
|
170
|
+
const encodedMessage = this.#encodeMessage(message);
|
|
171
|
+
const now = Temporal.Now.instant().epochMilliseconds;
|
|
172
|
+
const delay = options?.delay ?? Temporal.Duration.from({ seconds: 0 });
|
|
173
|
+
const scheduled = now + delay.total({ unit: "milliseconds" });
|
|
174
|
+
|
|
175
|
+
if (options?.delay) {
|
|
176
|
+
logger.debug("Enqueuing a message with a delay of {delay}...", {
|
|
177
|
+
delay,
|
|
178
|
+
message,
|
|
179
|
+
});
|
|
180
|
+
} else {
|
|
181
|
+
logger.debug("Enqueuing a message...", { message });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return this.#retryOnBusy(() => {
|
|
185
|
+
this.#db
|
|
186
|
+
.prepare(
|
|
187
|
+
`INSERT INTO "${this.#tableName}" (id, message, created, scheduled)
|
|
188
|
+
VALUES (?, ?, ?, ?)`,
|
|
189
|
+
)
|
|
190
|
+
.run(id, encodedMessage, now, scheduled);
|
|
191
|
+
|
|
192
|
+
logger.debug("Enqueued a message.", { message });
|
|
193
|
+
|
|
194
|
+
// Notify listeners that a message has been enqueued
|
|
195
|
+
const delayMs = delay.total("millisecond");
|
|
196
|
+
SqliteMessageQueue.#getNotifyChannel(this.#tableName).dispatchEvent(
|
|
197
|
+
new EnqueueEvent(delayMs),
|
|
198
|
+
);
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* {@inheritDoc MessageQueue.enqueueMany}
|
|
204
|
+
*/
|
|
205
|
+
enqueueMany(
|
|
206
|
+
// deno-lint-ignore no-explicit-any
|
|
207
|
+
messages: readonly any[],
|
|
208
|
+
options?: MessageQueueEnqueueOptions,
|
|
209
|
+
): Promise<void> {
|
|
210
|
+
if (messages.length === 0) return Promise.resolve();
|
|
211
|
+
|
|
212
|
+
this.initialize();
|
|
213
|
+
|
|
214
|
+
const now = Temporal.Now.instant().epochMilliseconds;
|
|
215
|
+
const delay = options?.delay ?? Temporal.Duration.from({ seconds: 0 });
|
|
216
|
+
const scheduled = now + delay.total({ unit: "milliseconds" });
|
|
217
|
+
|
|
218
|
+
if (options?.delay) {
|
|
219
|
+
logger.debug("Enqueuing messages with a delay of {delay}...", {
|
|
220
|
+
delay,
|
|
221
|
+
messages,
|
|
222
|
+
});
|
|
223
|
+
} else {
|
|
224
|
+
logger.debug("Enqueuing messages...", { messages });
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return this.#withTransactionRetries(() => {
|
|
228
|
+
const stmt = this.#db.prepare(
|
|
229
|
+
`INSERT INTO "${this.#tableName}" (id, message, created, scheduled)
|
|
230
|
+
VALUES (?, ?, ?, ?)`,
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
for (const message of messages) {
|
|
234
|
+
const id = crypto.randomUUID();
|
|
235
|
+
const encodedMessage = this.#encodeMessage(message);
|
|
236
|
+
stmt.run(id, encodedMessage, now, scheduled);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
logger.debug("Enqueued messages.", { messages });
|
|
240
|
+
|
|
241
|
+
// Notify listeners that messages have been enqueued
|
|
242
|
+
const delayMs = delay.total("millisecond");
|
|
243
|
+
SqliteMessageQueue.#getNotifyChannel(this.#tableName).dispatchEvent(
|
|
244
|
+
new EnqueueEvent(delayMs),
|
|
245
|
+
);
|
|
246
|
+
}).catch((error) => {
|
|
247
|
+
logger.error(
|
|
248
|
+
"Failed to enqueue messages to table {tableName}: {error}",
|
|
249
|
+
{
|
|
250
|
+
tableName: this.#tableName,
|
|
251
|
+
messageCount: messages.length,
|
|
252
|
+
error,
|
|
253
|
+
},
|
|
254
|
+
);
|
|
255
|
+
throw error;
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* {@inheritDoc MessageQueue.listen}
|
|
261
|
+
*/
|
|
262
|
+
async listen(
|
|
263
|
+
// deno-lint-ignore no-explicit-any
|
|
264
|
+
handler: (message: any) => Promise<void> | void,
|
|
265
|
+
options?: MessageQueueListenOptions,
|
|
266
|
+
): Promise<void> {
|
|
267
|
+
this.initialize();
|
|
268
|
+
|
|
269
|
+
const { signal } = options ?? {};
|
|
270
|
+
logger.debug(
|
|
271
|
+
"Starting to listen for messages on table {tableName}...",
|
|
272
|
+
{ tableName: this.#tableName },
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
const channel = SqliteMessageQueue.#getNotifyChannel(this.#tableName);
|
|
276
|
+
const timeouts = new Set<ReturnType<typeof setTimeout>>();
|
|
277
|
+
|
|
278
|
+
const poll = async () => {
|
|
279
|
+
while (signal == null || !signal.aborted) {
|
|
280
|
+
const now = Temporal.Now.instant().epochMilliseconds;
|
|
281
|
+
|
|
282
|
+
// Atomically fetch and delete the oldest message that is ready to be
|
|
283
|
+
// processed using DELETE ... RETURNING (SQLite >= 3.35.0)
|
|
284
|
+
// Wrapped in BEGIN IMMEDIATE transaction to ensure proper locking
|
|
285
|
+
// and prevent race conditions in multi-process scenarios
|
|
286
|
+
const result = await this.#withTransactionRetries(() => {
|
|
287
|
+
return this.#db
|
|
288
|
+
.prepare(
|
|
289
|
+
`DELETE FROM "${this.#tableName}"
|
|
290
|
+
WHERE id = (
|
|
291
|
+
SELECT id FROM "${this.#tableName}"
|
|
292
|
+
WHERE scheduled <= ?
|
|
293
|
+
ORDER BY scheduled
|
|
294
|
+
LIMIT 1
|
|
295
|
+
)
|
|
296
|
+
RETURNING id, message`,
|
|
297
|
+
)
|
|
298
|
+
.get(now) as { id: string; message: string } | undefined;
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
if (result) {
|
|
302
|
+
const message = this.#decodeMessage(result.message);
|
|
303
|
+
logger.debug("Processing message {id}...", {
|
|
304
|
+
id: result.id,
|
|
305
|
+
message,
|
|
306
|
+
});
|
|
307
|
+
try {
|
|
308
|
+
await handler(message);
|
|
309
|
+
logger.debug("Processed message {id}.", { id: result.id });
|
|
310
|
+
} catch (error) {
|
|
311
|
+
logger.error(
|
|
312
|
+
"Failed to process message {id} from table {tableName}: {error}",
|
|
313
|
+
{
|
|
314
|
+
id: result.id,
|
|
315
|
+
tableName: this.#tableName,
|
|
316
|
+
message,
|
|
317
|
+
error,
|
|
318
|
+
},
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Check for next message immediately
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// No more messages ready to process
|
|
327
|
+
break;
|
|
328
|
+
}
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
const onEnqueue = (event: Event) => {
|
|
332
|
+
const delayMs = (event as EnqueueEvent).delayMs;
|
|
333
|
+
if (delayMs < 1) {
|
|
334
|
+
poll();
|
|
335
|
+
} else {
|
|
336
|
+
timeouts.add(setTimeout(poll, delayMs));
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
channel.addEventListener("enqueue", onEnqueue);
|
|
341
|
+
signal?.addEventListener("abort", () => {
|
|
342
|
+
channel.removeEventListener("enqueue", onEnqueue);
|
|
343
|
+
for (const timeout of timeouts) clearTimeout(timeout);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
// Initial poll
|
|
347
|
+
await poll();
|
|
348
|
+
|
|
349
|
+
// Periodic polling as fallback
|
|
350
|
+
while (signal == null || !signal.aborted) {
|
|
351
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
352
|
+
await new Promise<unknown>((resolve) => {
|
|
353
|
+
const onAbort = () => {
|
|
354
|
+
signal?.removeEventListener("abort", onAbort);
|
|
355
|
+
resolve(undefined);
|
|
356
|
+
};
|
|
357
|
+
signal?.addEventListener("abort", onAbort);
|
|
358
|
+
timeout = setTimeout(() => {
|
|
359
|
+
signal?.removeEventListener("abort", onAbort);
|
|
360
|
+
resolve(0);
|
|
361
|
+
}, this.#pollIntervalMs);
|
|
362
|
+
timeouts.add(timeout);
|
|
363
|
+
});
|
|
364
|
+
if (timeout != null) timeouts.delete(timeout);
|
|
365
|
+
await poll();
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
logger.debug("Stopped listening for messages on table {tableName}.", {
|
|
369
|
+
tableName: this.#tableName,
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Creates the message queue table if it does not already exist.
|
|
375
|
+
* Does nothing if the table already exists.
|
|
376
|
+
*
|
|
377
|
+
* This method also configures the SQLite journal mode for better concurrency.
|
|
378
|
+
* WAL (Write-Ahead Logging) mode is enabled by default to improve
|
|
379
|
+
* concurrent access in multi-process environments.
|
|
380
|
+
*/
|
|
381
|
+
initialize(): void {
|
|
382
|
+
if (this.#initialized) {
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
logger.debug("Initializing the message queue table {tableName}...", {
|
|
387
|
+
tableName: this.#tableName,
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
// Set journal mode for better concurrency
|
|
391
|
+
// Note: This is persistent per database file and must be set outside a transaction
|
|
392
|
+
this.#db.exec(`PRAGMA journal_mode=${this.#journalMode}`);
|
|
393
|
+
|
|
394
|
+
this.#withTransaction(() => {
|
|
395
|
+
this.#db.exec(`
|
|
396
|
+
CREATE TABLE IF NOT EXISTS "${this.#tableName}" (
|
|
397
|
+
id TEXT PRIMARY KEY,
|
|
398
|
+
message TEXT NOT NULL,
|
|
399
|
+
created INTEGER NOT NULL,
|
|
400
|
+
scheduled INTEGER NOT NULL
|
|
401
|
+
)
|
|
402
|
+
`);
|
|
403
|
+
|
|
404
|
+
this.#db.exec(`
|
|
405
|
+
CREATE INDEX IF NOT EXISTS "idx_${this.#tableName}_scheduled"
|
|
406
|
+
ON "${this.#tableName}" (scheduled)
|
|
407
|
+
`);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
this.#initialized = true;
|
|
411
|
+
logger.debug("Initialized the message queue table {tableName}.", {
|
|
412
|
+
tableName: this.#tableName,
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Drops the table used by the message queue. Does nothing if the table
|
|
418
|
+
* does not exist.
|
|
419
|
+
*/
|
|
420
|
+
drop(): void {
|
|
421
|
+
this.#db.exec(`DROP TABLE IF EXISTS "${this.#tableName}"`);
|
|
422
|
+
this.#initialized = false;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Closes the database connection.
|
|
427
|
+
*/
|
|
428
|
+
[Symbol.dispose](): void {
|
|
429
|
+
try {
|
|
430
|
+
this.#db.close();
|
|
431
|
+
this.#unregisterInstance();
|
|
432
|
+
} catch (error) {
|
|
433
|
+
logger.error(
|
|
434
|
+
"Failed to close the database connection for table {tableName}: {error}",
|
|
435
|
+
{ tableName: this.#tableName, error },
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
#unregisterInstance(): void {
|
|
441
|
+
const instances = SqliteMessageQueue.#activeInstances.get(this.#tableName);
|
|
442
|
+
if (instances == null) return;
|
|
443
|
+
|
|
444
|
+
instances.delete(this.#instanceId);
|
|
445
|
+
|
|
446
|
+
// If no more instances exist for this table, cleanup EventTarget to prevent memory leak
|
|
447
|
+
if (instances.size === 0) {
|
|
448
|
+
SqliteMessageQueue.#activeInstances.delete(this.#tableName);
|
|
449
|
+
SqliteMessageQueue.#notifyChannels.delete(this.#tableName);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Checks if an error is a SQLITE_BUSY error or transaction conflict.
|
|
455
|
+
* Handles different error formats from node:sqlite and bun:sqlite.
|
|
456
|
+
*/
|
|
457
|
+
#isBusyError(error: unknown): boolean {
|
|
458
|
+
if (!(error instanceof Error)) return false;
|
|
459
|
+
|
|
460
|
+
// Check error message for SQLITE_BUSY
|
|
461
|
+
if (
|
|
462
|
+
error.message.includes("SQLITE_BUSY") ||
|
|
463
|
+
error.message.includes("database is locked") ||
|
|
464
|
+
error.message.includes("transaction within a transaction")
|
|
465
|
+
) {
|
|
466
|
+
return true;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// Check error code property (node:sqlite)
|
|
470
|
+
const errorWithCode = error as Error & { code?: string };
|
|
471
|
+
if (errorWithCode.code === "SQLITE_BUSY") {
|
|
472
|
+
return true;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// Check errno property (bun:sqlite)
|
|
476
|
+
const errorWithErrno = error as Error & { errno?: number };
|
|
477
|
+
if (errorWithErrno.errno === 5) { // SQLITE_BUSY = 5
|
|
478
|
+
return true;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
return false;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Retries a database operation with exponential backoff on SQLITE_BUSY errors.
|
|
486
|
+
*/
|
|
487
|
+
async #retryOnBusy<T>(operation: () => T): Promise<T> {
|
|
488
|
+
let lastError: unknown;
|
|
489
|
+
|
|
490
|
+
for (let attempt = 0; attempt <= this.#maxRetries; attempt++) {
|
|
491
|
+
try {
|
|
492
|
+
return operation();
|
|
493
|
+
} catch (error) {
|
|
494
|
+
lastError = error;
|
|
495
|
+
|
|
496
|
+
if (!this.#isBusyError(error)) {
|
|
497
|
+
logger.error(
|
|
498
|
+
"Database operation failed on table {tableName}: {error}",
|
|
499
|
+
{
|
|
500
|
+
tableName: this.#tableName,
|
|
501
|
+
error,
|
|
502
|
+
},
|
|
503
|
+
);
|
|
504
|
+
throw error;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
if (attempt === this.#maxRetries) {
|
|
508
|
+
logger.error(
|
|
509
|
+
"Max retries ({maxRetries}) reached for SQLITE_BUSY error on table {tableName}.",
|
|
510
|
+
{
|
|
511
|
+
maxRetries: this.#maxRetries,
|
|
512
|
+
tableName: this.#tableName,
|
|
513
|
+
error,
|
|
514
|
+
},
|
|
515
|
+
);
|
|
516
|
+
throw error;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// Exponential backoff: retryDelayMs * 2^attempt
|
|
520
|
+
const delayMs = this.#retryDelayMs * Math.pow(2, attempt);
|
|
521
|
+
logger.debug(
|
|
522
|
+
"SQLITE_BUSY error on table {tableName}, retrying in {delayMs}ms (attempt {attempt}/{maxRetries})...",
|
|
523
|
+
{
|
|
524
|
+
tableName: this.#tableName,
|
|
525
|
+
delayMs,
|
|
526
|
+
attempt: attempt + 1,
|
|
527
|
+
maxRetries: this.#maxRetries,
|
|
528
|
+
},
|
|
529
|
+
);
|
|
530
|
+
|
|
531
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
throw lastError;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Executes a database operation within a transaction.
|
|
540
|
+
* Automatically handles BEGIN IMMEDIATE, COMMIT, and ROLLBACK.
|
|
541
|
+
*/
|
|
542
|
+
#withTransaction<T>(operation: () => T): T {
|
|
543
|
+
let transactionStarted = false;
|
|
544
|
+
try {
|
|
545
|
+
this.#db.exec("BEGIN IMMEDIATE");
|
|
546
|
+
transactionStarted = true;
|
|
547
|
+
const result = operation();
|
|
548
|
+
this.#db.exec("COMMIT");
|
|
549
|
+
return result;
|
|
550
|
+
} catch (error) {
|
|
551
|
+
// Only rollback if transaction was successfully started
|
|
552
|
+
if (transactionStarted) {
|
|
553
|
+
try {
|
|
554
|
+
this.#db.exec("ROLLBACK");
|
|
555
|
+
} catch {
|
|
556
|
+
// Ignore rollback errors - transaction might have been rolled back already
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
throw error;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Executes a database operation within a transaction with retry logic.
|
|
565
|
+
* Automatically handles BEGIN IMMEDIATE, COMMIT, and ROLLBACK.
|
|
566
|
+
* Retries on SQLITE_BUSY errors with exponential backoff.
|
|
567
|
+
*/
|
|
568
|
+
async #withTransactionRetries<T>(operation: () => T): Promise<T> {
|
|
569
|
+
return await this.#retryOnBusy(() => this.#withTransaction(operation));
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
#encodeMessage(message: unknown): string {
|
|
573
|
+
return JSON.stringify(message);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
#decodeMessage(message: string): unknown {
|
|
577
|
+
return JSON.parse(message);
|
|
578
|
+
}
|
|
579
|
+
}
|
package/tsdown.config.ts
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import { defineConfig } from "tsdown";
|
|
2
2
|
|
|
3
3
|
export default defineConfig({
|
|
4
|
-
entry: [
|
|
4
|
+
entry: [
|
|
5
|
+
"src/mod.ts",
|
|
6
|
+
"src/kv.ts",
|
|
7
|
+
"src/mq.ts",
|
|
8
|
+
"src/sqlite.node.ts",
|
|
9
|
+
"src/sqlite.bun.ts",
|
|
10
|
+
],
|
|
5
11
|
dts: true,
|
|
6
12
|
unbundle: true,
|
|
7
13
|
format: ["esm", "cjs"],
|