@streamotter/gateway 0.1.0-rc.1
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 +158 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +17 -0
- package/dist/index.js.map +1 -0
- package/dist/internals.d.ts +6 -0
- package/dist/internals.d.ts.map +1 -0
- package/dist/internals.js +6 -0
- package/dist/internals.js.map +1 -0
- package/dist/management/index.d.ts +26 -0
- package/dist/management/index.d.ts.map +1 -0
- package/dist/management/index.js +354 -0
- package/dist/management/index.js.map +1 -0
- package/dist/runtime/budget.d.ts +24 -0
- package/dist/runtime/budget.d.ts.map +1 -0
- package/dist/runtime/budget.js +56 -0
- package/dist/runtime/budget.js.map +1 -0
- package/dist/runtime/core.d.ts +67 -0
- package/dist/runtime/core.d.ts.map +1 -0
- package/dist/runtime/core.js +34 -0
- package/dist/runtime/core.js.map +1 -0
- package/dist/runtime/gateway.d.ts +117 -0
- package/dist/runtime/gateway.d.ts.map +1 -0
- package/dist/runtime/gateway.js +881 -0
- package/dist/runtime/gateway.js.map +1 -0
- package/dist/runtime/identity.d.ts +28 -0
- package/dist/runtime/identity.d.ts.map +1 -0
- package/dist/runtime/identity.js +92 -0
- package/dist/runtime/identity.js.map +1 -0
- package/dist/runtime/session.d.ts +49 -0
- package/dist/runtime/session.d.ts.map +1 -0
- package/dist/runtime/session.js +299 -0
- package/dist/runtime/session.js.map +1 -0
- package/dist/runtime/subscription.d.ts +65 -0
- package/dist/runtime/subscription.d.ts.map +1 -0
- package/dist/runtime/subscription.js +482 -0
- package/dist/runtime/subscription.js.map +1 -0
- package/dist/runtime/traces.d.ts +26 -0
- package/dist/runtime/traces.d.ts.map +1 -0
- package/dist/runtime/traces.js +98 -0
- package/dist/runtime/traces.js.map +1 -0
- package/dist/runtime/util.d.ts +50 -0
- package/dist/runtime/util.d.ts.map +1 -0
- package/dist/runtime/util.js +148 -0
- package/dist/runtime/util.js.map +1 -0
- package/dist/sources/fixture.d.ts +27 -0
- package/dist/sources/fixture.d.ts.map +1 -0
- package/dist/sources/fixture.js +89 -0
- package/dist/sources/fixture.js.map +1 -0
- package/dist/sources/kafka.d.ts +63 -0
- package/dist/sources/kafka.d.ts.map +1 -0
- package/dist/sources/kafka.js +418 -0
- package/dist/sources/kafka.js.map +1 -0
- package/dist/sources/kafkajs-patch.d.ts +11 -0
- package/dist/sources/kafkajs-patch.d.ts.map +1 -0
- package/dist/sources/kafkajs-patch.js +34 -0
- package/dist/sources/kafkajs-patch.js.map +1 -0
- package/dist/sources/types.d.ts +46 -0
- package/dist/sources/types.d.ts.map +1 -0
- package/dist/sources/types.js +2 -0
- package/dist/sources/types.js.map +1 -0
- package/dist/transport/socketio.d.ts +44 -0
- package/dist/transport/socketio.d.ts.map +1 -0
- package/dist/transport/socketio.js +80 -0
- package/dist/transport/socketio.js.map +1 -0
- package/dist/transport/types.d.ts +10 -0
- package/dist/transport/types.d.ts.map +1 -0
- package/dist/transport/types.js +2 -0
- package/dist/transport/types.js.map +1 -0
- package/package.json +61 -0
- package/src/index.ts +20 -0
- package/src/internals.ts +5 -0
- package/src/management/index.ts +371 -0
- package/src/runtime/budget.ts +60 -0
- package/src/runtime/core.ts +101 -0
- package/src/runtime/gateway.ts +892 -0
- package/src/runtime/identity.ts +99 -0
- package/src/runtime/session.ts +329 -0
- package/src/runtime/subscription.ts +531 -0
- package/src/runtime/traces.ts +102 -0
- package/src/runtime/util.ts +157 -0
- package/src/sources/fixture.ts +95 -0
- package/src/sources/kafka.ts +440 -0
- package/src/sources/kafkajs-patch.ts +41 -0
- package/src/sources/types.ts +42 -0
- package/src/transport/socketio.ts +125 -0
- package/src/transport/types.ts +10 -0
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
import {
|
|
2
|
+
compareRevisions, isJsonValue, isPlainObject, isRevision, streamError, validateValue,
|
|
3
|
+
type ErrorCode, type Params, type Revision, type StreamEvent, type SubscriptionState
|
|
4
|
+
} from "@streamotter/contracts";
|
|
5
|
+
import { SubscriptionBudget } from "./budget.ts";
|
|
6
|
+
import { routingKey, type ChannelRuntime, type GatewayCore, type SubscriptionHost } from "./core.ts";
|
|
7
|
+
import { describeError, invokeHandler, newId, nowIso, sha256Hex } from "./util.ts";
|
|
8
|
+
|
|
9
|
+
/** A full-state frame waiting in a subscription's buffer or send queue. */
|
|
10
|
+
export interface PendingFrame {
|
|
11
|
+
readonly event: StreamEvent;
|
|
12
|
+
readonly bytes: number;
|
|
13
|
+
readonly revision: Revision;
|
|
14
|
+
readonly dataHash: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Envelope overhead added to serialized events when estimating frame bytes. */
|
|
18
|
+
export const FRAME_OVERHEAD_BYTES = 128;
|
|
19
|
+
|
|
20
|
+
export type AdmitResult = "queued" | "filtered" | "overflow" | "inactive";
|
|
21
|
+
|
|
22
|
+
type Phase =
|
|
23
|
+
| "idle"
|
|
24
|
+
| "authorizing" // authorize handler pending
|
|
25
|
+
| "waiting-source" // stale; waiting for the source to become ready (no snapshot calls)
|
|
26
|
+
| "capturing" // registered for live capture; snapshot pending; updates buffered
|
|
27
|
+
| "snapshot-sent" // snapshot in flight; updates still buffered
|
|
28
|
+
| "draining" // releasing buffered updates up to the captured boundary
|
|
29
|
+
| "live"
|
|
30
|
+
| "backoff" // stale; retry scheduled
|
|
31
|
+
| "resync-required"
|
|
32
|
+
| "terminated";
|
|
33
|
+
|
|
34
|
+
const CAPTURING: ReadonlySet<Phase> = new Set(["capturing", "snapshot-sent", "draining", "live"]);
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Server side of one subscription. Implements the V1 synchronization sequence:
|
|
38
|
+
* authorize → require source → capture → snapshot → recheck → send snapshot →
|
|
39
|
+
* receipt → drain buffered newer states → live. Each attempt is a new epoch;
|
|
40
|
+
* invalidation discards the previous generation's frames and handler results.
|
|
41
|
+
*/
|
|
42
|
+
export class ServerSubscription {
|
|
43
|
+
readonly id: string;
|
|
44
|
+
readonly channel: ChannelRuntime;
|
|
45
|
+
readonly params: Params;
|
|
46
|
+
readonly canonicalParams: string;
|
|
47
|
+
readonly routingKey: string;
|
|
48
|
+
readonly contractKey: string;
|
|
49
|
+
readonly budget: SubscriptionBudget;
|
|
50
|
+
readonly #host: SubscriptionHost;
|
|
51
|
+
readonly #core: GatewayCore;
|
|
52
|
+
|
|
53
|
+
#phase: Phase = "idle";
|
|
54
|
+
#publicState: SubscriptionState = "idle";
|
|
55
|
+
#epoch = "";
|
|
56
|
+
#sequence = 0;
|
|
57
|
+
#generation = 0;
|
|
58
|
+
#controller = new AbortController();
|
|
59
|
+
#attempts = 0;
|
|
60
|
+
#retryTimer: NodeJS.Timeout | null = null;
|
|
61
|
+
#queue: PendingFrame[] = [];
|
|
62
|
+
#inFlight: (PendingFrame & { sequence: number }) | null = null;
|
|
63
|
+
#receiptTimer: NodeJS.Timeout | null = null;
|
|
64
|
+
#drainRemaining = 0;
|
|
65
|
+
#snapshotRevision: Revision | null = null;
|
|
66
|
+
#tail: { revision: Revision; dataHash: string } | null = null;
|
|
67
|
+
#lastDelivered: Revision | null = null;
|
|
68
|
+
|
|
69
|
+
constructor(options: {
|
|
70
|
+
id: string;
|
|
71
|
+
channel: ChannelRuntime;
|
|
72
|
+
params: Params;
|
|
73
|
+
canonicalParams: string;
|
|
74
|
+
host: SubscriptionHost;
|
|
75
|
+
core: GatewayCore;
|
|
76
|
+
}) {
|
|
77
|
+
this.id = options.id;
|
|
78
|
+
this.channel = options.channel;
|
|
79
|
+
this.params = options.params;
|
|
80
|
+
this.canonicalParams = options.canonicalParams;
|
|
81
|
+
this.#host = options.host;
|
|
82
|
+
this.#core = options.core;
|
|
83
|
+
this.routingKey = routingKey(this.channel.name, this.channel.version, this.#host.principal.tenantId, this.canonicalParams);
|
|
84
|
+
this.contractKey = JSON.stringify([this.channel.name, this.channel.version, this.canonicalParams]);
|
|
85
|
+
const { limits } = this.#core;
|
|
86
|
+
this.budget = new SubscriptionBudget(limits.maxPendingFramesPerSubscription, limits.maxPendingBytesPerSubscription, this.#host.connectionBudget);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
get state(): SubscriptionState {
|
|
90
|
+
return this.#publicState;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
get phase(): string {
|
|
94
|
+
return this.#phase;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
get epoch(): string {
|
|
98
|
+
return this.#epoch;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
get terminated(): boolean {
|
|
102
|
+
return this.#phase === "terminated";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
get capturing(): boolean {
|
|
106
|
+
return CAPTURING.has(this.#phase);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
start(requestId: string): void {
|
|
110
|
+
this.channel.subscriptions.add(this);
|
|
111
|
+
void this.#beginAttempt(false, requestId);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Client-requested resynchronization. Coalesces with a synchronization that has
|
|
116
|
+
* not yet sent its snapshot (the client will still see a new epoch). Once the
|
|
117
|
+
* client has seen this epoch's frames, a request starts a new epoch.
|
|
118
|
+
*/
|
|
119
|
+
requestResync(requestId: string): void {
|
|
120
|
+
switch (this.#phase) {
|
|
121
|
+
case "backoff":
|
|
122
|
+
case "snapshot-sent":
|
|
123
|
+
case "draining":
|
|
124
|
+
void this.#beginAttempt(true, requestId);
|
|
125
|
+
return;
|
|
126
|
+
case "live":
|
|
127
|
+
case "resync-required":
|
|
128
|
+
this.#attempts = 0;
|
|
129
|
+
void this.#beginAttempt(true, requestId);
|
|
130
|
+
return;
|
|
131
|
+
default:
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
onSourceUnavailable(reason: ErrorCode): void {
|
|
137
|
+
switch (this.#phase) {
|
|
138
|
+
case "authorizing":
|
|
139
|
+
case "capturing":
|
|
140
|
+
case "snapshot-sent":
|
|
141
|
+
case "draining":
|
|
142
|
+
case "live":
|
|
143
|
+
case "backoff":
|
|
144
|
+
this.#invalidate();
|
|
145
|
+
this.#phase = "waiting-source";
|
|
146
|
+
this.#sendState("stale", reason);
|
|
147
|
+
return;
|
|
148
|
+
default:
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
onSourceReady(): void {
|
|
154
|
+
if (this.#phase === "waiting-source") void this.#beginAttempt(true, newId());
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Terminal failure: discards pending data and removes the subscription. */
|
|
158
|
+
fail(code: ErrorCode, requestId: string, message?: string): void {
|
|
159
|
+
if (this.#phase === "terminated") return;
|
|
160
|
+
this.#invalidate();
|
|
161
|
+
this.#phase = "terminated";
|
|
162
|
+
this.channel.subscriptions.delete(this);
|
|
163
|
+
this.#host.sendError({
|
|
164
|
+
subscriptionId: this.id,
|
|
165
|
+
epoch: this.#epoch,
|
|
166
|
+
error: streamError(code, message === undefined ? { requestId } : { requestId, message })
|
|
167
|
+
});
|
|
168
|
+
this.#sendState("failed", code);
|
|
169
|
+
this.#host.removeSubscription(this.id);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Silent teardown for unsubscribe or connection closure. */
|
|
173
|
+
dispose(): void {
|
|
174
|
+
if (this.#phase === "terminated") return;
|
|
175
|
+
this.#invalidate();
|
|
176
|
+
this.#phase = "terminated";
|
|
177
|
+
this.#publicState = "closed";
|
|
178
|
+
this.channel.subscriptions.delete(this);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** True when admitting this state would conflict with the current state at the same revision. */
|
|
182
|
+
conflicts(revision: Revision, dataHash: string): boolean {
|
|
183
|
+
if (!this.capturing || this.#tail === null) return false;
|
|
184
|
+
return this.#tail.revision === revision && this.#tail.dataHash !== dataHash;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Admits a mapped full state and records the queue outcome under the record's
|
|
189
|
+
* request ID (before any resulting send). Never waits: overflow invalidates this
|
|
190
|
+
* generation instead.
|
|
191
|
+
*/
|
|
192
|
+
admit(frame: PendingFrame, requestId: string): AdmitResult {
|
|
193
|
+
if (!this.capturing) return "inactive";
|
|
194
|
+
if (this.#tail !== null && compareRevisions(frame.revision, this.#tail.revision) <= 0) {
|
|
195
|
+
this.#trace("queue", "filtered", undefined, requestId);
|
|
196
|
+
return "filtered";
|
|
197
|
+
}
|
|
198
|
+
if (!this.budget.tryReserve(frame.bytes)) {
|
|
199
|
+
this.#trace("queue", "rejected", "OVERLOADED", requestId);
|
|
200
|
+
this.#attemptFailed("OVERLOADED", newId());
|
|
201
|
+
return "overflow";
|
|
202
|
+
}
|
|
203
|
+
this.#trace("queue", "ok", undefined, requestId);
|
|
204
|
+
this.#queue.push(frame);
|
|
205
|
+
this.#tail = { revision: frame.revision, dataHash: frame.dataHash };
|
|
206
|
+
this.#pump();
|
|
207
|
+
return "queued";
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
onReceipt(epoch: string, sequence: number): void {
|
|
211
|
+
if (epoch !== this.#epoch || !this.capturing) return;
|
|
212
|
+
const inFlight = this.#inFlight;
|
|
213
|
+
if (inFlight === null || sequence < inFlight.sequence) {
|
|
214
|
+
if (sequence <= this.#sequence) return; // Duplicate receipts are harmless.
|
|
215
|
+
this.#protocolViolation("A receipt was received for a frame that was not sent.");
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (sequence > inFlight.sequence) {
|
|
219
|
+
this.#protocolViolation("A receipt skipped an unacknowledged frame.");
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
this.#clearReceiptTimer();
|
|
223
|
+
this.budget.release(inFlight.bytes);
|
|
224
|
+
this.#inFlight = null;
|
|
225
|
+
if (this.#lastDelivered === null || compareRevisions(inFlight.revision, this.#lastDelivered) > 0) {
|
|
226
|
+
this.#lastDelivered = inFlight.revision;
|
|
227
|
+
}
|
|
228
|
+
this.#trace("receipt", "ok");
|
|
229
|
+
if (this.#phase === "snapshot-sent") {
|
|
230
|
+
const boundary = this.#snapshotRevision ?? "0";
|
|
231
|
+
const kept: PendingFrame[] = [];
|
|
232
|
+
for (const frame of this.#queue) {
|
|
233
|
+
if (compareRevisions(frame.revision, boundary) <= 0) {
|
|
234
|
+
this.budget.release(frame.bytes);
|
|
235
|
+
this.#trace("queue", "filtered");
|
|
236
|
+
} else {
|
|
237
|
+
kept.push(frame);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
this.#queue = kept;
|
|
241
|
+
this.#drainRemaining = kept.length;
|
|
242
|
+
this.#phase = "draining";
|
|
243
|
+
if (this.#drainRemaining === 0) this.#goLive();
|
|
244
|
+
else this.#pump();
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
if (this.#phase === "draining") {
|
|
248
|
+
this.#drainRemaining--;
|
|
249
|
+
if (this.#drainRemaining <= 0) {
|
|
250
|
+
this.#goLive();
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
this.#pump();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// --- synchronization attempts -------------------------------------------------
|
|
258
|
+
|
|
259
|
+
async #beginAttempt(announce: boolean, requestId: string): Promise<void> {
|
|
260
|
+
const generation = this.#invalidate();
|
|
261
|
+
const signal = this.#controller.signal;
|
|
262
|
+
this.#attempts++;
|
|
263
|
+
this.#phase = "authorizing";
|
|
264
|
+
if (announce) this.#sendState("authorizing");
|
|
265
|
+
else this.#publicState = "authorizing";
|
|
266
|
+
|
|
267
|
+
if (!(await this.#authorize(generation, signal, requestId))) return;
|
|
268
|
+
|
|
269
|
+
if (!this.channel.source.ready) {
|
|
270
|
+
this.#attempts--; // Waiting for a source does not consume an attempt.
|
|
271
|
+
this.#phase = "waiting-source";
|
|
272
|
+
this.#sendState("stale", this.channel.source.reason ?? "SOURCE_UNAVAILABLE");
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
this.#epoch = newId();
|
|
277
|
+
this.#sequence = 0;
|
|
278
|
+
this.#sendState("synchronizing");
|
|
279
|
+
this.#phase = "capturing";
|
|
280
|
+
this.#core.router.add(this);
|
|
281
|
+
|
|
282
|
+
const { limits } = this.#core;
|
|
283
|
+
const deadline = Date.now() + limits.snapshotTimeoutMs;
|
|
284
|
+
const acquired = await this.#core.snapshots.acquire(signal, deadline);
|
|
285
|
+
if (generation !== this.#generation) {
|
|
286
|
+
if (acquired) this.#core.snapshots.release();
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
if (!acquired) {
|
|
290
|
+
this.#trace("snapshot", "failed", "TIMEOUT");
|
|
291
|
+
this.#attemptFailed("TIMEOUT", requestId);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
const principal = this.#host.principal;
|
|
295
|
+
const handlers = this.channel.handlers;
|
|
296
|
+
let outcome;
|
|
297
|
+
try {
|
|
298
|
+
outcome = await invokeHandler(
|
|
299
|
+
context => handlers.snapshot({ ...context, principal, params: this.params }),
|
|
300
|
+
{ timeoutMs: Math.max(1, deadline - Date.now()), requestId, parent: signal }
|
|
301
|
+
);
|
|
302
|
+
} finally {
|
|
303
|
+
this.#core.snapshots.release();
|
|
304
|
+
}
|
|
305
|
+
if (generation !== this.#generation || outcome.kind === "aborted") return;
|
|
306
|
+
if (outcome.kind === "timeout") {
|
|
307
|
+
this.#trace("snapshot", "failed", "TIMEOUT");
|
|
308
|
+
this.#attemptFailed("TIMEOUT", requestId);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
if (outcome.kind === "error") {
|
|
312
|
+
this.#trace("snapshot", "failed", "HANDLER_FAILED");
|
|
313
|
+
this.#core.logger.warn("Snapshot handler failed", { channel: this.channel.name, requestId, error: describeError(outcome.error) });
|
|
314
|
+
this.fail("HANDLER_FAILED", requestId);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
const snapshot: unknown = outcome.value;
|
|
318
|
+
const problem = this.#snapshotProblem(snapshot);
|
|
319
|
+
if (problem !== null) {
|
|
320
|
+
this.#trace("snapshot", "rejected", "INVALID_PAYLOAD");
|
|
321
|
+
this.#core.logger.warn("Snapshot rejected", { channel: this.channel.name, requestId, reason: problem });
|
|
322
|
+
this.fail("INVALID_PAYLOAD", requestId);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
const { revision, data } = snapshot as { revision: Revision; data: StreamEvent["data"] };
|
|
326
|
+
if (this.#lastDelivered !== null && compareRevisions(revision, this.#lastDelivered) < 0) {
|
|
327
|
+
this.#trace("snapshot", "rejected", "INVALID_PAYLOAD");
|
|
328
|
+
this.#core.logger.warn("Snapshot regressed below delivered state", { channel: this.channel.name, requestId });
|
|
329
|
+
this.#attemptFailed("INVALID_PAYLOAD", requestId);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
this.#trace("snapshot", "ok");
|
|
333
|
+
|
|
334
|
+
// Recheck authorization and token validity immediately before delivery.
|
|
335
|
+
if (!(await this.#authorize(generation, signal, requestId))) return;
|
|
336
|
+
|
|
337
|
+
const event: StreamEvent = {
|
|
338
|
+
id: newId(),
|
|
339
|
+
channel: this.channel.name,
|
|
340
|
+
channelVersion: this.channel.version,
|
|
341
|
+
kind: "snapshot",
|
|
342
|
+
data,
|
|
343
|
+
revision,
|
|
344
|
+
receivedAt: nowIso()
|
|
345
|
+
};
|
|
346
|
+
const bytes = Buffer.byteLength(JSON.stringify(event)) + FRAME_OVERHEAD_BYTES;
|
|
347
|
+
if (bytes > limits.maxDataFrameBytes) {
|
|
348
|
+
this.#trace("snapshot", "rejected", "INVALID_PAYLOAD");
|
|
349
|
+
this.#core.logger.warn("Snapshot exceeds maxDataFrameBytes", { channel: this.channel.name, requestId, bytes });
|
|
350
|
+
this.fail("INVALID_PAYLOAD", requestId);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
if (!this.budget.tryReserve(bytes)) {
|
|
354
|
+
this.#trace("queue", "rejected", "OVERLOADED");
|
|
355
|
+
this.#attemptFailed("OVERLOADED", requestId);
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
const dataHash = sha256Hex(data);
|
|
359
|
+
this.#snapshotRevision = revision;
|
|
360
|
+
if (this.#tail === null || compareRevisions(revision, this.#tail.revision) >= 0) {
|
|
361
|
+
this.#tail = { revision, dataHash };
|
|
362
|
+
}
|
|
363
|
+
this.#phase = "snapshot-sent";
|
|
364
|
+
this.#transmit({ event, bytes, revision, dataHash });
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async #authorize(generation: number, signal: AbortSignal, requestId: string): Promise<boolean> {
|
|
368
|
+
const revocationSeq = this.#core.revocations.sequence;
|
|
369
|
+
const principal = this.#host.principal;
|
|
370
|
+
const outcome = await invokeHandler(
|
|
371
|
+
context => this.channel.handlers.authorize({ ...context, principal, params: this.params }),
|
|
372
|
+
{ timeoutMs: this.#core.limits.handlerTimeoutMs, requestId, parent: signal }
|
|
373
|
+
);
|
|
374
|
+
if (generation !== this.#generation || outcome.kind === "aborted") return false;
|
|
375
|
+
if (outcome.kind === "timeout") {
|
|
376
|
+
this.#trace("authorize", "failed", "TIMEOUT");
|
|
377
|
+
this.#attemptFailed("TIMEOUT", requestId);
|
|
378
|
+
return false;
|
|
379
|
+
}
|
|
380
|
+
if (outcome.kind === "error") {
|
|
381
|
+
this.#trace("authorize", "failed", "HANDLER_FAILED");
|
|
382
|
+
this.#core.logger.warn("Authorize handler failed", { channel: this.channel.name, requestId, error: describeError(outcome.error) });
|
|
383
|
+
this.fail("HANDLER_FAILED", requestId);
|
|
384
|
+
return false;
|
|
385
|
+
}
|
|
386
|
+
if (outcome.value !== true) {
|
|
387
|
+
this.#trace("authorize", "rejected", "FORBIDDEN");
|
|
388
|
+
this.fail("FORBIDDEN", requestId);
|
|
389
|
+
return false;
|
|
390
|
+
}
|
|
391
|
+
if (!this.#host.canDeliver()) return false;
|
|
392
|
+
const channel = { name: this.channel.name, version: this.channel.version, canonicalParams: this.canonicalParams };
|
|
393
|
+
if (this.#core.revocations.revokedSince(revocationSeq, principal, channel)) {
|
|
394
|
+
this.#trace("authorize", "rejected", "FORBIDDEN");
|
|
395
|
+
this.fail("FORBIDDEN", requestId);
|
|
396
|
+
return false;
|
|
397
|
+
}
|
|
398
|
+
this.#trace("authorize", "ok");
|
|
399
|
+
return true;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
#snapshotProblem(snapshot: unknown): string | null {
|
|
403
|
+
if (!isPlainObject(snapshot)) return "snapshot must return an object";
|
|
404
|
+
for (const key of Object.keys(snapshot)) {
|
|
405
|
+
if (key !== "revision" && key !== "data") return `unexpected snapshot field "${key}"`;
|
|
406
|
+
}
|
|
407
|
+
if (!isRevision(snapshot["revision"])) return "revision must be a canonical unsigned decimal string";
|
|
408
|
+
if (!isJsonValue(snapshot["data"])) return "data must be JSON";
|
|
409
|
+
const issue = validateValue(this.channel.payloadSchema, snapshot["data"]);
|
|
410
|
+
return issue === null ? null : `data ${issue.path}: ${issue.message}`;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** A failed attempt: stale, then bounded retry with one- then two-second backoff. */
|
|
414
|
+
#attemptFailed(code: ErrorCode, requestId: string): void {
|
|
415
|
+
const generation = this.#invalidate();
|
|
416
|
+
this.#phase = "backoff";
|
|
417
|
+
this.#sendState("stale", code);
|
|
418
|
+
if (this.#attempts >= this.#core.limits.maxSyncAttempts) {
|
|
419
|
+
this.#phase = "resync-required";
|
|
420
|
+
this.#host.sendError({ subscriptionId: this.id, epoch: this.#epoch, error: streamError("RESYNC_REQUIRED", { requestId }) });
|
|
421
|
+
this.#sendState("resync-required", "RESYNC_REQUIRED");
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
const delay = this.#attempts === 0 ? 0 : 1_000 * 2 ** (this.#attempts - 1);
|
|
425
|
+
this.#retryTimer = setTimeout(() => {
|
|
426
|
+
this.#retryTimer = null;
|
|
427
|
+
if (generation === this.#generation && this.#phase === "backoff") void this.#beginAttempt(true, newId());
|
|
428
|
+
}, delay);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** Interrupts an active generation (overflow, protocol violation) and resynchronizes. */
|
|
432
|
+
#interrupt(code: ErrorCode): void {
|
|
433
|
+
this.#trace("queue", "rejected", code);
|
|
434
|
+
this.#attemptFailed(code, newId());
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
#protocolViolation(message: string): void {
|
|
438
|
+
this.#host.sendError({
|
|
439
|
+
subscriptionId: this.id,
|
|
440
|
+
epoch: this.#epoch,
|
|
441
|
+
error: streamError("INVALID_REQUEST", { message, requestId: newId() })
|
|
442
|
+
});
|
|
443
|
+
this.#interrupt("INVALID_REQUEST");
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
#goLive(): void {
|
|
447
|
+
this.#phase = "live";
|
|
448
|
+
this.#attempts = 0;
|
|
449
|
+
this.#sendState("live");
|
|
450
|
+
this.#pump();
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// --- delivery ------------------------------------------------------------------
|
|
454
|
+
|
|
455
|
+
#pump(): void {
|
|
456
|
+
if (this.#inFlight !== null) return;
|
|
457
|
+
if (this.#phase !== "draining" && this.#phase !== "live") return;
|
|
458
|
+
const next = this.#queue.shift();
|
|
459
|
+
if (next !== undefined) this.#transmit(next);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
#transmit(frame: PendingFrame): void {
|
|
463
|
+
if (!this.#host.canDeliver()) {
|
|
464
|
+
this.budget.release(frame.bytes);
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
if (this.#sequence >= Number.MAX_SAFE_INTEGER - 1) {
|
|
468
|
+
this.budget.release(frame.bytes);
|
|
469
|
+
this.#interrupt("OVERLOADED");
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
this.#sequence++;
|
|
473
|
+
this.#inFlight = { ...frame, sequence: this.#sequence };
|
|
474
|
+
this.#host.sendData({ subscriptionId: this.id, epoch: this.#epoch, sequence: this.#sequence, event: frame.event });
|
|
475
|
+
this.#trace("send", "ok");
|
|
476
|
+
this.#receiptTimer = setTimeout(() => {
|
|
477
|
+
this.#receiptTimer = null;
|
|
478
|
+
this.#trace("receipt", "failed", "OVERLOADED");
|
|
479
|
+
this.#host.receiptTimedOut();
|
|
480
|
+
}, this.#core.limits.receiptTimeoutMs);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
#clearReceiptTimer(): void {
|
|
484
|
+
if (this.#receiptTimer !== null) {
|
|
485
|
+
clearTimeout(this.#receiptTimer);
|
|
486
|
+
this.#receiptTimer = null;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** Starts a new generation: aborts handlers, drops frames, and leaves the routing index. */
|
|
491
|
+
#invalidate(): number {
|
|
492
|
+
this.#generation++;
|
|
493
|
+
this.#controller.abort();
|
|
494
|
+
this.#controller = new AbortController();
|
|
495
|
+
if (this.#retryTimer !== null) {
|
|
496
|
+
clearTimeout(this.#retryTimer);
|
|
497
|
+
this.#retryTimer = null;
|
|
498
|
+
}
|
|
499
|
+
this.#clearReceiptTimer();
|
|
500
|
+
for (const frame of this.#queue) this.budget.release(frame.bytes);
|
|
501
|
+
this.#queue = [];
|
|
502
|
+
if (this.#inFlight !== null) {
|
|
503
|
+
this.budget.release(this.#inFlight.bytes);
|
|
504
|
+
this.#inFlight = null;
|
|
505
|
+
}
|
|
506
|
+
this.#drainRemaining = 0;
|
|
507
|
+
this.#snapshotRevision = null;
|
|
508
|
+
this.#tail = null;
|
|
509
|
+
this.#core.router.remove(this);
|
|
510
|
+
return this.#generation;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
#sendState(state: SubscriptionState, reason?: ErrorCode): void {
|
|
514
|
+
this.#publicState = state;
|
|
515
|
+
this.#host.sendState(reason === undefined
|
|
516
|
+
? { subscriptionId: this.id, epoch: this.#epoch, state }
|
|
517
|
+
: { subscriptionId: this.id, epoch: this.#epoch, state, reason });
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
#trace(stage: "authorize" | "snapshot" | "queue" | "send" | "receipt", outcome: "ok" | "filtered" | "rejected" | "failed", errorCode?: ErrorCode, requestId?: string): void {
|
|
521
|
+
this.#core.traces.record({
|
|
522
|
+
requestId: requestId ?? (this.#epoch || this.id),
|
|
523
|
+
stage,
|
|
524
|
+
outcome,
|
|
525
|
+
sourceId: this.channel.source.id,
|
|
526
|
+
channel: this.channel.name,
|
|
527
|
+
subscriptionId: this.id,
|
|
528
|
+
...(errorCode === undefined ? {} : { errorCode })
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { StreamOtterError, utf8ByteLength, type Page, type Trace } from "@streamotter/contracts";
|
|
3
|
+
import { nowIso } from "./util.ts";
|
|
4
|
+
|
|
5
|
+
interface Entry { seq: number; trace: Trace; bytes: number }
|
|
6
|
+
|
|
7
|
+
export type TraceInput = Omit<Trace, "id" | "at">;
|
|
8
|
+
export interface TraceQuery { limit: number; cursor?: string; sourceId?: string; channel?: string; outcome?: Trace["outcome"] }
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Bounded in-memory trace metadata. Never stores payloads or credentials. Cursors
|
|
12
|
+
* are ephemeral positions scoped to this gateway run, not data-recovery cursors.
|
|
13
|
+
*/
|
|
14
|
+
export class TraceBuffer {
|
|
15
|
+
readonly #maxEntries: number;
|
|
16
|
+
readonly #maxBytes: number;
|
|
17
|
+
readonly #runId = randomBytes(6).toString("base64url");
|
|
18
|
+
#entries: Entry[] = [];
|
|
19
|
+
#head = 0;
|
|
20
|
+
#bytes = 0;
|
|
21
|
+
#nextSeq = 1;
|
|
22
|
+
|
|
23
|
+
constructor(maxEntries: number, maxBytes: number) {
|
|
24
|
+
this.#maxEntries = maxEntries;
|
|
25
|
+
this.#maxBytes = maxBytes;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
record(input: TraceInput): void {
|
|
29
|
+
const seq = this.#nextSeq++;
|
|
30
|
+
const trace: Trace = { id: `${this.#runId}-${seq}`, at: nowIso(), ...input };
|
|
31
|
+
const bytes = utf8ByteLength(JSON.stringify(trace));
|
|
32
|
+
this.#entries.push({ seq, trace, bytes });
|
|
33
|
+
this.#bytes += bytes;
|
|
34
|
+
while (this.size > this.#maxEntries || (this.#bytes > this.#maxBytes && this.size > 0)) {
|
|
35
|
+
const dropped = this.#entries[this.#head];
|
|
36
|
+
if (dropped === undefined) break;
|
|
37
|
+
this.#bytes -= dropped.bytes;
|
|
38
|
+
this.#head++;
|
|
39
|
+
}
|
|
40
|
+
if (this.#head > 1024 && this.#head * 2 > this.#entries.length) {
|
|
41
|
+
this.#entries = this.#entries.slice(this.#head);
|
|
42
|
+
this.#head = 0;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
get size(): number {
|
|
47
|
+
return this.#entries.length - this.#head;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
#cursorFor(seq: number): string {
|
|
51
|
+
return Buffer.from(`${this.#runId}:${seq}`).toString("base64url");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
#parseCursor(cursor: string): number {
|
|
55
|
+
let decoded: string;
|
|
56
|
+
try {
|
|
57
|
+
decoded = Buffer.from(cursor, "base64url").toString("utf8");
|
|
58
|
+
} catch {
|
|
59
|
+
throw new StreamOtterError("INVALID_REQUEST", { message: "The trace cursor is malformed." });
|
|
60
|
+
}
|
|
61
|
+
const match = /^([A-Za-z0-9_-]+):(\d{1,15})$/.exec(decoded);
|
|
62
|
+
if (match === null) throw new StreamOtterError("INVALID_REQUEST", { message: "The trace cursor is malformed." });
|
|
63
|
+
if (match[1] !== this.#runId) throw new StreamOtterError("TRACE_CURSOR_EXPIRED");
|
|
64
|
+
return Number(match[2]);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Without a cursor, returns the newest `limit` matching traces (oldest first).
|
|
69
|
+
* With a cursor, returns up to `limit` matching traces recorded after it.
|
|
70
|
+
* nextCursor continues after the last examined trace, so callers can poll.
|
|
71
|
+
*/
|
|
72
|
+
page(query: TraceQuery): Page<Trace> {
|
|
73
|
+
const matches = (entry: Entry) =>
|
|
74
|
+
(query.sourceId === undefined || entry.trace.sourceId === query.sourceId)
|
|
75
|
+
&& (query.channel === undefined || entry.trace.channel === query.channel)
|
|
76
|
+
&& (query.outcome === undefined || entry.trace.outcome === query.outcome);
|
|
77
|
+
const oldestSeq = this.#entries[this.#head]?.seq ?? this.#nextSeq;
|
|
78
|
+
if (query.cursor === undefined) {
|
|
79
|
+
const items: Trace[] = [];
|
|
80
|
+
for (let i = this.#entries.length - 1; i >= this.#head && items.length < query.limit; i--) {
|
|
81
|
+
const entry = this.#entries[i];
|
|
82
|
+
if (entry !== undefined && matches(entry)) items.push(entry.trace);
|
|
83
|
+
}
|
|
84
|
+
items.reverse();
|
|
85
|
+
return { items, nextCursor: this.#cursorFor(this.#nextSeq - 1) };
|
|
86
|
+
}
|
|
87
|
+
const after = this.#parseCursor(query.cursor);
|
|
88
|
+
if (after + 1 < oldestSeq) throw new StreamOtterError("TRACE_CURSOR_EXPIRED");
|
|
89
|
+
const items: Trace[] = [];
|
|
90
|
+
let last = after;
|
|
91
|
+
for (let i = this.#head + Math.max(0, after + 1 - oldestSeq); i < this.#entries.length; i++) {
|
|
92
|
+
const entry = this.#entries[i];
|
|
93
|
+
if (entry === undefined) break;
|
|
94
|
+
last = entry.seq;
|
|
95
|
+
if (matches(entry)) {
|
|
96
|
+
items.push(entry.trace);
|
|
97
|
+
if (items.length >= query.limit) break;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return { items, nextCursor: this.#cursorFor(last) };
|
|
101
|
+
}
|
|
102
|
+
}
|