@rocicorp/zero 1.11.0-canary.7 → 1.11.0-canary.8
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/out/analyze-query/src/analyze-cli.js +2 -2
- package/out/analyze-query/src/analyze-cli.js.map +1 -1
- package/out/zero/package.js +1 -1
- package/out/zero/package.js.map +1 -1
- package/out/zero-cache/src/services/change-source/custom/change-source.js +2 -2
- package/out/zero-cache/src/services/change-source/custom/change-source.js.map +1 -1
- package/out/zero-cache/src/services/change-streamer/change-streamer-http.js +3 -3
- package/out/zero-cache/src/services/change-streamer/change-streamer-http.js.map +1 -1
- package/out/zero-cache/src/workers/connection.js +3 -3
- package/out/zero-cache/src/workers/connection.js.map +1 -1
- package/out/zero-cache/src/workers/syncer.js +2 -2
- package/out/zero-cache/src/workers/syncer.js.map +1 -1
- package/out/zero-client/src/client/version.js +1 -1
- package/package.json +1 -1
|
@@ -9,7 +9,7 @@ import { parseOptions } from "../../shared/src/options.js";
|
|
|
9
9
|
import { ZERO_ENV_VAR_PREFIX } from "../../zero-cache/src/config/zero-config.js";
|
|
10
10
|
import { Console } from "node:console";
|
|
11
11
|
import { styleText } from "node:util";
|
|
12
|
-
import { WebSocket } from "ws";
|
|
12
|
+
import { WebSocket as WebSocket$1 } from "ws";
|
|
13
13
|
//#region ../analyze-query/src/analyze-cli.ts
|
|
14
14
|
var options = {
|
|
15
15
|
zeroCacheURL: {
|
|
@@ -223,7 +223,7 @@ function resolveHandshakeHeaders(config) {
|
|
|
223
223
|
return headers;
|
|
224
224
|
}
|
|
225
225
|
function installWebSocketHeaderShim(headers) {
|
|
226
|
-
class HeaderInjectingWebSocket extends WebSocket {
|
|
226
|
+
class HeaderInjectingWebSocket extends WebSocket$1 {
|
|
227
227
|
constructor(url, protocols) {
|
|
228
228
|
super(url, protocols, { headers });
|
|
229
229
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"analyze-cli.js","names":[],"sources":["../../../../analyze-query/src/analyze-cli.ts"],"sourcesContent":["import '../../shared/src/dotenv.ts';\n\nimport {Console} from 'node:console';\nimport {styleText} from 'node:util';\nimport type {LogSink} from '@rocicorp/logger';\nimport {WebSocket as NodeWebSocket} from 'ws';\nimport {logLevel, logOptions} from '../../otel/src/log-options.ts';\nimport {colorConsole} from '../../shared/src/logging.ts';\nimport {parseOptions} from '../../shared/src/options.ts';\nimport * as v from '../../shared/src/valita.ts';\nimport {ZERO_ENV_VAR_PREFIX} from '../../zero-cache/src/config/zero-config.ts';\nimport {Zero} from '../../zero-client/src/client/zero.ts';\nimport type {AnalyzeQueryResult} from '../../zero-protocol/src/analyze-query-result.ts';\nimport type {AST} from '../../zero-protocol/src/ast.ts';\nimport type {Schema} from '../../zero-types/src/schema.ts';\nimport {formatPlannerEvents} from '../../zql/src/planner/planner-debug.ts';\nimport {createBuilder} from '../../zql/src/query/create-builder.ts';\nimport type {AnyQuery} from '../../zql/src/query/query.ts';\nimport type {SchemaQuery} from '../../zql/src/query/schema-query.ts';\n\nexport type AnalyzeCLIOptions = {\n schema: Schema;\n /** Defaults to `process.argv.slice(2)`. */\n argv?: readonly string[] | undefined;\n};\n\nconst options = {\n zeroCacheURL: {\n type: v.string().optional(),\n desc: [\n 'URL of the remote zero-cache to analyze against.',\n 'Accepts http(s):// or ws(s):// (ws(s) is the transport actually used).',\n ],\n },\n adminPassword: {\n type: v.string().optional(),\n desc: [\n 'Admin password for zero-cache.',\n 'Required when the server is configured with one; ignored in dev mode.',\n ],\n },\n authToken: {\n type: v.string().optional(),\n desc: [\n 'Raw JWT forwarded to zero-cache.',\n 'Used server-side to fill permission variables for the query.',\n ],\n },\n cookie: {\n type: v.string().optional(),\n desc: [\n 'Cookie header value sent on the WebSocket upgrade request,',\n 'e.g. `session=abc; foo=bar`. Use this when zero-cache is behind',\n 'a proxy that resolves auth via cookies. Merged with --headers-json',\n '(--cookie wins on conflict).',\n ],\n },\n headersJson: {\n type: v.string().optional(),\n desc: [\n 'JSON object of arbitrary headers to send on the WebSocket upgrade',\n 'request, e.g. `{\"x-api-key\":\"...\"}`. Escape hatch for exotic auth',\n 'schemes; prefer --auth-token or --cookie when possible.',\n ],\n },\n userId: {\n type: v.string().optional(),\n desc: [\n 'Optional userID to report to zero-cache.',\n 'Omit for anonymous analysis.',\n ],\n },\n ast: {\n type: v.string().optional(),\n desc: [\n 'JSON-encoded AST. Exactly one of --ast / --query / --query-name is required.',\n 'The AST is sent to the server verbatim — provide it in server (post-mapping) form.',\n ],\n },\n query: {\n type: v.string().optional(),\n desc: [\n 'ZQL query in chain form, e.g. `issue.related(\"comments\").limit(10)`.',\n 'Evaluated against the schema you pass to runAnalyzeCLI.',\n ],\n },\n queryName: {\n type: v.string().optional(),\n desc: [\n 'Name of a server-registered custom (named) query.',\n 'The server resolves the name + args via its registered query handler.',\n ],\n },\n queryArgs: {\n type: v.string().optional(),\n desc: [\n 'JSON-encoded array of arguments for --query-name. Defaults to `[]`.',\n ],\n },\n outputVendedRows: {\n type: v.boolean().default(false),\n desc: [\n 'Include the rows read from the replica to execute the query.',\n 'Each row appears once per read.',\n ],\n },\n outputSyncedRows: {\n type: v.boolean().default(false),\n desc: ['Include the rows that would be synced to the client.'],\n },\n joinPlans: {\n type: v.boolean().default(false),\n desc: ['Include join planner diagnostics.'],\n },\n log: {\n ...logOptions,\n level: logLevel.default('error'),\n },\n};\n\ntype QueryPlan =\n | {kind: 'ast'; ast: AST}\n | {kind: 'zql'; text: string}\n | {kind: 'named'; name: string; args: ReadonlyArray<unknown>};\n\n// Route all Zero client log output to stderr so stdout contains only the\n// analyze result. Shell redirection (`2>/dev/null`) can then cleanly silence\n// logs without affecting output.\nconst stderrConsole = new Console({\n stdout: process.stderr,\n stderr: process.stderr,\n});\nconst stderrLogSink: LogSink = {\n log(level, context, ...args) {\n const ctx = context\n ? Object.entries(context).map(([k, v]) =>\n v === undefined ? k : `${k}=${v}`,\n )\n : [];\n stderrConsole[level](...ctx, ...args);\n },\n};\n\n/**\n * Entry point for a user's `cli.ts`. Parses argv, connects to a remote\n * zero-cache by standing up an in-process Zero client (in-memory storage,\n * no subscriptions), calls the inspector's `analyze-query` RPC, and\n * renders the result. Intended to be called as:\n *\n * ```ts\n * import {schema} from './schema.ts';\n * import {runAnalyzeCLI} from '@rocicorp/zero/analyze';\n * await runAnalyzeCLI({schema});\n * ```\n *\n * Exits the process with code 1 on error.\n */\nexport async function runAnalyzeCLI(opts: AnalyzeCLIOptions): Promise<void> {\n const argv = (opts.argv ?? process.argv.slice(2)).map(s =>\n s.replaceAll('\\n', ' '),\n );\n\n const config = parseOptions(options, {\n argv,\n envNamePrefix: ZERO_ENV_VAR_PREFIX,\n description: [\n {\n header: 'analyze-query (remote)',\n content: `Analyze a ZQL query against a remote zero-cache.\n\n Connects to zero-cache's inspector protocol and reports the server-observed\n row scans, SQLite query plans, and timings.`,\n },\n {\n header: 'Examples',\n content: ` node cli.ts --zero-cache-url=https://zero.example.com \\\\\n --admin-password=\"$ZERO_ADMIN_PASSWORD\" \\\\\n --query='issue.related(\"comments\").limit(10)'\n\n node cli.ts --zero-cache-url=http://localhost:4848 \\\\\n --ast='\\\\{\"table\": \"issue\", \"limit\": 5\\\\}'\n\n node cli.ts --zero-cache-url=http://localhost:4848 \\\\\n --query-name=issueList --query-args='[]'`,\n },\n ],\n });\n\n if (!config.zeroCacheURL) {\n colorConsole.error('--zero-cache-url is required. See --help for usage.');\n process.exit(1);\n }\n\n const plan = buildQueryPlan(config);\n\n const handshakeHeaders = resolveHandshakeHeaders(config);\n if (Object.keys(handshakeHeaders).length > 0) {\n installWebSocketHeaderShim(handshakeHeaders);\n }\n\n // zero-client and replicache reference a build-time `TESTING` global that\n // bundlers replace with a boolean literal; under node there's no replacement,\n // so provide a runtime default.\n (globalThis as {TESTING?: boolean}).TESTING ??= false;\n\n const z = new Zero({\n schema: opts.schema,\n server: config.zeroCacheURL,\n auth: config.authToken,\n userID: config.userId,\n kvStore: 'mem',\n logLevel: config.log.level,\n logSink: stderrLogSink,\n });\n\n let result: AnalyzeQueryResult;\n try {\n const authOk = await z.inspector.authenticate(config.adminPassword ?? '');\n if (!authOk) {\n throw new Error(\n 'admin password rejected (or --admin-password is required)',\n );\n }\n\n const rpcOptions = {\n vendedRows: config.outputVendedRows,\n syncedRows: config.outputSyncedRows,\n joinPlans: config.joinPlans,\n };\n\n if (plan.kind === 'ast') {\n result = await z.inspector.analyzeServerAST(plan.ast, rpcOptions);\n } else if (plan.kind === 'named') {\n result = await z.inspector.analyzeNamedQuery(\n plan.name,\n plan.args as ReadonlyArray<never>,\n rpcOptions,\n );\n } else {\n const built = buildZqlQuery(plan.text, createBuilder(opts.schema));\n result = await z.inspector.analyzeQuery(built, rpcOptions);\n }\n } catch (e) {\n colorConsole.error(e instanceof Error ? e.message : String(e));\n await z.close().catch(() => {});\n process.exit(1);\n }\n\n renderResult(result, {\n outputSyncedRows: config.outputSyncedRows,\n outputVendedRows: config.outputVendedRows,\n });\n\n await z.close();\n}\n\nfunction buildQueryPlan(config: {\n ast?: string | undefined;\n query?: string | undefined;\n queryName?: string | undefined;\n queryArgs?: string | undefined;\n}): QueryPlan {\n const selectors = [\n config.ast !== undefined && 'ast',\n config.query !== undefined && 'query',\n config.queryName !== undefined && 'queryName',\n ].filter(Boolean) as string[];\n\n if (selectors.length === 0) {\n colorConsole.error(\n 'Exactly one of --ast / --query / --query-name is required.',\n );\n process.exit(1);\n }\n if (selectors.length > 1) {\n colorConsole.error(\n `Only one of --ast / --query / --query-name may be provided; got: ${selectors.join(', ')}`,\n );\n process.exit(1);\n }\n\n if (config.ast !== undefined) {\n return {kind: 'ast', ast: JSON.parse(config.ast) as AST};\n }\n if (config.query !== undefined) {\n return {kind: 'zql', text: config.query};\n }\n const args = config.queryArgs\n ? (JSON.parse(config.queryArgs) as ReadonlyArray<unknown>)\n : [];\n return {kind: 'named', name: config.queryName as string, args};\n}\n\nfunction buildZqlQuery(\n queryString: string,\n builder: SchemaQuery<Schema>,\n): AnyQuery {\n const f = new Function('builder', `return builder.${queryString};`);\n return f(builder) as AnyQuery;\n}\n\nfunction resolveHandshakeHeaders(config: {\n cookie?: string | undefined;\n headersJson?: string | undefined;\n}): Record<string, string> {\n const headers: Record<string, string> = {};\n if (config.headersJson !== undefined) {\n let parsed: unknown;\n try {\n parsed = JSON.parse(config.headersJson);\n } catch (e) {\n colorConsole.error(\n `--headers-json is not valid JSON: ${e instanceof Error ? e.message : String(e)}`,\n );\n process.exit(1);\n }\n if (\n parsed === null ||\n typeof parsed !== 'object' ||\n Array.isArray(parsed)\n ) {\n colorConsole.error('--headers-json must be a JSON object.');\n process.exit(1);\n }\n for (const [k, val] of Object.entries(parsed)) {\n if (typeof val !== 'string') {\n colorConsole.error(\n `--headers-json values must be strings; got ${typeof val} for \"${k}\".`,\n );\n process.exit(1);\n }\n headers[k] = val;\n }\n }\n if (config.cookie !== undefined) {\n headers.cookie = config.cookie;\n }\n return headers;\n}\n\nfunction installWebSocketHeaderShim(headers: Record<string, string>): void {\n class HeaderInjectingWebSocket extends NodeWebSocket {\n constructor(url: string | URL, protocols?: string | string[]) {\n super(url, protocols, {headers});\n }\n }\n (globalThis as {WebSocket?: unknown}).WebSocket = HeaderInjectingWebSocket;\n}\n\nfunction renderResult(\n result: AnalyzeQueryResult,\n opts: {outputSyncedRows: boolean; outputVendedRows: boolean},\n) {\n if (opts.outputSyncedRows) {\n colorConsole.log(styleText(['blue', 'bold'], '=== Synced Rows: ===\\n'));\n for (const [table, rows] of Object.entries(result.syncedRows ?? {})) {\n colorConsole.log(styleText('bold', table + ':'), rows);\n }\n }\n\n colorConsole.log(styleText(['blue', 'bold'], '=== Query Stats: ===\\n'));\n colorConsole.log(\n styleText('bold', 'total synced rows:'),\n result.syncedRowCount,\n );\n\n const readRowCountsByQuery = result.readRowCountsByQuery ?? {};\n let totalRowsRead = 0;\n for (const table of Object.keys(readRowCountsByQuery).sort()) {\n const counts = readRowCountsByQuery[table];\n for (const n of Object.values(counts)) {\n totalRowsRead += n;\n }\n colorConsole.log(styleText('bold', `${table} vended:`), counts);\n }\n colorConsole.log(\n styleText('bold', 'Rows Read (into JS):'),\n colorRowsConsidered(totalRowsRead),\n );\n const duration = result.elapsed ?? result.end - result.start;\n colorConsole.log(styleText('bold', 'time:'), colorTime(duration), 'ms');\n\n if (opts.outputVendedRows) {\n colorConsole.log(\n styleText(['blue', 'bold'], '=== JS Row Scan Values: ===\\n'),\n );\n for (const [table, rows] of Object.entries(result.readRows ?? {})) {\n colorConsole.log(styleText('bold', `${table}:`), rows);\n }\n }\n\n colorConsole.log(\n styleText(['blue', 'bold'], '\\n=== Rows Scanned (by SQLite): ===\\n'),\n );\n const dbScansByQuery = result.dbScansByQuery ?? {};\n let totalNVisit = 0;\n for (const [table, queries] of Object.entries(dbScansByQuery)) {\n colorConsole.log(styleText('bold', `${table}:`), queries);\n for (const count of Object.values(queries)) {\n totalNVisit += count;\n }\n }\n colorConsole.log(\n styleText('bold', 'total rows scanned:'),\n colorRowsConsidered(totalNVisit),\n );\n\n colorConsole.log(styleText(['blue', 'bold'], '\\n\\n=== Query Plans: ===\\n'));\n const plans = result.sqlitePlans ?? {};\n for (const [query, plan] of Object.entries(plans)) {\n colorConsole.log(styleText('bold', 'query'), query);\n colorConsole.log(plan.map((row, i) => colorPlanRow(row, i)).join('\\n'));\n colorConsole.log('\\n');\n }\n\n if (result.warnings.length > 0) {\n colorConsole.log(styleText(['yellow', 'bold'], '=== Warnings: ===\\n'));\n for (const w of result.warnings) {\n colorConsole.log(styleText('yellow', w));\n }\n }\n\n if (result.joinPlans && result.joinPlans.length > 0) {\n colorConsole.log(styleText(['blue', 'bold'], '\\n=== Join Plans: ===\\n'));\n colorConsole.log(formatPlannerEvents(result.joinPlans));\n }\n}\n\nfunction colorTime(duration: number) {\n if (duration < 100) {\n return styleText('green', duration.toFixed(2) + 'ms');\n } else if (duration < 1000) {\n return styleText('yellow', duration.toFixed(2) + 'ms');\n }\n return styleText('red', duration.toFixed(2) + 'ms');\n}\n\nfunction colorRowsConsidered(n: number) {\n if (n < 1000) {\n return styleText('green', n.toString());\n } else if (n < 10000) {\n return styleText('yellow', n.toString());\n }\n return styleText('red', n.toString());\n}\n\nfunction colorPlanRow(row: string, i: number) {\n if (row.includes('SCAN')) {\n if (i === 0) {\n return styleText('yellow', row);\n }\n return styleText('red', row);\n }\n return styleText('green', row);\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"analyze-cli.js","names":[],"sources":["../../../../analyze-query/src/analyze-cli.ts"],"sourcesContent":["import '../../shared/src/dotenv.ts';\n\nimport {Console} from 'node:console';\nimport {styleText} from 'node:util';\nimport type {LogSink} from '@rocicorp/logger';\nimport {WebSocket as NodeWebSocket} from 'ws';\nimport {logLevel, logOptions} from '../../otel/src/log-options.ts';\nimport {colorConsole} from '../../shared/src/logging.ts';\nimport {parseOptions} from '../../shared/src/options.ts';\nimport * as v from '../../shared/src/valita.ts';\nimport {ZERO_ENV_VAR_PREFIX} from '../../zero-cache/src/config/zero-config.ts';\nimport {Zero} from '../../zero-client/src/client/zero.ts';\nimport type {AnalyzeQueryResult} from '../../zero-protocol/src/analyze-query-result.ts';\nimport type {AST} from '../../zero-protocol/src/ast.ts';\nimport type {Schema} from '../../zero-types/src/schema.ts';\nimport {formatPlannerEvents} from '../../zql/src/planner/planner-debug.ts';\nimport {createBuilder} from '../../zql/src/query/create-builder.ts';\nimport type {AnyQuery} from '../../zql/src/query/query.ts';\nimport type {SchemaQuery} from '../../zql/src/query/schema-query.ts';\n\nexport type AnalyzeCLIOptions = {\n schema: Schema;\n /** Defaults to `process.argv.slice(2)`. */\n argv?: readonly string[] | undefined;\n};\n\nconst options = {\n zeroCacheURL: {\n type: v.string().optional(),\n desc: [\n 'URL of the remote zero-cache to analyze against.',\n 'Accepts http(s):// or ws(s):// (ws(s) is the transport actually used).',\n ],\n },\n adminPassword: {\n type: v.string().optional(),\n desc: [\n 'Admin password for zero-cache.',\n 'Required when the server is configured with one; ignored in dev mode.',\n ],\n },\n authToken: {\n type: v.string().optional(),\n desc: [\n 'Raw JWT forwarded to zero-cache.',\n 'Used server-side to fill permission variables for the query.',\n ],\n },\n cookie: {\n type: v.string().optional(),\n desc: [\n 'Cookie header value sent on the WebSocket upgrade request,',\n 'e.g. `session=abc; foo=bar`. Use this when zero-cache is behind',\n 'a proxy that resolves auth via cookies. Merged with --headers-json',\n '(--cookie wins on conflict).',\n ],\n },\n headersJson: {\n type: v.string().optional(),\n desc: [\n 'JSON object of arbitrary headers to send on the WebSocket upgrade',\n 'request, e.g. `{\"x-api-key\":\"...\"}`. Escape hatch for exotic auth',\n 'schemes; prefer --auth-token or --cookie when possible.',\n ],\n },\n userId: {\n type: v.string().optional(),\n desc: [\n 'Optional userID to report to zero-cache.',\n 'Omit for anonymous analysis.',\n ],\n },\n ast: {\n type: v.string().optional(),\n desc: [\n 'JSON-encoded AST. Exactly one of --ast / --query / --query-name is required.',\n 'The AST is sent to the server verbatim — provide it in server (post-mapping) form.',\n ],\n },\n query: {\n type: v.string().optional(),\n desc: [\n 'ZQL query in chain form, e.g. `issue.related(\"comments\").limit(10)`.',\n 'Evaluated against the schema you pass to runAnalyzeCLI.',\n ],\n },\n queryName: {\n type: v.string().optional(),\n desc: [\n 'Name of a server-registered custom (named) query.',\n 'The server resolves the name + args via its registered query handler.',\n ],\n },\n queryArgs: {\n type: v.string().optional(),\n desc: [\n 'JSON-encoded array of arguments for --query-name. Defaults to `[]`.',\n ],\n },\n outputVendedRows: {\n type: v.boolean().default(false),\n desc: [\n 'Include the rows read from the replica to execute the query.',\n 'Each row appears once per read.',\n ],\n },\n outputSyncedRows: {\n type: v.boolean().default(false),\n desc: ['Include the rows that would be synced to the client.'],\n },\n joinPlans: {\n type: v.boolean().default(false),\n desc: ['Include join planner diagnostics.'],\n },\n log: {\n ...logOptions,\n level: logLevel.default('error'),\n },\n};\n\ntype QueryPlan =\n | {kind: 'ast'; ast: AST}\n | {kind: 'zql'; text: string}\n | {kind: 'named'; name: string; args: ReadonlyArray<unknown>};\n\n// Route all Zero client log output to stderr so stdout contains only the\n// analyze result. Shell redirection (`2>/dev/null`) can then cleanly silence\n// logs without affecting output.\nconst stderrConsole = new Console({\n stdout: process.stderr,\n stderr: process.stderr,\n});\nconst stderrLogSink: LogSink = {\n log(level, context, ...args) {\n const ctx = context\n ? Object.entries(context).map(([k, v]) =>\n v === undefined ? k : `${k}=${v}`,\n )\n : [];\n stderrConsole[level](...ctx, ...args);\n },\n};\n\n/**\n * Entry point for a user's `cli.ts`. Parses argv, connects to a remote\n * zero-cache by standing up an in-process Zero client (in-memory storage,\n * no subscriptions), calls the inspector's `analyze-query` RPC, and\n * renders the result. Intended to be called as:\n *\n * ```ts\n * import {schema} from './schema.ts';\n * import {runAnalyzeCLI} from '@rocicorp/zero/analyze';\n * await runAnalyzeCLI({schema});\n * ```\n *\n * Exits the process with code 1 on error.\n */\nexport async function runAnalyzeCLI(opts: AnalyzeCLIOptions): Promise<void> {\n const argv = (opts.argv ?? process.argv.slice(2)).map(s =>\n s.replaceAll('\\n', ' '),\n );\n\n const config = parseOptions(options, {\n argv,\n envNamePrefix: ZERO_ENV_VAR_PREFIX,\n description: [\n {\n header: 'analyze-query (remote)',\n content: `Analyze a ZQL query against a remote zero-cache.\n\n Connects to zero-cache's inspector protocol and reports the server-observed\n row scans, SQLite query plans, and timings.`,\n },\n {\n header: 'Examples',\n content: ` node cli.ts --zero-cache-url=https://zero.example.com \\\\\n --admin-password=\"$ZERO_ADMIN_PASSWORD\" \\\\\n --query='issue.related(\"comments\").limit(10)'\n\n node cli.ts --zero-cache-url=http://localhost:4848 \\\\\n --ast='\\\\{\"table\": \"issue\", \"limit\": 5\\\\}'\n\n node cli.ts --zero-cache-url=http://localhost:4848 \\\\\n --query-name=issueList --query-args='[]'`,\n },\n ],\n });\n\n if (!config.zeroCacheURL) {\n colorConsole.error('--zero-cache-url is required. See --help for usage.');\n process.exit(1);\n }\n\n const plan = buildQueryPlan(config);\n\n const handshakeHeaders = resolveHandshakeHeaders(config);\n if (Object.keys(handshakeHeaders).length > 0) {\n installWebSocketHeaderShim(handshakeHeaders);\n }\n\n // zero-client and replicache reference a build-time `TESTING` global that\n // bundlers replace with a boolean literal; under node there's no replacement,\n // so provide a runtime default.\n (globalThis as {TESTING?: boolean}).TESTING ??= false;\n\n const z = new Zero({\n schema: opts.schema,\n server: config.zeroCacheURL,\n auth: config.authToken,\n userID: config.userId,\n kvStore: 'mem',\n logLevel: config.log.level,\n logSink: stderrLogSink,\n });\n\n let result: AnalyzeQueryResult;\n try {\n const authOk = await z.inspector.authenticate(config.adminPassword ?? '');\n if (!authOk) {\n throw new Error(\n 'admin password rejected (or --admin-password is required)',\n );\n }\n\n const rpcOptions = {\n vendedRows: config.outputVendedRows,\n syncedRows: config.outputSyncedRows,\n joinPlans: config.joinPlans,\n };\n\n if (plan.kind === 'ast') {\n result = await z.inspector.analyzeServerAST(plan.ast, rpcOptions);\n } else if (plan.kind === 'named') {\n result = await z.inspector.analyzeNamedQuery(\n plan.name,\n plan.args as ReadonlyArray<never>,\n rpcOptions,\n );\n } else {\n const built = buildZqlQuery(plan.text, createBuilder(opts.schema));\n result = await z.inspector.analyzeQuery(built, rpcOptions);\n }\n } catch (e) {\n colorConsole.error(e instanceof Error ? e.message : String(e));\n await z.close().catch(() => {});\n process.exit(1);\n }\n\n renderResult(result, {\n outputSyncedRows: config.outputSyncedRows,\n outputVendedRows: config.outputVendedRows,\n });\n\n await z.close();\n}\n\nfunction buildQueryPlan(config: {\n ast?: string | undefined;\n query?: string | undefined;\n queryName?: string | undefined;\n queryArgs?: string | undefined;\n}): QueryPlan {\n const selectors = [\n config.ast !== undefined && 'ast',\n config.query !== undefined && 'query',\n config.queryName !== undefined && 'queryName',\n ].filter(Boolean) as string[];\n\n if (selectors.length === 0) {\n colorConsole.error(\n 'Exactly one of --ast / --query / --query-name is required.',\n );\n process.exit(1);\n }\n if (selectors.length > 1) {\n colorConsole.error(\n `Only one of --ast / --query / --query-name may be provided; got: ${selectors.join(', ')}`,\n );\n process.exit(1);\n }\n\n if (config.ast !== undefined) {\n return {kind: 'ast', ast: JSON.parse(config.ast) as AST};\n }\n if (config.query !== undefined) {\n return {kind: 'zql', text: config.query};\n }\n const args = config.queryArgs\n ? (JSON.parse(config.queryArgs) as ReadonlyArray<unknown>)\n : [];\n return {kind: 'named', name: config.queryName as string, args};\n}\n\nfunction buildZqlQuery(\n queryString: string,\n builder: SchemaQuery<Schema>,\n): AnyQuery {\n const f = new Function('builder', `return builder.${queryString};`);\n return f(builder) as AnyQuery;\n}\n\nfunction resolveHandshakeHeaders(config: {\n cookie?: string | undefined;\n headersJson?: string | undefined;\n}): Record<string, string> {\n const headers: Record<string, string> = {};\n if (config.headersJson !== undefined) {\n let parsed: unknown;\n try {\n parsed = JSON.parse(config.headersJson);\n } catch (e) {\n colorConsole.error(\n `--headers-json is not valid JSON: ${e instanceof Error ? e.message : String(e)}`,\n );\n process.exit(1);\n }\n if (\n parsed === null ||\n typeof parsed !== 'object' ||\n Array.isArray(parsed)\n ) {\n colorConsole.error('--headers-json must be a JSON object.');\n process.exit(1);\n }\n for (const [k, val] of Object.entries(parsed)) {\n if (typeof val !== 'string') {\n colorConsole.error(\n `--headers-json values must be strings; got ${typeof val} for \"${k}\".`,\n );\n process.exit(1);\n }\n headers[k] = val;\n }\n }\n if (config.cookie !== undefined) {\n headers.cookie = config.cookie;\n }\n return headers;\n}\n\nfunction installWebSocketHeaderShim(headers: Record<string, string>): void {\n class HeaderInjectingWebSocket extends NodeWebSocket {\n constructor(url: string | URL, protocols?: string | string[]) {\n super(url, protocols, {headers});\n }\n }\n (globalThis as {WebSocket?: unknown}).WebSocket = HeaderInjectingWebSocket;\n}\n\nfunction renderResult(\n result: AnalyzeQueryResult,\n opts: {outputSyncedRows: boolean; outputVendedRows: boolean},\n) {\n if (opts.outputSyncedRows) {\n colorConsole.log(styleText(['blue', 'bold'], '=== Synced Rows: ===\\n'));\n for (const [table, rows] of Object.entries(result.syncedRows ?? {})) {\n colorConsole.log(styleText('bold', table + ':'), rows);\n }\n }\n\n colorConsole.log(styleText(['blue', 'bold'], '=== Query Stats: ===\\n'));\n colorConsole.log(\n styleText('bold', 'total synced rows:'),\n result.syncedRowCount,\n );\n\n const readRowCountsByQuery = result.readRowCountsByQuery ?? {};\n let totalRowsRead = 0;\n for (const table of Object.keys(readRowCountsByQuery).sort()) {\n const counts = readRowCountsByQuery[table];\n for (const n of Object.values(counts)) {\n totalRowsRead += n;\n }\n colorConsole.log(styleText('bold', `${table} vended:`), counts);\n }\n colorConsole.log(\n styleText('bold', 'Rows Read (into JS):'),\n colorRowsConsidered(totalRowsRead),\n );\n const duration = result.elapsed ?? result.end - result.start;\n colorConsole.log(styleText('bold', 'time:'), colorTime(duration), 'ms');\n\n if (opts.outputVendedRows) {\n colorConsole.log(\n styleText(['blue', 'bold'], '=== JS Row Scan Values: ===\\n'),\n );\n for (const [table, rows] of Object.entries(result.readRows ?? {})) {\n colorConsole.log(styleText('bold', `${table}:`), rows);\n }\n }\n\n colorConsole.log(\n styleText(['blue', 'bold'], '\\n=== Rows Scanned (by SQLite): ===\\n'),\n );\n const dbScansByQuery = result.dbScansByQuery ?? {};\n let totalNVisit = 0;\n for (const [table, queries] of Object.entries(dbScansByQuery)) {\n colorConsole.log(styleText('bold', `${table}:`), queries);\n for (const count of Object.values(queries)) {\n totalNVisit += count;\n }\n }\n colorConsole.log(\n styleText('bold', 'total rows scanned:'),\n colorRowsConsidered(totalNVisit),\n );\n\n colorConsole.log(styleText(['blue', 'bold'], '\\n\\n=== Query Plans: ===\\n'));\n const plans = result.sqlitePlans ?? {};\n for (const [query, plan] of Object.entries(plans)) {\n colorConsole.log(styleText('bold', 'query'), query);\n colorConsole.log(plan.map((row, i) => colorPlanRow(row, i)).join('\\n'));\n colorConsole.log('\\n');\n }\n\n if (result.warnings.length > 0) {\n colorConsole.log(styleText(['yellow', 'bold'], '=== Warnings: ===\\n'));\n for (const w of result.warnings) {\n colorConsole.log(styleText('yellow', w));\n }\n }\n\n if (result.joinPlans && result.joinPlans.length > 0) {\n colorConsole.log(styleText(['blue', 'bold'], '\\n=== Join Plans: ===\\n'));\n colorConsole.log(formatPlannerEvents(result.joinPlans));\n }\n}\n\nfunction colorTime(duration: number) {\n if (duration < 100) {\n return styleText('green', duration.toFixed(2) + 'ms');\n } else if (duration < 1000) {\n return styleText('yellow', duration.toFixed(2) + 'ms');\n }\n return styleText('red', duration.toFixed(2) + 'ms');\n}\n\nfunction colorRowsConsidered(n: number) {\n if (n < 1000) {\n return styleText('green', n.toString());\n } else if (n < 10000) {\n return styleText('yellow', n.toString());\n }\n return styleText('red', n.toString());\n}\n\nfunction colorPlanRow(row: string, i: number) {\n if (row.includes('SCAN')) {\n if (i === 0) {\n return styleText('yellow', row);\n }\n return styleText('red', row);\n }\n return styleText('green', row);\n}\n"],"mappings":"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"}
|
package/out/zero/package.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
var package_default = {
|
|
2
2
|
name: "@rocicorp/zero",
|
|
3
|
-
version: "1.11.0-canary.
|
|
3
|
+
version: "1.11.0-canary.8",
|
|
4
4
|
description: "Zero is a web framework for serverless web development.",
|
|
5
5
|
homepage: "https://zero.rocicorp.dev",
|
|
6
6
|
bugs: { "url": "https://bugs.rocicorp.dev" },
|
package/out/zero/package.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"package.js","names":[],"sources":["../../package.json"],"sourcesContent":["{\n \"name\": \"@rocicorp/zero\",\n \"version\": \"1.11.0-canary.
|
|
1
|
+
{"version":3,"file":"package.js","names":[],"sources":["../../package.json"],"sourcesContent":["{\n \"name\": \"@rocicorp/zero\",\n \"version\": \"1.11.0-canary.8\",\n \"description\": \"Zero is a web framework for serverless web development.\",\n \"homepage\": \"https://zero.rocicorp.dev\",\n \"bugs\": {\n \"url\": \"https://bugs.rocicorp.dev\"\n },\n \"license\": \"Apache-2.0\",\n \"author\": \"Rocicorp, Inc.\",\n \"repository\": {\n \"type\": \"git\",\n \"url\": \"git+https://github.com/rocicorp/mono.git\",\n \"directory\": \"packages/zero\"\n },\n \"bin\": {\n \"analyze-query\": \"./out/zero/src/analyze-query.js\",\n \"ast-to-zql\": \"./out/zero/src/ast-to-zql.js\",\n \"transform-query\": \"./out/zero/src/transform-query.js\",\n \"zero-build-schema\": \"./out/zero/src/build-schema.js\",\n \"zero-cache\": \"./out/zero/src/cli.js\",\n \"zero-cache-dev\": \"./out/zero/src/zero-cache-dev.js\",\n \"zero-deploy-permissions\": \"./out/zero/src/deploy-permissions.js\",\n \"zero-out\": \"./out/zero/src/zero-out.js\"\n },\n \"files\": [\n \"out\",\n \"!*.tsbuildinfo\"\n ],\n \"type\": \"module\",\n \"main\": \"out/zero/src/zero.js\",\n \"module\": \"out/zero/src/zero.js\",\n \"types\": \"out/zero/src/zero.d.ts\",\n \"exports\": {\n \".\": {\n \"types\": \"./out/zero/src/zero.d.ts\",\n \"default\": \"./out/zero/src/zero.js\"\n },\n \"./analyze\": {\n \"types\": \"./out/zero/src/analyze.d.ts\",\n \"default\": \"./out/zero/src/analyze.js\"\n },\n \"./bindings\": {\n \"types\": \"./out/zero/src/bindings.d.ts\",\n \"default\": \"./out/zero/src/bindings.js\"\n },\n \"./change-protocol/v0\": {\n \"types\": \"./out/zero/src/change-protocol/v0.d.ts\",\n \"default\": \"./out/zero/src/change-protocol/v0.js\"\n },\n \"./expo-sqlite\": {\n \"types\": \"./out/zero/src/expo-sqlite.d.ts\",\n \"default\": \"./out/zero/src/expo-sqlite.js\"\n },\n \"./op-sqlite\": {\n \"types\": \"./out/zero/src/op-sqlite.d.ts\",\n \"default\": \"./out/zero/src/op-sqlite.js\"\n },\n \"./pg\": {\n \"types\": \"./out/zero/src/pg.d.ts\",\n \"default\": \"./out/zero/src/pg.js\"\n },\n \"./react\": {\n \"types\": \"./out/zero/src/react.d.ts\",\n \"default\": \"./out/zero/src/react.js\"\n },\n \"./react-native\": {\n \"types\": \"./out/zero/src/react-native.d.ts\",\n \"default\": \"./out/zero/src/react-native.js\"\n },\n \"./server\": {\n \"types\": \"./out/zero/src/server.d.ts\",\n \"default\": \"./out/zero/src/server.js\"\n },\n \"./server/adapters/drizzle\": {\n \"types\": \"./out/zero/src/adapters/drizzle.d.ts\",\n \"default\": \"./out/zero/src/adapters/drizzle.js\"\n },\n \"./server/adapters/kysely\": {\n \"types\": \"./out/zero/src/adapters/kysely.d.ts\",\n \"default\": \"./out/zero/src/adapters/kysely.js\"\n },\n \"./server/adapters/prisma\": {\n \"types\": \"./out/zero/src/adapters/prisma.d.ts\",\n \"default\": \"./out/zero/src/adapters/prisma.js\"\n },\n \"./server/adapters/pg\": {\n \"types\": \"./out/zero/src/adapters/pg.d.ts\",\n \"default\": \"./out/zero/src/adapters/pg.js\"\n },\n \"./server/adapters/postgresjs\": {\n \"types\": \"./out/zero/src/adapters/postgresjs.d.ts\",\n \"default\": \"./out/zero/src/adapters/postgresjs.js\"\n },\n \"./solid\": {\n \"types\": \"./out/zero/src/solid.d.ts\",\n \"default\": \"./out/zero/src/solid.js\"\n },\n \"./sqlite\": {\n \"types\": \"./out/zero/src/sqlite.d.ts\",\n \"default\": \"./out/zero/src/sqlite.js\"\n },\n \"./zqlite\": {\n \"types\": \"./out/zero/src/zqlite.d.ts\",\n \"default\": \"./out/zero/src/zqlite.js\"\n }\n },\n \"scripts\": {\n \"build\": \"node --experimental-strip-types --no-warnings tool/build.ts\",\n \"build:watch\": \"node --experimental-strip-types --no-warnings tool/build.ts --watch\",\n \"check-types\": \"tsc -p tsconfig.client.json && tsc -p tsconfig.server.json\",\n \"check-types:client:watch\": \"tsc -p tsconfig.client.json --watch\",\n \"check-types:server:watch\": \"tsc -p tsconfig.server.json --watch\",\n \"format\": \"oxfmt .\",\n \"check-format\": \"oxfmt --check .\",\n \"lint\": \"oxlint --quiet --config ../../oxlint.config.ts src/\",\n \"docs\": \"node --experimental-strip-types --no-warnings tool/generate-docs.ts\",\n \"docs:server\": \"node --watch --experimental-strip-types --no-warnings tool/generate-docs.ts --server\",\n \"fmt\": \"oxfmt .\",\n \"check-fmt\": \"oxfmt --check .\"\n },\n \"dependencies\": {\n \"@badrap/valita\": \"0.3.11\",\n \"@databases/escape-identifier\": \"^1.0.3\",\n \"@databases/sql\": \"^3.3.0\",\n \"@dotenvx/dotenvx\": \"^1.39.0\",\n \"@drdgvhbh/postgres-error-codes\": \"^0.0.6\",\n \"@fastify/cors\": \"^10.0.0\",\n \"@fastify/websocket\": \"^11.0.0\",\n \"@google-cloud/precise-date\": \"^4.0.0\",\n \"@opentelemetry/api\": \"^1.9.0\",\n \"@opentelemetry/api-logs\": \"^0.218.0\",\n \"@opentelemetry/auto-instrumentations-node\": \"^0.76.0\",\n \"@opentelemetry/exporter-metrics-otlp-http\": \"^0.218.0\",\n \"@opentelemetry/resources\": \"^2.7.1\",\n \"@opentelemetry/sdk-metrics\": \"^2.7.1\",\n \"@opentelemetry/sdk-node\": \"^0.218.0\",\n \"@opentelemetry/sdk-trace-node\": \"^2.7.1\",\n \"@opentelemetry/semantic-conventions\": \"^1.41.1\",\n \"@postgresql-typed/oids\": \"^0.2.0\",\n \"@rocicorp/lock\": \"^1.0.4\",\n \"@rocicorp/logger\": \"^6.1.0\",\n \"@rocicorp/resolver\": \"^1.0.2\",\n \"@rocicorp/zero-sqlite3\": \"^1.1.4\",\n \"@standard-schema/spec\": \"^1.0.0\",\n \"@types/basic-auth\": \"^1.1.8\",\n \"@types/ws\": \"^8.5.12\",\n \"basic-auth\": \"^2.0.1\",\n \"chalk-template\": \"^1.1.0\",\n \"chokidar\": \"^4.0.1\",\n \"cloudevents\": \"^10.0.0\",\n \"command-line-args\": \"^6.0.1\",\n \"command-line-usage\": \"^7.0.3\",\n \"compare-utf8\": \"^0.2.3\",\n \"defu\": \"^6.1.4\",\n \"eventemitter3\": \"^5.0.1\",\n \"fastify\": \"^5.0.0\",\n \"is-in-subnet\": \"^4.0.1\",\n \"jose\": \"^5.9.3\",\n \"json-custom-numbers\": \"^3.1.1\",\n \"kasi\": \"^1.1.0\",\n \"nanoid\": \"^5.1.2\",\n \"oxfmt\": \"^0.65.0\",\n \"parse-prometheus-text-format\": \"^1.1.1\",\n \"pg-format\": \"npm:pg-format-fix@^1.0.5\",\n \"postgres\": \"3.4.7\",\n \"semver\": \"^7.5.4\",\n \"url-pattern\": \"^1.0.3\",\n \"urlpattern-polyfill\": \"^10.1.0\",\n \"ws\": \"^8.18.1\"\n },\n \"devDependencies\": {\n \"@op-engineering/op-sqlite\": \">=15\",\n \"@vitest/runner\": \"^4.1.6\",\n \"analyze-query\": \"workspace:*\",\n \"ast-to-zql\": \"workspace:*\",\n \"expo-sqlite\": \">=15\",\n \"oxc-parser\": \"^0.129.0\",\n \"replicache\": \"workspace:*\",\n \"shared\": \"workspace:*\",\n \"syncpack\": \"^14.3.0\",\n \"typedoc\": \"^0.28.17\",\n \"typescript\": \"~7.0.2\",\n \"vite\": \"^8.0.13\",\n \"vitest\": \"^4.1.6\",\n \"zero-cache\": \"workspace:*\",\n \"zero-client\": \"workspace:*\",\n \"zero-pg\": \"workspace:*\",\n \"zero-react\": \"workspace:*\",\n \"zero-server\": \"workspace:*\",\n \"zero-solid\": \"workspace:*\",\n \"zqlite\": \"workspace:*\"\n },\n \"engines\": {\n \"node\": \">=22\"\n },\n \"packageManager\": \"pnpm@11.11.0\"\n}\n"],"mappings":""}
|
|
@@ -13,7 +13,7 @@ import { ChangeProcessor } from "../../replicator/change-processor.js";
|
|
|
13
13
|
import { ReplicationStatusPublisher } from "../../replicator/replication-status.js";
|
|
14
14
|
import { restoreReplica } from "../common/replica-restore.js";
|
|
15
15
|
import { initReplica } from "../common/replica-schema.js";
|
|
16
|
-
import { WebSocket } from "ws";
|
|
16
|
+
import { WebSocket as WebSocket$1 } from "ws";
|
|
17
17
|
//#region ../zero-cache/src/services/change-source/custom/change-source.ts
|
|
18
18
|
/**
|
|
19
19
|
* Initializes a Custom change source before streaming changes from the
|
|
@@ -78,7 +78,7 @@ var CustomChangeSource = class {
|
|
|
78
78
|
url.searchParams.set("lastWatermark", clientWatermark);
|
|
79
79
|
url.searchParams.set("replicaVersion", replicaVersion);
|
|
80
80
|
}
|
|
81
|
-
const ws = new WebSocket(url);
|
|
81
|
+
const ws = new WebSocket$1(url);
|
|
82
82
|
const { instream, outstream } = stream(this.#lc, ws, changeStreamMessageSchema, { coalesce: (curr) => curr });
|
|
83
83
|
return {
|
|
84
84
|
changes: instream,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"change-source.js","names":["#lc","#upstreamUri","#shard","#replicationConfig","#startStream"],"sources":["../../../../../../../zero-cache/src/services/change-source/custom/change-source.ts"],"sourcesContent":["import type {LogContext} from '@rocicorp/logger';\nimport {WebSocket} from 'ws';\nimport {assert, unreachable} from '../../../../../shared/src/asserts.ts';\nimport {\n stringify,\n type JSONObject,\n} from '../../../../../shared/src/bigint-json.ts';\nimport {deepEqual} from '../../../../../shared/src/json.ts';\nimport type {SchemaValue} from '../../../../../zero-schema/src/table-schema.ts';\nimport {Database} from '../../../../../zqlite/src/db.ts';\nimport {computeZqlSpecs} from '../../../db/lite-tables.ts';\nimport {StatementRunner} from '../../../db/statements.ts';\nimport type {ShardConfig, ShardID} from '../../../types/shards.ts';\nimport {stream} from '../../../types/streams.ts';\nimport {\n AutoResetSignal,\n type ReplicationConfig,\n} from '../../change-streamer/schema/tables.ts';\nimport {ChangeProcessor} from '../../replicator/change-processor.ts';\nimport {ReplicationStatusPublisher} from '../../replicator/replication-status.ts';\nimport {\n createReplicationStateTables,\n getSubscriptionState,\n initReplicationState,\n} from '../../replicator/schema/replication-state.ts';\nimport type {ChangeSource, ChangeStream} from '../change-source.ts';\nimport {\n restoreReplica,\n type InitializeResult,\n type RestoreOptions,\n} from '../common/replica-restore.ts';\nimport {initReplica} from '../common/replica-schema.ts';\nimport {changeStreamMessageSchema} from '../protocol/current/downstream.ts';\nimport {\n type BackfillRequest,\n type ChangeSourceUpstream,\n} from '../protocol/current/upstream.ts';\n\n/** Server context to store with the initial sync metadata for debugging. */\nexport type ServerContext = JSONObject;\n\n/**\n * Initializes a Custom change source before streaming changes from the\n * corresponding logical replication stream.\n */\nexport async function initializeCustomChangeSource(\n lc: LogContext,\n upstreamURI: string,\n shard: ShardConfig,\n replicaDbFile: string,\n context: ServerContext,\n {litestream, constraints}: RestoreOptions = {},\n): Promise<InitializeResult> {\n // At the moment, the custom change-source implementation does not support\n // per-RM backups (and thus does not support litestream v5 or RMv2). The\n // current proposal to support this without requiring additional upstream\n // state tracking is to:\n // - use litestream to (ltx) query a fixed pool of subfolders within the\n // backupURL (e.g. /a/, /b/, /c/, /d/, /e/)\n // - restore from the subfolder with the latest ltx file\n // - backup to the subfolder with the oldest (or non-existent) ltx file\n if (litestream?.backupURL) {\n await restoreReplica(lc, litestream, replicaDbFile, constraints);\n }\n\n let initialSynced = false;\n await initReplica(\n lc,\n `replica-${shard.appID}-${shard.shardNum}`,\n replicaDbFile,\n (log, tx) => {\n initialSynced = true;\n return initialSync(log, shard, tx, upstreamURI, context);\n },\n );\n\n const replica = new Database(lc, replicaDbFile);\n const subscriptionState = getSubscriptionState(new StatementRunner(replica));\n replica.close();\n\n if (shard.publications.length) {\n // Verify that the publications match what has been synced.\n const requested = shard.publications.toSorted();\n const replicated = subscriptionState.publications.sort();\n if (!deepEqual(requested, replicated)) {\n throw new Error(\n `Invalid ShardConfig. Requested publications [${requested}] do not match synced publications: [${replicated}]`,\n );\n }\n }\n\n const changeSource = new CustomChangeSource(\n lc,\n upstreamURI,\n shard,\n subscriptionState,\n );\n\n return {\n subscriptionState,\n changeSource,\n destinationBackupURL: litestream?.backupURL,\n // A custom change source has no upstream `replicas` table to identify\n // itself from, so the generation is the whole of the SQLite change log's\n // identity here.\n replicaID: null,\n waitForBackupBeforeServing: initialSynced,\n };\n}\n\nclass CustomChangeSource implements ChangeSource {\n readonly #lc: LogContext;\n readonly #upstreamUri: string;\n readonly #shard: ShardID;\n readonly #replicationConfig: ReplicationConfig;\n\n constructor(\n lc: LogContext,\n upstreamUri: string,\n shard: ShardID,\n replicationConfig: ReplicationConfig,\n ) {\n this.#lc = lc.withContext('component', 'change-source');\n this.#upstreamUri = upstreamUri;\n this.#shard = shard;\n this.#replicationConfig = replicationConfig;\n }\n\n initialSync(): ChangeStream {\n return this.#startStream();\n }\n\n startLagReporter() {\n return null; // Not supported for custom sources\n }\n\n stop(): Promise<void> {\n return Promise.resolve();\n }\n\n startStream(\n clientWatermark: string,\n backfillRequests: BackfillRequest[] = [],\n ): Promise<ChangeStream> {\n if (backfillRequests?.length) {\n throw new Error(\n 'backfill is yet not supported for custom change sources',\n );\n }\n return Promise.resolve(this.#startStream(clientWatermark));\n }\n\n #startStream(clientWatermark?: string): ChangeStream {\n const {publications, replicaVersion} = this.#replicationConfig;\n const {appID, shardNum} = this.#shard;\n const url = new URL(this.#upstreamUri);\n url.searchParams.set('appID', appID);\n url.searchParams.set('shardNum', String(shardNum));\n for (const pub of publications) {\n url.searchParams.append('publications', pub);\n }\n if (clientWatermark) {\n assert(\n replicaVersion.length,\n 'replicaVersion is required when clientWatermark is set',\n );\n url.searchParams.set('lastWatermark', clientWatermark);\n url.searchParams.set('replicaVersion', replicaVersion);\n }\n\n const ws = new WebSocket(url);\n const {instream, outstream} = stream(\n this.#lc,\n ws,\n changeStreamMessageSchema,\n // Upstream acks coalesce. If upstream exhibits back-pressure,\n // only the last ACK is kept / buffered.\n {coalesce: (curr: ChangeSourceUpstream) => curr},\n );\n return {changes: instream, acks: outstream};\n }\n}\n\n/**\n * Initial sync for a custom change source makes a request to the\n * change source endpoint with no `replicaVersion` or `lastWatermark`.\n * The initial transaction returned by the endpoint is treated as\n * the initial sync, and the commit watermark of that transaction\n * becomes the `replicaVersion` of the initialized replica.\n *\n * Note that this is equivalent to how the LSN of the Postgres WAL\n * at initial sync time is the `replicaVersion` (and starting\n * version for all initially-synced rows).\n */\nexport async function initialSync(\n lc: LogContext,\n shard: ShardConfig,\n tx: Database,\n upstreamURI: string,\n context: ServerContext,\n) {\n const {appID: id, publications} = shard;\n const changeSource = new CustomChangeSource(lc, upstreamURI, shard, {\n replicaVersion: '', // ignored for initialSync()\n publications,\n });\n const {changes} = changeSource.initialSync();\n\n createReplicationStateTables(tx);\n const processor = new ChangeProcessor(\n new StatementRunner(tx),\n 'initial-sync',\n (_, err) => {\n throw err;\n },\n );\n\n const statusPublisher = ReplicationStatusPublisher.forRunningTransaction(tx);\n try {\n let num = 0;\n for await (const change of changes) {\n const [tag] = change;\n switch (tag) {\n case 'begin': {\n const {commitWatermark} = change[2];\n lc.info?.(\n `initial sync of shard ${id} at replicaVersion ${commitWatermark}`,\n );\n statusPublisher.publish(\n lc,\n 'Initializing',\n `Copying upstream tables at version ${commitWatermark}`,\n 5000,\n );\n initReplicationState(\n tx,\n publications.toSorted(),\n commitWatermark,\n context,\n false,\n );\n processor.processMessage(lc, change);\n break;\n }\n case 'data':\n processor.processMessage(lc, change);\n if (++num % 1000 === 0) {\n lc.debug?.(`processed ${num} changes`);\n }\n break;\n case 'commit':\n processor.processMessage(lc, change);\n validateInitiallySyncedData(lc, tx, shard);\n lc.info?.(`finished initial-sync of ${num} changes`);\n return;\n\n case 'status':\n break; // Ignored\n // @ts-expect-error: falls through if the tag is not 'reset-required\n case 'control': {\n const {tag, message} = change[1];\n if (tag === 'reset-required') {\n throw new AutoResetSignal(\n message ?? 'auto-reset signaled by change source',\n );\n }\n }\n // falls through\n case 'rollback':\n throw new Error(\n `unexpected message during initial-sync: ${stringify(change)}`,\n );\n default:\n unreachable(change);\n }\n }\n throw new Error(\n `change source ${upstreamURI} closed before initial-sync completed`,\n );\n } catch (e) {\n await statusPublisher.publishAndThrowError(lc, 'Initializing', e);\n } finally {\n statusPublisher.stop();\n }\n}\n\n// Verify that the upstream tables expected by the sync logic\n// have been properly initialized.\nfunction getRequiredTables({\n appID,\n shardNum,\n}: ShardID): Record<string, Record<string, SchemaValue>> {\n return {\n [`${appID}_${shardNum}.clients`]: {\n clientGroupID: {type: 'string'},\n clientID: {type: 'string'},\n lastMutationID: {type: 'number'},\n userID: {type: 'string'},\n },\n [`${appID}_${shardNum}.mutations`]: {\n clientGroupID: {type: 'string'},\n clientID: {type: 'string'},\n mutationID: {type: 'number'},\n mutation: {type: 'json'},\n },\n [`${appID}.permissions`]: {\n permissions: {type: 'json'},\n hash: {type: 'string'},\n },\n };\n}\n\nfunction validateInitiallySyncedData(\n lc: LogContext,\n db: Database,\n shard: ShardID,\n) {\n const tables = computeZqlSpecs(lc, db, {includeBackfillingColumns: true});\n const required = getRequiredTables(shard);\n for (const [name, columns] of Object.entries(required)) {\n const table = tables.get(name)?.zqlSpec;\n if (!table) {\n throw new Error(\n `Upstream is missing the \"${name}\" table. (Found ${[\n ...tables.keys(),\n ]})` +\n `Please ensure that each table has a unique index over one ` +\n `or more non-null columns.`,\n );\n }\n for (const [col, {type}] of Object.entries(columns)) {\n const found = table[col];\n if (!found) {\n throw new Error(\n `Upstream \"${table}\" table is missing the \"${col}\" column`,\n );\n }\n if (found.type !== type) {\n throw new Error(\n `Upstream \"${table}.${col}\" column is a ${found.type} type but must be a ${type} type.`,\n );\n }\n }\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"change-source.js","names":["#lc","#upstreamUri","#shard","#replicationConfig","#startStream"],"sources":["../../../../../../../zero-cache/src/services/change-source/custom/change-source.ts"],"sourcesContent":["import type {LogContext} from '@rocicorp/logger';\nimport {WebSocket} from 'ws';\nimport {assert, unreachable} from '../../../../../shared/src/asserts.ts';\nimport {\n stringify,\n type JSONObject,\n} from '../../../../../shared/src/bigint-json.ts';\nimport {deepEqual} from '../../../../../shared/src/json.ts';\nimport type {SchemaValue} from '../../../../../zero-schema/src/table-schema.ts';\nimport {Database} from '../../../../../zqlite/src/db.ts';\nimport {computeZqlSpecs} from '../../../db/lite-tables.ts';\nimport {StatementRunner} from '../../../db/statements.ts';\nimport type {ShardConfig, ShardID} from '../../../types/shards.ts';\nimport {stream} from '../../../types/streams.ts';\nimport {\n AutoResetSignal,\n type ReplicationConfig,\n} from '../../change-streamer/schema/tables.ts';\nimport {ChangeProcessor} from '../../replicator/change-processor.ts';\nimport {ReplicationStatusPublisher} from '../../replicator/replication-status.ts';\nimport {\n createReplicationStateTables,\n getSubscriptionState,\n initReplicationState,\n} from '../../replicator/schema/replication-state.ts';\nimport type {ChangeSource, ChangeStream} from '../change-source.ts';\nimport {\n restoreReplica,\n type InitializeResult,\n type RestoreOptions,\n} from '../common/replica-restore.ts';\nimport {initReplica} from '../common/replica-schema.ts';\nimport {changeStreamMessageSchema} from '../protocol/current/downstream.ts';\nimport {\n type BackfillRequest,\n type ChangeSourceUpstream,\n} from '../protocol/current/upstream.ts';\n\n/** Server context to store with the initial sync metadata for debugging. */\nexport type ServerContext = JSONObject;\n\n/**\n * Initializes a Custom change source before streaming changes from the\n * corresponding logical replication stream.\n */\nexport async function initializeCustomChangeSource(\n lc: LogContext,\n upstreamURI: string,\n shard: ShardConfig,\n replicaDbFile: string,\n context: ServerContext,\n {litestream, constraints}: RestoreOptions = {},\n): Promise<InitializeResult> {\n // At the moment, the custom change-source implementation does not support\n // per-RM backups (and thus does not support litestream v5 or RMv2). The\n // current proposal to support this without requiring additional upstream\n // state tracking is to:\n // - use litestream to (ltx) query a fixed pool of subfolders within the\n // backupURL (e.g. /a/, /b/, /c/, /d/, /e/)\n // - restore from the subfolder with the latest ltx file\n // - backup to the subfolder with the oldest (or non-existent) ltx file\n if (litestream?.backupURL) {\n await restoreReplica(lc, litestream, replicaDbFile, constraints);\n }\n\n let initialSynced = false;\n await initReplica(\n lc,\n `replica-${shard.appID}-${shard.shardNum}`,\n replicaDbFile,\n (log, tx) => {\n initialSynced = true;\n return initialSync(log, shard, tx, upstreamURI, context);\n },\n );\n\n const replica = new Database(lc, replicaDbFile);\n const subscriptionState = getSubscriptionState(new StatementRunner(replica));\n replica.close();\n\n if (shard.publications.length) {\n // Verify that the publications match what has been synced.\n const requested = shard.publications.toSorted();\n const replicated = subscriptionState.publications.sort();\n if (!deepEqual(requested, replicated)) {\n throw new Error(\n `Invalid ShardConfig. Requested publications [${requested}] do not match synced publications: [${replicated}]`,\n );\n }\n }\n\n const changeSource = new CustomChangeSource(\n lc,\n upstreamURI,\n shard,\n subscriptionState,\n );\n\n return {\n subscriptionState,\n changeSource,\n destinationBackupURL: litestream?.backupURL,\n // A custom change source has no upstream `replicas` table to identify\n // itself from, so the generation is the whole of the SQLite change log's\n // identity here.\n replicaID: null,\n waitForBackupBeforeServing: initialSynced,\n };\n}\n\nclass CustomChangeSource implements ChangeSource {\n readonly #lc: LogContext;\n readonly #upstreamUri: string;\n readonly #shard: ShardID;\n readonly #replicationConfig: ReplicationConfig;\n\n constructor(\n lc: LogContext,\n upstreamUri: string,\n shard: ShardID,\n replicationConfig: ReplicationConfig,\n ) {\n this.#lc = lc.withContext('component', 'change-source');\n this.#upstreamUri = upstreamUri;\n this.#shard = shard;\n this.#replicationConfig = replicationConfig;\n }\n\n initialSync(): ChangeStream {\n return this.#startStream();\n }\n\n startLagReporter() {\n return null; // Not supported for custom sources\n }\n\n stop(): Promise<void> {\n return Promise.resolve();\n }\n\n startStream(\n clientWatermark: string,\n backfillRequests: BackfillRequest[] = [],\n ): Promise<ChangeStream> {\n if (backfillRequests?.length) {\n throw new Error(\n 'backfill is yet not supported for custom change sources',\n );\n }\n return Promise.resolve(this.#startStream(clientWatermark));\n }\n\n #startStream(clientWatermark?: string): ChangeStream {\n const {publications, replicaVersion} = this.#replicationConfig;\n const {appID, shardNum} = this.#shard;\n const url = new URL(this.#upstreamUri);\n url.searchParams.set('appID', appID);\n url.searchParams.set('shardNum', String(shardNum));\n for (const pub of publications) {\n url.searchParams.append('publications', pub);\n }\n if (clientWatermark) {\n assert(\n replicaVersion.length,\n 'replicaVersion is required when clientWatermark is set',\n );\n url.searchParams.set('lastWatermark', clientWatermark);\n url.searchParams.set('replicaVersion', replicaVersion);\n }\n\n const ws = new WebSocket(url);\n const {instream, outstream} = stream(\n this.#lc,\n ws,\n changeStreamMessageSchema,\n // Upstream acks coalesce. If upstream exhibits back-pressure,\n // only the last ACK is kept / buffered.\n {coalesce: (curr: ChangeSourceUpstream) => curr},\n );\n return {changes: instream, acks: outstream};\n }\n}\n\n/**\n * Initial sync for a custom change source makes a request to the\n * change source endpoint with no `replicaVersion` or `lastWatermark`.\n * The initial transaction returned by the endpoint is treated as\n * the initial sync, and the commit watermark of that transaction\n * becomes the `replicaVersion` of the initialized replica.\n *\n * Note that this is equivalent to how the LSN of the Postgres WAL\n * at initial sync time is the `replicaVersion` (and starting\n * version for all initially-synced rows).\n */\nexport async function initialSync(\n lc: LogContext,\n shard: ShardConfig,\n tx: Database,\n upstreamURI: string,\n context: ServerContext,\n) {\n const {appID: id, publications} = shard;\n const changeSource = new CustomChangeSource(lc, upstreamURI, shard, {\n replicaVersion: '', // ignored for initialSync()\n publications,\n });\n const {changes} = changeSource.initialSync();\n\n createReplicationStateTables(tx);\n const processor = new ChangeProcessor(\n new StatementRunner(tx),\n 'initial-sync',\n (_, err) => {\n throw err;\n },\n );\n\n const statusPublisher = ReplicationStatusPublisher.forRunningTransaction(tx);\n try {\n let num = 0;\n for await (const change of changes) {\n const [tag] = change;\n switch (tag) {\n case 'begin': {\n const {commitWatermark} = change[2];\n lc.info?.(\n `initial sync of shard ${id} at replicaVersion ${commitWatermark}`,\n );\n statusPublisher.publish(\n lc,\n 'Initializing',\n `Copying upstream tables at version ${commitWatermark}`,\n 5000,\n );\n initReplicationState(\n tx,\n publications.toSorted(),\n commitWatermark,\n context,\n false,\n );\n processor.processMessage(lc, change);\n break;\n }\n case 'data':\n processor.processMessage(lc, change);\n if (++num % 1000 === 0) {\n lc.debug?.(`processed ${num} changes`);\n }\n break;\n case 'commit':\n processor.processMessage(lc, change);\n validateInitiallySyncedData(lc, tx, shard);\n lc.info?.(`finished initial-sync of ${num} changes`);\n return;\n\n case 'status':\n break; // Ignored\n // @ts-expect-error: falls through if the tag is not 'reset-required\n case 'control': {\n const {tag, message} = change[1];\n if (tag === 'reset-required') {\n throw new AutoResetSignal(\n message ?? 'auto-reset signaled by change source',\n );\n }\n }\n // falls through\n case 'rollback':\n throw new Error(\n `unexpected message during initial-sync: ${stringify(change)}`,\n );\n default:\n unreachable(change);\n }\n }\n throw new Error(\n `change source ${upstreamURI} closed before initial-sync completed`,\n );\n } catch (e) {\n await statusPublisher.publishAndThrowError(lc, 'Initializing', e);\n } finally {\n statusPublisher.stop();\n }\n}\n\n// Verify that the upstream tables expected by the sync logic\n// have been properly initialized.\nfunction getRequiredTables({\n appID,\n shardNum,\n}: ShardID): Record<string, Record<string, SchemaValue>> {\n return {\n [`${appID}_${shardNum}.clients`]: {\n clientGroupID: {type: 'string'},\n clientID: {type: 'string'},\n lastMutationID: {type: 'number'},\n userID: {type: 'string'},\n },\n [`${appID}_${shardNum}.mutations`]: {\n clientGroupID: {type: 'string'},\n clientID: {type: 'string'},\n mutationID: {type: 'number'},\n mutation: {type: 'json'},\n },\n [`${appID}.permissions`]: {\n permissions: {type: 'json'},\n hash: {type: 'string'},\n },\n };\n}\n\nfunction validateInitiallySyncedData(\n lc: LogContext,\n db: Database,\n shard: ShardID,\n) {\n const tables = computeZqlSpecs(lc, db, {includeBackfillingColumns: true});\n const required = getRequiredTables(shard);\n for (const [name, columns] of Object.entries(required)) {\n const table = tables.get(name)?.zqlSpec;\n if (!table) {\n throw new Error(\n `Upstream is missing the \"${name}\" table. (Found ${[\n ...tables.keys(),\n ]})` +\n `Please ensure that each table has a unique index over one ` +\n `or more non-null columns.`,\n );\n }\n for (const [col, {type}] of Object.entries(columns)) {\n const found = table[col];\n if (!found) {\n throw new Error(\n `Upstream \"${table}\" table is missing the \"${col}\" column`,\n );\n }\n if (found.type !== type) {\n throw new Error(\n `Upstream \"${table}.${col}\" column is a ${found.type} type but must be a ${type} type.`,\n );\n }\n }\n }\n}\n"],"mappings":"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"}
|
|
@@ -11,7 +11,7 @@ import { streamIn, streamInWithSize, streamOut, streamOutStringified } from "../
|
|
|
11
11
|
import { discoverChangeStreamerAddress } from "./schema/tables.js";
|
|
12
12
|
import { downstreamSchema } from "./change-streamer.js";
|
|
13
13
|
import { snapshotMessageSchema } from "./snapshot.js";
|
|
14
|
-
import WebSocket
|
|
14
|
+
import WebSocket from "ws";
|
|
15
15
|
import websocket from "@fastify/websocket";
|
|
16
16
|
//#region ../zero-cache/src/services/change-streamer/change-streamer-http.ts
|
|
17
17
|
var MIN_SUPPORTED_PROTOCOL_VERSION = 4;
|
|
@@ -116,11 +116,11 @@ var ChangeStreamerHttpClient = class {
|
|
|
116
116
|
return uri;
|
|
117
117
|
}
|
|
118
118
|
async reserveSnapshot(taskID) {
|
|
119
|
-
const ws = new WebSocket
|
|
119
|
+
const ws = new WebSocket(await this.#resolveChangeStreamer(SNAPSHOT_PATH) + `?${new URLSearchParams({ taskID }).toString()}`);
|
|
120
120
|
return streamIn(this.#lc, ws, snapshotMessageSchema);
|
|
121
121
|
}
|
|
122
122
|
async subscribe(ctx) {
|
|
123
|
-
const ws = new WebSocket
|
|
123
|
+
const ws = new WebSocket(await this.#resolveChangeStreamer(CHANGES_PATH) + `?${getParams(ctx).toString()}`);
|
|
124
124
|
return streamInWithSize(this.#lc, ws, downstreamSchema);
|
|
125
125
|
}
|
|
126
126
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"change-streamer-http.js","names":["#lc","#opts","#changeStreamer","#subscribe","#reserveSnapshot","#receiveWebsocket","#ensureChangeStreamerStarted","#changeStreamerStarted","#shardID","#changeDB","#changeStreamerURI","#resolveChangeStreamer"],"sources":["../../../../../../zero-cache/src/services/change-streamer/change-streamer-http.ts"],"sourcesContent":["import type {IncomingMessage} from 'node:http';\nimport websocket from '@fastify/websocket';\nimport type {LogContext} from '@rocicorp/logger';\nimport WebSocket from 'ws';\nimport {assert} from '../../../../shared/src/asserts.ts';\nimport {promiseVoid} from '../../../../shared/src/resolved-promises.ts';\nimport type {NormalizedZeroConfig} from '../../config/normalize.ts';\nimport type {IncomingMessageSubset} from '../../types/http.ts';\nimport {pgClient, type PostgresDB} from '../../types/pg.ts';\nimport {type Worker} from '../../types/processes.ts';\nimport {type ShardID} from '../../types/shards.ts';\nimport {\n streamIn,\n streamInWithSize,\n streamOut,\n streamOutStringified,\n type Source,\n} from '../../types/streams.ts';\nimport {URLParams} from '../../types/url-params.ts';\nimport {installWebSocketReceiver} from '../../types/websocket-handoff.ts';\nimport {closeWithError, PROTOCOL_ERROR} from '../../types/ws.ts';\nimport {HttpService, type Options as HttpOptions} from '../http-service.ts';\nimport {handleProfzRequest} from '../profz.ts';\nimport {\n downstreamSchema,\n PROTOCOL_VERSION,\n type ChangeStreamer,\n type ChangeStreamerService,\n type SizedDownstream,\n type SubscriberContext,\n} from './change-streamer.ts';\nimport {discoverChangeStreamerAddress} from './schema/tables.ts';\nimport {snapshotMessageSchema, type SnapshotMessage} from './snapshot.ts';\n\nconst MIN_SUPPORTED_PROTOCOL_VERSION = 4;\n\nconst SNAPSHOT_PATH_PATTERN = '/replication/:version/snapshot';\nconst CHANGES_PATH_PATTERN = '/replication/:version/changes';\nconst PATH_REGEX = /\\/replication\\/v(?<version>\\d+)\\/(changes|snapshot)$/;\n\nconst SNAPSHOT_PATH = `/replication/v${PROTOCOL_VERSION}/snapshot`;\nconst CHANGES_PATH = `/replication/v${PROTOCOL_VERSION}/changes`;\n\ntype Options = HttpOptions & {\n startupDelayMs: number;\n config?: Pick<NormalizedZeroConfig, 'adminPassword'> | undefined;\n getProfileWorker?: (() => Promise<Worker>) | undefined;\n};\n\nexport class ChangeStreamerHttpServer extends HttpService {\n readonly id = 'change-streamer-http-server';\n readonly #lc: LogContext;\n readonly #opts: Options;\n readonly #changeStreamer: ChangeStreamerService;\n\n constructor(\n lc: LogContext,\n opts: Options,\n parent: Worker,\n changeStreamer: ChangeStreamerService,\n ) {\n super('change-streamer-http-server', lc, opts, async fastify => {\n await fastify.register(websocket);\n\n fastify.get(CHANGES_PATH_PATTERN, {websocket: true}, this.#subscribe);\n fastify.get(\n SNAPSHOT_PATH_PATTERN,\n {websocket: true},\n this.#reserveSnapshot,\n );\n\n fastify.get('/profz', (req, res) =>\n handleProfzRequest(\n lc,\n opts.config ?? {adminPassword: undefined},\n req,\n res,\n opts.getProfileWorker,\n undefined,\n 'change-streamer',\n ),\n );\n\n installWebSocketReceiver<'snapshot' | 'changes'>(\n lc,\n fastify.websocketServer,\n this.#receiveWebsocket,\n parent,\n );\n });\n\n this.#lc = lc;\n this.#opts = opts;\n this.#changeStreamer = changeStreamer;\n }\n\n // Called when receiving a web socket via the main dispatcher handoff.\n readonly #receiveWebsocket = (\n ws: WebSocket,\n action: 'changes' | 'snapshot',\n msg: IncomingMessageSubset,\n ) => {\n switch (action) {\n case 'snapshot':\n return this.#reserveSnapshot(ws, msg);\n case 'changes':\n return this.#subscribe(ws, msg);\n default:\n closeWithError(\n this._lc,\n ws,\n `invalid action \"${action}\" received in handoff`,\n );\n return;\n }\n };\n\n readonly #reserveSnapshot = async (ws: WebSocket, req: RequestHeaders) => {\n this.#ensureChangeStreamerStarted('incoming snapshot reservation');\n try {\n const url = new URL(\n req.url ?? '',\n req.headers.origin ?? 'http://localhost',\n );\n checkProtocolVersion(url.pathname);\n const taskID = url.searchParams.get('taskID');\n if (!taskID) {\n throw new Error('Missing taskID in snapshot request');\n }\n const downstream =\n await this.#changeStreamer.startSnapshotReservation(taskID);\n void streamOut(this._lc, downstream, ws);\n } catch (err) {\n closeWithError(this._lc, ws, err, PROTOCOL_ERROR);\n }\n };\n\n readonly #subscribe = async (ws: WebSocket, req: RequestHeaders) => {\n try {\n const ctx = getSubscriberContext(req);\n if (ctx.mode === 'serving') {\n this.#ensureChangeStreamerStarted('incoming subscription');\n }\n\n const downstream = await this.#changeStreamer.subscribe(ctx);\n void streamOutStringified(this._lc, downstream, ws);\n } catch (err) {\n closeWithError(this._lc, ws, err, PROTOCOL_ERROR);\n }\n };\n\n #changeStreamerStarted = false;\n\n #ensureChangeStreamerStarted(reason: string) {\n if (!this.#changeStreamerStarted && this._state.shouldRun()) {\n this.#lc.info?.(`starting ChangeStreamerService: ${reason}`);\n void this.#changeStreamer\n .run()\n .catch(e =>\n this.#lc.warn?.(`ChangeStreamerService ended with error`, e),\n )\n .finally(() => this.stop());\n\n this.#changeStreamerStarted = true;\n }\n }\n\n protected override _onStart(): void {\n const {startupDelayMs, readinessGate = promiseVoid} = this.#opts;\n void readinessGate.then(() => {\n if (startupDelayMs > 0) {\n // In RMv1, starting the change-streamer forcibly shuts down the\n // previous change-streamer, causing view-syncers to reconnect.\n // If this replication-manager has just started, the routing layer may\n // not have registered it with DNS, as that only happens after it\n // confirms health checks. To minimize downtime, the takeover is\n // delayed for the configured startupDelayMs _after_ beginning to\n // advertise readiness.\n this.#lc.info?.(\n `waiting ${startupDelayMs}ms before taking over the change log`,\n );\n this._state.setTimeout(\n () =>\n this.#ensureChangeStreamerStarted(\n `startup delay elapsed (${startupDelayMs} ms)`,\n ),\n startupDelayMs,\n );\n } else {\n // In RMv2, this is not necessary because a new RM does not kill the\n // old one; it is started as soon as possible.\n this.#ensureChangeStreamerStarted('no startup delay configured');\n }\n });\n }\n\n protected override async _onStop(): Promise<void> {\n if (this.#changeStreamerStarted) {\n await this.#changeStreamer.stop();\n }\n }\n}\n\nexport class ChangeStreamerHttpClient implements ChangeStreamer {\n readonly #lc: LogContext;\n readonly #shardID: ShardID;\n readonly #changeDB: PostgresDB;\n readonly #changeStreamerURI: string | undefined;\n\n constructor(\n lc: LogContext,\n shardID: ShardID,\n changeDB: string,\n changeStreamerURI: string | undefined,\n ) {\n this.#lc = lc;\n this.#shardID = shardID;\n // Create a pg client with a single short-lived connection for the purpose\n // of change-streamer discovery (i.e. ChangeDB as DNS).\n this.#changeDB = pgClient(lc, changeDB, 'change-streamer-discovery', {\n max: 1,\n ['idle_timeout']: 15,\n });\n this.#changeStreamerURI = changeStreamerURI;\n }\n\n async #resolveChangeStreamer(path: string) {\n let baseURL = this.#changeStreamerURI;\n if (!baseURL) {\n const address = await discoverChangeStreamerAddress(\n this.#shardID,\n this.#changeDB,\n );\n if (!address) {\n throw new Error(`no change-streamer is running`);\n }\n baseURL = address.includes('://') ? `${address}/` : `ws://${address}/`;\n }\n const uri = new URL(path, baseURL);\n this.#lc.info?.(`connecting to change-streamer@${uri}`);\n return uri;\n }\n\n async reserveSnapshot(taskID: string): Promise<Source<SnapshotMessage>> {\n const uri = await this.#resolveChangeStreamer(SNAPSHOT_PATH);\n\n const params = new URLSearchParams({taskID});\n const ws = new WebSocket(uri + `?${params.toString()}`);\n\n return streamIn(this.#lc, ws, snapshotMessageSchema);\n }\n\n async subscribe(ctx: SubscriberContext): Promise<Source<SizedDownstream>> {\n const uri = await this.#resolveChangeStreamer(CHANGES_PATH);\n\n const params = getParams(ctx);\n const ws = new WebSocket(uri + `?${params.toString()}`);\n\n return streamInWithSize(this.#lc, ws, downstreamSchema);\n }\n}\n\ntype RequestHeaders = Pick<IncomingMessage, 'url' | 'headers'>;\n\nexport function getSubscriberContext(req: RequestHeaders): SubscriberContext {\n const url = new URL(req.url ?? '', req.headers.origin ?? 'http://localhost');\n const protocolVersion = checkProtocolVersion(url.pathname);\n const params = new URLParams(url);\n\n return {\n protocolVersion,\n id: params.get('id', true),\n taskID: params.get('taskID', true),\n mode: params.get('mode', false) === 'backup' ? 'backup' : 'serving',\n replicaVersion: params.get('replicaVersion', true),\n watermark: params.get('watermark', true),\n initial: params.getBoolean('initial'),\n // Absent for subscribers that predate the parameter, which is the safe\n // default: the barrier falls back to polling rather than waiting on an\n // ACK that would never be attributed to a writer.\n logsChangeStream: params.getBoolean('logsChangeStream'),\n };\n}\n\nfunction checkProtocolVersion(pathname: string): number {\n const match = PATH_REGEX.exec(pathname);\n if (!match) {\n throw new Error(`invalid path: ${pathname}`);\n }\n const v = Number(match.groups?.version);\n if (\n Number.isNaN(v) ||\n v > PROTOCOL_VERSION ||\n v < MIN_SUPPORTED_PROTOCOL_VERSION\n ) {\n throw new Error(\n `Cannot service client at protocol v${v}. ` +\n `Supported protocols: [v${MIN_SUPPORTED_PROTOCOL_VERSION} ... v${PROTOCOL_VERSION}]`,\n );\n }\n return v;\n}\n\n// This is called from the client-side (i.e. the replicator).\nfunction getParams(ctx: SubscriberContext): URLSearchParams {\n // The protocolVersion is hard-coded into the CHANGES_PATH.\n const {protocolVersion, ...stringParams} = ctx;\n assert(\n protocolVersion === PROTOCOL_VERSION,\n `replicator should be setting protocolVersion to ${PROTOCOL_VERSION}`,\n );\n return new URLSearchParams({\n ...stringParams,\n initial: ctx.initial ? 'true' : 'false',\n logsChangeStream: ctx.logsChangeStream ? 'true' : 'false',\n });\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"change-streamer-http.js","names":["#lc","#opts","#changeStreamer","#subscribe","#reserveSnapshot","#receiveWebsocket","#ensureChangeStreamerStarted","#changeStreamerStarted","#shardID","#changeDB","#changeStreamerURI","#resolveChangeStreamer"],"sources":["../../../../../../zero-cache/src/services/change-streamer/change-streamer-http.ts"],"sourcesContent":["import type {IncomingMessage} from 'node:http';\nimport websocket from '@fastify/websocket';\nimport type {LogContext} from '@rocicorp/logger';\nimport WebSocket from 'ws';\nimport {assert} from '../../../../shared/src/asserts.ts';\nimport {promiseVoid} from '../../../../shared/src/resolved-promises.ts';\nimport type {NormalizedZeroConfig} from '../../config/normalize.ts';\nimport type {IncomingMessageSubset} from '../../types/http.ts';\nimport {pgClient, type PostgresDB} from '../../types/pg.ts';\nimport {type Worker} from '../../types/processes.ts';\nimport {type ShardID} from '../../types/shards.ts';\nimport {\n streamIn,\n streamInWithSize,\n streamOut,\n streamOutStringified,\n type Source,\n} from '../../types/streams.ts';\nimport {URLParams} from '../../types/url-params.ts';\nimport {installWebSocketReceiver} from '../../types/websocket-handoff.ts';\nimport {closeWithError, PROTOCOL_ERROR} from '../../types/ws.ts';\nimport {HttpService, type Options as HttpOptions} from '../http-service.ts';\nimport {handleProfzRequest} from '../profz.ts';\nimport {\n downstreamSchema,\n PROTOCOL_VERSION,\n type ChangeStreamer,\n type ChangeStreamerService,\n type SizedDownstream,\n type SubscriberContext,\n} from './change-streamer.ts';\nimport {discoverChangeStreamerAddress} from './schema/tables.ts';\nimport {snapshotMessageSchema, type SnapshotMessage} from './snapshot.ts';\n\nconst MIN_SUPPORTED_PROTOCOL_VERSION = 4;\n\nconst SNAPSHOT_PATH_PATTERN = '/replication/:version/snapshot';\nconst CHANGES_PATH_PATTERN = '/replication/:version/changes';\nconst PATH_REGEX = /\\/replication\\/v(?<version>\\d+)\\/(changes|snapshot)$/;\n\nconst SNAPSHOT_PATH = `/replication/v${PROTOCOL_VERSION}/snapshot`;\nconst CHANGES_PATH = `/replication/v${PROTOCOL_VERSION}/changes`;\n\ntype Options = HttpOptions & {\n startupDelayMs: number;\n config?: Pick<NormalizedZeroConfig, 'adminPassword'> | undefined;\n getProfileWorker?: (() => Promise<Worker>) | undefined;\n};\n\nexport class ChangeStreamerHttpServer extends HttpService {\n readonly id = 'change-streamer-http-server';\n readonly #lc: LogContext;\n readonly #opts: Options;\n readonly #changeStreamer: ChangeStreamerService;\n\n constructor(\n lc: LogContext,\n opts: Options,\n parent: Worker,\n changeStreamer: ChangeStreamerService,\n ) {\n super('change-streamer-http-server', lc, opts, async fastify => {\n await fastify.register(websocket);\n\n fastify.get(CHANGES_PATH_PATTERN, {websocket: true}, this.#subscribe);\n fastify.get(\n SNAPSHOT_PATH_PATTERN,\n {websocket: true},\n this.#reserveSnapshot,\n );\n\n fastify.get('/profz', (req, res) =>\n handleProfzRequest(\n lc,\n opts.config ?? {adminPassword: undefined},\n req,\n res,\n opts.getProfileWorker,\n undefined,\n 'change-streamer',\n ),\n );\n\n installWebSocketReceiver<'snapshot' | 'changes'>(\n lc,\n fastify.websocketServer,\n this.#receiveWebsocket,\n parent,\n );\n });\n\n this.#lc = lc;\n this.#opts = opts;\n this.#changeStreamer = changeStreamer;\n }\n\n // Called when receiving a web socket via the main dispatcher handoff.\n readonly #receiveWebsocket = (\n ws: WebSocket,\n action: 'changes' | 'snapshot',\n msg: IncomingMessageSubset,\n ) => {\n switch (action) {\n case 'snapshot':\n return this.#reserveSnapshot(ws, msg);\n case 'changes':\n return this.#subscribe(ws, msg);\n default:\n closeWithError(\n this._lc,\n ws,\n `invalid action \"${action}\" received in handoff`,\n );\n return;\n }\n };\n\n readonly #reserveSnapshot = async (ws: WebSocket, req: RequestHeaders) => {\n this.#ensureChangeStreamerStarted('incoming snapshot reservation');\n try {\n const url = new URL(\n req.url ?? '',\n req.headers.origin ?? 'http://localhost',\n );\n checkProtocolVersion(url.pathname);\n const taskID = url.searchParams.get('taskID');\n if (!taskID) {\n throw new Error('Missing taskID in snapshot request');\n }\n const downstream =\n await this.#changeStreamer.startSnapshotReservation(taskID);\n void streamOut(this._lc, downstream, ws);\n } catch (err) {\n closeWithError(this._lc, ws, err, PROTOCOL_ERROR);\n }\n };\n\n readonly #subscribe = async (ws: WebSocket, req: RequestHeaders) => {\n try {\n const ctx = getSubscriberContext(req);\n if (ctx.mode === 'serving') {\n this.#ensureChangeStreamerStarted('incoming subscription');\n }\n\n const downstream = await this.#changeStreamer.subscribe(ctx);\n void streamOutStringified(this._lc, downstream, ws);\n } catch (err) {\n closeWithError(this._lc, ws, err, PROTOCOL_ERROR);\n }\n };\n\n #changeStreamerStarted = false;\n\n #ensureChangeStreamerStarted(reason: string) {\n if (!this.#changeStreamerStarted && this._state.shouldRun()) {\n this.#lc.info?.(`starting ChangeStreamerService: ${reason}`);\n void this.#changeStreamer\n .run()\n .catch(e =>\n this.#lc.warn?.(`ChangeStreamerService ended with error`, e),\n )\n .finally(() => this.stop());\n\n this.#changeStreamerStarted = true;\n }\n }\n\n protected override _onStart(): void {\n const {startupDelayMs, readinessGate = promiseVoid} = this.#opts;\n void readinessGate.then(() => {\n if (startupDelayMs > 0) {\n // In RMv1, starting the change-streamer forcibly shuts down the\n // previous change-streamer, causing view-syncers to reconnect.\n // If this replication-manager has just started, the routing layer may\n // not have registered it with DNS, as that only happens after it\n // confirms health checks. To minimize downtime, the takeover is\n // delayed for the configured startupDelayMs _after_ beginning to\n // advertise readiness.\n this.#lc.info?.(\n `waiting ${startupDelayMs}ms before taking over the change log`,\n );\n this._state.setTimeout(\n () =>\n this.#ensureChangeStreamerStarted(\n `startup delay elapsed (${startupDelayMs} ms)`,\n ),\n startupDelayMs,\n );\n } else {\n // In RMv2, this is not necessary because a new RM does not kill the\n // old one; it is started as soon as possible.\n this.#ensureChangeStreamerStarted('no startup delay configured');\n }\n });\n }\n\n protected override async _onStop(): Promise<void> {\n if (this.#changeStreamerStarted) {\n await this.#changeStreamer.stop();\n }\n }\n}\n\nexport class ChangeStreamerHttpClient implements ChangeStreamer {\n readonly #lc: LogContext;\n readonly #shardID: ShardID;\n readonly #changeDB: PostgresDB;\n readonly #changeStreamerURI: string | undefined;\n\n constructor(\n lc: LogContext,\n shardID: ShardID,\n changeDB: string,\n changeStreamerURI: string | undefined,\n ) {\n this.#lc = lc;\n this.#shardID = shardID;\n // Create a pg client with a single short-lived connection for the purpose\n // of change-streamer discovery (i.e. ChangeDB as DNS).\n this.#changeDB = pgClient(lc, changeDB, 'change-streamer-discovery', {\n max: 1,\n ['idle_timeout']: 15,\n });\n this.#changeStreamerURI = changeStreamerURI;\n }\n\n async #resolveChangeStreamer(path: string) {\n let baseURL = this.#changeStreamerURI;\n if (!baseURL) {\n const address = await discoverChangeStreamerAddress(\n this.#shardID,\n this.#changeDB,\n );\n if (!address) {\n throw new Error(`no change-streamer is running`);\n }\n baseURL = address.includes('://') ? `${address}/` : `ws://${address}/`;\n }\n const uri = new URL(path, baseURL);\n this.#lc.info?.(`connecting to change-streamer@${uri}`);\n return uri;\n }\n\n async reserveSnapshot(taskID: string): Promise<Source<SnapshotMessage>> {\n const uri = await this.#resolveChangeStreamer(SNAPSHOT_PATH);\n\n const params = new URLSearchParams({taskID});\n const ws = new WebSocket(uri + `?${params.toString()}`);\n\n return streamIn(this.#lc, ws, snapshotMessageSchema);\n }\n\n async subscribe(ctx: SubscriberContext): Promise<Source<SizedDownstream>> {\n const uri = await this.#resolveChangeStreamer(CHANGES_PATH);\n\n const params = getParams(ctx);\n const ws = new WebSocket(uri + `?${params.toString()}`);\n\n return streamInWithSize(this.#lc, ws, downstreamSchema);\n }\n}\n\ntype RequestHeaders = Pick<IncomingMessage, 'url' | 'headers'>;\n\nexport function getSubscriberContext(req: RequestHeaders): SubscriberContext {\n const url = new URL(req.url ?? '', req.headers.origin ?? 'http://localhost');\n const protocolVersion = checkProtocolVersion(url.pathname);\n const params = new URLParams(url);\n\n return {\n protocolVersion,\n id: params.get('id', true),\n taskID: params.get('taskID', true),\n mode: params.get('mode', false) === 'backup' ? 'backup' : 'serving',\n replicaVersion: params.get('replicaVersion', true),\n watermark: params.get('watermark', true),\n initial: params.getBoolean('initial'),\n // Absent for subscribers that predate the parameter, which is the safe\n // default: the barrier falls back to polling rather than waiting on an\n // ACK that would never be attributed to a writer.\n logsChangeStream: params.getBoolean('logsChangeStream'),\n };\n}\n\nfunction checkProtocolVersion(pathname: string): number {\n const match = PATH_REGEX.exec(pathname);\n if (!match) {\n throw new Error(`invalid path: ${pathname}`);\n }\n const v = Number(match.groups?.version);\n if (\n Number.isNaN(v) ||\n v > PROTOCOL_VERSION ||\n v < MIN_SUPPORTED_PROTOCOL_VERSION\n ) {\n throw new Error(\n `Cannot service client at protocol v${v}. ` +\n `Supported protocols: [v${MIN_SUPPORTED_PROTOCOL_VERSION} ... v${PROTOCOL_VERSION}]`,\n );\n }\n return v;\n}\n\n// This is called from the client-side (i.e. the replicator).\nfunction getParams(ctx: SubscriberContext): URLSearchParams {\n // The protocolVersion is hard-coded into the CHANGES_PATH.\n const {protocolVersion, ...stringParams} = ctx;\n assert(\n protocolVersion === PROTOCOL_VERSION,\n `replicator should be setting protocolVersion to ${PROTOCOL_VERSION}`,\n );\n return new URLSearchParams({\n ...stringParams,\n initial: ctx.initial ? 'true' : 'false',\n logsChangeStream: ctx.logsChangeStream ? 'true' : 'false',\n });\n}\n"],"mappings":";;;;;;;;;;;;;;;;AAkCA,IAAM,iCAAiC;AAEvC,IAAM,wBAAwB;AAC9B,IAAM,uBAAuB;AAC7B,IAAM,aAAa;AAEnB,IAAM,gBAAgB;AACtB,IAAM,eAAe;AAQrB,IAAa,2BAAb,cAA8C,YAAY;CACxD,KAAc;CACd;CACA;CACA;CAEA,YACE,IACA,MACA,QACA,gBACA;EACA,MAAM,+BAA+B,IAAI,MAAM,OAAM,YAAW;GAC9D,MAAM,QAAQ,SAAS,SAAS;GAEhC,QAAQ,IAAI,sBAAsB,EAAC,WAAW,KAAI,GAAG,KAAKG,UAAU;GACpE,QAAQ,IACN,uBACA,EAAC,WAAW,KAAI,GAChB,KAAKC,gBACP;GAEA,QAAQ,IAAI,WAAW,KAAK,QAC1B,mBACE,IACA,KAAK,UAAU,EAAC,eAAe,KAAA,EAAS,GACxC,KACA,KACA,KAAK,kBACL,KAAA,GACA,iBACF,CACF;GAEA,yBACE,IACA,QAAQ,iBACR,KAAKC,mBACL,MACF;EACF,CAAC;EAED,KAAKL,MAAM;EACX,KAAKC,QAAQ;EACb,KAAKC,kBAAkB;CACzB;CAGA,qBACE,IACA,QACA,QACG;EACH,QAAQ,QAAR;GACE,KAAK,YACH,OAAO,KAAKE,iBAAiB,IAAI,GAAG;GACtC,KAAK,WACH,OAAO,KAAKD,WAAW,IAAI,GAAG;GAChC;IACE,eACE,KAAK,KACL,IACA,mBAAmB,OAAO,sBAC5B;IACA;EACJ;CACF;CAEA,mBAA4B,OAAO,IAAe,QAAwB;EACxE,KAAKG,6BAA6B,+BAA+B;EACjE,IAAI;GACF,MAAM,MAAM,IAAI,IACd,IAAI,OAAO,IACX,IAAI,QAAQ,UAAU,kBACxB;GACA,qBAAqB,IAAI,QAAQ;GACjC,MAAM,SAAS,IAAI,aAAa,IAAI,QAAQ;GAC5C,IAAI,CAAC,QACH,MAAM,IAAI,MAAM,oCAAoC;GAEtD,MAAM,aACJ,MAAM,KAAKJ,gBAAgB,yBAAyB,MAAM;GAC5D,UAAe,KAAK,KAAK,YAAY,EAAE;EACzC,SAAS,KAAK;GACZ,eAAe,KAAK,KAAK,IAAI,KAAK,cAAc;EAClD;CACF;CAEA,aAAsB,OAAO,IAAe,QAAwB;EAClE,IAAI;GACF,MAAM,MAAM,qBAAqB,GAAG;GACpC,IAAI,IAAI,SAAS,WACf,KAAKI,6BAA6B,uBAAuB;GAG3D,MAAM,aAAa,MAAM,KAAKJ,gBAAgB,UAAU,GAAG;GAC3D,qBAA0B,KAAK,KAAK,YAAY,EAAE;EACpD,SAAS,KAAK;GACZ,eAAe,KAAK,KAAK,IAAI,KAAK,cAAc;EAClD;CACF;CAEA,yBAAyB;CAEzB,6BAA6B,QAAgB;EAC3C,IAAI,CAAC,KAAKK,0BAA0B,KAAK,OAAO,UAAU,GAAG;GAC3D,KAAKP,IAAI,OAAO,mCAAmC,QAAQ;GAC3D,KAAUE,gBACP,IAAI,EACJ,OAAM,MACL,KAAKF,IAAI,OAAO,0CAA0C,CAAC,CAC7D,EACC,cAAc,KAAK,KAAK,CAAC;GAE5B,KAAKO,yBAAyB;EAChC;CACF;CAEA,WAAoC;EAClC,MAAM,EAAC,gBAAgB,gBAAgB,gBAAe,KAAKN;EAC3D,cAAmB,WAAW;GAC5B,IAAI,iBAAiB,GAAG;IAQtB,KAAKD,IAAI,OACP,WAAW,eAAe,qCAC5B;IACA,KAAK,OAAO,iBAER,KAAKM,6BACH,0BAA0B,eAAe,KAC3C,GACF,cACF;GACF,OAGE,KAAKA,6BAA6B,6BAA6B;EAEnE,CAAC;CACH;CAEA,MAAyB,UAAyB;EAChD,IAAI,KAAKC,wBACP,MAAM,KAAKL,gBAAgB,KAAK;CAEpC;AACF;AAEA,IAAa,2BAAb,MAAgE;CAC9D;CACA;CACA;CACA;CAEA,YACE,IACA,SACA,UACA,mBACA;EACA,KAAKF,MAAM;EACX,KAAKQ,WAAW;EAGhB,KAAKC,YAAY,SAAS,IAAI,UAAU,6BAA6B;GACnE,KAAK;IACJ,iBAAiB;EACpB,CAAC;EACD,KAAKC,qBAAqB;CAC5B;CAEA,MAAMC,uBAAuB,MAAc;EACzC,IAAI,UAAU,KAAKD;EACnB,IAAI,CAAC,SAAS;GACZ,MAAM,UAAU,MAAM,8BACpB,KAAKF,UACL,KAAKC,SACP;GACA,IAAI,CAAC,SACH,MAAM,IAAI,MAAM,+BAA+B;GAEjD,UAAU,QAAQ,SAAS,KAAK,IAAI,GAAG,QAAQ,KAAK,QAAQ,QAAQ;EACtE;EACA,MAAM,MAAM,IAAI,IAAI,MAAM,OAAO;EACjC,KAAKT,IAAI,OAAO,iCAAiC,KAAK;EACtD,OAAO;CACT;CAEA,MAAM,gBAAgB,QAAkD;EAItE,MAAM,KAAK,IAAI,UAAU,MAHP,KAAKW,uBAAuB,aAAa,IAG5B,IAAI,IADhB,gBAAgB,EAAC,OAAM,CACP,EAAO,SAAS,GAAG;EAEtD,OAAO,SAAS,KAAKX,KAAK,IAAI,qBAAqB;CACrD;CAEA,MAAM,UAAU,KAA0D;EAIxE,MAAM,KAAK,IAAI,UAAU,MAHP,KAAKW,uBAAuB,YAAY,IAG3B,IADhB,UAAU,GACU,EAAO,SAAS,GAAG;EAEtD,OAAO,iBAAiB,KAAKX,KAAK,IAAI,gBAAgB;CACxD;AACF;AAIA,SAAgB,qBAAqB,KAAwC;CAC3E,MAAM,MAAM,IAAI,IAAI,IAAI,OAAO,IAAI,IAAI,QAAQ,UAAU,kBAAkB;CAC3E,MAAM,kBAAkB,qBAAqB,IAAI,QAAQ;CACzD,MAAM,SAAS,IAAI,UAAU,GAAG;CAEhC,OAAO;EACL;EACA,IAAI,OAAO,IAAI,MAAM,IAAI;EACzB,QAAQ,OAAO,IAAI,UAAU,IAAI;EACjC,MAAM,OAAO,IAAI,QAAQ,KAAK,MAAM,WAAW,WAAW;EAC1D,gBAAgB,OAAO,IAAI,kBAAkB,IAAI;EACjD,WAAW,OAAO,IAAI,aAAa,IAAI;EACvC,SAAS,OAAO,WAAW,SAAS;EAIpC,kBAAkB,OAAO,WAAW,kBAAkB;CACxD;AACF;AAEA,SAAS,qBAAqB,UAA0B;CACtD,MAAM,QAAQ,WAAW,KAAK,QAAQ;CACtC,IAAI,CAAC,OACH,MAAM,IAAI,MAAM,iBAAiB,UAAU;CAE7C,MAAM,IAAI,OAAO,MAAM,QAAQ,OAAO;CACtC,IACE,OAAO,MAAM,CAAC,KACd,IAAA,KACA,IAAI,gCAEJ,MAAM,IAAI,MACR,sCAAsC,EAAE,2BACZ,+BAA+B,SAC7D;CAEF,OAAO;AACT;AAGA,SAAS,UAAU,KAAyC;CAE1D,MAAM,EAAC,iBAAiB,GAAG,iBAAgB;CAC3C,OACE,oBAAA,GACA,mDACF;CACA,OAAO,IAAI,gBAAgB;EACzB,GAAG;EACH,SAAS,IAAI,UAAU,SAAS;EAChC,kBAAkB,IAAI,mBAAmB,SAAS;CACpD,CAAC;AACH"}
|
|
@@ -9,7 +9,7 @@ import { getOrCreateCounter } from "../observability/metrics.js";
|
|
|
9
9
|
import { ProtocolErrorWithLevel, getLogLevel, wrapWithProtocolError } from "../types/error-with-level.js";
|
|
10
10
|
import { upstreamSchemaWithUnparsedAnalyzeQuery } from "../../../zero-protocol/src/up.js";
|
|
11
11
|
import { PokeChunkEncoder } from "../types/poke-chunk.js";
|
|
12
|
-
import WebSocket
|
|
12
|
+
import WebSocket, { createWebSocketStream } from "ws";
|
|
13
13
|
import { Readable, Writable, pipeline } from "node:stream";
|
|
14
14
|
//#region ../zero-cache/src/workers/connection.ts
|
|
15
15
|
function hasOwn(value, property) {
|
|
@@ -216,7 +216,7 @@ var Connection = class {
|
|
|
216
216
|
}
|
|
217
217
|
};
|
|
218
218
|
function send(lc, ws, data, callback, serialized) {
|
|
219
|
-
if (ws.readyState === WebSocket
|
|
219
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
220
220
|
serialized ??= JSON.stringify(data);
|
|
221
221
|
if (callback === "ignore-backpressure") {
|
|
222
222
|
ws.send(serialized);
|
|
@@ -302,7 +302,7 @@ function sendAsync(lc, ws, data) {
|
|
|
302
302
|
}
|
|
303
303
|
function sendBinary(lc, ws, chunk) {
|
|
304
304
|
return new Promise((resolve, reject) => {
|
|
305
|
-
if (ws.readyState !== WebSocket
|
|
305
|
+
if (ws.readyState !== WebSocket.OPEN) {
|
|
306
306
|
const error = webSocketClosedError();
|
|
307
307
|
lc.debug?.(`Dropping outbound binary message on ws (state: ${ws.readyState})`);
|
|
308
308
|
reject(error);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"connection.js","names":["#ws","#wsID","#protocolVersion","#lc","#onClose","#messageHandler","#allowLegacyQueries","#downstreamSender","#downstreamMsgTimer","#webSocketErrors","#handleClose","#handleError","#proxyInbound","#maybeSendPong","#closeWithError","#closed","#viewSyncerOutboundStream","#pusherOutboundStream","#handleMessage","#handleMessageResult","#closeWithThrown","#proxyOutbound","#recordWebSocketError","#lastDownstreamMsgTime","#pokeChunkEncoder","#sendPokePart","#sendPokeEnd"],"sources":["../../../../../zero-cache/src/workers/connection.ts"],"sourcesContent":["import {pipeline, Readable, Writable} from 'node:stream';\nimport type {LogContext, LogLevel} from '@rocicorp/logger';\nimport type {CloseEvent, Data, ErrorEvent} from 'ws';\nimport WebSocket, {createWebSocketStream} from 'ws';\nimport {assert} from '../../../shared/src/asserts.ts';\nimport * as valita from '../../../shared/src/valita.ts';\nimport type {ConnectedMessage} from '../../../zero-protocol/src/connect.ts';\nimport type {Downstream} from '../../../zero-protocol/src/down.ts';\nimport {ErrorKind} from '../../../zero-protocol/src/error-kind.ts';\nimport {ErrorOrigin} from '../../../zero-protocol/src/error-origin.ts';\nimport type {ErrorBody} from '../../../zero-protocol/src/error.ts';\nimport {\n isProtocolError,\n type ProtocolError,\n} from '../../../zero-protocol/src/error.ts';\nimport {\n POKE_CHUNK_PROTOCOL_VERSION,\n type PokeChunk,\n type PokeEndMessage,\n type PokePartMessage,\n} from '../../../zero-protocol/src/poke.ts';\nimport {\n MIN_SERVER_SUPPORTED_SYNC_PROTOCOL,\n PROTOCOL_VERSION,\n} from '../../../zero-protocol/src/protocol-version.ts';\nimport {\n upstreamSchemaWithUnparsedAnalyzeQuery,\n type UpstreamWithUnparsedAnalyzeQuery,\n} from '../../../zero-protocol/src/up.ts';\nimport {getOrCreateCounter} from '../observability/metrics.ts';\nimport type {ViewSyncerDownstream} from '../types/downstream.ts';\nimport {\n ProtocolErrorWithLevel,\n getLogLevel,\n wrapWithProtocolError,\n} from '../types/error-with-level.ts';\nimport {PokeChunkEncoder} from '../types/poke-chunk.ts';\nimport type {Source} from '../types/streams.ts';\nimport type {ConnectParams} from './connect-params.ts';\n\nexport type HandlerResult =\n | {\n type: 'ok';\n }\n | {\n type: 'fatal';\n error: ErrorBody;\n }\n | {\n type: 'transient';\n errors: ErrorBody[];\n }\n | StreamResult;\n\nexport type StreamResult =\n | {\n type: 'stream';\n source: 'viewSyncer';\n stream: Source<ViewSyncerDownstream>;\n }\n | {\n type: 'stream';\n source: 'pusher';\n stream: Source<Downstream>;\n };\n\nexport interface MessageHandler {\n handleMessage(\n msg: UpstreamWithUnparsedAnalyzeQuery,\n ): Promise<HandlerResult[]>;\n}\n\nfunction hasOwn(value: unknown, property: string): boolean {\n return (\n typeof value === 'object' &&\n value !== null &&\n Object.hasOwn(value, property)\n );\n}\n\nfunction containsLegacyQuery(message: unknown): boolean {\n if (!Array.isArray(message)) {\n return false;\n }\n\n const body = message[1];\n if (typeof body !== 'object' || body === null) {\n return false;\n }\n\n if (\n message[0] !== 'initConnection' &&\n message[0] !== 'changeDesiredQueries'\n ) {\n return false;\n }\n\n const patch = body['desiredQueriesPatch'];\n return (\n Array.isArray(patch) && patch.some(operation => hasOwn(operation, 'ast'))\n );\n}\n\nconst LEGACY_QUERIES_DISABLED_MESSAGE =\n 'Legacy queries are disabled by this Zero server. This client must use custom queries instead of sending query ASTs directly. To temporarily restore compatibility, set ZERO_ALLOW_LEGACY_QUERIES=true on zero-cache.';\n\n// Exported for testing purposes.\nexport function parseUpstreamMessage(\n value: unknown,\n allowLegacyQueries: boolean,\n): UpstreamWithUnparsedAnalyzeQuery {\n if (!allowLegacyQueries && containsLegacyQuery(value)) {\n throw new Error(LEGACY_QUERIES_DISABLED_MESSAGE);\n }\n return valita.parse(value, upstreamSchemaWithUnparsedAnalyzeQuery);\n}\n\n// Ensures that a downstream message is sent at least every interval, sending a\n// 'pong' if necessary. This is set to be slightly longer than the client-side\n// PING_INTERVAL of 5 seconds, so that in the common case, 'pong's are sent in\n// response to client-initiated 'ping's. However, if the inbound stream is\n// backed up because a command is taking a long time to process, the pings\n// will be stuck in the queue (i.e. back-pressured), in which case pongs will\n// be manually sent to notify the client of server liveness.\n//\n// This is equivalent to what is done for Postgres keepalives on the\n// replication stream (which can similarly be back-pressured):\n// https://github.com/rocicorp/mono/blob/f98cb369a2dbb15650328859c732db358f187ef0/packages/zero-cache/src/services/change-source/pg/logical-replication/stream.ts#L21\nconst DOWNSTREAM_MSG_INTERVAL_MS = 6_000;\nexport const WEBSOCKET_SEND_TIMEOUT_MS = 10_000;\nconst PROTOCOL_VERSION_ATTRIBUTE = 'protocol.version';\nconst EVENT_TYPE_ATTRIBUTE = 'event.type';\n\n/**\n * Represents a connection between the client and server.\n *\n * Handles incoming messages on the connection and dispatches\n * them to the correct service.\n *\n * Listens to the ViewSyncer and sends messages to the client.\n */\nexport class Connection {\n readonly #ws: WebSocket;\n readonly #wsID: string;\n readonly #protocolVersion: number;\n readonly #lc: LogContext;\n readonly #onClose: () => void;\n readonly #messageHandler: MessageHandler;\n readonly #allowLegacyQueries: boolean;\n readonly #downstreamSender: DownstreamSender;\n readonly #downstreamMsgTimer: NodeJS.Timeout | undefined;\n readonly #webSocketErrors = getOrCreateCounter(\n 'sync',\n 'websocket.errors',\n 'Client WebSocket error events.',\n );\n\n #viewSyncerOutboundStream: Source<ViewSyncerDownstream> | undefined;\n #pusherOutboundStream: Source<Downstream> | undefined;\n #closed = false;\n\n constructor(\n lc: LogContext,\n connectParams: ConnectParams,\n ws: WebSocket,\n allowLegacyQueries: boolean,\n messageHandler: MessageHandler,\n onClose: () => void,\n ) {\n const {clientGroupID, clientID, wsID, protocolVersion} = connectParams;\n this.#messageHandler = messageHandler;\n this.#allowLegacyQueries = allowLegacyQueries;\n\n this.#ws = ws;\n this.#wsID = wsID;\n this.#protocolVersion = protocolVersion;\n\n this.#lc = lc\n .withContext('connection')\n .withContext('clientID', clientID)\n .withContext('clientGroupID', clientGroupID)\n .withContext('wsID', wsID);\n this.#downstreamSender = new DownstreamSender(\n this.#lc,\n ws,\n protocolVersion,\n );\n this.#lc.debug?.('new connection');\n this.#onClose = onClose;\n\n this.#ws.addEventListener('close', this.#handleClose);\n this.#ws.addEventListener('error', this.#handleError);\n\n this.#proxyInbound();\n this.#downstreamMsgTimer = setInterval(\n this.#maybeSendPong,\n DOWNSTREAM_MSG_INTERVAL_MS / 2,\n );\n }\n\n /**\n * Checks the protocol version and errors for unsupported protocols,\n * sending the initial `connected` response on success.\n *\n * This is early in the connection lifecycle because {@link #handleMessage}\n * will only parse messages with schema(s) of supported protocol versions.\n */\n init(): boolean {\n if (\n this.#protocolVersion > PROTOCOL_VERSION ||\n this.#protocolVersion < MIN_SERVER_SUPPORTED_SYNC_PROTOCOL\n ) {\n this.#closeWithError({\n kind: ErrorKind.VersionNotSupported,\n message: `server is at sync protocol v${PROTOCOL_VERSION} and does not support v${\n this.#protocolVersion\n }. The ${\n this.#protocolVersion > PROTOCOL_VERSION ? 'server' : 'client'\n } must be updated to a newer release.`,\n origin: ErrorOrigin.ZeroCache,\n });\n } else {\n const connectedMessage: ConnectedMessage = [\n 'connected',\n {wsid: this.#wsID, timestamp: Date.now()},\n ];\n this.send(connectedMessage, 'ignore-backpressure');\n return true;\n }\n return false;\n }\n\n close(reason: string, ...args: unknown[]) {\n if (this.#closed) {\n return;\n }\n this.#closed = true;\n this.#lc.info?.(`closing connection: ${reason}`, ...args);\n this.#ws.removeEventListener('close', this.#handleClose);\n this.#ws.removeEventListener('error', this.#handleError);\n this.#viewSyncerOutboundStream?.cancel();\n this.#viewSyncerOutboundStream = undefined;\n this.#pusherOutboundStream?.cancel();\n this.#pusherOutboundStream = undefined;\n this.#onClose();\n if (this.#ws.readyState !== this.#ws.CLOSED) {\n this.#ws.close();\n }\n clearTimeout(this.#downstreamMsgTimer);\n\n // spin down services if we have\n // no more client connections for the client group?\n }\n\n handleInitConnection(initConnectionMsg: string) {\n return this.#handleMessage({data: initConnectionMsg});\n }\n\n #handleMessage = async (event: {data: Data}) => {\n const data = event.data.toString();\n if (this.#closed) {\n this.#lc.debug?.('Ignoring message received after closed', data);\n return;\n }\n\n let msg;\n try {\n const value = JSON.parse(data);\n msg = parseUpstreamMessage(value, this.#allowLegacyQueries);\n } catch (e) {\n const errorBody = {\n kind: ErrorKind.InvalidMessage,\n message: String(e),\n origin: ErrorOrigin.ZeroCache,\n } as const;\n this.#closeWithError(\n errorBody,\n new ProtocolErrorWithLevel(errorBody, 'warn'),\n );\n return;\n }\n\n try {\n const msgType = msg[0];\n if (msgType === 'ping') {\n this.send(['pong', {}], 'ignore-backpressure');\n return;\n }\n\n const result = await this.#messageHandler.handleMessage(msg);\n for (const r of result) {\n this.#handleMessageResult(r);\n }\n } catch (e) {\n this.#closeWithThrown(e);\n }\n };\n\n #handleMessageResult(result: HandlerResult): void {\n switch (result.type) {\n case 'fatal':\n this.#closeWithError(result.error);\n break;\n case 'ok':\n break;\n case 'stream': {\n switch (result.source) {\n case 'viewSyncer':\n assert(\n this.#viewSyncerOutboundStream === undefined,\n 'Outbound stream already set for this connection!',\n );\n this.#viewSyncerOutboundStream = result.stream;\n break;\n case 'pusher':\n assert(\n this.#pusherOutboundStream === undefined,\n 'Outbound stream already set for this connection!',\n );\n this.#pusherOutboundStream = result.stream;\n break;\n }\n if (result.source === 'viewSyncer') {\n this.#proxyOutbound(result.stream, (downstream, callback) =>\n this.send(downstream.message, callback, downstream.serialized),\n );\n } else {\n this.#proxyOutbound(result.stream, (downstream, callback) =>\n this.send(downstream, callback),\n );\n }\n break;\n }\n case 'transient': {\n for (const error of result.errors) {\n this.sendError(error);\n }\n }\n }\n }\n\n #handleClose = (e: CloseEvent) => {\n const {code, reason, wasClean} = e;\n if (!wasClean) {\n this.#recordWebSocketError('unclean_close');\n }\n this.close('WebSocket close event', {code, reason, wasClean});\n };\n\n #handleError = (e: ErrorEvent) => {\n this.#recordWebSocketError('error_event');\n this.#lc.warn?.('WebSocket error event', e.message, e.error);\n };\n\n #recordWebSocketError(eventType: string) {\n this.#webSocketErrors.add(1, {\n [PROTOCOL_VERSION_ATTRIBUTE]: this.#protocolVersion,\n [EVENT_TYPE_ATTRIBUTE]: eventType,\n });\n }\n\n #proxyInbound() {\n pipeline(\n createWebSocketStream(this.#ws),\n new Writable({\n write: (data, _encoding, callback) => {\n this.#handleMessage({data}).then(() => callback(), callback);\n },\n }),\n // The done callback is not used, as #handleClose and #handleError,\n // configured on the underlying WebSocket, provide more complete\n // information.\n () => {},\n );\n }\n\n #proxyOutbound<T>(\n outboundStream: Source<T>,\n sendMessage: (\n downstream: T,\n callback: (err?: Error | null) => void,\n ) => void,\n ) {\n // Note: createWebSocketStream() is avoided here in order to control\n // exception handling with #closeWithThrown(). If the Writable\n // from createWebSocketStream() were instead used, exceptions\n // from the outboundStream result in the Writable closing the\n // the websocket before the error message can be sent.\n pipeline(\n Readable.from(outboundStream),\n new Writable({\n objectMode: true,\n write: (downstream: T, _encoding, callback) =>\n sendMessage(downstream, callback),\n }),\n e =>\n e\n ? this.#closeWithThrown(e)\n : this.close(`downstream closed by ViewSyncer`),\n );\n }\n\n #closeWithThrown(e: unknown) {\n const errorBody =\n findProtocolError(e)?.errorBody ?? wrapWithProtocolError(e).errorBody;\n\n this.#closeWithError(errorBody, e);\n }\n\n #closeWithError(errorBody: ErrorBody, thrown?: unknown) {\n this.sendError(errorBody, thrown);\n this.close(\n `${errorBody.kind} (${errorBody.origin}): ${errorBody.message}`,\n errorBody,\n );\n }\n\n #lastDownstreamMsgTime = Date.now();\n\n #maybeSendPong = () => {\n if (Date.now() - this.#lastDownstreamMsgTime > DOWNSTREAM_MSG_INTERVAL_MS) {\n this.#lc.debug?.('manually sending pong');\n this.send(['pong', {}], 'ignore-backpressure');\n }\n };\n\n send(\n data: Downstream,\n callback: ((err?: Error | null) => void) | 'ignore-backpressure',\n serialized?: string | undefined,\n ) {\n this.#lastDownstreamMsgTime = Date.now();\n this.#downstreamSender.send(data, callback, serialized);\n }\n\n sendError(errorBody: ErrorBody, thrown?: unknown) {\n sendError(this.#lc, this.#ws, errorBody, thrown);\n }\n}\n\nexport type WebSocketLike = Pick<WebSocket, 'readyState'> & {\n send(data: string | PokeChunk, cb?: (err?: Error) => void): void;\n};\n\n// Exported for testing purposes.\nexport function send(\n lc: LogContext,\n ws: WebSocketLike,\n data: Downstream,\n callback: ((err?: Error | null) => void) | 'ignore-backpressure',\n serialized?: string | undefined,\n) {\n if (ws.readyState === WebSocket.OPEN) {\n serialized ??= JSON.stringify(data);\n if (callback === 'ignore-backpressure') {\n ws.send(serialized);\n return;\n }\n\n let completed = false;\n const timer = setTimeout(() => {\n complete(webSocketSendTimeoutError());\n }, WEBSOCKET_SEND_TIMEOUT_MS);\n timer.unref();\n\n const complete = (error?: Error | null) => {\n if (completed) {\n return;\n }\n completed = true;\n clearTimeout(timer);\n callback(error);\n };\n\n ws.send(serialized, complete);\n } else {\n lc.debug?.(`Dropping outbound message on ws (state: ${ws.readyState})`, {\n dropped: data,\n });\n if (callback !== 'ignore-backpressure') {\n callback(\n new ProtocolErrorWithLevel(\n {\n kind: ErrorKind.Internal,\n message: 'WebSocket closed',\n origin: ErrorOrigin.ZeroCache,\n },\n 'info',\n ),\n );\n }\n }\n}\n\n/**\n * Sends downstream messages in the format supported by a connection's sync\n * protocol version. Keep version forks contained here so the view-syncer can\n * continue producing one canonical poke representation.\n *\n * Exported for compatibility testing.\n */\nexport class DownstreamSender {\n readonly #lc: LogContext;\n readonly #ws: WebSocketLike;\n readonly #protocolVersion: number;\n #pokeChunkEncoder: PokeChunkEncoder | undefined;\n\n constructor(lc: LogContext, ws: WebSocketLike, protocolVersion: number) {\n this.#lc = lc;\n this.#ws = ws;\n this.#protocolVersion = protocolVersion;\n }\n\n send(\n data: Downstream,\n callback: ((err?: Error | null) => void) | 'ignore-backpressure',\n serialized?: string | undefined,\n ): void {\n if (this.#protocolVersion >= POKE_CHUNK_PROTOCOL_VERSION) {\n switch (data[0]) {\n case 'pokeStart':\n assert(\n this.#pokeChunkEncoder === undefined,\n 'cannot start a poke while another poke is in progress',\n );\n this.#pokeChunkEncoder = new PokeChunkEncoder();\n break;\n case 'pokePart':\n this.#sendPokePart(data, callback, serialized);\n return;\n case 'pokeEnd':\n this.#sendPokeEnd(data, callback);\n return;\n }\n }\n send(this.#lc, this.#ws, data, callback, serialized);\n }\n\n #sendPokePart(\n pokePart: PokePartMessage,\n callback: ((err?: Error | null) => void) | 'ignore-backpressure',\n serialized?: string | undefined,\n ): void {\n const encoder = this.#pokeChunkEncoder;\n assert(encoder, 'pokePart received without a pokeStart');\n\n serialized ??= JSON.stringify(pokePart);\n assert(\n serialized.startsWith(POKE_PART_PREFIX) && serialized.endsWith(']'),\n 'invalid serialized pokePart',\n );\n void encoder\n .addPatch(serialized.slice(POKE_PART_PREFIX.length, -1), chunk =>\n sendBinary(this.#lc, this.#ws, chunk),\n )\n .then(\n () => invokeCallback(callback),\n error => invokeCallback(callback, toError(error)),\n );\n }\n\n #sendPokeEnd(\n pokeEnd: PokeEndMessage,\n callback: ((err?: Error | null) => void) | 'ignore-backpressure',\n ): void {\n const encoder = this.#pokeChunkEncoder;\n assert(encoder, 'pokeEnd received without a pokeStart');\n this.#pokeChunkEncoder = undefined;\n\n if (pokeEnd[1].cancel) {\n encoder.cancel();\n send(this.#lc, this.#ws, pokeEnd, callback);\n return;\n }\n\n void encoder\n .finish(chunk => sendBinary(this.#lc, this.#ws, chunk))\n .then(() => sendAsync(this.#lc, this.#ws, pokeEnd))\n .then(\n () => invokeCallback(callback),\n error => invokeCallback(callback, toError(error)),\n );\n }\n}\n\nconst POKE_PART_PREFIX = '[\"pokePart\",';\n\nfunction sendAsync(\n lc: LogContext,\n ws: WebSocketLike,\n data: Downstream,\n): Promise<void> {\n return new Promise((resolve, reject) => {\n send(lc, ws, data, error => (error ? reject(error) : resolve()));\n });\n}\n\nfunction sendBinary(\n lc: LogContext,\n ws: WebSocketLike,\n chunk: PokeChunk,\n): Promise<void> {\n return new Promise((resolve, reject) => {\n if (ws.readyState !== WebSocket.OPEN) {\n const error = webSocketClosedError();\n lc.debug?.(\n `Dropping outbound binary message on ws (state: ${ws.readyState})`,\n );\n reject(error);\n return;\n }\n let completed = false;\n const timer = setTimeout(\n () => complete(webSocketSendTimeoutError()),\n WEBSOCKET_SEND_TIMEOUT_MS,\n );\n timer.unref();\n\n const complete = (error?: Error | null) => {\n if (completed) {\n return;\n }\n completed = true;\n clearTimeout(timer);\n error ? reject(error) : resolve();\n };\n\n ws.send(chunk, complete);\n });\n}\n\nfunction invokeCallback(\n callback: ((err?: Error | null) => void) | 'ignore-backpressure',\n error?: Error,\n): void {\n if (callback !== 'ignore-backpressure') {\n callback(error);\n }\n}\n\nfunction toError(error: unknown): Error {\n return error instanceof Error ? error : new Error(String(error));\n}\n\nfunction webSocketClosedError(): ProtocolErrorWithLevel {\n return new ProtocolErrorWithLevel(\n {\n kind: ErrorKind.Internal,\n message: 'WebSocket closed',\n origin: ErrorOrigin.ZeroCache,\n },\n 'info',\n );\n}\n\nfunction webSocketSendTimeoutError(): ProtocolErrorWithLevel {\n return new ProtocolErrorWithLevel(\n {\n kind: ErrorKind.Internal,\n message: `WebSocket send timed out after ${WEBSOCKET_SEND_TIMEOUT_MS} ms`,\n origin: ErrorOrigin.ZeroCache,\n },\n 'info',\n );\n}\n\nexport function sendError(\n lc: LogContext,\n ws: WebSocket,\n errorBody: ErrorBody,\n thrown?: unknown,\n) {\n lc = lc.withContext('errorKind', errorBody.kind);\n\n let logLevel: LogLevel;\n\n // If the thrown error is a ProtocolErrorWithLevel, its explicit logLevel takes precedence\n if (thrown instanceof ProtocolErrorWithLevel) {\n logLevel = thrown.logLevel;\n }\n // Errors with errno or transient socket codes are low-level, transient I/O issues\n // (e.g., EPIPE, ECONNRESET) and should be warnings, not errors\n else if (\n hasErrno(thrown) ||\n hasTransientSocketCode(thrown) ||\n isTransientSocketMessage(errorBody.message)\n ) {\n logLevel = 'warn';\n }\n // Fallback: check errorBody.kind for errors that weren't thrown as ProtocolErrorWithLevel\n else if (\n errorBody.kind === ErrorKind.ClientNotFound ||\n errorBody.kind === ErrorKind.TransformFailed\n ) {\n logLevel = 'warn';\n } else {\n logLevel = thrown ? getLogLevel(thrown) : 'info';\n }\n\n lc[logLevel]?.('Sending error on WebSocket', errorBody, thrown ?? '');\n send(lc, ws, ['error', errorBody], 'ignore-backpressure');\n}\n\nexport function findProtocolError(error: unknown): ProtocolError | undefined {\n if (isProtocolError(error)) {\n return error;\n }\n if (error instanceof Error && error.cause) {\n return findProtocolError(error.cause);\n }\n return undefined;\n}\n\nfunction hasErrno(error: unknown): boolean {\n return Boolean(\n error &&\n typeof error === 'object' &&\n 'errno' in error &&\n typeof (error as {errno: unknown}).errno !== 'undefined',\n );\n}\n\n// System error codes that indicate transient socket conditions.\n// These are checked via the `code` property on errors.\nconst TRANSIENT_SOCKET_ERROR_CODES = new Set([\n 'EPIPE',\n 'ECONNRESET',\n 'ECANCELED',\n]);\n\n// Error messages that indicate transient socket conditions but don't have\n// standard error codes (e.g., WebSocket library errors).\nconst TRANSIENT_SOCKET_MESSAGE_PATTERNS = [\n 'socket was closed while data was being compressed',\n];\n\nfunction hasTransientSocketCode(error: unknown): boolean {\n if (!error || typeof error !== 'object') {\n return false;\n }\n const maybeCode =\n 'code' in error ? String((error as {code?: unknown}).code) : undefined;\n return Boolean(\n maybeCode && TRANSIENT_SOCKET_ERROR_CODES.has(maybeCode.toUpperCase()),\n );\n}\n\nfunction isTransientSocketMessage(message: string | undefined): boolean {\n if (!message) {\n return false;\n }\n const lower = message.toLowerCase();\n return TRANSIENT_SOCKET_MESSAGE_PATTERNS.some(pattern =>\n lower.includes(pattern),\n );\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"connection.js","names":["#ws","#wsID","#protocolVersion","#lc","#onClose","#messageHandler","#allowLegacyQueries","#downstreamSender","#downstreamMsgTimer","#webSocketErrors","#handleClose","#handleError","#proxyInbound","#maybeSendPong","#closeWithError","#closed","#viewSyncerOutboundStream","#pusherOutboundStream","#handleMessage","#handleMessageResult","#closeWithThrown","#proxyOutbound","#recordWebSocketError","#lastDownstreamMsgTime","#pokeChunkEncoder","#sendPokePart","#sendPokeEnd"],"sources":["../../../../../zero-cache/src/workers/connection.ts"],"sourcesContent":["import {pipeline, Readable, Writable} from 'node:stream';\nimport type {LogContext, LogLevel} from '@rocicorp/logger';\nimport type {CloseEvent, Data, ErrorEvent} from 'ws';\nimport WebSocket, {createWebSocketStream} from 'ws';\nimport {assert} from '../../../shared/src/asserts.ts';\nimport * as valita from '../../../shared/src/valita.ts';\nimport type {ConnectedMessage} from '../../../zero-protocol/src/connect.ts';\nimport type {Downstream} from '../../../zero-protocol/src/down.ts';\nimport {ErrorKind} from '../../../zero-protocol/src/error-kind.ts';\nimport {ErrorOrigin} from '../../../zero-protocol/src/error-origin.ts';\nimport type {ErrorBody} from '../../../zero-protocol/src/error.ts';\nimport {\n isProtocolError,\n type ProtocolError,\n} from '../../../zero-protocol/src/error.ts';\nimport {\n POKE_CHUNK_PROTOCOL_VERSION,\n type PokeChunk,\n type PokeEndMessage,\n type PokePartMessage,\n} from '../../../zero-protocol/src/poke.ts';\nimport {\n MIN_SERVER_SUPPORTED_SYNC_PROTOCOL,\n PROTOCOL_VERSION,\n} from '../../../zero-protocol/src/protocol-version.ts';\nimport {\n upstreamSchemaWithUnparsedAnalyzeQuery,\n type UpstreamWithUnparsedAnalyzeQuery,\n} from '../../../zero-protocol/src/up.ts';\nimport {getOrCreateCounter} from '../observability/metrics.ts';\nimport type {ViewSyncerDownstream} from '../types/downstream.ts';\nimport {\n ProtocolErrorWithLevel,\n getLogLevel,\n wrapWithProtocolError,\n} from '../types/error-with-level.ts';\nimport {PokeChunkEncoder} from '../types/poke-chunk.ts';\nimport type {Source} from '../types/streams.ts';\nimport type {ConnectParams} from './connect-params.ts';\n\nexport type HandlerResult =\n | {\n type: 'ok';\n }\n | {\n type: 'fatal';\n error: ErrorBody;\n }\n | {\n type: 'transient';\n errors: ErrorBody[];\n }\n | StreamResult;\n\nexport type StreamResult =\n | {\n type: 'stream';\n source: 'viewSyncer';\n stream: Source<ViewSyncerDownstream>;\n }\n | {\n type: 'stream';\n source: 'pusher';\n stream: Source<Downstream>;\n };\n\nexport interface MessageHandler {\n handleMessage(\n msg: UpstreamWithUnparsedAnalyzeQuery,\n ): Promise<HandlerResult[]>;\n}\n\nfunction hasOwn(value: unknown, property: string): boolean {\n return (\n typeof value === 'object' &&\n value !== null &&\n Object.hasOwn(value, property)\n );\n}\n\nfunction containsLegacyQuery(message: unknown): boolean {\n if (!Array.isArray(message)) {\n return false;\n }\n\n const body = message[1];\n if (typeof body !== 'object' || body === null) {\n return false;\n }\n\n if (\n message[0] !== 'initConnection' &&\n message[0] !== 'changeDesiredQueries'\n ) {\n return false;\n }\n\n const patch = body['desiredQueriesPatch'];\n return (\n Array.isArray(patch) && patch.some(operation => hasOwn(operation, 'ast'))\n );\n}\n\nconst LEGACY_QUERIES_DISABLED_MESSAGE =\n 'Legacy queries are disabled by this Zero server. This client must use custom queries instead of sending query ASTs directly. To temporarily restore compatibility, set ZERO_ALLOW_LEGACY_QUERIES=true on zero-cache.';\n\n// Exported for testing purposes.\nexport function parseUpstreamMessage(\n value: unknown,\n allowLegacyQueries: boolean,\n): UpstreamWithUnparsedAnalyzeQuery {\n if (!allowLegacyQueries && containsLegacyQuery(value)) {\n throw new Error(LEGACY_QUERIES_DISABLED_MESSAGE);\n }\n return valita.parse(value, upstreamSchemaWithUnparsedAnalyzeQuery);\n}\n\n// Ensures that a downstream message is sent at least every interval, sending a\n// 'pong' if necessary. This is set to be slightly longer than the client-side\n// PING_INTERVAL of 5 seconds, so that in the common case, 'pong's are sent in\n// response to client-initiated 'ping's. However, if the inbound stream is\n// backed up because a command is taking a long time to process, the pings\n// will be stuck in the queue (i.e. back-pressured), in which case pongs will\n// be manually sent to notify the client of server liveness.\n//\n// This is equivalent to what is done for Postgres keepalives on the\n// replication stream (which can similarly be back-pressured):\n// https://github.com/rocicorp/mono/blob/f98cb369a2dbb15650328859c732db358f187ef0/packages/zero-cache/src/services/change-source/pg/logical-replication/stream.ts#L21\nconst DOWNSTREAM_MSG_INTERVAL_MS = 6_000;\nexport const WEBSOCKET_SEND_TIMEOUT_MS = 10_000;\nconst PROTOCOL_VERSION_ATTRIBUTE = 'protocol.version';\nconst EVENT_TYPE_ATTRIBUTE = 'event.type';\n\n/**\n * Represents a connection between the client and server.\n *\n * Handles incoming messages on the connection and dispatches\n * them to the correct service.\n *\n * Listens to the ViewSyncer and sends messages to the client.\n */\nexport class Connection {\n readonly #ws: WebSocket;\n readonly #wsID: string;\n readonly #protocolVersion: number;\n readonly #lc: LogContext;\n readonly #onClose: () => void;\n readonly #messageHandler: MessageHandler;\n readonly #allowLegacyQueries: boolean;\n readonly #downstreamSender: DownstreamSender;\n readonly #downstreamMsgTimer: NodeJS.Timeout | undefined;\n readonly #webSocketErrors = getOrCreateCounter(\n 'sync',\n 'websocket.errors',\n 'Client WebSocket error events.',\n );\n\n #viewSyncerOutboundStream: Source<ViewSyncerDownstream> | undefined;\n #pusherOutboundStream: Source<Downstream> | undefined;\n #closed = false;\n\n constructor(\n lc: LogContext,\n connectParams: ConnectParams,\n ws: WebSocket,\n allowLegacyQueries: boolean,\n messageHandler: MessageHandler,\n onClose: () => void,\n ) {\n const {clientGroupID, clientID, wsID, protocolVersion} = connectParams;\n this.#messageHandler = messageHandler;\n this.#allowLegacyQueries = allowLegacyQueries;\n\n this.#ws = ws;\n this.#wsID = wsID;\n this.#protocolVersion = protocolVersion;\n\n this.#lc = lc\n .withContext('connection')\n .withContext('clientID', clientID)\n .withContext('clientGroupID', clientGroupID)\n .withContext('wsID', wsID);\n this.#downstreamSender = new DownstreamSender(\n this.#lc,\n ws,\n protocolVersion,\n );\n this.#lc.debug?.('new connection');\n this.#onClose = onClose;\n\n this.#ws.addEventListener('close', this.#handleClose);\n this.#ws.addEventListener('error', this.#handleError);\n\n this.#proxyInbound();\n this.#downstreamMsgTimer = setInterval(\n this.#maybeSendPong,\n DOWNSTREAM_MSG_INTERVAL_MS / 2,\n );\n }\n\n /**\n * Checks the protocol version and errors for unsupported protocols,\n * sending the initial `connected` response on success.\n *\n * This is early in the connection lifecycle because {@link #handleMessage}\n * will only parse messages with schema(s) of supported protocol versions.\n */\n init(): boolean {\n if (\n this.#protocolVersion > PROTOCOL_VERSION ||\n this.#protocolVersion < MIN_SERVER_SUPPORTED_SYNC_PROTOCOL\n ) {\n this.#closeWithError({\n kind: ErrorKind.VersionNotSupported,\n message: `server is at sync protocol v${PROTOCOL_VERSION} and does not support v${\n this.#protocolVersion\n }. The ${\n this.#protocolVersion > PROTOCOL_VERSION ? 'server' : 'client'\n } must be updated to a newer release.`,\n origin: ErrorOrigin.ZeroCache,\n });\n } else {\n const connectedMessage: ConnectedMessage = [\n 'connected',\n {wsid: this.#wsID, timestamp: Date.now()},\n ];\n this.send(connectedMessage, 'ignore-backpressure');\n return true;\n }\n return false;\n }\n\n close(reason: string, ...args: unknown[]) {\n if (this.#closed) {\n return;\n }\n this.#closed = true;\n this.#lc.info?.(`closing connection: ${reason}`, ...args);\n this.#ws.removeEventListener('close', this.#handleClose);\n this.#ws.removeEventListener('error', this.#handleError);\n this.#viewSyncerOutboundStream?.cancel();\n this.#viewSyncerOutboundStream = undefined;\n this.#pusherOutboundStream?.cancel();\n this.#pusherOutboundStream = undefined;\n this.#onClose();\n if (this.#ws.readyState !== this.#ws.CLOSED) {\n this.#ws.close();\n }\n clearTimeout(this.#downstreamMsgTimer);\n\n // spin down services if we have\n // no more client connections for the client group?\n }\n\n handleInitConnection(initConnectionMsg: string) {\n return this.#handleMessage({data: initConnectionMsg});\n }\n\n #handleMessage = async (event: {data: Data}) => {\n const data = event.data.toString();\n if (this.#closed) {\n this.#lc.debug?.('Ignoring message received after closed', data);\n return;\n }\n\n let msg;\n try {\n const value = JSON.parse(data);\n msg = parseUpstreamMessage(value, this.#allowLegacyQueries);\n } catch (e) {\n const errorBody = {\n kind: ErrorKind.InvalidMessage,\n message: String(e),\n origin: ErrorOrigin.ZeroCache,\n } as const;\n this.#closeWithError(\n errorBody,\n new ProtocolErrorWithLevel(errorBody, 'warn'),\n );\n return;\n }\n\n try {\n const msgType = msg[0];\n if (msgType === 'ping') {\n this.send(['pong', {}], 'ignore-backpressure');\n return;\n }\n\n const result = await this.#messageHandler.handleMessage(msg);\n for (const r of result) {\n this.#handleMessageResult(r);\n }\n } catch (e) {\n this.#closeWithThrown(e);\n }\n };\n\n #handleMessageResult(result: HandlerResult): void {\n switch (result.type) {\n case 'fatal':\n this.#closeWithError(result.error);\n break;\n case 'ok':\n break;\n case 'stream': {\n switch (result.source) {\n case 'viewSyncer':\n assert(\n this.#viewSyncerOutboundStream === undefined,\n 'Outbound stream already set for this connection!',\n );\n this.#viewSyncerOutboundStream = result.stream;\n break;\n case 'pusher':\n assert(\n this.#pusherOutboundStream === undefined,\n 'Outbound stream already set for this connection!',\n );\n this.#pusherOutboundStream = result.stream;\n break;\n }\n if (result.source === 'viewSyncer') {\n this.#proxyOutbound(result.stream, (downstream, callback) =>\n this.send(downstream.message, callback, downstream.serialized),\n );\n } else {\n this.#proxyOutbound(result.stream, (downstream, callback) =>\n this.send(downstream, callback),\n );\n }\n break;\n }\n case 'transient': {\n for (const error of result.errors) {\n this.sendError(error);\n }\n }\n }\n }\n\n #handleClose = (e: CloseEvent) => {\n const {code, reason, wasClean} = e;\n if (!wasClean) {\n this.#recordWebSocketError('unclean_close');\n }\n this.close('WebSocket close event', {code, reason, wasClean});\n };\n\n #handleError = (e: ErrorEvent) => {\n this.#recordWebSocketError('error_event');\n this.#lc.warn?.('WebSocket error event', e.message, e.error);\n };\n\n #recordWebSocketError(eventType: string) {\n this.#webSocketErrors.add(1, {\n [PROTOCOL_VERSION_ATTRIBUTE]: this.#protocolVersion,\n [EVENT_TYPE_ATTRIBUTE]: eventType,\n });\n }\n\n #proxyInbound() {\n pipeline(\n createWebSocketStream(this.#ws),\n new Writable({\n write: (data, _encoding, callback) => {\n this.#handleMessage({data}).then(() => callback(), callback);\n },\n }),\n // The done callback is not used, as #handleClose and #handleError,\n // configured on the underlying WebSocket, provide more complete\n // information.\n () => {},\n );\n }\n\n #proxyOutbound<T>(\n outboundStream: Source<T>,\n sendMessage: (\n downstream: T,\n callback: (err?: Error | null) => void,\n ) => void,\n ) {\n // Note: createWebSocketStream() is avoided here in order to control\n // exception handling with #closeWithThrown(). If the Writable\n // from createWebSocketStream() were instead used, exceptions\n // from the outboundStream result in the Writable closing the\n // the websocket before the error message can be sent.\n pipeline(\n Readable.from(outboundStream),\n new Writable({\n objectMode: true,\n write: (downstream: T, _encoding, callback) =>\n sendMessage(downstream, callback),\n }),\n e =>\n e\n ? this.#closeWithThrown(e)\n : this.close(`downstream closed by ViewSyncer`),\n );\n }\n\n #closeWithThrown(e: unknown) {\n const errorBody =\n findProtocolError(e)?.errorBody ?? wrapWithProtocolError(e).errorBody;\n\n this.#closeWithError(errorBody, e);\n }\n\n #closeWithError(errorBody: ErrorBody, thrown?: unknown) {\n this.sendError(errorBody, thrown);\n this.close(\n `${errorBody.kind} (${errorBody.origin}): ${errorBody.message}`,\n errorBody,\n );\n }\n\n #lastDownstreamMsgTime = Date.now();\n\n #maybeSendPong = () => {\n if (Date.now() - this.#lastDownstreamMsgTime > DOWNSTREAM_MSG_INTERVAL_MS) {\n this.#lc.debug?.('manually sending pong');\n this.send(['pong', {}], 'ignore-backpressure');\n }\n };\n\n send(\n data: Downstream,\n callback: ((err?: Error | null) => void) | 'ignore-backpressure',\n serialized?: string | undefined,\n ) {\n this.#lastDownstreamMsgTime = Date.now();\n this.#downstreamSender.send(data, callback, serialized);\n }\n\n sendError(errorBody: ErrorBody, thrown?: unknown) {\n sendError(this.#lc, this.#ws, errorBody, thrown);\n }\n}\n\nexport type WebSocketLike = Pick<WebSocket, 'readyState'> & {\n send(data: string | PokeChunk, cb?: (err?: Error) => void): void;\n};\n\n// Exported for testing purposes.\nexport function send(\n lc: LogContext,\n ws: WebSocketLike,\n data: Downstream,\n callback: ((err?: Error | null) => void) | 'ignore-backpressure',\n serialized?: string | undefined,\n) {\n if (ws.readyState === WebSocket.OPEN) {\n serialized ??= JSON.stringify(data);\n if (callback === 'ignore-backpressure') {\n ws.send(serialized);\n return;\n }\n\n let completed = false;\n const timer = setTimeout(() => {\n complete(webSocketSendTimeoutError());\n }, WEBSOCKET_SEND_TIMEOUT_MS);\n timer.unref();\n\n const complete = (error?: Error | null) => {\n if (completed) {\n return;\n }\n completed = true;\n clearTimeout(timer);\n callback(error);\n };\n\n ws.send(serialized, complete);\n } else {\n lc.debug?.(`Dropping outbound message on ws (state: ${ws.readyState})`, {\n dropped: data,\n });\n if (callback !== 'ignore-backpressure') {\n callback(\n new ProtocolErrorWithLevel(\n {\n kind: ErrorKind.Internal,\n message: 'WebSocket closed',\n origin: ErrorOrigin.ZeroCache,\n },\n 'info',\n ),\n );\n }\n }\n}\n\n/**\n * Sends downstream messages in the format supported by a connection's sync\n * protocol version. Keep version forks contained here so the view-syncer can\n * continue producing one canonical poke representation.\n *\n * Exported for compatibility testing.\n */\nexport class DownstreamSender {\n readonly #lc: LogContext;\n readonly #ws: WebSocketLike;\n readonly #protocolVersion: number;\n #pokeChunkEncoder: PokeChunkEncoder | undefined;\n\n constructor(lc: LogContext, ws: WebSocketLike, protocolVersion: number) {\n this.#lc = lc;\n this.#ws = ws;\n this.#protocolVersion = protocolVersion;\n }\n\n send(\n data: Downstream,\n callback: ((err?: Error | null) => void) | 'ignore-backpressure',\n serialized?: string | undefined,\n ): void {\n if (this.#protocolVersion >= POKE_CHUNK_PROTOCOL_VERSION) {\n switch (data[0]) {\n case 'pokeStart':\n assert(\n this.#pokeChunkEncoder === undefined,\n 'cannot start a poke while another poke is in progress',\n );\n this.#pokeChunkEncoder = new PokeChunkEncoder();\n break;\n case 'pokePart':\n this.#sendPokePart(data, callback, serialized);\n return;\n case 'pokeEnd':\n this.#sendPokeEnd(data, callback);\n return;\n }\n }\n send(this.#lc, this.#ws, data, callback, serialized);\n }\n\n #sendPokePart(\n pokePart: PokePartMessage,\n callback: ((err?: Error | null) => void) | 'ignore-backpressure',\n serialized?: string | undefined,\n ): void {\n const encoder = this.#pokeChunkEncoder;\n assert(encoder, 'pokePart received without a pokeStart');\n\n serialized ??= JSON.stringify(pokePart);\n assert(\n serialized.startsWith(POKE_PART_PREFIX) && serialized.endsWith(']'),\n 'invalid serialized pokePart',\n );\n void encoder\n .addPatch(serialized.slice(POKE_PART_PREFIX.length, -1), chunk =>\n sendBinary(this.#lc, this.#ws, chunk),\n )\n .then(\n () => invokeCallback(callback),\n error => invokeCallback(callback, toError(error)),\n );\n }\n\n #sendPokeEnd(\n pokeEnd: PokeEndMessage,\n callback: ((err?: Error | null) => void) | 'ignore-backpressure',\n ): void {\n const encoder = this.#pokeChunkEncoder;\n assert(encoder, 'pokeEnd received without a pokeStart');\n this.#pokeChunkEncoder = undefined;\n\n if (pokeEnd[1].cancel) {\n encoder.cancel();\n send(this.#lc, this.#ws, pokeEnd, callback);\n return;\n }\n\n void encoder\n .finish(chunk => sendBinary(this.#lc, this.#ws, chunk))\n .then(() => sendAsync(this.#lc, this.#ws, pokeEnd))\n .then(\n () => invokeCallback(callback),\n error => invokeCallback(callback, toError(error)),\n );\n }\n}\n\nconst POKE_PART_PREFIX = '[\"pokePart\",';\n\nfunction sendAsync(\n lc: LogContext,\n ws: WebSocketLike,\n data: Downstream,\n): Promise<void> {\n return new Promise((resolve, reject) => {\n send(lc, ws, data, error => (error ? reject(error) : resolve()));\n });\n}\n\nfunction sendBinary(\n lc: LogContext,\n ws: WebSocketLike,\n chunk: PokeChunk,\n): Promise<void> {\n return new Promise((resolve, reject) => {\n if (ws.readyState !== WebSocket.OPEN) {\n const error = webSocketClosedError();\n lc.debug?.(\n `Dropping outbound binary message on ws (state: ${ws.readyState})`,\n );\n reject(error);\n return;\n }\n let completed = false;\n const timer = setTimeout(\n () => complete(webSocketSendTimeoutError()),\n WEBSOCKET_SEND_TIMEOUT_MS,\n );\n timer.unref();\n\n const complete = (error?: Error | null) => {\n if (completed) {\n return;\n }\n completed = true;\n clearTimeout(timer);\n error ? reject(error) : resolve();\n };\n\n ws.send(chunk, complete);\n });\n}\n\nfunction invokeCallback(\n callback: ((err?: Error | null) => void) | 'ignore-backpressure',\n error?: Error,\n): void {\n if (callback !== 'ignore-backpressure') {\n callback(error);\n }\n}\n\nfunction toError(error: unknown): Error {\n return error instanceof Error ? error : new Error(String(error));\n}\n\nfunction webSocketClosedError(): ProtocolErrorWithLevel {\n return new ProtocolErrorWithLevel(\n {\n kind: ErrorKind.Internal,\n message: 'WebSocket closed',\n origin: ErrorOrigin.ZeroCache,\n },\n 'info',\n );\n}\n\nfunction webSocketSendTimeoutError(): ProtocolErrorWithLevel {\n return new ProtocolErrorWithLevel(\n {\n kind: ErrorKind.Internal,\n message: `WebSocket send timed out after ${WEBSOCKET_SEND_TIMEOUT_MS} ms`,\n origin: ErrorOrigin.ZeroCache,\n },\n 'info',\n );\n}\n\nexport function sendError(\n lc: LogContext,\n ws: WebSocket,\n errorBody: ErrorBody,\n thrown?: unknown,\n) {\n lc = lc.withContext('errorKind', errorBody.kind);\n\n let logLevel: LogLevel;\n\n // If the thrown error is a ProtocolErrorWithLevel, its explicit logLevel takes precedence\n if (thrown instanceof ProtocolErrorWithLevel) {\n logLevel = thrown.logLevel;\n }\n // Errors with errno or transient socket codes are low-level, transient I/O issues\n // (e.g., EPIPE, ECONNRESET) and should be warnings, not errors\n else if (\n hasErrno(thrown) ||\n hasTransientSocketCode(thrown) ||\n isTransientSocketMessage(errorBody.message)\n ) {\n logLevel = 'warn';\n }\n // Fallback: check errorBody.kind for errors that weren't thrown as ProtocolErrorWithLevel\n else if (\n errorBody.kind === ErrorKind.ClientNotFound ||\n errorBody.kind === ErrorKind.TransformFailed\n ) {\n logLevel = 'warn';\n } else {\n logLevel = thrown ? getLogLevel(thrown) : 'info';\n }\n\n lc[logLevel]?.('Sending error on WebSocket', errorBody, thrown ?? '');\n send(lc, ws, ['error', errorBody], 'ignore-backpressure');\n}\n\nexport function findProtocolError(error: unknown): ProtocolError | undefined {\n if (isProtocolError(error)) {\n return error;\n }\n if (error instanceof Error && error.cause) {\n return findProtocolError(error.cause);\n }\n return undefined;\n}\n\nfunction hasErrno(error: unknown): boolean {\n return Boolean(\n error &&\n typeof error === 'object' &&\n 'errno' in error &&\n typeof (error as {errno: unknown}).errno !== 'undefined',\n );\n}\n\n// System error codes that indicate transient socket conditions.\n// These are checked via the `code` property on errors.\nconst TRANSIENT_SOCKET_ERROR_CODES = new Set([\n 'EPIPE',\n 'ECONNRESET',\n 'ECANCELED',\n]);\n\n// Error messages that indicate transient socket conditions but don't have\n// standard error codes (e.g., WebSocket library errors).\nconst TRANSIENT_SOCKET_MESSAGE_PATTERNS = [\n 'socket was closed while data was being compressed',\n];\n\nfunction hasTransientSocketCode(error: unknown): boolean {\n if (!error || typeof error !== 'object') {\n return false;\n }\n const maybeCode =\n 'code' in error ? String((error as {code?: unknown}).code) : undefined;\n return Boolean(\n maybeCode && TRANSIENT_SOCKET_ERROR_CODES.has(maybeCode.toUpperCase()),\n );\n}\n\nfunction isTransientSocketMessage(message: string | undefined): boolean {\n if (!message) {\n return false;\n }\n const lower = message.toLowerCase();\n return TRANSIENT_SOCKET_MESSAGE_PATTERNS.some(pattern =>\n lower.includes(pattern),\n );\n}\n"],"mappings":"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"}
|
|
@@ -14,7 +14,7 @@ import { DrainCoordinator } from "../services/view-syncer/drain-coordinator.js";
|
|
|
14
14
|
import { Connection, sendError } from "./connection.js";
|
|
15
15
|
import { SyncerWsMessageHandler } from "./syncer-ws-message-handler.js";
|
|
16
16
|
import { resolver } from "@rocicorp/resolver";
|
|
17
|
-
import { WebSocket, WebSocketServer } from "ws";
|
|
17
|
+
import { WebSocket as WebSocket$1, WebSocketServer } from "ws";
|
|
18
18
|
import { pid } from "node:process";
|
|
19
19
|
//#region ../zero-cache/src/workers/syncer.ts
|
|
20
20
|
function computePipelineDedupStats(viewSyncers) {
|
|
@@ -341,7 +341,7 @@ var Syncer = class {
|
|
|
341
341
|
recordConnectionFailure("internal");
|
|
342
342
|
throw e;
|
|
343
343
|
}
|
|
344
|
-
if (ws.readyState !== WebSocket.OPEN) {
|
|
344
|
+
if (ws.readyState !== WebSocket$1.OPEN) {
|
|
345
345
|
this.#lc.debug?.("websocket closed while resolving auth", {
|
|
346
346
|
clientGroupID,
|
|
347
347
|
clientID,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"syncer.js","names":["#lc","#viewSyncers","#mutagens","#pushers","#connections","#drainCoordinator","#parent","#wss","#stopped","#config","#validateLegacyJWT","#replicaReadyStates","#webSocketOpenConnections","#webSocketConnectionAttempts","#webSocketConnectionSuccesses","#webSocketConnectionFailures","#viewSyncerLag","#recordReplicaReadyState","#createConnection","#computeServingLagDistribution","#computePipelineDedupStats","#viewSyncerLagSampleInterval","#recordViewSyncerLagSamples","#eligibilityLogInterval","#logSharedAdvanceEligibility","#servingLagDistributionCache","#servingLagDistributionCacheClearQueued"],"sources":["../../../../../zero-cache/src/workers/syncer.ts"],"sourcesContent":["import {pid} from 'node:process';\nimport type {MessagePort} from 'node:worker_threads';\nimport type {LogContext} from '@rocicorp/logger';\nimport {resolver} from '@rocicorp/resolver';\nimport {WebSocket, WebSocketServer, type ServerOptions} from 'ws';\nimport {promiseVoid} from '../../../shared/src/resolved-promises.ts';\nimport {ErrorKind} from '../../../zero-protocol/src/error-kind.ts';\nimport {ErrorOrigin} from '../../../zero-protocol/src/error-origin.ts';\nimport {\n isProtocolError,\n ProtocolError,\n} from '../../../zero-protocol/src/error.ts';\nimport {resolveAuth, type Auth, type ValidateLegacyJWT} from '../auth/auth.ts';\nimport {tokenConfigOptions} from '../auth/jwt.ts';\nimport {type ZeroConfig} from '../config/zero-config.ts';\nimport {\n getOrCreateCounter,\n getOrCreateGauge,\n getOrCreateNativeHistogram,\n getOrCreateUpDownCounter,\n} from '../observability/metrics.ts';\nimport {\n recordConnectionAttempted,\n recordConnectionSuccess,\n setActiveClientGroupsGetter,\n} from '../server/anonymous-otel-start.ts';\nimport type {Mutagen} from '../services/mutagen/mutagen.ts';\nimport type {Pusher} from '../services/mutagen/pusher.ts';\nimport type {ReplicaState} from '../services/replicator/replicator.ts';\nimport {ServiceRunner} from '../services/runner.ts';\nimport type {\n ActivityBasedService,\n Service,\n SingletonService,\n} from '../services/service.ts';\nimport type {ConnectionContextManager} from '../services/view-syncer/connection-context-manager.ts';\nimport {DrainCoordinator} from '../services/view-syncer/drain-coordinator.ts';\nimport type {ViewSyncer} from '../services/view-syncer/view-syncer.ts';\nimport type {Worker} from '../types/processes.ts';\nimport type {Subscription} from '../types/subscription.ts';\nimport {installWebSocketReceiver} from '../types/websocket-handoff.ts';\nimport type {ConnectParams} from './connect-params.ts';\nimport {Connection, sendError} from './connection.ts';\nimport {createNotifierFrom, subscribeTo} from './replicator.ts';\nimport {SyncerWsMessageHandler} from './syncer-ws-message-handler.ts';\n\nexport type SyncerWorkerData = {\n replicatorPort: MessagePort;\n};\n\nexport type ReplicaReadyState = {\n readonly watermark: string;\n readonly replicaReadyTimeMs: number;\n};\n\nexport type ServingLagViewSyncer = Pick<\n ViewSyncer,\n 'createdAtMs' | 'servedVersion' | 'servingLagEligible'\n>;\n\nexport type ServingLagStats = {\n readonly activeClientGroups: number;\n readonly laggingClientGroups: number;\n readonly minMs: number;\n readonly p50Ms: number;\n readonly p75Ms: number;\n readonly p99Ms: number;\n readonly maxMs: number;\n};\n\ntype ServingLagDistribution = ServingLagStats & {\n readonly lagsMs: readonly number[];\n};\n\nexport type PipelineDedupViewSyncer = Pick<\n ViewSyncer,\n 'pipelineHashes' | 'clientSchemaKey'\n>;\n\nexport type PipelineDedupStats = {\n readonly clientTotal: number;\n readonly internalTotal: number;\n // (clientSchemaKey, transformationHash) -> occurrence count across client\n // groups, for client/custom queries.\n readonly clientHashes: ReadonlyMap<\n string,\n {count: number; queryName: string | undefined}\n >;\n readonly internalUnique: number;\n readonly clientSchemas: number;\n};\n\nexport function computePipelineDedupStats(\n viewSyncers: Iterable<PipelineDedupViewSyncer>,\n): PipelineDedupStats {\n let clientTotal = 0;\n let internalTotal = 0;\n const clientHashes = new Map<\n string,\n {count: number; queryName: string | undefined}\n >();\n const internalHashes = new Set<string>();\n const schemaKeys = new Set<string>();\n let vsIndex = 0;\n for (const vs of viewSyncers) {\n const schemaKey = vs.clientSchemaKey;\n if (schemaKey !== undefined) {\n schemaKeys.add(schemaKey);\n }\n // Without a client schema the pipeline's dedup identity is unknown, so key\n // it uniquely per view-syncer; otherwise schema-less pipelines from\n // unrelated client groups would collapse under a shared `undefined/<hash>`\n // key and overstate deduplication.\n const dedupSchemaKey = schemaKey ?? `#vs${vsIndex}`;\n vsIndex++;\n for (const {\n transformationHash,\n internal,\n queryName,\n } of vs.pipelineHashes()) {\n // Pipelines only share when both the client schema (which determines\n // row keys) and the transformed AST are identical.\n const key = `${dedupSchemaKey}/${transformationHash}`;\n if (internal) {\n internalTotal++;\n internalHashes.add(key);\n } else {\n clientTotal++;\n const entry = clientHashes.get(key);\n if (entry) {\n entry.count++;\n } else {\n clientHashes.set(key, {count: 1, queryName});\n }\n }\n }\n }\n return {\n clientTotal,\n internalTotal,\n clientHashes,\n internalUnique: internalHashes.size,\n clientSchemas: schemaKeys.size,\n };\n}\n\nexport const MAX_REPLICA_READY_STATES = 10_000;\nconst VIEW_SYNCER_LAG_SAMPLE_INTERVAL_MS = 60_000;\nconst SHARED_ADVANCE_ELIGIBILITY_LOG_INTERVAL_MS = 5 * 60_000;\n\nconst PROTOCOL_VERSION_ATTRIBUTE = 'protocol.version';\nconst FAILURE_REASON_ATTRIBUTE = 'reason';\n\nfunction boundReplicaReadyStates(\n replicaReadyStates: ReplicaReadyState[],\n): void {\n if (replicaReadyStates.length > MAX_REPLICA_READY_STATES) {\n replicaReadyStates.splice(\n 0,\n replicaReadyStates.length - MAX_REPLICA_READY_STATES,\n );\n }\n}\n\nfunction pruneReplicaReadyStates(\n replicaReadyStates: ReplicaReadyState[],\n firstNeededIndex: number,\n): void {\n if (firstNeededIndex > 0) {\n replicaReadyStates.splice(0, firstNeededIndex);\n }\n\n boundReplicaReadyStates(replicaReadyStates);\n}\n\nfunction lowerBoundReplicaReadyTimeMs(\n replicaReadyStates: readonly ReplicaReadyState[],\n replicaReadyTimeMs: number,\n): number {\n let low = 0;\n let high = replicaReadyStates.length;\n while (low < high) {\n const mid = low + Math.floor((high - low) / 2);\n if (replicaReadyStates[mid].replicaReadyTimeMs < replicaReadyTimeMs) {\n low = mid + 1;\n } else {\n high = mid;\n }\n }\n return low;\n}\n\nfunction upperBoundWatermark(\n replicaReadyStates: readonly ReplicaReadyState[],\n watermark: string,\n): number {\n let low = 0;\n let high = replicaReadyStates.length;\n while (low < high) {\n const mid = low + Math.floor((high - low) / 2);\n if (replicaReadyStates[mid].watermark <= watermark) {\n low = mid + 1;\n } else {\n high = mid;\n }\n }\n return low;\n}\n\nfunction findFirstUnservedIndex(\n replicaReadyStates: readonly ReplicaReadyState[],\n viewSyncer: ServingLagViewSyncer,\n): number {\n const firstReadyAfterCreation = lowerBoundReplicaReadyTimeMs(\n replicaReadyStates,\n viewSyncer.createdAtMs,\n );\n const firstAfterServedVersion =\n viewSyncer.servedVersion === null\n ? 0\n : upperBoundWatermark(replicaReadyStates, viewSyncer.servedVersion);\n\n const firstUnservedIndex = Math.max(\n firstReadyAfterCreation,\n firstAfterServedVersion,\n );\n return firstUnservedIndex < replicaReadyStates.length\n ? firstUnservedIndex\n : -1;\n}\n\nfunction percentileNearestRank(\n sortedValues: readonly number[],\n percentile: number,\n): number {\n if (sortedValues.length === 0) {\n return 0;\n }\n const index = Math.min(\n sortedValues.length - 1,\n Math.max(0, Math.ceil((percentile / 100) * sortedValues.length) - 1),\n );\n return sortedValues[index];\n}\n\nfunction computeServingLagDistributionMs(\n now: number,\n replicaReadyStates: ReplicaReadyState[],\n viewSyncers: Iterable<ServingLagViewSyncer>,\n): ServingLagDistribution {\n const lags: number[] = [];\n let laggingClientGroups = 0;\n let firstNeededIndex = replicaReadyStates.length;\n\n for (const viewSyncer of viewSyncers) {\n if (!viewSyncer.servingLagEligible) {\n continue;\n }\n\n const firstUnservedIndex = findFirstUnservedIndex(\n replicaReadyStates,\n viewSyncer,\n );\n if (firstUnservedIndex === -1) {\n lags.push(0);\n continue;\n }\n\n firstNeededIndex = Math.min(firstNeededIndex, firstUnservedIndex);\n const lagMs = Math.max(\n 0,\n now - replicaReadyStates[firstUnservedIndex].replicaReadyTimeMs,\n );\n lags.push(lagMs);\n if (lagMs > 0) {\n laggingClientGroups++;\n }\n }\n\n pruneReplicaReadyStates(replicaReadyStates, firstNeededIndex);\n\n lags.sort((a, b) => a - b);\n return {\n activeClientGroups: lags.length,\n laggingClientGroups,\n // The percentile fields are for the legacy serving_lag_stats gauge.\n // The native view_syncer_lag histogram records lagsMs and computes\n // percentiles in Prometheus/AMP.\n minMs: lags[0] ?? 0,\n p50Ms: percentileNearestRank(lags, 50),\n p75Ms: percentileNearestRank(lags, 75),\n p99Ms: percentileNearestRank(lags, 99),\n maxMs: lags.at(-1) ?? 0,\n lagsMs: lags,\n };\n}\n\nexport function computeServingLagStatsMs(\n now: number,\n replicaReadyStates: ReplicaReadyState[],\n viewSyncers: Iterable<ServingLagViewSyncer>,\n): ServingLagStats {\n const distribution = computeServingLagDistributionMs(\n now,\n replicaReadyStates,\n viewSyncers,\n );\n return {\n activeClientGroups: distribution.activeClientGroups,\n laggingClientGroups: distribution.laggingClientGroups,\n minMs: distribution.minMs,\n p50Ms: distribution.p50Ms,\n p75Ms: distribution.p75Ms,\n p99Ms: distribution.p99Ms,\n maxMs: distribution.maxMs,\n };\n}\n\nexport function computeMaxServingLagMs(\n now: number,\n replicaReadyStates: ReplicaReadyState[],\n viewSyncers: Iterable<ServingLagViewSyncer>,\n): number {\n return computeServingLagStatsMs(now, replicaReadyStates, viewSyncers).maxMs;\n}\n\nfunction getWebSocketServerOptions(config: ZeroConfig): ServerOptions {\n const options: ServerOptions = {\n noServer: true,\n maxPayload: config.websocketMaxPayloadBytes,\n };\n\n if (config.websocketCompression) {\n options.perMessageDeflate = true;\n\n if (config.websocketCompressionOptions) {\n try {\n const compressionOptions = JSON.parse(\n config.websocketCompressionOptions,\n );\n options.perMessageDeflate = compressionOptions;\n } catch (e) {\n throw new Error(\n `Failed to parse ZERO_WEBSOCKET_COMPRESSION_OPTIONS: ${String(e)}. Expected valid JSON.`,\n );\n }\n }\n }\n\n return options;\n}\n\n/**\n * The Syncer worker receives websocket handoffs for \"/sync\" connections\n * from the Dispatcher in the main thread, and creates websocket\n * {@link Connection}s with a corresponding {@link ViewSyncer}, {@link Mutagen},\n * and {@link Subscription} to version notifications from the Replicator\n * worker.\n */\nexport class Syncer implements SingletonService {\n readonly id = `syncer-${pid}`;\n readonly #lc: LogContext;\n readonly #viewSyncers: ServiceRunner<ViewSyncer & ActivityBasedService>;\n readonly #mutagens: ServiceRunner<Mutagen & Service> | undefined;\n readonly #pushers: ServiceRunner<Pusher & Service> | undefined;\n readonly #connections = new Map<string, Connection>();\n readonly #drainCoordinator = new DrainCoordinator();\n readonly #parent: Worker;\n readonly #wss: WebSocketServer;\n readonly #stopped = resolver();\n readonly #config: ZeroConfig;\n readonly #validateLegacyJWT: ValidateLegacyJWT | undefined;\n readonly #replicaReadyStates: ReplicaReadyState[] = [];\n readonly #webSocketOpenConnections = getOrCreateUpDownCounter(\n 'sync',\n 'websocket.open_connections',\n 'Open client WebSocket connections.',\n );\n readonly #webSocketConnectionAttempts = getOrCreateCounter(\n 'sync',\n 'websocket.connection_attempts',\n 'Client WebSocket connection attempts.',\n );\n readonly #webSocketConnectionSuccesses = getOrCreateCounter(\n 'sync',\n 'websocket.connection_successes',\n 'Client WebSocket connections successfully initialized.',\n );\n readonly #webSocketConnectionFailures = getOrCreateCounter(\n 'sync',\n 'websocket.connection_failures',\n 'Client WebSocket connection attempts that failed before initialization.',\n );\n readonly #viewSyncerLag = getOrCreateNativeHistogram(\n 'sync',\n 'view_syncer_lag',\n {\n description:\n 'Lag from replica-ready change to ViewSyncer output for active ' +\n 'client groups. A change is output after IVM advancement, CVR flush, ' +\n 'and pokeEnd.',\n unit: 's',\n },\n );\n #servingLagDistributionCache: ServingLagDistribution | undefined;\n #servingLagDistributionCacheClearQueued = false;\n #viewSyncerLagSampleInterval: ReturnType<typeof setInterval> | undefined;\n #eligibilityLogInterval: ReturnType<typeof setInterval> | undefined;\n\n constructor(\n lc: LogContext,\n config: ZeroConfig,\n viewSyncerFactory: (\n id: string,\n sub: Subscription<ReplicaState>,\n drainCoordinator: DrainCoordinator,\n ) => ViewSyncer & ActivityBasedService,\n mutagenFactory: ((id: string) => Mutagen & Service) | undefined,\n pusherFactory:\n | ((\n id: string,\n connContextManager: ConnectionContextManager,\n ) => Pusher & Service)\n | undefined,\n parent: Worker,\n validateLegacyJWT: ValidateLegacyJWT | undefined,\n ) {\n this.#config = config;\n this.#validateLegacyJWT = validateLegacyJWT;\n // Relays notifications from the parent thread subscription\n // to ViewSyncers within this thread.\n const notifier = createNotifierFrom(lc, parent, state =>\n this.#recordReplicaReadyState(state),\n );\n subscribeTo(lc, parent);\n\n this.#lc = lc;\n this.#viewSyncers = new ServiceRunner(\n lc,\n id => viewSyncerFactory(id, notifier.subscribe(), this.#drainCoordinator),\n v => v.keepalive(),\n );\n if (mutagenFactory) {\n this.#mutagens = new ServiceRunner(lc, mutagenFactory, m => m.hasRefs());\n }\n if (pusherFactory) {\n this.#pushers = new ServiceRunner(\n lc,\n id =>\n pusherFactory(\n id,\n this.#viewSyncers.getService(id).connContextManager,\n ),\n p => p.hasRefs(),\n );\n }\n this.#parent = parent;\n this.#wss = new WebSocketServer(getWebSocketServerOptions(config));\n\n installWebSocketReceiver(\n lc,\n this.#wss,\n this.#createConnection,\n this.#parent,\n );\n\n setActiveClientGroupsGetter(() => this.#viewSyncers.size);\n\n getOrCreateGauge(\n 'sync',\n 'active-client-groups',\n 'Number of active client groups',\n ).addCallback(result => result.observe(this.#viewSyncers.size));\n\n getOrCreateGauge(\n 'sync',\n 'queries',\n 'Active queries (pipelines) across all client groups',\n ).addCallback(result => {\n let total = 0;\n for (const vs of this.#viewSyncers.getServices()) {\n total += vs.queryCount;\n }\n result.observe(total);\n });\n\n getOrCreateGauge(\n 'sync',\n 'rows',\n 'Tracked rows across all client groups',\n ).addCallback(result => {\n let total = 0;\n for (const vs of this.#viewSyncers.getServices()) {\n total += vs.rowCount;\n }\n result.observe(total);\n });\n\n getOrCreateGauge('sync', 'serving_lag', {\n description:\n 'Maximum time active ViewSyncer client groups have had unserved ' +\n 'replica changes. A change is served after IVM advancement, CVR flush, ' +\n 'and pokeEnd.',\n unit: 'millisecond',\n }).addCallback(result => {\n result.observe(this.#computeServingLagDistribution().maxMs);\n });\n\n getOrCreateGauge('sync', 'serving_lag_stats', {\n description:\n 'Distribution of time active ViewSyncer client groups have had ' +\n 'unserved replica changes. A change is served after IVM advancement, ' +\n 'CVR flush, and pokeEnd.',\n unit: 'millisecond',\n }).addCallback(result => {\n const stats = this.#computeServingLagDistribution();\n result.observe(stats.minMs, {stat: 'min'});\n result.observe(stats.p50Ms, {stat: 'p50'});\n result.observe(stats.p75Ms, {stat: 'p75'});\n result.observe(stats.p99Ms, {stat: 'p99'});\n result.observe(stats.maxMs, {stat: 'max'});\n });\n\n getOrCreateGauge(\n 'sync',\n 'serving_lagging_client_groups',\n 'Number of active client groups with unserved replica changes',\n ).addCallback(result => {\n result.observe(this.#computeServingLagDistribution().laggingClientGroups);\n });\n\n getOrCreateGauge(\n 'sync',\n 'pipelines_total',\n 'Query pipelines across all client groups on this worker, by query ' +\n 'type. The ratio to sync.pipelines_unique is the dedup factor ' +\n 'available to shared pipeline advancement.',\n ).addCallback(result => {\n const stats = this.#computePipelineDedupStats();\n result.observe(stats.clientTotal, {type: 'client'});\n result.observe(stats.internalTotal, {type: 'internal'});\n });\n\n getOrCreateGauge(\n 'sync',\n 'pipelines_unique',\n 'Distinct (clientSchema, transformationHash) pipelines across all ' +\n 'client groups on this worker, by query type',\n ).addCallback(result => {\n const stats = this.#computePipelineDedupStats();\n result.observe(stats.clientHashes.size, {type: 'client'});\n result.observe(stats.internalUnique, {type: 'internal'});\n });\n\n getOrCreateGauge(\n 'sync',\n 'client_schemas',\n 'Distinct client schemas across all client groups on this worker',\n ).addCallback(result => {\n result.observe(this.#computePipelineDedupStats().clientSchemas);\n });\n\n this.#viewSyncerLagSampleInterval = setInterval(\n () => this.#recordViewSyncerLagSamples(),\n VIEW_SYNCER_LAG_SAMPLE_INTERVAL_MS,\n );\n this.#viewSyncerLagSampleInterval.unref?.();\n\n this.#eligibilityLogInterval = setInterval(\n () => this.#logSharedAdvanceEligibility(),\n SHARED_ADVANCE_ELIGIBILITY_LOG_INTERVAL_MS,\n );\n this.#eligibilityLogInterval.unref?.();\n }\n\n #computeServingLagDistribution(): ServingLagDistribution {\n if (this.#servingLagDistributionCache) {\n return this.#servingLagDistributionCache;\n }\n\n const distribution = computeServingLagDistributionMs(\n Date.now(),\n this.#replicaReadyStates,\n this.#viewSyncers.getServices(),\n );\n this.#servingLagDistributionCache = distribution;\n if (!this.#servingLagDistributionCacheClearQueued) {\n this.#servingLagDistributionCacheClearQueued = true;\n queueMicrotask(() => {\n this.#servingLagDistributionCache = undefined;\n this.#servingLagDistributionCacheClearQueued = false;\n });\n }\n return distribution;\n }\n\n #recordViewSyncerLagSamples(): void {\n const {lagsMs} = this.#computeServingLagDistribution();\n for (const lagMs of lagsMs) {\n this.#viewSyncerLag.recordMs(lagMs);\n }\n }\n\n #computePipelineDedupStats(): PipelineDedupStats {\n return computePipelineDedupStats(this.#viewSyncers.getServices());\n }\n\n #logSharedAdvanceEligibility(): void {\n const stats = this.#computePipelineDedupStats();\n if (stats.clientTotal === 0) {\n return;\n }\n const unique = stats.clientHashes.size;\n const topDuplicated = [...stats.clientHashes.entries()]\n .filter(([, {count}]) => count > 1)\n .sort((a, b) => b[1].count - a[1].count)\n .slice(0, 10)\n .map(([key, {count, queryName}]) => ({key, count, queryName}));\n this.#lc.info?.('shared-advance-eligibility', {\n clientPipelines: stats.clientTotal,\n uniqueClientPipelines: unique,\n dedupFactor: Number((stats.clientTotal / Math.max(unique, 1)).toFixed(2)),\n internalPipelines: stats.internalTotal,\n clientSchemas: stats.clientSchemas,\n topDuplicated,\n });\n }\n\n #recordReplicaReadyState(state: ReplicaState): void {\n if (\n state.watermark === undefined ||\n state.replicaReadyTimeMs === undefined\n ) {\n return;\n }\n const last = this.#replicaReadyStates.at(-1);\n if (last && last.watermark >= state.watermark) {\n return;\n }\n this.#replicaReadyStates.push({\n watermark: state.watermark,\n replicaReadyTimeMs: state.replicaReadyTimeMs,\n });\n if (this.#viewSyncers.size === 0) {\n this.#replicaReadyStates.length = 0;\n return;\n }\n boundReplicaReadyStates(this.#replicaReadyStates);\n }\n\n readonly #createConnection = async (ws: WebSocket, params: ConnectParams) => {\n const metricAttributes = {\n [PROTOCOL_VERSION_ATTRIBUTE]: params.protocolVersion,\n };\n let connectionResultRecorded = false;\n const recordConnectionFailure = (reason: string) => {\n if (connectionResultRecorded) {\n return;\n }\n connectionResultRecorded = true;\n this.#webSocketConnectionFailures.add(1, {\n ...metricAttributes,\n [FAILURE_REASON_ATTRIBUTE]: reason,\n });\n };\n const recordConnectionSuccessMetric = () => {\n if (connectionResultRecorded) {\n return;\n }\n connectionResultRecorded = true;\n this.#webSocketConnectionSuccesses.add(1, metricAttributes);\n };\n\n this.#webSocketConnectionAttempts.add(1, metricAttributes);\n this.#webSocketOpenConnections.add(1, metricAttributes);\n ws.once('close', () => {\n this.#webSocketOpenConnections.add(-1, metricAttributes);\n });\n\n this.#lc.debug?.(\n 'creating connection',\n params.clientGroupID,\n params.clientID,\n );\n recordConnectionAttempted();\n const {clientID, clientGroupID, auth, userID} = params;\n const hasProvidedAuth = auth !== undefined && auth !== '';\n const incomingUserID = userID ?? null;\n\n if (hasProvidedAuth) {\n const tokenOptions = tokenConfigOptions(this.#config.auth ?? {});\n\n const hasPushOrMutate =\n this.#config?.push?.url !== undefined ||\n this.#config?.mutate?.url !== undefined;\n const hasQueries =\n this.#config?.query?.url !== undefined ||\n this.#config?.getQueries?.url !== undefined;\n\n // must either have one of the token options set or have custom mutations & queries enabled\n const hasExactlyOneTokenOption = tokenOptions.length === 1;\n const hasCustomEndpoints = hasPushOrMutate && hasQueries;\n if (!hasExactlyOneTokenOption && !hasCustomEndpoints) {\n recordConnectionFailure('configuration');\n throw new Error(\n 'Exactly one of jwk, secret, or jwksUrl must be set in order to verify tokens but actually the following were set: ' +\n JSON.stringify(tokenOptions) +\n '. You may also set both ZERO_MUTATE_URL and ZERO_QUERY_URL to enable custom mutations and queries without passing token verification options.',\n );\n }\n }\n\n let initialAuth: Auth | undefined;\n\n // Verify JWT BEFORE touching existing connections - prevents unauthenticated\n // attackers from force-disconnecting legitimate users via DoS.\n try {\n initialAuth = await resolveAuth(\n this.#lc\n .withContext('clientGroupID', clientGroupID)\n .withContext('clientID', clientID),\n // no previous auth, since this is a new connection, and resolveAuth is\n // connection scoped, not client group scoped\n undefined,\n incomingUserID,\n auth,\n this.#validateLegacyJWT,\n );\n } catch (e) {\n if (isProtocolError(e)) {\n this.#lc.warn?.(\n 'Rejecting sync connection during initial auth resolution',\n {\n clientGroupID,\n clientID,\n incomingUserID,\n hasProvidedAuth,\n errorKind: e.message,\n },\n );\n recordConnectionFailure('auth');\n sendError(this.#lc, ws, e.errorBody);\n ws.close(3000, e.errorBody.message);\n return;\n }\n recordConnectionFailure('internal');\n throw e;\n }\n\n // Resolving auth can take a while (e.g. a remote JWKS fetch), during\n // which the client may have disconnected. A Connection created for a\n // closed socket never receives the 'close' event and is thus never\n // cleaned up, leaking its timer, its registrations in the connection\n // context manager and its refs on the mutagen and pusher services.\n if (ws.readyState !== WebSocket.OPEN) {\n this.#lc.debug?.('websocket closed while resolving auth', {\n clientGroupID,\n clientID,\n readyState: ws.readyState,\n });\n recordConnectionFailure('closed_during_auth');\n return;\n }\n\n const viewSyncer = this.#viewSyncers.getService(clientGroupID);\n const connContextManager = viewSyncer.connContextManager;\n const group = connContextManager.getGroupState();\n\n // TODO(0xcadams): we only check for user ID mismatch here if the group is\n // already validated. This prevents wrong-user reconnects from evicting a\n // healthy connection, but it does not protect against same-user reconnects\n // with an invalid opaque token. The long-term fix is to keep the replacement\n // connection pending until its auth is fully validated, and only then replace\n // the existing socket.\n if (\n group.pinnedUser !== undefined &&\n group.pinnedUser.id !== incomingUserID\n ) {\n const error = new ProtocolError({\n kind: ErrorKind.Unauthorized,\n message:\n 'Client groups are pinned to a single userID. Connection userID does not match existing client group userID.',\n origin: ErrorOrigin.ZeroCache,\n });\n recordConnectionFailure('user_mismatch');\n sendError(this.#lc, ws, error.errorBody);\n ws.close(3000, error.message);\n return;\n }\n\n // Check for and close existing connections AFTER auth is validated\n const existing = this.#connections.get(clientID);\n if (existing) {\n this.#lc.debug?.(\n `client ${clientID} already connected, closing existing connection`,\n );\n existing.close(`replaced by ${params.wsID}`);\n }\n\n connContextManager.registerConnection(\n {clientID, wsID: params.wsID},\n params,\n initialAuth,\n );\n\n const mutagen = this.#mutagens?.getService(clientGroupID);\n const pusher = this.#pushers?.getService(clientGroupID);\n // a new connection is using the mutagen and pusher. Bump their ref counts.\n mutagen?.ref();\n pusher?.ref();\n\n let connection: Connection;\n try {\n connection = new Connection(\n this.#lc,\n params,\n ws,\n this.#config.allowLegacyQueries,\n new SyncerWsMessageHandler(\n this.#lc,\n params,\n connContextManager,\n viewSyncer,\n mutagen,\n pusher,\n ),\n () => {\n connContextManager.closeConnection({\n clientID,\n wsID: params.wsID,\n });\n if (this.#connections.get(clientID) === connection) {\n this.#connections.delete(clientID);\n }\n // Connection is closed. We can unref the mutagen and pusher.\n // If their ref counts are zero, they will stop themselves and set themselves invalid.\n mutagen?.unref();\n pusher?.unref();\n },\n );\n } catch (e) {\n recordConnectionFailure('internal');\n connContextManager.closeConnection({clientID, wsID: params.wsID});\n mutagen?.unref();\n pusher?.unref();\n throw e;\n }\n\n this.#connections.set(clientID, connection);\n\n if (connection.init()) {\n recordConnectionSuccessMetric();\n recordConnectionSuccess();\n } else {\n recordConnectionFailure('protocol_version');\n }\n\n if (params.initConnectionMsg) {\n this.#lc.debug?.(\n 'handling init connection message from sec header',\n params.clientGroupID,\n params.clientID,\n );\n await connection.handleInitConnection(\n JSON.stringify(params.initConnectionMsg),\n );\n }\n };\n\n run() {\n return this.#stopped.promise;\n }\n\n /**\n * Graceful shutdown involves shutting down view syncers one at a time, pausing\n * for the duration of view syncer's hydration between each one. This paces the\n * disconnects to avoid creating a backlog of hydrations in the receiving server\n * when the clients reconnect.\n */\n async drain() {\n const start = Date.now();\n this.#lc.info?.(`draining ${this.#viewSyncers.size} view-syncers`);\n\n this.#drainCoordinator.drainNextIn(0);\n\n while (this.#viewSyncers.size) {\n await this.#drainCoordinator.forceDrainTimeout;\n\n // Pick an arbitrary view syncer to force drain.\n for (const vs of this.#viewSyncers.getServices()) {\n this.#lc.debug?.(`draining view-syncer ${vs.id} (forced)`);\n // When this drain or an elective drain completes, the forceDrainTimeout will\n // resolve after the next drain interval.\n void vs.stop();\n break;\n }\n }\n this.#lc.info?.(`finished draining (${Date.now() - start} ms)`);\n }\n\n stop() {\n clearInterval(this.#viewSyncerLagSampleInterval);\n clearInterval(this.#eligibilityLogInterval);\n this.#wss.close();\n this.#stopped.resolve();\n return promiseVoid;\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"syncer.js","names":["#lc","#viewSyncers","#mutagens","#pushers","#connections","#drainCoordinator","#parent","#wss","#stopped","#config","#validateLegacyJWT","#replicaReadyStates","#webSocketOpenConnections","#webSocketConnectionAttempts","#webSocketConnectionSuccesses","#webSocketConnectionFailures","#viewSyncerLag","#recordReplicaReadyState","#createConnection","#computeServingLagDistribution","#computePipelineDedupStats","#viewSyncerLagSampleInterval","#recordViewSyncerLagSamples","#eligibilityLogInterval","#logSharedAdvanceEligibility","#servingLagDistributionCache","#servingLagDistributionCacheClearQueued"],"sources":["../../../../../zero-cache/src/workers/syncer.ts"],"sourcesContent":["import {pid} from 'node:process';\nimport type {MessagePort} from 'node:worker_threads';\nimport type {LogContext} from '@rocicorp/logger';\nimport {resolver} from '@rocicorp/resolver';\nimport {WebSocket, WebSocketServer, type ServerOptions} from 'ws';\nimport {promiseVoid} from '../../../shared/src/resolved-promises.ts';\nimport {ErrorKind} from '../../../zero-protocol/src/error-kind.ts';\nimport {ErrorOrigin} from '../../../zero-protocol/src/error-origin.ts';\nimport {\n isProtocolError,\n ProtocolError,\n} from '../../../zero-protocol/src/error.ts';\nimport {resolveAuth, type Auth, type ValidateLegacyJWT} from '../auth/auth.ts';\nimport {tokenConfigOptions} from '../auth/jwt.ts';\nimport {type ZeroConfig} from '../config/zero-config.ts';\nimport {\n getOrCreateCounter,\n getOrCreateGauge,\n getOrCreateNativeHistogram,\n getOrCreateUpDownCounter,\n} from '../observability/metrics.ts';\nimport {\n recordConnectionAttempted,\n recordConnectionSuccess,\n setActiveClientGroupsGetter,\n} from '../server/anonymous-otel-start.ts';\nimport type {Mutagen} from '../services/mutagen/mutagen.ts';\nimport type {Pusher} from '../services/mutagen/pusher.ts';\nimport type {ReplicaState} from '../services/replicator/replicator.ts';\nimport {ServiceRunner} from '../services/runner.ts';\nimport type {\n ActivityBasedService,\n Service,\n SingletonService,\n} from '../services/service.ts';\nimport type {ConnectionContextManager} from '../services/view-syncer/connection-context-manager.ts';\nimport {DrainCoordinator} from '../services/view-syncer/drain-coordinator.ts';\nimport type {ViewSyncer} from '../services/view-syncer/view-syncer.ts';\nimport type {Worker} from '../types/processes.ts';\nimport type {Subscription} from '../types/subscription.ts';\nimport {installWebSocketReceiver} from '../types/websocket-handoff.ts';\nimport type {ConnectParams} from './connect-params.ts';\nimport {Connection, sendError} from './connection.ts';\nimport {createNotifierFrom, subscribeTo} from './replicator.ts';\nimport {SyncerWsMessageHandler} from './syncer-ws-message-handler.ts';\n\nexport type SyncerWorkerData = {\n replicatorPort: MessagePort;\n};\n\nexport type ReplicaReadyState = {\n readonly watermark: string;\n readonly replicaReadyTimeMs: number;\n};\n\nexport type ServingLagViewSyncer = Pick<\n ViewSyncer,\n 'createdAtMs' | 'servedVersion' | 'servingLagEligible'\n>;\n\nexport type ServingLagStats = {\n readonly activeClientGroups: number;\n readonly laggingClientGroups: number;\n readonly minMs: number;\n readonly p50Ms: number;\n readonly p75Ms: number;\n readonly p99Ms: number;\n readonly maxMs: number;\n};\n\ntype ServingLagDistribution = ServingLagStats & {\n readonly lagsMs: readonly number[];\n};\n\nexport type PipelineDedupViewSyncer = Pick<\n ViewSyncer,\n 'pipelineHashes' | 'clientSchemaKey'\n>;\n\nexport type PipelineDedupStats = {\n readonly clientTotal: number;\n readonly internalTotal: number;\n // (clientSchemaKey, transformationHash) -> occurrence count across client\n // groups, for client/custom queries.\n readonly clientHashes: ReadonlyMap<\n string,\n {count: number; queryName: string | undefined}\n >;\n readonly internalUnique: number;\n readonly clientSchemas: number;\n};\n\nexport function computePipelineDedupStats(\n viewSyncers: Iterable<PipelineDedupViewSyncer>,\n): PipelineDedupStats {\n let clientTotal = 0;\n let internalTotal = 0;\n const clientHashes = new Map<\n string,\n {count: number; queryName: string | undefined}\n >();\n const internalHashes = new Set<string>();\n const schemaKeys = new Set<string>();\n let vsIndex = 0;\n for (const vs of viewSyncers) {\n const schemaKey = vs.clientSchemaKey;\n if (schemaKey !== undefined) {\n schemaKeys.add(schemaKey);\n }\n // Without a client schema the pipeline's dedup identity is unknown, so key\n // it uniquely per view-syncer; otherwise schema-less pipelines from\n // unrelated client groups would collapse under a shared `undefined/<hash>`\n // key and overstate deduplication.\n const dedupSchemaKey = schemaKey ?? `#vs${vsIndex}`;\n vsIndex++;\n for (const {\n transformationHash,\n internal,\n queryName,\n } of vs.pipelineHashes()) {\n // Pipelines only share when both the client schema (which determines\n // row keys) and the transformed AST are identical.\n const key = `${dedupSchemaKey}/${transformationHash}`;\n if (internal) {\n internalTotal++;\n internalHashes.add(key);\n } else {\n clientTotal++;\n const entry = clientHashes.get(key);\n if (entry) {\n entry.count++;\n } else {\n clientHashes.set(key, {count: 1, queryName});\n }\n }\n }\n }\n return {\n clientTotal,\n internalTotal,\n clientHashes,\n internalUnique: internalHashes.size,\n clientSchemas: schemaKeys.size,\n };\n}\n\nexport const MAX_REPLICA_READY_STATES = 10_000;\nconst VIEW_SYNCER_LAG_SAMPLE_INTERVAL_MS = 60_000;\nconst SHARED_ADVANCE_ELIGIBILITY_LOG_INTERVAL_MS = 5 * 60_000;\n\nconst PROTOCOL_VERSION_ATTRIBUTE = 'protocol.version';\nconst FAILURE_REASON_ATTRIBUTE = 'reason';\n\nfunction boundReplicaReadyStates(\n replicaReadyStates: ReplicaReadyState[],\n): void {\n if (replicaReadyStates.length > MAX_REPLICA_READY_STATES) {\n replicaReadyStates.splice(\n 0,\n replicaReadyStates.length - MAX_REPLICA_READY_STATES,\n );\n }\n}\n\nfunction pruneReplicaReadyStates(\n replicaReadyStates: ReplicaReadyState[],\n firstNeededIndex: number,\n): void {\n if (firstNeededIndex > 0) {\n replicaReadyStates.splice(0, firstNeededIndex);\n }\n\n boundReplicaReadyStates(replicaReadyStates);\n}\n\nfunction lowerBoundReplicaReadyTimeMs(\n replicaReadyStates: readonly ReplicaReadyState[],\n replicaReadyTimeMs: number,\n): number {\n let low = 0;\n let high = replicaReadyStates.length;\n while (low < high) {\n const mid = low + Math.floor((high - low) / 2);\n if (replicaReadyStates[mid].replicaReadyTimeMs < replicaReadyTimeMs) {\n low = mid + 1;\n } else {\n high = mid;\n }\n }\n return low;\n}\n\nfunction upperBoundWatermark(\n replicaReadyStates: readonly ReplicaReadyState[],\n watermark: string,\n): number {\n let low = 0;\n let high = replicaReadyStates.length;\n while (low < high) {\n const mid = low + Math.floor((high - low) / 2);\n if (replicaReadyStates[mid].watermark <= watermark) {\n low = mid + 1;\n } else {\n high = mid;\n }\n }\n return low;\n}\n\nfunction findFirstUnservedIndex(\n replicaReadyStates: readonly ReplicaReadyState[],\n viewSyncer: ServingLagViewSyncer,\n): number {\n const firstReadyAfterCreation = lowerBoundReplicaReadyTimeMs(\n replicaReadyStates,\n viewSyncer.createdAtMs,\n );\n const firstAfterServedVersion =\n viewSyncer.servedVersion === null\n ? 0\n : upperBoundWatermark(replicaReadyStates, viewSyncer.servedVersion);\n\n const firstUnservedIndex = Math.max(\n firstReadyAfterCreation,\n firstAfterServedVersion,\n );\n return firstUnservedIndex < replicaReadyStates.length\n ? firstUnservedIndex\n : -1;\n}\n\nfunction percentileNearestRank(\n sortedValues: readonly number[],\n percentile: number,\n): number {\n if (sortedValues.length === 0) {\n return 0;\n }\n const index = Math.min(\n sortedValues.length - 1,\n Math.max(0, Math.ceil((percentile / 100) * sortedValues.length) - 1),\n );\n return sortedValues[index];\n}\n\nfunction computeServingLagDistributionMs(\n now: number,\n replicaReadyStates: ReplicaReadyState[],\n viewSyncers: Iterable<ServingLagViewSyncer>,\n): ServingLagDistribution {\n const lags: number[] = [];\n let laggingClientGroups = 0;\n let firstNeededIndex = replicaReadyStates.length;\n\n for (const viewSyncer of viewSyncers) {\n if (!viewSyncer.servingLagEligible) {\n continue;\n }\n\n const firstUnservedIndex = findFirstUnservedIndex(\n replicaReadyStates,\n viewSyncer,\n );\n if (firstUnservedIndex === -1) {\n lags.push(0);\n continue;\n }\n\n firstNeededIndex = Math.min(firstNeededIndex, firstUnservedIndex);\n const lagMs = Math.max(\n 0,\n now - replicaReadyStates[firstUnservedIndex].replicaReadyTimeMs,\n );\n lags.push(lagMs);\n if (lagMs > 0) {\n laggingClientGroups++;\n }\n }\n\n pruneReplicaReadyStates(replicaReadyStates, firstNeededIndex);\n\n lags.sort((a, b) => a - b);\n return {\n activeClientGroups: lags.length,\n laggingClientGroups,\n // The percentile fields are for the legacy serving_lag_stats gauge.\n // The native view_syncer_lag histogram records lagsMs and computes\n // percentiles in Prometheus/AMP.\n minMs: lags[0] ?? 0,\n p50Ms: percentileNearestRank(lags, 50),\n p75Ms: percentileNearestRank(lags, 75),\n p99Ms: percentileNearestRank(lags, 99),\n maxMs: lags.at(-1) ?? 0,\n lagsMs: lags,\n };\n}\n\nexport function computeServingLagStatsMs(\n now: number,\n replicaReadyStates: ReplicaReadyState[],\n viewSyncers: Iterable<ServingLagViewSyncer>,\n): ServingLagStats {\n const distribution = computeServingLagDistributionMs(\n now,\n replicaReadyStates,\n viewSyncers,\n );\n return {\n activeClientGroups: distribution.activeClientGroups,\n laggingClientGroups: distribution.laggingClientGroups,\n minMs: distribution.minMs,\n p50Ms: distribution.p50Ms,\n p75Ms: distribution.p75Ms,\n p99Ms: distribution.p99Ms,\n maxMs: distribution.maxMs,\n };\n}\n\nexport function computeMaxServingLagMs(\n now: number,\n replicaReadyStates: ReplicaReadyState[],\n viewSyncers: Iterable<ServingLagViewSyncer>,\n): number {\n return computeServingLagStatsMs(now, replicaReadyStates, viewSyncers).maxMs;\n}\n\nfunction getWebSocketServerOptions(config: ZeroConfig): ServerOptions {\n const options: ServerOptions = {\n noServer: true,\n maxPayload: config.websocketMaxPayloadBytes,\n };\n\n if (config.websocketCompression) {\n options.perMessageDeflate = true;\n\n if (config.websocketCompressionOptions) {\n try {\n const compressionOptions = JSON.parse(\n config.websocketCompressionOptions,\n );\n options.perMessageDeflate = compressionOptions;\n } catch (e) {\n throw new Error(\n `Failed to parse ZERO_WEBSOCKET_COMPRESSION_OPTIONS: ${String(e)}. Expected valid JSON.`,\n );\n }\n }\n }\n\n return options;\n}\n\n/**\n * The Syncer worker receives websocket handoffs for \"/sync\" connections\n * from the Dispatcher in the main thread, and creates websocket\n * {@link Connection}s with a corresponding {@link ViewSyncer}, {@link Mutagen},\n * and {@link Subscription} to version notifications from the Replicator\n * worker.\n */\nexport class Syncer implements SingletonService {\n readonly id = `syncer-${pid}`;\n readonly #lc: LogContext;\n readonly #viewSyncers: ServiceRunner<ViewSyncer & ActivityBasedService>;\n readonly #mutagens: ServiceRunner<Mutagen & Service> | undefined;\n readonly #pushers: ServiceRunner<Pusher & Service> | undefined;\n readonly #connections = new Map<string, Connection>();\n readonly #drainCoordinator = new DrainCoordinator();\n readonly #parent: Worker;\n readonly #wss: WebSocketServer;\n readonly #stopped = resolver();\n readonly #config: ZeroConfig;\n readonly #validateLegacyJWT: ValidateLegacyJWT | undefined;\n readonly #replicaReadyStates: ReplicaReadyState[] = [];\n readonly #webSocketOpenConnections = getOrCreateUpDownCounter(\n 'sync',\n 'websocket.open_connections',\n 'Open client WebSocket connections.',\n );\n readonly #webSocketConnectionAttempts = getOrCreateCounter(\n 'sync',\n 'websocket.connection_attempts',\n 'Client WebSocket connection attempts.',\n );\n readonly #webSocketConnectionSuccesses = getOrCreateCounter(\n 'sync',\n 'websocket.connection_successes',\n 'Client WebSocket connections successfully initialized.',\n );\n readonly #webSocketConnectionFailures = getOrCreateCounter(\n 'sync',\n 'websocket.connection_failures',\n 'Client WebSocket connection attempts that failed before initialization.',\n );\n readonly #viewSyncerLag = getOrCreateNativeHistogram(\n 'sync',\n 'view_syncer_lag',\n {\n description:\n 'Lag from replica-ready change to ViewSyncer output for active ' +\n 'client groups. A change is output after IVM advancement, CVR flush, ' +\n 'and pokeEnd.',\n unit: 's',\n },\n );\n #servingLagDistributionCache: ServingLagDistribution | undefined;\n #servingLagDistributionCacheClearQueued = false;\n #viewSyncerLagSampleInterval: ReturnType<typeof setInterval> | undefined;\n #eligibilityLogInterval: ReturnType<typeof setInterval> | undefined;\n\n constructor(\n lc: LogContext,\n config: ZeroConfig,\n viewSyncerFactory: (\n id: string,\n sub: Subscription<ReplicaState>,\n drainCoordinator: DrainCoordinator,\n ) => ViewSyncer & ActivityBasedService,\n mutagenFactory: ((id: string) => Mutagen & Service) | undefined,\n pusherFactory:\n | ((\n id: string,\n connContextManager: ConnectionContextManager,\n ) => Pusher & Service)\n | undefined,\n parent: Worker,\n validateLegacyJWT: ValidateLegacyJWT | undefined,\n ) {\n this.#config = config;\n this.#validateLegacyJWT = validateLegacyJWT;\n // Relays notifications from the parent thread subscription\n // to ViewSyncers within this thread.\n const notifier = createNotifierFrom(lc, parent, state =>\n this.#recordReplicaReadyState(state),\n );\n subscribeTo(lc, parent);\n\n this.#lc = lc;\n this.#viewSyncers = new ServiceRunner(\n lc,\n id => viewSyncerFactory(id, notifier.subscribe(), this.#drainCoordinator),\n v => v.keepalive(),\n );\n if (mutagenFactory) {\n this.#mutagens = new ServiceRunner(lc, mutagenFactory, m => m.hasRefs());\n }\n if (pusherFactory) {\n this.#pushers = new ServiceRunner(\n lc,\n id =>\n pusherFactory(\n id,\n this.#viewSyncers.getService(id).connContextManager,\n ),\n p => p.hasRefs(),\n );\n }\n this.#parent = parent;\n this.#wss = new WebSocketServer(getWebSocketServerOptions(config));\n\n installWebSocketReceiver(\n lc,\n this.#wss,\n this.#createConnection,\n this.#parent,\n );\n\n setActiveClientGroupsGetter(() => this.#viewSyncers.size);\n\n getOrCreateGauge(\n 'sync',\n 'active-client-groups',\n 'Number of active client groups',\n ).addCallback(result => result.observe(this.#viewSyncers.size));\n\n getOrCreateGauge(\n 'sync',\n 'queries',\n 'Active queries (pipelines) across all client groups',\n ).addCallback(result => {\n let total = 0;\n for (const vs of this.#viewSyncers.getServices()) {\n total += vs.queryCount;\n }\n result.observe(total);\n });\n\n getOrCreateGauge(\n 'sync',\n 'rows',\n 'Tracked rows across all client groups',\n ).addCallback(result => {\n let total = 0;\n for (const vs of this.#viewSyncers.getServices()) {\n total += vs.rowCount;\n }\n result.observe(total);\n });\n\n getOrCreateGauge('sync', 'serving_lag', {\n description:\n 'Maximum time active ViewSyncer client groups have had unserved ' +\n 'replica changes. A change is served after IVM advancement, CVR flush, ' +\n 'and pokeEnd.',\n unit: 'millisecond',\n }).addCallback(result => {\n result.observe(this.#computeServingLagDistribution().maxMs);\n });\n\n getOrCreateGauge('sync', 'serving_lag_stats', {\n description:\n 'Distribution of time active ViewSyncer client groups have had ' +\n 'unserved replica changes. A change is served after IVM advancement, ' +\n 'CVR flush, and pokeEnd.',\n unit: 'millisecond',\n }).addCallback(result => {\n const stats = this.#computeServingLagDistribution();\n result.observe(stats.minMs, {stat: 'min'});\n result.observe(stats.p50Ms, {stat: 'p50'});\n result.observe(stats.p75Ms, {stat: 'p75'});\n result.observe(stats.p99Ms, {stat: 'p99'});\n result.observe(stats.maxMs, {stat: 'max'});\n });\n\n getOrCreateGauge(\n 'sync',\n 'serving_lagging_client_groups',\n 'Number of active client groups with unserved replica changes',\n ).addCallback(result => {\n result.observe(this.#computeServingLagDistribution().laggingClientGroups);\n });\n\n getOrCreateGauge(\n 'sync',\n 'pipelines_total',\n 'Query pipelines across all client groups on this worker, by query ' +\n 'type. The ratio to sync.pipelines_unique is the dedup factor ' +\n 'available to shared pipeline advancement.',\n ).addCallback(result => {\n const stats = this.#computePipelineDedupStats();\n result.observe(stats.clientTotal, {type: 'client'});\n result.observe(stats.internalTotal, {type: 'internal'});\n });\n\n getOrCreateGauge(\n 'sync',\n 'pipelines_unique',\n 'Distinct (clientSchema, transformationHash) pipelines across all ' +\n 'client groups on this worker, by query type',\n ).addCallback(result => {\n const stats = this.#computePipelineDedupStats();\n result.observe(stats.clientHashes.size, {type: 'client'});\n result.observe(stats.internalUnique, {type: 'internal'});\n });\n\n getOrCreateGauge(\n 'sync',\n 'client_schemas',\n 'Distinct client schemas across all client groups on this worker',\n ).addCallback(result => {\n result.observe(this.#computePipelineDedupStats().clientSchemas);\n });\n\n this.#viewSyncerLagSampleInterval = setInterval(\n () => this.#recordViewSyncerLagSamples(),\n VIEW_SYNCER_LAG_SAMPLE_INTERVAL_MS,\n );\n this.#viewSyncerLagSampleInterval.unref?.();\n\n this.#eligibilityLogInterval = setInterval(\n () => this.#logSharedAdvanceEligibility(),\n SHARED_ADVANCE_ELIGIBILITY_LOG_INTERVAL_MS,\n );\n this.#eligibilityLogInterval.unref?.();\n }\n\n #computeServingLagDistribution(): ServingLagDistribution {\n if (this.#servingLagDistributionCache) {\n return this.#servingLagDistributionCache;\n }\n\n const distribution = computeServingLagDistributionMs(\n Date.now(),\n this.#replicaReadyStates,\n this.#viewSyncers.getServices(),\n );\n this.#servingLagDistributionCache = distribution;\n if (!this.#servingLagDistributionCacheClearQueued) {\n this.#servingLagDistributionCacheClearQueued = true;\n queueMicrotask(() => {\n this.#servingLagDistributionCache = undefined;\n this.#servingLagDistributionCacheClearQueued = false;\n });\n }\n return distribution;\n }\n\n #recordViewSyncerLagSamples(): void {\n const {lagsMs} = this.#computeServingLagDistribution();\n for (const lagMs of lagsMs) {\n this.#viewSyncerLag.recordMs(lagMs);\n }\n }\n\n #computePipelineDedupStats(): PipelineDedupStats {\n return computePipelineDedupStats(this.#viewSyncers.getServices());\n }\n\n #logSharedAdvanceEligibility(): void {\n const stats = this.#computePipelineDedupStats();\n if (stats.clientTotal === 0) {\n return;\n }\n const unique = stats.clientHashes.size;\n const topDuplicated = [...stats.clientHashes.entries()]\n .filter(([, {count}]) => count > 1)\n .sort((a, b) => b[1].count - a[1].count)\n .slice(0, 10)\n .map(([key, {count, queryName}]) => ({key, count, queryName}));\n this.#lc.info?.('shared-advance-eligibility', {\n clientPipelines: stats.clientTotal,\n uniqueClientPipelines: unique,\n dedupFactor: Number((stats.clientTotal / Math.max(unique, 1)).toFixed(2)),\n internalPipelines: stats.internalTotal,\n clientSchemas: stats.clientSchemas,\n topDuplicated,\n });\n }\n\n #recordReplicaReadyState(state: ReplicaState): void {\n if (\n state.watermark === undefined ||\n state.replicaReadyTimeMs === undefined\n ) {\n return;\n }\n const last = this.#replicaReadyStates.at(-1);\n if (last && last.watermark >= state.watermark) {\n return;\n }\n this.#replicaReadyStates.push({\n watermark: state.watermark,\n replicaReadyTimeMs: state.replicaReadyTimeMs,\n });\n if (this.#viewSyncers.size === 0) {\n this.#replicaReadyStates.length = 0;\n return;\n }\n boundReplicaReadyStates(this.#replicaReadyStates);\n }\n\n readonly #createConnection = async (ws: WebSocket, params: ConnectParams) => {\n const metricAttributes = {\n [PROTOCOL_VERSION_ATTRIBUTE]: params.protocolVersion,\n };\n let connectionResultRecorded = false;\n const recordConnectionFailure = (reason: string) => {\n if (connectionResultRecorded) {\n return;\n }\n connectionResultRecorded = true;\n this.#webSocketConnectionFailures.add(1, {\n ...metricAttributes,\n [FAILURE_REASON_ATTRIBUTE]: reason,\n });\n };\n const recordConnectionSuccessMetric = () => {\n if (connectionResultRecorded) {\n return;\n }\n connectionResultRecorded = true;\n this.#webSocketConnectionSuccesses.add(1, metricAttributes);\n };\n\n this.#webSocketConnectionAttempts.add(1, metricAttributes);\n this.#webSocketOpenConnections.add(1, metricAttributes);\n ws.once('close', () => {\n this.#webSocketOpenConnections.add(-1, metricAttributes);\n });\n\n this.#lc.debug?.(\n 'creating connection',\n params.clientGroupID,\n params.clientID,\n );\n recordConnectionAttempted();\n const {clientID, clientGroupID, auth, userID} = params;\n const hasProvidedAuth = auth !== undefined && auth !== '';\n const incomingUserID = userID ?? null;\n\n if (hasProvidedAuth) {\n const tokenOptions = tokenConfigOptions(this.#config.auth ?? {});\n\n const hasPushOrMutate =\n this.#config?.push?.url !== undefined ||\n this.#config?.mutate?.url !== undefined;\n const hasQueries =\n this.#config?.query?.url !== undefined ||\n this.#config?.getQueries?.url !== undefined;\n\n // must either have one of the token options set or have custom mutations & queries enabled\n const hasExactlyOneTokenOption = tokenOptions.length === 1;\n const hasCustomEndpoints = hasPushOrMutate && hasQueries;\n if (!hasExactlyOneTokenOption && !hasCustomEndpoints) {\n recordConnectionFailure('configuration');\n throw new Error(\n 'Exactly one of jwk, secret, or jwksUrl must be set in order to verify tokens but actually the following were set: ' +\n JSON.stringify(tokenOptions) +\n '. You may also set both ZERO_MUTATE_URL and ZERO_QUERY_URL to enable custom mutations and queries without passing token verification options.',\n );\n }\n }\n\n let initialAuth: Auth | undefined;\n\n // Verify JWT BEFORE touching existing connections - prevents unauthenticated\n // attackers from force-disconnecting legitimate users via DoS.\n try {\n initialAuth = await resolveAuth(\n this.#lc\n .withContext('clientGroupID', clientGroupID)\n .withContext('clientID', clientID),\n // no previous auth, since this is a new connection, and resolveAuth is\n // connection scoped, not client group scoped\n undefined,\n incomingUserID,\n auth,\n this.#validateLegacyJWT,\n );\n } catch (e) {\n if (isProtocolError(e)) {\n this.#lc.warn?.(\n 'Rejecting sync connection during initial auth resolution',\n {\n clientGroupID,\n clientID,\n incomingUserID,\n hasProvidedAuth,\n errorKind: e.message,\n },\n );\n recordConnectionFailure('auth');\n sendError(this.#lc, ws, e.errorBody);\n ws.close(3000, e.errorBody.message);\n return;\n }\n recordConnectionFailure('internal');\n throw e;\n }\n\n // Resolving auth can take a while (e.g. a remote JWKS fetch), during\n // which the client may have disconnected. A Connection created for a\n // closed socket never receives the 'close' event and is thus never\n // cleaned up, leaking its timer, its registrations in the connection\n // context manager and its refs on the mutagen and pusher services.\n if (ws.readyState !== WebSocket.OPEN) {\n this.#lc.debug?.('websocket closed while resolving auth', {\n clientGroupID,\n clientID,\n readyState: ws.readyState,\n });\n recordConnectionFailure('closed_during_auth');\n return;\n }\n\n const viewSyncer = this.#viewSyncers.getService(clientGroupID);\n const connContextManager = viewSyncer.connContextManager;\n const group = connContextManager.getGroupState();\n\n // TODO(0xcadams): we only check for user ID mismatch here if the group is\n // already validated. This prevents wrong-user reconnects from evicting a\n // healthy connection, but it does not protect against same-user reconnects\n // with an invalid opaque token. The long-term fix is to keep the replacement\n // connection pending until its auth is fully validated, and only then replace\n // the existing socket.\n if (\n group.pinnedUser !== undefined &&\n group.pinnedUser.id !== incomingUserID\n ) {\n const error = new ProtocolError({\n kind: ErrorKind.Unauthorized,\n message:\n 'Client groups are pinned to a single userID. Connection userID does not match existing client group userID.',\n origin: ErrorOrigin.ZeroCache,\n });\n recordConnectionFailure('user_mismatch');\n sendError(this.#lc, ws, error.errorBody);\n ws.close(3000, error.message);\n return;\n }\n\n // Check for and close existing connections AFTER auth is validated\n const existing = this.#connections.get(clientID);\n if (existing) {\n this.#lc.debug?.(\n `client ${clientID} already connected, closing existing connection`,\n );\n existing.close(`replaced by ${params.wsID}`);\n }\n\n connContextManager.registerConnection(\n {clientID, wsID: params.wsID},\n params,\n initialAuth,\n );\n\n const mutagen = this.#mutagens?.getService(clientGroupID);\n const pusher = this.#pushers?.getService(clientGroupID);\n // a new connection is using the mutagen and pusher. Bump their ref counts.\n mutagen?.ref();\n pusher?.ref();\n\n let connection: Connection;\n try {\n connection = new Connection(\n this.#lc,\n params,\n ws,\n this.#config.allowLegacyQueries,\n new SyncerWsMessageHandler(\n this.#lc,\n params,\n connContextManager,\n viewSyncer,\n mutagen,\n pusher,\n ),\n () => {\n connContextManager.closeConnection({\n clientID,\n wsID: params.wsID,\n });\n if (this.#connections.get(clientID) === connection) {\n this.#connections.delete(clientID);\n }\n // Connection is closed. We can unref the mutagen and pusher.\n // If their ref counts are zero, they will stop themselves and set themselves invalid.\n mutagen?.unref();\n pusher?.unref();\n },\n );\n } catch (e) {\n recordConnectionFailure('internal');\n connContextManager.closeConnection({clientID, wsID: params.wsID});\n mutagen?.unref();\n pusher?.unref();\n throw e;\n }\n\n this.#connections.set(clientID, connection);\n\n if (connection.init()) {\n recordConnectionSuccessMetric();\n recordConnectionSuccess();\n } else {\n recordConnectionFailure('protocol_version');\n }\n\n if (params.initConnectionMsg) {\n this.#lc.debug?.(\n 'handling init connection message from sec header',\n params.clientGroupID,\n params.clientID,\n );\n await connection.handleInitConnection(\n JSON.stringify(params.initConnectionMsg),\n );\n }\n };\n\n run() {\n return this.#stopped.promise;\n }\n\n /**\n * Graceful shutdown involves shutting down view syncers one at a time, pausing\n * for the duration of view syncer's hydration between each one. This paces the\n * disconnects to avoid creating a backlog of hydrations in the receiving server\n * when the clients reconnect.\n */\n async drain() {\n const start = Date.now();\n this.#lc.info?.(`draining ${this.#viewSyncers.size} view-syncers`);\n\n this.#drainCoordinator.drainNextIn(0);\n\n while (this.#viewSyncers.size) {\n await this.#drainCoordinator.forceDrainTimeout;\n\n // Pick an arbitrary view syncer to force drain.\n for (const vs of this.#viewSyncers.getServices()) {\n this.#lc.debug?.(`draining view-syncer ${vs.id} (forced)`);\n // When this drain or an elective drain completes, the forceDrainTimeout will\n // resolve after the next drain interval.\n void vs.stop();\n break;\n }\n }\n this.#lc.info?.(`finished draining (${Date.now() - start} ms)`);\n }\n\n stop() {\n clearInterval(this.#viewSyncerLagSampleInterval);\n clearInterval(this.#eligibilityLogInterval);\n this.#wss.close();\n this.#stopped.resolve();\n return promiseVoid;\n }\n}\n"],"mappings":"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"}
|