@outbox-event-bus/sqlite-better-sqlite3-outbox 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/README.md +759 -0
- package/dist/index.cjs +259 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +75 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.mts +75 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +228 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +54 -0
- package/src/error-handling.test.ts +145 -0
- package/src/index.ts +2 -0
- package/src/integration.e2e.ts +258 -0
- package/src/schema.sql +30 -0
- package/src/sqlite-better-sqlite3-outbox.ts +291 -0
- package/src/sync-transaction.test.ts +117 -0
- package/src/transaction-storage.ts +42 -0
- package/src/transactions.test.ts +196 -0
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto"
|
|
2
|
+
import { existsSync, unlinkSync } from "node:fs"
|
|
3
|
+
import { join } from "node:path"
|
|
4
|
+
import Database from "better-sqlite3"
|
|
5
|
+
import { afterAll, beforeAll, describe, expect, it } from "vitest"
|
|
6
|
+
import { SqliteBetterSqlite3Outbox } from "./sqlite-better-sqlite3-outbox"
|
|
7
|
+
|
|
8
|
+
const DB_PATH = join(process.cwd(), `test-outbox-${randomUUID()}.db`)
|
|
9
|
+
|
|
10
|
+
describe("SqliteBetterSqlite3Outbox E2E", () => {
|
|
11
|
+
let db: Database.Database
|
|
12
|
+
|
|
13
|
+
beforeAll(() => {
|
|
14
|
+
if (existsSync(DB_PATH)) {
|
|
15
|
+
unlinkSync(DB_PATH)
|
|
16
|
+
}
|
|
17
|
+
db = new Database(DB_PATH)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
afterAll(() => {
|
|
21
|
+
if (db) {
|
|
22
|
+
db.close()
|
|
23
|
+
}
|
|
24
|
+
if (existsSync(DB_PATH)) {
|
|
25
|
+
unlinkSync(DB_PATH)
|
|
26
|
+
}
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it("should process events end-to-end", async () => {
|
|
30
|
+
const outbox = new SqliteBetterSqlite3Outbox({
|
|
31
|
+
dbPath: DB_PATH,
|
|
32
|
+
pollIntervalMs: 100,
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
const eventId = "event-1"
|
|
36
|
+
const event = {
|
|
37
|
+
id: eventId,
|
|
38
|
+
type: "user.created",
|
|
39
|
+
payload: { userId: "123", email: "test@example.com" },
|
|
40
|
+
occurredAt: new Date(),
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
await outbox.publish([event])
|
|
44
|
+
|
|
45
|
+
const result = db.prepare("SELECT * FROM outbox_events WHERE id = ?").get(eventId) as any
|
|
46
|
+
expect(result).toBeDefined()
|
|
47
|
+
expect(result.status).toBe("created")
|
|
48
|
+
|
|
49
|
+
const processedEvents: any[] = []
|
|
50
|
+
const handler = async (event: any) => {
|
|
51
|
+
processedEvents.push(event)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
outbox.start(handler, (err) => console.error("Outbox Error:", err))
|
|
55
|
+
|
|
56
|
+
await new Promise((resolve) => setTimeout(resolve, 1000))
|
|
57
|
+
|
|
58
|
+
expect(processedEvents).toHaveLength(1)
|
|
59
|
+
expect(processedEvents[0].id).toBe(eventId)
|
|
60
|
+
|
|
61
|
+
const eventAfter = db.prepare("SELECT * FROM outbox_events WHERE id = ?").get(eventId)
|
|
62
|
+
expect(eventAfter).toBeUndefined()
|
|
63
|
+
|
|
64
|
+
const archiveResult = db
|
|
65
|
+
.prepare("SELECT * FROM outbox_events_archive WHERE id = ?")
|
|
66
|
+
.get(eventId) as any
|
|
67
|
+
expect(archiveResult).toBeDefined()
|
|
68
|
+
expect(archiveResult.status).toBe("completed")
|
|
69
|
+
|
|
70
|
+
await outbox.stop()
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it("should retry failed events", async () => {
|
|
74
|
+
const outbox = new SqliteBetterSqlite3Outbox({
|
|
75
|
+
dbPath: DB_PATH,
|
|
76
|
+
pollIntervalMs: 100,
|
|
77
|
+
baseBackoffMs: 100,
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
const eventId = "event-2"
|
|
81
|
+
const event = {
|
|
82
|
+
id: eventId,
|
|
83
|
+
type: "order.placed",
|
|
84
|
+
payload: { orderId: "abc" },
|
|
85
|
+
occurredAt: new Date(),
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
await outbox.publish([event])
|
|
89
|
+
|
|
90
|
+
let attempts = 0
|
|
91
|
+
const handler = async (_event: any) => {
|
|
92
|
+
attempts++
|
|
93
|
+
throw new Error("Processing failed")
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
outbox.start(handler, () => {})
|
|
97
|
+
|
|
98
|
+
await new Promise((resolve) => setTimeout(resolve, 1500))
|
|
99
|
+
|
|
100
|
+
await outbox.stop()
|
|
101
|
+
|
|
102
|
+
const result = db.prepare("SELECT * FROM outbox_events WHERE id = ?").get(eventId) as any
|
|
103
|
+
expect(result).toBeDefined()
|
|
104
|
+
expect(result.status).toBe("failed")
|
|
105
|
+
expect(result.retry_count).toBeGreaterThan(1)
|
|
106
|
+
expect(attempts).toBeGreaterThan(1)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it("should support manual management of failed events", async () => {
|
|
110
|
+
const outbox = new SqliteBetterSqlite3Outbox({
|
|
111
|
+
dbPath: DB_PATH,
|
|
112
|
+
pollIntervalMs: 100,
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
const eventId = "event-manual"
|
|
116
|
+
const event = {
|
|
117
|
+
id: eventId,
|
|
118
|
+
type: "manual.retry",
|
|
119
|
+
payload: {},
|
|
120
|
+
occurredAt: new Date(),
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// 1. Insert directly as failed
|
|
124
|
+
db.prepare(`
|
|
125
|
+
INSERT INTO outbox_events (id, type, payload, occurred_at, status, retry_count, last_error)
|
|
126
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
127
|
+
`).run(
|
|
128
|
+
eventId,
|
|
129
|
+
event.type,
|
|
130
|
+
JSON.stringify(event.payload),
|
|
131
|
+
event.occurredAt.toISOString(),
|
|
132
|
+
"failed",
|
|
133
|
+
5,
|
|
134
|
+
"Manual failure"
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
const inserted = db.prepare("SELECT * FROM outbox_events WHERE id = ?").get(eventId)
|
|
138
|
+
|
|
139
|
+
const failed = await outbox.getFailedEvents()
|
|
140
|
+
const targetEvent = failed.find((e) => e.id === eventId)
|
|
141
|
+
|
|
142
|
+
expect(targetEvent).toBeDefined()
|
|
143
|
+
expect(targetEvent!.id).toBe(eventId)
|
|
144
|
+
expect(targetEvent!.error).toBe("Manual failure")
|
|
145
|
+
expect(targetEvent!.retryCount).toBe(5)
|
|
146
|
+
|
|
147
|
+
await outbox.retryEvents([eventId])
|
|
148
|
+
|
|
149
|
+
const retried = db.prepare("SELECT * FROM outbox_events WHERE id = ?").get(eventId) as any
|
|
150
|
+
expect(retried).toBeDefined()
|
|
151
|
+
expect(retried.status).toBe("created")
|
|
152
|
+
expect(retried.retry_count).toBe(0)
|
|
153
|
+
expect(retried.last_error).toBeNull()
|
|
154
|
+
|
|
155
|
+
const processed: any[] = []
|
|
156
|
+
outbox.start(
|
|
157
|
+
async (e) => {
|
|
158
|
+
processed.push(e)
|
|
159
|
+
},
|
|
160
|
+
(err) => console.error(err)
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
await new Promise((r) => setTimeout(r, 1000))
|
|
164
|
+
await outbox.stop()
|
|
165
|
+
|
|
166
|
+
const processedEvent = processed.find((e) => e.id === eventId)
|
|
167
|
+
expect(processedEvent).toBeDefined()
|
|
168
|
+
expect(processedEvent!.id).toBe(eventId)
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it("should recover from stuck events", async () => {
|
|
172
|
+
const outbox = new SqliteBetterSqlite3Outbox({
|
|
173
|
+
dbPath: DB_PATH,
|
|
174
|
+
pollIntervalMs: 100,
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
const eventId = "event-stuck"
|
|
178
|
+
const now = new Date()
|
|
179
|
+
|
|
180
|
+
db.prepare(`
|
|
181
|
+
INSERT INTO outbox_events (id, type, payload, occurred_at, status, retry_count, keep_alive, expire_in_seconds)
|
|
182
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
183
|
+
`).run(
|
|
184
|
+
eventId,
|
|
185
|
+
"stuck.event",
|
|
186
|
+
JSON.stringify({ stuck: true }),
|
|
187
|
+
new Date(now.getTime() - 400000).toISOString(),
|
|
188
|
+
"active",
|
|
189
|
+
0,
|
|
190
|
+
new Date(now.getTime() - 350000).toISOString(),
|
|
191
|
+
300
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
const processedEvents: any[] = []
|
|
195
|
+
const handler = async (event: any) => {
|
|
196
|
+
processedEvents.push(event)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
outbox.start(handler, (err) => console.error("Outbox Error:", err))
|
|
200
|
+
|
|
201
|
+
await new Promise((resolve) => setTimeout(resolve, 1500))
|
|
202
|
+
|
|
203
|
+
expect(processedEvents.some((e) => e.id === eventId)).toBe(true)
|
|
204
|
+
|
|
205
|
+
await outbox.stop()
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it("should handle concurrent processing safely", async () => {
|
|
209
|
+
const eventCount = 50
|
|
210
|
+
const events = Array.from({ length: eventCount }).map((_, i) => ({
|
|
211
|
+
id: `concurrent-${i}`,
|
|
212
|
+
type: "concurrent.test",
|
|
213
|
+
payload: { index: i },
|
|
214
|
+
occurredAt: new Date(),
|
|
215
|
+
}))
|
|
216
|
+
|
|
217
|
+
const outboxPublisher = new SqliteBetterSqlite3Outbox({
|
|
218
|
+
dbPath: DB_PATH,
|
|
219
|
+
pollIntervalMs: 100,
|
|
220
|
+
})
|
|
221
|
+
await outboxPublisher.publish(events)
|
|
222
|
+
await outboxPublisher.stop()
|
|
223
|
+
|
|
224
|
+
const workerCount = 5
|
|
225
|
+
const processedEvents: any[] = []
|
|
226
|
+
const workers: SqliteBetterSqlite3Outbox[] = []
|
|
227
|
+
|
|
228
|
+
// Shared handler that pushes to processedEvents
|
|
229
|
+
const handler = async (event: any) => {
|
|
230
|
+
await new Promise((resolve) => setTimeout(resolve, Math.random() * 50))
|
|
231
|
+
processedEvents.push(event)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
for (let i = 0; i < workerCount; i++) {
|
|
235
|
+
const worker = new SqliteBetterSqlite3Outbox({
|
|
236
|
+
dbPath: DB_PATH,
|
|
237
|
+
pollIntervalMs: 100 + Math.random() * 50,
|
|
238
|
+
batchSize: 5,
|
|
239
|
+
})
|
|
240
|
+
workers.push(worker)
|
|
241
|
+
worker.start(handler, (err) => console.error(`Worker ${i} Error:`, err))
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const maxWaitTime = 10000
|
|
245
|
+
const startTime = Date.now()
|
|
246
|
+
|
|
247
|
+
while (processedEvents.length < eventCount && Date.now() - startTime < maxWaitTime) {
|
|
248
|
+
await new Promise((resolve) => setTimeout(resolve, 200))
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
await Promise.all(workers.map((w) => w.stop()))
|
|
252
|
+
|
|
253
|
+
expect(processedEvents).toHaveLength(eventCount)
|
|
254
|
+
const ids = processedEvents.map((event) => event.id)
|
|
255
|
+
const uniqueIds = new Set(ids)
|
|
256
|
+
expect(uniqueIds.size).toBe(eventCount)
|
|
257
|
+
})
|
|
258
|
+
})
|
package/src/schema.sql
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS outbox_events (
|
|
2
|
+
id TEXT PRIMARY KEY,
|
|
3
|
+
type TEXT NOT NULL,
|
|
4
|
+
payload TEXT NOT NULL,
|
|
5
|
+
occurred_at TEXT NOT NULL,
|
|
6
|
+
status TEXT NOT NULL DEFAULT 'created',
|
|
7
|
+
retry_count INTEGER NOT NULL DEFAULT 0,
|
|
8
|
+
last_error TEXT,
|
|
9
|
+
next_retry_at TEXT,
|
|
10
|
+
created_on TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
11
|
+
started_on TEXT,
|
|
12
|
+
completed_on TEXT,
|
|
13
|
+
keep_alive TEXT,
|
|
14
|
+
expire_in_seconds INTEGER NOT NULL DEFAULT 300
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
CREATE TABLE IF NOT EXISTS outbox_events_archive (
|
|
18
|
+
id TEXT PRIMARY KEY,
|
|
19
|
+
type TEXT NOT NULL,
|
|
20
|
+
payload TEXT NOT NULL,
|
|
21
|
+
occurred_at TEXT NOT NULL,
|
|
22
|
+
status TEXT NOT NULL,
|
|
23
|
+
retry_count INTEGER NOT NULL,
|
|
24
|
+
last_error TEXT,
|
|
25
|
+
created_on TEXT NOT NULL,
|
|
26
|
+
started_on TEXT,
|
|
27
|
+
completed_on TEXT NOT NULL
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
CREATE INDEX IF NOT EXISTS idx_outbox_events_status_retry ON outbox_events (status, next_retry_at);
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import Database from "better-sqlite3"
|
|
2
|
+
import {
|
|
3
|
+
type BusEvent,
|
|
4
|
+
type ErrorHandler,
|
|
5
|
+
EventStatus,
|
|
6
|
+
type FailedBusEvent,
|
|
7
|
+
formatErrorMessage,
|
|
8
|
+
type IOutbox,
|
|
9
|
+
type OutboxConfig,
|
|
10
|
+
PollingService,
|
|
11
|
+
reportEventError,
|
|
12
|
+
} from "outbox-event-bus"
|
|
13
|
+
|
|
14
|
+
const DEFAULT_EXPIRE_IN_SECONDS = 300
|
|
15
|
+
|
|
16
|
+
const getOutboxSchema = (tableName: string, archiveTableName: string) => `
|
|
17
|
+
CREATE TABLE IF NOT EXISTS ${tableName} (
|
|
18
|
+
id TEXT PRIMARY KEY,
|
|
19
|
+
type TEXT NOT NULL,
|
|
20
|
+
payload TEXT NOT NULL,
|
|
21
|
+
occurred_at TEXT NOT NULL,
|
|
22
|
+
status TEXT NOT NULL DEFAULT '${EventStatus.CREATED}',
|
|
23
|
+
retry_count INTEGER NOT NULL DEFAULT 0,
|
|
24
|
+
last_error TEXT,
|
|
25
|
+
next_retry_at TEXT,
|
|
26
|
+
created_on TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
27
|
+
started_on TEXT,
|
|
28
|
+
completed_on TEXT,
|
|
29
|
+
keep_alive TEXT,
|
|
30
|
+
expire_in_seconds INTEGER NOT NULL DEFAULT ${DEFAULT_EXPIRE_IN_SECONDS}
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
CREATE TABLE IF NOT EXISTS ${archiveTableName} (
|
|
34
|
+
id TEXT PRIMARY KEY,
|
|
35
|
+
type TEXT NOT NULL,
|
|
36
|
+
payload TEXT NOT NULL,
|
|
37
|
+
occurred_at TEXT NOT NULL,
|
|
38
|
+
status TEXT NOT NULL,
|
|
39
|
+
retry_count INTEGER NOT NULL,
|
|
40
|
+
last_error TEXT,
|
|
41
|
+
created_on TEXT NOT NULL,
|
|
42
|
+
started_on TEXT,
|
|
43
|
+
completed_on TEXT NOT NULL
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
CREATE INDEX IF NOT EXISTS idx_${tableName}_status_retry ON ${tableName} (status, next_retry_at);
|
|
47
|
+
`
|
|
48
|
+
|
|
49
|
+
export interface SqliteBetterSqlite3OutboxConfig extends OutboxConfig {
|
|
50
|
+
dbPath?: string
|
|
51
|
+
db?: Database.Database
|
|
52
|
+
getTransaction?: (() => Database.Database | undefined) | undefined
|
|
53
|
+
tableName?: string
|
|
54
|
+
archiveTableName?: string
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface OutboxRow {
|
|
58
|
+
id: string
|
|
59
|
+
type: string
|
|
60
|
+
payload: string
|
|
61
|
+
occurred_at: string
|
|
62
|
+
status: EventStatus
|
|
63
|
+
retry_count: number
|
|
64
|
+
next_retry_at: string | null
|
|
65
|
+
last_error: string | null
|
|
66
|
+
created_on: string
|
|
67
|
+
started_on: string | null
|
|
68
|
+
completed_on: string | null
|
|
69
|
+
keep_alive: string | null
|
|
70
|
+
expire_in_seconds: number
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export class SqliteBetterSqlite3Outbox implements IOutbox<Database.Database> {
|
|
74
|
+
private readonly config: Required<SqliteBetterSqlite3OutboxConfig>
|
|
75
|
+
private readonly db: Database.Database
|
|
76
|
+
private readonly poller: PollingService
|
|
77
|
+
|
|
78
|
+
constructor(config: SqliteBetterSqlite3OutboxConfig) {
|
|
79
|
+
if (config.db) {
|
|
80
|
+
this.db = config.db
|
|
81
|
+
} else {
|
|
82
|
+
if (!config.dbPath) throw new Error("dbPath is required if db is not provided")
|
|
83
|
+
this.db = new Database(config.dbPath)
|
|
84
|
+
this.db.pragma("journal_mode = WAL")
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
this.config = {
|
|
88
|
+
batchSize: config.batchSize ?? 50,
|
|
89
|
+
pollIntervalMs: config.pollIntervalMs ?? 1000,
|
|
90
|
+
maxRetries: config.maxRetries ?? 5,
|
|
91
|
+
baseBackoffMs: config.baseBackoffMs ?? 1000,
|
|
92
|
+
processingTimeoutMs: config.processingTimeoutMs ?? 30000,
|
|
93
|
+
maxErrorBackoffMs: config.maxErrorBackoffMs ?? 30000,
|
|
94
|
+
dbPath: config.dbPath ?? "",
|
|
95
|
+
db: this.db,
|
|
96
|
+
getTransaction: config.getTransaction,
|
|
97
|
+
tableName: config.tableName ?? "outbox_events",
|
|
98
|
+
archiveTableName: config.archiveTableName ?? "outbox_events_archive",
|
|
99
|
+
} as Required<SqliteBetterSqlite3OutboxConfig>
|
|
100
|
+
|
|
101
|
+
this.init()
|
|
102
|
+
|
|
103
|
+
this.poller = new PollingService({
|
|
104
|
+
pollIntervalMs: this.config.pollIntervalMs,
|
|
105
|
+
baseBackoffMs: this.config.baseBackoffMs,
|
|
106
|
+
maxErrorBackoffMs: this.config.maxErrorBackoffMs,
|
|
107
|
+
processBatch: (handler) => this.processBatch(handler),
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private init() {
|
|
112
|
+
this.db.exec(getOutboxSchema(this.config.tableName, this.config.archiveTableName))
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async publish(events: BusEvent[], transaction?: Database.Database): Promise<void> {
|
|
116
|
+
if (events.length === 0) return
|
|
117
|
+
|
|
118
|
+
const executor = transaction ?? this.config.getTransaction?.() ?? this.db
|
|
119
|
+
|
|
120
|
+
const insert = executor.prepare(`
|
|
121
|
+
INSERT INTO ${this.config.tableName} (id, type, payload, occurred_at, status)
|
|
122
|
+
VALUES (?, ?, ?, ?, '${EventStatus.CREATED}')
|
|
123
|
+
`)
|
|
124
|
+
|
|
125
|
+
executor.transaction(() => {
|
|
126
|
+
for (const event of events) {
|
|
127
|
+
insert.run(
|
|
128
|
+
event.id,
|
|
129
|
+
event.type,
|
|
130
|
+
JSON.stringify(event.payload),
|
|
131
|
+
event.occurredAt.toISOString()
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
})()
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async getFailedEvents(): Promise<FailedBusEvent[]> {
|
|
138
|
+
const rows = this.db
|
|
139
|
+
.prepare(`
|
|
140
|
+
SELECT * FROM ${this.config.tableName}
|
|
141
|
+
WHERE status = '${EventStatus.FAILED}'
|
|
142
|
+
ORDER BY occurred_at DESC
|
|
143
|
+
LIMIT 100
|
|
144
|
+
`)
|
|
145
|
+
.all() as OutboxRow[]
|
|
146
|
+
|
|
147
|
+
return rows.map((row) => {
|
|
148
|
+
const event: FailedBusEvent = {
|
|
149
|
+
id: row.id,
|
|
150
|
+
type: row.type,
|
|
151
|
+
payload: JSON.parse(row.payload),
|
|
152
|
+
occurredAt: new Date(row.occurred_at),
|
|
153
|
+
retryCount: row.retry_count,
|
|
154
|
+
}
|
|
155
|
+
if (row.last_error) event.error = row.last_error
|
|
156
|
+
if (row.started_on) event.lastAttemptAt = new Date(row.started_on)
|
|
157
|
+
return event
|
|
158
|
+
})
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async retryEvents(eventIds: string[]): Promise<void> {
|
|
162
|
+
if (eventIds.length === 0) return
|
|
163
|
+
|
|
164
|
+
const placeholders = eventIds.map(() => "?").join(",")
|
|
165
|
+
this.db
|
|
166
|
+
.prepare(`
|
|
167
|
+
UPDATE ${this.config.tableName}
|
|
168
|
+
SET status = '${EventStatus.CREATED}',
|
|
169
|
+
retry_count = 0,
|
|
170
|
+
next_retry_at = NULL,
|
|
171
|
+
last_error = NULL
|
|
172
|
+
WHERE id IN (${placeholders})
|
|
173
|
+
`)
|
|
174
|
+
.run(...eventIds)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
start(handler: (event: BusEvent) => Promise<void>, onError: ErrorHandler): void {
|
|
178
|
+
this.poller.start(handler, onError)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async stop(): Promise<void> {
|
|
182
|
+
await this.poller.stop()
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private async processBatch(handler: (event: BusEvent) => Promise<void>) {
|
|
186
|
+
const now = new Date().toISOString()
|
|
187
|
+
const msNow = Date.now()
|
|
188
|
+
|
|
189
|
+
const lockedEvents = this.db.transaction(() => {
|
|
190
|
+
// Select events that are ready to process:
|
|
191
|
+
// 1. New events (status = 'created')
|
|
192
|
+
// 2. Failed events that can be retried (retry_count < max AND next_retry_at has passed)
|
|
193
|
+
// 3. Stuck events (status = 'active' but keepAlive + expire_in_seconds < now)
|
|
194
|
+
const rows = this.db
|
|
195
|
+
.prepare(`
|
|
196
|
+
SELECT * FROM ${this.config.tableName}
|
|
197
|
+
WHERE status = '${EventStatus.CREATED}'
|
|
198
|
+
OR (status = '${EventStatus.FAILED}' AND retry_count < ? AND next_retry_at <= ?)
|
|
199
|
+
OR (status = '${EventStatus.ACTIVE}' AND datetime(keep_alive, '+' || expire_in_seconds || ' seconds') < datetime(?))
|
|
200
|
+
LIMIT ?
|
|
201
|
+
`)
|
|
202
|
+
.all(this.config.maxRetries, now, now, this.config.batchSize) as OutboxRow[]
|
|
203
|
+
|
|
204
|
+
if (rows.length === 0) return []
|
|
205
|
+
|
|
206
|
+
const ids = rows.map((r) => r.id)
|
|
207
|
+
const placeholders = ids.map(() => "?").join(",")
|
|
208
|
+
|
|
209
|
+
this.db
|
|
210
|
+
.prepare(`
|
|
211
|
+
UPDATE ${this.config.tableName}
|
|
212
|
+
SET status = '${EventStatus.ACTIVE}',
|
|
213
|
+
started_on = ?,
|
|
214
|
+
keep_alive = ?
|
|
215
|
+
WHERE id IN (${placeholders})
|
|
216
|
+
`)
|
|
217
|
+
.run(now, now, ...ids)
|
|
218
|
+
|
|
219
|
+
return rows
|
|
220
|
+
})()
|
|
221
|
+
|
|
222
|
+
if (lockedEvents.length === 0) return
|
|
223
|
+
|
|
224
|
+
const busEvents: BusEvent[] = lockedEvents.map((row) => ({
|
|
225
|
+
id: row.id,
|
|
226
|
+
type: row.type,
|
|
227
|
+
payload: JSON.parse(row.payload),
|
|
228
|
+
occurredAt: new Date(row.occurred_at),
|
|
229
|
+
}))
|
|
230
|
+
|
|
231
|
+
const completedEvents: { event: BusEvent; lockedEvent: OutboxRow }[] = []
|
|
232
|
+
|
|
233
|
+
for (let index = 0; index < busEvents.length; index++) {
|
|
234
|
+
const event = busEvents[index]!
|
|
235
|
+
const lockedEvent = lockedEvents[index]!
|
|
236
|
+
|
|
237
|
+
try {
|
|
238
|
+
await handler(event)
|
|
239
|
+
completedEvents.push({ event, lockedEvent })
|
|
240
|
+
} catch (error) {
|
|
241
|
+
const retryCount = lockedEvent.retry_count + 1
|
|
242
|
+
reportEventError(this.poller.onError, error, event, retryCount, this.config.maxRetries)
|
|
243
|
+
|
|
244
|
+
const delay = this.poller.calculateBackoff(retryCount)
|
|
245
|
+
|
|
246
|
+
this.db
|
|
247
|
+
.prepare(`
|
|
248
|
+
UPDATE ${this.config.tableName}
|
|
249
|
+
SET status = '${EventStatus.FAILED}',
|
|
250
|
+
retry_count = ?,
|
|
251
|
+
last_error = ?,
|
|
252
|
+
next_retry_at = ?
|
|
253
|
+
WHERE id = ?
|
|
254
|
+
`)
|
|
255
|
+
.run(
|
|
256
|
+
retryCount,
|
|
257
|
+
formatErrorMessage(error),
|
|
258
|
+
new Date(msNow + delay).toISOString(),
|
|
259
|
+
lockedEvent.id
|
|
260
|
+
)
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (completedEvents.length > 0) {
|
|
265
|
+
this.db.transaction(() => {
|
|
266
|
+
const insertArchive = this.db.prepare(`
|
|
267
|
+
INSERT INTO ${this.config.archiveTableName} (
|
|
268
|
+
id, type, payload, occurred_at, status, retry_count, last_error, created_on, started_on, completed_on
|
|
269
|
+
) VALUES (?, ?, ?, ?, '${EventStatus.COMPLETED}', ?, ?, ?, ?, ?)
|
|
270
|
+
`)
|
|
271
|
+
const deleteEvent = this.db.prepare(`DELETE FROM ${this.config.tableName} WHERE id = ?`)
|
|
272
|
+
|
|
273
|
+
const completionTime = new Date().toISOString()
|
|
274
|
+
for (const { lockedEvent } of completedEvents) {
|
|
275
|
+
insertArchive.run(
|
|
276
|
+
lockedEvent.id,
|
|
277
|
+
lockedEvent.type,
|
|
278
|
+
lockedEvent.payload,
|
|
279
|
+
lockedEvent.occurred_at,
|
|
280
|
+
lockedEvent.retry_count,
|
|
281
|
+
lockedEvent.last_error,
|
|
282
|
+
lockedEvent.created_on,
|
|
283
|
+
now,
|
|
284
|
+
completionTime
|
|
285
|
+
)
|
|
286
|
+
deleteEvent.run(lockedEvent.id)
|
|
287
|
+
}
|
|
288
|
+
})()
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { existsSync, unlinkSync } from "node:fs"
|
|
2
|
+
import Database from "better-sqlite3"
|
|
3
|
+
import { afterAll, beforeEach, describe, expect, it } from "vitest"
|
|
4
|
+
import { OutboxEventBus } from "../../../core/src/bus/outbox-event-bus"
|
|
5
|
+
import { SqliteBetterSqlite3Outbox } from "./index"
|
|
6
|
+
|
|
7
|
+
const DB_PATH = "./test-sync-transactions.db"
|
|
8
|
+
|
|
9
|
+
describe("SqliteBetterSqlite3Outbox Synchronous Transactions", () => {
|
|
10
|
+
let db: Database.Database
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
db = new Database(":memory:")
|
|
14
|
+
// Create business table
|
|
15
|
+
db.exec("CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, name TEXT)")
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
afterAll(() => {
|
|
19
|
+
if (db) {
|
|
20
|
+
db.close()
|
|
21
|
+
}
|
|
22
|
+
if (existsSync(DB_PATH)) {
|
|
23
|
+
unlinkSync(DB_PATH)
|
|
24
|
+
}
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it("should atomically commit event and data when using shared connection", async () => {
|
|
28
|
+
// 1. Setup Outbox with getTransaction returning the shared db
|
|
29
|
+
const outbox = new SqliteBetterSqlite3Outbox({
|
|
30
|
+
db,
|
|
31
|
+
getTransaction: () => db, // <--- CRITICAL: Use the same DB instance
|
|
32
|
+
})
|
|
33
|
+
const bus = new OutboxEventBus(outbox, (err) => console.error(err))
|
|
34
|
+
|
|
35
|
+
const userId = "user-1"
|
|
36
|
+
const eventId = "event-1"
|
|
37
|
+
|
|
38
|
+
// 2. Run synchronous transaction
|
|
39
|
+
const createUser = db.transaction(() => {
|
|
40
|
+
db.prepare("INSERT INTO users (id, name) VALUES (?, ?)").run(userId, "Alice")
|
|
41
|
+
|
|
42
|
+
// Emit event synchronously (using void to ignore Promise)
|
|
43
|
+
// Since we provided getTransaction: () => db, this uses the SAME connection
|
|
44
|
+
// and thus participates in the transaction via SAVEPOINT
|
|
45
|
+
void bus.emit({
|
|
46
|
+
id: eventId,
|
|
47
|
+
type: "user.created",
|
|
48
|
+
payload: { id: userId, name: "Alice" },
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
createUser()
|
|
53
|
+
|
|
54
|
+
// 3. Verify both exist
|
|
55
|
+
const user = db.prepare("SELECT * FROM users WHERE id = ?").get(userId)
|
|
56
|
+
expect(user).toBeDefined()
|
|
57
|
+
|
|
58
|
+
const event = db.prepare("SELECT * FROM outbox_events WHERE id = ?").get(eventId)
|
|
59
|
+
expect(event).toBeDefined()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it("should atomically rollback event and data on error", async () => {
|
|
63
|
+
const outbox = new SqliteBetterSqlite3Outbox({
|
|
64
|
+
db,
|
|
65
|
+
getTransaction: () => db,
|
|
66
|
+
})
|
|
67
|
+
const bus = new OutboxEventBus(outbox, (err) => console.error(err))
|
|
68
|
+
|
|
69
|
+
const userId = "user-2"
|
|
70
|
+
const eventId = "event-2"
|
|
71
|
+
|
|
72
|
+
// 2. Run synchronous transaction that fails
|
|
73
|
+
const createUser = db.transaction(() => {
|
|
74
|
+
db.prepare("INSERT INTO users (id, name) VALUES (?, ?)").run(userId, "Bob")
|
|
75
|
+
|
|
76
|
+
void bus.emit({
|
|
77
|
+
id: eventId,
|
|
78
|
+
type: "user.created",
|
|
79
|
+
payload: { id: userId, name: "Bob" },
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
throw new Error("Rollback!")
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
expect(() => createUser()).toThrow("Rollback!")
|
|
86
|
+
|
|
87
|
+
// 3. Verify NEITHER exist
|
|
88
|
+
const user = db.prepare("SELECT * FROM users WHERE id = ?").get(userId)
|
|
89
|
+
expect(user).toBeUndefined()
|
|
90
|
+
|
|
91
|
+
const event = db.prepare("SELECT * FROM outbox_events WHERE id = ?").get(eventId)
|
|
92
|
+
expect(event).toBeUndefined()
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it("should throw TypeError if async function is used (preventing await usage)", async () => {
|
|
96
|
+
const outbox = new SqliteBetterSqlite3Outbox({
|
|
97
|
+
dbPath: DB_PATH,
|
|
98
|
+
getTransaction: () => db,
|
|
99
|
+
})
|
|
100
|
+
const _bus = new OutboxEventBus(outbox, (err) => console.error(err))
|
|
101
|
+
|
|
102
|
+
const userId = "user-async"
|
|
103
|
+
|
|
104
|
+
// @ts-expect-error - Testing runtime behavior
|
|
105
|
+
const tx = db.transaction(async () => {
|
|
106
|
+
db.prepare("INSERT INTO users (id, name) VALUES (?, ?)").run(userId, "Async")
|
|
107
|
+
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
// It throws TypeError: Transaction function cannot return a promise
|
|
111
|
+
expect(() => tx()).toThrow("Transaction function cannot return a promise")
|
|
112
|
+
|
|
113
|
+
// Verify rollback happened
|
|
114
|
+
const user = db.prepare("SELECT * FROM users WHERE id = ?").get(userId)
|
|
115
|
+
expect(user).toBeUndefined()
|
|
116
|
+
})
|
|
117
|
+
})
|