@supalive/core 1.15.0 → 1.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index-B2nE1y9E.d.ts +2384 -0
- package/dist/index-B2nE1y9E.d.ts.map +1 -0
- package/dist/index-B4UxN1ZL.d.ts +2365 -0
- package/dist/index-B4UxN1ZL.d.ts.map +1 -0
- package/dist/index-B5hAqW3O.d.ts +2402 -0
- package/dist/index-B5hAqW3O.d.ts.map +1 -0
- package/dist/index-B8JFAyw4.d.ts +2377 -0
- package/dist/index-B8JFAyw4.d.ts.map +1 -0
- package/dist/index-BI3MfAyI.d.ts +2399 -0
- package/dist/index-BI3MfAyI.d.ts.map +1 -0
- package/dist/index-BL90lDw7.d.ts +2400 -0
- package/dist/index-BL90lDw7.d.ts.map +1 -0
- package/dist/index-BvryqYUm.d.ts +2394 -0
- package/dist/index-BvryqYUm.d.ts.map +1 -0
- package/dist/index-CfcrWLLm.d.ts +2377 -0
- package/dist/index-CfcrWLLm.d.ts.map +1 -0
- package/dist/index-DYkOBnw_.d.ts +2378 -0
- package/dist/index-DYkOBnw_.d.ts.map +1 -0
- package/dist/index-DcM2uKpT.d.ts +2393 -0
- package/dist/index-DcM2uKpT.d.ts.map +1 -0
- package/dist/index-DsvdhOKL.d.ts +2400 -0
- package/dist/index-DsvdhOKL.d.ts.map +1 -0
- package/dist/index-ggi1DrSG.d.ts +2336 -0
- package/dist/index-ggi1DrSG.d.ts.map +1 -0
- package/dist/mysql-07ROfbBZ.d.ts +114 -0
- package/dist/mysql-07ROfbBZ.d.ts.map +1 -0
- package/dist/mysql-BJaQhVGE.d.ts +114 -0
- package/dist/mysql-BJaQhVGE.d.ts.map +1 -0
- package/dist/mysql-BKs5PTSR.d.ts +114 -0
- package/dist/mysql-BKs5PTSR.d.ts.map +1 -0
- package/dist/mysql-BTigT6mP.d.ts +114 -0
- package/dist/mysql-BTigT6mP.d.ts.map +1 -0
- package/dist/mysql-C7Km_lgc.d.ts +114 -0
- package/dist/mysql-C7Km_lgc.d.ts.map +1 -0
- package/dist/mysql-CXymz1Eu.d.ts +114 -0
- package/dist/mysql-CXymz1Eu.d.ts.map +1 -0
- package/dist/mysql-ClUK7S2j.js +623 -0
- package/dist/mysql-ClUK7S2j.js.map +1 -0
- package/dist/mysql-CtpWqOhO.d.ts +114 -0
- package/dist/mysql-CtpWqOhO.d.ts.map +1 -0
- package/dist/mysql-Cxro4f6T.d.ts +114 -0
- package/dist/mysql-Cxro4f6T.d.ts.map +1 -0
- package/dist/mysql-DNiYIZCK.d.ts +114 -0
- package/dist/mysql-DNiYIZCK.d.ts.map +1 -0
- package/dist/mysql-DS1s1c5M.d.ts +114 -0
- package/dist/mysql-DS1s1c5M.d.ts.map +1 -0
- package/dist/mysql-fTKZ1zaF.js +623 -0
- package/dist/mysql-fTKZ1zaF.js.map +1 -0
- package/dist/mysql-qTszEKAU.d.ts +114 -0
- package/dist/mysql-qTszEKAU.d.ts.map +1 -0
- package/dist/mysql-qW13LcGQ.d.ts +114 -0
- package/dist/mysql-qW13LcGQ.d.ts.map +1 -0
- package/dist/overlap-checker-1TwhkAwy.js +259 -0
- package/dist/overlap-checker-1TwhkAwy.js.map +1 -0
- package/dist/overlap-checker-CGlxWcyb.js +259 -0
- package/dist/overlap-checker-CGlxWcyb.js.map +1 -0
- package/dist/postgres-B0fqJe1X.d.ts +118 -0
- package/dist/postgres-B0fqJe1X.d.ts.map +1 -0
- package/dist/postgres-B5-5vgLJ.d.ts +118 -0
- package/dist/postgres-B5-5vgLJ.d.ts.map +1 -0
- package/dist/postgres-BATDLPml.d.ts +118 -0
- package/dist/postgres-BATDLPml.d.ts.map +1 -0
- package/dist/postgres-BUVYjnaT.d.ts +118 -0
- package/dist/postgres-BUVYjnaT.d.ts.map +1 -0
- package/dist/postgres-Bk_8PFRT.d.ts +118 -0
- package/dist/postgres-Bk_8PFRT.d.ts.map +1 -0
- package/dist/postgres-Bx79_JEy.d.ts +118 -0
- package/dist/postgres-Bx79_JEy.d.ts.map +1 -0
- package/dist/postgres-C3w741VH.d.ts +118 -0
- package/dist/postgres-C3w741VH.d.ts.map +1 -0
- package/dist/postgres-CAZYYgbZ.d.ts +118 -0
- package/dist/postgres-CAZYYgbZ.d.ts.map +1 -0
- package/dist/postgres-CN03Espg.js +868 -0
- package/dist/postgres-CN03Espg.js.map +1 -0
- package/dist/postgres-CRPJdZx2.js +868 -0
- package/dist/postgres-CRPJdZx2.js.map +1 -0
- package/dist/postgres-DCXnKaJr.d.ts +118 -0
- package/dist/postgres-DCXnKaJr.d.ts.map +1 -0
- package/dist/postgres-DgCYPf0j.d.ts +118 -0
- package/dist/postgres-DgCYPf0j.d.ts.map +1 -0
- package/dist/postgres-Dnshux6k.d.ts +118 -0
- package/dist/postgres-Dnshux6k.d.ts.map +1 -0
- package/dist/postgres-bhVzeRf2.d.ts +118 -0
- package/dist/postgres-bhVzeRf2.d.ts.map +1 -0
- package/dist/query-LJranz0c.js +795 -0
- package/dist/query-LJranz0c.js.map +1 -0
- package/dist/query-q1ZvKrZO.js +762 -0
- package/dist/query-q1ZvKrZO.js.map +1 -0
- package/dist/realtime_db-B0Tin87E.js +312 -0
- package/dist/realtime_db-B0Tin87E.js.map +1 -0
- package/dist/realtime_db-DVhhasJj.js +312 -0
- package/dist/realtime_db-DVhhasJj.js.map +1 -0
- package/dist/router-Bk0eLfmB.js +285 -0
- package/dist/router-Bk0eLfmB.js.map +1 -0
- package/dist/router-DP2ThAwh.js.map +1 -1
- package/dist/src/client/index.d.ts +2 -2
- package/dist/src/client/index.js +95 -20
- package/dist/src/client/index.js.map +1 -1
- package/dist/src/exports/mysql.d.ts +1 -1
- package/dist/src/exports/mysql.js +1 -1
- package/dist/src/exports/postgres.d.ts +1 -1
- package/dist/src/exports/postgres.js +1 -1
- package/dist/src/exports/procedure.d.ts +2 -2
- package/dist/src/exports/procedure.js +1 -1
- package/dist/src/exports/schema-sql.d.ts +1 -1
- package/dist/src/exports/server.d.ts +13 -6
- package/dist/src/exports/server.d.ts.map +1 -1
- package/dist/src/exports/server.js +802 -252
- package/dist/src/exports/server.js.map +1 -1
- package/dist/src/exports/sub-manager-worker-entry.js +1 -1
- package/dist/src/exports/types.d.ts +2 -2
- package/dist/src/exports/types.js +1 -1
- package/dist/sub-worker-dispatch-D7jfw6ys.js +826 -0
- package/dist/sub-worker-dispatch-D7jfw6ys.js.map +1 -0
- package/dist/sub-worker-dispatch-qiSs5Xkm.js +826 -0
- package/dist/sub-worker-dispatch-qiSs5Xkm.js.map +1 -0
- package/dist/types_client_rpc-CWZmlWmM.js +145 -0
- package/dist/types_client_rpc-CWZmlWmM.js.map +1 -0
- package/dist/types_client_rpc-D4td5W0Y.js +141 -0
- package/dist/types_client_rpc-D4td5W0Y.js.map +1 -0
- package/dist/types_server-AXVa_2mF.d.ts +684 -0
- package/dist/types_server-AXVa_2mF.d.ts.map +1 -0
- package/dist/types_server-BhC19R2a.js.map +1 -1
- package/dist/types_server-BmIgDd8d.d.ts +614 -0
- package/dist/types_server-BmIgDd8d.d.ts.map +1 -0
- package/dist/types_server-C6B9_b1R.d.ts +662 -0
- package/dist/types_server-C6B9_b1R.d.ts.map +1 -0
- package/dist/types_server-CUPM08l2.d.ts +684 -0
- package/dist/types_server-CUPM08l2.d.ts.map +1 -0
- package/dist/types_server-CtYRmHdO.d.ts +684 -0
- package/dist/types_server-CtYRmHdO.d.ts.map +1 -0
- package/dist/types_server-DHRi8bGO.d.ts +684 -0
- package/dist/types_server-DHRi8bGO.d.ts.map +1 -0
- package/dist/types_server-DRr5yKy1.d.ts +655 -0
- package/dist/types_server-DRr5yKy1.d.ts.map +1 -0
- package/dist/types_server-DmCbve3w.d.ts +684 -0
- package/dist/types_server-DmCbve3w.d.ts.map +1 -0
- package/dist/types_server-DoeybfmY.d.ts +684 -0
- package/dist/types_server-DoeybfmY.d.ts.map +1 -0
- package/dist/types_server-Gs_vSVaS.d.ts +655 -0
- package/dist/types_server-Gs_vSVaS.d.ts.map +1 -0
- package/dist/types_server-ILUmPFvk.d.ts +614 -0
- package/dist/types_server-ILUmPFvk.d.ts.map +1 -0
- package/dist/types_server-JK14NE0j.d.ts +684 -0
- package/dist/types_server-JK14NE0j.d.ts.map +1 -0
- package/dist/types_server-hYv2--1M.d.ts +684 -0
- package/dist/types_server-hYv2--1M.d.ts.map +1 -0
- package/dist/types_server-kkQJDSjA.d.ts +684 -0
- package/dist/types_server-kkQJDSjA.d.ts.map +1 -0
- package/package.json +2 -1
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { l as primaryOnlyConn, n as getContextRegistry } from "../../router-DP2ThAwh.js";
|
|
2
2
|
import { n as stableStringify, r as supaliveStringify, t as groupByToMap } from "../../helper-zdJT5FUc.js";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { s as traceCacheCall } from "../../query-LJranz0c.js";
|
|
4
|
+
import { r as evaluateCacheFreshness } from "../../overlap-checker-1TwhkAwy.js";
|
|
5
|
+
import { i as AuthenticationError, o as ClientMessageSchema } from "../../types_client_rpc-CWZmlWmM.js";
|
|
5
6
|
import { n as RemoteSubscriptionMessageSchema } from "../../types_server-BhC19R2a.js";
|
|
6
|
-
import { n as sleep } from "../../realtime_db-
|
|
7
|
+
import { n as sleep } from "../../realtime_db-B0Tin87E.js";
|
|
8
|
+
import { C as appInstanceId, D as serializeMapValue, E as logger, S as RetentionWatermark, T as logLevelOf, _ as UnregisterSubscriptionResultSchema, a as bootstrapDbTypes, b as UpdateSubscriptionReadSetResultSchema, c as initCore, d as InvalidateWritesetParamsSchema, f as InvalidateWritesetResultSchema, g as UnregisterSubscriptionParamsSchema, h as RegisterSubscriptionResultSchema, i as DB_QUERY_TIMEOUT_MS, l as initDatabase, m as RegisterSubscriptionParamsSchema, n as SubscriptionWorker, o as createDatabase, p as RegisterSubscriptionBatchResultSchema, r as CORE_VERSION, s as initCacheLayer, t as dispatchWorkerMessage, u as CacheLayer, v as UnregisterSubscriptionsResultSchema, w as isProduction, x as RETENTION_WATERMARK_CHANNEL, y as UpdateSubscriptionReadSetParamsSchema } from "../../sub-worker-dispatch-qiSs5Xkm.js";
|
|
7
9
|
import { n as resolveReadRouting, t as makeConnRouting } from "../../read-routing-BUcQAwnd.js";
|
|
8
|
-
import {
|
|
10
|
+
import { ROOT_CONTEXT, SpanKind, SpanStatusCode, context, isSpanContextValid, metrics, propagation, trace } from "@opentelemetry/api";
|
|
9
11
|
import { v4 } from "uuid";
|
|
10
12
|
import { createServer } from "http";
|
|
11
13
|
import { WebSocket, WebSocketServer } from "ws";
|
|
@@ -72,6 +74,251 @@ async function generateCacheKey(queryName, input, queryIdentity) {
|
|
|
72
74
|
return `ck_${await getHashOf(`${queryName}_${typeof input === "string" ? input : stableStringify(input)}_${queryIdentity}`)}`;
|
|
73
75
|
}
|
|
74
76
|
//#endregion
|
|
77
|
+
//#region src/observability.ts
|
|
78
|
+
let rootLogger = logger;
|
|
79
|
+
/** Replace the process-wide root logger. Call once at startup, before creating
|
|
80
|
+
* the server, to route Supalive's request/lifecycle logs through your own pino
|
|
81
|
+
* instance. */
|
|
82
|
+
function configureRootLogger(logger) {
|
|
83
|
+
rootLogger = logger;
|
|
84
|
+
}
|
|
85
|
+
/** The current root logger (defaults to the env-configured singleton). */
|
|
86
|
+
function getRootLogger() {
|
|
87
|
+
return rootLogger;
|
|
88
|
+
}
|
|
89
|
+
const CLIENT_ERROR_CODES = /* @__PURE__ */ new Set([
|
|
90
|
+
"NOT_FOUND",
|
|
91
|
+
"BAD_REQUEST",
|
|
92
|
+
"INVALID_MESSAGE",
|
|
93
|
+
"PARSE_ERROR",
|
|
94
|
+
"UNAUTHENTICATED",
|
|
95
|
+
"RATE_LIMITED",
|
|
96
|
+
"METHOD_NOT_ALLOWED",
|
|
97
|
+
"TOO_MANY_CONCURRENT_MUTATIONS",
|
|
98
|
+
"TOO_MANY_CONCURRENT_ACTIONS",
|
|
99
|
+
"SUBSCRIBE_ERROR"
|
|
100
|
+
]);
|
|
101
|
+
/**
|
|
102
|
+
* The bound observability surface for one server instance: an injectable logger,
|
|
103
|
+
* OTel metric instruments, and the `record()` seam every operation funnels
|
|
104
|
+
* through. Built once in the server constructor and shared with the HTTP RPC
|
|
105
|
+
* handler.
|
|
106
|
+
*/
|
|
107
|
+
var Observability = class {
|
|
108
|
+
logger;
|
|
109
|
+
requestLog;
|
|
110
|
+
onEvent;
|
|
111
|
+
requestCounter;
|
|
112
|
+
durationHistogram;
|
|
113
|
+
activeConnections;
|
|
114
|
+
activeSubscriptions;
|
|
115
|
+
tracer;
|
|
116
|
+
constructor(opts = {}) {
|
|
117
|
+
const base = opts.logger ?? getRootLogger();
|
|
118
|
+
this.logger = opts.level ? base.child({}, { level: opts.level }) : base;
|
|
119
|
+
this.requestLog = opts.requestLog ?? "all";
|
|
120
|
+
this.onEvent = opts.onEvent;
|
|
121
|
+
const meterName = opts.meterName ?? "@supalive/core";
|
|
122
|
+
const meter = metrics.getMeter(meterName, opts.meterVersion);
|
|
123
|
+
this.tracer = trace.getTracer(meterName, opts.meterVersion);
|
|
124
|
+
this.requestCounter = meter.createCounter("supalive_requests_total", { description: "Total Supalive operations (WS calls, subscribes, HTTP RPC, jobs)." });
|
|
125
|
+
this.durationHistogram = meter.createHistogram("supalive_request_duration_ms", {
|
|
126
|
+
description: "Operation duration in milliseconds.",
|
|
127
|
+
unit: "ms"
|
|
128
|
+
});
|
|
129
|
+
this.activeConnections = meter.createUpDownCounter("supalive_active_connections", { description: "Currently open WebSocket connections." });
|
|
130
|
+
this.activeSubscriptions = meter.createUpDownCounter("supalive_active_subscriptions", { description: "Currently registered live-query subscriptions on this instance." });
|
|
131
|
+
}
|
|
132
|
+
/** Record one completed operation: metrics + optional onEvent + one log line,
|
|
133
|
+
* and finalize the op's span (status/attributes/exception) when present. */
|
|
134
|
+
record(e) {
|
|
135
|
+
const traceId = this.finalizeSpan(e);
|
|
136
|
+
if (traceId) (e.extra ??= {}).traceId = traceId;
|
|
137
|
+
const attrs = {
|
|
138
|
+
transport: e.transport,
|
|
139
|
+
procedure: e.procedure ?? "",
|
|
140
|
+
kind: e.kind ?? "",
|
|
141
|
+
outcome: e.outcome,
|
|
142
|
+
code: e.code ?? ""
|
|
143
|
+
};
|
|
144
|
+
this.requestCounter.add(1, attrs);
|
|
145
|
+
this.durationHistogram.record(e.durationMs, attrs);
|
|
146
|
+
if (this.onEvent) try {
|
|
147
|
+
this.onEvent(e);
|
|
148
|
+
} catch {}
|
|
149
|
+
if (this.requestLog === "off") return;
|
|
150
|
+
if (e.outcome === "ok") {
|
|
151
|
+
if (this.requestLog !== "all") return;
|
|
152
|
+
this.logger.info(logFields(e), summary(e));
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
const fields = logFields(e);
|
|
156
|
+
if (!e.error && e.code != null && CLIENT_ERROR_CODES.has(e.code)) this.logger.warn(fields, summary(e));
|
|
157
|
+
else this.logger.error({
|
|
158
|
+
...fields,
|
|
159
|
+
err: e.error
|
|
160
|
+
}, summary(e));
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Start a SERVER span for an operation, as a child of `parent` (or the active
|
|
164
|
+
* context). No-op — returns a non-recording span — until a TracerProvider is
|
|
165
|
+
* registered by the app, so this costs nothing when tracing is disabled.
|
|
166
|
+
* Returns the span plus a context that has it active, for `context.with(...)`
|
|
167
|
+
* wrapping so nested handler work / `ctx.span` children attach correctly.
|
|
168
|
+
*/
|
|
169
|
+
startSpan(name, opts = {}) {
|
|
170
|
+
const parent = opts.parent ?? context.active();
|
|
171
|
+
const span = this.tracer.startSpan(name, {
|
|
172
|
+
kind: SpanKind.SERVER,
|
|
173
|
+
attributes: {
|
|
174
|
+
...opts.transport ? { "supalive.transport": opts.transport } : {},
|
|
175
|
+
...opts.procedure ? { "supalive.procedure": opts.procedure } : {},
|
|
176
|
+
...opts.kind ? { "supalive.kind": opts.kind } : {}
|
|
177
|
+
}
|
|
178
|
+
}, parent);
|
|
179
|
+
return {
|
|
180
|
+
span,
|
|
181
|
+
context: trace.setSpan(parent, span)
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Continue a trace from an inbound carrier (HTTP headers, or a WS message's
|
|
186
|
+
* `trace` field). Returns a context to pass as `startSpan({ parent })`. When
|
|
187
|
+
* no propagator is registered / no trace context is present, returns the root
|
|
188
|
+
* context (a fresh trace).
|
|
189
|
+
*/
|
|
190
|
+
extractContext(carrier) {
|
|
191
|
+
return propagation.extract(ROOT_CONTEXT, carrier);
|
|
192
|
+
}
|
|
193
|
+
/** Set the span's final status/attributes from the event, then end it.
|
|
194
|
+
* Returns the (valid) trace id for log correlation, or undefined. */
|
|
195
|
+
finalizeSpan(e) {
|
|
196
|
+
const span = e.span;
|
|
197
|
+
if (!span) return void 0;
|
|
198
|
+
try {
|
|
199
|
+
span.setAttribute("supalive.outcome", e.outcome);
|
|
200
|
+
if (e.code) span.setAttribute("supalive.code", e.code);
|
|
201
|
+
if (e.procedure) span.setAttribute("supalive.procedure", e.procedure);
|
|
202
|
+
if (e.kind) span.setAttribute("supalive.kind", e.kind);
|
|
203
|
+
if (e.outcome === "error") {
|
|
204
|
+
if (e.error instanceof Error) span.recordException(e.error);
|
|
205
|
+
span.setStatus({
|
|
206
|
+
code: SpanStatusCode.ERROR,
|
|
207
|
+
message: e.code
|
|
208
|
+
});
|
|
209
|
+
} else span.setStatus({ code: SpanStatusCode.OK });
|
|
210
|
+
} finally {
|
|
211
|
+
span.end();
|
|
212
|
+
}
|
|
213
|
+
const sc = span.spanContext();
|
|
214
|
+
return isSpanContextValid(sc) ? sc.traceId : void 0;
|
|
215
|
+
}
|
|
216
|
+
/** A request-scoped child logger for `ctx.log`, bound with the given fields
|
|
217
|
+
* (reqId, procedure, and trace id when tracing is on). */
|
|
218
|
+
childLogger(fields) {
|
|
219
|
+
return this.logger.child(fields);
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* A LAZY request-scoped `ctx.log`. Most handlers never log, and pino's
|
|
223
|
+
* `.child()` (bindings merge + serialization) isn't free — so this defers the
|
|
224
|
+
* child creation until the first `debug/info/warn/error` call and reuses it
|
|
225
|
+
* after. When a handler logs nothing, no child logger is ever allocated; the
|
|
226
|
+
* only per-request cost is this small delegator object. Returns the narrow
|
|
227
|
+
* {@link HandlerLogger} surface the handler context exposes.
|
|
228
|
+
*/
|
|
229
|
+
lazyChildLogger(fields) {
|
|
230
|
+
const base = this.logger;
|
|
231
|
+
let child;
|
|
232
|
+
const get = () => child ??= base.child(fields);
|
|
233
|
+
return {
|
|
234
|
+
debug: (...args) => get().debug(...args),
|
|
235
|
+
info: (...args) => get().info(...args),
|
|
236
|
+
warn: (...args) => get().warn(...args),
|
|
237
|
+
error: (...args) => get().error(...args)
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
/** The span's trace id, or undefined when tracing is off / the span is
|
|
241
|
+
* non-recording. For binding onto `ctx.log`. */
|
|
242
|
+
traceIdOf(span) {
|
|
243
|
+
if (!span) return void 0;
|
|
244
|
+
const sc = span.spanContext();
|
|
245
|
+
return isSpanContextValid(sc) ? sc.traceId : void 0;
|
|
246
|
+
}
|
|
247
|
+
connectionOpened() {
|
|
248
|
+
this.activeConnections.add(1);
|
|
249
|
+
}
|
|
250
|
+
connectionClosed() {
|
|
251
|
+
this.activeConnections.add(-1);
|
|
252
|
+
}
|
|
253
|
+
subscriptionAdded(n = 1) {
|
|
254
|
+
this.activeSubscriptions.add(n);
|
|
255
|
+
}
|
|
256
|
+
subscriptionRemoved(n = 1) {
|
|
257
|
+
this.activeSubscriptions.add(-n);
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
/** Start marker for an operation; pass to {@link elapsedMs} to get its duration. */
|
|
261
|
+
function startTimer() {
|
|
262
|
+
return performance.now();
|
|
263
|
+
}
|
|
264
|
+
function elapsedMs(startedAt) {
|
|
265
|
+
return Math.round((performance.now() - startedAt) * 1e3) / 1e3;
|
|
266
|
+
}
|
|
267
|
+
const coreTracer = trace.getTracer("@supalive/core");
|
|
268
|
+
/**
|
|
269
|
+
* Run a query handler inside a `query <procedureName>` span, nested under
|
|
270
|
+
* `parentContext` (the operation span) or the active context. The span is handed
|
|
271
|
+
* to `run` so it becomes the query handler's `ctx.span` and the parent of any DB
|
|
272
|
+
* spans. Cache HITs never call this (no execution), so a span appearing means a
|
|
273
|
+
* real recompute — including the otherwise-invisible background reactive path,
|
|
274
|
+
* where `parentContext` is omitted and the span is a root.
|
|
275
|
+
*
|
|
276
|
+
* No-op-cheap when tracing is off: the started span is non-recording, so we skip
|
|
277
|
+
* the `context.with` and just run.
|
|
278
|
+
*/
|
|
279
|
+
function traceQueryExecution(procedureName, trigger, parentContext, run) {
|
|
280
|
+
const parent = parentContext ?? context.active();
|
|
281
|
+
const span = coreTracer.startSpan(`query ${procedureName}`, { attributes: {
|
|
282
|
+
"supalive.kind": "query",
|
|
283
|
+
"supalive.procedure": procedureName,
|
|
284
|
+
"supalive.trigger": trigger
|
|
285
|
+
} }, parent);
|
|
286
|
+
const exec = async () => {
|
|
287
|
+
try {
|
|
288
|
+
const r = await run(span);
|
|
289
|
+
span.setStatus({ code: SpanStatusCode.OK });
|
|
290
|
+
return r;
|
|
291
|
+
} catch (err) {
|
|
292
|
+
if (err instanceof Error) span.recordException(err);
|
|
293
|
+
span.setStatus({ code: SpanStatusCode.ERROR });
|
|
294
|
+
throw err;
|
|
295
|
+
} finally {
|
|
296
|
+
span.end();
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
return span.isRecording() ? context.with(trace.setSpan(parent, span), exec) : exec();
|
|
300
|
+
}
|
|
301
|
+
function logFields(e) {
|
|
302
|
+
const f = {
|
|
303
|
+
op: e.op,
|
|
304
|
+
transport: e.transport,
|
|
305
|
+
outcome: e.outcome,
|
|
306
|
+
durMs: e.durationMs
|
|
307
|
+
};
|
|
308
|
+
if (e.procedure) f.procedure = e.procedure;
|
|
309
|
+
if (e.kind) f.kind = e.kind;
|
|
310
|
+
if (e.code) f.code = e.code;
|
|
311
|
+
if (e.reqId) f.reqId = e.reqId;
|
|
312
|
+
if (e.sessionId) f.sessionId = e.sessionId;
|
|
313
|
+
if (e.userId != null) f.userId = e.userId;
|
|
314
|
+
if (e.extra) Object.assign(f, e.extra);
|
|
315
|
+
return f;
|
|
316
|
+
}
|
|
317
|
+
function summary(e) {
|
|
318
|
+
const label = e.procedure ? `${e.kind ?? e.op} ${e.procedure}` : e.op;
|
|
319
|
+
return `${e.transport} ${label} ${e.outcome}`;
|
|
320
|
+
}
|
|
321
|
+
//#endregion
|
|
75
322
|
//#region src/server/one-shot-query.ts
|
|
76
323
|
/**
|
|
77
324
|
* Execute a one-shot query (WS `call` or HTTP RPC) with the same caching model
|
|
@@ -108,14 +355,24 @@ async function generateCacheKey(queryName, input, queryIdentity) {
|
|
|
108
355
|
* `ts` the result reflects, which the caller echoes so the client can advance
|
|
109
356
|
* its high-water.
|
|
110
357
|
*/
|
|
111
|
-
async function runCachedQuery(deps, procedure, procedureName, input, serverCtx, lastSnapshotTs, clientMinTs) {
|
|
358
|
+
async function runCachedQuery(deps, handlerObs, procedure, procedureName, input, serverCtx, lastSnapshotTs, clientMinTs) {
|
|
112
359
|
const queryIdentity = resolveQueryIdentity(procedure, serverCtx, input, deps.getUserId);
|
|
113
360
|
const cacheKey = await generateCacheKey(procedureName, stableStringify(input), queryIdentity);
|
|
114
361
|
const routing = await resolveReadRouting(deps.db, lastSnapshotTs, clientMinTs ?? 0n);
|
|
115
362
|
const readTs = routing.readTs;
|
|
116
|
-
const metadata = await
|
|
117
|
-
|
|
118
|
-
|
|
363
|
+
const { metadata, freshness } = await traceCacheCall("freshness", async () => {
|
|
364
|
+
const metadata = await deps.cache.getQueryCacheMetaData(cacheKey);
|
|
365
|
+
return {
|
|
366
|
+
metadata,
|
|
367
|
+
freshness: await evaluateCacheFreshness(routing.readImpl, metadata, readTs, deps.getMinRetainedTs())
|
|
368
|
+
};
|
|
369
|
+
});
|
|
370
|
+
if (freshness.fresh) {
|
|
371
|
+
const cached = await traceCacheCall("get", async (setHit) => {
|
|
372
|
+
const c = await deps.cache.getQueryCacheFor(cacheKey);
|
|
373
|
+
setHit(!!(c && metadata && c.version === metadata.version));
|
|
374
|
+
return c;
|
|
375
|
+
});
|
|
119
376
|
if (cached && metadata && cached.version === metadata.version) {
|
|
120
377
|
if (readTs > metadata.lastSnapshotTs) deps.cache.advanceQueryCacheMetadata(cacheKey, {
|
|
121
378
|
lastSnapshotTs: readTs,
|
|
@@ -134,11 +391,16 @@ async function runCachedQuery(deps, procedure, procedureName, input, serverCtx,
|
|
|
134
391
|
beginTs: readTs
|
|
135
392
|
}, async (ctx) => {
|
|
136
393
|
const fn = procedure.fn;
|
|
137
|
-
return
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
394
|
+
return traceQueryExecution(procedureName, "request", void 0, (querySpan) => {
|
|
395
|
+
const qctx = {
|
|
396
|
+
db: ctx,
|
|
397
|
+
serverCtx,
|
|
398
|
+
...makeConnRouting(ctx, routing),
|
|
399
|
+
log: handlerObs.log,
|
|
400
|
+
span: querySpan
|
|
401
|
+
};
|
|
402
|
+
return fn(qctx, input);
|
|
403
|
+
});
|
|
142
404
|
});
|
|
143
405
|
await deps.cache.setQueryCacheAndMetadataFor(cacheKey, result.data, {
|
|
144
406
|
lastSnapshotTs: result.ts,
|
|
@@ -152,7 +414,7 @@ async function runCachedQuery(deps, procedure, procedureName, input, serverCtx,
|
|
|
152
414
|
}
|
|
153
415
|
//#endregion
|
|
154
416
|
//#region src/server/sub-manager-client.ts
|
|
155
|
-
const logger$
|
|
417
|
+
const logger$3 = logger.child({ name: "SUBS_MGR_CLIENT" }, { level: logLevelOf("SUB_MANAGER_CLIENT_LOG_LEVEL") });
|
|
156
418
|
const subscriptionManagerDataPack = {
|
|
157
419
|
encode: (data) => supaliveStringify(data),
|
|
158
420
|
decode: (data) => JSON.parse(data)
|
|
@@ -209,7 +471,7 @@ var SubManagerClient = class {
|
|
|
209
471
|
this.client.dataPack = subscriptionManagerDataPack;
|
|
210
472
|
this.client.on("open", this.handleOpen.bind(this));
|
|
211
473
|
this.client.on("close", this.handleClose.bind(this));
|
|
212
|
-
this.client.on("error", (err) => logger$
|
|
474
|
+
this.client.on("error", (err) => logger$3.warn(err, "sub-manager client socket error"));
|
|
213
475
|
}
|
|
214
476
|
/**
|
|
215
477
|
* Wire up the app-server-driven recovery. Must be called before any
|
|
@@ -270,7 +532,7 @@ var SubManagerClient = class {
|
|
|
270
532
|
appendToBuffer(writeSet) {
|
|
271
533
|
if (this.bufferedWriteSets.length + writeSet.length > this.bufferLimit) {
|
|
272
534
|
if (!this.bufferDroppedDuringOutage) {
|
|
273
|
-
logger$
|
|
535
|
+
logger$3.warn(`Invalidate buffer would exceed ${this.bufferLimit}; dropping buffered entries. Subscriptions will be caught up via commit-log replay on re-register.`);
|
|
274
536
|
this.bufferDroppedDuringOutage = true;
|
|
275
537
|
}
|
|
276
538
|
this.bufferedWriteSets = [];
|
|
@@ -287,7 +549,7 @@ var SubManagerClient = class {
|
|
|
287
549
|
const result = await this.call("invalidateWriteset", { writeSet }, InvalidateWritesetResultSchema);
|
|
288
550
|
if (this.affectedHandler && result.affected.length > 0) await this.affectedHandler(result.affected);
|
|
289
551
|
} catch (err) {
|
|
290
|
-
logger$
|
|
552
|
+
logger$3.error(err, "Failed to flush invalidate buffer after recovery");
|
|
291
553
|
}
|
|
292
554
|
}
|
|
293
555
|
markBusy() {
|
|
@@ -303,28 +565,28 @@ var SubManagerClient = class {
|
|
|
303
565
|
this.resolveReady();
|
|
304
566
|
}
|
|
305
567
|
handleClose() {
|
|
306
|
-
logger$
|
|
568
|
+
logger$3.info("sub-manager client: socket closed");
|
|
307
569
|
this.markBusy();
|
|
308
570
|
}
|
|
309
571
|
handleOpen() {
|
|
310
572
|
if (!this.hasConnectedOnce) {
|
|
311
573
|
this.hasConnectedOnce = true;
|
|
312
|
-
logger$
|
|
574
|
+
logger$3.info("sub-manager client: initial connect");
|
|
313
575
|
this.markReady();
|
|
314
576
|
return;
|
|
315
577
|
}
|
|
316
|
-
logger$
|
|
578
|
+
logger$3.info("sub-manager client: reconnected — starting recovery");
|
|
317
579
|
this.runRecovery();
|
|
318
580
|
}
|
|
319
581
|
async runRecovery() {
|
|
320
582
|
try {
|
|
321
583
|
if (this.recoveryDriver) await this.recoveryDriver();
|
|
322
|
-
else logger$
|
|
584
|
+
else logger$3.warn("sub-manager client: no recoveryDriver configured — skipping recovery step");
|
|
323
585
|
await this.flushBuffer();
|
|
324
586
|
} catch (err) {
|
|
325
|
-
logger$
|
|
587
|
+
logger$3.error(err, "Recovery failed; state will be marked ready anyway");
|
|
326
588
|
} finally {
|
|
327
|
-
logger$
|
|
589
|
+
logger$3.info("sub-manager client: recovery complete");
|
|
328
590
|
this.markReady();
|
|
329
591
|
}
|
|
330
592
|
}
|
|
@@ -342,7 +604,7 @@ var SubManagerClient = class {
|
|
|
342
604
|
};
|
|
343
605
|
//#endregion
|
|
344
606
|
//#region src/jobs/scheduler.ts
|
|
345
|
-
const logger$
|
|
607
|
+
const logger$2 = logger.child({ name: "SCHEDULER" }, { level: logLevelOf("SCHEDULER_LOG_LEVEL") });
|
|
346
608
|
function delayMsFrom(opts) {
|
|
347
609
|
if (opts?.runAt) return Math.max(0, opts.runAt.getTime() - Date.now());
|
|
348
610
|
if (opts?.delaySeconds && opts.delaySeconds > 0) return opts.delaySeconds * 1e3;
|
|
@@ -372,18 +634,18 @@ var DevScheduler = class {
|
|
|
372
634
|
for (const job of jobs) {
|
|
373
635
|
const endpoint = endpointFor(job.name);
|
|
374
636
|
const timer = setInterval(() => {
|
|
375
|
-
this.post(endpoint, {}).catch((err) => logger$
|
|
637
|
+
this.post(endpoint, {}).catch((err) => logger$2.error(err, `dev recurring job "${job.name}" failed`));
|
|
376
638
|
}, this.intervalMs);
|
|
377
639
|
timer.unref?.();
|
|
378
640
|
this.intervals.set(job.name, timer);
|
|
379
|
-
logger$
|
|
641
|
+
logger$2.info(`dev schedule "${job.name}" (cron "${job.cron}") → firing every ${this.intervalMs}ms`);
|
|
380
642
|
}
|
|
381
643
|
}
|
|
382
644
|
async enqueue(name, body, endpoint, opts) {
|
|
383
645
|
const id = crypto.randomUUID();
|
|
384
646
|
const timer = setTimeout(() => {
|
|
385
647
|
this.timeouts.delete(id);
|
|
386
|
-
this.post(endpoint, body).catch((err) => logger$
|
|
648
|
+
this.post(endpoint, body).catch((err) => logger$2.error(err, `dev one-shot job "${name}" failed`));
|
|
387
649
|
}, delayMsFrom(opts));
|
|
388
650
|
timer.unref?.();
|
|
389
651
|
this.timeouts.set(id, timer);
|
|
@@ -441,7 +703,7 @@ var QStashScheduler = class {
|
|
|
441
703
|
url: req.url
|
|
442
704
|
});
|
|
443
705
|
} catch (err) {
|
|
444
|
-
logger$
|
|
706
|
+
logger$2.warn(err, `signature verification failed for job "${req.name}"`);
|
|
445
707
|
return false;
|
|
446
708
|
}
|
|
447
709
|
}
|
|
@@ -451,15 +713,15 @@ var QStashScheduler = class {
|
|
|
451
713
|
try {
|
|
452
714
|
existing = await this.client.schedules.list();
|
|
453
715
|
} catch (err) {
|
|
454
|
-
logger$
|
|
716
|
+
logger$2.error(err, "failed to list QStash schedules; skipping reconcile of stale schedules");
|
|
455
717
|
}
|
|
456
718
|
for (const schedule of existing) {
|
|
457
719
|
const id = schedule.scheduleId;
|
|
458
720
|
if (id?.startsWith(SCHEDULE_ID_PREFIX) && !desired.has(id)) try {
|
|
459
721
|
await this.client.schedules.delete(id);
|
|
460
|
-
logger$
|
|
722
|
+
logger$2.info(`removed stale QStash schedule ${id}`);
|
|
461
723
|
} catch (err) {
|
|
462
|
-
logger$
|
|
724
|
+
logger$2.error(err, `failed to remove stale schedule ${id}`);
|
|
463
725
|
}
|
|
464
726
|
}
|
|
465
727
|
for (const [scheduleId, job] of desired) try {
|
|
@@ -468,9 +730,9 @@ var QStashScheduler = class {
|
|
|
468
730
|
destination: endpointFor(job.name),
|
|
469
731
|
cron: job.cron
|
|
470
732
|
});
|
|
471
|
-
logger$
|
|
733
|
+
logger$2.info(`synced QStash schedule "${job.name}" (${job.cron})`);
|
|
472
734
|
} catch (err) {
|
|
473
|
-
logger$
|
|
735
|
+
logger$2.error(err, `failed to sync schedule for job "${job.name}"`);
|
|
474
736
|
throw err;
|
|
475
737
|
}
|
|
476
738
|
}
|
|
@@ -557,7 +819,6 @@ var FixedWindowRateLimiter = class {
|
|
|
557
819
|
};
|
|
558
820
|
//#endregion
|
|
559
821
|
//#region src/server/http-rpc.ts
|
|
560
|
-
const logger$3 = logger.child({ name: "HTTP_RPC" }, { level: logLevelOf("SERVER_LOG_LEVEL") });
|
|
561
822
|
/**
|
|
562
823
|
* Handles the one-shot HTTP RPC transport: a single `POST {path}` endpoint for
|
|
563
824
|
* `query`/`mutation`/`action`. The procedure name travels in the request body
|
|
@@ -573,44 +834,87 @@ const logger$3 = logger.child({ name: "HTTP_RPC" }, { level: logLevelOf("SERVER_
|
|
|
573
834
|
*/
|
|
574
835
|
var HttpRpcHandler = class {
|
|
575
836
|
deps;
|
|
837
|
+
/** The RPC endpoint path (always set; claimed even when disabled so a call is
|
|
838
|
+
* explicitly rejected rather than silently falling through). */
|
|
576
839
|
path;
|
|
577
840
|
rpc;
|
|
841
|
+
rpcEnabled;
|
|
578
842
|
rateLimiter;
|
|
843
|
+
/** Job webhook prefix (`{jobPath}/`) — a job POST is `{jobPath}/<jobName>`. */
|
|
844
|
+
jobPrefix;
|
|
579
845
|
constructor(deps) {
|
|
580
846
|
this.deps = deps;
|
|
581
847
|
this.rpc = deps.rpc;
|
|
848
|
+
this.rpcEnabled = deps.rpcEnabled;
|
|
582
849
|
this.path = normalizeRpcPath(deps.rpc.path ?? "/_rpc");
|
|
583
|
-
if (deps.rpc.rateLimit !== false) {
|
|
850
|
+
if (this.rpcEnabled && deps.rpc.rateLimit !== false) {
|
|
584
851
|
const rl = deps.rpc.rateLimit ?? {};
|
|
585
852
|
this.rateLimiter = new FixedWindowRateLimiter(rl.windowMs ?? 6e4, rl.max ?? 120);
|
|
586
853
|
}
|
|
854
|
+
this.jobPrefix = `${deps.jobPath}/`;
|
|
587
855
|
}
|
|
588
|
-
/** True when this handler owns the given request path
|
|
589
|
-
* carried in the body now, so a single exact path is the
|
|
856
|
+
/** True when this handler owns the given request path as an RPC call. The
|
|
857
|
+
* procedure name is carried in the body now, so a single exact path is the
|
|
858
|
+
* whole RPC surface. Claims the path even when disabled so {@link handle}
|
|
859
|
+
* rejects the call explicitly. */
|
|
590
860
|
handlesPath(path) {
|
|
591
861
|
return path === this.path;
|
|
592
862
|
}
|
|
863
|
+
/** True when the path is a job webhook (`{jobPath}/<name>`). */
|
|
864
|
+
handlesJobPath(path) {
|
|
865
|
+
return path.startsWith(this.jobPrefix);
|
|
866
|
+
}
|
|
593
867
|
stop() {
|
|
594
868
|
this.rateLimiter?.stop();
|
|
595
869
|
}
|
|
596
870
|
async handle(req, res, path) {
|
|
871
|
+
if (!this.rpcEnabled) return this.send(res, 404, {
|
|
872
|
+
success: false,
|
|
873
|
+
error: "not found",
|
|
874
|
+
code: "NOT_FOUND"
|
|
875
|
+
});
|
|
597
876
|
this.applyCors(req, res);
|
|
598
877
|
if (req.method === "OPTIONS") {
|
|
599
878
|
res.writeHead(204);
|
|
600
879
|
res.end();
|
|
601
880
|
return;
|
|
602
881
|
}
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
882
|
+
const startedAt = startTimer();
|
|
883
|
+
const reqId = crypto.randomUUID().slice(0, 8);
|
|
884
|
+
let procName;
|
|
885
|
+
let kind;
|
|
886
|
+
let userId;
|
|
887
|
+
let span;
|
|
888
|
+
const record = (outcome, opts) => {
|
|
889
|
+
this.deps.obs.record({
|
|
890
|
+
op: "rpc",
|
|
891
|
+
transport: "http",
|
|
892
|
+
procedure: procName,
|
|
893
|
+
kind,
|
|
894
|
+
outcome,
|
|
895
|
+
durationMs: elapsedMs(startedAt),
|
|
896
|
+
code: opts?.code,
|
|
897
|
+
error: opts?.error,
|
|
898
|
+
reqId,
|
|
899
|
+
userId: userId ?? void 0,
|
|
900
|
+
span
|
|
901
|
+
});
|
|
902
|
+
};
|
|
903
|
+
if (req.method !== "POST") {
|
|
904
|
+
record("error", { code: "METHOD_NOT_ALLOWED" });
|
|
905
|
+
return this.send(res, 405, {
|
|
906
|
+
success: false,
|
|
907
|
+
error: "method not allowed",
|
|
908
|
+
code: "METHOD_NOT_ALLOWED"
|
|
909
|
+
});
|
|
910
|
+
}
|
|
608
911
|
if (this.rateLimiter) {
|
|
609
912
|
const keyBy = this.rpc.rateLimit ? this.rpc.rateLimit.keyBy : void 0;
|
|
610
913
|
const key = keyBy ? keyBy(req) : clientIp(req);
|
|
611
914
|
const rl = this.rateLimiter.check(key);
|
|
612
915
|
if (!rl.allowed) {
|
|
613
916
|
res.setHeader("retry-after", String(Math.ceil(rl.retryAfterMs / 1e3)));
|
|
917
|
+
record("error", { code: "RATE_LIMITED" });
|
|
614
918
|
return this.send(res, 429, {
|
|
615
919
|
success: false,
|
|
616
920
|
error: "rate limit exceeded",
|
|
@@ -629,44 +933,88 @@ var HttpRpcHandler = class {
|
|
|
629
933
|
rawInput = envelope.input ?? {};
|
|
630
934
|
minTs = typeof envelope.minTs === "string" ? parseMinTs$1(envelope.minTs) : void 0;
|
|
631
935
|
} catch {
|
|
936
|
+
record("error", { code: "BAD_REQUEST" });
|
|
632
937
|
return this.send(res, 400, {
|
|
633
938
|
success: false,
|
|
634
939
|
error: "invalid request",
|
|
635
940
|
code: "BAD_REQUEST"
|
|
636
941
|
});
|
|
637
942
|
}
|
|
943
|
+
procName = name;
|
|
638
944
|
const procedure = this.deps.getRegistry()?.getProcedure(name);
|
|
639
|
-
if (!procedure || procedure.internal || procedure.type === "job")
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
945
|
+
if (!procedure || procedure.internal || procedure.type === "job") {
|
|
946
|
+
record("error", { code: "NOT_FOUND" });
|
|
947
|
+
return this.send(res, 404, {
|
|
948
|
+
success: false,
|
|
949
|
+
error: "not found",
|
|
950
|
+
code: "NOT_FOUND"
|
|
951
|
+
});
|
|
952
|
+
}
|
|
953
|
+
kind = procedure.type;
|
|
644
954
|
const auth = await this.resolveAuth(req);
|
|
645
|
-
if (!auth.ok)
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
955
|
+
if (!auth.ok) {
|
|
956
|
+
record("error", { code: "UNAUTHENTICATED" });
|
|
957
|
+
return this.send(res, 401, {
|
|
958
|
+
success: false,
|
|
959
|
+
error: auth.message,
|
|
960
|
+
code: "UNAUTHENTICATED"
|
|
961
|
+
});
|
|
962
|
+
}
|
|
963
|
+
userId = this.deps.getUserId?.(auth.ctx);
|
|
650
964
|
let input;
|
|
651
965
|
try {
|
|
652
|
-
|
|
966
|
+
const parseRes = procedure.inputSchema.safeParse(rawInput);
|
|
967
|
+
if (!parseRes.success) {
|
|
968
|
+
record("error", { code: "BAD_REQUEST" });
|
|
969
|
+
return this.send(res, 400, {
|
|
970
|
+
success: false,
|
|
971
|
+
error: `Invalid input: ${parseRes.error.issues[0]?.message ?? "Unknow error"}`,
|
|
972
|
+
code: "BAD_REQUEST"
|
|
973
|
+
});
|
|
974
|
+
}
|
|
975
|
+
input = parseRes.data;
|
|
653
976
|
} catch (err) {
|
|
654
|
-
|
|
977
|
+
record("error", {
|
|
978
|
+
code: "BAD_REQUEST",
|
|
979
|
+
error: err
|
|
980
|
+
});
|
|
655
981
|
return this.send(res, 400, {
|
|
656
982
|
success: false,
|
|
657
983
|
error: "invalid input",
|
|
658
984
|
code: "BAD_REQUEST"
|
|
659
985
|
});
|
|
660
986
|
}
|
|
987
|
+
const started = this.deps.obs.startSpan(`rpc.${kind} ${name}`, {
|
|
988
|
+
transport: "http",
|
|
989
|
+
procedure: name,
|
|
990
|
+
kind,
|
|
991
|
+
parent: this.deps.obs.extractContext(req.headers)
|
|
992
|
+
});
|
|
993
|
+
span = started.span;
|
|
994
|
+
const traceId = this.deps.obs.traceIdOf(span);
|
|
995
|
+
const handlerObs = {
|
|
996
|
+
log: this.deps.obs.lazyChildLogger({
|
|
997
|
+
reqId,
|
|
998
|
+
procedure: name,
|
|
999
|
+
kind,
|
|
1000
|
+
...userId != null ? { userId } : {},
|
|
1001
|
+
...traceId ? { traceId } : {}
|
|
1002
|
+
}),
|
|
1003
|
+
span
|
|
1004
|
+
};
|
|
661
1005
|
try {
|
|
662
|
-
const { data, ts } = await this.runProcedure(procedure, input, auth.ctx, minTs);
|
|
1006
|
+
const { data, ts } = await context.with(started.context, () => this.runProcedure(procedure, input, auth.ctx, minTs, handlerObs));
|
|
1007
|
+
record("ok");
|
|
663
1008
|
return this.send(res, 200, {
|
|
664
1009
|
success: true,
|
|
665
1010
|
data,
|
|
666
1011
|
ts: ts?.toString()
|
|
667
1012
|
});
|
|
668
1013
|
} catch (err) {
|
|
669
|
-
|
|
1014
|
+
record("error", {
|
|
1015
|
+
code: "EXECUTION_ERROR",
|
|
1016
|
+
error: err
|
|
1017
|
+
});
|
|
670
1018
|
return this.send(res, 500, {
|
|
671
1019
|
success: false,
|
|
672
1020
|
error: err instanceof Error ? err.message : "execution failed",
|
|
@@ -674,10 +1022,97 @@ var HttpRpcHandler = class {
|
|
|
674
1022
|
});
|
|
675
1023
|
}
|
|
676
1024
|
}
|
|
677
|
-
|
|
1025
|
+
/**
|
|
1026
|
+
* Handles a job webhook: `POST {jobPath}/<jobName>`. Kept alongside {@link handle}
|
|
1027
|
+
* so the whole HTTP surface (RPC + jobs) lives in one class. Every job POST is
|
|
1028
|
+
* authenticated by the configured scheduler BEFORE any lookup or work — a forged
|
|
1029
|
+
* POST must not even learn whether a job name exists. Returns 500 on handler
|
|
1030
|
+
* failure so the scheduler retries (jobs must be idempotent).
|
|
1031
|
+
*/
|
|
1032
|
+
async handleJob(req, res, path) {
|
|
1033
|
+
if (req.method !== "POST") return this.send(res, 405, { error: "method not allowed" });
|
|
1034
|
+
const name = decodeURIComponent(path.slice(this.jobPrefix.length));
|
|
1035
|
+
if (!name || name.includes("/")) return this.send(res, 404, { error: "unknown job" });
|
|
1036
|
+
if (!this.deps.jobScheduler) return this.send(res, 503, { error: "jobs not enabled" });
|
|
1037
|
+
const startedAt = startTimer();
|
|
1038
|
+
const rawBody = await readBody(req);
|
|
1039
|
+
let verified = false;
|
|
1040
|
+
try {
|
|
1041
|
+
verified = await this.deps.jobScheduler.verify({
|
|
1042
|
+
name,
|
|
1043
|
+
rawBody,
|
|
1044
|
+
headers: lowercaseHeaders(req.headers),
|
|
1045
|
+
url: this.deps.endpointFor(name)
|
|
1046
|
+
});
|
|
1047
|
+
} catch (err) {
|
|
1048
|
+
this.deps.obs.logger.warn(err, `job "${name}" verification threw`);
|
|
1049
|
+
}
|
|
1050
|
+
if (!verified) return this.send(res, 401, { error: "invalid signature" });
|
|
1051
|
+
const procedure = this.deps.getRegistry()?.getProcedure(name);
|
|
1052
|
+
if (!procedure || procedure.type !== "job") return this.send(res, 404, { error: "unknown job" });
|
|
1053
|
+
let input;
|
|
1054
|
+
try {
|
|
1055
|
+
const parsed = rawBody.trim().length > 0 ? JSON.parse(rawBody) : {};
|
|
1056
|
+
input = procedure.inputSchema.parse(parsed);
|
|
1057
|
+
} catch (err) {
|
|
1058
|
+
this.deps.obs.logger.warn(err, `job "${name}" received an invalid payload`);
|
|
1059
|
+
return this.send(res, 400, { error: "invalid payload" });
|
|
1060
|
+
}
|
|
1061
|
+
const started = this.deps.obs.startSpan(`job ${name}`, {
|
|
1062
|
+
transport: "http",
|
|
1063
|
+
procedure: name,
|
|
1064
|
+
kind: "job",
|
|
1065
|
+
parent: this.deps.obs.extractContext(req.headers)
|
|
1066
|
+
});
|
|
1067
|
+
const span = started.span;
|
|
1068
|
+
const traceId = this.deps.obs.traceIdOf(span);
|
|
1069
|
+
const jobLog = this.deps.obs.lazyChildLogger({
|
|
1070
|
+
reqId: span && traceId ? traceId : name,
|
|
1071
|
+
procedure: name,
|
|
1072
|
+
kind: "job",
|
|
1073
|
+
...traceId ? { traceId } : {}
|
|
1074
|
+
});
|
|
1075
|
+
try {
|
|
1076
|
+
const serverCtx = this.deps.jobContext ? await this.deps.jobContext() : void 0;
|
|
1077
|
+
const fn = procedure.fn;
|
|
1078
|
+
const result = await context.with(started.context, () => fn({
|
|
1079
|
+
db: this.deps.db,
|
|
1080
|
+
serverCtx,
|
|
1081
|
+
log: jobLog,
|
|
1082
|
+
span
|
|
1083
|
+
}, input));
|
|
1084
|
+
this.deps.obs.record({
|
|
1085
|
+
op: "job",
|
|
1086
|
+
transport: "http",
|
|
1087
|
+
procedure: name,
|
|
1088
|
+
kind: "job",
|
|
1089
|
+
outcome: "ok",
|
|
1090
|
+
durationMs: elapsedMs(startedAt),
|
|
1091
|
+
span
|
|
1092
|
+
});
|
|
1093
|
+
return this.send(res, 200, {
|
|
1094
|
+
ok: true,
|
|
1095
|
+
result
|
|
1096
|
+
});
|
|
1097
|
+
} catch (err) {
|
|
1098
|
+
this.deps.obs.record({
|
|
1099
|
+
op: "job",
|
|
1100
|
+
transport: "http",
|
|
1101
|
+
procedure: name,
|
|
1102
|
+
kind: "job",
|
|
1103
|
+
outcome: "error",
|
|
1104
|
+
durationMs: elapsedMs(startedAt),
|
|
1105
|
+
code: "JOB_FAILED",
|
|
1106
|
+
error: err,
|
|
1107
|
+
span
|
|
1108
|
+
});
|
|
1109
|
+
return this.send(res, 500, { error: "job failed" });
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
async runProcedure(procedure, input, serverCtx, minTs, handlerObs) {
|
|
678
1113
|
if (procedure.type === "query") {
|
|
679
1114
|
const lastSnapshotTs = await this.deps.db.impl.getLatestSnapshotTimestamp();
|
|
680
|
-
return runCachedQuery(this.deps.oneShotDeps, procedure, procedure.name, input, serverCtx, lastSnapshotTs, minTs);
|
|
1115
|
+
return runCachedQuery(this.deps.oneShotDeps, handlerObs, procedure, procedure.name, input, serverCtx, lastSnapshotTs, minTs);
|
|
681
1116
|
}
|
|
682
1117
|
if (procedure.type === "mutation") {
|
|
683
1118
|
const res = await this.deps.db.mutation(async (ctx) => {
|
|
@@ -685,7 +1120,9 @@ var HttpRpcHandler = class {
|
|
|
685
1120
|
return fn({
|
|
686
1121
|
db: ctx,
|
|
687
1122
|
serverCtx,
|
|
688
|
-
...primaryOnlyConn
|
|
1123
|
+
...primaryOnlyConn,
|
|
1124
|
+
log: handlerObs.log,
|
|
1125
|
+
span: handlerObs.span
|
|
689
1126
|
}, input);
|
|
690
1127
|
});
|
|
691
1128
|
return {
|
|
@@ -698,7 +1135,9 @@ var HttpRpcHandler = class {
|
|
|
698
1135
|
db: this.deps.db,
|
|
699
1136
|
serverCtx,
|
|
700
1137
|
storage: this.deps.storage,
|
|
701
|
-
scheduler: this.deps.scheduler
|
|
1138
|
+
scheduler: this.deps.scheduler,
|
|
1139
|
+
log: handlerObs.log,
|
|
1140
|
+
span: handlerObs.span
|
|
702
1141
|
}, input) };
|
|
703
1142
|
}
|
|
704
1143
|
async resolveAuth(req) {
|
|
@@ -769,6 +1208,11 @@ function defaultHttpAuth(req) {
|
|
|
769
1208
|
const match = /^Bearer\s+(.+)$/i.exec(value);
|
|
770
1209
|
return match ? { token: match[1] } : void 0;
|
|
771
1210
|
}
|
|
1211
|
+
function lowercaseHeaders(headers) {
|
|
1212
|
+
const out = {};
|
|
1213
|
+
for (const [k, v] of Object.entries(headers)) out[k.toLowerCase()] = Array.isArray(v) ? v.join(",") : v;
|
|
1214
|
+
return out;
|
|
1215
|
+
}
|
|
772
1216
|
function clientIp(req) {
|
|
773
1217
|
const xff = req.headers["x-forwarded-for"];
|
|
774
1218
|
if (xff) {
|
|
@@ -840,7 +1284,6 @@ const CLIENT_TIMEOUT_MS = 12e4;
|
|
|
840
1284
|
* mutation triggers a new race.
|
|
841
1285
|
*/
|
|
842
1286
|
const RECOMPUTE_LOCK_TTL_SECONDS = Number(process.env.SUPALIVE_RECOMPUTE_LOCK_TTL_SECONDS ?? 10);
|
|
843
|
-
const logger$2 = logger.child({ "name": "SUPALIVE" }, { level: logLevelOf("SERVER_LOG_LEVEL") });
|
|
844
1287
|
/**
|
|
845
1288
|
* Supalive WebSocket Server
|
|
846
1289
|
*/
|
|
@@ -861,6 +1304,8 @@ var SupaliveWebSocketServer = class {
|
|
|
861
1304
|
scheduler;
|
|
862
1305
|
jobPath;
|
|
863
1306
|
jobClient;
|
|
1307
|
+
/** Owns the HTTP surface: one-shot RPC (when `config.rpc` is set) AND the job
|
|
1308
|
+
* webhook. Always constructed — jobs work even without RPC enabled. */
|
|
864
1309
|
rpcHandler;
|
|
865
1310
|
/** Optional object storage exposed to every procedure as `ctx.storage`. */
|
|
866
1311
|
storage;
|
|
@@ -872,6 +1317,10 @@ var SupaliveWebSocketServer = class {
|
|
|
872
1317
|
totalConnections: 0,
|
|
873
1318
|
activeSubscriptions: 0
|
|
874
1319
|
};
|
|
1320
|
+
/** Structured logs + OTel metrics + the per-operation `onEvent` seam. */
|
|
1321
|
+
obs;
|
|
1322
|
+
/** The request/lifecycle logger (injectable via `config.observability.logger`). */
|
|
1323
|
+
log;
|
|
875
1324
|
constructor(config) {
|
|
876
1325
|
this.config = {
|
|
877
1326
|
port: 3e3,
|
|
@@ -881,6 +1330,15 @@ var SupaliveWebSocketServer = class {
|
|
|
881
1330
|
...config
|
|
882
1331
|
};
|
|
883
1332
|
this.instanceId = appInstanceId;
|
|
1333
|
+
this.obs = new Observability({
|
|
1334
|
+
logger: (config.observability?.logger ?? logger).child({ name: "SUPALIVE" }, { level: config.observability?.level ?? logLevelOf("SERVER_LOG_LEVEL") }),
|
|
1335
|
+
level: void 0,
|
|
1336
|
+
requestLog: config.observability?.requestLog,
|
|
1337
|
+
onEvent: config.observability?.onEvent,
|
|
1338
|
+
meterName: config.observability?.meterName,
|
|
1339
|
+
meterVersion: config.observability?.meterVersion
|
|
1340
|
+
});
|
|
1341
|
+
this.log = this.obs.logger;
|
|
884
1342
|
if (config.subManager) this.subManagerClient = config.subManager;
|
|
885
1343
|
else if (config.subManagerUrl) this.subManagerClient = new SubManagerClient(config.subManagerUrl);
|
|
886
1344
|
else throw new Error("either `subManager` (in-process link) or `subManagerUrl` (WebSocket URL) is required");
|
|
@@ -890,7 +1348,7 @@ var SupaliveWebSocketServer = class {
|
|
|
890
1348
|
this.db = config.database;
|
|
891
1349
|
this.storage = config.storage;
|
|
892
1350
|
this.db.setOnAfterCommit((writeSet, commitTs) => {
|
|
893
|
-
this.handleMutationCommit(writeSet, commitTs).catch((err) =>
|
|
1351
|
+
this.handleMutationCommit(writeSet, commitTs).catch((err) => this.log.error(err, "post-commit invalidation failed"));
|
|
894
1352
|
});
|
|
895
1353
|
this.scheduler = config.scheduler;
|
|
896
1354
|
this.jobPath = normalizeJobPath(config.jobPath ?? "/_jobs");
|
|
@@ -901,20 +1359,27 @@ var SupaliveWebSocketServer = class {
|
|
|
901
1359
|
getUserId: config.getUserId,
|
|
902
1360
|
getMinRetainedTs: () => this.retentionWatermark.get()
|
|
903
1361
|
};
|
|
904
|
-
|
|
1362
|
+
this.rpcHandler = new HttpRpcHandler({
|
|
905
1363
|
getRegistry: () => this.contextRegistry,
|
|
906
1364
|
db: this.db,
|
|
907
1365
|
oneShotDeps: this.oneShotDeps,
|
|
908
1366
|
verifyAuth: config.verifyAuth,
|
|
909
|
-
rpc: config.rpc,
|
|
1367
|
+
rpc: config.rpc ?? {},
|
|
1368
|
+
rpcEnabled: config.rpc !== void 0,
|
|
910
1369
|
storage: this.storage,
|
|
911
|
-
scheduler: this.jobClient
|
|
1370
|
+
scheduler: this.jobClient,
|
|
1371
|
+
obs: this.obs,
|
|
1372
|
+
getUserId: config.getUserId,
|
|
1373
|
+
jobScheduler: this.scheduler,
|
|
1374
|
+
jobContext: config.jobContext,
|
|
1375
|
+
jobPath: this.jobPath,
|
|
1376
|
+
endpointFor: (name) => this.endpointFor(name)
|
|
912
1377
|
});
|
|
913
1378
|
this.subManagerClient.setRecoveryDriver(this.runSubManagerRecovery.bind(this));
|
|
914
1379
|
this.subManagerClient.setAffectedHandler(this.runAffectedHandler.bind(this));
|
|
915
1380
|
this.httpServer = createServer((req, res) => {
|
|
916
1381
|
this.handleHttpRequest(req, res).catch((err) => {
|
|
917
|
-
|
|
1382
|
+
this.log.error(err, "HTTP request handler error");
|
|
918
1383
|
if (!res.headersSent) {
|
|
919
1384
|
res.writeHead(500, { "content-type": "application/json" });
|
|
920
1385
|
res.end(JSON.stringify({ error: "internal error" }));
|
|
@@ -944,7 +1409,7 @@ var SupaliveWebSocketServer = class {
|
|
|
944
1409
|
startSessionHeartbeat(session) {
|
|
945
1410
|
session.heartbeatTimer = setInterval(() => {
|
|
946
1411
|
if (Date.now() - session.lastReceivedAt > CLIENT_TIMEOUT_MS) {
|
|
947
|
-
|
|
1412
|
+
this.log.info(`ws inactivity → terminate session ${session.id}`);
|
|
948
1413
|
try {
|
|
949
1414
|
session.ws.terminate();
|
|
950
1415
|
} catch {}
|
|
@@ -970,11 +1435,11 @@ var SupaliveWebSocketServer = class {
|
|
|
970
1435
|
try {
|
|
971
1436
|
await this.retentionWatermark.loadFrom(this.db.impl);
|
|
972
1437
|
} catch (err) {
|
|
973
|
-
|
|
1438
|
+
this.log.error(err, "failed to load initial retention watermark");
|
|
974
1439
|
}
|
|
975
1440
|
await new Promise((resolve) => {
|
|
976
1441
|
this.httpServer.listen(this.config.port, this.config.host, () => {
|
|
977
|
-
|
|
1442
|
+
this.log.info(`Server: ${this.instanceId} listening on ${this.config.host}:${this.config.port}`);
|
|
978
1443
|
resolve();
|
|
979
1444
|
});
|
|
980
1445
|
});
|
|
@@ -995,11 +1460,14 @@ var SupaliveWebSocketServer = class {
|
|
|
995
1460
|
if (jobs.length === 0) return;
|
|
996
1461
|
try {
|
|
997
1462
|
await this.scheduler.syncSchedules(jobs, (name) => this.endpointFor(name));
|
|
998
|
-
|
|
1463
|
+
this.log.info(`Synced ${jobs.length} recurring job schedule(s)`);
|
|
999
1464
|
} catch (err) {
|
|
1000
|
-
|
|
1465
|
+
this.log.error(err, "Failed to sync job schedules");
|
|
1001
1466
|
}
|
|
1002
1467
|
}
|
|
1468
|
+
getObservability() {
|
|
1469
|
+
return this.obs;
|
|
1470
|
+
}
|
|
1003
1471
|
/** Absolute URL the scheduler POSTs to for a given job name. */
|
|
1004
1472
|
endpointFor(name) {
|
|
1005
1473
|
return `${this.config.publicUrl}${this.jobPath}/${name}`;
|
|
@@ -1028,9 +1496,9 @@ var SupaliveWebSocketServer = class {
|
|
|
1028
1496
|
async stop() {
|
|
1029
1497
|
this.wss.close();
|
|
1030
1498
|
for (const [ws] of this.sessions) ws.close(1001, "Server shutting down");
|
|
1031
|
-
this.rpcHandler
|
|
1499
|
+
this.rpcHandler.stop();
|
|
1032
1500
|
this.httpServer.close();
|
|
1033
|
-
|
|
1501
|
+
this.log.info("Server stopped");
|
|
1034
1502
|
}
|
|
1035
1503
|
getStats() {
|
|
1036
1504
|
return {
|
|
@@ -1054,63 +1522,25 @@ var SupaliveWebSocketServer = class {
|
|
|
1054
1522
|
status: "ok",
|
|
1055
1523
|
instanceId: this.instanceId
|
|
1056
1524
|
});
|
|
1057
|
-
if (this.rpcHandler
|
|
1058
|
-
|
|
1059
|
-
if (
|
|
1060
|
-
|
|
1061
|
-
const name = decodeURIComponent(path.slice(jobPrefix.length));
|
|
1062
|
-
if (!name || name.includes("/")) return sendJson(res, 404, { error: "unknown job" });
|
|
1063
|
-
if (!this.scheduler) return sendJson(res, 503, { error: "jobs not enabled" });
|
|
1064
|
-
const rawBody = await readRequestBody(req);
|
|
1065
|
-
let verified = false;
|
|
1066
|
-
try {
|
|
1067
|
-
verified = await this.scheduler.verify({
|
|
1068
|
-
name,
|
|
1069
|
-
rawBody,
|
|
1070
|
-
headers: lowercaseHeaders(req.headers),
|
|
1071
|
-
url: this.endpointFor(name)
|
|
1072
|
-
});
|
|
1073
|
-
} catch (err) {
|
|
1074
|
-
logger$2.warn(err, `job "${name}" verification threw`);
|
|
1075
|
-
}
|
|
1076
|
-
if (!verified) return sendJson(res, 401, { error: "invalid signature" });
|
|
1077
|
-
const procedure = this.contextRegistry?.getProcedure(name);
|
|
1078
|
-
if (!procedure || procedure.type !== "job") return sendJson(res, 404, { error: "unknown job" });
|
|
1079
|
-
let input;
|
|
1080
|
-
try {
|
|
1081
|
-
const parsed = rawBody.trim().length > 0 ? JSON.parse(rawBody) : {};
|
|
1082
|
-
input = procedure.inputSchema.parse(parsed);
|
|
1083
|
-
} catch (err) {
|
|
1084
|
-
logger$2.warn(err, `job "${name}" received an invalid payload`);
|
|
1085
|
-
return sendJson(res, 400, { error: "invalid payload" });
|
|
1086
|
-
}
|
|
1087
|
-
try {
|
|
1088
|
-
const serverCtx = this.config.jobContext ? await this.config.jobContext() : void 0;
|
|
1089
|
-
const fn = procedure.fn;
|
|
1090
|
-
const result = await fn({
|
|
1091
|
-
db: this.db,
|
|
1092
|
-
serverCtx
|
|
1093
|
-
}, input);
|
|
1094
|
-
logger$2.info(`job "${name}" completed`);
|
|
1095
|
-
return sendJson(res, 200, {
|
|
1096
|
-
ok: true,
|
|
1097
|
-
result
|
|
1098
|
-
});
|
|
1099
|
-
} catch (err) {
|
|
1100
|
-
logger$2.error(err, `job "${name}" failed`);
|
|
1101
|
-
return sendJson(res, 500, { error: "job failed" });
|
|
1525
|
+
if (this.rpcHandler.handlesPath(path)) return this.rpcHandler.handle(req, res, path);
|
|
1526
|
+
if (this.rpcHandler.handlesJobPath(path)) return this.rpcHandler.handleJob(req, res, path);
|
|
1527
|
+
if (this.config.onHttpRequest) {
|
|
1528
|
+
if (await this.config.onHttpRequest(req, res)) return;
|
|
1102
1529
|
}
|
|
1530
|
+
return sendJson(res, 404, { error: "not found" });
|
|
1103
1531
|
}
|
|
1104
1532
|
setupWebSocketHandlers() {
|
|
1105
1533
|
this.wss.on("connection", (ws, req) => {
|
|
1106
1534
|
this.handleConnection(ws, req).catch((err) => {
|
|
1107
|
-
|
|
1535
|
+
this.log.error(err, "Connection handler error");
|
|
1108
1536
|
});
|
|
1109
1537
|
});
|
|
1110
1538
|
}
|
|
1111
1539
|
async handleConnection(ws, req) {
|
|
1112
1540
|
this.stats.totalConnections++;
|
|
1113
1541
|
this.stats.activeConnections++;
|
|
1542
|
+
this.obs.connectionOpened();
|
|
1543
|
+
const connectedAt = startTimer();
|
|
1114
1544
|
const session = {
|
|
1115
1545
|
id: v4(),
|
|
1116
1546
|
ws,
|
|
@@ -1123,7 +1553,10 @@ var SupaliveWebSocketServer = class {
|
|
|
1123
1553
|
lastSentAt: Date.now(),
|
|
1124
1554
|
heartbeatTimer: null
|
|
1125
1555
|
};
|
|
1126
|
-
|
|
1556
|
+
this.log.info({
|
|
1557
|
+
sessionId: session.id,
|
|
1558
|
+
activeConnections: this.stats.activeConnections
|
|
1559
|
+
}, `New session: ${session.id}`);
|
|
1127
1560
|
this.sessions.set(ws, session);
|
|
1128
1561
|
this.sendMessage(session, {
|
|
1129
1562
|
type: "connected",
|
|
@@ -1132,7 +1565,7 @@ var SupaliveWebSocketServer = class {
|
|
|
1132
1565
|
ws.on("message", (data) => {
|
|
1133
1566
|
session.lastReceivedAt = Date.now();
|
|
1134
1567
|
this.handleMessage(session, data.toString()).catch((err) => {
|
|
1135
|
-
|
|
1568
|
+
this.log.error(err, "Message handler error");
|
|
1136
1569
|
});
|
|
1137
1570
|
});
|
|
1138
1571
|
ws.on("ping", () => {
|
|
@@ -1143,17 +1576,24 @@ var SupaliveWebSocketServer = class {
|
|
|
1143
1576
|
});
|
|
1144
1577
|
this.startSessionHeartbeat(session);
|
|
1145
1578
|
ws.on("close", (code) => {
|
|
1146
|
-
logger$2.info(`Connection closed: ${session.id}, code: ${code}`);
|
|
1147
1579
|
this.clearSessionTimers(session);
|
|
1148
1580
|
this.handleDisconnect(session);
|
|
1149
1581
|
this.sessions.delete(ws);
|
|
1150
1582
|
this.stats.activeConnections--;
|
|
1583
|
+
this.obs.connectionClosed();
|
|
1584
|
+
this.log.info({
|
|
1585
|
+
sessionId: session.id,
|
|
1586
|
+
code,
|
|
1587
|
+
durMs: elapsedMs(connectedAt),
|
|
1588
|
+
activeConnections: this.stats.activeConnections
|
|
1589
|
+
}, `Connection closed: ${session.id}, code: ${code}`);
|
|
1151
1590
|
});
|
|
1152
1591
|
ws.on("error", (err) => {
|
|
1153
|
-
|
|
1592
|
+
this.log.error(err, "Websocket error");
|
|
1154
1593
|
});
|
|
1155
1594
|
}
|
|
1156
1595
|
async handleMessage(session, message) {
|
|
1596
|
+
const startedAt = startTimer();
|
|
1157
1597
|
try {
|
|
1158
1598
|
const rawMsg = JSON.parse(message);
|
|
1159
1599
|
const parseResult = ClientMessageSchema.safeParse(rawMsg);
|
|
@@ -1161,22 +1601,22 @@ var SupaliveWebSocketServer = class {
|
|
|
1161
1601
|
this.sendError(session, "", "Invalid message format", "INVALID_MESSAGE");
|
|
1162
1602
|
return;
|
|
1163
1603
|
}
|
|
1164
|
-
await this.processMessage(session, parseResult.data);
|
|
1604
|
+
await this.processMessage(session, parseResult.data, startedAt);
|
|
1165
1605
|
} catch (err) {
|
|
1166
|
-
|
|
1606
|
+
this.log.error(err, "Message handling error");
|
|
1167
1607
|
this.sendError(session, "", "Parse error", "PARSE_ERROR");
|
|
1168
1608
|
}
|
|
1169
1609
|
}
|
|
1170
|
-
async processMessage(session, msg) {
|
|
1610
|
+
async processMessage(session, msg, startedAt) {
|
|
1171
1611
|
switch (msg.type) {
|
|
1172
1612
|
case "auth":
|
|
1173
1613
|
await this.handleAuth(session, msg.data);
|
|
1174
1614
|
break;
|
|
1175
1615
|
case "call":
|
|
1176
|
-
await this.handleCall(session, msg.id, msg.procedure, msg.input, parseMinTs(msg.minTs));
|
|
1616
|
+
await this.handleCall(session, msg.id, msg.procedure, msg.input, startedAt, parseMinTs(msg.minTs), msg.trace);
|
|
1177
1617
|
break;
|
|
1178
1618
|
case "subscribe":
|
|
1179
|
-
await this.handleSubscribe(session, msg.id, msg.procedure, msg.input);
|
|
1619
|
+
await this.handleSubscribe(session, msg.id, msg.procedure, msg.input, startedAt, msg.trace);
|
|
1180
1620
|
break;
|
|
1181
1621
|
case "unsubscribe":
|
|
1182
1622
|
await this.handleUnsubscribe(session, msg.subId);
|
|
@@ -1224,16 +1664,17 @@ var SupaliveWebSocketServer = class {
|
|
|
1224
1664
|
* session. Callers (handleCall) own the response/error for their own
|
|
1225
1665
|
* op via the returned promise.
|
|
1226
1666
|
*/
|
|
1227
|
-
enqueueMutation(session, procedure, validatedInput) {
|
|
1667
|
+
enqueueMutation(session, procedure, validatedInput, handlerObs) {
|
|
1228
1668
|
return new Promise((resolve, reject) => {
|
|
1229
1669
|
const pending = {
|
|
1230
1670
|
procedure,
|
|
1231
1671
|
validatedInput,
|
|
1232
1672
|
resolve,
|
|
1233
|
-
reject
|
|
1673
|
+
reject,
|
|
1674
|
+
handlerObs
|
|
1234
1675
|
};
|
|
1235
1676
|
session.mutationQueue.push(pending);
|
|
1236
|
-
this.drainMutationQueue(session).catch((err) =>
|
|
1677
|
+
this.drainMutationQueue(session).catch((err) => this.log.error(err, "drainMutationQueue crashed"));
|
|
1237
1678
|
});
|
|
1238
1679
|
}
|
|
1239
1680
|
/**
|
|
@@ -1249,13 +1690,20 @@ var SupaliveWebSocketServer = class {
|
|
|
1249
1690
|
const op = session.mutationQueue.shift();
|
|
1250
1691
|
try {
|
|
1251
1692
|
const fn = op.procedure.fn;
|
|
1252
|
-
const
|
|
1693
|
+
const runMutation = () => this.db.mutation(async (ctx) => {
|
|
1253
1694
|
return fn({
|
|
1254
1695
|
db: ctx,
|
|
1255
1696
|
serverCtx: session.serverCtx,
|
|
1256
|
-
...primaryOnlyConn
|
|
1697
|
+
...primaryOnlyConn,
|
|
1698
|
+
log: op.handlerObs?.log ?? this.obs.lazyChildLogger({
|
|
1699
|
+
caller: "internal",
|
|
1700
|
+
procedure: op.procedure.name
|
|
1701
|
+
}),
|
|
1702
|
+
span: op.handlerObs?.span
|
|
1257
1703
|
}, op.validatedInput);
|
|
1258
1704
|
});
|
|
1705
|
+
const parentSpan = op.handlerObs?.span;
|
|
1706
|
+
const result = parentSpan?.isRecording() ? await context.with(trace.setSpan(context.active(), parentSpan), runMutation) : await runMutation();
|
|
1259
1707
|
op.resolve(result);
|
|
1260
1708
|
} catch (err) {
|
|
1261
1709
|
op.reject(err);
|
|
@@ -1265,81 +1713,181 @@ var SupaliveWebSocketServer = class {
|
|
|
1265
1713
|
session.mutationInFlight = false;
|
|
1266
1714
|
}
|
|
1267
1715
|
}
|
|
1268
|
-
async handleCall(session, id, procedureName, input, minTs) {
|
|
1716
|
+
async handleCall(session, id, procedureName, input, startedAt, minTs, trace) {
|
|
1717
|
+
const userId = this.config.getUserId?.(session.serverCtx) ?? void 0;
|
|
1718
|
+
let span;
|
|
1719
|
+
const record = (outcome, opts) => {
|
|
1720
|
+
this.obs.record({
|
|
1721
|
+
op: "rpc",
|
|
1722
|
+
transport: "ws",
|
|
1723
|
+
procedure: procedureName,
|
|
1724
|
+
kind: opts?.kind,
|
|
1725
|
+
outcome,
|
|
1726
|
+
durationMs: elapsedMs(startedAt),
|
|
1727
|
+
code: opts?.code,
|
|
1728
|
+
error: opts?.error,
|
|
1729
|
+
sessionId: session.id,
|
|
1730
|
+
reqId: id,
|
|
1731
|
+
userId,
|
|
1732
|
+
span
|
|
1733
|
+
});
|
|
1734
|
+
};
|
|
1269
1735
|
if (!this.router) {
|
|
1270
1736
|
this.sendError(session, id, "Router not registered", "SERVER_ERROR");
|
|
1737
|
+
record("error", { code: "SERVER_ERROR" });
|
|
1271
1738
|
return;
|
|
1272
1739
|
}
|
|
1273
1740
|
const procedure = this.contextRegistry.getProcedure(procedureName);
|
|
1274
1741
|
if (!procedure || procedure.type === "job") {
|
|
1275
1742
|
this.sendError(session, id, `Procedure "${procedureName}" not found`, "NOT_FOUND");
|
|
1743
|
+
record("error", { code: "NOT_FOUND" });
|
|
1276
1744
|
return;
|
|
1277
1745
|
}
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
}
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
this.
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1746
|
+
const kind = procedure.type;
|
|
1747
|
+
const parent = trace ? this.obs.extractContext({ traceparent: trace }) : void 0;
|
|
1748
|
+
const started = this.obs.startSpan(`rpc.${kind} ${procedureName}`, {
|
|
1749
|
+
transport: "ws",
|
|
1750
|
+
procedure: procedureName,
|
|
1751
|
+
kind,
|
|
1752
|
+
parent
|
|
1753
|
+
});
|
|
1754
|
+
span = started.span;
|
|
1755
|
+
const traceId = this.obs.traceIdOf(span);
|
|
1756
|
+
const handlerObs = {
|
|
1757
|
+
log: this.obs.lazyChildLogger({
|
|
1758
|
+
reqId: id,
|
|
1759
|
+
sessionId: session.id,
|
|
1760
|
+
procedure: procedureName,
|
|
1761
|
+
kind,
|
|
1762
|
+
...userId != null ? { userId } : {},
|
|
1763
|
+
...traceId ? { traceId } : {}
|
|
1764
|
+
}),
|
|
1765
|
+
span
|
|
1766
|
+
};
|
|
1767
|
+
await context.with(started.context, async () => {
|
|
1768
|
+
try {
|
|
1769
|
+
const validatedInput = procedure.inputSchema.parse(input);
|
|
1770
|
+
if (procedure.type === "query") {
|
|
1771
|
+
const lastSnapshotTs = await this.db.impl.getLatestSnapshotTimestamp();
|
|
1772
|
+
const { data, ts } = await runCachedQuery(this.oneShotDeps, handlerObs, procedure, procedureName, validatedInput, session.serverCtx, lastSnapshotTs, minTs);
|
|
1773
|
+
this.sendMessage(session, {
|
|
1774
|
+
type: "response",
|
|
1775
|
+
id,
|
|
1776
|
+
success: true,
|
|
1777
|
+
data,
|
|
1778
|
+
ts: ts.toString()
|
|
1779
|
+
});
|
|
1780
|
+
record("ok", { kind });
|
|
1781
|
+
} else if (procedure.type === "mutation") {
|
|
1782
|
+
if (session.mutationQueue.length >= MAX_MUTATION_QUEUE_BUFFER) {
|
|
1783
|
+
this.sendError(session, id, `Too many concurrent mutations on this connection (max ${MAX_MUTATION_QUEUE_BUFFER})`, "TOO_MANY_CONCURRENT_MUTATIONS");
|
|
1784
|
+
record("error", {
|
|
1785
|
+
kind,
|
|
1786
|
+
code: "TOO_MANY_CONCURRENT_MUTATIONS"
|
|
1787
|
+
});
|
|
1788
|
+
return;
|
|
1789
|
+
}
|
|
1790
|
+
const mutationResult = await this.enqueueMutation(session, procedure, validatedInput, handlerObs);
|
|
1791
|
+
this.sendMessage(session, {
|
|
1792
|
+
type: "response",
|
|
1793
|
+
id,
|
|
1794
|
+
success: true,
|
|
1795
|
+
data: mutationResult.result,
|
|
1796
|
+
ts: mutationResult.commitTs.toString()
|
|
1797
|
+
});
|
|
1798
|
+
record("ok", { kind });
|
|
1799
|
+
} else if (procedure.type === "action") {
|
|
1800
|
+
if (session.actionInFlight >= MAX_ACTION_CONCURRENCY) {
|
|
1801
|
+
this.sendError(session, id, `Too many concurrent actions on this connection (max ${MAX_ACTION_CONCURRENCY})`, "TOO_MANY_CONCURRENT_ACTIONS");
|
|
1802
|
+
record("error", {
|
|
1803
|
+
kind,
|
|
1804
|
+
code: "TOO_MANY_CONCURRENT_ACTIONS"
|
|
1805
|
+
});
|
|
1806
|
+
return;
|
|
1807
|
+
}
|
|
1808
|
+
session.actionInFlight++;
|
|
1809
|
+
let result;
|
|
1810
|
+
try {
|
|
1811
|
+
const fn = procedure.fn;
|
|
1812
|
+
result = await fn({
|
|
1813
|
+
db: this.db,
|
|
1814
|
+
serverCtx: session.serverCtx,
|
|
1815
|
+
storage: this.storage,
|
|
1816
|
+
scheduler: this.jobClient,
|
|
1817
|
+
log: handlerObs.log,
|
|
1818
|
+
span: handlerObs.span
|
|
1819
|
+
}, validatedInput);
|
|
1820
|
+
} finally {
|
|
1821
|
+
session.actionInFlight--;
|
|
1822
|
+
}
|
|
1823
|
+
this.sendMessage(session, {
|
|
1824
|
+
type: "response",
|
|
1825
|
+
id,
|
|
1826
|
+
success: true,
|
|
1827
|
+
data: result
|
|
1828
|
+
});
|
|
1829
|
+
record("ok", { kind });
|
|
1320
1830
|
}
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1831
|
+
} catch (err) {
|
|
1832
|
+
this.sendError(session, id, err instanceof Error ? err.message : "Execution failed", "EXECUTION_ERROR");
|
|
1833
|
+
record("error", {
|
|
1834
|
+
kind,
|
|
1835
|
+
code: "EXECUTION_ERROR",
|
|
1836
|
+
error: err
|
|
1326
1837
|
});
|
|
1327
1838
|
}
|
|
1328
|
-
}
|
|
1329
|
-
logger$2.error(err, `Error executing "${procedureName}"`);
|
|
1330
|
-
this.sendError(session, id, err instanceof Error ? err.message : "Execution failed", "EXECUTION_ERROR");
|
|
1331
|
-
}
|
|
1839
|
+
});
|
|
1332
1840
|
}
|
|
1333
|
-
async handleSubscribe(session, id, procedureName, input) {
|
|
1841
|
+
async handleSubscribe(session, id, procedureName, input, startedAt, trace) {
|
|
1842
|
+
const userId = this.config.getUserId?.(session.serverCtx) ?? void 0;
|
|
1843
|
+
let span;
|
|
1844
|
+
const record = (outcome, opts) => {
|
|
1845
|
+
this.obs.record({
|
|
1846
|
+
op: "subscribe",
|
|
1847
|
+
transport: "ws",
|
|
1848
|
+
procedure: procedureName,
|
|
1849
|
+
kind: "subscribe",
|
|
1850
|
+
outcome,
|
|
1851
|
+
durationMs: elapsedMs(startedAt),
|
|
1852
|
+
code: opts?.code,
|
|
1853
|
+
error: opts?.error,
|
|
1854
|
+
sessionId: session.id,
|
|
1855
|
+
reqId: id,
|
|
1856
|
+
userId,
|
|
1857
|
+
span
|
|
1858
|
+
});
|
|
1859
|
+
};
|
|
1334
1860
|
if (!this.router) {
|
|
1335
1861
|
this.sendError(session, id, "Router not registered", "SERVER_ERROR");
|
|
1862
|
+
record("error", { code: "SERVER_ERROR" });
|
|
1336
1863
|
return;
|
|
1337
1864
|
}
|
|
1338
1865
|
const procedure = this.contextRegistry.getProcedure(procedureName);
|
|
1339
1866
|
if (!procedure || procedure.type !== "query") {
|
|
1340
1867
|
this.sendError(session, id, `Procedure "${procedureName}" not found or not a query`, "NOT_FOUND");
|
|
1868
|
+
record("error", { code: "NOT_FOUND" });
|
|
1341
1869
|
return;
|
|
1342
1870
|
}
|
|
1871
|
+
const parent = trace ? this.obs.extractContext({ traceparent: trace }) : void 0;
|
|
1872
|
+
const started = this.obs.startSpan(`subscribe ${procedureName}`, {
|
|
1873
|
+
transport: "ws",
|
|
1874
|
+
procedure: procedureName,
|
|
1875
|
+
kind: "subscribe",
|
|
1876
|
+
parent
|
|
1877
|
+
});
|
|
1878
|
+
span = started.span;
|
|
1879
|
+
const traceId = this.obs.traceIdOf(span);
|
|
1880
|
+
const handlerObs = {
|
|
1881
|
+
log: this.obs.lazyChildLogger({
|
|
1882
|
+
reqId: id,
|
|
1883
|
+
sessionId: session.id,
|
|
1884
|
+
procedure: procedureName,
|
|
1885
|
+
kind: "subscribe",
|
|
1886
|
+
...userId != null ? { userId } : {},
|
|
1887
|
+
...traceId ? { traceId } : {}
|
|
1888
|
+
}),
|
|
1889
|
+
span
|
|
1890
|
+
};
|
|
1343
1891
|
try {
|
|
1344
1892
|
const validatedInput = procedure.inputSchema.parse(input);
|
|
1345
1893
|
const queryIdentity = resolveQueryIdentity(procedure, session.serverCtx, validatedInput, this.config.getUserId);
|
|
@@ -1359,7 +1907,11 @@ var SupaliveWebSocketServer = class {
|
|
|
1359
1907
|
data = cached.data;
|
|
1360
1908
|
servedTs = cached.lastSnapshotTs;
|
|
1361
1909
|
} else {
|
|
1362
|
-
liveResult = await this.executeLiveQuery(procedureName, validatedInput, session.serverCtx
|
|
1910
|
+
liveResult = await this.executeLiveQuery(procedureName, validatedInput, session.serverCtx, {
|
|
1911
|
+
log: handlerObs.log,
|
|
1912
|
+
parentContext: started.context,
|
|
1913
|
+
trigger: "subscribe"
|
|
1914
|
+
});
|
|
1363
1915
|
data = liveResult.data;
|
|
1364
1916
|
servedTs = liveResult.ts;
|
|
1365
1917
|
}
|
|
@@ -1384,7 +1936,11 @@ var SupaliveWebSocketServer = class {
|
|
|
1384
1936
|
}
|
|
1385
1937
|
}
|
|
1386
1938
|
if (rebuild) {
|
|
1387
|
-
liveResult = await this.executeLiveQuery(procedureName, validatedInput, session.serverCtx
|
|
1939
|
+
liveResult = await this.executeLiveQuery(procedureName, validatedInput, session.serverCtx, {
|
|
1940
|
+
log: handlerObs.log,
|
|
1941
|
+
parentContext: started.context,
|
|
1942
|
+
trigger: "subscribe"
|
|
1943
|
+
});
|
|
1388
1944
|
data = liveResult.data;
|
|
1389
1945
|
servedTs = liveResult.ts;
|
|
1390
1946
|
}
|
|
@@ -1394,6 +1950,7 @@ var SupaliveWebSocketServer = class {
|
|
|
1394
1950
|
subscribedTimes: 1
|
|
1395
1951
|
});
|
|
1396
1952
|
this.stats.activeSubscriptions++;
|
|
1953
|
+
this.obs.subscriptionAdded();
|
|
1397
1954
|
}
|
|
1398
1955
|
if (liveResult) {
|
|
1399
1956
|
const [updateResult] = await Promise.all([this.subManagerClient.updateSubscriptionReadSet({
|
|
@@ -1403,7 +1960,7 @@ var SupaliveWebSocketServer = class {
|
|
|
1403
1960
|
readSet: liveResult.readSet,
|
|
1404
1961
|
instanceName: this.instanceId
|
|
1405
1962
|
}), this.cache.setQueryCacheFor(cacheKey, data, liveResult.ts)]);
|
|
1406
|
-
if (!updateResult.tracked)
|
|
1963
|
+
if (!updateResult.tracked) this.log.warn(`updateSubscriptionReadSet returned tracked:false immediately after register for ${subId} — possible sub-manager restart mid-subscribe`);
|
|
1407
1964
|
}
|
|
1408
1965
|
dataHash = await getHashOf(data);
|
|
1409
1966
|
this.sendMessage(session, {
|
|
@@ -1419,10 +1976,13 @@ var SupaliveWebSocketServer = class {
|
|
|
1419
1976
|
dataHash,
|
|
1420
1977
|
ts: servedTs.toString()
|
|
1421
1978
|
});
|
|
1422
|
-
|
|
1979
|
+
record("ok", { code: void 0 });
|
|
1423
1980
|
} catch (err) {
|
|
1424
|
-
logger$2.error(err, `Error subscribing to "${procedureName}"`);
|
|
1425
1981
|
this.sendError(session, id, err instanceof Error ? err.message : "Subscribe failed", "SUBSCRIBE_ERROR");
|
|
1982
|
+
record("error", {
|
|
1983
|
+
code: "SUBSCRIBE_ERROR",
|
|
1984
|
+
error: err
|
|
1985
|
+
});
|
|
1426
1986
|
}
|
|
1427
1987
|
}
|
|
1428
1988
|
async handleUnsubscribe(session, subId) {
|
|
@@ -1436,19 +1996,24 @@ var SupaliveWebSocketServer = class {
|
|
|
1436
1996
|
session.subIds.delete(subId);
|
|
1437
1997
|
this.serverSubscriptions.delete(subId);
|
|
1438
1998
|
this.stats.activeSubscriptions--;
|
|
1999
|
+
this.obs.subscriptionRemoved();
|
|
1439
2000
|
try {
|
|
1440
2001
|
await this.subManagerClient.unregisterSubscription({
|
|
1441
2002
|
subId,
|
|
1442
2003
|
instanceName: this.instanceId
|
|
1443
2004
|
});
|
|
1444
2005
|
} catch (err) {
|
|
1445
|
-
|
|
2006
|
+
this.log.error(err, "Failed to unregister from sub-manager:");
|
|
1446
2007
|
}
|
|
1447
2008
|
}
|
|
1448
|
-
|
|
2009
|
+
this.log.debug({
|
|
2010
|
+
sessionId: session.id,
|
|
2011
|
+
subId,
|
|
2012
|
+
remaining: serverSub.subscribedTimes
|
|
2013
|
+
}, `Unsubscribed: ${subId} for session ${session.id}`);
|
|
1449
2014
|
}
|
|
1450
2015
|
async handleDisconnect(session) {
|
|
1451
|
-
|
|
2016
|
+
this.log.info(`Session disconnected: ${session.id}`);
|
|
1452
2017
|
let unregisterSubs = void 0;
|
|
1453
2018
|
for (const subId of session.subIds) {
|
|
1454
2019
|
const sub = this.serverSubscriptions.get(subId);
|
|
@@ -1466,13 +2031,14 @@ var SupaliveWebSocketServer = class {
|
|
|
1466
2031
|
instanceName: this.instanceId
|
|
1467
2032
|
})));
|
|
1468
2033
|
} catch (err) {
|
|
1469
|
-
|
|
2034
|
+
this.log.error(err, "Failed to unregister from sub-manager:");
|
|
1470
2035
|
}
|
|
1471
2036
|
for (const sub of unregisterSubs) if (sub.subscribedTimes <= 0) {
|
|
1472
2037
|
this.serverSubscriptions.delete(sub.subId);
|
|
1473
2038
|
this.stats.activeSubscriptions--;
|
|
2039
|
+
this.obs.subscriptionRemoved();
|
|
1474
2040
|
}
|
|
1475
|
-
|
|
2041
|
+
this.log.info(`All Subscription sessions is disconnected: ${session.id}, subs: ${unregisterSubs.length}`);
|
|
1476
2042
|
}
|
|
1477
2043
|
/**
|
|
1478
2044
|
* Drives recovery on a sub-manager reconnect. Iterates this app server's
|
|
@@ -1491,10 +2057,10 @@ var SupaliveWebSocketServer = class {
|
|
|
1491
2057
|
async runSubManagerRecovery() {
|
|
1492
2058
|
const snapshot = Array.from(this.serverSubscriptions.values());
|
|
1493
2059
|
if (snapshot.length === 0) {
|
|
1494
|
-
|
|
2060
|
+
this.log.info("Sub-manager recovery: no subscriptions to restore");
|
|
1495
2061
|
return;
|
|
1496
2062
|
}
|
|
1497
|
-
|
|
2063
|
+
this.log.info(`Sub-manager recovery: restoring ${snapshot.length} subscription(s)`);
|
|
1498
2064
|
const lastSnapshotTs = await this.db.impl.getLatestSnapshotTimestamp();
|
|
1499
2065
|
const subIds = snapshot.map((e) => e.subId);
|
|
1500
2066
|
const recordsMap = await this.cache.fetchRegistrationRecordsBatch(subIds);
|
|
@@ -1536,11 +2102,11 @@ var SupaliveWebSocketServer = class {
|
|
|
1536
2102
|
});
|
|
1537
2103
|
}
|
|
1538
2104
|
} catch (err) {
|
|
1539
|
-
|
|
2105
|
+
this.log.error(err, `Sub-manager recovery: failed to batch register ${i}-${i + batch.length}`);
|
|
1540
2106
|
}
|
|
1541
2107
|
}
|
|
1542
|
-
await Promise.allSettled(recompute.map((a) => this.reexecuteAffectedQuery(a)));
|
|
1543
|
-
|
|
2108
|
+
await Promise.allSettled(recompute.map((a) => this.reexecuteAffectedQuery(a, "recovery")));
|
|
2109
|
+
this.log.info(`Sub-manager recovery: done (${recompute.length} reexec'd)`);
|
|
1544
2110
|
}
|
|
1545
2111
|
async runAffectedHandler(affected) {
|
|
1546
2112
|
await Promise.allSettled(affected.map((a) => this.reexecuteAffectedQuery(a)));
|
|
@@ -1557,6 +2123,7 @@ var SupaliveWebSocketServer = class {
|
|
|
1557
2123
|
if (serverSub) {
|
|
1558
2124
|
this.serverSubscriptions.delete(subId);
|
|
1559
2125
|
this.stats.activeSubscriptions = Math.max(0, this.stats.activeSubscriptions - 1);
|
|
2126
|
+
this.obs.subscriptionRemoved();
|
|
1560
2127
|
for (const [, session] of this.sessions) if (session.subIds.has(subId)) {
|
|
1561
2128
|
notified++;
|
|
1562
2129
|
session.subIds.delete(subId);
|
|
@@ -1567,18 +2134,18 @@ var SupaliveWebSocketServer = class {
|
|
|
1567
2134
|
});
|
|
1568
2135
|
}
|
|
1569
2136
|
}
|
|
1570
|
-
|
|
2137
|
+
this.log.warn(`Dropped subscription ${subId} locally (${reason}); notified ${notified} session(s)`);
|
|
1571
2138
|
}
|
|
1572
2139
|
async handleMutationCommit(writeSet, commitTs) {
|
|
1573
2140
|
if (writeSet.length === 0) return;
|
|
1574
2141
|
const tables = [...new Set(writeSet.map((w) => w.table))];
|
|
1575
|
-
|
|
2142
|
+
this.log.debug(`Mutation committed for tables: [${tables.join(", ")}]`);
|
|
1576
2143
|
try {
|
|
1577
2144
|
const result = await this.subManagerClient.invalidateWriteset({ writeSet });
|
|
1578
|
-
|
|
2145
|
+
this.log.debug({ affected: result }, "Sub-managers invalidation result");
|
|
1579
2146
|
if (result?.affected?.length > 0) await Promise.allSettled(result.affected.map((affected) => this.dispatchAffected(affected, commitTs)));
|
|
1580
2147
|
} catch (err) {
|
|
1581
|
-
|
|
2148
|
+
this.log.error(err, "Failed to notify sub-managers");
|
|
1582
2149
|
}
|
|
1583
2150
|
}
|
|
1584
2151
|
/**
|
|
@@ -1600,7 +2167,7 @@ var SupaliveWebSocketServer = class {
|
|
|
1600
2167
|
return;
|
|
1601
2168
|
}
|
|
1602
2169
|
if (affected.notifyInstances.length === 0) {
|
|
1603
|
-
|
|
2170
|
+
this.log.debug(`dispatchAffected: no live instances hold ${affected.subId} — dropping`);
|
|
1604
2171
|
return;
|
|
1605
2172
|
}
|
|
1606
2173
|
await this.publishRecomputeRace(affected, commitTs);
|
|
@@ -1626,23 +2193,23 @@ var SupaliveWebSocketServer = class {
|
|
|
1626
2193
|
try {
|
|
1627
2194
|
await redisRetryPolicy.execute(() => this.cache.redis.publish(channel, payload));
|
|
1628
2195
|
} catch (err) {
|
|
1629
|
-
|
|
2196
|
+
this.log.error(err, `Failed to publish recompute-race to ${channel} after many retries`);
|
|
1630
2197
|
}
|
|
1631
2198
|
});
|
|
1632
2199
|
await Promise.all(publishPromises);
|
|
1633
2200
|
}
|
|
1634
|
-
async reexecuteAffectedQuery(affected) {
|
|
2201
|
+
async reexecuteAffectedQuery(affected, trigger = "recompute") {
|
|
1635
2202
|
let foundSession = null;
|
|
1636
2203
|
for (const [, session] of this.sessions) if (session.subIds.has(affected.subId)) {
|
|
1637
2204
|
foundSession = session;
|
|
1638
2205
|
break;
|
|
1639
2206
|
}
|
|
1640
2207
|
if (!foundSession) {
|
|
1641
|
-
|
|
2208
|
+
this.log.debug(`Session not found for subId: ${affected.subId}`);
|
|
1642
2209
|
return;
|
|
1643
2210
|
}
|
|
1644
2211
|
try {
|
|
1645
|
-
const liveResult = await this.executeLiveQuery(affected.queryName, affected.args, foundSession.serverCtx);
|
|
2212
|
+
const liveResult = await this.executeLiveQuery(affected.queryName, affected.args, foundSession.serverCtx, { trigger });
|
|
1646
2213
|
const dataHash = await getHashOf(liveResult.data);
|
|
1647
2214
|
const [updateResult] = await Promise.all([this.subManagerClient.updateSubscriptionReadSet({
|
|
1648
2215
|
cacheKey: affected.cacheKey,
|
|
@@ -1664,21 +2231,27 @@ var SupaliveWebSocketServer = class {
|
|
|
1664
2231
|
ts
|
|
1665
2232
|
});
|
|
1666
2233
|
if (affected.notifyInstances.length > 0) await this.publishSubscriptionUpdate(affected.subId, liveResult.data, dataHash, affected.notifyInstances, ts);
|
|
1667
|
-
|
|
2234
|
+
this.log.debug(`Re-executed affected query for subId: ${affected.subId}`);
|
|
1668
2235
|
} catch (err) {
|
|
1669
|
-
|
|
2236
|
+
this.log.error(err, `Failed to re-execute query for subId ${affected.subId}`);
|
|
1670
2237
|
}
|
|
1671
2238
|
}
|
|
1672
|
-
async executeLiveQuery(procedureName, input, serverCtx) {
|
|
2239
|
+
async executeLiveQuery(procedureName, input, serverCtx, opts) {
|
|
1673
2240
|
const procedure = this.contextRegistry.getProcedure(procedureName);
|
|
1674
2241
|
if (!procedure || procedure.type !== "query") throw new Error(`Procedure not defined: ${procedureName}`);
|
|
1675
2242
|
return this.db.liveQuery(async (ctx) => {
|
|
1676
2243
|
const fn = procedure.fn;
|
|
1677
|
-
|
|
2244
|
+
const callerLog = () => this.obs.lazyChildLogger({
|
|
2245
|
+
caller: "internal",
|
|
2246
|
+
procedure: procedureName
|
|
2247
|
+
});
|
|
2248
|
+
return traceQueryExecution(procedureName, opts?.trigger ?? "recompute", opts?.parentContext, (querySpan) => fn({
|
|
1678
2249
|
db: ctx,
|
|
1679
2250
|
serverCtx,
|
|
1680
|
-
...primaryOnlyConn
|
|
1681
|
-
|
|
2251
|
+
...primaryOnlyConn,
|
|
2252
|
+
log: opts?.log ?? callerLog(),
|
|
2253
|
+
span: querySpan
|
|
2254
|
+
}, input));
|
|
1682
2255
|
});
|
|
1683
2256
|
}
|
|
1684
2257
|
sendMessage(session, message) {
|
|
@@ -1705,7 +2278,7 @@ var SupaliveWebSocketServer = class {
|
|
|
1705
2278
|
const appChannels = [...this.appPubSubChannels];
|
|
1706
2279
|
await this.redisSub.subscribe(instanceChannel, schemaChannel, watermarkChannel, ...appChannels);
|
|
1707
2280
|
this.redisSub.on("connect", () => {
|
|
1708
|
-
|
|
2281
|
+
this.log.info(`Subscribed to Redis channels: ${[
|
|
1709
2282
|
instanceChannel,
|
|
1710
2283
|
schemaChannel,
|
|
1711
2284
|
watermarkChannel,
|
|
@@ -1714,20 +2287,20 @@ var SupaliveWebSocketServer = class {
|
|
|
1714
2287
|
});
|
|
1715
2288
|
this.redisSub.on("message", (channel, message) => {
|
|
1716
2289
|
if (channel === instanceChannel) this.handleRemoteInstanceMessage(message).catch((err) => {
|
|
1717
|
-
|
|
2290
|
+
this.log.error(err, "Error handling remote instance message");
|
|
1718
2291
|
});
|
|
1719
2292
|
else if (channel === schemaChannel) this.handleSchemaReload().catch((err) => {
|
|
1720
|
-
|
|
2293
|
+
this.log.error(err, "Error handling schema reload");
|
|
1721
2294
|
});
|
|
1722
2295
|
else if (channel === watermarkChannel) try {
|
|
1723
2296
|
this.retentionWatermark.set(BigInt(message));
|
|
1724
2297
|
} catch (err) {
|
|
1725
|
-
|
|
2298
|
+
this.log.error(err, `Bad retention watermark message: ${message}`);
|
|
1726
2299
|
}
|
|
1727
|
-
else if (this.appPubSubChannels.has(channel)) Promise.resolve(this.config.appPubSub?.onMessage(channel, message)).catch((err) =>
|
|
2300
|
+
else if (this.appPubSubChannels.has(channel)) Promise.resolve(this.config.appPubSub?.onMessage(channel, message)).catch((err) => this.log.error(err, `Error in appPubSub handler for channel ${channel}`));
|
|
1728
2301
|
});
|
|
1729
2302
|
this.redisSub.on("error", (err) => {
|
|
1730
|
-
|
|
2303
|
+
this.log.error(err, `Subscriber error for channels: ${instanceChannel}, ${schemaChannel}`);
|
|
1731
2304
|
});
|
|
1732
2305
|
}
|
|
1733
2306
|
/**
|
|
@@ -1737,13 +2310,13 @@ var SupaliveWebSocketServer = class {
|
|
|
1737
2310
|
* messages published by this instance (we already have the new types).
|
|
1738
2311
|
*/
|
|
1739
2312
|
async handleSchemaReload() {
|
|
1740
|
-
|
|
2313
|
+
this.log.info(`Schema reload request received in instance ${this.instanceId} — reloading schema...`);
|
|
1741
2314
|
try {
|
|
1742
2315
|
await sleep(1e3 + Math.random() * 5e3);
|
|
1743
2316
|
await bootstrapDbTypes(this.db, this.cache, true);
|
|
1744
|
-
|
|
2317
|
+
this.log.info(`Schema reload applied in instance ${this.instanceId}`);
|
|
1745
2318
|
} catch (err) {
|
|
1746
|
-
|
|
2319
|
+
this.log.error(err, "Failed to reload schema");
|
|
1747
2320
|
return;
|
|
1748
2321
|
}
|
|
1749
2322
|
}
|
|
@@ -1777,7 +2350,7 @@ var SupaliveWebSocketServer = class {
|
|
|
1777
2350
|
try {
|
|
1778
2351
|
parsed = RemoteSubscriptionMessageSchema.parse(JSON.parse(message));
|
|
1779
2352
|
} catch (err) {
|
|
1780
|
-
|
|
2353
|
+
this.log.error(err, "Failed to parse remote instance message");
|
|
1781
2354
|
return;
|
|
1782
2355
|
}
|
|
1783
2356
|
if (parsed.originInstance === this.instanceId) return;
|
|
@@ -1800,7 +2373,7 @@ var SupaliveWebSocketServer = class {
|
|
|
1800
2373
|
async handleRemoteRecomputeRace(req) {
|
|
1801
2374
|
if (!this.hasLocalSessionFor(req.subId)) return;
|
|
1802
2375
|
if (!await this.cache.tryAcquireRecomputeLock(this.instanceId, req.subId, req.commitTs, RECOMPUTE_LOCK_TTL_SECONDS)) {
|
|
1803
|
-
|
|
2376
|
+
this.log.debug(`recompute-race: lost lock for ${req.subId}@${req.commitTs}, waiting for winner's update`);
|
|
1804
2377
|
return;
|
|
1805
2378
|
}
|
|
1806
2379
|
await this.reexecuteAffectedQuery({
|
|
@@ -1826,7 +2399,7 @@ var SupaliveWebSocketServer = class {
|
|
|
1826
2399
|
try {
|
|
1827
2400
|
await redisRetryPolicy.execute(() => this.cache.redis.publish(channel, payload));
|
|
1828
2401
|
} catch (err) {
|
|
1829
|
-
|
|
2402
|
+
this.log.error(err, `Failed to publish to ${channel} after many retries`);
|
|
1830
2403
|
}
|
|
1831
2404
|
});
|
|
1832
2405
|
await Promise.all(publishPromises);
|
|
@@ -1853,29 +2426,6 @@ function sendJson(res, status, body) {
|
|
|
1853
2426
|
res.writeHead(status, { "content-type": "application/json" });
|
|
1854
2427
|
res.end(payload);
|
|
1855
2428
|
}
|
|
1856
|
-
function lowercaseHeaders(headers) {
|
|
1857
|
-
const out = {};
|
|
1858
|
-
for (const [k, v] of Object.entries(headers)) out[k.toLowerCase()] = Array.isArray(v) ? v.join(",") : v;
|
|
1859
|
-
return out;
|
|
1860
|
-
}
|
|
1861
|
-
/** Buffer an incoming request body to a string, capped to guard against abuse. */
|
|
1862
|
-
function readRequestBody(req, maxBytes = 1024 * 1024) {
|
|
1863
|
-
return new Promise((resolve, reject) => {
|
|
1864
|
-
const chunks = [];
|
|
1865
|
-
let size = 0;
|
|
1866
|
-
req.on("data", (chunk) => {
|
|
1867
|
-
size += chunk.length;
|
|
1868
|
-
if (size > maxBytes) {
|
|
1869
|
-
reject(/* @__PURE__ */ new Error("request body too large"));
|
|
1870
|
-
req.destroy();
|
|
1871
|
-
return;
|
|
1872
|
-
}
|
|
1873
|
-
chunks.push(chunk);
|
|
1874
|
-
});
|
|
1875
|
-
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
1876
|
-
req.on("error", reject);
|
|
1877
|
-
});
|
|
1878
|
-
}
|
|
1879
2429
|
//#endregion
|
|
1880
2430
|
//#region src/server/hash.ts
|
|
1881
2431
|
/**
|
|
@@ -2223,6 +2773,6 @@ function resolveMaxConnectionsPerWorker(config, workers) {
|
|
|
2223
2773
|
return Math.max(1, Math.floor(total / workers));
|
|
2224
2774
|
}
|
|
2225
2775
|
//#endregion
|
|
2226
|
-
export { ANONYMOUS_IDENTITY, CORE_VERSION, CacheLayer, DB_QUERY_TIMEOUT_MS, DevScheduler, InvalidateWritesetParamsSchema, JobClient, QStashScheduler, RegisterSubscriptionParamsSchema, SubManagerClient, SubscriptionManager, SupaliveWebSocketServer, UnregisterSubscriptionParamsSchema, UpdateSubscriptionReadSetParamsSchema, appInstanceId, bootstrapDbTypes, createDatabase, generateCacheKey, generateSubscriptionId, getHashOf, initCacheLayer, initCore, initDatabase, isProduction, logLevelOf, logger, serializeMapValue };
|
|
2776
|
+
export { ANONYMOUS_IDENTITY, CORE_VERSION, CacheLayer, DB_QUERY_TIMEOUT_MS, DevScheduler, InvalidateWritesetParamsSchema, JobClient, Observability, QStashScheduler, RegisterSubscriptionParamsSchema, SubManagerClient, SubscriptionManager, SupaliveWebSocketServer, UnregisterSubscriptionParamsSchema, UpdateSubscriptionReadSetParamsSchema, appInstanceId, bootstrapDbTypes, configureRootLogger, createDatabase, elapsedMs, generateCacheKey, generateSubscriptionId, getHashOf, getRootLogger, initCacheLayer, initCore, initDatabase, isProduction, logLevelOf, logger, serializeMapValue, startTimer, traceQueryExecution };
|
|
2227
2777
|
|
|
2228
2778
|
//# sourceMappingURL=server.js.map
|