@kronos-ts/postgres 0.1.1 → 0.2.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/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -1
- package/dist/index.js.map +1 -1
- package/dist/postgres-event-scheduler.d.ts +88 -0
- package/dist/postgres-event-scheduler.d.ts.map +1 -0
- package/dist/postgres-event-scheduler.js +226 -0
- package/dist/postgres-event-scheduler.js.map +1 -0
- package/dist/postgres-event-store.d.ts.map +1 -1
- package/dist/postgres-event-store.js +70 -35
- package/dist/postgres-event-store.js.map +1 -1
- package/dist/postgres-transaction-manager.d.ts +33 -0
- package/dist/postgres-transaction-manager.d.ts.map +1 -0
- package/dist/postgres-transaction-manager.js +92 -0
- package/dist/postgres-transaction-manager.js.map +1 -0
- package/dist/postgres.d.ts +22 -5
- package/dist/postgres.d.ts.map +1 -1
- package/dist/postgres.js +46 -5
- package/dist/postgres.js.map +1 -1
- package/dist/schema.d.ts +47 -0
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +67 -0
- package/dist/schema.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +18 -0
- package/src/postgres-event-scheduler.ts +314 -0
- package/src/postgres-event-store.ts +77 -30
- package/src/postgres-transaction-manager.ts +120 -0
- package/src/postgres.ts +64 -6
- package/src/schema.ts +70 -0
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* createPostgresEventScheduler — durable {@link EventScheduler} backed by
|
|
3
|
+
* the kronos_scheduled_events table, plus a polling worker that fires
|
|
4
|
+
* due schedules into the event store.
|
|
5
|
+
*
|
|
6
|
+
* # Schedule path (caller-driven, inside a UoW)
|
|
7
|
+
*
|
|
8
|
+
* schedule(event, at)
|
|
9
|
+
* → requires INVOCATION phase
|
|
10
|
+
* → captures tags via the configured TagResolver at schedule-time
|
|
11
|
+
* → INSERT row (schedule_id = event.identifier, status='pending')
|
|
12
|
+
* → joins the active UoW transaction via getOrBeginActiveTransaction;
|
|
13
|
+
* if the UoW rolls back, the schedule is never persisted
|
|
14
|
+
* → returns { id: schedule_id } as the cancellation token
|
|
15
|
+
*
|
|
16
|
+
* # Cancel path
|
|
17
|
+
*
|
|
18
|
+
* cancel(token)
|
|
19
|
+
* → SELECT … FOR UPDATE to lock the row + read prior status
|
|
20
|
+
* → branch:
|
|
21
|
+
* status='pending' → UPDATE status='cancelled' → { kind: 'cancelled' }
|
|
22
|
+
* status='appended' → no UPDATE → { kind: 'already-appended' }
|
|
23
|
+
* status='cancelled' → no UPDATE → { kind: 'not-found' }
|
|
24
|
+
* no row → no UPDATE → { kind: 'not-found' }
|
|
25
|
+
* → cancel inside a UoW joins the active tx; outside, opens its own
|
|
26
|
+
* adapter.transaction so the SELECT-FOR-UPDATE + UPDATE land atomically
|
|
27
|
+
*
|
|
28
|
+
* # Worker path (background)
|
|
29
|
+
*
|
|
30
|
+
* start() spins a setInterval that, per tick, runs inside a fresh UoW:
|
|
31
|
+
* 1. force the lazy pg tx open via getOrBeginActiveTransaction
|
|
32
|
+
* 2. SELECT … WHERE status='pending' AND fire_at <= now()
|
|
33
|
+
* ORDER BY fire_at LIMIT $batchSize FOR UPDATE SKIP LOCKED
|
|
34
|
+
* — the SKIP LOCKED keeps multiple worker instances safe
|
|
35
|
+
* 3. for each row: reconstruct EventMessage, call eventStore.append
|
|
36
|
+
* (which joins the same UoW tx), UPDATE status='appended'
|
|
37
|
+
* 4. UoW COMMIT → all appends + status flips land atomically
|
|
38
|
+
*
|
|
39
|
+
* If the worker process dies mid-tick before COMMIT, the rows stay
|
|
40
|
+
* 'pending' and get re-picked on the next tick — at-least-once delivery.
|
|
41
|
+
* The schedule_id is reused as event.identifier so the events table's
|
|
42
|
+
* UNIQUE constraint dedupes any spurious double-append (e.g., on the
|
|
43
|
+
* rare race where a previous COMMIT succeeded but the status UPDATE
|
|
44
|
+
* failed afterwards — though here they share a tx, so this is mainly
|
|
45
|
+
* a defensive note).
|
|
46
|
+
*
|
|
47
|
+
* # Multi-node safety
|
|
48
|
+
*
|
|
49
|
+
* FOR UPDATE SKIP LOCKED is the locking primitive. Two worker processes
|
|
50
|
+
* polling the same table will hand non-overlapping batches of rows to
|
|
51
|
+
* their respective ticks; neither blocks the other. No leader election
|
|
52
|
+
* or distributed lock is needed.
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
import { qualifiedNameToString, qualifiedNameFromString } from "@kronos-ts/common"
|
|
56
|
+
import type { EventMessage } from "@kronos-ts/messaging"
|
|
57
|
+
import type {
|
|
58
|
+
EventScheduler,
|
|
59
|
+
ScheduleToken,
|
|
60
|
+
CancelResult,
|
|
61
|
+
UoWRunner,
|
|
62
|
+
} from "@kronos-ts/messaging"
|
|
63
|
+
import { getOrBeginActiveTransaction } from "@kronos-ts/messaging"
|
|
64
|
+
// Deep-path import: requireInvocationPhase is intentionally not in the
|
|
65
|
+
// messaging barrel — it's the framework-internal mutator guard consumed
|
|
66
|
+
// by append/send/emitUpdate, and now schedule(). Same access pattern.
|
|
67
|
+
import { requireInvocationPhase } from "@kronos-ts/messaging/processing-state"
|
|
68
|
+
import type { EventStore } from "@kronos-ts/eventsourcing"
|
|
69
|
+
import { IsolationLevel } from "./adapter.js"
|
|
70
|
+
import type { PostgresAdapter, PostgresAdapterTransaction } from "./adapter.js"
|
|
71
|
+
import { encodeTag } from "./criteria-sql.js"
|
|
72
|
+
import { type TableNames, DEFAULT_TABLE_NAMES } from "./schema.js"
|
|
73
|
+
import type { TagResolver } from "./postgres-event-store.js"
|
|
74
|
+
|
|
75
|
+
export interface PostgresEventSchedulerConfig {
|
|
76
|
+
readonly adapter: PostgresAdapter
|
|
77
|
+
readonly eventStore: EventStore
|
|
78
|
+
/**
|
|
79
|
+
* The composed UoW runner the worker uses per tick — must be wrapped
|
|
80
|
+
* with {@link lazyTransactionalUnitOfWorkFactory} (or equivalent) so
|
|
81
|
+
* the worker's UoW sees the postgres tx. Typically the resolved
|
|
82
|
+
* `unitOfWorkFactory` slot.
|
|
83
|
+
*/
|
|
84
|
+
readonly uowFactory: UoWRunner
|
|
85
|
+
readonly tagResolver: TagResolver
|
|
86
|
+
readonly tableNames?: TableNames
|
|
87
|
+
/**
|
|
88
|
+
* Worker poll interval. Defaults to 1000ms — a compromise between
|
|
89
|
+
* fire-latency and DB chatter. Production users wanting tighter
|
|
90
|
+
* latency should lower this, ideally combined with LISTEN/NOTIFY
|
|
91
|
+
* wake-up (not yet wired here).
|
|
92
|
+
*/
|
|
93
|
+
readonly pollIntervalMs?: number
|
|
94
|
+
/** Max rows the worker processes per tick. Defaults to 50. */
|
|
95
|
+
readonly batchSize?: number
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface PostgresEventScheduler extends EventScheduler {
|
|
99
|
+
/** Begin background polling. Idempotent. */
|
|
100
|
+
start(): Promise<void>
|
|
101
|
+
/** Stop polling. Resolves once the in-flight tick (if any) has settled. */
|
|
102
|
+
stop(): Promise<void>
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
interface ScheduleRow {
|
|
106
|
+
schedule_id: string
|
|
107
|
+
type: string
|
|
108
|
+
tags: string[]
|
|
109
|
+
payload: unknown
|
|
110
|
+
metadata: unknown
|
|
111
|
+
version: string
|
|
112
|
+
message_timestamp: string | number
|
|
113
|
+
[key: string]: unknown
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function createPostgresEventScheduler(
|
|
117
|
+
config: PostgresEventSchedulerConfig,
|
|
118
|
+
): PostgresEventScheduler {
|
|
119
|
+
const { adapter, eventStore, uowFactory, tagResolver } = config
|
|
120
|
+
const tables = config.tableNames ?? DEFAULT_TABLE_NAMES
|
|
121
|
+
const pollIntervalMs = config.pollIntervalMs ?? 1000
|
|
122
|
+
const batchSize = config.batchSize ?? 50
|
|
123
|
+
|
|
124
|
+
async function insertSchedule(
|
|
125
|
+
tx: PostgresAdapterTransaction,
|
|
126
|
+
event: EventMessage,
|
|
127
|
+
at: Date,
|
|
128
|
+
): Promise<string> {
|
|
129
|
+
const scheduleId = event.identifier
|
|
130
|
+
const encodedTags = tagResolver
|
|
131
|
+
.resolve(event)
|
|
132
|
+
.map((t) => encodeTag(t.key, t.value))
|
|
133
|
+
await tx.query(
|
|
134
|
+
`INSERT INTO ${tables.scheduled}
|
|
135
|
+
(schedule_id, fire_at, status, type, tags, payload, metadata, version, message_timestamp)
|
|
136
|
+
VALUES ($1, $2, 'pending', $3, $4, $5, $6, $7, $8)`,
|
|
137
|
+
[
|
|
138
|
+
scheduleId,
|
|
139
|
+
at.toISOString(),
|
|
140
|
+
qualifiedNameToString(event.name),
|
|
141
|
+
encodedTags,
|
|
142
|
+
JSON.stringify(event.payload ?? {}),
|
|
143
|
+
JSON.stringify(event.metadata ?? {}),
|
|
144
|
+
event.version,
|
|
145
|
+
event.timestamp,
|
|
146
|
+
],
|
|
147
|
+
)
|
|
148
|
+
return scheduleId
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function cancelOnTx(
|
|
152
|
+
tx: PostgresAdapterTransaction,
|
|
153
|
+
scheduleId: string,
|
|
154
|
+
): Promise<CancelResult> {
|
|
155
|
+
const rows = await tx.query<{ status: string }>(
|
|
156
|
+
`SELECT status FROM ${tables.scheduled} WHERE schedule_id = $1 FOR UPDATE`,
|
|
157
|
+
[scheduleId],
|
|
158
|
+
)
|
|
159
|
+
const row = rows[0]
|
|
160
|
+
if (!row) return { kind: "not-found" }
|
|
161
|
+
if (row.status === "appended") return { kind: "already-appended" }
|
|
162
|
+
if (row.status === "cancelled") return { kind: "not-found" }
|
|
163
|
+
|
|
164
|
+
await tx.query(
|
|
165
|
+
`UPDATE ${tables.scheduled} SET status = 'cancelled' WHERE schedule_id = $1`,
|
|
166
|
+
[scheduleId],
|
|
167
|
+
)
|
|
168
|
+
return { kind: "cancelled" }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function decodeJsonbValue(v: unknown): unknown {
|
|
172
|
+
if (typeof v === "string") {
|
|
173
|
+
try {
|
|
174
|
+
return JSON.parse(v)
|
|
175
|
+
} catch {
|
|
176
|
+
return v
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return v ?? {}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function decodeTags(encoded: string[]): EventMessage["tags"] {
|
|
183
|
+
return encoded.map((t) => {
|
|
184
|
+
// U+001F unit separator — matches encodeTag in criteria-sql.ts
|
|
185
|
+
const sep = t.indexOf("")
|
|
186
|
+
return sep >= 0
|
|
187
|
+
? { key: t.slice(0, sep), value: t.slice(sep + 1) }
|
|
188
|
+
: { key: t, value: "" }
|
|
189
|
+
})
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function reconstructEvent(row: ScheduleRow): EventMessage {
|
|
193
|
+
return {
|
|
194
|
+
identifier: row.schedule_id,
|
|
195
|
+
name: qualifiedNameFromString(row.type),
|
|
196
|
+
payload: decodeJsonbValue(row.payload),
|
|
197
|
+
metadata: decodeJsonbValue(row.metadata) as EventMessage["metadata"],
|
|
198
|
+
timestamp: Number(row.message_timestamp),
|
|
199
|
+
version: row.version,
|
|
200
|
+
tags: decodeTags(row.tags),
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Worker state
|
|
205
|
+
let running = false
|
|
206
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
207
|
+
let activeTick: Promise<void> | undefined
|
|
208
|
+
|
|
209
|
+
async function tick(): Promise<void> {
|
|
210
|
+
try {
|
|
211
|
+
await uowFactory(undefined, async () => {
|
|
212
|
+
const tx = await getOrBeginActiveTransaction<PostgresAdapterTransaction>()
|
|
213
|
+
if (!tx) {
|
|
214
|
+
// No lazy tx factory installed on the UoW — the scheduler was
|
|
215
|
+
// configured with a uowFactory that doesn't wrap the postgres tx
|
|
216
|
+
// manager. Without a shared tx the worker can't atomically
|
|
217
|
+
// append-and-mark, so refuse to fire rather than risk
|
|
218
|
+
// partial-state. This is a misconfiguration; surface loudly.
|
|
219
|
+
throw new Error(
|
|
220
|
+
"postgresEventScheduler worker requires a uowFactory wrapped with lazyTransactionalUnitOfWorkFactory + postgresTransactionManager",
|
|
221
|
+
)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const rows = await tx.query<ScheduleRow>(
|
|
225
|
+
`SELECT schedule_id, type, tags, payload, metadata, version, message_timestamp
|
|
226
|
+
FROM ${tables.scheduled}
|
|
227
|
+
WHERE status = 'pending' AND fire_at <= now()
|
|
228
|
+
ORDER BY fire_at
|
|
229
|
+
LIMIT $1
|
|
230
|
+
FOR UPDATE SKIP LOCKED`,
|
|
231
|
+
[batchSize],
|
|
232
|
+
)
|
|
233
|
+
if (rows.length === 0) return
|
|
234
|
+
|
|
235
|
+
for (const row of rows) {
|
|
236
|
+
const event = reconstructEvent(row)
|
|
237
|
+
// eventStore.append joins our shared UoW tx via
|
|
238
|
+
// getOrBeginActiveTransaction (postgres-event-store refactor).
|
|
239
|
+
// event_id = schedule_id, so re-fires after a crash dedupe via
|
|
240
|
+
// the events table's UNIQUE(event_id) constraint.
|
|
241
|
+
await eventStore.append([event])
|
|
242
|
+
await tx.query(
|
|
243
|
+
`UPDATE ${tables.scheduled} SET status = 'appended' WHERE schedule_id = $1`,
|
|
244
|
+
[row.schedule_id],
|
|
245
|
+
)
|
|
246
|
+
}
|
|
247
|
+
})
|
|
248
|
+
} catch (err) {
|
|
249
|
+
// A failed tick leaves rows 'pending' (the UoW rolls back the whole
|
|
250
|
+
// batch). The next tick re-tries. Log so operators see persistent
|
|
251
|
+
// failures rather than a silent hang.
|
|
252
|
+
console.warn("postgresEventScheduler: worker tick failed:", err)
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function scheduleNextTick(): void {
|
|
257
|
+
if (!running) return
|
|
258
|
+
timer = setTimeout(() => {
|
|
259
|
+
activeTick = tick().finally(() => {
|
|
260
|
+
activeTick = undefined
|
|
261
|
+
scheduleNextTick()
|
|
262
|
+
})
|
|
263
|
+
}, pollIntervalMs)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
async schedule(event: EventMessage, at: Date): Promise<ScheduleToken> {
|
|
268
|
+
requireInvocationPhase()
|
|
269
|
+
const shared = await getOrBeginActiveTransaction<PostgresAdapterTransaction>()
|
|
270
|
+
if (shared === undefined) {
|
|
271
|
+
// schedule() must be transactional with the caller's UoW so a
|
|
272
|
+
// rolled-back command does not leak schedules. Refuse rather
|
|
273
|
+
// than open a side-channel tx the caller can't roll back.
|
|
274
|
+
throw new Error(
|
|
275
|
+
"postgresEventScheduler.schedule requires a UoW with a postgres transaction (configure lazyTransactionalUnitOfWorkFactory + postgresTransactionManager)",
|
|
276
|
+
)
|
|
277
|
+
}
|
|
278
|
+
const id = await insertSchedule(shared, event, at)
|
|
279
|
+
return { id }
|
|
280
|
+
},
|
|
281
|
+
|
|
282
|
+
async cancel(token: ScheduleToken): Promise<CancelResult> {
|
|
283
|
+
const shared = await getOrBeginActiveTransaction<PostgresAdapterTransaction>()
|
|
284
|
+
if (shared !== undefined) {
|
|
285
|
+
return cancelOnTx(shared, token.id)
|
|
286
|
+
}
|
|
287
|
+
return adapter.transaction(IsolationLevel.READ_COMMITTED, (tx) =>
|
|
288
|
+
cancelOnTx(tx, token.id),
|
|
289
|
+
)
|
|
290
|
+
},
|
|
291
|
+
|
|
292
|
+
async start(): Promise<void> {
|
|
293
|
+
if (running) return
|
|
294
|
+
running = true
|
|
295
|
+
scheduleNextTick()
|
|
296
|
+
},
|
|
297
|
+
|
|
298
|
+
async stop(): Promise<void> {
|
|
299
|
+
running = false
|
|
300
|
+
if (timer !== undefined) {
|
|
301
|
+
clearTimeout(timer)
|
|
302
|
+
timer = undefined
|
|
303
|
+
}
|
|
304
|
+
if (activeTick !== undefined) {
|
|
305
|
+
try {
|
|
306
|
+
await activeTick
|
|
307
|
+
} catch {
|
|
308
|
+
// The tick logs its own failures; stop() returns successfully
|
|
309
|
+
// either way so callers can shut down deterministically.
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
},
|
|
313
|
+
}
|
|
314
|
+
}
|
|
@@ -49,7 +49,13 @@ import type {
|
|
|
49
49
|
StreamingCondition,
|
|
50
50
|
TrackingToken,
|
|
51
51
|
} from "@kronos-ts/messaging"
|
|
52
|
-
import {
|
|
52
|
+
import {
|
|
53
|
+
createMessageStream,
|
|
54
|
+
globalSequenceToken,
|
|
55
|
+
FIRST_TOKEN,
|
|
56
|
+
getOrBeginActiveTransaction,
|
|
57
|
+
onAfterCommit,
|
|
58
|
+
} from "@kronos-ts/messaging"
|
|
53
59
|
import { qualifiedNameToString, qualifiedNameFromString } from "@kronos-ts/common"
|
|
54
60
|
import type { Serializer } from "@kronos-ts/common"
|
|
55
61
|
export type { Serializer } from "@kronos-ts/common"
|
|
@@ -325,31 +331,54 @@ export function createPostgresEventStore(
|
|
|
325
331
|
events: ReadonlyArray<EventMessage>,
|
|
326
332
|
condition?: AppendCondition,
|
|
327
333
|
): Promise<ConsistencyMarker> {
|
|
328
|
-
//
|
|
334
|
+
// Two paths: join an active UoW tx (writes commit atomically with
|
|
335
|
+
// everything else in the UoW — scheduler inserts, future outbox, etc.),
|
|
336
|
+
// or open our own short-lived tx when no UoW is active. The shared
|
|
337
|
+
// path defers NOTIFY + subscriber dispatch to AFTER_COMMIT because the
|
|
338
|
+
// tx hasn't actually committed yet when checkAndInsert returns.
|
|
329
339
|
const targets = lockTargetsForCondition(condition)
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
})
|
|
337
|
-
} catch (err) {
|
|
338
|
-
if (isDcbViolation(err)) {
|
|
339
|
-
// Re-throw AppendConditionError directly (already has correct type)
|
|
340
|
-
throw err
|
|
340
|
+
const shared = await getOrBeginActiveTransaction<PostgresAdapterTransaction>()
|
|
341
|
+
|
|
342
|
+
const notifyAndFanout = async () => {
|
|
343
|
+
await adapter.query(`NOTIFY ${notifyChannel}`)
|
|
344
|
+
for (const sub of eventSubscribers) {
|
|
345
|
+
try { await sub(events) } catch { /* ignore subscriber errors */ }
|
|
341
346
|
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const runAppend = async (tx: PostgresAdapterTransaction): Promise<ConsistencyMarker> => {
|
|
350
|
+
await acquireWriteLocks(tx, targets)
|
|
351
|
+
const captured = await checkAndInsert(tx, events, condition)
|
|
352
|
+
return markerAt(captured.position)
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const translateError = (err: unknown): never => {
|
|
356
|
+
if (isDcbViolation(err)) throw err
|
|
342
357
|
if ((err as { code?: string }).code === "23505") {
|
|
343
358
|
throw AppendConditionError.fromConflictCount(0, condition?.marker.position ?? -1n)
|
|
344
359
|
}
|
|
345
360
|
throw err
|
|
346
361
|
}
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
try {
|
|
362
|
+
|
|
363
|
+
if (shared !== undefined) {
|
|
364
|
+
let marker: ConsistencyMarker
|
|
365
|
+
try {
|
|
366
|
+
marker = await runAppend(shared)
|
|
367
|
+
} catch (err) {
|
|
368
|
+
translateError(err)
|
|
369
|
+
}
|
|
370
|
+
onAfterCommit(notifyAndFanout)
|
|
371
|
+
return marker!
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
let marker: ConsistencyMarker
|
|
375
|
+
try {
|
|
376
|
+
marker = await adapter.transaction(IsolationLevel.READ_COMMITTED, runAppend)
|
|
377
|
+
} catch (err) {
|
|
378
|
+
translateError(err)
|
|
351
379
|
}
|
|
352
|
-
|
|
380
|
+
await notifyAndFanout()
|
|
381
|
+
return marker!
|
|
353
382
|
},
|
|
354
383
|
|
|
355
384
|
async getHeadPosition(): Promise<bigint> {
|
|
@@ -372,26 +401,44 @@ export function createPostgresEventStore(
|
|
|
372
401
|
},
|
|
373
402
|
|
|
374
403
|
async publish(events: ReadonlyArray<EventMessage>): Promise<void> {
|
|
375
|
-
// publish = append without condition;
|
|
404
|
+
// publish = append without condition; same shared/own tx split as append().
|
|
376
405
|
const targets: LockTarget[] = []
|
|
377
|
-
|
|
406
|
+
const shared = await getOrBeginActiveTransaction<PostgresAdapterTransaction>()
|
|
407
|
+
|
|
408
|
+
const notifyAndFanout = async () => {
|
|
409
|
+
await adapter.query(`NOTIFY ${notifyChannel}`)
|
|
410
|
+
for (const sub of eventSubscribers) {
|
|
411
|
+
try { await sub(events) } catch { /* ignore subscriber errors */ }
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const runPublish = async (tx: PostgresAdapterTransaction): Promise<void> => {
|
|
416
|
+
await acquireWriteLocks(tx, targets)
|
|
417
|
+
await checkAndInsert(tx, events, undefined)
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (shared !== undefined) {
|
|
421
|
+
try {
|
|
422
|
+
await runPublish(shared)
|
|
423
|
+
} catch (err) {
|
|
424
|
+
if ((err as { code?: string }).code === "23505") {
|
|
425
|
+
throw AppendConditionError.fromConflictCount(0, -1n)
|
|
426
|
+
}
|
|
427
|
+
throw err
|
|
428
|
+
}
|
|
429
|
+
onAfterCommit(notifyAndFanout)
|
|
430
|
+
return
|
|
431
|
+
}
|
|
432
|
+
|
|
378
433
|
try {
|
|
379
|
-
|
|
380
|
-
await acquireWriteLocks(tx, targets)
|
|
381
|
-
const captured = await checkAndInsert(tx, events, undefined)
|
|
382
|
-
return markerAt(captured.position)
|
|
383
|
-
})
|
|
434
|
+
await adapter.transaction(IsolationLevel.READ_COMMITTED, runPublish)
|
|
384
435
|
} catch (err) {
|
|
385
436
|
if ((err as { code?: string }).code === "23505") {
|
|
386
437
|
throw AppendConditionError.fromConflictCount(0, -1n)
|
|
387
438
|
}
|
|
388
439
|
throw err
|
|
389
440
|
}
|
|
390
|
-
|
|
391
|
-
await adapter.query(`NOTIFY ${notifyChannel}`)
|
|
392
|
-
for (const sub of eventSubscribers) {
|
|
393
|
-
try { await sub(events) } catch { /* ignore subscriber errors */ }
|
|
394
|
-
}
|
|
441
|
+
await notifyAndFanout()
|
|
395
442
|
},
|
|
396
443
|
|
|
397
444
|
subscribe(
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* postgresTransactionManager — bridges the framework's TransactionManager
|
|
3
|
+
* lifecycle to the postgres adapter's callback-shaped `adapter.transaction`.
|
|
4
|
+
*
|
|
5
|
+
* adapter.transaction(IL, fn) opens a pg tx, runs `fn(tx)`, and COMMITs on
|
|
6
|
+
* fn-resolve / ROLLBACKs on fn-reject. The framework needs a tx whose
|
|
7
|
+
* commit/rollback is callable LATER (at the UoW's COMMIT or onError phase),
|
|
8
|
+
* not when fn returns. We bridge by parking `fn` on a deferred completion
|
|
9
|
+
* promise — `commit()` resolves it (→ adapter commits), `rollback()`
|
|
10
|
+
* rejects it (→ adapter rolls back).
|
|
11
|
+
*
|
|
12
|
+
* Same shape as kysely/prisma transaction managers in this repo.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { getOrBeginActiveTransaction, type TransactionManager } from "@kronos-ts/messaging"
|
|
16
|
+
import type { PostgresAdapter, PostgresAdapterTransaction } from "./adapter.js"
|
|
17
|
+
import { IsolationLevel } from "./adapter.js"
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Module-private symbol attaching commit/rollback control to a tx handle.
|
|
21
|
+
* Consumers via `getActiveTransaction<PostgresAdapterTransaction>()` see
|
|
22
|
+
* only `{ query }` — they cannot read this without importing the symbol.
|
|
23
|
+
*/
|
|
24
|
+
const TX_CONTROL = Symbol("kronos.postgresTxControl")
|
|
25
|
+
|
|
26
|
+
interface TxControl {
|
|
27
|
+
readonly resolveCommit: () => void
|
|
28
|
+
readonly rejectRollback: (err: unknown) => void
|
|
29
|
+
readonly txPromise: Promise<void>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface ManagedPostgresTransaction extends PostgresAdapterTransaction {
|
|
33
|
+
[TX_CONTROL]: TxControl
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Marker error: signals an intentional rollback so the .catch can suppress it. */
|
|
37
|
+
const ROLLBACK_MARKER = "__kronos_postgres_tx_rollback__"
|
|
38
|
+
|
|
39
|
+
export function postgresTransactionManager(
|
|
40
|
+
adapter: PostgresAdapter,
|
|
41
|
+
isolationLevel: IsolationLevel = IsolationLevel.READ_COMMITTED,
|
|
42
|
+
): TransactionManager<PostgresAdapterTransaction> {
|
|
43
|
+
return {
|
|
44
|
+
async begin(): Promise<PostgresAdapterTransaction> {
|
|
45
|
+
let captureTx!: (tx: PostgresAdapterTransaction) => void
|
|
46
|
+
const txReady = new Promise<PostgresAdapterTransaction>((res) => {
|
|
47
|
+
captureTx = res
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
let resolveCommit!: () => void
|
|
51
|
+
let rejectRollback!: (err: unknown) => void
|
|
52
|
+
const completion = new Promise<void>((res, rej) => {
|
|
53
|
+
resolveCommit = res
|
|
54
|
+
rejectRollback = rej
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
const txPromise = adapter
|
|
58
|
+
.transaction(isolationLevel, async (tx) => {
|
|
59
|
+
captureTx(tx)
|
|
60
|
+
await completion
|
|
61
|
+
})
|
|
62
|
+
.then(
|
|
63
|
+
() => undefined,
|
|
64
|
+
(err) => {
|
|
65
|
+
// Suppress the marker — rollback is an expected outcome.
|
|
66
|
+
if (err instanceof Error && err.message === ROLLBACK_MARKER) return
|
|
67
|
+
throw err
|
|
68
|
+
},
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
const tx = (await txReady) as ManagedPostgresTransaction
|
|
72
|
+
tx[TX_CONTROL] = { resolveCommit, rejectRollback, txPromise }
|
|
73
|
+
return tx
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
async commit(tx: PostgresAdapterTransaction): Promise<void> {
|
|
77
|
+
const ctrl = (tx as ManagedPostgresTransaction)[TX_CONTROL]
|
|
78
|
+
ctrl.resolveCommit()
|
|
79
|
+
await ctrl.txPromise
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
async rollback(tx: PostgresAdapterTransaction): Promise<void> {
|
|
83
|
+
const ctrl = (tx as ManagedPostgresTransaction)[TX_CONTROL]
|
|
84
|
+
ctrl.rejectRollback(new Error(ROLLBACK_MARKER))
|
|
85
|
+
try {
|
|
86
|
+
await ctrl.txPromise
|
|
87
|
+
} catch (err) {
|
|
88
|
+
// A real follow-up error during ROLLBACK execution. Don't throw
|
|
89
|
+
// from rollback() — the UoW is already in an error path and a
|
|
90
|
+
// cascading throw masks the original failure.
|
|
91
|
+
console.warn("postgresTransactionManager: rollback path threw:", err)
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Run `fn` inside a postgres tx, joining a UoW-scoped tx if one is active
|
|
99
|
+
* (or installed lazily), otherwise opening an ad-hoc tx via `adapter.transaction`.
|
|
100
|
+
*
|
|
101
|
+
* This is what every postgres-extension write path should funnel through —
|
|
102
|
+
* event store appends, snapshot writes, scheduler inserts — so that all
|
|
103
|
+
* writes inside a single UoW land in one pg tx and commit/roll back atomically.
|
|
104
|
+
* Calls from outside any UoW (e.g., the scheduler worker loop, projection
|
|
105
|
+
* queries, lifecycle bootstraps) get their own short-lived tx.
|
|
106
|
+
*
|
|
107
|
+
* Returns whatever `fn` returns. Tx commit/rollback happens when the
|
|
108
|
+
* surrounding UoW's COMMIT/onError fires (joined path) or when `fn` resolves/
|
|
109
|
+
* rejects (ad-hoc path).
|
|
110
|
+
*/
|
|
111
|
+
export async function withSharedOrOwnTx<R>(
|
|
112
|
+
adapter: PostgresAdapter,
|
|
113
|
+
fn: (tx: PostgresAdapterTransaction) => Promise<R>,
|
|
114
|
+
isolationLevel: IsolationLevel = IsolationLevel.READ_COMMITTED,
|
|
115
|
+
): Promise<R> {
|
|
116
|
+
const shared = await getOrBeginActiveTransaction<PostgresAdapterTransaction>()
|
|
117
|
+
if (shared !== undefined) return fn(shared)
|
|
118
|
+
return adapter.transaction(isolationLevel, fn)
|
|
119
|
+
}
|
|
120
|
+
|
package/src/postgres.ts
CHANGED
|
@@ -1,9 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* postgres(config) — Extension factory for @kronos-ts/postgres.
|
|
3
3
|
*
|
|
4
|
-
* Populates
|
|
5
|
-
* - eventStore
|
|
6
|
-
* - snapshotStore
|
|
4
|
+
* Populates five slots:
|
|
5
|
+
* - eventStore : EventStorageEngine via createPostgresEventStore
|
|
6
|
+
* - snapshotStore : SnapshotStore via createPostgresSnapshotStore
|
|
7
|
+
* - transactionManager : postgresTransactionManager(adapter)
|
|
8
|
+
* - unitOfWorkFactory : lazyTransactionalUnitOfWorkFactory(runInNewUoW, tm)
|
|
9
|
+
* - eventScheduler : createPostgresEventScheduler(...) (durable,
|
|
10
|
+
* background worker started in "processors" stage)
|
|
11
|
+
*
|
|
12
|
+
* Setting the last two together is what gives `append() + schedule()` (and
|
|
13
|
+
* any future postgres-extension writer) a SHARED UoW transaction —
|
|
14
|
+
* everything that writes inside one UoW commits or rolls back atomically.
|
|
15
|
+
* Lazy variant chosen so pure-read UoWs (queries, projections that don't
|
|
16
|
+
* write) never claim a pool connection. Users who need different
|
|
17
|
+
* composition (e.g., eager for benchmarking, custom UoW wrapping) can
|
|
18
|
+
* override with `app.forceSet(...)`.
|
|
7
19
|
*
|
|
8
20
|
* Lifecycle (mirrors @kronos-ts/kronosdb extension shape):
|
|
9
21
|
* - app.onStart("connect", ...) — adapter.connect() with withRetry; then
|
|
@@ -11,16 +23,26 @@
|
|
|
11
23
|
* their own migration tooling are not surprised)
|
|
12
24
|
* - app.onStop("connect", ...) — adapter.disconnect()
|
|
13
25
|
*
|
|
14
|
-
* Does NOT populate eventBus, commandBus, queryBus, tokenStore
|
|
15
|
-
*
|
|
26
|
+
* Does NOT populate eventBus, commandBus, queryBus, or tokenStore (out of
|
|
27
|
+
* scope for this extension — postgres token store is a separate package).
|
|
16
28
|
*/
|
|
17
29
|
|
|
18
|
-
import type { App } from "@kronos-ts/app"
|
|
30
|
+
import type { App, KronosComponents } from "@kronos-ts/app"
|
|
19
31
|
import type { ResilienceConfig } from "@kronos-ts/common"
|
|
20
32
|
import { withRetry } from "@kronos-ts/common"
|
|
33
|
+
import {
|
|
34
|
+
lazyTransactionalUnitOfWorkFactory,
|
|
35
|
+
runInNewUoW,
|
|
36
|
+
type TransactionManager,
|
|
37
|
+
} from "@kronos-ts/messaging"
|
|
21
38
|
import type { PostgresAdapter } from "./adapter.js"
|
|
22
39
|
import { createPostgresEventStore } from "./postgres-event-store.js"
|
|
23
40
|
import { createPostgresSnapshotStore } from "./postgres-snapshot-store.js"
|
|
41
|
+
import { postgresTransactionManager } from "./postgres-transaction-manager.js"
|
|
42
|
+
import {
|
|
43
|
+
createPostgresEventScheduler,
|
|
44
|
+
type PostgresEventScheduler,
|
|
45
|
+
} from "./postgres-event-scheduler.js"
|
|
24
46
|
import { bootstrapSchema, DEFAULT_TABLE_NAMES, type TableNames } from "./schema.js"
|
|
25
47
|
|
|
26
48
|
export interface PostgresConfig {
|
|
@@ -34,6 +56,11 @@ export interface PostgresConfig {
|
|
|
34
56
|
/** Retry policy for the initial connect + bootstrap. Defaults to
|
|
35
57
|
* framework defaults via withRetry. */
|
|
36
58
|
readonly resilience?: Partial<ResilienceConfig>
|
|
59
|
+
/** Tuning for the durable scheduler's polling worker. */
|
|
60
|
+
readonly scheduler?: {
|
|
61
|
+
readonly pollIntervalMs?: number
|
|
62
|
+
readonly batchSize?: number
|
|
63
|
+
}
|
|
37
64
|
}
|
|
38
65
|
|
|
39
66
|
export function postgres(config: PostgresConfig): (app: App) => void {
|
|
@@ -48,6 +75,29 @@ export function postgres(config: PostgresConfig): (app: App) => void {
|
|
|
48
75
|
app.set("snapshotStore", ({ serializer }) =>
|
|
49
76
|
createPostgresSnapshotStore({ adapter, serializer, tableNames: tables }),
|
|
50
77
|
)
|
|
78
|
+
app.set("transactionManager", () => postgresTransactionManager(adapter))
|
|
79
|
+
app.set("unitOfWorkFactory", (resolved: KronosComponents) =>
|
|
80
|
+
lazyTransactionalUnitOfWorkFactory(
|
|
81
|
+
runInNewUoW,
|
|
82
|
+
resolved.transactionManager as TransactionManager<unknown>,
|
|
83
|
+
),
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
// Durable scheduler — closure captures the instance so the worker can be
|
|
87
|
+
// start()'d in "processors" and stop()'d in "connect" symmetric to other
|
|
88
|
+
// background workers.
|
|
89
|
+
let scheduler: PostgresEventScheduler | undefined
|
|
90
|
+
app.set("eventScheduler", ({ eventStore, unitOfWorkFactory, tagResolver }) => {
|
|
91
|
+
scheduler = createPostgresEventScheduler({
|
|
92
|
+
adapter,
|
|
93
|
+
eventStore,
|
|
94
|
+
uowFactory: unitOfWorkFactory,
|
|
95
|
+
tagResolver,
|
|
96
|
+
tableNames: tables,
|
|
97
|
+
...config.scheduler,
|
|
98
|
+
})
|
|
99
|
+
return scheduler
|
|
100
|
+
})
|
|
51
101
|
|
|
52
102
|
app.onStart("connect", async () => {
|
|
53
103
|
await withRetry(() => adapter.connect(), { event: "initial-connect", ...resilience })
|
|
@@ -59,7 +109,15 @@ export function postgres(config: PostgresConfig): (app: App) => void {
|
|
|
59
109
|
}
|
|
60
110
|
})
|
|
61
111
|
|
|
112
|
+
// Worker spins up after registration/warmup so all slots are resolved and
|
|
113
|
+
// any user-supplied processors are in place before due schedules start
|
|
114
|
+
// firing into the event store.
|
|
115
|
+
app.onStart("processors", async () => {
|
|
116
|
+
if (scheduler) await scheduler.start()
|
|
117
|
+
})
|
|
118
|
+
|
|
62
119
|
app.onStop("connect", async () => {
|
|
120
|
+
if (scheduler) await scheduler.stop()
|
|
63
121
|
await adapter.disconnect()
|
|
64
122
|
})
|
|
65
123
|
}
|