@nylorun/runtime 0.12.0-beta → 0.14.0-beta
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/CHANGELOG.md +361 -0
- package/README.md +69 -15
- package/dist/adapters/execution/restate.d.ts +7 -2
- package/dist/adapters/execution/restate.js +38 -8
- package/dist/adapters/replication/pgoutput.d.ts +16 -0
- package/dist/adapters/replication/pgoutput.js +169 -0
- package/dist/adapters/streams/s2.js +37 -20
- package/dist/admin-openapi.json +1071 -0
- package/dist/api/a2a/card.d.ts +15 -0
- package/dist/api/a2a/card.js +24 -0
- package/dist/api/a2a/endpoint.d.ts +8 -0
- package/dist/api/a2a/endpoint.js +81 -0
- package/dist/api/a2a/protocol.d.ts +144 -0
- package/dist/api/a2a/protocol.js +188 -0
- package/dist/api/a2a/routes.d.ts +19 -0
- package/dist/api/a2a/routes.js +289 -0
- package/dist/api/a2a/tasks.d.ts +50 -0
- package/dist/api/a2a/tasks.js +186 -0
- package/dist/api/ag-ui/endpoint.d.ts +8 -0
- package/dist/api/ag-ui/endpoint.js +126 -0
- package/dist/api/ag-ui/history.d.ts +7 -0
- package/dist/api/ag-ui/history.js +60 -0
- package/dist/api/ag-ui/routes.d.ts +32 -0
- package/dist/api/ag-ui/routes.js +305 -0
- package/dist/api/ag-ui/session-id.d.ts +1 -0
- package/dist/api/ag-ui/session-id.js +12 -0
- package/dist/api/ag-ui/sse.d.ts +9 -0
- package/dist/api/ag-ui/sse.js +7 -0
- package/dist/api/ag-ui/translate.d.ts +38 -0
- package/dist/api/ag-ui/translate.js +222 -0
- package/dist/api/components.d.ts +7825 -0
- package/dist/api/components.js +83 -0
- package/dist/api/http/app.d.ts +18 -0
- package/dist/api/http/app.js +58 -0
- package/dist/api/http/body.d.ts +4 -0
- package/dist/api/http/body.js +37 -0
- package/dist/api/http/define.d.ts +39 -0
- package/dist/api/http/define.js +130 -0
- package/dist/api/http/respond.d.ts +9 -0
- package/dist/api/http/respond.js +44 -0
- package/dist/api/http/routes/access.d.ts +24 -0
- package/dist/api/http/routes/access.js +259 -0
- package/dist/api/http/routes/actions.d.ts +8 -0
- package/dist/api/http/routes/actions.js +76 -0
- package/dist/api/http/routes/endpoints.d.ts +7 -0
- package/dist/api/http/routes/endpoints.js +100 -0
- package/dist/api/http/routes/sessions.d.ts +3 -0
- package/dist/api/http/routes/sessions.js +205 -0
- package/dist/api/http/routes/tenant.d.ts +10 -0
- package/dist/api/http/routes/tenant.js +172 -0
- package/dist/api/http/routes/vaults.d.ts +8 -0
- package/dist/api/http/routes/vaults.js +167 -0
- package/dist/api/openapi.d.ts +19 -0
- package/dist/api/openapi.js +138 -0
- package/dist/api/route.d.ts +12 -0
- package/dist/api/route.js +4 -0
- package/dist/core/bearer.d.ts +8 -0
- package/dist/core/bearer.js +11 -0
- package/dist/core/flow-host.d.ts +7 -15
- package/dist/core/flow-host.js +19 -41
- package/dist/core/sandbox-routes.d.ts +12 -7
- package/dist/core/sandbox-routes.js +24 -25
- package/dist/execution/memory.d.ts +8 -5
- package/dist/execution/memory.js +33 -13
- package/dist/execution/types.d.ts +38 -6
- package/dist/execution/types.js +12 -3
- package/dist/host/admin-api.d.ts +22 -0
- package/dist/host/admin-api.js +178 -0
- package/dist/host/app.d.ts +34 -0
- package/dist/host/app.js +185 -0
- package/dist/host/config-for.d.ts +2 -0
- package/dist/host/config-for.js +1 -0
- package/dist/host/config.d.ts +11 -0
- package/dist/host/cors.d.ts +27 -0
- package/dist/host/cors.js +64 -0
- package/dist/host/create-host.d.ts +31 -3
- package/dist/host/create-host.js +168 -327
- package/dist/host/http.d.ts +14 -3
- package/dist/host/http.js +64 -23
- package/dist/host/main.js +55 -4
- package/dist/host/stack-config.d.ts +20 -0
- package/dist/host/stack-config.js +55 -0
- package/dist/mcp/pool.d.ts +19 -1
- package/dist/mcp/pool.js +60 -10
- package/dist/model/catalog.d.ts +2 -10
- package/dist/model/classify.d.ts +13 -0
- package/dist/model/classify.js +61 -0
- package/dist/model/models.d.ts +10 -1
- package/dist/model/models.js +14 -6
- package/dist/model/pi-model.d.ts +17 -2
- package/dist/model/pi-model.js +366 -222
- package/dist/openapi.json +25696 -0
- package/dist/sandbox/manager.d.ts +13 -1
- package/dist/sandbox/manager.js +8 -11
- package/dist/sandbox/resolve.d.ts +33 -0
- package/dist/sandbox/resolve.js +81 -0
- package/dist/sandbox/session-sandbox.d.ts +11 -0
- package/dist/sandbox/session-sandbox.js +51 -0
- package/dist/sandbox/share.d.ts +14 -2
- package/dist/sandbox/share.js +33 -3
- package/dist/sandbox/tenant-config.d.ts +37 -0
- package/dist/sandbox/tenant-config.js +51 -0
- package/dist/sandbox/tools.d.ts +7 -9
- package/dist/sandbox/tools.js +11 -3
- package/dist/sandbox/types.d.ts +8 -2
- package/dist/store/event.d.ts +19 -0
- package/dist/store/event.js +49 -0
- package/dist/store/memory.d.ts +20 -8
- package/dist/store/memory.js +265 -100
- package/dist/store/postgres/connect.d.ts +13 -0
- package/dist/store/postgres/connect.js +5 -0
- package/dist/store/postgres/migrations/003_subject_tokens.d.ts +8 -0
- package/dist/store/postgres/migrations/003_subject_tokens.js +42 -0
- package/dist/store/postgres/migrations/004_publishable_keys.d.ts +6 -0
- package/dist/store/postgres/migrations/004_publishable_keys.js +18 -0
- package/dist/store/postgres/migrations/005_action_endpoints.d.ts +6 -0
- package/dist/store/postgres/migrations/005_action_endpoints.js +32 -0
- package/dist/store/postgres/migrations/006_drop_executors.d.ts +11 -0
- package/dist/store/postgres/migrations/006_drop_executors.js +32 -0
- package/dist/store/postgres/migrations/007_event_time.d.ts +7 -0
- package/dist/store/postgres/migrations/007_event_time.js +14 -0
- package/dist/store/postgres/migrations/008_durable_streams.d.ts +13 -0
- package/dist/store/postgres/migrations/008_durable_streams.js +35 -0
- package/dist/store/postgres/migrations/index.d.ts +5 -1
- package/dist/store/postgres/migrations/index.js +22 -2
- package/dist/store/postgres/migrations/shared/index.d.ts +31 -0
- package/dist/store/postgres/migrations/shared/index.js +92 -0
- package/dist/store/postgres/record.d.ts +11 -0
- package/dist/store/postgres/record.js +64 -0
- package/dist/store/postgres/store.js +337 -95
- package/dist/store/postgres/tenants.js +14 -0
- package/dist/store/types.d.ts +176 -81
- package/dist/streams/basin.d.ts +18 -1
- package/dist/streams/basin.js +34 -2
- package/dist/streams/control.d.ts +18 -0
- package/dist/streams/control.js +29 -0
- package/dist/streams/memory.d.ts +3 -1
- package/dist/streams/memory.js +14 -8
- package/dist/streams/relay/core.d.ts +35 -0
- package/dist/streams/relay/core.js +366 -0
- package/dist/streams/relay/memory.d.ts +69 -0
- package/dist/streams/relay/memory.js +176 -0
- package/dist/streams/relay/types.d.ts +77 -0
- package/dist/streams/relay/types.js +7 -0
- package/dist/streams/types.d.ts +66 -36
- package/dist/streams/types.js +18 -53
- package/dist/tenant/access-policy.d.ts +30 -0
- package/dist/tenant/access-policy.js +52 -0
- package/dist/tenant/actions.d.ts +10 -149
- package/dist/tenant/actions.js +5 -167
- package/dist/tenant/advance.js +32 -0
- package/dist/tenant/auth.d.ts +19 -17
- package/dist/tenant/auth.js +90 -93
- package/dist/tenant/browser.d.ts +15 -0
- package/dist/tenant/browser.js +39 -0
- package/dist/tenant/commands.d.ts +18 -2
- package/dist/tenant/commands.js +121 -129
- package/dist/tenant/context.d.ts +75 -18
- package/dist/tenant/context.js +14 -10
- package/dist/tenant/delivery-token.d.ts +41 -0
- package/dist/tenant/delivery-token.js +105 -0
- package/dist/tenant/delivery.d.ts +62 -0
- package/dist/tenant/delivery.js +476 -0
- package/dist/tenant/effects.d.ts +5 -0
- package/dist/tenant/effects.js +138 -48
- package/dist/tenant/endpoints.d.ts +67 -0
- package/dist/tenant/endpoints.js +90 -0
- package/dist/tenant/ephemeral.d.ts +6 -0
- package/dist/tenant/ephemeral.js +60 -36
- package/dist/tenant/http.d.ts +8 -9
- package/dist/tenant/http.js +13 -27
- package/dist/tenant/jwt.d.ts +16 -0
- package/dist/tenant/jwt.js +50 -0
- package/dist/tenant/module.d.ts +3 -0
- package/dist/tenant/module.js +6 -12
- package/dist/tenant/outbound.d.ts +44 -0
- package/dist/tenant/outbound.js +127 -0
- package/dist/tenant/pool.d.ts +0 -2
- package/dist/tenant/pool.js +0 -18
- package/dist/tenant/reset.d.ts +0 -4
- package/dist/tenant/reset.js +0 -5
- package/dist/tenant/runtime.d.ts +10 -20
- package/dist/tenant/runtime.js +59 -42
- package/dist/tenant/scheduler.d.ts +7 -2
- package/dist/tenant/scheduler.js +7 -3
- package/dist/tenant/session-streams.d.ts +125 -0
- package/dist/tenant/session-streams.js +399 -0
- package/dist/tenant/sessions.d.ts +28 -4
- package/dist/tenant/sessions.js +118 -24
- package/dist/tenant/signing-keys.d.ts +55 -0
- package/dist/tenant/signing-keys.js +172 -0
- package/dist/tenant/slim.d.ts +8 -0
- package/dist/tenant/slim.js +21 -0
- package/dist/tenant/status.d.ts +0 -3
- package/dist/tenant/status.js +19 -12
- package/dist/tenant/streams.d.ts +60 -67
- package/dist/tenant/streams.js +140 -208
- package/dist/tenant/subject-limits.d.ts +16 -0
- package/dist/tenant/subject-limits.js +45 -0
- package/dist/tenant/sweep.d.ts +1 -10
- package/dist/tenant/sweep.js +29 -69
- package/dist/tenant/tokens.d.ts +14 -0
- package/dist/tenant/tokens.js +178 -0
- package/dist/tenant/transcript.d.ts +48 -5
- package/dist/tenant/transcript.js +58 -1
- package/dist/tenant/types.d.ts +29 -7
- package/dist/tenant/worker.d.ts +3 -1
- package/dist/tenant/worker.js +9 -3
- package/dist/vault/service.d.ts +18 -2
- package/dist/vault/service.js +57 -13
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +20 -8
- package/dist/core/executors.d.ts +0 -44
- package/dist/core/executors.js +0 -95
- package/dist/streams/relay.d.ts +0 -37
- package/dist/streams/relay.js +0 -254
- package/dist/tenant/live.d.ts +0 -92
- package/dist/tenant/live.js +0 -290
- package/dist/tenant/routes-tenant.d.ts +0 -12
- package/dist/tenant/routes-tenant.js +0 -137
- package/dist/tenant/routes.d.ts +0 -9
- package/dist/tenant/routes.js +0 -151
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Durable Session Execution on Restate (architecture §12.3).
|
|
3
3
|
*
|
|
4
|
-
* The only module that imports the Restate SDK. It serves
|
|
4
|
+
* The only module that imports the Restate SDK. It serves four virtual
|
|
5
5
|
* objects on the Worker endpoint, all named with `servicePrefix`:
|
|
6
6
|
*
|
|
7
7
|
* - `<prefix>NylorunSession`, key `<tenantId>:<sessionId>`. Its exclusive
|
|
@@ -17,9 +17,14 @@
|
|
|
17
17
|
* - `<prefix>NylorunTimer`, key `<tenantId>:<timer key>`. `set` records the
|
|
18
18
|
* latest time and sends a delayed `fire`; a `fire` for an older time is a
|
|
19
19
|
* no-op, which is how setting a key again replaces it.
|
|
20
|
+
* - `<prefix>NylorunAction`, key `<tenantId>:<actionId>`. Its exclusive
|
|
21
|
+
* `deliver` handler calls `WorkerHandlers.deliver`. Like `advance`, it keeps
|
|
22
|
+
* no state and makes no `ctx.run`: its only journal entry is the delayed
|
|
23
|
+
* self-send after a `retry` result, so an endpoint that is down never spends
|
|
24
|
+
* the retry budget, which is for infrastructure errors.
|
|
20
25
|
*
|
|
21
|
-
* Wakes, timers and sweep arming go through the ingress as one-way
|
|
22
|
-
* an API node can call them without ever calling `start`.
|
|
26
|
+
* Wakes, deliveries, timers and sweep arming go through the ingress as one-way
|
|
27
|
+
* sends, so an API node can call them without ever calling `start`.
|
|
23
28
|
*/
|
|
24
29
|
import { createServer } from "node:http2";
|
|
25
30
|
import * as restate from "@restatedev/restate-sdk";
|
|
@@ -49,6 +54,7 @@ export class RestateExecution {
|
|
|
49
54
|
session: `${prefix}NylorunSession`,
|
|
50
55
|
tenant: `${prefix}NylorunTenant`,
|
|
51
56
|
timer: `${prefix}NylorunTimer`,
|
|
57
|
+
action: `${prefix}NylorunAction`,
|
|
52
58
|
};
|
|
53
59
|
this.ingressUrl = options.ingressUrl.replace(/\/+$/, "");
|
|
54
60
|
this.adminUrl = options.adminUrl.replace(/\/+$/, "");
|
|
@@ -63,6 +69,9 @@ export class RestateExecution {
|
|
|
63
69
|
throw new Error(`Unknown wake reason: ${String(wake.reason)}`);
|
|
64
70
|
await this.send(this.names.session, sessionKey(tenantId, sessionId), "advance", { reason: wake.reason }, wake.dedupeKey);
|
|
65
71
|
}
|
|
72
|
+
async deliver(tenantId, actionId) {
|
|
73
|
+
await this.send(this.names.action, sessionKey(tenantId, actionId), "deliver", {});
|
|
74
|
+
}
|
|
66
75
|
async timer(tenantId, key, at) {
|
|
67
76
|
await this.send(this.names.timer, sessionKey(tenantId, key), "set", {
|
|
68
77
|
at: at.getTime(),
|
|
@@ -161,7 +170,7 @@ export class RestateExecution {
|
|
|
161
170
|
handlers: {
|
|
162
171
|
advance: async (ctx, _wake) => {
|
|
163
172
|
const { tenantId, sessionId } = parseSessionKey(ctx.key);
|
|
164
|
-
const result = await this.
|
|
173
|
+
const result = await this.runAborted(ctx.request().attemptCompletedSignal, (handlers, signal) => handlers.advance(tenantId, sessionId, signal));
|
|
165
174
|
if (result.status === "busy")
|
|
166
175
|
ctx
|
|
167
176
|
.objectSendClient({ name: names.session }, ctx.key)
|
|
@@ -232,10 +241,31 @@ export class RestateExecution {
|
|
|
232
241
|
},
|
|
233
242
|
options: serviceOptions,
|
|
234
243
|
});
|
|
235
|
-
|
|
244
|
+
const action = restate.object({
|
|
245
|
+
name: names.action,
|
|
246
|
+
handlers: {
|
|
247
|
+
deliver: async (ctx, _input) => {
|
|
248
|
+
const { tenantId, sessionId: actionId } = parseSessionKey(ctx.key);
|
|
249
|
+
const result = await this.runAborted(ctx.request().attemptCompletedSignal, (handlers, signal) => {
|
|
250
|
+
if (!handlers.deliver)
|
|
251
|
+
throw new Error("WorkerHandlers.deliver is required for deliveries");
|
|
252
|
+
return handlers.deliver(tenantId, actionId, signal);
|
|
253
|
+
});
|
|
254
|
+
if (result.status === "retry")
|
|
255
|
+
ctx
|
|
256
|
+
.objectSendClient({ name: names.action }, ctx.key)
|
|
257
|
+
.deliver({}, restate.rpc.sendOpts({ delay: Math.max(0, result.retryAfterMs) }));
|
|
258
|
+
},
|
|
259
|
+
},
|
|
260
|
+
options: serviceOptions,
|
|
261
|
+
});
|
|
262
|
+
return [session, tenant, timer, action];
|
|
236
263
|
}
|
|
237
|
-
/**
|
|
238
|
-
|
|
264
|
+
/**
|
|
265
|
+
* Runs a Worker handler with a signal aborted by `stop` or by the end of the Restate
|
|
266
|
+
* attempt (`advance` and `deliver`).
|
|
267
|
+
*/
|
|
268
|
+
async runAborted(attemptDone, fn) {
|
|
239
269
|
const controller = new AbortController();
|
|
240
270
|
const onAttemptDone = () => controller.abort(new Error("Restate attempt ended"));
|
|
241
271
|
if (attemptDone.aborted)
|
|
@@ -335,7 +365,7 @@ export async function listStuckInvocations(options) {
|
|
|
335
365
|
const prefix = options.servicePrefix ?? "";
|
|
336
366
|
if (!/^[A-Za-z0-9_]*$/.test(prefix))
|
|
337
367
|
throw new Error(`Invalid Restate service prefix: ${prefix}`);
|
|
338
|
-
const services = ["NylorunSession", "NylorunTenant", "NylorunTimer"].map((name) => `'${prefix}${name}'`);
|
|
368
|
+
const services = ["NylorunSession", "NylorunTenant", "NylorunTimer", "NylorunAction"].map((name) => `'${prefix}${name}'`);
|
|
339
369
|
const clauses = [
|
|
340
370
|
`target_service_name IN (${services.join(", ")})`,
|
|
341
371
|
`status IN ('paused', 'backing-off')`,
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { ChangeSource } from "../../streams/relay/types.js";
|
|
2
|
+
export interface PgoutputSourceOptions {
|
|
3
|
+
/** A Postgres URL whose role may replicate (`REPLICATION`) and read `nylorun_streams`. */
|
|
4
|
+
connectionString: string;
|
|
5
|
+
/** Default `nylorun_stream_relay`. */
|
|
6
|
+
slot?: string;
|
|
7
|
+
/** Default `nylorun_stream_relay`. */
|
|
8
|
+
publication?: string;
|
|
9
|
+
/** The schema of the record tables. Default `nylorun_streams`. */
|
|
10
|
+
schema?: string;
|
|
11
|
+
/** Wait between attempts to take the slot. Default 5 s. */
|
|
12
|
+
retryMs?: number;
|
|
13
|
+
log?: (message: string, fields?: Record<string, unknown>) => void;
|
|
14
|
+
}
|
|
15
|
+
export declare const DEFAULT_RELAY_SLOT = "nylorun_stream_relay";
|
|
16
|
+
export declare function createPgoutputSource(options: PgoutputSourceOptions): ChangeSource;
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The stream relay's change source on Postgres logical replication (Durable Streams §7.1,
|
|
3
|
+
* §7.4, §7.6): a persistent `pgoutput` slot on the `nylorun_stream_relay` publication, read
|
|
4
|
+
* with `pg-logical-replication`. The only file importing it (with `pg`, which it needs).
|
|
5
|
+
*
|
|
6
|
+
* - **One reader per slot.** Postgres lets one connection hold a slot, so every Runtime
|
|
7
|
+
* process can run a source and exactly one is active; the others retry every few seconds
|
|
8
|
+
* and take over when it goes away. The slot is the leader election.
|
|
9
|
+
* - **Acknowledged only on request.** The library acknowledges the last position it
|
|
10
|
+
* *received* on a timer; that is turned off (`timeoutSeconds: 0`) and keepalives are
|
|
11
|
+
* answered by the relay, which acknowledges only what S2 has.
|
|
12
|
+
* - **Always from the confirmed position.** Each attempt is a new connection that starts at
|
|
13
|
+
* `0/0`, which Postgres reads as the slot's confirmed position, so a reconnect replays
|
|
14
|
+
* whatever was received but not acknowledged.
|
|
15
|
+
* - **Fresh slots.** A missing slot is created; a slot Postgres invalidated
|
|
16
|
+
* (`max_slot_wal_keep_size`) is dropped and created again. Either way the relay must
|
|
17
|
+
* reconcile the record with S2; that is recorded in `relay_slots` until `reconciled()`, so a
|
|
18
|
+
* crash during reconciliation, or another process winning the slot, does not lose it.
|
|
19
|
+
*/
|
|
20
|
+
import pg from "pg";
|
|
21
|
+
import { LogicalReplicationService, PgoutputPlugin } from "pg-logical-replication";
|
|
22
|
+
export const DEFAULT_RELAY_SLOT = "nylorun_stream_relay";
|
|
23
|
+
export function createPgoutputSource(options) {
|
|
24
|
+
const slot = options.slot ?? DEFAULT_RELAY_SLOT;
|
|
25
|
+
const publication = options.publication ?? "nylorun_stream_relay";
|
|
26
|
+
const schema = options.schema ?? "nylorun_streams";
|
|
27
|
+
const retryMs = options.retryMs ?? 5000;
|
|
28
|
+
const log = options.log ?? (() => { });
|
|
29
|
+
const pool = new pg.Pool({
|
|
30
|
+
connectionString: options.connectionString,
|
|
31
|
+
max: 2,
|
|
32
|
+
application_name: "nylorun-stream-relay",
|
|
33
|
+
});
|
|
34
|
+
let service;
|
|
35
|
+
let stopped = false;
|
|
36
|
+
let running;
|
|
37
|
+
let wake;
|
|
38
|
+
/** Creates the slot when missing or lost; true when the record must be reconciled. */
|
|
39
|
+
async function prepareSlot() {
|
|
40
|
+
const client = await pool.connect();
|
|
41
|
+
try {
|
|
42
|
+
const { rows } = await client.query("SELECT wal_status, active FROM pg_replication_slots WHERE slot_name = $1", [slot]);
|
|
43
|
+
const existing = rows[0];
|
|
44
|
+
if (existing?.wal_status === "lost" && !existing.active) {
|
|
45
|
+
log("replication slot was invalidated; creating it again", { slot });
|
|
46
|
+
await client.query("SELECT pg_drop_replication_slot($1)", [slot]);
|
|
47
|
+
}
|
|
48
|
+
if (!existing || existing.wal_status === "lost") {
|
|
49
|
+
try {
|
|
50
|
+
await client.query("SELECT pg_create_logical_replication_slot($1, 'pgoutput')", [slot]);
|
|
51
|
+
log("replication slot created", { slot });
|
|
52
|
+
await client.query(`INSERT INTO ${schema}.relay_slots (slot_name, reconcile_pending) VALUES ($1, true)
|
|
53
|
+
ON CONFLICT (slot_name) DO UPDATE SET reconcile_pending = true`, [slot]);
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
// Another process created it first; its row says whether to reconcile.
|
|
57
|
+
if (error.code !== "42710")
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const pending = await client.query(`SELECT reconcile_pending FROM ${schema}.relay_slots WHERE slot_name = $1`, [slot]);
|
|
62
|
+
// A slot created before this table knew it: reconcile once to be sure.
|
|
63
|
+
return pending.rows[0]?.reconcile_pending ?? true;
|
|
64
|
+
}
|
|
65
|
+
finally {
|
|
66
|
+
client.release();
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
async function attempt(handlers) {
|
|
70
|
+
const fresh = await prepareSlot();
|
|
71
|
+
// `stop()` during `prepareSlot` had no service to stop: do not start one it cannot.
|
|
72
|
+
if (stopped)
|
|
73
|
+
return;
|
|
74
|
+
const current = new LogicalReplicationService({ connectionString: options.connectionString, application_name: "nylorun-stream-relay" }, { acknowledge: { auto: false, timeoutSeconds: 0 }, flowControl: { enabled: false } });
|
|
75
|
+
service = current;
|
|
76
|
+
let decoding;
|
|
77
|
+
current.on("start", () => handlers.onActive({ fresh }));
|
|
78
|
+
current.on("data", (_lsn, message) => {
|
|
79
|
+
switch (message.tag) {
|
|
80
|
+
case "begin":
|
|
81
|
+
decoding = { rows: [] };
|
|
82
|
+
break;
|
|
83
|
+
case "insert":
|
|
84
|
+
if (decoding &&
|
|
85
|
+
message.relation?.schema === schema &&
|
|
86
|
+
message.relation?.name === "session_events")
|
|
87
|
+
decoding.rows.push(rowOf(message.new));
|
|
88
|
+
break;
|
|
89
|
+
case "commit": {
|
|
90
|
+
const tx = { endLsn: message.commitEndLsn, rows: decoding?.rows ?? [] };
|
|
91
|
+
decoding = undefined;
|
|
92
|
+
// A transaction with no record rows still moves the slot once acknowledged.
|
|
93
|
+
handlers.onTx(tx);
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
current.on("heartbeat", (lsn, _timestamp, shouldRespond) => {
|
|
99
|
+
// Mid-transaction, the server's position may be past rows not yet delivered.
|
|
100
|
+
if (!shouldRespond || decoding)
|
|
101
|
+
return;
|
|
102
|
+
const position = handlers.onKeepalive(lsn);
|
|
103
|
+
if (position)
|
|
104
|
+
void current.acknowledge(position).catch(() => { });
|
|
105
|
+
});
|
|
106
|
+
current.on("error", () => { });
|
|
107
|
+
const plugin = new PgoutputPlugin({ protoVersion: 1, publicationNames: [publication] });
|
|
108
|
+
// Resolves when replication ends, rejects when it fails; `0/0` = the slot's confirmed position.
|
|
109
|
+
await current.subscribe(plugin, slot, "0/00000000");
|
|
110
|
+
}
|
|
111
|
+
async function run(handlers) {
|
|
112
|
+
while (!stopped) {
|
|
113
|
+
let failure;
|
|
114
|
+
try {
|
|
115
|
+
await attempt(handlers);
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
failure = error;
|
|
119
|
+
}
|
|
120
|
+
if (stopped)
|
|
121
|
+
break;
|
|
122
|
+
const busy = failure?.code === "55006";
|
|
123
|
+
// Another process holds the slot: it is the relay. Anything else: report and retry.
|
|
124
|
+
handlers.onInactive(busy ? undefined : failure ?? new Error("replication ended"));
|
|
125
|
+
await new Promise((resolve) => {
|
|
126
|
+
wake = resolve;
|
|
127
|
+
setTimeout(resolve, retryMs).unref?.();
|
|
128
|
+
});
|
|
129
|
+
wake = undefined;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
start(handlers) {
|
|
134
|
+
if (running)
|
|
135
|
+
throw new Error("The change source is already started");
|
|
136
|
+
running = run(handlers);
|
|
137
|
+
},
|
|
138
|
+
acknowledge(lsn) {
|
|
139
|
+
void service?.acknowledge(lsn).catch(() => { });
|
|
140
|
+
},
|
|
141
|
+
async reconciled() {
|
|
142
|
+
await pool.query(`UPDATE ${schema}.relay_slots SET reconcile_pending = false WHERE slot_name = $1`, [
|
|
143
|
+
slot,
|
|
144
|
+
]);
|
|
145
|
+
},
|
|
146
|
+
async lag() {
|
|
147
|
+
const { rows } = await pool.query(`SELECT pg_wal_lsn_diff(pg_current_wal_lsn(), confirmed_flush_lsn)::text AS lag
|
|
148
|
+
FROM pg_replication_slots WHERE slot_name = $1`, [slot]);
|
|
149
|
+
return rows[0]?.lag == null ? undefined : Number(rows[0].lag);
|
|
150
|
+
},
|
|
151
|
+
async stop() {
|
|
152
|
+
stopped = true;
|
|
153
|
+
wake?.();
|
|
154
|
+
await service?.stop().catch(() => undefined);
|
|
155
|
+
await running?.catch(() => undefined);
|
|
156
|
+
await pool.end().catch(() => undefined);
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
/** A decoded `session_events` row (`pg` parses `json`; `bigint` arrives as text). */
|
|
161
|
+
function rowOf(values) {
|
|
162
|
+
return {
|
|
163
|
+
tenantId: values.tenant_id,
|
|
164
|
+
sessionId: values.session_id,
|
|
165
|
+
seq: Number(values.seq),
|
|
166
|
+
generation: Number(values.generation),
|
|
167
|
+
body: typeof values.body === "string" ? JSON.parse(values.body) : values.body,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
@@ -5,12 +5,12 @@
|
|
|
5
5
|
*
|
|
6
6
|
* ## Layout
|
|
7
7
|
*
|
|
8
|
-
* - One basin per Tenant, named by `tenantBasinName` (`streams/basin.ts`):
|
|
9
|
-
* `<basinPrefix>tn-<ulid>` for a Tenant id `tn_<ulid
|
|
10
|
-
* - The basin is created with `createStreamOnAppend`, so
|
|
11
|
-
* `
|
|
12
|
-
*
|
|
13
|
-
*
|
|
8
|
+
* - One basin per Tenant basin generation, named by `tenantBasinName` (`streams/basin.ts`):
|
|
9
|
+
* `<basinPrefix>tn-<ulid>` for a Tenant id `tn_<ulid>`, `…-<g base36>` for generation g.
|
|
10
|
+
* - The basin is created with `createStreamOnAppend` and `createStreamOnRead`, so
|
|
11
|
+
* `sessions/<id>` and `tenant/control` come into existence on first use. Its
|
|
12
|
+
* default stream config sets infinite retention, which session streams
|
|
13
|
+
* keep; the control stream is created with a one-day
|
|
14
14
|
* age-based retention instead (signals are latency hints, not history).
|
|
15
15
|
* - Bodies are JSON text in string records. Record timestamps are S2 arrival
|
|
16
16
|
* times.
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
* explicit `{ infinite: {} }` retention to keep session history; this adapter
|
|
28
28
|
* sets it as the basin default, and s2-lite honours it (a new stream reports
|
|
29
29
|
* `infinite`). A `streamConfig` sent with an append that creates the stream
|
|
30
|
-
* is honoured too (used for the
|
|
30
|
+
* is honoured too (used for the control stream). `deleteOnEmpty`
|
|
31
31
|
* defaults to disabled (`minAgeSecs: 0`).
|
|
32
32
|
* - **Stream deletion** is immediate for our purposes: right after `DELETE`,
|
|
33
33
|
* `checkTail` and reads return 404 `stream_not_found` (new read sessions
|
|
@@ -58,9 +58,9 @@
|
|
|
58
58
|
* use the SDK's HTTP/2 (s2s) transport, which s2-lite serves over cleartext.
|
|
59
59
|
*/
|
|
60
60
|
import { AppendInput, AppendRecord, RangeNotSatisfiableError, S2, S2Error, SeqNumMismatchError, } from "@s2-dev/streamstore";
|
|
61
|
-
import { tenantBasinName, validateBasinPrefix } from "../../streams/basin.js";
|
|
62
|
-
import { CONTROL_STREAM,
|
|
63
|
-
/** Retention of the
|
|
61
|
+
import { parseBasin, tenantBasinName, validateBasinPrefix } from "../../streams/basin.js";
|
|
62
|
+
import { CONTROL_STREAM, } from "../../streams/types.js";
|
|
63
|
+
/** Retention of the control signal stream. */
|
|
64
64
|
const SIGNAL_RETENTION_SECS = 24 * 60 * 60;
|
|
65
65
|
/** Records per unary read while reading history. */
|
|
66
66
|
const HISTORY_PAGE = 1000;
|
|
@@ -99,7 +99,7 @@ class S2Streams {
|
|
|
99
99
|
return AppendRecord.string({ body });
|
|
100
100
|
}), {
|
|
101
101
|
...(options.matchSeq !== undefined ? { matchSeqNum: options.matchSeq } : {}),
|
|
102
|
-
...(stream ===
|
|
102
|
+
...(stream === CONTROL_STREAM
|
|
103
103
|
? { streamConfig: { retentionPolicy: { ageSecs: SIGNAL_RETENTION_SECS } } }
|
|
104
104
|
: {}),
|
|
105
105
|
});
|
|
@@ -155,6 +155,9 @@ class S2Streams {
|
|
|
155
155
|
basin,
|
|
156
156
|
config: {
|
|
157
157
|
createStreamOnAppend: true,
|
|
158
|
+
// A reader that arrives before a session's first event creates the
|
|
159
|
+
// stream and follows it live, instead of polling until it exists.
|
|
160
|
+
createStreamOnRead: true,
|
|
158
161
|
defaultStreamConfig: { retentionPolicy: { infinite: {} } },
|
|
159
162
|
},
|
|
160
163
|
}, { signal: this.closing.signal });
|
|
@@ -171,15 +174,24 @@ class S2Streams {
|
|
|
171
174
|
}
|
|
172
175
|
}
|
|
173
176
|
}
|
|
174
|
-
async deleteTenant(tenantId) {
|
|
177
|
+
async deleteTenant(tenantId, options = {}) {
|
|
175
178
|
this.checkOpen();
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
179
|
+
const basins = [this.basinName(tenantId)];
|
|
180
|
+
// Every later generation's basin too: `<basin>-<g base36>`.
|
|
181
|
+
if (options.allGenerations && parseBasin(tenantId).generation === 0) {
|
|
182
|
+
const later = new RegExp(`^${escapeRegExp(basins[0])}-[0-9a-z]{1,2}$`);
|
|
183
|
+
for await (const info of this.s2.basins.listAll({ prefix: `${basins[0]}-` }, { signal: this.closing.signal }))
|
|
184
|
+
if (later.test(info.name) && info.deletedAt == null)
|
|
185
|
+
basins.push(info.name);
|
|
182
186
|
}
|
|
187
|
+
for (const basin of basins)
|
|
188
|
+
try {
|
|
189
|
+
await this.s2.basins.delete({ basin }, { signal: this.closing.signal });
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
if (!isMissing(error))
|
|
193
|
+
throw error;
|
|
194
|
+
}
|
|
183
195
|
}
|
|
184
196
|
async deleteStream(tenantId, stream) {
|
|
185
197
|
this.checkOpen();
|
|
@@ -256,7 +268,9 @@ class S2Streams {
|
|
|
256
268
|
/**
|
|
257
269
|
* Follows `stream` from `fromSeq` with S2 read sessions, reopening a session
|
|
258
270
|
* from the next sequence whenever one ends or fails, until aborted, closed,
|
|
259
|
-
* or the Tenant's basin is gone.
|
|
271
|
+
* or the Tenant's basin is gone. Basins create a stream on read, so a reader
|
|
272
|
+
* that arrives before the first append waits on the live session instead of
|
|
273
|
+
* polling.
|
|
260
274
|
*/
|
|
261
275
|
async *readLive(tenantId, stream, fromSeq, signal) {
|
|
262
276
|
const aborted = anySignal(signal, this.closing.signal);
|
|
@@ -288,7 +302,7 @@ class S2Streams {
|
|
|
288
302
|
if ((isMissing(error) || isCode(error, "stream_deletion_pending")) &&
|
|
289
303
|
(await this.basinGone(tenantId, aborted)))
|
|
290
304
|
return;
|
|
291
|
-
//
|
|
305
|
+
// S2 unreachable, or a stream being deleted: retry below.
|
|
292
306
|
}
|
|
293
307
|
await sleep(delay, aborted);
|
|
294
308
|
delay = Math.min(delay * 2, RETRY_MAX_MS);
|
|
@@ -343,6 +357,9 @@ function isTransactionConflict(error) {
|
|
|
343
357
|
function isTenantGone(error) {
|
|
344
358
|
return isCode(error, "basin_not_found") || isCode(error, "basin_deletion_pending");
|
|
345
359
|
}
|
|
360
|
+
function escapeRegExp(text) {
|
|
361
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
362
|
+
}
|
|
346
363
|
function anySignal(...signals) {
|
|
347
364
|
return AbortSignal.any(signals.filter((s) => s !== undefined));
|
|
348
365
|
}
|