@rocicorp/zero 1.12.0-canary.12 → 1.12.0-canary.13
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/replicache/src/btree/diff.d.ts.map +1 -1
- package/out/replicache/src/btree/diff.js +1 -2
- package/out/replicache/src/btree/diff.js.map +1 -1
- package/out/replicache/src/btree/read.d.ts +31 -1
- package/out/replicache/src/btree/read.d.ts.map +1 -1
- package/out/replicache/src/btree/read.js +74 -19
- package/out/replicache/src/btree/read.js.map +1 -1
- package/out/replicache/src/btree/splice.d.ts.map +1 -1
- package/out/replicache/src/btree/splice.js +2 -1
- package/out/replicache/src/btree/splice.js.map +1 -1
- package/out/replicache/src/btree/write.d.ts.map +1 -1
- package/out/replicache/src/btree/write.js +2 -1
- package/out/replicache/src/btree/write.js.map +1 -1
- package/out/replicache/src/persist/collect-idb-databases.d.ts +1 -0
- package/out/replicache/src/persist/collect-idb-databases.d.ts.map +1 -1
- package/out/replicache/src/persist/collect-idb-databases.js +13 -5
- package/out/replicache/src/persist/collect-idb-databases.js.map +1 -1
- package/out/replicache/src/scan-iterator.js +1 -1
- package/out/replicache/src/sync/request-id.js +2 -2
- package/out/replicache/src/sync/request-id.js.map +1 -1
- package/out/shared/src/bigint-json.d.ts.map +1 -1
- package/out/shared/src/bigint-json.js +1 -0
- package/out/shared/src/bigint-json.js.map +1 -1
- package/out/shared/src/btree-set.d.ts +21 -0
- package/out/shared/src/btree-set.d.ts.map +1 -1
- package/out/shared/src/btree-set.js +54 -0
- package/out/shared/src/btree-set.js.map +1 -1
- package/out/shared/src/headers.js +20 -0
- package/out/shared/src/headers.js.map +1 -0
- package/out/shared/src/natural-runs.d.ts +191 -0
- package/out/shared/src/natural-runs.d.ts.map +1 -0
- package/out/shared/src/natural-runs.js +337 -0
- package/out/shared/src/natural-runs.js.map +1 -0
- package/out/shared/src/random-uint64.d.ts.map +1 -1
- package/out/shared/src/random-uint64.js +2 -2
- package/out/shared/src/random-uint64.js.map +1 -1
- package/out/shared/src/random-values.d.ts +19 -1
- package/out/shared/src/random-values.d.ts.map +1 -1
- package/out/shared/src/random-values.js +38 -4
- package/out/shared/src/random-values.js.map +1 -1
- package/out/shared/src/tuple-map.d.ts +22 -0
- package/out/shared/src/tuple-map.d.ts.map +1 -0
- package/out/shared/src/tuple-map.js +48 -0
- package/out/shared/src/tuple-map.js.map +1 -0
- package/out/zero/package.js +1 -1
- package/out/zero/package.js.map +1 -1
- package/out/zero-cache/src/auth/auth.d.ts.map +1 -1
- package/out/zero-cache/src/auth/auth.js +5 -0
- package/out/zero-cache/src/auth/auth.js.map +1 -1
- package/out/zero-cache/src/config/zero-config.d.ts +21 -0
- package/out/zero-cache/src/config/zero-config.d.ts.map +1 -1
- package/out/zero-cache/src/config/zero-config.js +47 -9
- package/out/zero-cache/src/config/zero-config.js.map +1 -1
- package/out/zero-cache/src/custom-queries/transform-query.js +2 -2
- package/out/zero-cache/src/custom-queries/transform-query.js.map +1 -1
- package/out/zero-cache/src/db/pg-to-lite.d.ts.map +1 -1
- package/out/zero-cache/src/db/pg-to-lite.js +3 -2
- package/out/zero-cache/src/db/pg-to-lite.js.map +1 -1
- package/out/zero-cache/src/db/pg-type-parser.js +55 -2
- package/out/zero-cache/src/db/pg-type-parser.js.map +1 -1
- package/out/zero-cache/src/server/change-streamer.js +5 -2
- package/out/zero-cache/src/server/change-streamer.js.map +1 -1
- package/out/zero-cache/src/server/inspector-delegate.js +6 -29
- package/out/zero-cache/src/server/inspector-delegate.js.map +1 -1
- package/out/zero-cache/src/server/main.js +5 -0
- package/out/zero-cache/src/server/main.js.map +1 -1
- package/out/zero-cache/src/server/replicator.js +3 -2
- package/out/zero-cache/src/server/replicator.js.map +1 -1
- package/out/zero-cache/src/server/syncer.js +5 -4
- package/out/zero-cache/src/server/syncer.js.map +1 -1
- package/out/zero-cache/src/server/worker-dispatcher.d.ts.map +1 -1
- package/out/zero-cache/src/server/worker-dispatcher.js +2 -1
- package/out/zero-cache/src/server/worker-dispatcher.js.map +1 -1
- package/out/zero-cache/src/services/admin-auth.d.ts +22 -0
- package/out/zero-cache/src/services/admin-auth.d.ts.map +1 -0
- package/out/zero-cache/src/services/admin-auth.js +42 -0
- package/out/zero-cache/src/services/admin-auth.js.map +1 -0
- package/out/zero-cache/src/services/auth-attempt-counter.d.ts +33 -0
- package/out/zero-cache/src/services/auth-attempt-counter.d.ts.map +1 -0
- package/out/zero-cache/src/services/auth-attempt-counter.js +61 -0
- package/out/zero-cache/src/services/auth-attempt-counter.js.map +1 -0
- package/out/zero-cache/src/services/change-source/pg/backfill-stream.js +1 -1
- package/out/zero-cache/src/services/change-source/pg/change-source.js +110 -13
- package/out/zero-cache/src/services/change-source/pg/change-source.js.map +1 -1
- package/out/zero-cache/src/services/change-source/pg/schema/ddl.d.ts +13 -7
- package/out/zero-cache/src/services/change-source/pg/schema/ddl.d.ts.map +1 -1
- package/out/zero-cache/src/services/change-source/pg/schema/ddl.js +38 -5
- package/out/zero-cache/src/services/change-source/pg/schema/ddl.js.map +1 -1
- package/out/zero-cache/src/services/change-source/pg/schema/init.js +10 -1
- package/out/zero-cache/src/services/change-source/pg/schema/init.js.map +1 -1
- package/out/zero-cache/src/services/change-source/pg/schema/shard.d.ts +4 -0
- package/out/zero-cache/src/services/change-source/pg/schema/shard.d.ts.map +1 -1
- package/out/zero-cache/src/services/change-source/pg/schema/shard.js +10 -3
- package/out/zero-cache/src/services/change-source/pg/schema/shard.js.map +1 -1
- package/out/zero-cache/src/services/change-streamer/change-streamer-http.js +23 -2
- package/out/zero-cache/src/services/change-streamer/change-streamer-http.js.map +1 -1
- package/out/zero-cache/src/services/change-streamer/change-streamer-service.js +2 -2
- package/out/zero-cache/src/services/change-streamer/change-streamer-service.js.map +1 -1
- package/out/zero-cache/src/services/change-streamer/change-streamer.js +10 -3
- package/out/zero-cache/src/services/change-streamer/change-streamer.js.map +1 -1
- package/out/zero-cache/src/services/change-streamer/subscriber.js.map +1 -1
- package/out/zero-cache/src/services/heapz.d.ts.map +1 -1
- package/out/zero-cache/src/services/heapz.js +4 -5
- package/out/zero-cache/src/services/heapz.js.map +1 -1
- package/out/zero-cache/src/services/mutagen/error.d.ts +5 -0
- package/out/zero-cache/src/services/mutagen/error.d.ts.map +1 -1
- package/out/zero-cache/src/services/mutagen/error.js +7 -1
- package/out/zero-cache/src/services/mutagen/error.js.map +1 -1
- package/out/zero-cache/src/services/mutagen/mutagen.js +1 -1
- package/out/zero-cache/src/services/mutagen/mutagen.js.map +1 -1
- package/out/zero-cache/src/services/mutagen/pusher.js +1 -1
- package/out/zero-cache/src/services/mutagen/pusher.js.map +1 -1
- package/out/zero-cache/src/services/plannerz.d.ts.map +1 -1
- package/out/zero-cache/src/services/plannerz.js +3 -7
- package/out/zero-cache/src/services/plannerz.js.map +1 -1
- package/out/zero-cache/src/services/profz.d.ts.map +1 -1
- package/out/zero-cache/src/services/profz.js +4 -13
- package/out/zero-cache/src/services/profz.js.map +1 -1
- package/out/zero-cache/src/services/replicator/change-processor.js +29 -2
- package/out/zero-cache/src/services/replicator/change-processor.js.map +1 -1
- package/out/zero-cache/src/services/replicator/incremental-sync.js +3 -3
- package/out/zero-cache/src/services/replicator/incremental-sync.js.map +1 -1
- package/out/zero-cache/src/services/statz.d.ts.map +1 -1
- package/out/zero-cache/src/services/statz.js +2 -7
- package/out/zero-cache/src/services/statz.js.map +1 -1
- package/out/zero-cache/src/services/view-syncer/client-handler.js +22 -1
- package/out/zero-cache/src/services/view-syncer/client-handler.js.map +1 -1
- package/out/zero-cache/src/services/view-syncer/connection-context-manager.js +40 -2
- package/out/zero-cache/src/services/view-syncer/connection-context-manager.js.map +1 -1
- package/out/zero-cache/src/services/view-syncer/cvr-store.js +54 -17
- package/out/zero-cache/src/services/view-syncer/cvr-store.js.map +1 -1
- package/out/zero-cache/src/services/view-syncer/cvr.js +11 -0
- package/out/zero-cache/src/services/view-syncer/cvr.js.map +1 -1
- package/out/zero-cache/src/services/view-syncer/inspect-auth-limiter.js +107 -0
- package/out/zero-cache/src/services/view-syncer/inspect-auth-limiter.js.map +1 -0
- package/out/zero-cache/src/services/view-syncer/inspect-handler.js +11 -6
- package/out/zero-cache/src/services/view-syncer/inspect-handler.js.map +1 -1
- package/out/zero-cache/src/services/view-syncer/pipeline-driver.js +2 -1
- package/out/zero-cache/src/services/view-syncer/pipeline-driver.js.map +1 -1
- package/out/zero-cache/src/services/view-syncer/row-record-cache.js +16 -14
- package/out/zero-cache/src/services/view-syncer/row-record-cache.js.map +1 -1
- package/out/zero-cache/src/services/view-syncer/schema/cvr.js +4 -1
- package/out/zero-cache/src/services/view-syncer/schema/cvr.js.map +1 -1
- package/out/zero-cache/src/services/view-syncer/schema/init.js +3 -0
- package/out/zero-cache/src/services/view-syncer/schema/init.js.map +1 -1
- package/out/zero-cache/src/services/view-syncer/view-syncer.js +89 -51
- package/out/zero-cache/src/services/view-syncer/view-syncer.js.map +1 -1
- package/out/zero-cache/src/types/client-group-log-id.d.ts +9 -0
- package/out/zero-cache/src/types/client-group-log-id.d.ts.map +1 -0
- package/out/zero-cache/src/types/client-group-log-id.js +16 -0
- package/out/zero-cache/src/types/client-group-log-id.js.map +1 -0
- package/out/zero-cache/src/types/lite.d.ts.map +1 -1
- package/out/zero-cache/src/types/lite.js +1 -0
- package/out/zero-cache/src/types/lite.js.map +1 -1
- package/out/zero-cache/src/types/pg.d.ts +13 -2
- package/out/zero-cache/src/types/pg.d.ts.map +1 -1
- package/out/zero-cache/src/types/pg.js +2 -2
- package/out/zero-cache/src/types/pg.js.map +1 -1
- package/out/zero-cache/src/types/poke-chunk.d.ts +25 -4
- package/out/zero-cache/src/types/poke-chunk.d.ts.map +1 -1
- package/out/zero-cache/src/types/poke-chunk.js +46 -5
- package/out/zero-cache/src/types/poke-chunk.js.map +1 -1
- package/out/zero-cache/src/workers/connection.d.ts.map +1 -1
- package/out/zero-cache/src/workers/connection.js +10 -3
- package/out/zero-cache/src/workers/connection.js.map +1 -1
- package/out/zero-cache/src/workers/rehome.d.ts +10 -0
- package/out/zero-cache/src/workers/rehome.d.ts.map +1 -1
- package/out/zero-cache/src/workers/rehome.js +4 -2
- package/out/zero-cache/src/workers/rehome.js.map +1 -1
- package/out/zero-cache/src/workers/syncer-ws-message-handler.js +3 -2
- package/out/zero-cache/src/workers/syncer-ws-message-handler.js.map +1 -1
- package/out/zero-cache/src/workers/syncer.js +15 -15
- package/out/zero-cache/src/workers/syncer.js.map +1 -1
- package/out/zero-client/src/client/inspector/inspector.d.ts +4 -0
- package/out/zero-client/src/client/inspector/inspector.d.ts.map +1 -1
- package/out/zero-client/src/client/inspector/inspector.js +4 -0
- package/out/zero-client/src/client/inspector/inspector.js.map +1 -1
- package/out/zero-client/src/client/inspector/lazy-inspector.d.ts +10 -2
- package/out/zero-client/src/client/inspector/lazy-inspector.d.ts.map +1 -1
- package/out/zero-client/src/client/inspector/lazy-inspector.js +38 -31
- package/out/zero-client/src/client/inspector/lazy-inspector.js.map +1 -1
- package/out/zero-client/src/client/inspector/query.d.ts +2 -2
- package/out/zero-client/src/client/inspector/query.d.ts.map +1 -1
- package/out/zero-client/src/client/inspector/query.js +4 -4
- package/out/zero-client/src/client/inspector/query.js.map +1 -1
- package/out/zero-client/src/client/ivm-branch.d.ts +3 -0
- package/out/zero-client/src/client/ivm-branch.d.ts.map +1 -1
- package/out/zero-client/src/client/ivm-branch.js +10 -5
- package/out/zero-client/src/client/ivm-branch.js.map +1 -1
- package/out/zero-client/src/client/reconnect-backoff.d.ts +31 -0
- package/out/zero-client/src/client/reconnect-backoff.d.ts.map +1 -0
- package/out/zero-client/src/client/reconnect-backoff.js +44 -0
- package/out/zero-client/src/client/reconnect-backoff.js.map +1 -0
- package/out/zero-client/src/client/version.js +1 -1
- package/out/zero-client/src/client/zero.d.ts.map +1 -1
- package/out/zero-client/src/client/zero.js +7 -0
- package/out/zero-client/src/client/zero.js.map +1 -1
- package/out/zero-client/src/util/nanoid.js +2 -2
- package/out/zero-client/src/util/nanoid.js.map +1 -1
- package/out/zero-protocol/src/up.d.ts +42 -42
- package/out/zero-server/src/process-mutations.d.ts +10 -0
- package/out/zero-server/src/process-mutations.d.ts.map +1 -1
- package/out/zero-server/src/process-mutations.js +39 -6
- package/out/zero-server/src/process-mutations.js.map +1 -1
- package/out/zql/src/ivm/memory-source.d.ts.map +1 -1
- package/out/zql/src/ivm/memory-source.js +89 -6
- package/out/zql/src/ivm/memory-source.js.map +1 -1
- package/out/zql/src/planner/planner-connection.d.ts.map +1 -1
- package/out/zql/src/planner/planner-connection.js +37 -3
- package/out/zql/src/planner/planner-connection.js.map +1 -1
- package/package.json +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"pusher.js","names":["MutationType.Custom","ErrorKind.PushFailed","ErrorOrigin.ZeroCache","ErrorReason.HTTP","ErrorOrigin.Server","ErrorReason.UnsupportedPushVersion","ErrorReason.Internal","ErrorReason.OutOfOrderMutation"],"sources":["../../../../../../zero-cache/src/services/mutagen/pusher.ts"],"sourcesContent":["import {context, propagation, ROOT_CONTEXT} from '@opentelemetry/api';\nimport type {LogContext} from '@rocicorp/logger';\nimport {groupBy, newArray} from '../../../../shared/src/arrays.ts';\nimport {assert} from '../../../../shared/src/asserts.ts';\nimport {getErrorMessage} from '../../../../shared/src/error.ts';\nimport {getOrInsertComputed} from '../../../../shared/src/map.ts';\nimport {must} from '../../../../shared/src/must.ts';\nimport {Queue} from '../../../../shared/src/queue.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 {ErrorReason} from '../../../../zero-protocol/src/error-reason.ts';\nimport {\n isProtocolError,\n type PushFailedBody,\n} from '../../../../zero-protocol/src/error.ts';\nimport {\n mutateResponseSchema,\n type MutateResponse,\n} from '../../../../zero-protocol/src/mutate-server.ts';\nimport type {MutationID} from '../../../../zero-protocol/src/mutation-id.ts';\nimport * as MutationType from '../../../../zero-protocol/src/mutation-type-enum.ts';\nimport {CLEANUP_RESULTS_MUTATION_NAME} from '../../../../zero-protocol/src/mutation.ts';\nimport {type PushBody} from '../../../../zero-protocol/src/push.ts';\nimport {authEquals, isAuthErrorBody} from '../../auth/auth.ts';\nimport {type ZeroConfig} from '../../config/zero-config.ts';\nimport {fetchFromAPIServer} from '../../custom/fetch.ts';\nimport {getOrCreateCounter} from '../../observability/metrics.ts';\nimport {recordMutation} from '../../server/anonymous-otel-start.ts';\nimport {ProtocolErrorWithLevel} from '../../types/error-with-level.ts';\nimport type {Source} from '../../types/streams.ts';\nimport {Subscription} from '../../types/subscription.ts';\nimport type {HandlerResult, StreamResult} from '../../workers/connection.ts';\nimport type {RefCountedService, Service} from '../service.ts';\nimport type {\n ConnectionContext,\n ConnectionContextManager,\n ConnectionSelector,\n} from '../view-syncer/connection-context-manager.ts';\n\nexport interface Pusher extends RefCountedService {\n initConnection(selector: ConnectionSelector): Source<Downstream>;\n enqueuePush(selector: ConnectionSelector, push: PushBody): HandlerResult;\n ackMutationResponses(\n requester: ConnectionSelector,\n upToID: MutationID,\n ): Promise<void>;\n deleteClientMutations(\n requester: ConnectionSelector,\n clientIDs: string[],\n ): Promise<void>;\n}\n\ntype Config = Pick<ZeroConfig, 'app' | 'shard'>;\n\n/**\n * Receives push messages from zero-client and forwards\n * them the the user's API server.\n *\n * If the user's API server is taking too long to process\n * the push, the PusherService will add the push to a queue\n * and send pushes in bulk the next time the user's API server\n * is available.\n *\n * - One PusherService exists per client group.\n * - Mutations for a given client are always sent in-order\n * - Mutations for different clients in the same group may be interleaved\n */\nexport class PusherService implements Service, Pusher {\n readonly id: string;\n readonly #connContextManager: ConnectionContextManager;\n readonly #pusher: PushWorker;\n readonly #queue: Queue<PusherEntryOrStop>;\n readonly #config: Config;\n readonly #lc: LogContext;\n #stopped: Promise<void> | undefined;\n #refCount = 0;\n #isStopped = false;\n\n constructor(\n appConfig: Config,\n lc: LogContext,\n clientGroupID: string,\n connContextManager: ConnectionContextManager,\n ) {\n this.#connContextManager = connContextManager;\n this.#config = appConfig;\n this.#lc = lc.withContext('component', 'pusherService');\n this.#queue = new Queue();\n this.#pusher = new PushWorker(\n appConfig,\n lc,\n this.#connContextManager,\n this.#queue,\n );\n this.id = clientGroupID;\n }\n\n initConnection(selector: ConnectionSelector): Source<Downstream> {\n return this.#pusher.initConnection(selector);\n }\n\n enqueuePush(\n selector: ConnectionSelector,\n push: PushBody,\n ): Exclude<HandlerResult, StreamResult> {\n const connCtx = this.#connContextManager.getConnectionContext(selector);\n if (connCtx) {\n this.#pusher.enqueuePush(connCtx, push);\n }\n\n return {\n type: 'ok',\n };\n }\n\n async ackMutationResponses(\n requester: ConnectionSelector,\n upToID: MutationID,\n ): Promise<void> {\n const connCtx = this.#connContextManager.getConnectionContext(requester);\n if (!connCtx?.mutateContext?.url) {\n // No push URL configured, skip cleanup\n return;\n }\n\n const cleanupBody: PushBody = {\n clientGroupID: this.id,\n mutations: [\n {\n type: MutationType.Custom,\n id: 0, // Not tracked - this is fire-and-forget\n clientID: upToID.clientID,\n name: CLEANUP_RESULTS_MUTATION_NAME,\n args: [\n {\n type: 'single',\n clientGroupID: this.id,\n clientID: upToID.clientID,\n upToMutationID: upToID.id,\n },\n ],\n timestamp: Date.now(),\n },\n ],\n pushVersion: 1,\n timestamp: Date.now(),\n requestID: `cleanup-${this.id}-${upToID.clientID}-${upToID.id}`,\n };\n\n try {\n await fetchFromAPIServer(\n mutateResponseSchema,\n 'push',\n this.#lc,\n connCtx,\n {appID: this.#config.app.id, shardNum: this.#config.shard.num},\n cleanupBody,\n {operation: 'cleanup', cleanupType: 'single'},\n );\n } catch (e) {\n this.#lc.warn?.('Failed to send cleanup mutation', {\n error: getErrorMessage(e),\n });\n }\n }\n\n /**\n * Bulk cleanup is routed through the requester's push context.\n *\n * This assumes the client group shares a compatible push endpoint/auth\n * context.\n */\n async deleteClientMutations(\n requester: ConnectionSelector,\n clientIDs: string[],\n ): Promise<void> {\n if (clientIDs.length === 0) {\n return;\n }\n\n const connCtx = this.#connContextManager.getConnectionContext(requester);\n if (!connCtx?.mutateContext?.url) {\n // No push URL configured, skip cleanup\n return;\n }\n\n const cleanupBody: PushBody = {\n clientGroupID: this.id,\n mutations: [\n {\n type: MutationType.Custom,\n id: 0, // Not tracked - this is fire-and-forget\n clientID: clientIDs[0], // Use first client as sender\n name: CLEANUP_RESULTS_MUTATION_NAME,\n args: [\n {\n type: 'bulk',\n clientGroupID: this.id,\n clientIDs,\n },\n ],\n timestamp: Date.now(),\n },\n ],\n pushVersion: 1,\n timestamp: Date.now(),\n requestID: `cleanup-bulk-${this.id}-${Date.now()}`,\n };\n\n try {\n await fetchFromAPIServer(\n mutateResponseSchema,\n 'push',\n this.#lc,\n connCtx,\n {appID: this.#config.app.id, shardNum: this.#config.shard.num},\n cleanupBody,\n {operation: 'cleanup', cleanupType: 'bulk'},\n );\n } catch (e) {\n this.#lc.warn?.('Failed to send bulk cleanup mutation', {\n error: getErrorMessage(e),\n });\n }\n }\n\n ref() {\n assert(!this.#isStopped, 'PusherService is already stopped');\n ++this.#refCount;\n }\n\n unref() {\n assert(!this.#isStopped, 'PusherService is already stopped');\n --this.#refCount;\n if (this.#refCount <= 0) {\n void this.stop();\n }\n }\n\n hasRefs(): boolean {\n return this.#refCount > 0;\n }\n\n run(): Promise<void> {\n this.#stopped = this.#pusher.run();\n return this.#stopped;\n }\n\n stop(): Promise<void> {\n if (this.#isStopped) {\n return must(this.#stopped, 'Stop was called before `run`');\n }\n this.#isStopped = true;\n this.#queue.enqueue('stop');\n return must(this.#stopped, 'Stop was called before `run`');\n }\n}\n\ntype PusherEntry = {\n push: PushBody;\n connCtx: ConnectionContext;\n};\ntype PusherEntryOrStop = PusherEntry | 'stop';\n\n/**\n * Awaits items in the queue then drains and sends them all\n * to the user's API server.\n */\nclass PushWorker {\n readonly #connContextManager: ConnectionContextManager;\n readonly #queue: Queue<PusherEntryOrStop>;\n readonly #lc: LogContext;\n readonly #config: Config;\n readonly #clients: Map<\n string,\n {wsID: string; downstream: Subscription<Downstream>}\n >;\n /**\n * For each client, the wsID of its socket whose push most recently failed.\n * Pushes queued behind the failed one on that socket are not sent: they\n * start past the failed mutation, so the API server could only reject them\n * as out of order. The socket is failed (or was already replaced), and the\n * client re-sends every unacknowledged mutation on its next socket.\n *\n * An entry is kept until the socket can no longer queue pushes (it closed or\n * the client reconnected) and the queue has been drained since, so it is not\n * removed while pushes from the failed socket may still be queued.\n */\n readonly #failedSockets = new Map<string, string>();\n\n readonly #customMutations = getOrCreateCounter(\n 'mutation',\n 'custom',\n 'Number of custom mutations processed',\n );\n readonly #pushes = getOrCreateCounter(\n 'mutation',\n 'pushes',\n 'Number of pushes processed by the pusher',\n );\n\n constructor(\n config: Config,\n lc: LogContext,\n connContextManager: ConnectionContextManager,\n queue: Queue<PusherEntryOrStop>,\n ) {\n this.#lc = lc.withContext('component', 'pusher');\n this.#connContextManager = connContextManager;\n this.#queue = queue;\n this.#config = config;\n this.#clients = new Map();\n }\n\n /**\n * Returns a new downstream stream if the clientID,wsID pair has not been seen before.\n * If a clientID already exists with a different wsID, that client's downstream is cancelled.\n */\n initConnection(selector: ConnectionSelector): Subscription<Downstream> {\n const existing = this.#clients.get(selector.clientID);\n if (existing && existing.wsID === selector.wsID) {\n // already initialized for this socket\n throw new Error('Connection was already initialized');\n }\n\n // client is back on a new connection\n if (existing) {\n existing.downstream.cancel();\n }\n\n const downstream = Subscription.create<Downstream>({\n cleanup: () => {\n this.#clients.delete(selector.clientID);\n },\n });\n this.#clients.set(selector.clientID, {\n wsID: selector.wsID,\n downstream,\n });\n return downstream;\n }\n\n enqueuePush(connCtx: ConnectionContext, push: PushBody) {\n this.#queue.enqueue({\n push,\n connCtx,\n });\n }\n\n async run() {\n for (;;) {\n // Failed sockets that can no longer queue pushes. Everything they queued\n // is in the next drain, so they are forgotten once it has been handled.\n const closedSockets = [...this.#failedSockets].filter(\n ([clientID, wsID]) =>\n !this.#connContextManager.getConnectionContext({clientID, wsID}),\n );\n const task = await this.#queue.dequeue();\n const rest = this.#queue.drain();\n const [pushes, terminate] = combinePushes([task, ...rest]);\n for (const push of pushes) {\n const {clientID, wsID} = push.connCtx;\n if (this.#failedSockets.get(clientID) === wsID) {\n this.#lc.debug?.(\n 'Not sending push queued behind a failed push on the same socket; the client re-sends it on its next socket',\n {clientID, wsID, mutations: push.push.mutations.length},\n );\n continue;\n }\n const parentContext = push.push.traceparent\n ? propagation.extract(ROOT_CONTEXT, {\n traceparent: push.push.traceparent,\n })\n : context.active();\n const response = await context.with(parentContext, () =>\n this.#processPush(push),\n );\n await this.#fanOutResponses(push.connCtx, response);\n }\n\n for (const [clientID, wsID] of closedSockets) {\n if (this.#failedSockets.get(clientID) === wsID) {\n this.#failedSockets.delete(clientID);\n }\n }\n\n if (terminate) {\n break;\n }\n }\n }\n\n /**\n * 1. If the entire `push` fails, we send the error to `origin`, the\n * connection the push was sent on.\n * 2. If the push succeeds, we look for any mutation failure that should cause the connection to terminate\n * and terminate `origin`.\n *\n * Only `origin` is failed, even when the push carried other clients'\n * mutations (the client group's commit chain includes other tabs' pending\n * mutations). A client tracks what it has sent per socket, so the gap a\n * failed push leaves is on `origin` alone. Other clients send their own\n * mutations on their own sockets, and see any lasting error there.\n */\n #fanOutResponses(origin: ConnectionContext, response: MutateResponse) {\n const connectionTerminations: (() => void)[] = [];\n\n // if the entire push failed, send that to the client.\n if (\n ('kind' in response && response.kind === ErrorKind.PushFailed) ||\n 'error' in response\n ) {\n this.#lc.warn?.(\n 'The server behind ZERO_MUTATE_URL returned a push error.',\n response,\n );\n const mutationIDs = response.mutationIDs ?? [];\n if (mutationIDs.length > 0) {\n // We do not resolve mutations on the client if the push fails\n // as those mutations will be retried.\n if ('error' in response) {\n // This error code path will eventually be removed when we\n // no longer support the legacy push error format.\n const pushFailedBody: PushFailedBody =\n response.error === 'http'\n ? {\n kind: ErrorKind.PushFailed,\n origin: ErrorOrigin.ZeroCache,\n reason: ErrorReason.HTTP,\n status: response.status,\n bodyPreview: response.details,\n mutationIDs,\n message: `Fetch from API server returned non-OK status ${response.status}`,\n }\n : response.error === 'unsupportedPushVersion'\n ? {\n kind: ErrorKind.PushFailed,\n origin: ErrorOrigin.Server,\n reason: ErrorReason.UnsupportedPushVersion,\n mutationIDs,\n message: `Unsupported push version`,\n }\n : {\n kind: ErrorKind.PushFailed,\n origin: ErrorOrigin.Server,\n reason: ErrorReason.Internal,\n mutationIDs,\n message:\n response.error === 'zeroPusher'\n ? response.details\n : response.error === 'unsupportedSchemaVersion'\n ? 'Unsupported schema version'\n : 'An unknown error occurred while pushing to the API server',\n };\n\n this.#failOrigin(origin, pushFailedBody);\n } else {\n this.#failOrigin(origin, response);\n }\n }\n } else {\n // Look for mutations results that should cause us to terminate the connection\n const groupedMutations = groupBy(response.mutations, m => m.id.clientID);\n for (const mutations of groupedMutations.values()) {\n let failure: PushFailedBody | undefined;\n let i = 0;\n for (; i < mutations.length; i++) {\n const m = mutations[i];\n if ('error' in m.result) {\n this.#lc.warn?.(\n 'The server behind ZERO_MUTATE_URL returned a mutation error.',\n m.result,\n );\n }\n // This error code path will eventually be removed,\n // keeping this for backwards compatibility, but the server\n // should now return a PushFailedBody with the mutationIDs\n if ('error' in m.result && m.result.error === 'oooMutation') {\n failure = {\n kind: ErrorKind.PushFailed,\n origin: ErrorOrigin.Server,\n reason: ErrorReason.OutOfOrderMutation,\n message: 'mutation was out of order',\n details: m.result.details,\n mutationIDs: mutations.map(m => ({\n clientID: m.id.clientID,\n id: m.id.id,\n })),\n };\n break;\n }\n }\n\n if (failure && i < mutations.length - 1) {\n this.#lc.warn?.(\n 'push-response contains mutations after a mutation which should fatal the connection',\n );\n }\n\n if (failure) {\n connectionTerminations.push(() => this.#failOrigin(origin, failure));\n }\n }\n }\n\n connectionTerminations.forEach(cb => cb());\n }\n\n /**\n * Fails `origin` after a push sent on it failed, and records it in\n * `#failedSockets` so the pushes it queued behind that one are not sent.\n *\n * If the client has reconnected since sending the push, the old socket is\n * already gone and the client re-sends its mutations on the new one. Failing\n * the new socket would put the client in its error state, rejecting its\n * outstanding mutations, for a failure it has already recovered from.\n */\n #failOrigin(origin: ConnectionContext, errorBody: PushFailedBody): void {\n this.#failedSockets.set(origin.clientID, origin.wsID);\n const client = this.#clients.get(origin.clientID);\n if (client?.wsID === origin.wsID) {\n this.#failDownstream(client.downstream, errorBody);\n }\n }\n\n async #processPush(entry: PusherEntry): Promise<MutateResponse> {\n this.#customMutations.add(entry.push.mutations.length, {\n clientGroupID: entry.push.clientGroupID,\n });\n this.#pushes.add(1, {\n clientGroupID: entry.push.clientGroupID,\n });\n\n // Record custom mutations for telemetry\n recordMutation('custom', entry.push.mutations.length);\n\n const url = must(\n entry.connCtx.mutateContext.url,\n 'ZERO_MUTATE_URL is not set',\n );\n\n this.#lc.debug?.(\n 'pushing to',\n url,\n 'with',\n entry.push.mutations.length,\n 'mutations',\n );\n\n let mutationIDs: MutationID[] = [];\n\n try {\n mutationIDs = entry.push.mutations.map(m => ({\n id: m.id,\n clientID: m.clientID,\n }));\n\n const response = await fetchFromAPIServer(\n mutateResponseSchema,\n 'push',\n this.#lc,\n entry.connCtx,\n {\n appID: this.#config.app.id,\n shardNum: this.#config.shard.num,\n },\n entry.push,\n {operation: 'mutate'},\n );\n if (\n ('kind' in response && response.kind === ErrorKind.PushFailed) ||\n 'error' in response\n ) {\n if (isAuthErrorBody(response)) {\n this.#lc.warn?.('Push auth failed; invalidating connection', {\n clientID: entry.connCtx.clientID,\n response: 'kind' in response ? response.message : undefined,\n });\n this.#connContextManager.failConnection(\n entry.connCtx,\n entry.connCtx.revision,\n );\n }\n return response;\n }\n // A successful push also validates this connection's current auth snapshot.\n // That lets later shared work reuse it without trusting stale credentials.\n this.#connContextManager.validateConnection(\n entry.connCtx,\n entry.connCtx.revision,\n 'kind' in response &&\n response.kind === 'MutateResponse' &&\n response?.userID !== undefined\n ? {kind: 'server-validated', validatedUserID: response.userID}\n : {kind: 'client-fallback'},\n );\n return response;\n } catch (e) {\n if (isProtocolError(e) && e.errorBody.kind === ErrorKind.PushFailed) {\n const response = {\n ...e.errorBody,\n mutationIDs,\n } as const satisfies PushFailedBody;\n if (isAuthErrorBody(response)) {\n this.#lc.warn?.('Push auth failed; invalidating connection', {\n clientID: entry.connCtx.clientID,\n response: 'kind' in response ? response.message : undefined,\n });\n this.#connContextManager.failConnection(\n entry.connCtx,\n entry.connCtx.revision,\n );\n }\n return response;\n }\n\n if (isProtocolError(e) && isAuthErrorBody(e.errorBody)) {\n // The push completed far enough for local validation to reject the\n // connection, so invalidate it and surface the result as PushFailed.\n this.#lc.warn?.('Push validation failed; invalidating connection', {\n clientID: entry.connCtx.clientID,\n response: e.message,\n });\n this.#connContextManager.failConnection(\n entry.connCtx,\n entry.connCtx.revision,\n );\n return {\n kind: ErrorKind.PushFailed,\n origin: ErrorOrigin.ZeroCache,\n reason: ErrorReason.HTTP,\n message: e.message,\n status: 401,\n mutationIDs,\n } as const satisfies PushFailedBody;\n }\n\n return {\n kind: ErrorKind.PushFailed,\n origin: ErrorOrigin.ZeroCache,\n reason: ErrorReason.Internal,\n message: `Failed to push: ${getErrorMessage(e)}`,\n mutationIDs,\n } as const satisfies PushFailedBody;\n }\n }\n\n #failDownstream(\n downstream: Subscription<Downstream>,\n errorBody: PushFailedBody,\n ): void {\n downstream.fail(new ProtocolErrorWithLevel(errorBody, 'warn'));\n }\n}\n\n/**\n * Pushes for different clients, sockets, or auth revisions could be interleaved.\n *\n * In order to batch safely, we only combine pushes from the same\n * clientID/wsID/revision snapshot.\n */\nexport function combinePushes(\n entries: readonly (PusherEntryOrStop | undefined)[],\n): [PusherEntry[], boolean] {\n const pushesByConnection = new Map<string, PusherEntry[]>();\n\n function collect() {\n const ret: PusherEntry[] = [];\n for (const entries of pushesByConnection.values()) {\n const composite: PusherEntry = {\n ...entries[0],\n push: {\n ...entries[0].push,\n mutations: [],\n },\n };\n ret.push(composite);\n for (const entry of entries) {\n assertAreCompatiblePushes(composite, entry);\n composite.push.mutations.push(...entry.push.mutations);\n }\n }\n return ret;\n }\n\n for (const entry of entries) {\n if (entry === 'stop' || entry === undefined) {\n return [collect(), true];\n }\n\n const key = `${entry.connCtx.clientID}:${entry.connCtx.wsID}:${entry.connCtx.revision}`;\n getOrInsertComputed(pushesByConnection, key, newArray).push(entry);\n }\n\n return [collect(), false] as const;\n}\n\n// These invariants should always be true for a given clientID.\n// If they are not, we have a bug in the code somewhere.\nfunction assertAreCompatiblePushes(left: PusherEntry, right: PusherEntry) {\n assert(\n left.connCtx.clientID === right.connCtx.clientID,\n 'clientID must be the same for all pushes',\n );\n assert(\n left.connCtx.wsID === right.connCtx.wsID,\n 'wsID must be the same for all pushes',\n );\n assert(\n left.connCtx.revision === right.connCtx.revision,\n 'revision must be the same for all pushes',\n );\n assert(\n authEquals(left.connCtx.auth, right.connCtx.auth),\n 'auth must be the same for all pushes with the same clientID',\n );\n assert(\n left.push.schemaVersion === right.push.schemaVersion,\n 'schemaVersion must be the same for all pushes with the same clientID',\n );\n assert(\n left.push.pushVersion === right.push.pushVersion,\n 'pushVersion must be the same for all pushes with the same clientID',\n );\n assert(\n left.connCtx.mutateContext.headerOptions.cookie ===\n right.connCtx.mutateContext.headerOptions.cookie,\n 'httpCookie must be the same for all pushes with the same clientID',\n );\n assert(\n left.connCtx.mutateContext.headerOptions.origin ===\n right.connCtx.mutateContext.headerOptions.origin,\n 'origin must be the same for all pushes with the same clientID',\n );\n assert(\n left.connCtx.user.id === right.connCtx.user.id,\n 'userID must be the same for all pushes with the same clientID',\n );\n assert(\n left.connCtx.mutateContext.url === right.connCtx.mutateContext.url,\n 'userPushURL must be the same for all pushes with the same clientID',\n );\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"pusher.js","names":["MutationType.Custom","ErrorKind.PushFailed","ErrorOrigin.ZeroCache","ErrorReason.HTTP","ErrorOrigin.Server","ErrorReason.UnsupportedPushVersion","ErrorReason.Internal","ErrorReason.OutOfOrderMutation"],"sources":["../../../../../../zero-cache/src/services/mutagen/pusher.ts"],"sourcesContent":["import {context, propagation, ROOT_CONTEXT} from '@opentelemetry/api';\nimport type {LogContext} from '@rocicorp/logger';\nimport {groupBy, newArray} from '../../../../shared/src/arrays.ts';\nimport {assert} from '../../../../shared/src/asserts.ts';\nimport {getErrorMessage} from '../../../../shared/src/error.ts';\nimport {getOrInsertComputed} from '../../../../shared/src/map.ts';\nimport {must} from '../../../../shared/src/must.ts';\nimport {Queue} from '../../../../shared/src/queue.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 {ErrorReason} from '../../../../zero-protocol/src/error-reason.ts';\nimport {\n isProtocolError,\n type PushFailedBody,\n} from '../../../../zero-protocol/src/error.ts';\nimport {\n mutateResponseSchema,\n type MutateResponse,\n} from '../../../../zero-protocol/src/mutate-server.ts';\nimport type {MutationID} from '../../../../zero-protocol/src/mutation-id.ts';\nimport * as MutationType from '../../../../zero-protocol/src/mutation-type-enum.ts';\nimport {CLEANUP_RESULTS_MUTATION_NAME} from '../../../../zero-protocol/src/mutation.ts';\nimport {type PushBody} from '../../../../zero-protocol/src/push.ts';\nimport {authEquals, isAuthErrorBody} from '../../auth/auth.ts';\nimport {type ZeroConfig} from '../../config/zero-config.ts';\nimport {fetchFromAPIServer} from '../../custom/fetch.ts';\nimport {getOrCreateCounter} from '../../observability/metrics.ts';\nimport {recordMutation} from '../../server/anonymous-otel-start.ts';\nimport {ProtocolErrorWithLevel} from '../../types/error-with-level.ts';\nimport type {Source} from '../../types/streams.ts';\nimport {Subscription} from '../../types/subscription.ts';\nimport type {HandlerResult, StreamResult} from '../../workers/connection.ts';\nimport type {RefCountedService, Service} from '../service.ts';\nimport type {\n ConnectionContext,\n ConnectionContextManager,\n ConnectionSelector,\n} from '../view-syncer/connection-context-manager.ts';\n\nexport interface Pusher extends RefCountedService {\n initConnection(selector: ConnectionSelector): Source<Downstream>;\n enqueuePush(selector: ConnectionSelector, push: PushBody): HandlerResult;\n ackMutationResponses(\n requester: ConnectionSelector,\n upToID: MutationID,\n ): Promise<void>;\n deleteClientMutations(\n requester: ConnectionSelector,\n clientIDs: string[],\n ): Promise<void>;\n}\n\ntype Config = Pick<ZeroConfig, 'app' | 'shard'>;\n\n/**\n * Receives push messages from zero-client and forwards\n * them the the user's API server.\n *\n * If the user's API server is taking too long to process\n * the push, the PusherService will add the push to a queue\n * and send pushes in bulk the next time the user's API server\n * is available.\n *\n * - One PusherService exists per client group.\n * - Mutations for a given client are always sent in-order\n * - Mutations for different clients in the same group may be interleaved\n */\nexport class PusherService implements Service, Pusher {\n readonly id: string;\n readonly #connContextManager: ConnectionContextManager;\n readonly #pusher: PushWorker;\n readonly #queue: Queue<PusherEntryOrStop>;\n readonly #config: Config;\n readonly #lc: LogContext;\n #stopped: Promise<void> | undefined;\n #refCount = 0;\n #isStopped = false;\n\n constructor(\n appConfig: Config,\n lc: LogContext,\n clientGroupID: string,\n connContextManager: ConnectionContextManager,\n ) {\n this.#connContextManager = connContextManager;\n this.#config = appConfig;\n this.#lc = lc.withContext('component', 'pusherService');\n this.#queue = new Queue();\n this.#pusher = new PushWorker(\n appConfig,\n lc,\n this.#connContextManager,\n this.#queue,\n );\n this.id = clientGroupID;\n }\n\n initConnection(selector: ConnectionSelector): Source<Downstream> {\n return this.#pusher.initConnection(selector);\n }\n\n enqueuePush(\n selector: ConnectionSelector,\n push: PushBody,\n ): Exclude<HandlerResult, StreamResult> {\n const connCtx = this.#connContextManager.getConnectionContext(selector);\n if (connCtx) {\n this.#pusher.enqueuePush(connCtx, push);\n }\n\n return {\n type: 'ok',\n };\n }\n\n async ackMutationResponses(\n requester: ConnectionSelector,\n upToID: MutationID,\n ): Promise<void> {\n const connCtx = this.#connContextManager.getConnectionContext(requester);\n if (!connCtx?.mutateContext?.url) {\n // No push URL configured, skip cleanup\n return;\n }\n\n const cleanupBody: PushBody = {\n clientGroupID: this.id,\n mutations: [\n {\n type: MutationType.Custom,\n id: 0, // Not tracked - this is fire-and-forget\n clientID: upToID.clientID,\n name: CLEANUP_RESULTS_MUTATION_NAME,\n args: [\n {\n type: 'single',\n clientGroupID: this.id,\n clientID: upToID.clientID,\n upToMutationID: upToID.id,\n },\n ],\n timestamp: Date.now(),\n },\n ],\n pushVersion: 1,\n timestamp: Date.now(),\n requestID: `cleanup-${this.id}-${upToID.clientID}-${upToID.id}`,\n };\n\n try {\n await fetchFromAPIServer(\n mutateResponseSchema,\n 'push',\n this.#lc,\n connCtx,\n {appID: this.#config.app.id, shardNum: this.#config.shard.num},\n cleanupBody,\n {operation: 'cleanup', cleanupType: 'single'},\n );\n } catch (e) {\n this.#lc.warn?.('Failed to send cleanup mutation', {\n error: getErrorMessage(e),\n });\n }\n }\n\n /**\n * Bulk cleanup is routed through the requester's push context.\n *\n * This assumes the client group shares a compatible push endpoint/auth\n * context.\n */\n async deleteClientMutations(\n requester: ConnectionSelector,\n clientIDs: string[],\n ): Promise<void> {\n if (clientIDs.length === 0) {\n return;\n }\n\n const connCtx = this.#connContextManager.getConnectionContext(requester);\n if (!connCtx?.mutateContext?.url) {\n // No push URL configured, skip cleanup\n return;\n }\n\n const cleanupBody: PushBody = {\n clientGroupID: this.id,\n mutations: [\n {\n type: MutationType.Custom,\n id: 0, // Not tracked - this is fire-and-forget\n clientID: clientIDs[0], // Use first client as sender\n name: CLEANUP_RESULTS_MUTATION_NAME,\n args: [\n {\n type: 'bulk',\n clientGroupID: this.id,\n clientIDs,\n },\n ],\n timestamp: Date.now(),\n },\n ],\n pushVersion: 1,\n timestamp: Date.now(),\n requestID: `cleanup-bulk-${this.id}-${Date.now()}`,\n };\n\n try {\n await fetchFromAPIServer(\n mutateResponseSchema,\n 'push',\n this.#lc,\n connCtx,\n {appID: this.#config.app.id, shardNum: this.#config.shard.num},\n cleanupBody,\n {operation: 'cleanup', cleanupType: 'bulk'},\n );\n } catch (e) {\n this.#lc.warn?.('Failed to send bulk cleanup mutation', {\n error: getErrorMessage(e),\n });\n }\n }\n\n ref() {\n assert(!this.#isStopped, 'PusherService is already stopped');\n ++this.#refCount;\n }\n\n unref() {\n assert(!this.#isStopped, 'PusherService is already stopped');\n --this.#refCount;\n if (this.#refCount <= 0) {\n void this.stop();\n }\n }\n\n hasRefs(): boolean {\n return this.#refCount > 0;\n }\n\n run(): Promise<void> {\n this.#stopped = this.#pusher.run();\n return this.#stopped;\n }\n\n stop(): Promise<void> {\n if (this.#isStopped) {\n return must(this.#stopped, 'Stop was called before `run`');\n }\n this.#isStopped = true;\n this.#queue.enqueue('stop');\n return must(this.#stopped, 'Stop was called before `run`');\n }\n}\n\ntype PusherEntry = {\n push: PushBody;\n connCtx: ConnectionContext;\n};\ntype PusherEntryOrStop = PusherEntry | 'stop';\n\n/**\n * Awaits items in the queue then drains and sends them all\n * to the user's API server.\n */\nclass PushWorker {\n readonly #connContextManager: ConnectionContextManager;\n readonly #queue: Queue<PusherEntryOrStop>;\n readonly #lc: LogContext;\n readonly #config: Config;\n readonly #clients: Map<\n string,\n {wsID: string; downstream: Subscription<Downstream>}\n >;\n /**\n * For each client, the wsID of its socket whose push most recently failed.\n * Pushes queued behind the failed one on that socket are not sent: they\n * start past the failed mutation, so the API server could only reject them\n * as out of order. The socket is failed (or was already replaced), and the\n * client re-sends every unacknowledged mutation on its next socket.\n *\n * An entry is kept until the socket can no longer queue pushes (it closed or\n * the client reconnected) and the queue has been drained since, so it is not\n * removed while pushes from the failed socket may still be queued.\n */\n readonly #failedSockets = new Map<string, string>();\n\n readonly #customMutations = getOrCreateCounter(\n 'mutation',\n 'custom',\n 'Number of custom mutations processed',\n );\n readonly #pushes = getOrCreateCounter(\n 'mutation',\n 'pushes',\n 'Number of pushes processed by the pusher',\n );\n\n constructor(\n config: Config,\n lc: LogContext,\n connContextManager: ConnectionContextManager,\n queue: Queue<PusherEntryOrStop>,\n ) {\n this.#lc = lc.withContext('component', 'pusher');\n this.#connContextManager = connContextManager;\n this.#queue = queue;\n this.#config = config;\n this.#clients = new Map();\n }\n\n /**\n * Returns a new downstream stream if the clientID,wsID pair has not been seen before.\n * If a clientID already exists with a different wsID, that client's downstream is cancelled.\n */\n initConnection(selector: ConnectionSelector): Subscription<Downstream> {\n const existing = this.#clients.get(selector.clientID);\n if (existing && existing.wsID === selector.wsID) {\n // already initialized for this socket\n throw new Error('Connection was already initialized');\n }\n\n // client is back on a new connection\n if (existing) {\n existing.downstream.cancel();\n }\n\n const downstream = Subscription.create<Downstream>({\n cleanup: () => {\n this.#clients.delete(selector.clientID);\n },\n });\n this.#clients.set(selector.clientID, {\n wsID: selector.wsID,\n downstream,\n });\n return downstream;\n }\n\n enqueuePush(connCtx: ConnectionContext, push: PushBody) {\n this.#queue.enqueue({\n push,\n connCtx,\n });\n }\n\n async run() {\n for (;;) {\n // Failed sockets that can no longer queue pushes. Everything they queued\n // is in the next drain, so they are forgotten once it has been handled.\n const closedSockets = [...this.#failedSockets].filter(\n ([clientID, wsID]) =>\n !this.#connContextManager.getConnectionContext({clientID, wsID}),\n );\n const task = await this.#queue.dequeue();\n const rest = this.#queue.drain();\n const [pushes, terminate] = combinePushes([task, ...rest]);\n for (const push of pushes) {\n const {clientID, wsID} = push.connCtx;\n if (this.#failedSockets.get(clientID) === wsID) {\n this.#lc.debug?.(\n 'Not sending push queued behind a failed push on the same socket; the client re-sends it on its next socket',\n {clientID, wsID, mutations: push.push.mutations.length},\n );\n continue;\n }\n const parentContext = push.push.traceparent\n ? propagation.extract(ROOT_CONTEXT, {\n traceparent: push.push.traceparent,\n })\n : context.active();\n const response = await context.with(parentContext, () =>\n this.#processPush(push),\n );\n await this.#fanOutResponses(push.connCtx, response);\n }\n\n for (const [clientID, wsID] of closedSockets) {\n if (this.#failedSockets.get(clientID) === wsID) {\n this.#failedSockets.delete(clientID);\n }\n }\n\n if (terminate) {\n break;\n }\n }\n }\n\n /**\n * 1. If the entire `push` fails, we send the error to `origin`, the\n * connection the push was sent on.\n * 2. If the push succeeds, we look for any mutation failure that should cause the connection to terminate\n * and terminate `origin`.\n *\n * Only `origin` is failed, even when the push carried other clients'\n * mutations (the client group's commit chain includes other tabs' pending\n * mutations). A client tracks what it has sent per socket, so the gap a\n * failed push leaves is on `origin` alone. Other clients send their own\n * mutations on their own sockets, and see any lasting error there.\n */\n #fanOutResponses(origin: ConnectionContext, response: MutateResponse) {\n const connectionTerminations: (() => void)[] = [];\n\n // if the entire push failed, send that to the client.\n if (\n ('kind' in response && response.kind === ErrorKind.PushFailed) ||\n 'error' in response\n ) {\n this.#lc.warn?.(\n 'The server behind ZERO_MUTATE_URL returned a push error.',\n response,\n );\n const mutationIDs = response.mutationIDs ?? [];\n if (mutationIDs.length > 0) {\n // We do not resolve mutations on the client if the push fails\n // as those mutations will be retried.\n if ('error' in response) {\n // This error code path will eventually be removed when we\n // no longer support the legacy push error format.\n const pushFailedBody: PushFailedBody =\n response.error === 'http'\n ? {\n kind: ErrorKind.PushFailed,\n origin: ErrorOrigin.ZeroCache,\n reason: ErrorReason.HTTP,\n status: response.status,\n bodyPreview: response.details,\n mutationIDs,\n message: `Fetch from API server returned non-OK status ${response.status}`,\n }\n : response.error === 'unsupportedPushVersion'\n ? {\n kind: ErrorKind.PushFailed,\n origin: ErrorOrigin.Server,\n reason: ErrorReason.UnsupportedPushVersion,\n mutationIDs,\n message: `Unsupported push version`,\n }\n : {\n kind: ErrorKind.PushFailed,\n origin: ErrorOrigin.Server,\n reason: ErrorReason.Internal,\n mutationIDs,\n message:\n response.error === 'zeroPusher'\n ? response.details\n : response.error === 'unsupportedSchemaVersion'\n ? 'Unsupported schema version'\n : 'An unknown error occurred while pushing to the API server',\n };\n\n this.#failOrigin(origin, pushFailedBody);\n } else {\n this.#failOrigin(origin, response);\n }\n }\n } else {\n // Look for mutations results that should cause us to terminate the connection\n const groupedMutations = groupBy(response.mutations, m => m.id.clientID);\n for (const mutations of groupedMutations.values()) {\n let failure: PushFailedBody | undefined;\n let i = 0;\n for (; i < mutations.length; i++) {\n const m = mutations[i];\n if ('error' in m.result) {\n this.#lc.warn?.(\n 'The server behind ZERO_MUTATE_URL returned a mutation error.',\n m.result,\n );\n }\n // This error code path will eventually be removed,\n // keeping this for backwards compatibility, but the server\n // should now return a PushFailedBody with the mutationIDs\n if ('error' in m.result && m.result.error === 'oooMutation') {\n failure = {\n kind: ErrorKind.PushFailed,\n origin: ErrorOrigin.Server,\n reason: ErrorReason.OutOfOrderMutation,\n message: 'mutation was out of order',\n details: m.result.details,\n mutationIDs: mutations.map(m => ({\n clientID: m.id.clientID,\n id: m.id.id,\n })),\n };\n break;\n }\n }\n\n if (failure && i < mutations.length - 1) {\n this.#lc.warn?.(\n 'push-response contains mutations after a mutation which should fatal the connection',\n );\n }\n\n if (failure) {\n connectionTerminations.push(() => this.#failOrigin(origin, failure));\n }\n }\n }\n\n connectionTerminations.forEach(cb => cb());\n }\n\n /**\n * Fails `origin` after a push sent on it failed, and records it in\n * `#failedSockets` so the pushes it queued behind that one are not sent.\n *\n * If the client has reconnected since sending the push, the old socket is\n * already gone and the client re-sends its mutations on the new one. Failing\n * the new socket would put the client in its error state, rejecting its\n * outstanding mutations, for a failure it has already recovered from.\n */\n #failOrigin(origin: ConnectionContext, errorBody: PushFailedBody): void {\n this.#failedSockets.set(origin.clientID, origin.wsID);\n const client = this.#clients.get(origin.clientID);\n if (client?.wsID === origin.wsID) {\n this.#failDownstream(client.downstream, errorBody);\n }\n }\n\n async #processPush(entry: PusherEntry): Promise<MutateResponse> {\n this.#customMutations.add(entry.push.mutations.length, {\n clientGroupID: entry.push.clientGroupID,\n });\n this.#pushes.add(1, {\n clientGroupID: entry.push.clientGroupID,\n });\n\n // Record custom mutations for telemetry\n recordMutation('custom', entry.push.mutations.length);\n\n const url = must(\n entry.connCtx.mutateContext.url,\n 'ZERO_MUTATE_URL is not set',\n );\n\n this.#lc.debug?.(\n 'pushing to',\n url,\n 'with',\n entry.push.mutations.length,\n 'mutations',\n );\n\n let mutationIDs: MutationID[] = [];\n\n try {\n mutationIDs = entry.push.mutations.map(m => ({\n id: m.id,\n clientID: m.clientID,\n }));\n\n const response = await fetchFromAPIServer(\n mutateResponseSchema,\n 'push',\n this.#lc,\n entry.connCtx,\n {\n appID: this.#config.app.id,\n shardNum: this.#config.shard.num,\n },\n entry.push,\n {operation: 'mutate'},\n );\n if (\n ('kind' in response && response.kind === ErrorKind.PushFailed) ||\n 'error' in response\n ) {\n if (isAuthErrorBody(response)) {\n this.#lc.warn?.('Push auth failed; invalidating connection', {\n clientID: entry.connCtx.clientID,\n response: 'kind' in response ? response.message : undefined,\n });\n this.#connContextManager.failConnection(\n entry.connCtx,\n entry.connCtx.revision,\n );\n }\n return response;\n }\n // A successful push also validates this connection's current auth snapshot.\n // That lets later shared work reuse it without trusting stale credentials.\n this.#connContextManager.validateConnection(\n entry.connCtx,\n entry.connCtx.revision,\n 'kind' in response &&\n response.kind === 'MutateResponse' &&\n response?.userID !== undefined\n ? {kind: 'server-validated', validatedUserID: response.userID}\n : {kind: 'server-unverified'},\n );\n return response;\n } catch (e) {\n if (isProtocolError(e) && e.errorBody.kind === ErrorKind.PushFailed) {\n const response = {\n ...e.errorBody,\n mutationIDs,\n } as const satisfies PushFailedBody;\n if (isAuthErrorBody(response)) {\n this.#lc.warn?.('Push auth failed; invalidating connection', {\n clientID: entry.connCtx.clientID,\n response: 'kind' in response ? response.message : undefined,\n });\n this.#connContextManager.failConnection(\n entry.connCtx,\n entry.connCtx.revision,\n );\n }\n return response;\n }\n\n if (isProtocolError(e) && isAuthErrorBody(e.errorBody)) {\n // The push completed far enough for local validation to reject the\n // connection, so invalidate it and surface the result as PushFailed.\n this.#lc.warn?.('Push validation failed; invalidating connection', {\n clientID: entry.connCtx.clientID,\n response: e.message,\n });\n this.#connContextManager.failConnection(\n entry.connCtx,\n entry.connCtx.revision,\n );\n return {\n kind: ErrorKind.PushFailed,\n origin: ErrorOrigin.ZeroCache,\n reason: ErrorReason.HTTP,\n message: e.message,\n status: 401,\n mutationIDs,\n } as const satisfies PushFailedBody;\n }\n\n return {\n kind: ErrorKind.PushFailed,\n origin: ErrorOrigin.ZeroCache,\n reason: ErrorReason.Internal,\n message: `Failed to push: ${getErrorMessage(e)}`,\n mutationIDs,\n } as const satisfies PushFailedBody;\n }\n }\n\n #failDownstream(\n downstream: Subscription<Downstream>,\n errorBody: PushFailedBody,\n ): void {\n downstream.fail(new ProtocolErrorWithLevel(errorBody, 'warn'));\n }\n}\n\n/**\n * Pushes for different clients, sockets, or auth revisions could be interleaved.\n *\n * In order to batch safely, we only combine pushes from the same\n * clientID/wsID/revision snapshot.\n */\nexport function combinePushes(\n entries: readonly (PusherEntryOrStop | undefined)[],\n): [PusherEntry[], boolean] {\n const pushesByConnection = new Map<string, PusherEntry[]>();\n\n function collect() {\n const ret: PusherEntry[] = [];\n for (const entries of pushesByConnection.values()) {\n const composite: PusherEntry = {\n ...entries[0],\n push: {\n ...entries[0].push,\n mutations: [],\n },\n };\n ret.push(composite);\n for (const entry of entries) {\n assertAreCompatiblePushes(composite, entry);\n composite.push.mutations.push(...entry.push.mutations);\n }\n }\n return ret;\n }\n\n for (const entry of entries) {\n if (entry === 'stop' || entry === undefined) {\n return [collect(), true];\n }\n\n const key = `${entry.connCtx.clientID}:${entry.connCtx.wsID}:${entry.connCtx.revision}`;\n getOrInsertComputed(pushesByConnection, key, newArray).push(entry);\n }\n\n return [collect(), false] as const;\n}\n\n// These invariants should always be true for a given clientID.\n// If they are not, we have a bug in the code somewhere.\nfunction assertAreCompatiblePushes(left: PusherEntry, right: PusherEntry) {\n assert(\n left.connCtx.clientID === right.connCtx.clientID,\n 'clientID must be the same for all pushes',\n );\n assert(\n left.connCtx.wsID === right.connCtx.wsID,\n 'wsID must be the same for all pushes',\n );\n assert(\n left.connCtx.revision === right.connCtx.revision,\n 'revision must be the same for all pushes',\n );\n assert(\n authEquals(left.connCtx.auth, right.connCtx.auth),\n 'auth must be the same for all pushes with the same clientID',\n );\n assert(\n left.push.schemaVersion === right.push.schemaVersion,\n 'schemaVersion must be the same for all pushes with the same clientID',\n );\n assert(\n left.push.pushVersion === right.push.pushVersion,\n 'pushVersion must be the same for all pushes with the same clientID',\n );\n assert(\n left.connCtx.mutateContext.headerOptions.cookie ===\n right.connCtx.mutateContext.headerOptions.cookie,\n 'httpCookie must be the same for all pushes with the same clientID',\n );\n assert(\n left.connCtx.mutateContext.headerOptions.origin ===\n right.connCtx.mutateContext.headerOptions.origin,\n 'origin must be the same for all pushes with the same clientID',\n );\n assert(\n left.connCtx.user.id === right.connCtx.user.id,\n 'userID must be the same for all pushes with the same clientID',\n );\n assert(\n left.connCtx.mutateContext.url === right.connCtx.mutateContext.url,\n 'userPushURL must be the same for all pushes with the same clientID',\n );\n}\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"plannerz.d.ts","sourceRoot":"","sources":["../../../../../zero-cache/src/services/plannerz.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAC,UAAU,EAAC,MAAM,kBAAkB,CAAC;
|
|
1
|
+
{"version":3,"file":"plannerz.d.ts","sourceRoot":"","sources":["../../../../../zero-cache/src/services/plannerz.ts"],"names":[],"mappings":"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"}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { getServerVersion } from "../config/zero-config.js";
|
|
2
2
|
import { Database } from "../../../zqlite/src/db.js";
|
|
3
|
+
import { authorizeAdminRequest } from "./admin-auth.js";
|
|
3
4
|
import { decodeSampleKinds } from "../../../zqlite/src/sqlite-stat4-sample.js";
|
|
4
5
|
import { computeZqlSpecsFromLiteSpecs, listIndexes, listTables } from "../db/lite-tables.js";
|
|
5
6
|
import { StatementRunner } from "../db/statements.js";
|
|
6
7
|
import { getReplicationState } from "./replicator/schema/replication-state.js";
|
|
7
8
|
import _usingCtx from "../../../_virtual/_@oxc-project_runtime@0.154.0/helpers/esm/usingCtx.js";
|
|
8
|
-
import auth from "basic-auth";
|
|
9
9
|
//#region ../zero-cache/src/services/plannerz.ts
|
|
10
10
|
/**
|
|
11
11
|
* How the statistics in the replica were gathered. zero-cache only ever runs
|
|
@@ -243,11 +243,7 @@ function replicaWatermark(lc, db) {
|
|
|
243
243
|
* * `stat4=full`: includes the full sqlite_stat4 histogram, which is large
|
|
244
244
|
*/
|
|
245
245
|
async function handlePlannerzRequest(lc, config, req, res) {
|
|
246
|
-
|
|
247
|
-
if (getOperatorAccess(lc, config, credentials?.pass) === void 0) {
|
|
248
|
-
res.code(401).header("WWW-Authenticate", "Basic realm=\"Plannerz Protected Area\"").send("Unauthorized");
|
|
249
|
-
return;
|
|
250
|
-
}
|
|
246
|
+
if (authorizeAdminRequest(lc, config, req, res, "Plannerz Protected Area", "operator") === void 0) return;
|
|
251
247
|
const query = req.query;
|
|
252
248
|
const indent = query.pretty !== void 0 ? 2 : void 0;
|
|
253
249
|
let bundle;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"plannerz.js","names":[],"sources":["../../../../../zero-cache/src/services/plannerz.ts"],"sourcesContent":["import type {LogContext} from '@rocicorp/logger';\nimport auth from 'basic-auth';\nimport type {FastifyReply, FastifyRequest} from 'fastify';\nimport {Database} from '../../../zqlite/src/db.ts';\nimport {\n decodeSampleKinds,\n type SampleValueKind,\n} from '../../../zqlite/src/sqlite-stat4-sample.ts';\nimport type {NormalizedZeroConfig as ZeroConfig} from '../config/normalize.ts';\nimport {getOperatorAccess, getServerVersion} from '../config/zero-config.ts';\nimport {\n computeZqlSpecsFromLiteSpecs,\n listIndexes,\n listTables,\n} from '../db/lite-tables.ts';\nimport {StatementRunner} from '../db/statements.ts';\nimport {getReplicationState} from './replicator/schema/replication-state.ts';\n\n/**\n * How the statistics in the replica were gathered. zero-cache only ever runs\n * `PRAGMA optimize`, and always under an `analysis_limit`, which samples rather\n * than scanning. A replica that someone ran `ANALYZE` on by hand has better\n * statistics, which is visible in `sqlite_stat4` having rows at all.\n */\nconst APPROXIMATE_METHOD =\n 'PRAGMA optimize under an analysis_limit. Row counts and per-key averages ' +\n 'are sampled estimates, and sqlite_stat4 is not collected.';\nconst FULL_METHOD =\n 'ANALYZE, with no analysis limit. sqlite_stat4 has rows, so a full ANALYZE ' +\n 'ran against this replica at some point.';\n\n/**\n * A fixed glossary, sent with every bundle. The audience is a language model\n * that has the user's codebase but has never seen these statistics, and would\n * otherwise read a sampled estimate as an exact count.\n */\nconst ABOUT = {\n whatThisIs:\n 'Schema, indexes and SQLite statistics for the SQLite replica that ' +\n 'zero-cache queries. Use it to decide how to rewrite ZQL queries and ' +\n 'which indexes to add. It contains no row data.',\n stat1Format:\n 'sqlite_stat1.stat is \"<rows> <avg rows per distinct 1-column prefix> ' +\n '<... per 2-column prefix> ...\", parsed here into `rows` and ' +\n '`avgRowsPerPrefix`. NULLs count as ordinary values, so a sparse column ' +\n 'looks less selective than it is. Trailing flags such as \"unordered\", ' +\n '\"noskipscan\" and \"sz=N\" are kept in `flags`.',\n stat4Format:\n 'sqlite_stat4 is a per-index histogram. For each sample, `nEq` is the ' +\n 'rows equal to it, `nLt` the rows less than it, and `nDLt` the distinct ' +\n 'values less than it, one entry per key column. A large `nEq` next to a ' +\n 'small `nDLt` means a skewed column, where the average in stat1 hides ' +\n 'the common values. Sample values are never returned: `sample` reports ' +\n 'only each key column’s type.',\n howThePlannerUsesThis: [\n 'Cost comes from SQLite scanstatus estimates for the SQL each source ' +\n 'connection would run, so the statistics here are what the planner sees.',\n 'Join fanout comes from sqlite_stat4, else sqlite_stat1, else a default ' +\n 'of 3. With no stat4 (the usual case) fanout on a nullable foreign key ' +\n 'is overestimated, because stat1 counts NULLs.',\n 'The planner chooses per join whether to run it as a semi-join or to flip ' +\n 'it so the child drives. An index that matches the child’s ' +\n 'constraint plus ordering is what makes a flip cheap.',\n ],\n whatYouCanChange: [\n 'The ZQL query: `whereExists` vs `related`, the order of conditions, and ' +\n 'ordering that matches an existing index.',\n 'A `limit`, which lets the planner stop early.',\n 'Indexes: add them in Postgres, not on the replica. The replica copies ' +\n 'upstream indexes, so a new index reaches it through replication.',\n 'The planner flags in `server.planner`.',\n ],\n caveats: [\n 'Row counts are estimates from the statistics, not COUNT(*). This ' +\n 'endpoint never scans a table.',\n 'Statistics are as of the last time they were gathered, which SQLite does ' +\n 'not record. A table whose size changed a lot since then can be far off.',\n 'Tables in `tables` with `syncable: false` cannot be queried by clients: ' +\n 'they lack a primary key or unique index, or every column has a type ' +\n 'ZQL does not support.',\n ],\n} as const;\n\nexport type Stat1 = {\n /** The raw `sqlite_stat1.stat` text, in case a flag is not parsed here. */\n raw: string;\n /** Estimated rows in the table. */\n rows: number;\n /**\n * Average rows per distinct key prefix: the first entry is for a 1-column\n * prefix, the second for 2 columns, and so on.\n */\n avgRowsPerPrefix: number[];\n /** Non-numeric tokens, e.g. `unordered`, `noskipscan`, `sz=N`. */\n flags: string[];\n};\n\n/** A `sqlite_stat4` sample with its values replaced by their types. */\nexport type Stat4Sample = {\n nEq: number[];\n nLt: number[];\n nDLt: number[];\n /** One entry per key column, plus the trailing rowid. */\n sample: SampleValueKind[];\n};\n\n/**\n * A digest of an index's `sqlite_stat4` histogram, one entry per key prefix\n * depth. This is what the default response carries: a full histogram runs to\n * hundreds of samples per index, which is a lot of context to spend on data an\n * LLM would only summarize anyway.\n */\ntype Stat4PrefixSummary = {\n /** Rows equal to the most common sampled key at this depth. */\n maxRowsPerKey: number;\n /** Median rows per sampled key at this depth. */\n medianRowsPerKey: number;\n /**\n * Lower bound on distinct keys at this depth, from the last sample's count of\n * distinct values less than it.\n */\n estimatedDistinctKeys: number;\n};\n\nexport type Stat4Summary = {\n samples: number;\n /** The type of each key column, and of the trailing rowid. */\n sampleKinds: SampleValueKind[];\n /** One entry per key prefix depth: 1 column, then 2, and so on. */\n perPrefix: Stat4PrefixSummary[];\n};\n\ntype IndexStats = {\n name: string;\n columns: {name: string; dir: 'ASC' | 'DESC'}[];\n unique: boolean;\n partial: boolean;\n stat1?: Stat1 | undefined;\n stat4?: Stat4Summary | undefined;\n /** The full histogram, only with `?stat4=full`. */\n stat4Samples?: Stat4Sample[] | undefined;\n};\n\ntype ColumnStats = {\n name: string;\n dataType: string;\n nullable: boolean;\n /** The ZQL type, or `null` for a column clients cannot see. */\n zqlType: string | null;\n backfilling?: true | undefined;\n};\n\ntype TableStats = {\n name: string;\n columns: ColumnStats[];\n primaryKey: string[] | undefined;\n estimatedRows: number | undefined;\n syncable: boolean;\n indexes: IndexStats[];\n};\n\nexport type PlannerzBundle = {\n about: typeof ABOUT;\n server: {\n zeroVersion: string;\n sqliteVersion: string;\n replicaWatermark: string | undefined;\n generatedAt: string;\n planner: {\n enableQueryPlanner: boolean;\n enableCorrelatedPredicatePushdown: boolean;\n enablePlannerAwarePushdown: boolean;\n };\n };\n statsQuality: {\n stat1Present: boolean;\n stat4Rows: number;\n method: string;\n tablesWithoutStats: string[];\n };\n tables: TableStats[];\n};\n\n/**\n * Parses a `sqlite_stat1.stat` value.\n *\n * @visibleForTesting\n */\nexport function parseStat1(raw: string): Stat1 {\n const tokens = raw.trim().split(SEPARATOR).filter(Boolean);\n const numbers: number[] = [];\n const flags: string[] = [];\n for (const token of tokens) {\n // Only leading tokens are counts; anything else is a flag, even if it\n // parses as a number (e.g. a future \"sz=N\" spelled differently).\n if (flags.length === 0 && COUNT.test(token)) {\n numbers.push(parseInt(token, 10));\n } else {\n flags.push(token);\n }\n }\n return {\n raw,\n rows: numbers[0] ?? 0,\n avgRowsPerPrefix: numbers.slice(1),\n flags,\n };\n}\n\n/** Whitespace between the counts in a `sqlite_stat1` or `sqlite_stat4` value. */\nconst SEPARATOR = /\\s+/;\n/** A count, as opposed to a trailing flag such as `unordered` or `sz=N`. */\nconst COUNT = /^\\d+$/;\n\ntype Stat1Row = {tbl: string; idx: string | null; stat: string};\ntype Stat4Row = {\n tbl: string;\n idx: string;\n neq: string;\n nlt: string;\n ndlt: string;\n sample: Buffer;\n};\n\nfunction tableExists(db: Database, name: string): boolean {\n return (\n db\n .prepare(\n `SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`,\n )\n .all(name).length > 0\n );\n}\n\nfunction parseCounts(raw: string): number[] {\n return raw\n .trim()\n .split(SEPARATOR)\n .filter(Boolean)\n .map(n => parseInt(n, 10));\n}\n\n/**\n * Builds the `/plannerz` bundle from the replica's catalog, `sqlite_stat*`\n * tables and the server config. No user table is read, so the cost does not\n * depend on how much data the replica holds.\n */\nfunction buildPlannerzBundle(\n lc: LogContext,\n config: ZeroConfig,\n opts: BundleOptions = {},\n): PlannerzBundle {\n using db = new Database(lc, config.replica.file, {readonly: true});\n return buildPlannerzBundleFromDB(lc, db, config, opts);\n}\n\nexport type BundleOptions = {\n /** Include the full `sqlite_stat4` histogram, not just the digest. */\n fullStat4?: boolean | undefined;\n};\n\n/** @visibleForTesting */\nexport function buildPlannerzBundleFromDB(\n lc: LogContext,\n db: Database,\n config: ZeroConfig,\n opts: BundleOptions = {},\n): PlannerzBundle {\n const tables = listReplicaTables(lc, db);\n const indexes = listIndexes(db);\n const zqlSpecs = computeZqlSpecsFromLiteSpecs(\n tables,\n indexes,\n {includeBackfillingColumns: false},\n undefined,\n undefined,\n lc,\n );\n\n const stat1Present = tableExists(db, 'sqlite_stat1');\n const stat1ByTableAndIndex = new Map<string, Stat1>();\n const statsByTable = new Map<string, Stat1>();\n if (stat1Present) {\n for (const {tbl, idx, stat} of db\n .prepare(`SELECT tbl, idx, stat FROM sqlite_stat1`)\n .all() as Stat1Row[]) {\n const parsed = parseStat1(stat);\n if (idx !== null) {\n stat1ByTableAndIndex.set(`${tbl}.${idx}`, parsed);\n }\n // Every row for a table starts with the table's row count. A row with a\n // null `idx` is written for a table with no indexes, and is preferred\n // because it is about the table itself.\n if (idx === null || !statsByTable.has(tbl)) {\n statsByTable.set(tbl, parsed);\n }\n }\n }\n\n const stat4Present = tableExists(db, 'sqlite_stat4');\n const stat4ByTableAndIndex = new Map<string, Stat4Sample[]>();\n let stat4Rows = 0;\n if (stat4Present) {\n for (const {tbl, idx, neq, nlt, ndlt, sample} of db\n .prepare(`SELECT tbl, idx, neq, nlt, ndlt, sample FROM sqlite_stat4`)\n .all() as Stat4Row[]) {\n stat4Rows++;\n const samples = stat4ByTableAndIndex.get(`${tbl}.${idx}`) ?? [];\n samples.push({\n nEq: parseCounts(neq),\n nLt: parseCounts(nlt),\n nDLt: parseCounts(ndlt),\n // Values are deliberately dropped here: only the types are reported.\n sample: decodeSampleKinds(sample),\n });\n stat4ByTableAndIndex.set(`${tbl}.${idx}`, samples);\n }\n }\n\n const indexesByTable = new Map<string, IndexStats[]>();\n for (const index of indexes) {\n const key = `${index.tableName}.${index.name}`;\n const stat4 = stat4ByTableAndIndex.get(key);\n const forTable = indexesByTable.get(index.tableName) ?? [];\n forTable.push({\n name: index.name,\n columns: Object.entries(index.columns).map(([name, dir]) => ({\n name,\n dir,\n })),\n unique: index.unique,\n partial: index.partial === true,\n stat1: stat1ByTableAndIndex.get(key),\n stat4: stat4?.length ? summarizeStat4(stat4) : undefined,\n stat4Samples: opts.fullStat4 && stat4?.length ? stat4 : undefined,\n });\n indexesByTable.set(index.tableName, forTable);\n }\n\n const tablesWithoutStats: string[] = [];\n const tableStats = tables.map(table => {\n const zqlSpec = zqlSpecs.get(table.name)?.zqlSpec;\n if (!statsByTable.has(table.name)) {\n tablesWithoutStats.push(table.name);\n }\n const backfilling = new Set(table.backfilling ?? []);\n return {\n name: table.name,\n columns: Object.entries(table.columns).map(([name, spec]) => ({\n name,\n dataType: spec.dataType,\n nullable: !spec.notNull,\n zqlType: zqlSpec?.[name]?.type ?? null,\n ...(backfilling.has(name) ? {backfilling: true as const} : {}),\n })),\n primaryKey: table.primaryKey ? [...table.primaryKey] : undefined,\n estimatedRows: statsByTable.get(table.name)?.rows,\n syncable: zqlSpec !== undefined,\n indexes: indexesByTable.get(table.name) ?? [],\n };\n });\n\n return {\n about: ABOUT,\n server: {\n zeroVersion: getServerVersion(config),\n sqliteVersion: sqliteVersion(db),\n replicaWatermark: replicaWatermark(lc, db),\n generatedAt: new Date().toISOString(),\n planner: {\n enableQueryPlanner: config.enableQueryPlanner,\n // Both default to on, matching services/analyze.ts.\n enableCorrelatedPredicatePushdown:\n config.enableCorrelatedPredicatePushdown !== false,\n enablePlannerAwarePushdown: config.enablePlannerAwarePushdown !== false,\n },\n },\n statsQuality: {\n stat1Present,\n stat4Rows,\n method: stat4Rows > 0 ? FULL_METHOD : APPROXIMATE_METHOD,\n tablesWithoutStats,\n },\n tables: tableStats,\n };\n}\n\n/**\n * Lists the replica's tables, tolerating a replica that has no metadata tables\n * yet. `listTables` reads `_zero.tableMetadata` for backfill status, which does\n * not exist until initial sync has created it. Reporting the schema without\n * backfill status beats failing the whole request.\n */\nfunction listReplicaTables(lc: LogContext, db: Database) {\n try {\n return listTables(db);\n } catch (e) {\n lc.debug?.('reading table metadata failed; listing tables without it', e);\n return listTables(db, true, false);\n }\n}\n\n/**\n * Digests an index's stat4 samples.\n *\n * Note that `nLt` and `nDLt` are stored as text, so SQL cannot order the\n * samples by them numerically. This works off the whole set rather than\n * relying on the samples' order: the highest `nDLt` is a lower bound on the\n * distinct keys, since the sample holding it has that many distinct keys below\n * it plus itself.\n *\n * @visibleForTesting\n */\nexport function summarizeStat4(samples: Stat4Sample[]): Stat4Summary {\n const depth = Math.max(...samples.map(s => s.nEq.length));\n const perPrefix: Stat4PrefixSummary[] = [];\n for (let d = 0; d < depth; d++) {\n const counts = samples\n .map(s => s.nEq[d])\n .filter(n => n !== undefined)\n .sort((a, b) => a - b);\n const maxDistinctBelow = Math.max(\n ...samples.map(s => s.nDLt[d]).filter(n => n !== undefined),\n );\n perPrefix.push({\n maxRowsPerKey: counts.at(-1)!,\n medianRowsPerKey: counts[Math.floor((counts.length - 1) / 2)],\n estimatedDistinctKeys: maxDistinctBelow + 1,\n });\n }\n return {\n samples: samples.length,\n // Every sample of an index describes the same columns.\n sampleKinds: samples[0].sample,\n perPrefix,\n };\n}\n\nfunction sqliteVersion(db: Database): string {\n const [{version}] = db\n .prepare(`SELECT sqlite_version() AS version`)\n .all() as {version: string}[];\n return version;\n}\n\nfunction replicaWatermark(lc: LogContext, db: Database): string | undefined {\n try {\n return getReplicationState(new StatementRunner(db)).stateVersion;\n } catch (e) {\n // A replica that has not finished initial sync has no replication state.\n lc.debug?.('could not read replication state', e);\n return undefined;\n }\n}\n\n/**\n * Serves the replica's schema, indexes and SQLite statistics for consumption by\n * a language model, so that it can advise on query rewrites and indexes.\n *\n * Row data is never returned. See `designs/003_replica_stats_endpoint.md`.\n *\n * This is available to the operator password as well as the admin password\n * (see `--operator-password`). That does not extend to `/plannerz/analyze`,\n * which runs caller-supplied queries against the replica and so must require\n * the admin password (`isAdminPasswordValid`).\n *\n * HTTP query parameters:\n * * `pretty`: indents the JSON\n * * `stat4=full`: includes the full sqlite_stat4 histogram, which is large\n */\nexport async function handlePlannerzRequest(\n lc: LogContext,\n config: ZeroConfig,\n req: FastifyRequest,\n res: FastifyReply,\n) {\n const credentials = auth(req);\n if (getOperatorAccess(lc, config, credentials?.pass) === undefined) {\n void res\n .code(401)\n .header('WWW-Authenticate', 'Basic realm=\"Plannerz Protected Area\"')\n .send('Unauthorized');\n return;\n }\n\n const query = req.query as Record<string, unknown>;\n const indent = query.pretty !== undefined ? 2 : undefined;\n let bundle: PlannerzBundle;\n try {\n bundle = buildPlannerzBundle(lc, config, {\n fullStat4: query.stat4 === 'full',\n });\n } catch (e) {\n lc.error?.('error building plannerz bundle', e);\n await res.code(500).send({error: String(e)});\n return;\n }\n await res\n .header('Content-Type', 'application/json')\n .send(JSON.stringify(bundle, null, indent));\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"plannerz.js","names":[],"sources":["../../../../../zero-cache/src/services/plannerz.ts"],"sourcesContent":["import type {LogContext} from '@rocicorp/logger';\nimport type {FastifyReply, FastifyRequest} from 'fastify';\nimport {Database} from '../../../zqlite/src/db.ts';\nimport {\n decodeSampleKinds,\n type SampleValueKind,\n} from '../../../zqlite/src/sqlite-stat4-sample.ts';\nimport type {NormalizedZeroConfig as ZeroConfig} from '../config/normalize.ts';\nimport {getServerVersion} from '../config/zero-config.ts';\nimport {\n computeZqlSpecsFromLiteSpecs,\n listIndexes,\n listTables,\n} from '../db/lite-tables.ts';\nimport {StatementRunner} from '../db/statements.ts';\nimport {authorizeAdminRequest} from './admin-auth.ts';\nimport {getReplicationState} from './replicator/schema/replication-state.ts';\n\n/**\n * How the statistics in the replica were gathered. zero-cache only ever runs\n * `PRAGMA optimize`, and always under an `analysis_limit`, which samples rather\n * than scanning. A replica that someone ran `ANALYZE` on by hand has better\n * statistics, which is visible in `sqlite_stat4` having rows at all.\n */\nconst APPROXIMATE_METHOD =\n 'PRAGMA optimize under an analysis_limit. Row counts and per-key averages ' +\n 'are sampled estimates, and sqlite_stat4 is not collected.';\nconst FULL_METHOD =\n 'ANALYZE, with no analysis limit. sqlite_stat4 has rows, so a full ANALYZE ' +\n 'ran against this replica at some point.';\n\n/**\n * A fixed glossary, sent with every bundle. The audience is a language model\n * that has the user's codebase but has never seen these statistics, and would\n * otherwise read a sampled estimate as an exact count.\n */\nconst ABOUT = {\n whatThisIs:\n 'Schema, indexes and SQLite statistics for the SQLite replica that ' +\n 'zero-cache queries. Use it to decide how to rewrite ZQL queries and ' +\n 'which indexes to add. It contains no row data.',\n stat1Format:\n 'sqlite_stat1.stat is \"<rows> <avg rows per distinct 1-column prefix> ' +\n '<... per 2-column prefix> ...\", parsed here into `rows` and ' +\n '`avgRowsPerPrefix`. NULLs count as ordinary values, so a sparse column ' +\n 'looks less selective than it is. Trailing flags such as \"unordered\", ' +\n '\"noskipscan\" and \"sz=N\" are kept in `flags`.',\n stat4Format:\n 'sqlite_stat4 is a per-index histogram. For each sample, `nEq` is the ' +\n 'rows equal to it, `nLt` the rows less than it, and `nDLt` the distinct ' +\n 'values less than it, one entry per key column. A large `nEq` next to a ' +\n 'small `nDLt` means a skewed column, where the average in stat1 hides ' +\n 'the common values. Sample values are never returned: `sample` reports ' +\n 'only each key column’s type.',\n howThePlannerUsesThis: [\n 'Cost comes from SQLite scanstatus estimates for the SQL each source ' +\n 'connection would run, so the statistics here are what the planner sees.',\n 'Join fanout comes from sqlite_stat4, else sqlite_stat1, else a default ' +\n 'of 3. With no stat4 (the usual case) fanout on a nullable foreign key ' +\n 'is overestimated, because stat1 counts NULLs.',\n 'The planner chooses per join whether to run it as a semi-join or to flip ' +\n 'it so the child drives. An index that matches the child’s ' +\n 'constraint plus ordering is what makes a flip cheap.',\n ],\n whatYouCanChange: [\n 'The ZQL query: `whereExists` vs `related`, the order of conditions, and ' +\n 'ordering that matches an existing index.',\n 'A `limit`, which lets the planner stop early.',\n 'Indexes: add them in Postgres, not on the replica. The replica copies ' +\n 'upstream indexes, so a new index reaches it through replication.',\n 'The planner flags in `server.planner`.',\n ],\n caveats: [\n 'Row counts are estimates from the statistics, not COUNT(*). This ' +\n 'endpoint never scans a table.',\n 'Statistics are as of the last time they were gathered, which SQLite does ' +\n 'not record. A table whose size changed a lot since then can be far off.',\n 'Tables in `tables` with `syncable: false` cannot be queried by clients: ' +\n 'they lack a primary key or unique index, or every column has a type ' +\n 'ZQL does not support.',\n ],\n} as const;\n\nexport type Stat1 = {\n /** The raw `sqlite_stat1.stat` text, in case a flag is not parsed here. */\n raw: string;\n /** Estimated rows in the table. */\n rows: number;\n /**\n * Average rows per distinct key prefix: the first entry is for a 1-column\n * prefix, the second for 2 columns, and so on.\n */\n avgRowsPerPrefix: number[];\n /** Non-numeric tokens, e.g. `unordered`, `noskipscan`, `sz=N`. */\n flags: string[];\n};\n\n/** A `sqlite_stat4` sample with its values replaced by their types. */\nexport type Stat4Sample = {\n nEq: number[];\n nLt: number[];\n nDLt: number[];\n /** One entry per key column, plus the trailing rowid. */\n sample: SampleValueKind[];\n};\n\n/**\n * A digest of an index's `sqlite_stat4` histogram, one entry per key prefix\n * depth. This is what the default response carries: a full histogram runs to\n * hundreds of samples per index, which is a lot of context to spend on data an\n * LLM would only summarize anyway.\n */\ntype Stat4PrefixSummary = {\n /** Rows equal to the most common sampled key at this depth. */\n maxRowsPerKey: number;\n /** Median rows per sampled key at this depth. */\n medianRowsPerKey: number;\n /**\n * Lower bound on distinct keys at this depth, from the last sample's count of\n * distinct values less than it.\n */\n estimatedDistinctKeys: number;\n};\n\nexport type Stat4Summary = {\n samples: number;\n /** The type of each key column, and of the trailing rowid. */\n sampleKinds: SampleValueKind[];\n /** One entry per key prefix depth: 1 column, then 2, and so on. */\n perPrefix: Stat4PrefixSummary[];\n};\n\ntype IndexStats = {\n name: string;\n columns: {name: string; dir: 'ASC' | 'DESC'}[];\n unique: boolean;\n partial: boolean;\n stat1?: Stat1 | undefined;\n stat4?: Stat4Summary | undefined;\n /** The full histogram, only with `?stat4=full`. */\n stat4Samples?: Stat4Sample[] | undefined;\n};\n\ntype ColumnStats = {\n name: string;\n dataType: string;\n nullable: boolean;\n /** The ZQL type, or `null` for a column clients cannot see. */\n zqlType: string | null;\n backfilling?: true | undefined;\n};\n\ntype TableStats = {\n name: string;\n columns: ColumnStats[];\n primaryKey: string[] | undefined;\n estimatedRows: number | undefined;\n syncable: boolean;\n indexes: IndexStats[];\n};\n\nexport type PlannerzBundle = {\n about: typeof ABOUT;\n server: {\n zeroVersion: string;\n sqliteVersion: string;\n replicaWatermark: string | undefined;\n generatedAt: string;\n planner: {\n enableQueryPlanner: boolean;\n enableCorrelatedPredicatePushdown: boolean;\n enablePlannerAwarePushdown: boolean;\n };\n };\n statsQuality: {\n stat1Present: boolean;\n stat4Rows: number;\n method: string;\n tablesWithoutStats: string[];\n };\n tables: TableStats[];\n};\n\n/**\n * Parses a `sqlite_stat1.stat` value.\n *\n * @visibleForTesting\n */\nexport function parseStat1(raw: string): Stat1 {\n const tokens = raw.trim().split(SEPARATOR).filter(Boolean);\n const numbers: number[] = [];\n const flags: string[] = [];\n for (const token of tokens) {\n // Only leading tokens are counts; anything else is a flag, even if it\n // parses as a number (e.g. a future \"sz=N\" spelled differently).\n if (flags.length === 0 && COUNT.test(token)) {\n numbers.push(parseInt(token, 10));\n } else {\n flags.push(token);\n }\n }\n return {\n raw,\n rows: numbers[0] ?? 0,\n avgRowsPerPrefix: numbers.slice(1),\n flags,\n };\n}\n\n/** Whitespace between the counts in a `sqlite_stat1` or `sqlite_stat4` value. */\nconst SEPARATOR = /\\s+/;\n/** A count, as opposed to a trailing flag such as `unordered` or `sz=N`. */\nconst COUNT = /^\\d+$/;\n\ntype Stat1Row = {tbl: string; idx: string | null; stat: string};\ntype Stat4Row = {\n tbl: string;\n idx: string;\n neq: string;\n nlt: string;\n ndlt: string;\n sample: Buffer;\n};\n\nfunction tableExists(db: Database, name: string): boolean {\n return (\n db\n .prepare(\n `SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`,\n )\n .all(name).length > 0\n );\n}\n\nfunction parseCounts(raw: string): number[] {\n return raw\n .trim()\n .split(SEPARATOR)\n .filter(Boolean)\n .map(n => parseInt(n, 10));\n}\n\n/**\n * Builds the `/plannerz` bundle from the replica's catalog, `sqlite_stat*`\n * tables and the server config. No user table is read, so the cost does not\n * depend on how much data the replica holds.\n */\nfunction buildPlannerzBundle(\n lc: LogContext,\n config: ZeroConfig,\n opts: BundleOptions = {},\n): PlannerzBundle {\n using db = new Database(lc, config.replica.file, {readonly: true});\n return buildPlannerzBundleFromDB(lc, db, config, opts);\n}\n\nexport type BundleOptions = {\n /** Include the full `sqlite_stat4` histogram, not just the digest. */\n fullStat4?: boolean | undefined;\n};\n\n/** @visibleForTesting */\nexport function buildPlannerzBundleFromDB(\n lc: LogContext,\n db: Database,\n config: ZeroConfig,\n opts: BundleOptions = {},\n): PlannerzBundle {\n const tables = listReplicaTables(lc, db);\n const indexes = listIndexes(db);\n const zqlSpecs = computeZqlSpecsFromLiteSpecs(\n tables,\n indexes,\n {includeBackfillingColumns: false},\n undefined,\n undefined,\n lc,\n );\n\n const stat1Present = tableExists(db, 'sqlite_stat1');\n const stat1ByTableAndIndex = new Map<string, Stat1>();\n const statsByTable = new Map<string, Stat1>();\n if (stat1Present) {\n for (const {tbl, idx, stat} of db\n .prepare(`SELECT tbl, idx, stat FROM sqlite_stat1`)\n .all() as Stat1Row[]) {\n const parsed = parseStat1(stat);\n if (idx !== null) {\n stat1ByTableAndIndex.set(`${tbl}.${idx}`, parsed);\n }\n // Every row for a table starts with the table's row count. A row with a\n // null `idx` is written for a table with no indexes, and is preferred\n // because it is about the table itself.\n if (idx === null || !statsByTable.has(tbl)) {\n statsByTable.set(tbl, parsed);\n }\n }\n }\n\n const stat4Present = tableExists(db, 'sqlite_stat4');\n const stat4ByTableAndIndex = new Map<string, Stat4Sample[]>();\n let stat4Rows = 0;\n if (stat4Present) {\n for (const {tbl, idx, neq, nlt, ndlt, sample} of db\n .prepare(`SELECT tbl, idx, neq, nlt, ndlt, sample FROM sqlite_stat4`)\n .all() as Stat4Row[]) {\n stat4Rows++;\n const samples = stat4ByTableAndIndex.get(`${tbl}.${idx}`) ?? [];\n samples.push({\n nEq: parseCounts(neq),\n nLt: parseCounts(nlt),\n nDLt: parseCounts(ndlt),\n // Values are deliberately dropped here: only the types are reported.\n sample: decodeSampleKinds(sample),\n });\n stat4ByTableAndIndex.set(`${tbl}.${idx}`, samples);\n }\n }\n\n const indexesByTable = new Map<string, IndexStats[]>();\n for (const index of indexes) {\n const key = `${index.tableName}.${index.name}`;\n const stat4 = stat4ByTableAndIndex.get(key);\n const forTable = indexesByTable.get(index.tableName) ?? [];\n forTable.push({\n name: index.name,\n columns: Object.entries(index.columns).map(([name, dir]) => ({\n name,\n dir,\n })),\n unique: index.unique,\n partial: index.partial === true,\n stat1: stat1ByTableAndIndex.get(key),\n stat4: stat4?.length ? summarizeStat4(stat4) : undefined,\n stat4Samples: opts.fullStat4 && stat4?.length ? stat4 : undefined,\n });\n indexesByTable.set(index.tableName, forTable);\n }\n\n const tablesWithoutStats: string[] = [];\n const tableStats = tables.map(table => {\n const zqlSpec = zqlSpecs.get(table.name)?.zqlSpec;\n if (!statsByTable.has(table.name)) {\n tablesWithoutStats.push(table.name);\n }\n const backfilling = new Set(table.backfilling ?? []);\n return {\n name: table.name,\n columns: Object.entries(table.columns).map(([name, spec]) => ({\n name,\n dataType: spec.dataType,\n nullable: !spec.notNull,\n zqlType: zqlSpec?.[name]?.type ?? null,\n ...(backfilling.has(name) ? {backfilling: true as const} : {}),\n })),\n primaryKey: table.primaryKey ? [...table.primaryKey] : undefined,\n estimatedRows: statsByTable.get(table.name)?.rows,\n syncable: zqlSpec !== undefined,\n indexes: indexesByTable.get(table.name) ?? [],\n };\n });\n\n return {\n about: ABOUT,\n server: {\n zeroVersion: getServerVersion(config),\n sqliteVersion: sqliteVersion(db),\n replicaWatermark: replicaWatermark(lc, db),\n generatedAt: new Date().toISOString(),\n planner: {\n enableQueryPlanner: config.enableQueryPlanner,\n // Both default to on, matching services/analyze.ts.\n enableCorrelatedPredicatePushdown:\n config.enableCorrelatedPredicatePushdown !== false,\n enablePlannerAwarePushdown: config.enablePlannerAwarePushdown !== false,\n },\n },\n statsQuality: {\n stat1Present,\n stat4Rows,\n method: stat4Rows > 0 ? FULL_METHOD : APPROXIMATE_METHOD,\n tablesWithoutStats,\n },\n tables: tableStats,\n };\n}\n\n/**\n * Lists the replica's tables, tolerating a replica that has no metadata tables\n * yet. `listTables` reads `_zero.tableMetadata` for backfill status, which does\n * not exist until initial sync has created it. Reporting the schema without\n * backfill status beats failing the whole request.\n */\nfunction listReplicaTables(lc: LogContext, db: Database) {\n try {\n return listTables(db);\n } catch (e) {\n lc.debug?.('reading table metadata failed; listing tables without it', e);\n return listTables(db, true, false);\n }\n}\n\n/**\n * Digests an index's stat4 samples.\n *\n * Note that `nLt` and `nDLt` are stored as text, so SQL cannot order the\n * samples by them numerically. This works off the whole set rather than\n * relying on the samples' order: the highest `nDLt` is a lower bound on the\n * distinct keys, since the sample holding it has that many distinct keys below\n * it plus itself.\n *\n * @visibleForTesting\n */\nexport function summarizeStat4(samples: Stat4Sample[]): Stat4Summary {\n const depth = Math.max(...samples.map(s => s.nEq.length));\n const perPrefix: Stat4PrefixSummary[] = [];\n for (let d = 0; d < depth; d++) {\n const counts = samples\n .map(s => s.nEq[d])\n .filter(n => n !== undefined)\n .sort((a, b) => a - b);\n const maxDistinctBelow = Math.max(\n ...samples.map(s => s.nDLt[d]).filter(n => n !== undefined),\n );\n perPrefix.push({\n maxRowsPerKey: counts.at(-1)!,\n medianRowsPerKey: counts[Math.floor((counts.length - 1) / 2)],\n estimatedDistinctKeys: maxDistinctBelow + 1,\n });\n }\n return {\n samples: samples.length,\n // Every sample of an index describes the same columns.\n sampleKinds: samples[0].sample,\n perPrefix,\n };\n}\n\nfunction sqliteVersion(db: Database): string {\n const [{version}] = db\n .prepare(`SELECT sqlite_version() AS version`)\n .all() as {version: string}[];\n return version;\n}\n\nfunction replicaWatermark(lc: LogContext, db: Database): string | undefined {\n try {\n return getReplicationState(new StatementRunner(db)).stateVersion;\n } catch (e) {\n // A replica that has not finished initial sync has no replication state.\n lc.debug?.('could not read replication state', e);\n return undefined;\n }\n}\n\n/**\n * Serves the replica's schema, indexes and SQLite statistics for consumption by\n * a language model, so that it can advise on query rewrites and indexes.\n *\n * Row data is never returned. See `designs/003_replica_stats_endpoint.md`.\n *\n * This is available to the operator password as well as the admin password\n * (see `--operator-password`). That does not extend to `/plannerz/analyze`,\n * which runs caller-supplied queries against the replica and so must require\n * the admin password (`isAdminPasswordValid`).\n *\n * HTTP query parameters:\n * * `pretty`: indents the JSON\n * * `stat4=full`: includes the full sqlite_stat4 histogram, which is large\n */\nexport async function handlePlannerzRequest(\n lc: LogContext,\n config: ZeroConfig,\n req: FastifyRequest,\n res: FastifyReply,\n) {\n if (\n authorizeAdminRequest(\n lc,\n config,\n req,\n res,\n 'Plannerz Protected Area',\n 'operator',\n ) === undefined\n ) {\n return;\n }\n\n const query = req.query as Record<string, unknown>;\n const indent = query.pretty !== undefined ? 2 : undefined;\n let bundle: PlannerzBundle;\n try {\n bundle = buildPlannerzBundle(lc, config, {\n fullStat4: query.stat4 === 'full',\n });\n } catch (e) {\n lc.error?.('error building plannerz bundle', e);\n await res.code(500).send({error: String(e)});\n return;\n }\n await res\n .header('Content-Type', 'application/json')\n .send(JSON.stringify(bundle, null, indent));\n}\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"profz.d.ts","sourceRoot":"","sources":["../../../../../zero-cache/src/services/profz.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAC,UAAU,EAAC,MAAM,kBAAkB,CAAC;
|
|
1
|
+
{"version":3,"file":"profz.d.ts","sourceRoot":"","sources":["../../../../../zero-cache/src/services/profz.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAC,UAAU,EAAC,MAAM,kBAAkB,CAAC;AACjD,OAAO,KAAK,EAAC,YAAY,EAAE,cAAc,EAAC,MAAM,SAAS,CAAC;AAE1D,OAAO,KAAK,EAAC,oBAAoB,EAAC,MAAM,wBAAwB,CAAC;AACjE,OAAO,EAKL,KAAK,MAAM,EACZ,MAAM,uBAAuB,CAAC;AAK/B;;;;;;;;;;GAUG;AACH,wBAAsB,kBAAkB,CACtC,EAAE,EAAE,UAAU,EACd,MAAM,EAAE,IAAI,CAAC,oBAAoB,EAAE,eAAe,GAAG,kBAAkB,CAAC,EACxE,GAAG,EAAE,cAAc,EACnB,GAAG,EAAE,YAAY,EACjB,SAAS,CAAC,EAAE,CAAC,MAAM,OAAO,CAAC,MAAM,CAAC,CAAC,GAAG,SAAS,EAC/C,YAAY,CAAC,EAAE,MAAM,GAAG,SAAS,EACjC,gBAAgB,GAAE,MAAqB,GACtC,OAAO,CAAC,IAAI,CAAC,CA0Kf;AAED,wBAAsB,oBAAoB,CACxC,EAAE,EAAE,UAAU,EACd,MAAM,EAAE,oBAAoB,EAC5B,GAAG,EAAE,cAAc,EACnB,GAAG,EAAE,YAAY,EACjB,SAAS,CAAC,EAAE,CAAC,MAAM,OAAO,CAAC,MAAM,CAAC,CAAC,GAAG,SAAS,GAC9C,OAAO,CAAC,IAAI,CAAC,CA6Df;AAMD;;;;;;;;;;GAUG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAgBvD"}
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import { sleep } from "../../../shared/src/sleep.js";
|
|
2
2
|
import { singleProcessMode, subscribeToMessageType } from "../types/processes.js";
|
|
3
|
-
import {
|
|
3
|
+
import { authorizeAdminRequest } from "./admin-auth.js";
|
|
4
4
|
import { CpuProfiler } from "../types/profiler.js";
|
|
5
5
|
import { URLParams } from "../types/url-params.js";
|
|
6
6
|
import { randomUUID } from "node:crypto";
|
|
7
|
-
import auth from "basic-auth";
|
|
8
7
|
//#region ../zero-cache/src/services/profz.ts
|
|
9
8
|
/**
|
|
10
9
|
* Serves CPU profiles. This is available to the operator password as well as
|
|
@@ -18,12 +17,8 @@ import auth from "basic-auth";
|
|
|
18
17
|
* sources are redacted for `operator` access. See {@link redactProfile}.
|
|
19
18
|
*/
|
|
20
19
|
async function handleProfzRequest(lc, config, req, res, getWorker, forcedWorker, localProcessName = "dispatcher") {
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
if (access === void 0) {
|
|
24
|
-
res.code(401).header("WWW-Authenticate", "Basic realm=\"Profz Protected Area\"").send("Unauthorized");
|
|
25
|
-
return;
|
|
26
|
-
}
|
|
20
|
+
const access = authorizeAdminRequest(lc, config, req, res, "Profz Protected Area", "operator");
|
|
21
|
+
if (access === void 0) return;
|
|
27
22
|
const params = new URLParams(new URL(req.url, "http://localhost"));
|
|
28
23
|
const rawDuration = params.getInteger("duration", false) ?? params.getInteger("seconds", false) ?? 5;
|
|
29
24
|
const durationMs = Math.max(1, Math.min(60, rawDuration)) * 1e3;
|
|
@@ -100,11 +95,7 @@ async function handleProfzRequest(lc, config, req, res, getWorker, forcedWorker,
|
|
|
100
95
|
res.header("Content-Type", "application/json").header("Content-Disposition", `attachment; filename="zero-profile-bundle-${Date.now()}.json"`).send(bundle);
|
|
101
96
|
}
|
|
102
97
|
async function handleProfrmzRequest(lc, config, req, res, getWorker) {
|
|
103
|
-
|
|
104
|
-
if (getOperatorAccess(lc, config, credentials?.pass) === void 0) {
|
|
105
|
-
res.code(401).header("WWW-Authenticate", "Basic realm=\"Profrmz Protected Area\"").send("Unauthorized");
|
|
106
|
-
return;
|
|
107
|
-
}
|
|
98
|
+
if (authorizeAdminRequest(lc, config, req, res, "Profrmz Protected Area", "operator") === void 0) return;
|
|
108
99
|
if (config.changeStreamer.uri) {
|
|
109
100
|
const upstreamURL = new URL("/profz", config.changeStreamer.uri);
|
|
110
101
|
if (upstreamURL.protocol === "ws:") upstreamURL.protocol = "http:";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"profz.js","names":[],"sources":["../../../../../zero-cache/src/services/profz.ts"],"sourcesContent":["import {randomUUID} from 'node:crypto';\nimport type {Profiler} from 'node:inspector';\nimport type {LogContext} from '@rocicorp/logger';\nimport auth from 'basic-auth';\nimport type {FastifyReply, FastifyRequest} from 'fastify';\nimport {sleep} from '../../../shared/src/sleep.ts';\nimport type {NormalizedZeroConfig} from '../config/normalize.ts';\nimport {getOperatorAccess} from '../config/zero-config.ts';\nimport {\n singleProcessMode,\n subscribeToMessageType,\n type ProfileResponse,\n type ProfileResponseMessage,\n type Worker,\n} from '../types/processes.ts';\nimport {CpuProfiler} from '../types/profiler.ts';\nimport {URLParams} from '../types/url-params.ts';\n\n/**\n * Serves CPU profiles. This is available to the operator password as well as\n * the admin password (see `--operator-password`), so it must never serve\n * application data to `operator` access.\n *\n * A V8 CPU profile describes code, not data: function names, script URLs and\n * positions, and sample timings. The exception is a regular expression, which\n * V8 names after its source (`RegExp: <source>`). zero-cache compiles `LIKE`\n * and `ILIKE` patterns from client queries into regular expressions, so their\n * sources are redacted for `operator` access. See {@link redactProfile}.\n */\nexport async function handleProfzRequest(\n lc: LogContext,\n config: Pick<NormalizedZeroConfig, 'adminPassword' | 'operatorPassword'>,\n req: FastifyRequest,\n res: FastifyReply,\n getWorker?: (() => Promise<Worker>) | undefined,\n forcedWorker?: string | undefined,\n localProcessName: string = 'dispatcher',\n): Promise<void> {\n const credentials = auth(req);\n const access = getOperatorAccess(lc, config, credentials?.pass);\n if (access === undefined) {\n void res\n .code(401)\n .header('WWW-Authenticate', 'Basic realm=\"Profz Protected Area\"')\n .send('Unauthorized');\n return;\n }\n\n const params = new URLParams(new URL(req.url, 'http://localhost'));\n const rawDuration =\n params.getInteger('duration', false) ??\n params.getInteger('seconds', false) ??\n 5;\n const durationSec = Math.max(1, Math.min(60, rawDuration));\n const durationMs = durationSec * 1000;\n const targetWorker = forcedWorker ?? params.get('worker', false) ?? undefined;\n const targetWorkerIndex = params.getInteger('index', false) ?? undefined;\n const id = randomUUID();\n\n const responses = new Map<string, unknown>();\n\n // Profile dispatcher / local process if requested, when profiling all processes,\n // or when no worker dispatcher is available (single process / standalone mode)\n const shouldProfileLocal =\n singleProcessMode() ||\n getWorker === undefined ||\n targetWorker === undefined ||\n targetWorker === localProcessName;\n\n const localProfilePromise = shouldProfileLocal\n ? CpuProfiler.profile(durationMs).catch(err => {\n lc.warn?.('Failed to capture local CPU profile:', err);\n return null;\n })\n : Promise.resolve(null);\n\n if (!singleProcessMode() && getWorker !== undefined) {\n try {\n const worker = await getWorker();\n const onMessage = (msg: ProfileResponse) => {\n if (msg.id === id && msg.profile) {\n responses.set(msg.name, msg.profile);\n } else if (msg.id === id && msg.error) {\n lc.warn?.(`Worker ${msg.name} profile error: ${msg.error}`);\n }\n };\n\n // The worker is long-lived (and shared across requests), so the\n // per-request handler must be removed once this request is done.\n const unsubscribe = subscribeToMessageType<ProfileResponseMessage>(\n worker,\n 'profileResponse',\n onMessage,\n );\n try {\n worker.send([\n 'profile',\n {\n id,\n durationMs,\n worker:\n targetWorker === localProcessName ? '__none__' : targetWorker,\n workerIndex: targetWorkerIndex,\n },\n ]);\n\n // Wait for profile duration, then wait for child workers to transfer\n // profiles over IPC. Under heavy production load, serializing and sending\n // large CPU profiles can take several seconds.\n const graceMs =\n params.getInteger('graceMs', false) ??\n params.getInteger('timeout', false) ??\n 5000;\n await sleep(durationMs);\n\n const pollMs = 50;\n let waited = 0;\n let lastCount = responses.size;\n let quietMs = 0;\n while (waited < graceMs) {\n await sleep(pollMs);\n waited += pollMs;\n\n if (targetWorker && targetWorker !== 'all') {\n const hasTarget = [...responses.keys()].some(\n name =>\n name === targetWorker || name.startsWith(`${targetWorker}-`),\n );\n if (hasTarget) {\n break;\n }\n } else if (responses.size !== lastCount) {\n lastCount = responses.size;\n quietMs = 0;\n } else if (responses.size > 0) {\n quietMs += pollMs;\n if (quietMs >= 400) {\n break;\n }\n }\n }\n } finally {\n unsubscribe();\n }\n } catch (err) {\n lc.warn?.('Failed to dispatch profile request to child workers:', err);\n }\n }\n\n const localProfile = await localProfilePromise;\n if (localProfile) {\n const localName =\n targetWorker ?? (singleProcessMode() ? 'zero-cache' : localProcessName);\n responses.set(localName, localProfile);\n }\n\n if (access === 'operator') {\n for (const [name, prof] of responses) {\n responses.set(name, redactProfile(prof));\n }\n }\n\n // If a single specific worker was requested, return that profile directly\n if (targetWorker !== undefined && targetWorker !== 'all') {\n let matchedProfile = responses.get(targetWorker);\n if (!matchedProfile) {\n // Try matching by prefix or substring (e.g. 'syncer' matches 'syncer-0')\n for (const [name, prof] of responses.entries()) {\n if (name === targetWorker || name.startsWith(`${targetWorker}-`)) {\n matchedProfile = prof;\n break;\n }\n }\n }\n\n if (matchedProfile) {\n void res\n .header('Content-Type', 'application/json')\n .header(\n 'Content-Disposition',\n `attachment; filename=\"${targetWorker}.cpuprofile\"`,\n )\n .send(matchedProfile);\n return;\n }\n\n void res.code(404).send({\n error: `No profile captured for worker \"${targetWorker}\". Available: ${[...responses.keys()].join(', ')}`,\n });\n return;\n }\n\n // Default (Option A): Return multi-process profile bundle as a JSON map\n const bundle: Record<string, unknown> = {};\n for (const [name, prof] of responses.entries()) {\n bundle[name] = prof;\n }\n\n void res\n .header('Content-Type', 'application/json')\n .header(\n 'Content-Disposition',\n `attachment; filename=\"zero-profile-bundle-${Date.now()}.json\"`,\n )\n .send(bundle);\n}\n\nexport async function handleProfrmzRequest(\n lc: LogContext,\n config: NormalizedZeroConfig,\n req: FastifyRequest,\n res: FastifyReply,\n getWorker?: (() => Promise<Worker>) | undefined,\n): Promise<void> {\n const credentials = auth(req);\n if (getOperatorAccess(lc, config, credentials?.pass) === undefined) {\n void res\n .code(401)\n .header('WWW-Authenticate', 'Basic realm=\"Profrmz Protected Area\"')\n .send('Unauthorized');\n return;\n }\n\n // In distributed mode, proxy to the upstream Replication Manager. The\n // credentials are forwarded, and the Replication Manager's /profz decides\n // for itself what access they grant (and so what to redact).\n if (config.changeStreamer.uri) {\n const upstreamURL = new URL('/profz', config.changeStreamer.uri);\n if (upstreamURL.protocol === 'ws:') {\n upstreamURL.protocol = 'http:';\n } else if (upstreamURL.protocol === 'wss:') {\n upstreamURL.protocol = 'https:';\n }\n const incomingURL = new URL(req.url, 'http://localhost');\n for (const [key, value] of incomingURL.searchParams.entries()) {\n upstreamURL.searchParams.set(key, value);\n }\n\n try {\n const headers: Record<string, string> = {};\n if (req.headers.authorization) {\n headers.authorization = req.headers.authorization;\n }\n const response = await fetch(upstreamURL.toString(), {\n method: 'GET',\n headers,\n });\n\n res.code(response.status);\n const contentType = response.headers.get('content-type');\n if (contentType) {\n res.header('content-type', contentType);\n }\n const contentDisposition = response.headers.get('content-disposition');\n if (contentDisposition) {\n res.header('content-disposition', contentDisposition);\n }\n\n const body = await response.text();\n return res.send(body);\n } catch (err) {\n lc.error?.(`Failed to proxy /profrmz to RM at ${upstreamURL}:`, err);\n return res.code(502).send({\n error: `Failed to proxy profiling request to RM at ${upstreamURL}: ${String(err)}`,\n });\n }\n }\n\n // In single-node mode, profile local change-streamer / RM process\n return handleProfzRequest(lc, config, req, res, getWorker, 'change-streamer');\n}\n\n// V8 names the code for a regular expression `RegExp: <source>`.\nconst REGEXP_NAME_PREFIX = 'RegExp:';\nconst REDACTED_REGEXP_NAME = 'RegExp: <redacted>';\n\n/**\n * Returns a copy of the V8 CPU `profile` with the source of every regular\n * expression replaced, since a pattern built at runtime can contain\n * application data (e.g. a `LIKE` pattern from a client query). The time spent\n * in regular expressions is still attributed to them.\n *\n * A `profile` that is not in the expected format is withheld rather than\n * served unredacted.\n *\n * @visibleForTesting\n */\nexport function redactProfile(profile: unknown): unknown {\n const {nodes} = (profile ?? {}) as Partial<Profiler.Profile>;\n if (!Array.isArray(nodes)) {\n return {error: 'Profile withheld: unrecognized format'};\n }\n return {\n ...(profile as Profiler.Profile),\n nodes: nodes.map(node =>\n node.callFrame?.functionName?.startsWith(REGEXP_NAME_PREFIX)\n ? {\n ...node,\n callFrame: {...node.callFrame, functionName: REDACTED_REGEXP_NAME},\n }\n : node,\n ),\n };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"profz.js","names":[],"sources":["../../../../../zero-cache/src/services/profz.ts"],"sourcesContent":["import {randomUUID} from 'node:crypto';\nimport type {Profiler} from 'node:inspector';\nimport type {LogContext} from '@rocicorp/logger';\nimport type {FastifyReply, FastifyRequest} from 'fastify';\nimport {sleep} from '../../../shared/src/sleep.ts';\nimport type {NormalizedZeroConfig} from '../config/normalize.ts';\nimport {\n singleProcessMode,\n subscribeToMessageType,\n type ProfileResponse,\n type ProfileResponseMessage,\n type Worker,\n} from '../types/processes.ts';\nimport {CpuProfiler} from '../types/profiler.ts';\nimport {URLParams} from '../types/url-params.ts';\nimport {authorizeAdminRequest} from './admin-auth.ts';\n\n/**\n * Serves CPU profiles. This is available to the operator password as well as\n * the admin password (see `--operator-password`), so it must never serve\n * application data to `operator` access.\n *\n * A V8 CPU profile describes code, not data: function names, script URLs and\n * positions, and sample timings. The exception is a regular expression, which\n * V8 names after its source (`RegExp: <source>`). zero-cache compiles `LIKE`\n * and `ILIKE` patterns from client queries into regular expressions, so their\n * sources are redacted for `operator` access. See {@link redactProfile}.\n */\nexport async function handleProfzRequest(\n lc: LogContext,\n config: Pick<NormalizedZeroConfig, 'adminPassword' | 'operatorPassword'>,\n req: FastifyRequest,\n res: FastifyReply,\n getWorker?: (() => Promise<Worker>) | undefined,\n forcedWorker?: string | undefined,\n localProcessName: string = 'dispatcher',\n): Promise<void> {\n const access = authorizeAdminRequest(\n lc,\n config,\n req,\n res,\n 'Profz Protected Area',\n 'operator',\n );\n if (access === undefined) {\n return;\n }\n\n const params = new URLParams(new URL(req.url, 'http://localhost'));\n const rawDuration =\n params.getInteger('duration', false) ??\n params.getInteger('seconds', false) ??\n 5;\n const durationSec = Math.max(1, Math.min(60, rawDuration));\n const durationMs = durationSec * 1000;\n const targetWorker = forcedWorker ?? params.get('worker', false) ?? undefined;\n const targetWorkerIndex = params.getInteger('index', false) ?? undefined;\n const id = randomUUID();\n\n const responses = new Map<string, unknown>();\n\n // Profile dispatcher / local process if requested, when profiling all processes,\n // or when no worker dispatcher is available (single process / standalone mode)\n const shouldProfileLocal =\n singleProcessMode() ||\n getWorker === undefined ||\n targetWorker === undefined ||\n targetWorker === localProcessName;\n\n const localProfilePromise = shouldProfileLocal\n ? CpuProfiler.profile(durationMs).catch(err => {\n lc.warn?.('Failed to capture local CPU profile:', err);\n return null;\n })\n : Promise.resolve(null);\n\n if (!singleProcessMode() && getWorker !== undefined) {\n try {\n const worker = await getWorker();\n const onMessage = (msg: ProfileResponse) => {\n if (msg.id === id && msg.profile) {\n responses.set(msg.name, msg.profile);\n } else if (msg.id === id && msg.error) {\n lc.warn?.(`Worker ${msg.name} profile error: ${msg.error}`);\n }\n };\n\n // The worker is long-lived (and shared across requests), so the\n // per-request handler must be removed once this request is done.\n const unsubscribe = subscribeToMessageType<ProfileResponseMessage>(\n worker,\n 'profileResponse',\n onMessage,\n );\n try {\n worker.send([\n 'profile',\n {\n id,\n durationMs,\n worker:\n targetWorker === localProcessName ? '__none__' : targetWorker,\n workerIndex: targetWorkerIndex,\n },\n ]);\n\n // Wait for profile duration, then wait for child workers to transfer\n // profiles over IPC. Under heavy production load, serializing and sending\n // large CPU profiles can take several seconds.\n const graceMs =\n params.getInteger('graceMs', false) ??\n params.getInteger('timeout', false) ??\n 5000;\n await sleep(durationMs);\n\n const pollMs = 50;\n let waited = 0;\n let lastCount = responses.size;\n let quietMs = 0;\n while (waited < graceMs) {\n await sleep(pollMs);\n waited += pollMs;\n\n if (targetWorker && targetWorker !== 'all') {\n const hasTarget = [...responses.keys()].some(\n name =>\n name === targetWorker || name.startsWith(`${targetWorker}-`),\n );\n if (hasTarget) {\n break;\n }\n } else if (responses.size !== lastCount) {\n lastCount = responses.size;\n quietMs = 0;\n } else if (responses.size > 0) {\n quietMs += pollMs;\n if (quietMs >= 400) {\n break;\n }\n }\n }\n } finally {\n unsubscribe();\n }\n } catch (err) {\n lc.warn?.('Failed to dispatch profile request to child workers:', err);\n }\n }\n\n const localProfile = await localProfilePromise;\n if (localProfile) {\n const localName =\n targetWorker ?? (singleProcessMode() ? 'zero-cache' : localProcessName);\n responses.set(localName, localProfile);\n }\n\n if (access === 'operator') {\n for (const [name, prof] of responses) {\n responses.set(name, redactProfile(prof));\n }\n }\n\n // If a single specific worker was requested, return that profile directly\n if (targetWorker !== undefined && targetWorker !== 'all') {\n let matchedProfile = responses.get(targetWorker);\n if (!matchedProfile) {\n // Try matching by prefix or substring (e.g. 'syncer' matches 'syncer-0')\n for (const [name, prof] of responses.entries()) {\n if (name === targetWorker || name.startsWith(`${targetWorker}-`)) {\n matchedProfile = prof;\n break;\n }\n }\n }\n\n if (matchedProfile) {\n void res\n .header('Content-Type', 'application/json')\n .header(\n 'Content-Disposition',\n `attachment; filename=\"${targetWorker}.cpuprofile\"`,\n )\n .send(matchedProfile);\n return;\n }\n\n void res.code(404).send({\n error: `No profile captured for worker \"${targetWorker}\". Available: ${[...responses.keys()].join(', ')}`,\n });\n return;\n }\n\n // Default (Option A): Return multi-process profile bundle as a JSON map\n const bundle: Record<string, unknown> = {};\n for (const [name, prof] of responses.entries()) {\n bundle[name] = prof;\n }\n\n void res\n .header('Content-Type', 'application/json')\n .header(\n 'Content-Disposition',\n `attachment; filename=\"zero-profile-bundle-${Date.now()}.json\"`,\n )\n .send(bundle);\n}\n\nexport async function handleProfrmzRequest(\n lc: LogContext,\n config: NormalizedZeroConfig,\n req: FastifyRequest,\n res: FastifyReply,\n getWorker?: (() => Promise<Worker>) | undefined,\n): Promise<void> {\n if (\n authorizeAdminRequest(\n lc,\n config,\n req,\n res,\n 'Profrmz Protected Area',\n 'operator',\n ) === undefined\n ) {\n return;\n }\n\n // In distributed mode, proxy to the upstream Replication Manager. The\n // credentials are forwarded, and the Replication Manager's /profz decides\n // for itself what access they grant (and so what to redact).\n if (config.changeStreamer.uri) {\n const upstreamURL = new URL('/profz', config.changeStreamer.uri);\n if (upstreamURL.protocol === 'ws:') {\n upstreamURL.protocol = 'http:';\n } else if (upstreamURL.protocol === 'wss:') {\n upstreamURL.protocol = 'https:';\n }\n const incomingURL = new URL(req.url, 'http://localhost');\n for (const [key, value] of incomingURL.searchParams.entries()) {\n upstreamURL.searchParams.set(key, value);\n }\n\n try {\n const headers: Record<string, string> = {};\n if (req.headers.authorization) {\n headers.authorization = req.headers.authorization;\n }\n const response = await fetch(upstreamURL.toString(), {\n method: 'GET',\n headers,\n });\n\n res.code(response.status);\n const contentType = response.headers.get('content-type');\n if (contentType) {\n res.header('content-type', contentType);\n }\n const contentDisposition = response.headers.get('content-disposition');\n if (contentDisposition) {\n res.header('content-disposition', contentDisposition);\n }\n\n const body = await response.text();\n return res.send(body);\n } catch (err) {\n lc.error?.(`Failed to proxy /profrmz to RM at ${upstreamURL}:`, err);\n return res.code(502).send({\n error: `Failed to proxy profiling request to RM at ${upstreamURL}: ${String(err)}`,\n });\n }\n }\n\n // In single-node mode, profile local change-streamer / RM process\n return handleProfzRequest(lc, config, req, res, getWorker, 'change-streamer');\n}\n\n// V8 names the code for a regular expression `RegExp: <source>`.\nconst REGEXP_NAME_PREFIX = 'RegExp:';\nconst REDACTED_REGEXP_NAME = 'RegExp: <redacted>';\n\n/**\n * Returns a copy of the V8 CPU `profile` with the source of every regular\n * expression replaced, since a pattern built at runtime can contain\n * application data (e.g. a `LIKE` pattern from a client query). The time spent\n * in regular expressions is still attributed to them.\n *\n * A `profile` that is not in the expected format is withheld rather than\n * served unredacted.\n *\n * @visibleForTesting\n */\nexport function redactProfile(profile: unknown): unknown {\n const {nodes} = (profile ?? {}) as Partial<Profiler.Profile>;\n if (!Array.isArray(nodes)) {\n return {error: 'Profile withheld: unrecognized format'};\n }\n return {\n ...(profile as Profiler.Profile),\n nodes: nodes.map(node =>\n node.callFrame?.functionName?.startsWith(REGEXP_NAME_PREFIX)\n ? {\n ...node,\n callFrame: {...node.callFrame, functionName: REDACTED_REGEXP_NAME},\n }\n : node,\n ),\n };\n}\n"],"mappings":"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"}
|
|
@@ -20,6 +20,7 @@ import { acceptBackfill } from "../change-source/protocol/backfill-progress.js";
|
|
|
20
20
|
import { SqliteError } from "@rocicorp/zero-sqlite3";
|
|
21
21
|
import { constants } from "node:buffer";
|
|
22
22
|
//#region ../zero-cache/src/services/replicator/change-processor.ts
|
|
23
|
+
var MAX_COMMIT_ATTEMPTS = 5;
|
|
23
24
|
var BIND_VALUE_TOO_BIG_ERROR = "The bound string, buffer, or bigint is too big";
|
|
24
25
|
var MAX_SQLITE_BIND_BYTES = Math.min(constants.MAX_LENGTH, constants.MAX_STRING_LENGTH);
|
|
25
26
|
/**
|
|
@@ -562,7 +563,7 @@ var TransactionProcessor = class {
|
|
|
562
563
|
this.#db.db.pragma("optimize");
|
|
563
564
|
this.#lc.info?.(`PRAGMA optimized after schema change (${Date.now() - start} ms)`);
|
|
564
565
|
}
|
|
565
|
-
if (this.#mode !== "initial-sync") this.#
|
|
566
|
+
if (this.#mode !== "initial-sync") this.#commit();
|
|
566
567
|
const elapsedMs = Date.now() - this.#startMs;
|
|
567
568
|
this.#lc.debug?.(`Committed tx@${this.#version} (${elapsedMs} ms)`);
|
|
568
569
|
return {
|
|
@@ -573,6 +574,33 @@ var TransactionProcessor = class {
|
|
|
573
574
|
upstreamCommitTimeMs: commit.commitTimeMs
|
|
574
575
|
};
|
|
575
576
|
}
|
|
577
|
+
/**
|
|
578
|
+
* A wal2 `COMMIT` can fail with SQLITE_BUSY, without consulting the
|
|
579
|
+
* busy_timeout, if it switches wal files just as the `ServingCheckpointer`
|
|
580
|
+
* briefly locks the file it is switching to: the writer releases its locks
|
|
581
|
+
* on that file before taking the read lock that it needs on it. The
|
|
582
|
+
* transaction stays open and the switch has already happened, so the next
|
|
583
|
+
* attempt does not contend for that lock.
|
|
584
|
+
*
|
|
585
|
+
* No backoff is needed between attempts: the retry does not switch files
|
|
586
|
+
* again, so it never asks for the contended lock, whether or not the
|
|
587
|
+
* checkpointer still holds it. A second failure has some other cause.
|
|
588
|
+
*
|
|
589
|
+
* This is a workaround for a bug in SQLite's wal2 branch (in
|
|
590
|
+
* `walRestartLog()`), reported at
|
|
591
|
+
* https://sqlite.org/forum/forumpost/63ba0208a66aa7139119b56edb43a416b160ca1996a23de2b0da24ca48fb9b3a.
|
|
592
|
+
* The retry runs without the read lock the failed attempt meant to take.
|
|
593
|
+
* Remove it once zero-sqlite3 includes a fix.
|
|
594
|
+
*/
|
|
595
|
+
#commit() {
|
|
596
|
+
for (let attempt = 1;; attempt++) try {
|
|
597
|
+
this.#db.commit();
|
|
598
|
+
return;
|
|
599
|
+
} catch (e) {
|
|
600
|
+
if (attempt === MAX_COMMIT_ATTEMPTS || !(e instanceof SqliteError && e.code === "SQLITE_BUSY")) throw e;
|
|
601
|
+
this.#lc.warn?.(`SQLITE_BUSY on COMMIT (attempt ${attempt})`, e);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
576
604
|
abort(lc) {
|
|
577
605
|
lc.info?.(`aborting transaction ${this.#version}`);
|
|
578
606
|
this.#db.rollback();
|
|
@@ -587,7 +615,6 @@ function getBackfilledColumns(row, { backfilling }) {
|
|
|
587
615
|
return backfilling.filter((col) => col in row);
|
|
588
616
|
}
|
|
589
617
|
function oversizedValueDescription(value) {
|
|
590
|
-
if (typeof value === "bigint") return BigInt.asIntN(64, value) !== value ? "valueType=bigint fitsInt64=false" : void 0;
|
|
591
618
|
const sizeBytes = typeof value === "string" ? Buffer.byteLength(value) : value instanceof Uint8Array ? value.byteLength : void 0;
|
|
592
619
|
if (sizeBytes !== void 0 && sizeBytes > MAX_SQLITE_BIND_BYTES) return `valueType=${typeof value === "string" ? "string" : "buffer"} sizeBytes=${sizeBytes} limitBytes=${MAX_SQLITE_BIND_BYTES}`;
|
|
593
620
|
}
|