@arki/journal 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +541 -2
- package/dist/api.d.ts +336 -0
- package/dist/api.d.ts.map +1 -0
- package/dist/api.js +2 -0
- package/dist/api.js.map +1 -0
- package/dist/command.d.ts +27 -0
- package/dist/command.d.ts.map +1 -0
- package/dist/command.js +157 -0
- package/dist/command.js.map +1 -0
- package/dist/debug.d.ts +7 -0
- package/dist/debug.d.ts.map +1 -0
- package/dist/debug.js +8 -0
- package/dist/debug.js.map +1 -0
- package/dist/dot.d.ts +102 -0
- package/dist/dot.d.ts.map +1 -0
- package/dist/dot.js +159 -0
- package/dist/dot.js.map +1 -0
- package/dist/entity.d.ts +56 -0
- package/dist/entity.d.ts.map +1 -0
- package/dist/entity.js +174 -0
- package/dist/entity.js.map +1 -0
- package/dist/errors.d.ts +47 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +112 -0
- package/dist/errors.js.map +1 -0
- package/dist/event.d.ts +9 -0
- package/dist/event.d.ts.map +1 -0
- package/dist/event.js +19 -0
- package/dist/event.js.map +1 -0
- package/dist/fold.d.ts +29 -0
- package/dist/fold.d.ts.map +1 -0
- package/dist/fold.js +49 -0
- package/dist/fold.js.map +1 -0
- package/dist/handlers.d.ts +26 -0
- package/dist/handlers.d.ts.map +1 -0
- package/dist/handlers.js +130 -0
- package/dist/handlers.js.map +1 -0
- package/dist/history.d.ts +49 -0
- package/dist/history.d.ts.map +1 -0
- package/dist/history.js +259 -0
- package/dist/history.js.map +1 -0
- package/dist/hooks.d.ts +14 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +67 -0
- package/dist/hooks.js.map +1 -0
- package/dist/id.d.ts +18 -0
- package/dist/id.d.ts.map +1 -0
- package/dist/id.js +41 -0
- package/dist/id.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/journal.d.ts +51 -0
- package/dist/journal.d.ts.map +1 -0
- package/dist/journal.js +181 -0
- package/dist/journal.js.map +1 -0
- package/dist/listen.d.ts +37 -0
- package/dist/listen.d.ts.map +1 -0
- package/dist/listen.js +90 -0
- package/dist/listen.js.map +1 -0
- package/dist/orpc.d.ts +21 -0
- package/dist/orpc.d.ts.map +1 -0
- package/dist/orpc.js +57 -0
- package/dist/orpc.js.map +1 -0
- package/dist/pg.d.ts +537 -0
- package/dist/pg.d.ts.map +1 -0
- package/dist/pg.js +80 -0
- package/dist/pg.js.map +1 -0
- package/dist/storage.d.ts +52 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +221 -0
- package/dist/storage.js.map +1 -0
- package/dist/testing.d.ts +19 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +23 -0
- package/dist/testing.js.map +1 -0
- package/dist/transaction-guard.d.ts +2 -0
- package/dist/transaction-guard.d.ts.map +1 -0
- package/dist/transaction-guard.js +39 -0
- package/dist/transaction-guard.js.map +1 -0
- package/dist/types.d.ts +414 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/worker.d.ts +109 -0
- package/dist/worker.d.ts.map +1 -0
- package/dist/worker.js +459 -0
- package/dist/worker.js.map +1 -0
- package/dist/write.d.ts +39 -0
- package/dist/write.d.ts.map +1 -0
- package/dist/write.js +287 -0
- package/dist/write.js.map +1 -0
- package/docs/agent-guide.md +324 -0
- package/llms.txt +40 -0
- package/package.json +99 -4
- package/src/api.ts +465 -0
- package/src/command.ts +242 -0
- package/src/debug.ts +8 -0
- package/src/dot.ts +296 -0
- package/src/entity.ts +377 -0
- package/src/errors.ts +134 -0
- package/src/event.ts +26 -0
- package/src/fold.ts +97 -0
- package/src/handlers.ts +262 -0
- package/src/history.ts +390 -0
- package/src/hooks.ts +85 -0
- package/src/id.ts +54 -0
- package/src/index.ts +76 -0
- package/src/journal.ts +256 -0
- package/src/listen.ts +109 -0
- package/src/orpc.ts +59 -0
- package/src/pg.ts +107 -0
- package/src/storage.ts +277 -0
- package/src/test-support.ts +82 -0
- package/src/testing.ts +23 -0
- package/src/transaction-guard.ts +41 -0
- package/src/types.ts +439 -0
- package/src/worker.ts +610 -0
- package/src/write.ts +434 -0
package/src/worker.ts
ADDED
|
@@ -0,0 +1,610 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The async lane: job claims under a lease, the retry policy, `drain` (one batch),
|
|
3
|
+
* the worker loop around it, waking sleeping workers, and counting jobs no
|
|
4
|
+
* effect of this journal can claim.
|
|
5
|
+
* May import `fold`, `history`, `storage` and leaf files.
|
|
6
|
+
* Must not import `journal`, `api`, `write` or `command`.
|
|
7
|
+
*/
|
|
8
|
+
import type { z } from 'zod';
|
|
9
|
+
import { and, asc, desc, eq, gt, gte, inArray, isNotNull, isNull, lte, max, notInArray, or, sql } from 'drizzle-orm';
|
|
10
|
+
|
|
11
|
+
import type { History } from './history.js';
|
|
12
|
+
import type { HookPointInternal } from './hooks.js';
|
|
13
|
+
import type { JournalTables } from './pg.js';
|
|
14
|
+
import type {
|
|
15
|
+
DbHandle,
|
|
16
|
+
Effect,
|
|
17
|
+
EffectHookContext,
|
|
18
|
+
JournalWorker,
|
|
19
|
+
Projection,
|
|
20
|
+
RootDb,
|
|
21
|
+
StrandedJobs,
|
|
22
|
+
Tap,
|
|
23
|
+
TransactionSource,
|
|
24
|
+
Unlisten,
|
|
25
|
+
WakeListener,
|
|
26
|
+
} from './types.js';
|
|
27
|
+
import { debugDrain, debugWorker } from './debug.js';
|
|
28
|
+
import { JournalError } from './errors.js';
|
|
29
|
+
import { findEventDefinition } from './fold.js';
|
|
30
|
+
import { leaseToken } from './id.js';
|
|
31
|
+
import { assertRootDb } from './storage.js';
|
|
32
|
+
|
|
33
|
+
const DEFAULT_LEASE_SECONDS = 60;
|
|
34
|
+
const MAX_ATTEMPTS = 8;
|
|
35
|
+
const BACKOFF_BASE_MS = 1000;
|
|
36
|
+
const BACKOFF_CAP_MS = 300_000;
|
|
37
|
+
const DEFAULT_POLL_MS = 1000;
|
|
38
|
+
|
|
39
|
+
function errorText(error: unknown): string {
|
|
40
|
+
return error instanceof Error ? `${error.name}: ${error.message}` : String(error);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Seconds to wait before the next attempt: doubling with jitter, capped at 5 minutes. */
|
|
44
|
+
function backoffSeconds(attempts: number): number {
|
|
45
|
+
const base = Math.min(BACKOFF_CAP_MS, BACKOFF_BASE_MS * 2 ** Math.max(0, attempts - 1));
|
|
46
|
+
const jitter = base * (Math.random() * 0.5 - 0.25);
|
|
47
|
+
return Math.round(Math.min(BACKOFF_CAP_MS, base + jitter)) / 1000;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Database time plus `seconds`. The worker's clock never decides a lease or a retry. */
|
|
51
|
+
const dbNowPlus = (seconds: number) => sql`now() + make_interval(secs => ${seconds})`;
|
|
52
|
+
|
|
53
|
+
export type JobRow = JournalTables['jobs']['$inferSelect'];
|
|
54
|
+
|
|
55
|
+
/** What `drain` needs from the journal. `Run` is the type of an effect's `ctx.run`. */
|
|
56
|
+
export type DrainDeps<Meta extends z.ZodObject, AppDb extends RootDb, Run> = {
|
|
57
|
+
readonly tables: JournalTables;
|
|
58
|
+
/** The effect hooks. Frozen by the first drain. */
|
|
59
|
+
readonly hooks: HookPointInternal<EffectHookContext<z.output<Meta>>>;
|
|
60
|
+
readonly effects: readonly Effect<z.output<Meta>, AppDb, Run>[];
|
|
61
|
+
readonly history: Pick<History<Meta>, 'decodeStoredEvent'>;
|
|
62
|
+
/** Makes an effect's `ctx.run`: the journal's `run`, bound to the root database this drain was given and to the event the job is for. */
|
|
63
|
+
readonly runOn: (db: AppDb & RootDb, cause: { readonly seq: number; readonly correlationId: string | null }) => Run;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export function createDrain<Meta extends z.ZodObject, AppDb extends RootDb, Run>(deps: DrainDeps<Meta, AppDb, Run>) {
|
|
67
|
+
const { events: eventsTable, jobs: jobsTable } = deps.tables;
|
|
68
|
+
const { decodeStoredEvent } = deps.history;
|
|
69
|
+
const effectsByName = new Map(deps.effects.map(effect => [effect.name, effect]));
|
|
70
|
+
|
|
71
|
+
const known = [...effectsByName.keys()];
|
|
72
|
+
|
|
73
|
+
// Every time here is database time, so a worker with a skewed clock
|
|
74
|
+
// cannot take a live lease or push a retry into the far future.
|
|
75
|
+
const claimOne = (db: AppDb & RootDb, leaseSeconds: number): Promise<JobRow | undefined> =>
|
|
76
|
+
db.transaction(async (tx): Promise<JobRow | undefined> => {
|
|
77
|
+
const due = await tx
|
|
78
|
+
.select({ id: jobsTable.id })
|
|
79
|
+
.from(jobsTable)
|
|
80
|
+
.where(
|
|
81
|
+
and(
|
|
82
|
+
isNull(jobsTable.deadAt),
|
|
83
|
+
lte(jobsTable.availableAt, sql`now()`),
|
|
84
|
+
or(isNull(jobsTable.leaseUntil), lte(jobsTable.leaseUntil, sql`now()`)),
|
|
85
|
+
inArray(jobsTable.effect, known),
|
|
86
|
+
),
|
|
87
|
+
)
|
|
88
|
+
.orderBy(desc(jobsTable.priority), asc(jobsTable.availableAt))
|
|
89
|
+
.limit(1)
|
|
90
|
+
.for('update', { skipLocked: true });
|
|
91
|
+
const first = due[0];
|
|
92
|
+
if (first === undefined) return;
|
|
93
|
+
const [claimed] = await tx
|
|
94
|
+
.update(jobsTable)
|
|
95
|
+
.set({
|
|
96
|
+
leaseToken: leaseToken(),
|
|
97
|
+
leaseUntil: dbNowPlus(leaseSeconds),
|
|
98
|
+
attempts: sql`${jobsTable.attempts} + 1`,
|
|
99
|
+
})
|
|
100
|
+
.where(eq(jobsTable.id, first.id))
|
|
101
|
+
.returning();
|
|
102
|
+
return claimed;
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Keeps the lease alive while the effect runs: every half lease, push `lease_until`
|
|
107
|
+
* out by a full lease, under this worker's token. Zero rows means the lease was
|
|
108
|
+
* lost (the worker stalled past the lease and another one reclaimed the job):
|
|
109
|
+
* the effect's signal aborts so it can stop, and its ack will find nothing to
|
|
110
|
+
* acknowledge. The timer never keeps the process alive.
|
|
111
|
+
*/
|
|
112
|
+
const renewWhile = (db: AppDb & RootDb, job: JobRow, leaseSeconds: number, lost: AbortController) => {
|
|
113
|
+
const token = job.leaseToken ?? '';
|
|
114
|
+
let inFlight = false;
|
|
115
|
+
let finished = false;
|
|
116
|
+
const timer = setInterval(() => {
|
|
117
|
+
if (inFlight) return;
|
|
118
|
+
inFlight = true;
|
|
119
|
+
void db
|
|
120
|
+
.update(jobsTable)
|
|
121
|
+
.set({ leaseUntil: dbNowPlus(leaseSeconds) })
|
|
122
|
+
.where(and(eq(jobsTable.id, job.id), eq(jobsTable.leaseToken, token)))
|
|
123
|
+
.returning({ id: jobsTable.id })
|
|
124
|
+
.then(rows => {
|
|
125
|
+
if (rows.length === 0 && !finished) {
|
|
126
|
+
clearInterval(timer);
|
|
127
|
+
lost.abort(new Error(`journal: the lease on job ${job.id} was lost; another worker holds it now`));
|
|
128
|
+
}
|
|
129
|
+
})
|
|
130
|
+
.catch((error: unknown) =>
|
|
131
|
+
debugDrain.debug('{effect} {jobId}: lease renewal failed: {failed}', {
|
|
132
|
+
effect: job.effect,
|
|
133
|
+
jobId: job.id,
|
|
134
|
+
failed: errorText(error),
|
|
135
|
+
}),
|
|
136
|
+
)
|
|
137
|
+
.finally(() => {
|
|
138
|
+
inFlight = false;
|
|
139
|
+
});
|
|
140
|
+
}, leaseSeconds * 500);
|
|
141
|
+
// Node and Bun return a handle with unref(); browsers and Deno return a number.
|
|
142
|
+
const handle: unknown = timer;
|
|
143
|
+
if (typeof handle === 'object' && handle !== null && 'unref' in handle && typeof handle.unref === 'function') {
|
|
144
|
+
handle.unref();
|
|
145
|
+
}
|
|
146
|
+
return () => {
|
|
147
|
+
finished = true;
|
|
148
|
+
clearInterval(timer);
|
|
149
|
+
};
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const runJob = async (db: AppDb & RootDb, job: JobRow, signal: AbortSignal, leaseSeconds: number): Promise<void> => {
|
|
153
|
+
const effect = effectsByName.get(job.effect);
|
|
154
|
+
const lost = new AbortController();
|
|
155
|
+
const stopRenewing = renewWhile(db, job, leaseSeconds, lost);
|
|
156
|
+
try {
|
|
157
|
+
if (effect === undefined) throw new Error(`effect "${job.effect}" is not registered`);
|
|
158
|
+
const [eventRow] = await db.select().from(eventsTable).where(eq(eventsTable.seq, job.eventSeq));
|
|
159
|
+
if (eventRow === undefined) throw new Error(`event seq ${job.eventSeq} not found`);
|
|
160
|
+
const event = decodeStoredEvent(
|
|
161
|
+
eventRow,
|
|
162
|
+
findEventDefinition(effect.entities, eventRow.kind, eventRow.type),
|
|
163
|
+
eventRow.entityId,
|
|
164
|
+
);
|
|
165
|
+
// Inside the lease: a hook that throws fails the job like a handler failure.
|
|
166
|
+
const ownSignal = AbortSignal.any([signal, lost.signal]);
|
|
167
|
+
await deps.hooks.run({ name: job.effect, event, jobId: job.id, attempt: job.attempts }, () =>
|
|
168
|
+
effect.run(event, { jobId: job.id, signal: ownSignal, db, run: deps.runOn(db, event) }),
|
|
169
|
+
);
|
|
170
|
+
await db.delete(jobsTable).where(and(eq(jobsTable.id, job.id), eq(jobsTable.leaseToken, job.leaseToken ?? '')));
|
|
171
|
+
debugDrain.debug('{effect} {jobId}: done', { effect: job.effect, jobId: job.id });
|
|
172
|
+
} catch (error) {
|
|
173
|
+
const dead = job.attempts >= MAX_ATTEMPTS;
|
|
174
|
+
await db
|
|
175
|
+
.update(jobsTable)
|
|
176
|
+
.set({
|
|
177
|
+
lastError: errorText(error),
|
|
178
|
+
leaseToken: null,
|
|
179
|
+
leaseUntil: null,
|
|
180
|
+
...(dead ? { deadAt: sql`now()` } : { availableAt: dbNowPlus(backoffSeconds(job.attempts)) }),
|
|
181
|
+
})
|
|
182
|
+
.where(and(eq(jobsTable.id, job.id), eq(jobsTable.leaseToken, job.leaseToken ?? '')));
|
|
183
|
+
debugDrain.debug('{effect} {jobId}: attempt {attempts} failed{value4}: {value5}', {
|
|
184
|
+
effect: job.effect,
|
|
185
|
+
jobId: job.id,
|
|
186
|
+
attempts: job.attempts,
|
|
187
|
+
value4: dead ? ', dead' : '',
|
|
188
|
+
value5: errorText(error),
|
|
189
|
+
});
|
|
190
|
+
} finally {
|
|
191
|
+
stopRenewing();
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
return async (
|
|
196
|
+
db: AppDb & RootDb,
|
|
197
|
+
opts?: { readonly batchSize?: number; readonly signal?: AbortSignal; readonly leaseSeconds?: number },
|
|
198
|
+
): Promise<number> => {
|
|
199
|
+
assertRootDb(db, 'drain');
|
|
200
|
+
deps.hooks.freeze();
|
|
201
|
+
const batchSize = opts?.batchSize ?? 20;
|
|
202
|
+
const leaseSeconds = opts?.leaseSeconds ?? DEFAULT_LEASE_SECONDS;
|
|
203
|
+
const signal = opts?.signal ?? new AbortController().signal;
|
|
204
|
+
if (known.length === 0) return 0;
|
|
205
|
+
|
|
206
|
+
// One job is claimed at a time, right before it runs, so its lease starts
|
|
207
|
+
// when its work starts. Claiming the whole batch up front would start every
|
|
208
|
+
// lease at once: while the first jobs ran, the leases of the last ones
|
|
209
|
+
// could run out, and another worker would take them and run them twice.
|
|
210
|
+
// An aborted signal stops the claiming; the job already running finishes.
|
|
211
|
+
let claimed = 0;
|
|
212
|
+
while (claimed < batchSize && !signal.aborted) {
|
|
213
|
+
const job = await claimOne(db, leaseSeconds);
|
|
214
|
+
if (job === undefined) break;
|
|
215
|
+
claimed += 1;
|
|
216
|
+
await runJob(db, job, signal, leaseSeconds);
|
|
217
|
+
}
|
|
218
|
+
return claimed;
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Counts pending jobs whose effect this journal does not have. `drain` never
|
|
224
|
+
* claims them, on purpose: during a rolling deploy an older worker may still
|
|
225
|
+
* have that effect. But once every worker runs the new code, a renamed or
|
|
226
|
+
* removed effect leaves its jobs here for good, so they must be visible.
|
|
227
|
+
*/
|
|
228
|
+
export function createStrandedJobs(deps: { readonly tables: JournalTables; readonly effectNames: readonly string[] }) {
|
|
229
|
+
const { jobs: jobsTable } = deps.tables;
|
|
230
|
+
return async (db: DbHandle): Promise<readonly StrandedJobs[]> =>
|
|
231
|
+
db
|
|
232
|
+
.select({ effect: jobsTable.effect, jobs: sql<number>`count(*)::int` })
|
|
233
|
+
.from(jobsTable)
|
|
234
|
+
.where(
|
|
235
|
+
and(
|
|
236
|
+
isNull(jobsTable.deadAt),
|
|
237
|
+
deps.effectNames.length > 0 ? notInArray(jobsTable.effect, [...deps.effectNames]) : undefined,
|
|
238
|
+
),
|
|
239
|
+
)
|
|
240
|
+
.groupBy(jobsTable.effect)
|
|
241
|
+
.orderBy(asc(jobsTable.effect));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** The job verbs an operator uses: list, retry a dead job, discard a dead job. */
|
|
245
|
+
export function createAdmin(deps: { readonly tables: JournalTables; readonly wake: () => void }) {
|
|
246
|
+
const { jobs: jobsTable } = deps.tables;
|
|
247
|
+
|
|
248
|
+
const jobs = (
|
|
249
|
+
db: DbHandle,
|
|
250
|
+
opts?: { readonly status?: 'pending' | 'dead' | 'all'; readonly effect?: string; readonly limit?: number },
|
|
251
|
+
): Promise<JobRow[]> => {
|
|
252
|
+
const status = opts?.status ?? 'pending';
|
|
253
|
+
return db
|
|
254
|
+
.select()
|
|
255
|
+
.from(jobsTable)
|
|
256
|
+
.where(
|
|
257
|
+
and(
|
|
258
|
+
status === 'pending' ? isNull(jobsTable.deadAt) : status === 'dead' ? isNotNull(jobsTable.deadAt) : undefined,
|
|
259
|
+
opts?.effect === undefined ? undefined : eq(jobsTable.effect, opts.effect),
|
|
260
|
+
),
|
|
261
|
+
)
|
|
262
|
+
.orderBy(asc(jobsTable.availableAt))
|
|
263
|
+
.limit(opts?.limit ?? 100);
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
/** Dead to pending, in one statement: attempts and lease cleared, due now. Wakes the workers. */
|
|
267
|
+
const retry = async (db: DbHandle, jobId: string): Promise<void> => {
|
|
268
|
+
const rows = await db
|
|
269
|
+
.update(jobsTable)
|
|
270
|
+
.set({ deadAt: null, attempts: 0, lastError: null, leaseToken: null, leaseUntil: null, availableAt: sql`now()` })
|
|
271
|
+
.where(and(eq(jobsTable.id, jobId), isNotNull(jobsTable.deadAt)))
|
|
272
|
+
.returning({ id: jobsTable.id });
|
|
273
|
+
if (rows.length === 0)
|
|
274
|
+
throw new Error(`journal: job ${JSON.stringify(jobId)} is not a dead job; only a dead job is retried`);
|
|
275
|
+
deps.wake();
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
/** Delete a dead job. A live one is never deleted here: that would not stop its work, only lose the record of it. */
|
|
279
|
+
const discard = async (db: DbHandle, jobId: string): Promise<void> => {
|
|
280
|
+
const rows = await db
|
|
281
|
+
.delete(jobsTable)
|
|
282
|
+
.where(and(eq(jobsTable.id, jobId), isNotNull(jobsTable.deadAt)))
|
|
283
|
+
.returning({ id: jobsTable.id });
|
|
284
|
+
if (rows.length === 0)
|
|
285
|
+
throw new Error(`journal: job ${JSON.stringify(jobId)} is not a dead job; only a dead job is discarded`);
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
return { jobs, retry, discard };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** What the worker loop needs from the journal. */
|
|
292
|
+
export type WorkerDeps<AppDb extends RootDb> = {
|
|
293
|
+
/** Runs one batch. The journal passes its own `drain` method, looked up on each call. */
|
|
294
|
+
readonly drain: (
|
|
295
|
+
db: AppDb & RootDb,
|
|
296
|
+
opts?: { readonly batchSize?: number; readonly signal?: AbortSignal; readonly leaseSeconds?: number },
|
|
297
|
+
) => Promise<number>;
|
|
298
|
+
/** One batch per async projection. Returns how many events were scanned. */
|
|
299
|
+
readonly catchUp: (
|
|
300
|
+
db: AppDb & RootDb,
|
|
301
|
+
opts?: { readonly batchSize?: number },
|
|
302
|
+
) => Promise<{ readonly scanned: number }>;
|
|
303
|
+
/** Fire the taps for one wake payload. */
|
|
304
|
+
readonly dispatch: (db: AppDb & RootDb, payload: string) => Promise<void>;
|
|
305
|
+
readonly tapNames: readonly string[];
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
/** What the async lane of projections needs from the journal. */
|
|
309
|
+
export type CatchUpDeps<Meta extends z.ZodObject, Tx extends TransactionSource<Tx>> = {
|
|
310
|
+
readonly tables: JournalTables;
|
|
311
|
+
readonly projections: readonly Projection<z.output<Meta>, Tx>[];
|
|
312
|
+
readonly history: Pick<History<Meta>, 'decodeStoredEvent'>;
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
const DEFAULT_CATCH_UP_BATCH = 100;
|
|
316
|
+
|
|
317
|
+
export function createCatchUp<Meta extends z.ZodObject, Tx extends TransactionSource<Tx>>(deps: CatchUpDeps<Meta, Tx>) {
|
|
318
|
+
const { events: eventsTable, cursors: cursorsTable } = deps.tables;
|
|
319
|
+
const { decodeStoredEvent } = deps.history;
|
|
320
|
+
const byName = new Map(deps.projections.map(projection => [projection.name, projection]));
|
|
321
|
+
|
|
322
|
+
/** The cursor row, created at 0 if missing, locked for this transaction. */
|
|
323
|
+
const lockCursor = async (tx: DbHandle, name: string): Promise<number> => {
|
|
324
|
+
await tx.insert(cursorsTable).values({ name, seq: 0 }).onConflictDoNothing();
|
|
325
|
+
const [row] = await tx
|
|
326
|
+
.select({ seq: cursorsTable.seq })
|
|
327
|
+
.from(cursorsTable)
|
|
328
|
+
.where(eq(cursorsTable.name, name))
|
|
329
|
+
.for('update');
|
|
330
|
+
if (row === undefined) throw new Error(`journal: cursor "${name}" vanished while locked`);
|
|
331
|
+
return row.seq;
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* One batch for one projection: lock its cursor, scan the next events in seq order,
|
|
336
|
+
* apply the matching ones, move the cursor to the last event scanned, commit. The
|
|
337
|
+
* cursor is global scan progress, so lag reaches zero and two workers cannot apply
|
|
338
|
+
* one batch twice: the second waits on the row lock and starts after the first moved it.
|
|
339
|
+
*/
|
|
340
|
+
const batchFor = async (db: TransactionSource<Tx>, projection: Projection<z.output<Meta>, Tx>, batchSize: number) =>
|
|
341
|
+
db.transaction(async tx => {
|
|
342
|
+
const from = await lockCursor(tx, projection.name);
|
|
343
|
+
const rows = await tx
|
|
344
|
+
.select()
|
|
345
|
+
.from(eventsTable)
|
|
346
|
+
.where(gt(eventsTable.seq, from))
|
|
347
|
+
.orderBy(asc(eventsTable.seq))
|
|
348
|
+
.limit(batchSize);
|
|
349
|
+
const last = rows.at(-1);
|
|
350
|
+
if (last === undefined) return 0;
|
|
351
|
+
const kinds = new Set(projection.entities.map(entity => entity.kind));
|
|
352
|
+
for (const row of rows) {
|
|
353
|
+
if (!kinds.has(row.kind) || !projection.types.has(row.type)) continue;
|
|
354
|
+
await projection.run(
|
|
355
|
+
tx,
|
|
356
|
+
decodeStoredEvent(row, findEventDefinition(projection.entities, row.kind, row.type), row.entityId),
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
await tx
|
|
360
|
+
.update(cursorsTable)
|
|
361
|
+
.set({ seq: last.seq, updatedAt: sql`now()` })
|
|
362
|
+
.where(eq(cursorsTable.name, projection.name));
|
|
363
|
+
return rows.length;
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
const catchUp = async (
|
|
367
|
+
db: RootDb & TransactionSource<Tx>,
|
|
368
|
+
opts?: { readonly batchSize?: number },
|
|
369
|
+
): Promise<{ readonly scanned: number }> => {
|
|
370
|
+
assertRootDb(db, 'catchUp');
|
|
371
|
+
const batchSize = opts?.batchSize ?? DEFAULT_CATCH_UP_BATCH;
|
|
372
|
+
let scanned = 0;
|
|
373
|
+
for (const projection of deps.projections) {
|
|
374
|
+
// One projection's failure leaves the others running; its batch rolled back and runs again next pass.
|
|
375
|
+
try {
|
|
376
|
+
scanned += await batchFor(db, projection, batchSize);
|
|
377
|
+
} catch (error) {
|
|
378
|
+
debugDrain.debug('projection {projectionName}: batch failed: {failed}', {
|
|
379
|
+
projectionName: projection.name,
|
|
380
|
+
failed: errorText(error),
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
return { scanned };
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
const reset = async (
|
|
388
|
+
db: RootDb & TransactionSource<Tx>,
|
|
389
|
+
projection: Projection<z.output<Meta>, Tx>,
|
|
390
|
+
clear?: (tx: Tx) => Promise<void>,
|
|
391
|
+
): Promise<void> => {
|
|
392
|
+
assertRootDb(db, 'reset');
|
|
393
|
+
if (!byName.has(projection.name))
|
|
394
|
+
throw new JournalError(
|
|
395
|
+
'BAD_DEFINITION',
|
|
396
|
+
'projection',
|
|
397
|
+
projection.name,
|
|
398
|
+
`journal: "${projection.name}" is not an async projection of this journal`,
|
|
399
|
+
);
|
|
400
|
+
await db.transaction(async (tx: Tx) => {
|
|
401
|
+
// The lock first, so no batch lands between the clearing and the rewind.
|
|
402
|
+
await lockCursor(tx, projection.name);
|
|
403
|
+
await clear?.(tx);
|
|
404
|
+
await tx
|
|
405
|
+
.update(cursorsTable)
|
|
406
|
+
.set({ seq: 0, updatedAt: sql`now()` })
|
|
407
|
+
.where(eq(cursorsTable.name, projection.name));
|
|
408
|
+
});
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
const cursors = async (
|
|
412
|
+
db: DbHandle,
|
|
413
|
+
): Promise<readonly { readonly name: string; readonly seq: number; readonly lag: number }[]> => {
|
|
414
|
+
const [top] = await db.select({ seq: max(eventsTable.seq) }).from(eventsTable);
|
|
415
|
+
const latest = top?.seq ?? 0;
|
|
416
|
+
const rows = await db.select().from(cursorsTable);
|
|
417
|
+
const stored = new Map(rows.map(row => [row.name, row.seq]));
|
|
418
|
+
return deps.projections.map(projection => {
|
|
419
|
+
const seq = stored.get(projection.name) ?? 0;
|
|
420
|
+
return { name: projection.name, seq, lag: Math.max(0, latest - seq) };
|
|
421
|
+
});
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
return { catchUp, reset, cursors };
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** What tap dispatch needs from the journal. */
|
|
428
|
+
export type TapDispatchDeps<Meta extends z.ZodObject, AppDb extends RootDb> = {
|
|
429
|
+
readonly tables: Pick<JournalTables, 'events'>;
|
|
430
|
+
readonly taps: readonly Tap<z.output<Meta>, AppDb>[];
|
|
431
|
+
readonly history: Pick<History<Meta>, 'decodeStoredEvent'>;
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
/** A wake payload: the seq range one commit recorded. Anything else is ignored. */
|
|
435
|
+
const parseRange = (payload: string): { readonly from: number; readonly to: number } | undefined => {
|
|
436
|
+
try {
|
|
437
|
+
const parsed: unknown = JSON.parse(payload);
|
|
438
|
+
if (typeof parsed !== 'object' || parsed === null || !('from' in parsed) || !('to' in parsed)) return undefined;
|
|
439
|
+
const { from, to } = parsed;
|
|
440
|
+
if (typeof from !== 'number' || typeof to !== 'number' || !Number.isInteger(from) || !Number.isInteger(to))
|
|
441
|
+
return undefined;
|
|
442
|
+
return from <= to ? { from, to } : undefined;
|
|
443
|
+
} catch {
|
|
444
|
+
return undefined;
|
|
445
|
+
}
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
export function createTapDispatch<Meta extends z.ZodObject, AppDb extends RootDb>(deps: TapDispatchDeps<Meta, AppDb>) {
|
|
449
|
+
const eventsTable = deps.tables.events;
|
|
450
|
+
const kinds = [...new Set(deps.taps.flatMap(tap => tap.entities.map(entity => entity.kind)))];
|
|
451
|
+
const types = [...new Set(deps.taps.flatMap(tap => [...tap.types]))];
|
|
452
|
+
|
|
453
|
+
/** Load the committed events in the range that some tap wants, and run each matching tap. Errors are logged, never thrown. */
|
|
454
|
+
const dispatch = async (db: AppDb & RootDb, payload: string): Promise<void> => {
|
|
455
|
+
if (deps.taps.length === 0) return;
|
|
456
|
+
const range = parseRange(payload);
|
|
457
|
+
if (range === undefined) return;
|
|
458
|
+
const rows = await db
|
|
459
|
+
.select()
|
|
460
|
+
.from(eventsTable)
|
|
461
|
+
.where(
|
|
462
|
+
and(
|
|
463
|
+
gte(eventsTable.seq, range.from),
|
|
464
|
+
lte(eventsTable.seq, range.to),
|
|
465
|
+
inArray(eventsTable.kind, kinds),
|
|
466
|
+
inArray(eventsTable.type, types),
|
|
467
|
+
),
|
|
468
|
+
)
|
|
469
|
+
.orderBy(asc(eventsTable.seq));
|
|
470
|
+
for (const row of rows) {
|
|
471
|
+
for (const tap of deps.taps) {
|
|
472
|
+
if (!tap.types.has(row.type) || !tap.entities.some(entity => entity.kind === row.kind)) continue;
|
|
473
|
+
try {
|
|
474
|
+
const event = deps.history.decodeStoredEvent(
|
|
475
|
+
row,
|
|
476
|
+
findEventDefinition(tap.entities, row.kind, row.type),
|
|
477
|
+
row.entityId,
|
|
478
|
+
);
|
|
479
|
+
await tap.run(event, { db });
|
|
480
|
+
} catch (error) {
|
|
481
|
+
debugDrain.debug('tap {tapName}: {rowType} {entityId} failed: {failed}', {
|
|
482
|
+
tapName: tap.name,
|
|
483
|
+
rowType: row.type,
|
|
484
|
+
entityId: row.entityId,
|
|
485
|
+
failed: errorText(error),
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
};
|
|
491
|
+
return dispatch;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const quiet = (): void => {
|
|
495
|
+
/* a dispatch failure was logged where it happened */
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
export function createWorkers<AppDb extends RootDb>(deps: WorkerDeps<AppDb>) {
|
|
499
|
+
/** One wake function per running worker this journal started. */
|
|
500
|
+
const wakers = new Set<() => void>();
|
|
501
|
+
|
|
502
|
+
const worker = (
|
|
503
|
+
db: AppDb & RootDb,
|
|
504
|
+
opts?: {
|
|
505
|
+
readonly pollMs?: number;
|
|
506
|
+
readonly batchSize?: number;
|
|
507
|
+
readonly leaseSeconds?: number;
|
|
508
|
+
readonly listen?: WakeListener;
|
|
509
|
+
readonly taps?: boolean;
|
|
510
|
+
},
|
|
511
|
+
): JournalWorker => {
|
|
512
|
+
assertRootDb(db, 'worker');
|
|
513
|
+
if (deps.tapNames.length > 0 && opts?.listen === undefined && opts?.taps !== false)
|
|
514
|
+
throw new Error(
|
|
515
|
+
`journal: this journal has taps (${deps.tapNames.join(', ')}) but the worker has no \`listen\`; taps fire only from the wake channel. Wire LISTEN on journal.wakeChannel, or pass { taps: false } for a worker that runs jobs only`,
|
|
516
|
+
);
|
|
517
|
+
const pollMs = opts?.pollMs ?? DEFAULT_POLL_MS;
|
|
518
|
+
let unlisten: Unlisten | undefined;
|
|
519
|
+
// Wake payloads dispatch one after another, in the order they arrived.
|
|
520
|
+
let dispatching: Promise<void> = Promise.resolve();
|
|
521
|
+
let running = false;
|
|
522
|
+
let loop: Promise<void> = Promise.resolve();
|
|
523
|
+
// Aborted by `stop()`. It reaches the running effect as `ctx.signal`, and
|
|
524
|
+
// `drain` claims no further job once it is aborted.
|
|
525
|
+
let stopping = new AbortController();
|
|
526
|
+
let endSleep: (() => void) | undefined;
|
|
527
|
+
// A wake that arrived during a pass. That pass may have looked before the
|
|
528
|
+
// commit that caused the wake, so one more pass follows it.
|
|
529
|
+
let woken = false;
|
|
530
|
+
const sleep = () =>
|
|
531
|
+
new Promise<void>(resolve => {
|
|
532
|
+
const timer = setTimeout(() => {
|
|
533
|
+
endSleep = undefined;
|
|
534
|
+
resolve();
|
|
535
|
+
}, pollMs);
|
|
536
|
+
endSleep = () => {
|
|
537
|
+
clearTimeout(timer);
|
|
538
|
+
endSleep = undefined;
|
|
539
|
+
resolve();
|
|
540
|
+
};
|
|
541
|
+
});
|
|
542
|
+
// `stop()` and `wake()` flip these from another call while `run` is awaiting.
|
|
543
|
+
const isRunning = () => running;
|
|
544
|
+
const isWoken = () => woken;
|
|
545
|
+
const run = async () => {
|
|
546
|
+
while (isRunning()) {
|
|
547
|
+
woken = false;
|
|
548
|
+
let processed = 0;
|
|
549
|
+
try {
|
|
550
|
+
processed = await deps.drain(db, {
|
|
551
|
+
batchSize: opts?.batchSize,
|
|
552
|
+
signal: stopping.signal,
|
|
553
|
+
leaseSeconds: opts?.leaseSeconds,
|
|
554
|
+
});
|
|
555
|
+
if (isRunning()) {
|
|
556
|
+
const caught = await deps.catchUp(db);
|
|
557
|
+
processed += caught.scanned;
|
|
558
|
+
}
|
|
559
|
+
} catch (error) {
|
|
560
|
+
debugWorker.debug('drain failed: {failed}', { failed: errorText(error) });
|
|
561
|
+
}
|
|
562
|
+
if (isRunning() && processed === 0 && !isWoken()) await sleep();
|
|
563
|
+
}
|
|
564
|
+
};
|
|
565
|
+
const wake = () => {
|
|
566
|
+
if (!running) return;
|
|
567
|
+
woken = true;
|
|
568
|
+
endSleep?.();
|
|
569
|
+
};
|
|
570
|
+
return {
|
|
571
|
+
async start() {
|
|
572
|
+
if (running) return;
|
|
573
|
+
running = true;
|
|
574
|
+
stopping = new AbortController();
|
|
575
|
+
wakers.add(wake);
|
|
576
|
+
if (opts?.listen !== undefined) {
|
|
577
|
+
unlisten = await opts.listen(payload => {
|
|
578
|
+
wake();
|
|
579
|
+
if (opts.taps === false) return;
|
|
580
|
+
dispatching = dispatching.then(() => deps.dispatch(db, payload)).catch(quiet);
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
debugWorker.debug('start (poll {pollMs}ms{value2})', {
|
|
584
|
+
pollMs,
|
|
585
|
+
value2: unlisten === undefined ? '' : ', listening',
|
|
586
|
+
});
|
|
587
|
+
loop = run();
|
|
588
|
+
},
|
|
589
|
+
async stop() {
|
|
590
|
+
running = false;
|
|
591
|
+
stopping.abort();
|
|
592
|
+
// eslint-disable-next-line drizzle/enforce-delete-with-where -- Set#delete, not a Drizzle query
|
|
593
|
+
wakers.delete(wake);
|
|
594
|
+
endSleep?.();
|
|
595
|
+
await unlisten?.();
|
|
596
|
+
unlisten = undefined;
|
|
597
|
+
await dispatching;
|
|
598
|
+
await loop;
|
|
599
|
+
debugWorker.debug('stopped');
|
|
600
|
+
},
|
|
601
|
+
wake,
|
|
602
|
+
};
|
|
603
|
+
};
|
|
604
|
+
|
|
605
|
+
const wakeAll = (): void => {
|
|
606
|
+
for (const wake of wakers) wake();
|
|
607
|
+
};
|
|
608
|
+
|
|
609
|
+
return { worker, wakeAll };
|
|
610
|
+
}
|