@rocicorp/zero 1.10.0-head-e8decd93-20260729 → 1.10.0-head-3dcaaf2a-20260729

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.
@@ -1,6 +1,6 @@
1
1
  var package_default = {
2
2
  name: "@rocicorp/zero",
3
- version: "1.10.0-head-e8decd93-20260729",
3
+ version: "1.10.0-head-3dcaaf2a-20260729",
4
4
  description: "Zero is a web framework for serverless web development.",
5
5
  homepage: "https://zero.rocicorp.dev",
6
6
  bugs: { "url": "https://bugs.rocicorp.dev" },
@@ -1 +1 @@
1
- {"version":3,"file":"package.js","names":[],"sources":["../../package.json"],"sourcesContent":["{\n \"name\": \"@rocicorp/zero\",\n \"version\": \"1.10.0-head-e8decd93-20260729\",\n \"description\": \"Zero is a web framework for serverless web development.\",\n \"homepage\": \"https://zero.rocicorp.dev\",\n \"bugs\": {\n \"url\": \"https://bugs.rocicorp.dev\"\n },\n \"license\": \"Apache-2.0\",\n \"author\": \"Rocicorp, Inc.\",\n \"repository\": {\n \"type\": \"git\",\n \"url\": \"git+https://github.com/rocicorp/mono.git\",\n \"directory\": \"packages/zero\"\n },\n \"bin\": {\n \"analyze-query\": \"./out/zero/src/analyze-query.js\",\n \"ast-to-zql\": \"./out/zero/src/ast-to-zql.js\",\n \"transform-query\": \"./out/zero/src/transform-query.js\",\n \"zero-build-schema\": \"./out/zero/src/build-schema.js\",\n \"zero-cache\": \"./out/zero/src/cli.js\",\n \"zero-cache-dev\": \"./out/zero/src/zero-cache-dev.js\",\n \"zero-deploy-permissions\": \"./out/zero/src/deploy-permissions.js\",\n \"zero-out\": \"./out/zero/src/zero-out.js\"\n },\n \"files\": [\n \"out\",\n \"!*.tsbuildinfo\"\n ],\n \"type\": \"module\",\n \"main\": \"out/zero/src/zero.js\",\n \"module\": \"out/zero/src/zero.js\",\n \"types\": \"out/zero/src/zero.d.ts\",\n \"exports\": {\n \".\": {\n \"types\": \"./out/zero/src/zero.d.ts\",\n \"default\": \"./out/zero/src/zero.js\"\n },\n \"./analyze\": {\n \"types\": \"./out/zero/src/analyze.d.ts\",\n \"default\": \"./out/zero/src/analyze.js\"\n },\n \"./bindings\": {\n \"types\": \"./out/zero/src/bindings.d.ts\",\n \"default\": \"./out/zero/src/bindings.js\"\n },\n \"./change-protocol/v0\": {\n \"types\": \"./out/zero/src/change-protocol/v0.d.ts\",\n \"default\": \"./out/zero/src/change-protocol/v0.js\"\n },\n \"./expo-sqlite\": {\n \"types\": \"./out/zero/src/expo-sqlite.d.ts\",\n \"default\": \"./out/zero/src/expo-sqlite.js\"\n },\n \"./op-sqlite\": {\n \"types\": \"./out/zero/src/op-sqlite.d.ts\",\n \"default\": \"./out/zero/src/op-sqlite.js\"\n },\n \"./pg\": {\n \"types\": \"./out/zero/src/pg.d.ts\",\n \"default\": \"./out/zero/src/pg.js\"\n },\n \"./react\": {\n \"types\": \"./out/zero/src/react.d.ts\",\n \"default\": \"./out/zero/src/react.js\"\n },\n \"./react-native\": {\n \"types\": \"./out/zero/src/react-native.d.ts\",\n \"default\": \"./out/zero/src/react-native.js\"\n },\n \"./server\": {\n \"types\": \"./out/zero/src/server.d.ts\",\n \"default\": \"./out/zero/src/server.js\"\n },\n \"./server/adapters/drizzle\": {\n \"types\": \"./out/zero/src/adapters/drizzle.d.ts\",\n \"default\": \"./out/zero/src/adapters/drizzle.js\"\n },\n \"./server/adapters/kysely\": {\n \"types\": \"./out/zero/src/adapters/kysely.d.ts\",\n \"default\": \"./out/zero/src/adapters/kysely.js\"\n },\n \"./server/adapters/prisma\": {\n \"types\": \"./out/zero/src/adapters/prisma.d.ts\",\n \"default\": \"./out/zero/src/adapters/prisma.js\"\n },\n \"./server/adapters/pg\": {\n \"types\": \"./out/zero/src/adapters/pg.d.ts\",\n \"default\": \"./out/zero/src/adapters/pg.js\"\n },\n \"./server/adapters/postgresjs\": {\n \"types\": \"./out/zero/src/adapters/postgresjs.d.ts\",\n \"default\": \"./out/zero/src/adapters/postgresjs.js\"\n },\n \"./solid\": {\n \"types\": \"./out/zero/src/solid.d.ts\",\n \"default\": \"./out/zero/src/solid.js\"\n },\n \"./sqlite\": {\n \"types\": \"./out/zero/src/sqlite.d.ts\",\n \"default\": \"./out/zero/src/sqlite.js\"\n },\n \"./zqlite\": {\n \"types\": \"./out/zero/src/zqlite.d.ts\",\n \"default\": \"./out/zero/src/zqlite.js\"\n }\n },\n \"scripts\": {\n \"build\": \"node --experimental-strip-types --no-warnings tool/build.ts\",\n \"build:watch\": \"node --experimental-strip-types --no-warnings tool/build.ts --watch\",\n \"check-types\": \"tsc -p tsconfig.client.json && tsc -p tsconfig.server.json\",\n \"check-types:client:watch\": \"tsc -p tsconfig.client.json --watch\",\n \"check-types:server:watch\": \"tsc -p tsconfig.server.json --watch\",\n \"format\": \"oxfmt .\",\n \"check-format\": \"oxfmt --check .\",\n \"lint\": \"oxlint --quiet --config ../../oxlint.config.ts src/\",\n \"docs\": \"node --experimental-strip-types --no-warnings tool/generate-docs.ts\",\n \"docs:server\": \"node --watch --experimental-strip-types --no-warnings tool/generate-docs.ts --server\",\n \"fmt\": \"oxfmt .\",\n \"check-fmt\": \"oxfmt --check .\"\n },\n \"dependencies\": {\n \"@badrap/valita\": \"0.3.11\",\n \"@databases/escape-identifier\": \"^1.0.3\",\n \"@databases/sql\": \"^3.3.0\",\n \"@dotenvx/dotenvx\": \"^1.39.0\",\n \"@drdgvhbh/postgres-error-codes\": \"^0.0.6\",\n \"@fastify/cors\": \"^10.0.0\",\n \"@fastify/websocket\": \"^11.0.0\",\n \"@google-cloud/precise-date\": \"^4.0.0\",\n \"@opentelemetry/api\": \"^1.9.0\",\n \"@opentelemetry/api-logs\": \"^0.218.0\",\n \"@opentelemetry/auto-instrumentations-node\": \"^0.76.0\",\n \"@opentelemetry/exporter-metrics-otlp-http\": \"^0.218.0\",\n \"@opentelemetry/resources\": \"^2.7.1\",\n \"@opentelemetry/sdk-metrics\": \"^2.7.1\",\n \"@opentelemetry/sdk-node\": \"^0.218.0\",\n \"@opentelemetry/sdk-trace-node\": \"^2.7.1\",\n \"@opentelemetry/semantic-conventions\": \"^1.41.1\",\n \"@postgresql-typed/oids\": \"^0.2.0\",\n \"@rocicorp/lock\": \"^1.0.4\",\n \"@rocicorp/logger\": \"^6.1.0\",\n \"@rocicorp/resolver\": \"^1.0.2\",\n \"@rocicorp/zero-sqlite3\": \"^1.1.2\",\n \"@standard-schema/spec\": \"^1.0.0\",\n \"@types/basic-auth\": \"^1.1.8\",\n \"@types/ws\": \"^8.5.12\",\n \"basic-auth\": \"^2.0.1\",\n \"chalk-template\": \"^1.1.0\",\n \"chokidar\": \"^4.0.1\",\n \"cloudevents\": \"^10.0.0\",\n \"command-line-args\": \"^6.0.1\",\n \"command-line-usage\": \"^7.0.3\",\n \"compare-utf8\": \"^0.2.0\",\n \"defu\": \"^6.1.4\",\n \"eventemitter3\": \"^5.0.1\",\n \"fastify\": \"^5.0.0\",\n \"is-in-subnet\": \"^4.0.1\",\n \"jose\": \"^5.9.3\",\n \"js-xxhash\": \"^4.0.0\",\n \"json-custom-numbers\": \"^3.1.1\",\n \"kasi\": \"^1.1.0\",\n \"nanoid\": \"^5.1.2\",\n \"oxfmt\": \"^0.45.0\",\n \"parse-prometheus-text-format\": \"^1.1.1\",\n \"pg-format\": \"npm:pg-format-fix@^1.0.5\",\n \"postgres\": \"3.4.7\",\n \"semver\": \"^7.5.4\",\n \"url-pattern\": \"^1.0.3\",\n \"urlpattern-polyfill\": \"^10.1.0\",\n \"ws\": \"^8.18.1\"\n },\n \"devDependencies\": {\n \"@op-engineering/op-sqlite\": \">=15\",\n \"@vitest/runner\": \"^4.1.6\",\n \"analyze-query\": \"workspace:*\",\n \"ast-to-zql\": \"workspace:*\",\n \"expo-sqlite\": \">=15\",\n \"oxc-parser\": \"^0.129.0\",\n \"replicache\": \"workspace:*\",\n \"shared\": \"workspace:*\",\n \"syncpack\": \"^14.3.0\",\n \"typedoc\": \"^0.28.17\",\n \"typedoc-plugin-markdown\": \"^4.10.0\",\n \"typescript\": \"~6.0.2\",\n \"vite\": \"^8.0.13\",\n \"vitest\": \"^4.1.6\",\n \"zero-cache\": \"workspace:*\",\n \"zero-client\": \"workspace:*\",\n \"zero-pg\": \"workspace:*\",\n \"zero-react\": \"workspace:*\",\n \"zero-server\": \"workspace:*\",\n \"zero-solid\": \"workspace:*\",\n \"zqlite\": \"workspace:*\"\n },\n \"engines\": {\n \"node\": \">=22\"\n },\n \"packageManager\": \"pnpm@11.11.0\"\n}\n"],"mappings":""}
1
+ {"version":3,"file":"package.js","names":[],"sources":["../../package.json"],"sourcesContent":["{\n \"name\": \"@rocicorp/zero\",\n \"version\": \"1.10.0-head-3dcaaf2a-20260729\",\n \"description\": \"Zero is a web framework for serverless web development.\",\n \"homepage\": \"https://zero.rocicorp.dev\",\n \"bugs\": {\n \"url\": \"https://bugs.rocicorp.dev\"\n },\n \"license\": \"Apache-2.0\",\n \"author\": \"Rocicorp, Inc.\",\n \"repository\": {\n \"type\": \"git\",\n \"url\": \"git+https://github.com/rocicorp/mono.git\",\n \"directory\": \"packages/zero\"\n },\n \"bin\": {\n \"analyze-query\": \"./out/zero/src/analyze-query.js\",\n \"ast-to-zql\": \"./out/zero/src/ast-to-zql.js\",\n \"transform-query\": \"./out/zero/src/transform-query.js\",\n \"zero-build-schema\": \"./out/zero/src/build-schema.js\",\n \"zero-cache\": \"./out/zero/src/cli.js\",\n \"zero-cache-dev\": \"./out/zero/src/zero-cache-dev.js\",\n \"zero-deploy-permissions\": \"./out/zero/src/deploy-permissions.js\",\n \"zero-out\": \"./out/zero/src/zero-out.js\"\n },\n \"files\": [\n \"out\",\n \"!*.tsbuildinfo\"\n ],\n \"type\": \"module\",\n \"main\": \"out/zero/src/zero.js\",\n \"module\": \"out/zero/src/zero.js\",\n \"types\": \"out/zero/src/zero.d.ts\",\n \"exports\": {\n \".\": {\n \"types\": \"./out/zero/src/zero.d.ts\",\n \"default\": \"./out/zero/src/zero.js\"\n },\n \"./analyze\": {\n \"types\": \"./out/zero/src/analyze.d.ts\",\n \"default\": \"./out/zero/src/analyze.js\"\n },\n \"./bindings\": {\n \"types\": \"./out/zero/src/bindings.d.ts\",\n \"default\": \"./out/zero/src/bindings.js\"\n },\n \"./change-protocol/v0\": {\n \"types\": \"./out/zero/src/change-protocol/v0.d.ts\",\n \"default\": \"./out/zero/src/change-protocol/v0.js\"\n },\n \"./expo-sqlite\": {\n \"types\": \"./out/zero/src/expo-sqlite.d.ts\",\n \"default\": \"./out/zero/src/expo-sqlite.js\"\n },\n \"./op-sqlite\": {\n \"types\": \"./out/zero/src/op-sqlite.d.ts\",\n \"default\": \"./out/zero/src/op-sqlite.js\"\n },\n \"./pg\": {\n \"types\": \"./out/zero/src/pg.d.ts\",\n \"default\": \"./out/zero/src/pg.js\"\n },\n \"./react\": {\n \"types\": \"./out/zero/src/react.d.ts\",\n \"default\": \"./out/zero/src/react.js\"\n },\n \"./react-native\": {\n \"types\": \"./out/zero/src/react-native.d.ts\",\n \"default\": \"./out/zero/src/react-native.js\"\n },\n \"./server\": {\n \"types\": \"./out/zero/src/server.d.ts\",\n \"default\": \"./out/zero/src/server.js\"\n },\n \"./server/adapters/drizzle\": {\n \"types\": \"./out/zero/src/adapters/drizzle.d.ts\",\n \"default\": \"./out/zero/src/adapters/drizzle.js\"\n },\n \"./server/adapters/kysely\": {\n \"types\": \"./out/zero/src/adapters/kysely.d.ts\",\n \"default\": \"./out/zero/src/adapters/kysely.js\"\n },\n \"./server/adapters/prisma\": {\n \"types\": \"./out/zero/src/adapters/prisma.d.ts\",\n \"default\": \"./out/zero/src/adapters/prisma.js\"\n },\n \"./server/adapters/pg\": {\n \"types\": \"./out/zero/src/adapters/pg.d.ts\",\n \"default\": \"./out/zero/src/adapters/pg.js\"\n },\n \"./server/adapters/postgresjs\": {\n \"types\": \"./out/zero/src/adapters/postgresjs.d.ts\",\n \"default\": \"./out/zero/src/adapters/postgresjs.js\"\n },\n \"./solid\": {\n \"types\": \"./out/zero/src/solid.d.ts\",\n \"default\": \"./out/zero/src/solid.js\"\n },\n \"./sqlite\": {\n \"types\": \"./out/zero/src/sqlite.d.ts\",\n \"default\": \"./out/zero/src/sqlite.js\"\n },\n \"./zqlite\": {\n \"types\": \"./out/zero/src/zqlite.d.ts\",\n \"default\": \"./out/zero/src/zqlite.js\"\n }\n },\n \"scripts\": {\n \"build\": \"node --experimental-strip-types --no-warnings tool/build.ts\",\n \"build:watch\": \"node --experimental-strip-types --no-warnings tool/build.ts --watch\",\n \"check-types\": \"tsc -p tsconfig.client.json && tsc -p tsconfig.server.json\",\n \"check-types:client:watch\": \"tsc -p tsconfig.client.json --watch\",\n \"check-types:server:watch\": \"tsc -p tsconfig.server.json --watch\",\n \"format\": \"oxfmt .\",\n \"check-format\": \"oxfmt --check .\",\n \"lint\": \"oxlint --quiet --config ../../oxlint.config.ts src/\",\n \"docs\": \"node --experimental-strip-types --no-warnings tool/generate-docs.ts\",\n \"docs:server\": \"node --watch --experimental-strip-types --no-warnings tool/generate-docs.ts --server\",\n \"fmt\": \"oxfmt .\",\n \"check-fmt\": \"oxfmt --check .\"\n },\n \"dependencies\": {\n \"@badrap/valita\": \"0.3.11\",\n \"@databases/escape-identifier\": \"^1.0.3\",\n \"@databases/sql\": \"^3.3.0\",\n \"@dotenvx/dotenvx\": \"^1.39.0\",\n \"@drdgvhbh/postgres-error-codes\": \"^0.0.6\",\n \"@fastify/cors\": \"^10.0.0\",\n \"@fastify/websocket\": \"^11.0.0\",\n \"@google-cloud/precise-date\": \"^4.0.0\",\n \"@opentelemetry/api\": \"^1.9.0\",\n \"@opentelemetry/api-logs\": \"^0.218.0\",\n \"@opentelemetry/auto-instrumentations-node\": \"^0.76.0\",\n \"@opentelemetry/exporter-metrics-otlp-http\": \"^0.218.0\",\n \"@opentelemetry/resources\": \"^2.7.1\",\n \"@opentelemetry/sdk-metrics\": \"^2.7.1\",\n \"@opentelemetry/sdk-node\": \"^0.218.0\",\n \"@opentelemetry/sdk-trace-node\": \"^2.7.1\",\n \"@opentelemetry/semantic-conventions\": \"^1.41.1\",\n \"@postgresql-typed/oids\": \"^0.2.0\",\n \"@rocicorp/lock\": \"^1.0.4\",\n \"@rocicorp/logger\": \"^6.1.0\",\n \"@rocicorp/resolver\": \"^1.0.2\",\n \"@rocicorp/zero-sqlite3\": \"^1.1.2\",\n \"@standard-schema/spec\": \"^1.0.0\",\n \"@types/basic-auth\": \"^1.1.8\",\n \"@types/ws\": \"^8.5.12\",\n \"basic-auth\": \"^2.0.1\",\n \"chalk-template\": \"^1.1.0\",\n \"chokidar\": \"^4.0.1\",\n \"cloudevents\": \"^10.0.0\",\n \"command-line-args\": \"^6.0.1\",\n \"command-line-usage\": \"^7.0.3\",\n \"compare-utf8\": \"^0.2.0\",\n \"defu\": \"^6.1.4\",\n \"eventemitter3\": \"^5.0.1\",\n \"fastify\": \"^5.0.0\",\n \"is-in-subnet\": \"^4.0.1\",\n \"jose\": \"^5.9.3\",\n \"js-xxhash\": \"^4.0.0\",\n \"json-custom-numbers\": \"^3.1.1\",\n \"kasi\": \"^1.1.0\",\n \"nanoid\": \"^5.1.2\",\n \"oxfmt\": \"^0.45.0\",\n \"parse-prometheus-text-format\": \"^1.1.1\",\n \"pg-format\": \"npm:pg-format-fix@^1.0.5\",\n \"postgres\": \"3.4.7\",\n \"semver\": \"^7.5.4\",\n \"url-pattern\": \"^1.0.3\",\n \"urlpattern-polyfill\": \"^10.1.0\",\n \"ws\": \"^8.18.1\"\n },\n \"devDependencies\": {\n \"@op-engineering/op-sqlite\": \">=15\",\n \"@vitest/runner\": \"^4.1.6\",\n \"analyze-query\": \"workspace:*\",\n \"ast-to-zql\": \"workspace:*\",\n \"expo-sqlite\": \">=15\",\n \"oxc-parser\": \"^0.129.0\",\n \"replicache\": \"workspace:*\",\n \"shared\": \"workspace:*\",\n \"syncpack\": \"^14.3.0\",\n \"typedoc\": \"^0.28.17\",\n \"typedoc-plugin-markdown\": \"^4.10.0\",\n \"typescript\": \"~6.0.2\",\n \"vite\": \"^8.0.13\",\n \"vitest\": \"^4.1.6\",\n \"zero-cache\": \"workspace:*\",\n \"zero-client\": \"workspace:*\",\n \"zero-pg\": \"workspace:*\",\n \"zero-react\": \"workspace:*\",\n \"zero-server\": \"workspace:*\",\n \"zero-solid\": \"workspace:*\",\n \"zqlite\": \"workspace:*\"\n },\n \"engines\": {\n \"node\": \">=22\"\n },\n \"packageManager\": \"pnpm@11.11.0\"\n}\n"],"mappings":""}
@@ -27,6 +27,12 @@ export type SQLiteCatchupOptions = {
27
27
  shouldUse?: ((ctx: SubscriberContext) => boolean) | undefined;
28
28
  /** Slice 9 supplies the writer-serialized purge guard. */
29
29
  cleanupGuard?: SQLiteChangeLogCleanupGuard | undefined;
30
+ /**
31
+ * How long the change log may be unavailable before declining to serve from
32
+ * it is reported as a warning rather than a debug line. Defaults to
33
+ * {@link DEFAULT_CHANGE_LOG_UNAVAILABLE_WARN_THRESHOLD_MS}.
34
+ */
35
+ notReadyWarnThresholdMs?: number | undefined;
30
36
  };
31
37
  export type TuningOptions = StorerOptions & {
32
38
  flowControlConsensusPaddingSeconds: number;
@@ -1 +1 @@
1
- {"version":3,"file":"change-streamer-service.d.ts","sourceRoot":"","sources":["../../../../../../zero-cache/src/services/change-streamer/change-streamer-service.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAC,UAAU,EAAC,MAAM,kBAAkB,CAAC;AAWjD,OAAO,KAAK,EAAC,UAAU,EAAC,MAAM,mBAAmB,CAAC;AAClD,OAAO,KAAK,EAAC,OAAO,EAAC,MAAM,uBAAuB,CAAC;AAGnD,OAAO,KAAK,EACV,YAAY,EAEb,MAAM,mCAAmC,CAAC;AAM3C,OAAO,EAGL,KAAK,0BAA0B,EAChC,MAAM,qCAAqC,CAAC;AAC7C,OAAO,KAAK,EAAC,iBAAiB,EAAC,MAAM,2CAA2C,CAAC;AAMjF,OAAO,EAEL,KAAK,qBAAqB,EAE1B,KAAK,iBAAiB,EACvB,MAAM,sBAAsB,CAAC;AAQ9B,OAAO,EAEL,KAAK,2BAA2B,EACjC,MAAM,gCAAgC,CAAC;AAExC,OAAO,EAEL,KAAK,SAAS,EACd,KAAK,aAAa,IAAI,aAAa,EACpC,MAAM,aAAa,CAAC;AAGrB,MAAM,MAAM,oBAAoB,GAAG;IACjC,sEAAsE;IACtE,aAAa,EAAE,MAAM,CAAC;IACtB,aAAa,EAAE,MAAM,CAAC;IACtB,gBAAgB,EAAE,MAAM,CAAC;IACzB;;;OAGG;IACH,qBAAqB,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC3C;;;;;;OAMG;IACH,SAAS,CAAC,EAAE,CAAC,CAAC,GAAG,EAAE,iBAAiB,KAAK,OAAO,CAAC,GAAG,SAAS,CAAC;IAC9D,0DAA0D;IAC1D,YAAY,CAAC,EAAE,2BAA2B,GAAG,SAAS,CAAC;CACxD,CAAC;AAEF,MAAM,MAAM,aAAa,GAAG,aAAa,GAAG;IAC1C,kCAAkC,EAAE,MAAM,CAAC;IAC3C,6BAA6B,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IACpD,aAAa,CAAC,EAAE,oBAAoB,GAAG,SAAS,CAAC;CAClD,CAAC;AAEF;;GAEG;AACH,wBAAsB,kBAAkB,CACtC,EAAE,EAAE,UAAU,EACd,KAAK,EAAE,OAAO,EACd,MAAM,EAAE,MAAM,EACd,gBAAgB,EAAE,MAAM,EACxB,iBAAiB,EAAE,MAAM,EACzB,QAAQ,EAAE,UAAU,EACpB,YAAY,EAAE,YAAY,EAC1B,0BAA0B,EAAE,0BAA0B,EACtD,iBAAiB,EAAE,iBAAiB,EACpC,SAAS,EAAE,SAAS,GAAG,IAAI,EAC3B,SAAS,EAAE,OAAO,EAClB,IAAI,EAAE,aAAa,EACnB,YAAY,oBAAa,GACxB,OAAO,CAAC,qBAAqB,CAAC,CA2BhC"}
1
+ {"version":3,"file":"change-streamer-service.d.ts","sourceRoot":"","sources":["../../../../../../zero-cache/src/services/change-streamer/change-streamer-service.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAC,UAAU,EAAC,MAAM,kBAAkB,CAAC;AAWjD,OAAO,KAAK,EAAC,UAAU,EAAC,MAAM,mBAAmB,CAAC;AAClD,OAAO,KAAK,EAAC,OAAO,EAAC,MAAM,uBAAuB,CAAC;AAGnD,OAAO,KAAK,EACV,YAAY,EAEb,MAAM,mCAAmC,CAAC;AAM3C,OAAO,EAGL,KAAK,0BAA0B,EAChC,MAAM,qCAAqC,CAAC;AAC7C,OAAO,KAAK,EAAC,iBAAiB,EAAC,MAAM,2CAA2C,CAAC;AAMjF,OAAO,EAEL,KAAK,qBAAqB,EAE1B,KAAK,iBAAiB,EACvB,MAAM,sBAAsB,CAAC;AAQ9B,OAAO,EAEL,KAAK,2BAA2B,EACjC,MAAM,gCAAgC,CAAC;AAExC,OAAO,EAEL,KAAK,SAAS,EACd,KAAK,aAAa,IAAI,aAAa,EACpC,MAAM,aAAa,CAAC;AAGrB,MAAM,MAAM,oBAAoB,GAAG;IACjC,sEAAsE;IACtE,aAAa,EAAE,MAAM,CAAC;IACtB,aAAa,EAAE,MAAM,CAAC;IACtB,gBAAgB,EAAE,MAAM,CAAC;IACzB;;;OAGG;IACH,qBAAqB,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC3C;;;;;;OAMG;IACH,SAAS,CAAC,EAAE,CAAC,CAAC,GAAG,EAAE,iBAAiB,KAAK,OAAO,CAAC,GAAG,SAAS,CAAC;IAC9D,0DAA0D;IAC1D,YAAY,CAAC,EAAE,2BAA2B,GAAG,SAAS,CAAC;IACvD;;;;OAIG;IACH,uBAAuB,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CAC9C,CAAC;AAEF,MAAM,MAAM,aAAa,GAAG,aAAa,GAAG;IAC1C,kCAAkC,EAAE,MAAM,CAAC;IAC3C,6BAA6B,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IACpD,aAAa,CAAC,EAAE,oBAAoB,GAAG,SAAS,CAAC;CAClD,CAAC;AAEF;;GAEG;AACH,wBAAsB,kBAAkB,CACtC,EAAE,EAAE,UAAU,EACd,KAAK,EAAE,OAAO,EACd,MAAM,EAAE,MAAM,EACd,gBAAgB,EAAE,MAAM,EACxB,iBAAiB,EAAE,MAAM,EACzB,QAAQ,EAAE,UAAU,EACpB,YAAY,EAAE,YAAY,EAC1B,0BAA0B,EAAE,0BAA0B,EACtD,iBAAiB,EAAE,iBAAiB,EACpC,SAAS,EAAE,SAAS,GAAG,IAAI,EAC3B,SAAS,EAAE,OAAO,EAClB,IAAI,EAAE,aAAa,EACnB,YAAY,oBAAa,GACxB,OAAO,CAAC,qBAAqB,CAAC,CA2BhC"}
@@ -26,6 +26,7 @@ async function initializeStreamer(lc, shard, taskID, discoveryAddress, discovery
26
26
  return new ChangeStreamerImpl(lc, shard, taskID, discoveryAddress, discoveryProtocol, changeDB, replicaVersion, changeSource, replicationStatusPublisher, purgeLock, autoReset, opts, setTimeoutFn);
27
27
  }
28
28
  var REPLICATION_STATUS_ERROR_DELAY_THRESHOLD_MS = 5e3;
29
+ var DEFAULT_CHANGE_LOG_UNAVAILABLE_WARN_THRESHOLD_MS = 6e4;
29
30
  /**
30
31
  * Upstream-agnostic dispatch of messages in a {@link ChangeStreamMessage} to a
31
32
  * {@link Forwarder} and {@link Storer} to execute the forward-store-ack
@@ -186,6 +187,7 @@ var ChangeStreamerImpl = class {
186
187
  #purgeLock;
187
188
  #stream;
188
189
  #sqliteCatchup;
190
+ #changeLogUnavailableSince;
189
191
  #lastForwardedCommitWatermark;
190
192
  #currentTransactionCompletion;
191
193
  constructor(lc, shard, taskID, discoveryAddress, discoveryProtocol, changeDB, replicaVersion, source, replicationStatusPublisher, initialPurgeLock, autoReset, opts, setTimeoutFn = setTimeout) {
@@ -449,7 +451,36 @@ var ChangeStreamerImpl = class {
449
451
  return;
450
452
  }
451
453
  if (!opts.shouldUse?.(ctx)) return;
452
- if (!this.#sqliteCatchup) this.#sqliteCatchup = new SQLiteChangeLogCatchup(this.#lc, this.#forwarder, new SQLiteChangeLogReader(this.#lc, opts.changeLogFile), {
454
+ return this.#sqliteCatchup ?? this.#openSQLiteCatchup(opts, ctx);
455
+ }
456
+ /**
457
+ * Opens the change log and, if it can serve, wraps it in the coordinator that
458
+ * subsequent subscriptions reuse.
459
+ *
460
+ * The change-streamer and the replicator that writes the log start
461
+ * independently, so the log may not exist yet, may exist without content, or
462
+ * may be unreadable. None of those can be allowed to fail a subscription:
463
+ * this is the last point at which PG catchup is still available -- past
464
+ * `Forwarder.add()` the subscriber is committed to SQLite -- so each declines
465
+ * here instead. Neither the failure nor the reader is retained, so a later
466
+ * subscription retries from scratch.
467
+ */
468
+ #openSQLiteCatchup(opts, ctx) {
469
+ let reader;
470
+ try {
471
+ reader = new SQLiteChangeLogReader(this.#lc, opts.changeLogFile);
472
+ if (reader.plan(ctx.watermark).kind === "not-ready") {
473
+ reader.close();
474
+ this.#declineSQLiteCatchup(ctx, opts, `${opts.changeLogFile} has no changes to serve yet`);
475
+ return;
476
+ }
477
+ } catch (e) {
478
+ reader?.close();
479
+ this.#declineSQLiteCatchup(ctx, opts, `cannot read ${opts.changeLogFile}`, e);
480
+ return;
481
+ }
482
+ this.#changeLogUnavailableSince = void 0;
483
+ this.#sqliteCatchup = new SQLiteChangeLogCatchup(this.#lc, this.#forwarder, reader, {
453
484
  batchSize: opts.readBatchRows,
454
485
  barrierTimeoutMs: opts.barrierTimeoutMs,
455
486
  barrierPollIntervalMs: opts.barrierPollIntervalMs,
@@ -457,6 +488,18 @@ var ChangeStreamerImpl = class {
457
488
  });
458
489
  return this.#sqliteCatchup;
459
490
  }
491
+ /**
492
+ * Reports falling back to PG catchup, tracking how long the log has been
493
+ * unavailable so that a change-streamer that merely started before its
494
+ * replicator does not look like an incident.
495
+ */
496
+ #declineSQLiteCatchup(ctx, opts, reason, error) {
497
+ const now = Date.now();
498
+ this.#changeLogUnavailableSince ??= now;
499
+ const unavailableMs = now - this.#changeLogUnavailableSince;
500
+ const threshold = opts.notReadyWarnThresholdMs ?? DEFAULT_CHANGE_LOG_UNAVAILABLE_WARN_THRESHOLD_MS;
501
+ this.#lc[unavailableMs >= threshold ? "warn" : "debug"]?.(`serving ${ctx.id} from PG catchup: ${reason} (unavailable for ${unavailableMs} ms)`, ...error === void 0 ? [] : [error]);
502
+ }
460
503
  };
461
504
  var CLEANUP_DELAY_MS = DEFAULT_MAX_RETRY_DELAY_MS * 3;
462
505
  var ROLLBACK_JSON = JSON.stringify(["rollback", { tag: "rollback" }]);
@@ -1 +1 @@
1
- {"version":3,"file":"change-streamer-service.js","names":["#lc","#shard","#changeDB","#replicaVersion","#source","#storer","#forwarder","#replicationStatusPublisher","#sqliteCatchupOptions","#autoReset","#state","#initialWatermarks","#serving","#txCounter","#changeCounter","#stream","#purgeLock","#latestStatus","#latestLagReportCommitTimeMs","#lastForwardedCommitWatermark","#handleControlMessage","#recordForwardedTransactionBoundary","#sqliteCatchup","#selectSQLiteCatchup","#captureRequiredHead","#purgeOldChanges","#currentTransactionCompletion"],"sources":["../../../../../../zero-cache/src/services/change-streamer/change-streamer-service.ts"],"sourcesContent":["import {getDefaultHighWaterMark} from 'node:stream';\nimport type {LogContext} from '@rocicorp/logger';\nimport {resolver, type Resolver} from '@rocicorp/resolver';\nimport {assert, unreachable} from '../../../../shared/src/asserts.ts';\nimport {promiseVoid} from '../../../../shared/src/resolved-promises.ts';\nimport {publishCriticalEvent} from '../../observability/events.ts';\nimport {getOrCreateCounter} from '../../observability/metrics.ts';\nimport {\n min,\n type AtLeastOne,\n type LexiVersion,\n} from '../../types/lexi-version.ts';\nimport type {PostgresDB} from '../../types/pg.ts';\nimport type {ShardID} from '../../types/shards.ts';\nimport type {Source} from '../../types/streams.ts';\nimport {Subscription} from '../../types/subscription.ts';\nimport type {\n ChangeSource,\n ChangeStream,\n} from '../change-source/change-source.ts';\nimport {\n type ChangeStreamData,\n type ChangeStreamControl,\n type Rollback,\n} from '../change-source/protocol/current/downstream.ts';\nimport {\n publishReplicationError,\n replicationStatusError,\n type ReplicationStatusPublisher,\n} from '../replicator/replication-status.ts';\nimport type {SubscriptionState} from '../replicator/schema/replication-state.ts';\nimport {\n DEFAULT_MAX_RETRY_DELAY_MS,\n RunningState,\n UnrecoverableError,\n} from '../running-state.ts';\nimport {\n type WatermarkedChange,\n type ChangeStreamerService,\n type Status,\n type SubscriberContext,\n} from './change-streamer.ts';\nimport * as ErrorType from './error-type-enum.ts';\nimport {Forwarder} from './forwarder.ts';\nimport {\n AutoResetSignal,\n ensureReplicationConfig,\n markResetRequired,\n} from './schema/tables.ts';\nimport {\n SQLiteChangeLogCatchup,\n type SQLiteChangeLogCleanupGuard,\n} from './sqlite-change-log-catchup.ts';\nimport {SQLiteChangeLogReader} from './sqlite-change-log-reader.ts';\nimport {\n Storer,\n type PurgeLock,\n type TuningOptions as StorerOptions,\n} from './storer.ts';\nimport {Subscriber} from './subscriber.ts';\n\nexport type SQLiteCatchupOptions = {\n /** The change-log database, i.e. `changeLogFileName(replicaFile)`. */\n changeLogFile: string;\n readBatchRows: number;\n barrierTimeoutMs: number;\n /**\n * Backstop for the change-log writer's ACK, which is what normally releases\n * the barrier. Defaults to the catchup coordinator's interval.\n */\n barrierPollIntervalMs?: number | undefined;\n /**\n * Slice 11 supplies the production canary selector.\n *\n * It does not need to exclude backup subscribers or the change-log writer:\n * eligibility checks reject both before invoking this selector. Serving a\n * writer from SQLite would make it wait on its own ACK.\n */\n shouldUse?: ((ctx: SubscriberContext) => boolean) | undefined;\n /** Slice 9 supplies the writer-serialized purge guard. */\n cleanupGuard?: SQLiteChangeLogCleanupGuard | undefined;\n};\n\nexport type TuningOptions = StorerOptions & {\n flowControlConsensusPaddingSeconds: number;\n flowControlEventDrivenRelease?: boolean | undefined;\n sqliteCatchup?: SQLiteCatchupOptions | undefined;\n};\n\n/**\n * Performs initialization and schema migrations to initialize a ChangeStreamerImpl.\n */\nexport async function initializeStreamer(\n lc: LogContext,\n shard: ShardID,\n taskID: string,\n discoveryAddress: string,\n discoveryProtocol: string,\n changeDB: PostgresDB,\n changeSource: ChangeSource,\n replicationStatusPublisher: ReplicationStatusPublisher,\n subscriptionState: SubscriptionState,\n purgeLock: PurgeLock | null,\n autoReset: boolean,\n opts: TuningOptions,\n setTimeoutFn = setTimeout,\n): Promise<ChangeStreamerService> {\n await ensureReplicationConfig(\n lc,\n changeDB,\n subscriptionState,\n shard,\n autoReset,\n purgeLock ?? undefined,\n setTimeoutFn,\n );\n\n const {replicaVersion} = subscriptionState;\n return new ChangeStreamerImpl(\n lc,\n shard,\n taskID,\n discoveryAddress,\n discoveryProtocol,\n changeDB,\n replicaVersion,\n changeSource,\n replicationStatusPublisher,\n purgeLock,\n autoReset,\n opts,\n setTimeoutFn,\n );\n}\n\nconst REPLICATION_STATUS_ERROR_DELAY_THRESHOLD_MS = 5000;\n\n/**\n * Upstream-agnostic dispatch of messages in a {@link ChangeStreamMessage} to a\n * {@link Forwarder} and {@link Storer} to execute the forward-store-ack\n * procedure described in {@link ChangeStreamer}.\n *\n * ### Subscriber Catchup\n *\n * Connecting clients first need to be \"caught up\" to the current watermark\n * (from stored change log entries) before new entries are forwarded to\n * them. This is non-trivial because the replication stream may be in the\n * middle of a pending streamed Transaction for which some entries have\n * already been forwarded but are not yet committed to the store.\n *\n *\n * ```\n * ------------------------------- - - - - - - - - - - - - - - - - - - -\n * | Historic changes in storage | Pending (streamed) tx | Next tx\n * ------------------------------- - - - - - - - - - - - - - - - - - - -\n * Replication stream\n * > > > > > > > > >\n * ^ ---> required catchup ---> ^\n * Subscriber watermark Subscription begins\n * ```\n *\n * Preemptively buffering the changes of every pending transaction\n * would be wasteful and consume too much memory for large transactions.\n *\n * Instead, the streamer synchronously dispatches changes and subscriptions\n * to the {@link Forwarder} and the {@link Storer} such that the two\n * components are aligned as to where in the stream the subscription started.\n * The two components then coordinate catchup and handoff via the\n * {@link Subscriber} object with the following algorithm:\n *\n * * If the streamer is in the middle of a pending Transaction, the\n * Subscriber is \"queued\" on both the Forwarder and the Storer. In this\n * state, new changes are *not* forwarded to the Subscriber, and catchup\n * is not yet executed.\n * * Once the commit message for the pending Transaction is processed\n * by the Storer, it begins catchup on the Subscriber (with a READONLY\n * snapshot so that it does not block subsequent storage operations).\n * This catchup is thus guaranteed to load the change log entries of\n * that last Transaction.\n * * When the Forwarder processes that same commit message, it moves the\n * Subscriber from the \"queued\" to the \"active\" set of clients such that\n * the Subscriber begins receiving new changes, starting from the next\n * Transaction.\n * * The Subscriber does not forward those changes, however, if its catchup\n * is not complete. Until then, it buffers the changes in memory.\n * * Once catchup is complete, the buffered changes are immediately sent\n * and the Subscriber henceforth forwards changes as they are received.\n *\n * In the (common) case where the streamer is not in the middle of a pending\n * transaction when a subscription begins, the Storer begins catchup\n * immediately and the Forwarder directly adds the Subscriber to its active\n * set. However, the Subscriber still buffers any forwarded messages until\n * its catchup is complete.\n *\n * ### Watermarks and ordering\n *\n * The ChangeStreamerService depends on its {@link ChangeSource} to send\n * changes in contiguous [`begin`, `data` ..., `data`, `commit`] sequences\n * in commit order. This follows Postgres's Logical Replication Protocol\n * Message Flow:\n *\n * https://www.postgresql.org/docs/16/protocol-logical-replication.html#PROTOCOL-LOGICAL-MESSAGES-FLOW\n *\n * > The logical replication protocol sends individual transactions one by one.\n * > This means that all messages between a pair of Begin and Commit messages belong to the same transaction.\n *\n * In order to correctly replay (new) and filter (old) messages to subscribers\n * at different points in the replication stream, these changes must be assigned\n * watermarks such that they preserve the order in which they were received\n * from the ChangeSource.\n *\n * A previous implementation incorrectly derived these watermarks from the Postgres\n * Log Sequence Numbers (LSN) of each message. However, LSNs from concurrent,\n * non-conflicting transactions can overlap, which can result in a `begin` message\n * with an earlier LSN arriving after a `commit` message. For example, the\n * changes for these transactions:\n *\n * ```\n * LSN: 1 2 3 4 5 6 7 8 9 10\n * tx1: begin data data data commit\n * tx2: begin data data data commit\n * ```\n *\n * will arrive as:\n *\n * ```\n * begin1, data2, data4, data6, commit8, begin3, data5, data7, data9, commit10\n * ```\n *\n * Thus, LSN of non-commit messages are not suitable for tracking the sorting\n * order of the replication stream.\n *\n * Instead, the ChangeStreamer uses the following algorithm for deterministic\n * catchup and filtering of changes:\n *\n * * A `commit` message is assigned to a watermark corresponding to its LSN.\n * These are guaranteed to be in commit order by definition.\n *\n * * `begin` and `data` messages are assigned to the watermark of the\n * preceding `commit` (the previous transaction, or the replication\n * slot's starting LSN) plus 1. This guarantees that they will be sorted\n * after the previously commit transaction even if their LSNs came before it.\n * This is referred to as the `preCommitWatermark`.\n *\n * * In the ChangeLog DB, messages have a secondary sort column `pos`, which is\n * the position of the message within its transaction, with the `begin` message\n * starting at `0`. This guarantees that `begin` and `data` messages will be\n * fetched in the original ChangeSource order during catchup.\n *\n * `begin` and `data` messages share the same watermark, but this is sufficient for\n * Subscriber filtering because subscribers only know about the `commit` watermarks\n * exposed in the `Downstream` `Commit` message. The Subscriber object thus compares\n * the internal watermarks of the incoming messages against the commit watermark of\n * the caller, updating the watermark at every `Commit` message that is forwarded.\n *\n * ### Cleanup\n *\n * As mentioned in the {@link ChangeStreamer} documentation: \"the ChangeStreamer\n * uses a combination of [the \"initial\", i.e. backup-derived watermark and] ACK\n * responses from connected subscribers to determine the watermark up\n * to which it is safe to purge old change log entries.\"\n *\n * More concretely:\n *\n * * The `initial`, backup-derived watermark is the earliest to which cleanup\n * should ever happen.\n *\n * * However, it is possible for the replica backup to be *ahead* of a connected\n * subscriber; and if a network error causes that subscriber to retry from its\n * last watermark, the change streamer must support it.\n *\n * Thus, before cleaning up to an `initial` backup-derived watermark, the change\n * streamer first confirms that all connected subscribers have also passed\n * that watermark.\n */\nclass ChangeStreamerImpl implements ChangeStreamerService {\n readonly id: string;\n readonly #lc: LogContext;\n readonly #shard: ShardID;\n readonly #changeDB: PostgresDB;\n readonly #replicaVersion: string;\n readonly #source: ChangeSource;\n readonly #storer: Storer;\n readonly #forwarder: Forwarder;\n readonly #replicationStatusPublisher: ReplicationStatusPublisher;\n readonly #sqliteCatchupOptions: SQLiteCatchupOptions | undefined;\n\n readonly #autoReset: boolean;\n readonly #state: RunningState;\n readonly #initialWatermarks = new Set<string>();\n\n // Starting the (Postgres) ChangeStream results in killing the previous\n // Postgres subscriber, potentially creating a gap in which the old\n // change-streamer has shut down and the new change-streamer has not yet\n // been recognized as \"healthy\" (and thus does not get any requests).\n //\n // To minimize this gap, delay starting the ChangeStream until the first\n // request from a `serving` replicator, indicating that higher level\n // load-balancing / routing logic has begun routing requests to this task.\n readonly #serving = resolver();\n\n readonly #txCounter = getOrCreateCounter(\n 'replication',\n 'transactions',\n 'Count of replicated transactions',\n );\n readonly #changeCounter = getOrCreateCounter(\n 'replication',\n 'changes',\n 'Count of replicated changes (DML or DDL statements)',\n );\n\n #latestStatus: Status;\n #latestLagReportCommitTimeMs = 0;\n #purgeLock: PurgeLock | null;\n #stream: ChangeStream | undefined;\n #sqliteCatchup: SQLiteChangeLogCatchup | undefined;\n #lastForwardedCommitWatermark: string | undefined;\n #currentTransactionCompletion:\n | Resolver<ForwardedTransactionCompletion>\n | undefined;\n\n constructor(\n lc: LogContext,\n shard: ShardID,\n taskID: string,\n discoveryAddress: string,\n discoveryProtocol: string,\n changeDB: PostgresDB,\n replicaVersion: string,\n source: ChangeSource,\n replicationStatusPublisher: ReplicationStatusPublisher,\n initialPurgeLock: PurgeLock | null,\n autoReset: boolean,\n opts: TuningOptions,\n setTimeoutFn = setTimeout,\n ) {\n this.id = `change-streamer`;\n this.#lc = lc.withContext('component', 'change-streamer');\n this.#shard = shard;\n this.#changeDB = changeDB;\n this.#replicaVersion = replicaVersion;\n this.#source = source;\n this.#storer = new Storer(\n lc,\n shard,\n taskID,\n discoveryAddress,\n discoveryProtocol,\n changeDB,\n replicaVersion,\n consumed => this.#stream?.acks.push(['status', consumed[1], consumed[2]]),\n err => this.stop(err),\n opts,\n );\n this.#forwarder = new Forwarder(lc, {\n flowControlConsensusPaddingSeconds:\n opts.flowControlConsensusPaddingSeconds,\n eventDrivenRelease: opts.flowControlEventDrivenRelease,\n });\n this.#replicationStatusPublisher = replicationStatusPublisher;\n this.#sqliteCatchupOptions = opts.sqliteCatchup;\n this.#purgeLock = initialPurgeLock;\n this.#autoReset = autoReset;\n this.#state = new RunningState(this.id, undefined, setTimeoutFn);\n this.#latestStatus = {tag: 'status'};\n }\n\n async run() {\n this.#lc.info?.('starting change stream');\n\n this.#forwarder.startProgressMonitor();\n\n const lagReportInit = await this.#source.startLagReporter();\n if (lagReportInit) {\n this.#latestStatus.lagReport = {\n nextSendTimeMs: lagReportInit.nextSendTimeMs,\n };\n // Record the commit time of the initiated lag report (i.e. \"head\")\n // for the purpose of skipping over any lag reports that are re-streamed\n // by the change-source in the case of a change-streamer starting from\n // an older watermark.\n this.#latestLagReportCommitTimeMs = lagReportInit.firstCommitTimeMs;\n }\n\n // Once this change-streamer acquires \"ownership\" of the change DB,\n // it is safe to start the storer.\n await this.#storer.assumeOwnership(this.#purgeLock);\n this.#purgeLock = null;\n\n // The threshold in (estimated number of) bytes to send() on subscriber\n // websockets before `await`-ing the I/O buffers to be ready for more.\n const flushBytesThreshold = getDefaultHighWaterMark(false);\n\n while (this.#state.shouldRun()) {\n let err: unknown;\n let watermark: string | null = null;\n let unflushedBytes = 0;\n try {\n const {lastWatermark, backfillRequests} =\n await this.#storer.getStartStreamInitializationParameters();\n // SQLite catchup must not be eligible until this has been initialized\n // from the durable PG head. Commits observed only since process startup\n // are insufficient after a change-streamer restart.\n this.#lastForwardedCommitWatermark = lastWatermark;\n const stream = await this.#source.startStream(\n lastWatermark,\n backfillRequests,\n );\n this.#storer.run().catch(e => stream.changes.cancel(e));\n\n this.#stream = stream;\n if (\n this.#state.resetBackoff() >\n REPLICATION_STATUS_ERROR_DELAY_THRESHOLD_MS\n ) {\n // After recovering from a backoff for which a replication status\n // error was published, publish an OK status\n this.#replicationStatusPublisher.publish(\n this.#lc,\n 'Replicating',\n `Replicating from ${lastWatermark}`,\n );\n }\n watermark = null;\n\n for await (const change of stream.changes) {\n const [type, msg] = change;\n switch (type) {\n case 'status':\n if (msg.ack) {\n this.#storer.status(change); // storer acks once it gets through its queue\n }\n if (\n msg.lagReport &&\n msg.lagReport.lastTimings.commitTimeMs >=\n this.#latestLagReportCommitTimeMs\n ) {\n // Lag reports are not stored in the cdc change log, but rather\n // only forwarded on \"live\" connections. When a new subscriber\n // is catching up, it is initialized with the #latestStatus\n // from which it can measure lag while catching up.\n this.#latestStatus.lagReport = msg.lagReport;\n this.#latestLagReportCommitTimeMs =\n msg.lagReport.lastTimings.commitTimeMs;\n this.#forwarder.sendStatus(this.#latestStatus);\n }\n continue;\n case 'control':\n await this.#handleControlMessage(msg);\n continue; // control messages are not stored/forwarded\n case 'begin':\n watermark = change[2].commitWatermark;\n break;\n case 'commit':\n if (watermark !== change[2].watermark) {\n throw new UnrecoverableError(\n `commit watermark ${change[2].watermark} does not match 'begin' watermark ${watermark}`,\n );\n }\n this.#txCounter.add(1);\n break;\n default:\n if (type === 'data') {\n this.#changeCounter.add(1);\n }\n if (watermark === null) {\n throw new UnrecoverableError(\n `${type} change (${msg.tag}) received before 'begin' message`,\n );\n }\n break;\n }\n\n const json = this.#storer.store(watermark, change);\n const entry: WatermarkedChange = [watermark, change[1].tag, json];\n unflushedBytes += json.length;\n if (unflushedBytes < flushBytesThreshold) {\n // pipeline changes until flushBytesThreshold\n this.#forwarder.forward(entry);\n this.#recordForwardedTransactionBoundary(type, entry[0]);\n } else {\n // Wait for messages to clear socket buffers to ensure that they\n // make their way to subscribers. Without this `await`, the\n // messages end up being buffered in this process, which:\n // (1) results in memory pressure and increased GC activity\n // (2) prevents subscribers from processing the messages as they\n // arrive, instead getting them in a large batch after being\n // idle while they were queued (causing further delays).\n const forwarded = this.#forwarder.forwardWithFlowControl(entry);\n // forwardWithFlowControl synchronously sends the entry and updates\n // the Forwarder's transaction state before returning its flow-\n // control promise. Record the boundary before awaiting that promise\n // so registrations during the wait observe the forwarded state.\n this.#recordForwardedTransactionBoundary(type, entry[0]);\n await forwarded;\n unflushedBytes = 0;\n }\n\n if (type === 'commit' || type === 'rollback') {\n watermark = null;\n }\n\n // Allow the storer to exert back pressure.\n const readyForMore = this.#storer.readyForMore();\n if (readyForMore) {\n await readyForMore;\n }\n }\n } catch (e) {\n err = e;\n } finally {\n this.#stream?.changes.cancel();\n this.#stream = undefined;\n }\n\n // When the change stream is interrupted, abort any pending transaction.\n if (watermark) {\n this.#lc.warn?.(`aborting interrupted transaction ${watermark}`);\n this.#storer.abort();\n this.#forwarder.forward([watermark, 'rollback', ROLLBACK_JSON]);\n this.#recordForwardedTransactionBoundary('rollback', watermark);\n }\n\n // Backoff and drain any pending entries in the storer before reconnecting.\n await Promise.all([\n this.#storer.stop(),\n this.#state.backoff(this.#lc, err),\n this.#state.retryDelay > REPLICATION_STATUS_ERROR_DELAY_THRESHOLD_MS\n ? publishCriticalEvent(\n this.#lc,\n replicationStatusError(this.#lc, 'Replicating', err),\n )\n : promiseVoid,\n ]);\n }\n\n this.#forwarder.stopProgressMonitor();\n this.#lc.info?.('ChangeStreamer stopped');\n }\n\n async #handleControlMessage(msg: ChangeStreamControl[1]) {\n this.#lc.info?.('received control message', msg);\n const {tag} = msg;\n\n switch (tag) {\n case 'reset-required':\n await markResetRequired(this.#changeDB, this.#shard);\n await publishReplicationError(\n this.#lc,\n 'Replicating',\n msg.message ?? 'Resync required',\n msg.errorDetails,\n );\n if (this.#autoReset) {\n this.#lc.warn?.('shutting down for auto-reset');\n await this.stop(new AutoResetSignal());\n }\n break;\n default:\n unreachable(tag);\n }\n }\n\n async subscribe(ctx: SubscriberContext): Promise<Source<string>> {\n const {protocolVersion, id, mode, replicaVersion, watermark} = ctx;\n if (mode === 'serving') {\n this.#serving.resolve();\n }\n let cleanupSubscriber = () => {};\n const downstream = Subscription.create<string>({\n cleanup: () => cleanupSubscriber(),\n });\n const subscriber = new Subscriber(\n protocolVersion,\n id,\n watermark,\n downstream,\n () => this.#latestStatus,\n ctx.logsChangeStream\n ? {\n // This subscriber writes the SQLite change log that catchup reads,\n // so its ACKs are what advance that log's head. Routing them to the\n // barrier saves it from polling the replica for the same fact.\n onAck: acked => this.#sqliteCatchup?.onChangeLogWriterAck(acked),\n }\n : {},\n );\n const lc = this.#lc.withContext('subscriber', subscriber.id);\n cleanupSubscriber = () => {\n lc.info?.(`removing subscriber ${subscriber.id}`);\n this.#forwarder.remove(subscriber);\n };\n if (replicaVersion !== this.#replicaVersion) {\n lc.warn?.(`rejecting subscriber at replica version ${replicaVersion}`);\n subscriber.close(\n ErrorType.WrongReplicaVersion,\n `current replica version is ${\n this.#replicaVersion\n } (requested ${replicaVersion})`,\n );\n } else {\n lc.info?.(`adding subscriber ${subscriber.id}`);\n\n const sqliteCatchup = this.#selectSQLiteCatchup(ctx);\n if (sqliteCatchup) {\n cleanupSubscriber = () => sqliteCatchup.remove(subscriber);\n await sqliteCatchup.catchup(subscriber, () =>\n this.#captureRequiredHead(),\n );\n } else {\n // Keep the existing PG registration/catchup lockstep unchanged when\n // SQLite was not selected before Forwarder.add().\n this.#forwarder.add(subscriber);\n this.#storer.catchup(subscriber, mode);\n }\n }\n return downstream;\n }\n\n scheduleCleanup(watermark: string) {\n const origSize = this.#initialWatermarks.size;\n this.#initialWatermarks.add(watermark);\n\n if (origSize === 0) {\n this.#state.setTimeout(() => this.#purgeOldChanges(), CLEANUP_DELAY_MS);\n }\n }\n\n async getChangeLogState(): Promise<{\n replicaVersion: string;\n minWatermark: string;\n }> {\n const minWatermark = await this.#storer.getMinWatermarkForCatchup();\n if (!minWatermark) {\n this.#lc.warn?.(\n `Unexpected empty changeLog. Resync if \"Local replica watermark\" errors arise`,\n );\n }\n return {\n replicaVersion: this.#replicaVersion,\n minWatermark: minWatermark ?? this.#replicaVersion,\n };\n }\n\n /**\n * Makes a best effort to purge the change log. In the event of a database\n * error, exceptions will be logged and swallowed, so this method is safe\n * to run in a timeout.\n */\n async #purgeOldChanges(): Promise<void> {\n const initial = [...this.#initialWatermarks];\n if (initial.length === 0) {\n this.#lc.warn?.('No initial watermarks to check for cleanup'); // Not expected.\n return;\n }\n const current = [...this.#forwarder.getAcks()];\n if (current.length === 0) {\n // Also not expected, but possible (e.g. subscriber connects, then disconnects).\n // Bail to be safe.\n this.#lc.warn?.('No subscribers to confirm cleanup');\n return;\n }\n try {\n const earliestInitial = min(...(initial as AtLeastOne<LexiVersion>));\n const earliestCurrent = min(...(current as AtLeastOne<LexiVersion>));\n if (earliestCurrent < earliestInitial) {\n this.#lc.info?.(\n `At least one client is behind backup (${earliestCurrent} < ${earliestInitial})`,\n );\n } else {\n this.#lc.info?.(`Purging changes before ${earliestInitial} ...`);\n const start = performance.now();\n const deleted = await this.#storer.purgeRecordsBefore(earliestInitial);\n const elapsed = (performance.now() - start).toFixed(2);\n this.#lc.info?.(\n `Purged ${deleted} changes before ${earliestInitial} (${elapsed} ms)`,\n );\n this.#initialWatermarks.delete(earliestInitial);\n }\n } catch (e) {\n this.#lc.warn?.(`error purging change log`, e);\n } finally {\n if (this.#initialWatermarks.size) {\n // If there are unpurged watermarks to check, schedule the next purge.\n this.#state.setTimeout(() => this.#purgeOldChanges(), CLEANUP_DELAY_MS);\n }\n }\n }\n\n async stop(err?: unknown) {\n this.#state.stop(this.#lc, err);\n this.#stream?.changes.cancel();\n this.#sqliteCatchup?.close();\n await this.#storer.stop();\n await this.#source.stop();\n }\n\n #recordForwardedTransactionBoundary(\n type: ChangeStreamData[0],\n watermark: string,\n ) {\n switch (type) {\n case 'begin':\n assert(\n this.#currentTransactionCompletion === undefined,\n 'forwarded begin while a transaction is already in progress',\n );\n this.#currentTransactionCompletion =\n resolver<ForwardedTransactionCompletion>();\n break;\n case 'commit': {\n const completion = this.#currentTransactionCompletion;\n assert(completion, 'forwarded commit without a pending transaction');\n this.#lastForwardedCommitWatermark = watermark;\n this.#currentTransactionCompletion = undefined;\n completion.resolve({kind: 'committed', watermark});\n break;\n }\n case 'rollback': {\n const completion = this.#currentTransactionCompletion;\n assert(completion, 'forwarded rollback without a pending transaction');\n const committed = this.#lastForwardedCommitWatermark;\n assert(committed, 'last forwarded commit watermark is not initialized');\n this.#currentTransactionCompletion = undefined;\n completion.resolve({kind: 'rolled-back', watermark: committed});\n break;\n }\n }\n }\n\n #captureRequiredHead(): string | Promise<string> {\n const committed = this.#lastForwardedCommitWatermark;\n assert(committed, 'last forwarded commit watermark is not initialized');\n return (\n this.#currentTransactionCompletion?.promise.then(\n completion => completion.watermark,\n ) ?? committed\n );\n }\n\n #selectSQLiteCatchup(\n ctx: SubscriberContext,\n ): SQLiteChangeLogCatchup | undefined {\n const opts = this.#sqliteCatchupOptions;\n // Slice 11 supplies a non-default selector. Until then, this keeps all\n // production subscriptions on PG even though the complete SQLite handoff\n // path is wired and testable.\n if (this.#lastForwardedCommitWatermark === undefined || !opts) {\n return undefined;\n }\n // SQLite catchup is only for disposable serving replicas. Backup\n // subscribers retain the existing PG recovery policy until RMv2 can\n // resume the canonical replica directly from replica/backup/slot state.\n // Enforce this before the selector so no canary policy can override it.\n if (ctx.mode !== 'serving') {\n this.#lc.info?.(\n `not serving backup subscriber ${ctx.id} from SQLite catchup`,\n );\n return undefined;\n }\n if (ctx.logsChangeStream) {\n // A change-log writer cannot be served from the change log it writes.\n // The barrier waits for a head that only this subscriber can advance,\n // and it advances it by consuming the very subscription the barrier is\n // holding: it would wait on its own ACK until the deadline, on every\n // reconnect. Enforced here rather than left to the selector so that no\n // selector can express it.\n this.#lc.warn?.(\n `not serving change-log writer ${ctx.id} from SQLite catchup: ` +\n `it would wait on its own ACK`,\n );\n return undefined;\n }\n if (!opts.shouldUse?.(ctx)) {\n return undefined;\n }\n if (!this.#sqliteCatchup) {\n this.#sqliteCatchup = new SQLiteChangeLogCatchup(\n this.#lc,\n this.#forwarder,\n new SQLiteChangeLogReader(this.#lc, opts.changeLogFile),\n {\n batchSize: opts.readBatchRows,\n barrierTimeoutMs: opts.barrierTimeoutMs,\n barrierPollIntervalMs: opts.barrierPollIntervalMs,\n cleanupGuard: opts.cleanupGuard,\n },\n );\n }\n return this.#sqliteCatchup;\n }\n}\n\ntype ForwardedTransactionCompletion =\n | {kind: 'committed'; watermark: string}\n | {kind: 'rolled-back'; watermark: string};\n\n// The delay between receiving an initial, backup-based watermark\n// and performing a check of whether to purge records before it.\n// This delay should be long enough to handle situations like the following:\n//\n// 1. `litestream restore` downloads a backup for the `replication-manager`\n// 2. `replication-manager` starts up and runs this `change-streamer`\n// 3. `zero-cache`s that are running on a different replica connect to this\n// `change-streamer` after exponential backoff retries.\n//\n// It is possible for a `zero-cache`[3] to be behind the backup restored [1].\n// This cleanup delay (30 seconds) is thus set to be a value comfortably\n// longer than the max delay for exponential backoff (10 seconds) in\n// `services/running-state.ts`. This allows the `zero-cache` [3] to reconnect\n// so that the `change-streamer` can track its progress and know when it has\n// surpassed the initial watermark of the backup [1].\nconst CLEANUP_DELAY_MS = DEFAULT_MAX_RETRY_DELAY_MS * 3;\n\nconst ROLLBACK_JSON = JSON.stringify([\n 'rollback',\n {tag: 'rollback'},\n] satisfies Rollback);\n"],"mappings":"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"}
1
+ {"version":3,"file":"change-streamer-service.js","names":["#lc","#shard","#changeDB","#replicaVersion","#source","#storer","#forwarder","#replicationStatusPublisher","#sqliteCatchupOptions","#autoReset","#state","#initialWatermarks","#serving","#txCounter","#changeCounter","#stream","#purgeLock","#latestStatus","#latestLagReportCommitTimeMs","#lastForwardedCommitWatermark","#handleControlMessage","#recordForwardedTransactionBoundary","#sqliteCatchup","#selectSQLiteCatchup","#captureRequiredHead","#purgeOldChanges","#currentTransactionCompletion","#openSQLiteCatchup","#declineSQLiteCatchup","#changeLogUnavailableSince"],"sources":["../../../../../../zero-cache/src/services/change-streamer/change-streamer-service.ts"],"sourcesContent":["import {getDefaultHighWaterMark} from 'node:stream';\nimport type {LogContext} from '@rocicorp/logger';\nimport {resolver, type Resolver} from '@rocicorp/resolver';\nimport {assert, unreachable} from '../../../../shared/src/asserts.ts';\nimport {promiseVoid} from '../../../../shared/src/resolved-promises.ts';\nimport {publishCriticalEvent} from '../../observability/events.ts';\nimport {getOrCreateCounter} from '../../observability/metrics.ts';\nimport {\n min,\n type AtLeastOne,\n type LexiVersion,\n} from '../../types/lexi-version.ts';\nimport type {PostgresDB} from '../../types/pg.ts';\nimport type {ShardID} from '../../types/shards.ts';\nimport type {Source} from '../../types/streams.ts';\nimport {Subscription} from '../../types/subscription.ts';\nimport type {\n ChangeSource,\n ChangeStream,\n} from '../change-source/change-source.ts';\nimport {\n type ChangeStreamData,\n type ChangeStreamControl,\n type Rollback,\n} from '../change-source/protocol/current/downstream.ts';\nimport {\n publishReplicationError,\n replicationStatusError,\n type ReplicationStatusPublisher,\n} from '../replicator/replication-status.ts';\nimport type {SubscriptionState} from '../replicator/schema/replication-state.ts';\nimport {\n DEFAULT_MAX_RETRY_DELAY_MS,\n RunningState,\n UnrecoverableError,\n} from '../running-state.ts';\nimport {\n type WatermarkedChange,\n type ChangeStreamerService,\n type Status,\n type SubscriberContext,\n} from './change-streamer.ts';\nimport * as ErrorType from './error-type-enum.ts';\nimport {Forwarder} from './forwarder.ts';\nimport {\n AutoResetSignal,\n ensureReplicationConfig,\n markResetRequired,\n} from './schema/tables.ts';\nimport {\n SQLiteChangeLogCatchup,\n type SQLiteChangeLogCleanupGuard,\n} from './sqlite-change-log-catchup.ts';\nimport {SQLiteChangeLogReader} from './sqlite-change-log-reader.ts';\nimport {\n Storer,\n type PurgeLock,\n type TuningOptions as StorerOptions,\n} from './storer.ts';\nimport {Subscriber} from './subscriber.ts';\n\nexport type SQLiteCatchupOptions = {\n /** The change-log database, i.e. `changeLogFileName(replicaFile)`. */\n changeLogFile: string;\n readBatchRows: number;\n barrierTimeoutMs: number;\n /**\n * Backstop for the change-log writer's ACK, which is what normally releases\n * the barrier. Defaults to the catchup coordinator's interval.\n */\n barrierPollIntervalMs?: number | undefined;\n /**\n * Slice 11 supplies the production canary selector.\n *\n * It does not need to exclude backup subscribers or the change-log writer:\n * eligibility checks reject both before invoking this selector. Serving a\n * writer from SQLite would make it wait on its own ACK.\n */\n shouldUse?: ((ctx: SubscriberContext) => boolean) | undefined;\n /** Slice 9 supplies the writer-serialized purge guard. */\n cleanupGuard?: SQLiteChangeLogCleanupGuard | undefined;\n /**\n * How long the change log may be unavailable before declining to serve from\n * it is reported as a warning rather than a debug line. Defaults to\n * {@link DEFAULT_CHANGE_LOG_UNAVAILABLE_WARN_THRESHOLD_MS}.\n */\n notReadyWarnThresholdMs?: number | undefined;\n};\n\nexport type TuningOptions = StorerOptions & {\n flowControlConsensusPaddingSeconds: number;\n flowControlEventDrivenRelease?: boolean | undefined;\n sqliteCatchup?: SQLiteCatchupOptions | undefined;\n};\n\n/**\n * Performs initialization and schema migrations to initialize a ChangeStreamerImpl.\n */\nexport async function initializeStreamer(\n lc: LogContext,\n shard: ShardID,\n taskID: string,\n discoveryAddress: string,\n discoveryProtocol: string,\n changeDB: PostgresDB,\n changeSource: ChangeSource,\n replicationStatusPublisher: ReplicationStatusPublisher,\n subscriptionState: SubscriptionState,\n purgeLock: PurgeLock | null,\n autoReset: boolean,\n opts: TuningOptions,\n setTimeoutFn = setTimeout,\n): Promise<ChangeStreamerService> {\n await ensureReplicationConfig(\n lc,\n changeDB,\n subscriptionState,\n shard,\n autoReset,\n purgeLock ?? undefined,\n setTimeoutFn,\n );\n\n const {replicaVersion} = subscriptionState;\n return new ChangeStreamerImpl(\n lc,\n shard,\n taskID,\n discoveryAddress,\n discoveryProtocol,\n changeDB,\n replicaVersion,\n changeSource,\n replicationStatusPublisher,\n purgeLock,\n autoReset,\n opts,\n setTimeoutFn,\n );\n}\n\nconst REPLICATION_STATUS_ERROR_DELAY_THRESHOLD_MS = 5000;\n\n// How long the change log may be unavailable before declining to serve from it\n// stops looking like normal startup ordering. A change-streamer routinely\n// starts before the replicator has created and reconciled the log, so early\n// declines are expected. Still declining a minute later means the log is not\n// being written at all -- `sqliteChangeLogMode=off`, a replicator crash loop,\n// or a path mismatch -- which is worth surfacing.\nconst DEFAULT_CHANGE_LOG_UNAVAILABLE_WARN_THRESHOLD_MS = 60_000;\n\n/**\n * Upstream-agnostic dispatch of messages in a {@link ChangeStreamMessage} to a\n * {@link Forwarder} and {@link Storer} to execute the forward-store-ack\n * procedure described in {@link ChangeStreamer}.\n *\n * ### Subscriber Catchup\n *\n * Connecting clients first need to be \"caught up\" to the current watermark\n * (from stored change log entries) before new entries are forwarded to\n * them. This is non-trivial because the replication stream may be in the\n * middle of a pending streamed Transaction for which some entries have\n * already been forwarded but are not yet committed to the store.\n *\n *\n * ```\n * ------------------------------- - - - - - - - - - - - - - - - - - - -\n * | Historic changes in storage | Pending (streamed) tx | Next tx\n * ------------------------------- - - - - - - - - - - - - - - - - - - -\n * Replication stream\n * > > > > > > > > >\n * ^ ---> required catchup ---> ^\n * Subscriber watermark Subscription begins\n * ```\n *\n * Preemptively buffering the changes of every pending transaction\n * would be wasteful and consume too much memory for large transactions.\n *\n * Instead, the streamer synchronously dispatches changes and subscriptions\n * to the {@link Forwarder} and the {@link Storer} such that the two\n * components are aligned as to where in the stream the subscription started.\n * The two components then coordinate catchup and handoff via the\n * {@link Subscriber} object with the following algorithm:\n *\n * * If the streamer is in the middle of a pending Transaction, the\n * Subscriber is \"queued\" on both the Forwarder and the Storer. In this\n * state, new changes are *not* forwarded to the Subscriber, and catchup\n * is not yet executed.\n * * Once the commit message for the pending Transaction is processed\n * by the Storer, it begins catchup on the Subscriber (with a READONLY\n * snapshot so that it does not block subsequent storage operations).\n * This catchup is thus guaranteed to load the change log entries of\n * that last Transaction.\n * * When the Forwarder processes that same commit message, it moves the\n * Subscriber from the \"queued\" to the \"active\" set of clients such that\n * the Subscriber begins receiving new changes, starting from the next\n * Transaction.\n * * The Subscriber does not forward those changes, however, if its catchup\n * is not complete. Until then, it buffers the changes in memory.\n * * Once catchup is complete, the buffered changes are immediately sent\n * and the Subscriber henceforth forwards changes as they are received.\n *\n * In the (common) case where the streamer is not in the middle of a pending\n * transaction when a subscription begins, the Storer begins catchup\n * immediately and the Forwarder directly adds the Subscriber to its active\n * set. However, the Subscriber still buffers any forwarded messages until\n * its catchup is complete.\n *\n * ### Watermarks and ordering\n *\n * The ChangeStreamerService depends on its {@link ChangeSource} to send\n * changes in contiguous [`begin`, `data` ..., `data`, `commit`] sequences\n * in commit order. This follows Postgres's Logical Replication Protocol\n * Message Flow:\n *\n * https://www.postgresql.org/docs/16/protocol-logical-replication.html#PROTOCOL-LOGICAL-MESSAGES-FLOW\n *\n * > The logical replication protocol sends individual transactions one by one.\n * > This means that all messages between a pair of Begin and Commit messages belong to the same transaction.\n *\n * In order to correctly replay (new) and filter (old) messages to subscribers\n * at different points in the replication stream, these changes must be assigned\n * watermarks such that they preserve the order in which they were received\n * from the ChangeSource.\n *\n * A previous implementation incorrectly derived these watermarks from the Postgres\n * Log Sequence Numbers (LSN) of each message. However, LSNs from concurrent,\n * non-conflicting transactions can overlap, which can result in a `begin` message\n * with an earlier LSN arriving after a `commit` message. For example, the\n * changes for these transactions:\n *\n * ```\n * LSN: 1 2 3 4 5 6 7 8 9 10\n * tx1: begin data data data commit\n * tx2: begin data data data commit\n * ```\n *\n * will arrive as:\n *\n * ```\n * begin1, data2, data4, data6, commit8, begin3, data5, data7, data9, commit10\n * ```\n *\n * Thus, LSN of non-commit messages are not suitable for tracking the sorting\n * order of the replication stream.\n *\n * Instead, the ChangeStreamer uses the following algorithm for deterministic\n * catchup and filtering of changes:\n *\n * * A `commit` message is assigned to a watermark corresponding to its LSN.\n * These are guaranteed to be in commit order by definition.\n *\n * * `begin` and `data` messages are assigned to the watermark of the\n * preceding `commit` (the previous transaction, or the replication\n * slot's starting LSN) plus 1. This guarantees that they will be sorted\n * after the previously commit transaction even if their LSNs came before it.\n * This is referred to as the `preCommitWatermark`.\n *\n * * In the ChangeLog DB, messages have a secondary sort column `pos`, which is\n * the position of the message within its transaction, with the `begin` message\n * starting at `0`. This guarantees that `begin` and `data` messages will be\n * fetched in the original ChangeSource order during catchup.\n *\n * `begin` and `data` messages share the same watermark, but this is sufficient for\n * Subscriber filtering because subscribers only know about the `commit` watermarks\n * exposed in the `Downstream` `Commit` message. The Subscriber object thus compares\n * the internal watermarks of the incoming messages against the commit watermark of\n * the caller, updating the watermark at every `Commit` message that is forwarded.\n *\n * ### Cleanup\n *\n * As mentioned in the {@link ChangeStreamer} documentation: \"the ChangeStreamer\n * uses a combination of [the \"initial\", i.e. backup-derived watermark and] ACK\n * responses from connected subscribers to determine the watermark up\n * to which it is safe to purge old change log entries.\"\n *\n * More concretely:\n *\n * * The `initial`, backup-derived watermark is the earliest to which cleanup\n * should ever happen.\n *\n * * However, it is possible for the replica backup to be *ahead* of a connected\n * subscriber; and if a network error causes that subscriber to retry from its\n * last watermark, the change streamer must support it.\n *\n * Thus, before cleaning up to an `initial` backup-derived watermark, the change\n * streamer first confirms that all connected subscribers have also passed\n * that watermark.\n */\nclass ChangeStreamerImpl implements ChangeStreamerService {\n readonly id: string;\n readonly #lc: LogContext;\n readonly #shard: ShardID;\n readonly #changeDB: PostgresDB;\n readonly #replicaVersion: string;\n readonly #source: ChangeSource;\n readonly #storer: Storer;\n readonly #forwarder: Forwarder;\n readonly #replicationStatusPublisher: ReplicationStatusPublisher;\n readonly #sqliteCatchupOptions: SQLiteCatchupOptions | undefined;\n\n readonly #autoReset: boolean;\n readonly #state: RunningState;\n readonly #initialWatermarks = new Set<string>();\n\n // Starting the (Postgres) ChangeStream results in killing the previous\n // Postgres subscriber, potentially creating a gap in which the old\n // change-streamer has shut down and the new change-streamer has not yet\n // been recognized as \"healthy\" (and thus does not get any requests).\n //\n // To minimize this gap, delay starting the ChangeStream until the first\n // request from a `serving` replicator, indicating that higher level\n // load-balancing / routing logic has begun routing requests to this task.\n readonly #serving = resolver();\n\n readonly #txCounter = getOrCreateCounter(\n 'replication',\n 'transactions',\n 'Count of replicated transactions',\n );\n readonly #changeCounter = getOrCreateCounter(\n 'replication',\n 'changes',\n 'Count of replicated changes (DML or DDL statements)',\n );\n\n #latestStatus: Status;\n #latestLagReportCommitTimeMs = 0;\n #purgeLock: PurgeLock | null;\n #stream: ChangeStream | undefined;\n #sqliteCatchup: SQLiteChangeLogCatchup | undefined;\n #changeLogUnavailableSince: number | undefined;\n #lastForwardedCommitWatermark: string | undefined;\n #currentTransactionCompletion:\n | Resolver<ForwardedTransactionCompletion>\n | undefined;\n\n constructor(\n lc: LogContext,\n shard: ShardID,\n taskID: string,\n discoveryAddress: string,\n discoveryProtocol: string,\n changeDB: PostgresDB,\n replicaVersion: string,\n source: ChangeSource,\n replicationStatusPublisher: ReplicationStatusPublisher,\n initialPurgeLock: PurgeLock | null,\n autoReset: boolean,\n opts: TuningOptions,\n setTimeoutFn = setTimeout,\n ) {\n this.id = `change-streamer`;\n this.#lc = lc.withContext('component', 'change-streamer');\n this.#shard = shard;\n this.#changeDB = changeDB;\n this.#replicaVersion = replicaVersion;\n this.#source = source;\n this.#storer = new Storer(\n lc,\n shard,\n taskID,\n discoveryAddress,\n discoveryProtocol,\n changeDB,\n replicaVersion,\n consumed => this.#stream?.acks.push(['status', consumed[1], consumed[2]]),\n err => this.stop(err),\n opts,\n );\n this.#forwarder = new Forwarder(lc, {\n flowControlConsensusPaddingSeconds:\n opts.flowControlConsensusPaddingSeconds,\n eventDrivenRelease: opts.flowControlEventDrivenRelease,\n });\n this.#replicationStatusPublisher = replicationStatusPublisher;\n this.#sqliteCatchupOptions = opts.sqliteCatchup;\n this.#purgeLock = initialPurgeLock;\n this.#autoReset = autoReset;\n this.#state = new RunningState(this.id, undefined, setTimeoutFn);\n this.#latestStatus = {tag: 'status'};\n }\n\n async run() {\n this.#lc.info?.('starting change stream');\n\n this.#forwarder.startProgressMonitor();\n\n const lagReportInit = await this.#source.startLagReporter();\n if (lagReportInit) {\n this.#latestStatus.lagReport = {\n nextSendTimeMs: lagReportInit.nextSendTimeMs,\n };\n // Record the commit time of the initiated lag report (i.e. \"head\")\n // for the purpose of skipping over any lag reports that are re-streamed\n // by the change-source in the case of a change-streamer starting from\n // an older watermark.\n this.#latestLagReportCommitTimeMs = lagReportInit.firstCommitTimeMs;\n }\n\n // Once this change-streamer acquires \"ownership\" of the change DB,\n // it is safe to start the storer.\n await this.#storer.assumeOwnership(this.#purgeLock);\n this.#purgeLock = null;\n\n // The threshold in (estimated number of) bytes to send() on subscriber\n // websockets before `await`-ing the I/O buffers to be ready for more.\n const flushBytesThreshold = getDefaultHighWaterMark(false);\n\n while (this.#state.shouldRun()) {\n let err: unknown;\n let watermark: string | null = null;\n let unflushedBytes = 0;\n try {\n const {lastWatermark, backfillRequests} =\n await this.#storer.getStartStreamInitializationParameters();\n // SQLite catchup must not be eligible until this has been initialized\n // from the durable PG head. Commits observed only since process startup\n // are insufficient after a change-streamer restart.\n this.#lastForwardedCommitWatermark = lastWatermark;\n const stream = await this.#source.startStream(\n lastWatermark,\n backfillRequests,\n );\n this.#storer.run().catch(e => stream.changes.cancel(e));\n\n this.#stream = stream;\n if (\n this.#state.resetBackoff() >\n REPLICATION_STATUS_ERROR_DELAY_THRESHOLD_MS\n ) {\n // After recovering from a backoff for which a replication status\n // error was published, publish an OK status\n this.#replicationStatusPublisher.publish(\n this.#lc,\n 'Replicating',\n `Replicating from ${lastWatermark}`,\n );\n }\n watermark = null;\n\n for await (const change of stream.changes) {\n const [type, msg] = change;\n switch (type) {\n case 'status':\n if (msg.ack) {\n this.#storer.status(change); // storer acks once it gets through its queue\n }\n if (\n msg.lagReport &&\n msg.lagReport.lastTimings.commitTimeMs >=\n this.#latestLagReportCommitTimeMs\n ) {\n // Lag reports are not stored in the cdc change log, but rather\n // only forwarded on \"live\" connections. When a new subscriber\n // is catching up, it is initialized with the #latestStatus\n // from which it can measure lag while catching up.\n this.#latestStatus.lagReport = msg.lagReport;\n this.#latestLagReportCommitTimeMs =\n msg.lagReport.lastTimings.commitTimeMs;\n this.#forwarder.sendStatus(this.#latestStatus);\n }\n continue;\n case 'control':\n await this.#handleControlMessage(msg);\n continue; // control messages are not stored/forwarded\n case 'begin':\n watermark = change[2].commitWatermark;\n break;\n case 'commit':\n if (watermark !== change[2].watermark) {\n throw new UnrecoverableError(\n `commit watermark ${change[2].watermark} does not match 'begin' watermark ${watermark}`,\n );\n }\n this.#txCounter.add(1);\n break;\n default:\n if (type === 'data') {\n this.#changeCounter.add(1);\n }\n if (watermark === null) {\n throw new UnrecoverableError(\n `${type} change (${msg.tag}) received before 'begin' message`,\n );\n }\n break;\n }\n\n const json = this.#storer.store(watermark, change);\n const entry: WatermarkedChange = [watermark, change[1].tag, json];\n unflushedBytes += json.length;\n if (unflushedBytes < flushBytesThreshold) {\n // pipeline changes until flushBytesThreshold\n this.#forwarder.forward(entry);\n this.#recordForwardedTransactionBoundary(type, entry[0]);\n } else {\n // Wait for messages to clear socket buffers to ensure that they\n // make their way to subscribers. Without this `await`, the\n // messages end up being buffered in this process, which:\n // (1) results in memory pressure and increased GC activity\n // (2) prevents subscribers from processing the messages as they\n // arrive, instead getting them in a large batch after being\n // idle while they were queued (causing further delays).\n const forwarded = this.#forwarder.forwardWithFlowControl(entry);\n // forwardWithFlowControl synchronously sends the entry and updates\n // the Forwarder's transaction state before returning its flow-\n // control promise. Record the boundary before awaiting that promise\n // so registrations during the wait observe the forwarded state.\n this.#recordForwardedTransactionBoundary(type, entry[0]);\n await forwarded;\n unflushedBytes = 0;\n }\n\n if (type === 'commit' || type === 'rollback') {\n watermark = null;\n }\n\n // Allow the storer to exert back pressure.\n const readyForMore = this.#storer.readyForMore();\n if (readyForMore) {\n await readyForMore;\n }\n }\n } catch (e) {\n err = e;\n } finally {\n this.#stream?.changes.cancel();\n this.#stream = undefined;\n }\n\n // When the change stream is interrupted, abort any pending transaction.\n if (watermark) {\n this.#lc.warn?.(`aborting interrupted transaction ${watermark}`);\n this.#storer.abort();\n this.#forwarder.forward([watermark, 'rollback', ROLLBACK_JSON]);\n this.#recordForwardedTransactionBoundary('rollback', watermark);\n }\n\n // Backoff and drain any pending entries in the storer before reconnecting.\n await Promise.all([\n this.#storer.stop(),\n this.#state.backoff(this.#lc, err),\n this.#state.retryDelay > REPLICATION_STATUS_ERROR_DELAY_THRESHOLD_MS\n ? publishCriticalEvent(\n this.#lc,\n replicationStatusError(this.#lc, 'Replicating', err),\n )\n : promiseVoid,\n ]);\n }\n\n this.#forwarder.stopProgressMonitor();\n this.#lc.info?.('ChangeStreamer stopped');\n }\n\n async #handleControlMessage(msg: ChangeStreamControl[1]) {\n this.#lc.info?.('received control message', msg);\n const {tag} = msg;\n\n switch (tag) {\n case 'reset-required':\n await markResetRequired(this.#changeDB, this.#shard);\n await publishReplicationError(\n this.#lc,\n 'Replicating',\n msg.message ?? 'Resync required',\n msg.errorDetails,\n );\n if (this.#autoReset) {\n this.#lc.warn?.('shutting down for auto-reset');\n await this.stop(new AutoResetSignal());\n }\n break;\n default:\n unreachable(tag);\n }\n }\n\n async subscribe(ctx: SubscriberContext): Promise<Source<string>> {\n const {protocolVersion, id, mode, replicaVersion, watermark} = ctx;\n if (mode === 'serving') {\n this.#serving.resolve();\n }\n let cleanupSubscriber = () => {};\n const downstream = Subscription.create<string>({\n cleanup: () => cleanupSubscriber(),\n });\n const subscriber = new Subscriber(\n protocolVersion,\n id,\n watermark,\n downstream,\n () => this.#latestStatus,\n ctx.logsChangeStream\n ? {\n // This subscriber writes the SQLite change log that catchup reads,\n // so its ACKs are what advance that log's head. Routing them to the\n // barrier saves it from polling the replica for the same fact.\n onAck: acked => this.#sqliteCatchup?.onChangeLogWriterAck(acked),\n }\n : {},\n );\n const lc = this.#lc.withContext('subscriber', subscriber.id);\n cleanupSubscriber = () => {\n lc.info?.(`removing subscriber ${subscriber.id}`);\n this.#forwarder.remove(subscriber);\n };\n if (replicaVersion !== this.#replicaVersion) {\n lc.warn?.(`rejecting subscriber at replica version ${replicaVersion}`);\n subscriber.close(\n ErrorType.WrongReplicaVersion,\n `current replica version is ${\n this.#replicaVersion\n } (requested ${replicaVersion})`,\n );\n } else {\n lc.info?.(`adding subscriber ${subscriber.id}`);\n\n const sqliteCatchup = this.#selectSQLiteCatchup(ctx);\n if (sqliteCatchup) {\n cleanupSubscriber = () => sqliteCatchup.remove(subscriber);\n await sqliteCatchup.catchup(subscriber, () =>\n this.#captureRequiredHead(),\n );\n } else {\n // Keep the existing PG registration/catchup lockstep unchanged when\n // SQLite was not selected before Forwarder.add().\n this.#forwarder.add(subscriber);\n this.#storer.catchup(subscriber, mode);\n }\n }\n return downstream;\n }\n\n scheduleCleanup(watermark: string) {\n const origSize = this.#initialWatermarks.size;\n this.#initialWatermarks.add(watermark);\n\n if (origSize === 0) {\n this.#state.setTimeout(() => this.#purgeOldChanges(), CLEANUP_DELAY_MS);\n }\n }\n\n async getChangeLogState(): Promise<{\n replicaVersion: string;\n minWatermark: string;\n }> {\n const minWatermark = await this.#storer.getMinWatermarkForCatchup();\n if (!minWatermark) {\n this.#lc.warn?.(\n `Unexpected empty changeLog. Resync if \"Local replica watermark\" errors arise`,\n );\n }\n return {\n replicaVersion: this.#replicaVersion,\n minWatermark: minWatermark ?? this.#replicaVersion,\n };\n }\n\n /**\n * Makes a best effort to purge the change log. In the event of a database\n * error, exceptions will be logged and swallowed, so this method is safe\n * to run in a timeout.\n */\n async #purgeOldChanges(): Promise<void> {\n const initial = [...this.#initialWatermarks];\n if (initial.length === 0) {\n this.#lc.warn?.('No initial watermarks to check for cleanup'); // Not expected.\n return;\n }\n const current = [...this.#forwarder.getAcks()];\n if (current.length === 0) {\n // Also not expected, but possible (e.g. subscriber connects, then disconnects).\n // Bail to be safe.\n this.#lc.warn?.('No subscribers to confirm cleanup');\n return;\n }\n try {\n const earliestInitial = min(...(initial as AtLeastOne<LexiVersion>));\n const earliestCurrent = min(...(current as AtLeastOne<LexiVersion>));\n if (earliestCurrent < earliestInitial) {\n this.#lc.info?.(\n `At least one client is behind backup (${earliestCurrent} < ${earliestInitial})`,\n );\n } else {\n this.#lc.info?.(`Purging changes before ${earliestInitial} ...`);\n const start = performance.now();\n const deleted = await this.#storer.purgeRecordsBefore(earliestInitial);\n const elapsed = (performance.now() - start).toFixed(2);\n this.#lc.info?.(\n `Purged ${deleted} changes before ${earliestInitial} (${elapsed} ms)`,\n );\n this.#initialWatermarks.delete(earliestInitial);\n }\n } catch (e) {\n this.#lc.warn?.(`error purging change log`, e);\n } finally {\n if (this.#initialWatermarks.size) {\n // If there are unpurged watermarks to check, schedule the next purge.\n this.#state.setTimeout(() => this.#purgeOldChanges(), CLEANUP_DELAY_MS);\n }\n }\n }\n\n async stop(err?: unknown) {\n this.#state.stop(this.#lc, err);\n this.#stream?.changes.cancel();\n this.#sqliteCatchup?.close();\n await this.#storer.stop();\n await this.#source.stop();\n }\n\n #recordForwardedTransactionBoundary(\n type: ChangeStreamData[0],\n watermark: string,\n ) {\n switch (type) {\n case 'begin':\n assert(\n this.#currentTransactionCompletion === undefined,\n 'forwarded begin while a transaction is already in progress',\n );\n this.#currentTransactionCompletion =\n resolver<ForwardedTransactionCompletion>();\n break;\n case 'commit': {\n const completion = this.#currentTransactionCompletion;\n assert(completion, 'forwarded commit without a pending transaction');\n this.#lastForwardedCommitWatermark = watermark;\n this.#currentTransactionCompletion = undefined;\n completion.resolve({kind: 'committed', watermark});\n break;\n }\n case 'rollback': {\n const completion = this.#currentTransactionCompletion;\n assert(completion, 'forwarded rollback without a pending transaction');\n const committed = this.#lastForwardedCommitWatermark;\n assert(committed, 'last forwarded commit watermark is not initialized');\n this.#currentTransactionCompletion = undefined;\n completion.resolve({kind: 'rolled-back', watermark: committed});\n break;\n }\n }\n }\n\n #captureRequiredHead(): string | Promise<string> {\n const committed = this.#lastForwardedCommitWatermark;\n assert(committed, 'last forwarded commit watermark is not initialized');\n return (\n this.#currentTransactionCompletion?.promise.then(\n completion => completion.watermark,\n ) ?? committed\n );\n }\n\n #selectSQLiteCatchup(\n ctx: SubscriberContext,\n ): SQLiteChangeLogCatchup | undefined {\n const opts = this.#sqliteCatchupOptions;\n // Slice 11 supplies a non-default selector. Until then, this keeps all\n // production subscriptions on PG even though the complete SQLite handoff\n // path is wired and testable.\n if (this.#lastForwardedCommitWatermark === undefined || !opts) {\n return undefined;\n }\n // SQLite catchup is only for disposable serving replicas. Backup\n // subscribers retain the existing PG recovery policy until RMv2 can\n // resume the canonical replica directly from replica/backup/slot state.\n // Enforce this before the selector so no canary policy can override it.\n if (ctx.mode !== 'serving') {\n this.#lc.info?.(\n `not serving backup subscriber ${ctx.id} from SQLite catchup`,\n );\n return undefined;\n }\n if (ctx.logsChangeStream) {\n // A change-log writer cannot be served from the change log it writes.\n // The barrier waits for a head that only this subscriber can advance,\n // and it advances it by consuming the very subscription the barrier is\n // holding: it would wait on its own ACK until the deadline, on every\n // reconnect. Enforced here rather than left to the selector so that no\n // selector can express it.\n this.#lc.warn?.(\n `not serving change-log writer ${ctx.id} from SQLite catchup: ` +\n `it would wait on its own ACK`,\n );\n return undefined;\n }\n if (!opts.shouldUse?.(ctx)) {\n return undefined;\n }\n return this.#sqliteCatchup ?? this.#openSQLiteCatchup(opts, ctx);\n }\n\n /**\n * Opens the change log and, if it can serve, wraps it in the coordinator that\n * subsequent subscriptions reuse.\n *\n * The change-streamer and the replicator that writes the log start\n * independently, so the log may not exist yet, may exist without content, or\n * may be unreadable. None of those can be allowed to fail a subscription:\n * this is the last point at which PG catchup is still available -- past\n * `Forwarder.add()` the subscriber is committed to SQLite -- so each declines\n * here instead. Neither the failure nor the reader is retained, so a later\n * subscription retries from scratch.\n */\n #openSQLiteCatchup(\n opts: SQLiteCatchupOptions,\n ctx: SubscriberContext,\n ): SQLiteChangeLogCatchup | undefined {\n let reader: SQLiteChangeLogReader | undefined;\n try {\n reader = new SQLiteChangeLogReader(this.#lc, opts.changeLogFile);\n // `plan()` doubles as the readiness check: it reports `not-ready` when\n // the writer has not created or seeded the stream table. The barrier\n // cannot wait its way out of that, since a log with no head can never\n // reach the required one.\n if (reader.plan(ctx.watermark).kind === 'not-ready') {\n reader.close();\n this.#declineSQLiteCatchup(\n ctx,\n opts,\n `${opts.changeLogFile} has no changes to serve yet`,\n );\n return undefined;\n }\n } catch (e) {\n // An absent file is the common case, since a readonly handle cannot\n // create one. A corrupt or truncated file lands here too, and is equally\n // not a reason to fail the subscription.\n reader?.close();\n this.#declineSQLiteCatchup(\n ctx,\n opts,\n `cannot read ${opts.changeLogFile}`,\n e,\n );\n return undefined;\n }\n this.#changeLogUnavailableSince = undefined;\n // Readiness is checked once, on the way to the cached coordinator: a log\n // with content keeps it. Purging preserves the latest transaction as a\n // catchup boundary, and `reconcileChangeLog` reseeds inside a single\n // transaction, so a reader never observes an emptied log.\n this.#sqliteCatchup = new SQLiteChangeLogCatchup(\n this.#lc,\n this.#forwarder,\n reader,\n {\n batchSize: opts.readBatchRows,\n barrierTimeoutMs: opts.barrierTimeoutMs,\n barrierPollIntervalMs: opts.barrierPollIntervalMs,\n cleanupGuard: opts.cleanupGuard,\n },\n );\n return this.#sqliteCatchup;\n }\n\n /**\n * Reports falling back to PG catchup, tracking how long the log has been\n * unavailable so that a change-streamer that merely started before its\n * replicator does not look like an incident.\n */\n #declineSQLiteCatchup(\n ctx: SubscriberContext,\n opts: SQLiteCatchupOptions,\n reason: string,\n error?: unknown,\n ): void {\n const now = Date.now();\n this.#changeLogUnavailableSince ??= now;\n const unavailableMs = now - this.#changeLogUnavailableSince;\n const threshold =\n opts.notReadyWarnThresholdMs ??\n DEFAULT_CHANGE_LOG_UNAVAILABLE_WARN_THRESHOLD_MS;\n this.#lc[unavailableMs >= threshold ? 'warn' : 'debug']?.(\n `serving ${ctx.id} from PG catchup: ${reason} ` +\n `(unavailable for ${unavailableMs} ms)`,\n ...(error === undefined ? [] : [error]),\n );\n }\n}\n\ntype ForwardedTransactionCompletion =\n | {kind: 'committed'; watermark: string}\n | {kind: 'rolled-back'; watermark: string};\n\n// The delay between receiving an initial, backup-based watermark\n// and performing a check of whether to purge records before it.\n// This delay should be long enough to handle situations like the following:\n//\n// 1. `litestream restore` downloads a backup for the `replication-manager`\n// 2. `replication-manager` starts up and runs this `change-streamer`\n// 3. `zero-cache`s that are running on a different replica connect to this\n// `change-streamer` after exponential backoff retries.\n//\n// It is possible for a `zero-cache`[3] to be behind the backup restored [1].\n// This cleanup delay (30 seconds) is thus set to be a value comfortably\n// longer than the max delay for exponential backoff (10 seconds) in\n// `services/running-state.ts`. This allows the `zero-cache` [3] to reconnect\n// so that the `change-streamer` can track its progress and know when it has\n// surpassed the initial watermark of the backup [1].\nconst CLEANUP_DELAY_MS = DEFAULT_MAX_RETRY_DELAY_MS * 3;\n\nconst ROLLBACK_JSON = JSON.stringify([\n 'rollback',\n {tag: 'rollback'},\n] satisfies Rollback);\n"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"sqlite-change-log-catchup.d.ts","sourceRoot":"","sources":["../../../../../../zero-cache/src/services/change-streamer/sqlite-change-log-catchup.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAC,UAAU,EAAC,MAAM,kBAAkB,CAAC;AAGjD,OAAO,EAAC,KAAK,EAAC,MAAM,iCAAiC,CAAC;AAEtD,OAAO,KAAK,EAAC,iBAAiB,EAAC,MAAM,sBAAsB,CAAC;AAE5D,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,gBAAgB,CAAC;AAC9C,OAAO,KAAK,EAAC,WAAW,EAAC,MAAM,+BAA+B,CAAC;AAC/D,OAAO,KAAK,EAAC,UAAU,EAAC,MAAM,iBAAiB,CAAC;AAEhD,MAAM,WAAW,4BAA4B;IAC3C,IAAI,CAAC,aAAa,EAAE,MAAM,GAAG,WAAW,CAAC;IACzC,IAAI,CACF,aAAa,EAAE,MAAM,EACrB,gBAAgB,EAAE,MAAM,EACxB,SAAS,EAAE,MAAM,EACjB,MAAM,CAAC,EAAE,WAAW,GACnB,aAAa,CAAC,SAAS,iBAAiB,EAAE,CAAC,CAAC;IAC/C,KAAK,IAAI,IAAI,CAAC;CACf;AAED;;;;GAIG;AACH,MAAM,WAAW,2BAA2B;IAC1C,oBAAoB,CAAC,CAAC,EAAE,QAAQ,EAAE,MAAM,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC;CACxD;AASD,MAAM,MAAM,6BAA6B,GAAG;IAC1C,SAAS,EAAE,MAAM,CAAC;IAClB,gBAAgB,EAAE,MAAM,CAAC;IACzB,qBAAqB,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC3C,YAAY,CAAC,EAAE,2BAA2B,GAAG,SAAS,CAAC;IACvD,KAAK,CAAC,EAAE,OAAO,KAAK,GAAG,SAAS,CAAC;IACjC,GAAG,CAAC,EAAE,CAAC,MAAM,MAAM,CAAC,GAAG,SAAS,CAAC;CAClC,CAAC;AAMF;;;;;;;;GAQG;AACH,qBAAa,sBAAuB,YAAW,UAAU;;gBAiCrD,EAAE,EAAE,UAAU,EACd,SAAS,EAAE,SAAS,EACpB,MAAM,EAAE,4BAA4B,EACpC,IAAI,EAAE,6BAA6B;IAcrC;;;;OAIG;IACG,OAAO,CACX,UAAU,EAAE,UAAU,EACtB,mBAAmB,EAAE,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,GAClD,OAAO,CAAC,IAAI,CAAC;IA8BhB;;;;;;;;;OASG;IACH,oBAAoB,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI;IAS7C,MAAM,CAAC,UAAU,EAAE,UAAU,GAAG,IAAI;IAMpC,KAAK,IAAI,IAAI;IAab,CAAC,MAAM,CAAC,OAAO,CAAC,IAAI,IAAI;CAkRzB;AAID;;;;GAIG;AACH,qBAAa,2BAA4B,SAAQ,KAAK;CAAG;AAEzD,uEAAuE;AACvE,qBAAa,kCAAmC,SAAQ,2BAA2B;IACjF,QAAQ,CAAC,IAAI,wCAAwC;CACtD;AAED,iEAAiE;AACjE,qBAAa,kCAAmC,SAAQ,2BAA2B;IACjF,QAAQ,CAAC,IAAI,wCAAwC;CACtD"}
1
+ {"version":3,"file":"sqlite-change-log-catchup.d.ts","sourceRoot":"","sources":["../../../../../../zero-cache/src/services/change-streamer/sqlite-change-log-catchup.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAC,UAAU,EAAC,MAAM,kBAAkB,CAAC;AAGjD,OAAO,EAAC,KAAK,EAAC,MAAM,iCAAiC,CAAC;AAEtD,OAAO,KAAK,EAAC,iBAAiB,EAAC,MAAM,sBAAsB,CAAC;AAE5D,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,gBAAgB,CAAC;AAC9C,OAAO,KAAK,EAAC,WAAW,EAAC,MAAM,+BAA+B,CAAC;AAC/D,OAAO,KAAK,EAAC,UAAU,EAAC,MAAM,iBAAiB,CAAC;AAEhD,MAAM,WAAW,4BAA4B;IAC3C,IAAI,CAAC,aAAa,EAAE,MAAM,GAAG,WAAW,CAAC;IACzC,IAAI,CACF,aAAa,EAAE,MAAM,EACrB,gBAAgB,EAAE,MAAM,EACxB,SAAS,EAAE,MAAM,EACjB,MAAM,CAAC,EAAE,WAAW,GACnB,aAAa,CAAC,SAAS,iBAAiB,EAAE,CAAC,CAAC;IAC/C,KAAK,IAAI,IAAI,CAAC;CACf;AAED;;;;GAIG;AACH,MAAM,WAAW,2BAA2B;IAC1C,oBAAoB,CAAC,CAAC,EAAE,QAAQ,EAAE,MAAM,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC;CACxD;AASD,MAAM,MAAM,6BAA6B,GAAG;IAC1C,SAAS,EAAE,MAAM,CAAC;IAClB,gBAAgB,EAAE,MAAM,CAAC;IACzB,qBAAqB,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC3C,YAAY,CAAC,EAAE,2BAA2B,GAAG,SAAS,CAAC;IACvD,KAAK,CAAC,EAAE,OAAO,KAAK,GAAG,SAAS,CAAC;IACjC,GAAG,CAAC,EAAE,CAAC,MAAM,MAAM,CAAC,GAAG,SAAS,CAAC;CAClC,CAAC;AAMF;;;;;;;;GAQG;AACH,qBAAa,sBAAuB,YAAW,UAAU;;gBAiCrD,EAAE,EAAE,UAAU,EACd,SAAS,EAAE,SAAS,EACpB,MAAM,EAAE,4BAA4B,EACpC,IAAI,EAAE,6BAA6B;IAcrC;;;;OAIG;IACG,OAAO,CACX,UAAU,EAAE,UAAU,EACtB,mBAAmB,EAAE,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,GAClD,OAAO,CAAC,IAAI,CAAC;IA8BhB;;;;;;;;;OASG;IACH,oBAAoB,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI;IAS7C,MAAM,CAAC,UAAU,EAAE,UAAU,GAAG,IAAI;IAMpC,KAAK,IAAI,IAAI;IAab,CAAC,MAAM,CAAC,OAAO,CAAC,IAAI,IAAI;CAoRzB;AAID;;;;GAIG;AACH,qBAAa,2BAA4B,SAAQ,KAAK;CAAG;AAEzD,uEAAuE;AACvE,qBAAa,kCAAmC,SAAQ,2BAA2B;IACjF,QAAQ,CAAC,IAAI,wCAAwC;CACtD;AAED,iEAAiE;AACjE,qBAAa,kCAAmC,SAAQ,2BAA2B;IACjF,QAAQ,CAAC,IAAI,wCAAwC;CACtD"}
@@ -1 +1 @@
1
- {"version":3,"file":"sqlite-change-log-catchup.js","names":["#lc","#forwarder","#reader","#batchSize","#barrierTimeoutMs","#barrierPollIntervalMs","#cleanupGuard","#sleep","#now","#barrierTimeouts","#barrierBacklogOverflows","#barrierWakeups","#catchups","#ackWaiters","#closed","#throwIfAborted","#run","#awaitRequiredHead","#waitForPlan","#awaitChangeLogProgress"],"sources":["../../../../../../zero-cache/src/services/change-streamer/sqlite-change-log-catchup.ts"],"sourcesContent":["import type {LogContext} from '@rocicorp/logger';\nimport {resolver} from '@rocicorp/resolver';\nimport {AbortError} from '../../../../shared/src/abort-error.ts';\nimport {sleep} from '../../../../shared/src/sleep.ts';\nimport {getOrCreateCounter} from '../../observability/metrics.ts';\nimport type {WatermarkedChange} from './change-streamer.ts';\nimport * as ErrorType from './error-type-enum.ts';\nimport type {Forwarder} from './forwarder.ts';\nimport type {CatchupPlan} from './sqlite-change-log-reader.ts';\nimport type {Subscriber} from './subscriber.ts';\n\nexport interface SQLiteChangeLogCatchupReader {\n plan(fromWatermark: string): CatchupPlan;\n read(\n fromWatermark: string,\n throughWatermark: string,\n batchSize: number,\n signal?: AbortSignal,\n ): AsyncIterable<readonly WatermarkedChange[]>;\n close(): void;\n}\n\n/**\n * Slice 9 replaces this no-op implementation with a guard that waits for an\n * in-flight purge and blocks new purge dispatch while the subscriber's ACK is\n * made visible to the Forwarder.\n */\nexport interface SQLiteChangeLogCleanupGuard {\n runWhilePurgeBlocked<T>(register: () => T): Promise<T>;\n}\n\n// How long the barrier waits for the change-log writer's ACK before falling\n// back to reading the replica. The ACK normally arrives first, so this is a\n// backstop for the windows in which no ACK is coming: no writer is subscribed\n// (in which case the change log cannot be advancing either), the writer is\n// mid-reconnect, or it predates the `logsChangeStream` subscription parameter.\nconst DEFAULT_BARRIER_POLL_INTERVAL_MS = 1000;\n\nexport type SQLiteChangeLogCatchupOptions = {\n batchSize: number;\n barrierTimeoutMs: number;\n barrierPollIntervalMs?: number | undefined;\n cleanupGuard?: SQLiteChangeLogCleanupGuard | undefined;\n sleep?: typeof sleep | undefined;\n now?: (() => number) | undefined;\n};\n\nconst NOOP_CLEANUP_GUARD: SQLiteChangeLogCleanupGuard = {\n runWhilePurgeBlocked: register => Promise.resolve(register()),\n};\n\n/**\n * Coordinates a serving subscriber's gap-free transition from replica-local\n * SQLite catchup to the Forwarder's live stream.\n *\n * Registration and required-head capture happen in one synchronous callback.\n * The subscriber therefore either sees a transaction in SQLite catchup or in\n * its live backlog (duplicates across that boundary are filtered by\n * Subscriber), never in neither place.\n */\nexport class SQLiteChangeLogCatchup implements Disposable {\n readonly #lc: LogContext;\n readonly #forwarder: Forwarder;\n readonly #reader: SQLiteChangeLogCatchupReader;\n readonly #batchSize: number;\n readonly #barrierTimeoutMs: number;\n readonly #barrierPollIntervalMs: number;\n readonly #cleanupGuard: SQLiteChangeLogCleanupGuard;\n readonly #sleep: typeof sleep;\n readonly #now: () => number;\n readonly #barrierTimeouts = getOrCreateCounter(\n 'replication',\n 'sqlite_change_log.barrier_timeouts',\n 'SQLite change-log catchups that timed out waiting for the required head.',\n );\n readonly #barrierBacklogOverflows = getOrCreateCounter(\n 'replication',\n 'sqlite_change_log.barrier_backlog_overflows',\n 'SQLite change-log catchups abandoned because the subscriber backlog ' +\n 'reached its high water mark while waiting for the required head.',\n );\n readonly #barrierWakeups = getOrCreateCounter(\n 'replication',\n 'sqlite_change_log.barrier_wakeups',\n 'SQLite catchup barrier waits, labeled by what ended the wait. A ' +\n 'population dominated by \"poll\" means the change-log writer ACK is not ' +\n 'reaching the barrier.',\n );\n readonly #catchups = new Map<Subscriber, AbortController>();\n readonly #ackWaiters = new Set<AckWaiter>();\n #closed = false;\n\n constructor(\n lc: LogContext,\n forwarder: Forwarder,\n reader: SQLiteChangeLogCatchupReader,\n opts: SQLiteChangeLogCatchupOptions,\n ) {\n this.#lc = lc.withContext('component', 'sqlite-change-log-catchup');\n this.#forwarder = forwarder;\n this.#reader = reader;\n this.#batchSize = opts.batchSize;\n this.#barrierTimeoutMs = opts.barrierTimeoutMs;\n this.#barrierPollIntervalMs =\n opts.barrierPollIntervalMs ?? DEFAULT_BARRIER_POLL_INTERVAL_MS;\n this.#cleanupGuard = opts.cleanupGuard ?? NOOP_CLEANUP_GUARD;\n this.#sleep = opts.sleep ?? sleep;\n this.#now = opts.now ?? Date.now;\n }\n\n /**\n * Registers the subscriber before resolving. Catchup itself continues in\n * the background so subscribe() does not wait for SQLite to reach the\n * required head.\n */\n async catchup(\n subscriber: Subscriber,\n captureRequiredHead: () => string | Promise<string>,\n ): Promise<void> {\n if (this.#closed) {\n subscriber.fail(new AbortError('SQLite change-log catchup is closed'));\n return;\n }\n\n const abort = new AbortController();\n this.#catchups.set(subscriber, abort);\n let requiredHead: string | Promise<string> | undefined;\n try {\n await this.#cleanupGuard.runWhilePurgeBlocked(() => {\n this.#throwIfAborted(abort.signal);\n requiredHead = captureRequiredHead();\n this.#forwarder.add(subscriber);\n });\n } catch (error) {\n this.#catchups.delete(subscriber);\n if (!abort.signal.aborted) {\n this.#lc.error?.(\n `error while registering SQLite catchup subscriber ${subscriber.id}`,\n error,\n );\n subscriber.fail(error);\n }\n return;\n }\n\n void this.#run(subscriber, requiredHead as string | Promise<string>, abort);\n }\n\n /**\n * Notes that the subscriber writing the SQLite change log has acked\n * `watermark`, i.e. has durably applied it to the replica this coordinator\n * reads. That is the only event that advances the change log, so it is what\n * the barrier waits on instead of polling the replica for it.\n *\n * The ACK is a wakeup, not an authority: `plan()` still decides what can be\n * read. An ACK that never arrives costs the barrier a poll interval, and one\n * that arrives early costs an extra `plan()` call.\n */\n onChangeLogWriterAck(watermark: string): void {\n for (const waiter of this.#ackWaiters) {\n if (watermark >= waiter.watermark) {\n this.#ackWaiters.delete(waiter);\n waiter.resolve();\n }\n }\n }\n\n remove(subscriber: Subscriber): void {\n this.#catchups.get(subscriber)?.abort();\n this.#catchups.delete(subscriber);\n this.#forwarder.remove(subscriber);\n }\n\n close(): void {\n if (this.#closed) {\n return;\n }\n this.#closed = true;\n for (const [subscriber, abort] of this.#catchups) {\n abort.abort();\n this.#forwarder.remove(subscriber);\n }\n this.#catchups.clear();\n this.#reader.close();\n }\n\n [Symbol.dispose](): void {\n this.close();\n }\n\n async #run(\n subscriber: Subscriber,\n requiredHead: string | Promise<string>,\n abort: AbortController,\n ): Promise<void> {\n const {signal} = abort;\n try {\n const requiredHeadStart = this.#now();\n const required = await this.#awaitRequiredHead(requiredHead, signal);\n const requiredHeadWaitMs = this.#now() - requiredHeadStart;\n if (requiredHeadWaitMs > this.#barrierTimeoutMs) {\n this.#lc.info?.(\n `waited ${requiredHeadWaitMs} ms for the forwarded transaction to ` +\n `commit before starting the SQLite barrier for ${subscriber.id}`,\n );\n }\n // The barrier bounds replica lag, not upstream transaction duration, so\n // its deadline starts once the required head is known. Sharing one\n // deadline with the wait above would fail subscribers that registered\n // during a transaction longer than barrierTimeoutMs, however current\n // the replica is.\n const deadline = this.#now() + this.#barrierTimeoutMs;\n const plan = await this.#waitForPlan(\n subscriber,\n required,\n deadline,\n signal,\n );\n this.#throwIfAborted(signal);\n\n if (plan.kind === 'too-old') {\n const message =\n `earliest supported watermark is ${plan.minWatermark} ` +\n `(requested ${subscriber.watermark})`;\n this.#lc.warn?.(\n `rejecting subscriber at watermark ${subscriber.watermark} ` +\n `(earliest watermark: ${plan.minWatermark})`,\n );\n subscriber.close(ErrorType.WatermarkTooOld, message);\n return;\n }\n\n let count = 0;\n const start = this.#now();\n if (plan.kind === 'range') {\n let lastBatchConsumed: Promise<unknown> | undefined;\n for await (const changes of this.#reader.read(\n subscriber.watermark,\n plan.headWatermark,\n this.#batchSize,\n signal,\n )) {\n const waitStart = this.#now();\n await lastBatchConsumed;\n const elapsed = this.#now() - waitStart;\n if (lastBatchConsumed) {\n this.#lc[elapsed > 100 ? 'info' : 'debug']?.(\n `waited ${elapsed.toFixed(3)} ms for ${subscriber.id} to consume ` +\n `the previous SQLite catchup batch`,\n );\n }\n this.#throwIfAborted(signal);\n for (const change of changes) {\n lastBatchConsumed = subscriber.catchup(change);\n count++;\n }\n }\n await lastBatchConsumed;\n } else {\n this.#lc.warn?.(\n `subscriber ${subscriber.id} at watermark ${subscriber.watermark} ` +\n `is ahead of the SQLite change-log head ${plan.headWatermark}; ` +\n `waiting for the replica to catch up`,\n );\n }\n\n this.#throwIfAborted(signal);\n this.#lc.info?.(\n `caught up ${subscriber.id} from SQLite with ${count} changes ` +\n `(${this.#now() - start} ms)`,\n );\n // Keep buffering live sends until the asynchronous backlog drain has\n // established its ordering boundary.\n void subscriber.setCaughtUp();\n } catch (error) {\n if (signal.aborted) {\n return;\n }\n if (error instanceof SQLiteChangeLogBarrierError) {\n if (error instanceof SQLiteChangeLogBarrierTimeoutError) {\n this.#barrierTimeouts.add(1);\n }\n // Giving up on the barrier means the replica has not caught up *yet*,\n // not that it cannot serve this subscriber. End the subscription\n // cleanly so the client reconnects and retries: IncrementalSyncer\n // treats any ['error', ...] message as terminal and restores a fresh\n // replica from litestream, whereas a clean end backs off and\n // re-subscribes. If retries keep failing until the change log is purged\n // past the subscriber's watermark, plan() returns 'too-old', which is\n // terminal by design.\n this.#lc.warn?.(\n `ending subscription for ${subscriber.id} to retry SQLite catchup`,\n error,\n );\n subscriber.close();\n return;\n }\n this.#lc.error?.(\n `error while catching up subscriber ${subscriber.id} from SQLite`,\n error,\n );\n subscriber.fail(error);\n } finally {\n if (this.#catchups.get(subscriber) === abort) {\n this.#catchups.delete(subscriber);\n }\n }\n }\n\n /**\n * Waits for the transaction that was in flight at registration to finish.\n *\n * This wait is bounded by the upstream transaction rather than by\n * `barrierTimeoutMs`: the completion settles on both commit and rollback,\n * and an interrupted change stream forwards a synthetic rollback, so it\n * always settles. Aborting -- the subscriber disconnecting or the service\n * shutting down -- is what releases it early.\n */\n async #awaitRequiredHead(\n requiredHead: string | Promise<string>,\n signal: AbortSignal,\n ): Promise<string> {\n if (typeof requiredHead === 'string') {\n return requiredHead;\n }\n this.#throwIfAborted(signal);\n let onAbort: (() => void) | undefined;\n try {\n return await Promise.race([\n requiredHead,\n new Promise<never>((_, reject) => {\n onAbort = () =>\n reject(new AbortError('SQLite change-log catchup aborted'));\n signal.addEventListener('abort', onAbort, {once: true});\n }),\n ]);\n } finally {\n if (onAbort) {\n signal.removeEventListener('abort', onAbort);\n }\n }\n }\n\n async #waitForPlan(\n subscriber: Subscriber,\n requiredHead: string,\n deadline: number,\n signal: AbortSignal,\n ): Promise<Exclude<CatchupPlan, {kind: 'not-ready'}>> {\n // Race backlog growth once around the whole polling loop. Attaching the\n // same unresolved promise to every per-poll race would retain one reaction\n // per iteration until the subscriber's backlog eventually filled.\n const backlogFull = subscriber.whenBacklogFull();\n const barrier = new AbortController();\n const abortBarrier = () => barrier.abort();\n signal.addEventListener('abort', abortBarrier, {once: true});\n if (signal.aborted) {\n barrier.abort();\n }\n\n let observedHead = subscriber.watermark;\n try {\n const planReady = (async () => {\n while (true) {\n this.#throwIfAborted(barrier.signal);\n const plan = this.#reader.plan(subscriber.watermark);\n // A not-ready log has no head to compare, so it can never satisfy\n // the required head: keep waiting for the writer to create and\n // reconcile it, and let the barrier deadline end the subscription\n // cleanly for a retry. Slice 7E declines these before this point.\n if (plan.kind !== 'not-ready') {\n observedHead = plan.headWatermark;\n if (plan.headWatermark >= requiredHead) {\n return plan;\n }\n }\n const remaining = deadline - this.#now();\n if (remaining <= 0) {\n throw new SQLiteChangeLogBarrierTimeoutError(\n `timed out waiting for SQLite head ${observedHead} to ` +\n `reach required head ${requiredHead}`,\n );\n }\n await this.#awaitChangeLogProgress(\n requiredHead,\n remaining,\n barrier.signal,\n );\n }\n })();\n\n const backlogOverflow = backlogFull.promise.then(() => {\n this.#throwIfAborted(signal);\n // close() also resolves backlog waiters so none are stranded. The\n // backlog is cleared first, which distinguishes close from overflow.\n if (!subscriber.backlogFull) {\n return new Promise<never>(() => {});\n }\n this.#barrierWakeups.add(1, {'wakeup.source': 'backlog'});\n // Bytes, not time, are what the barrier actually costs: past the high\n // water mark this subscriber's send() no longer resolves, so it holds\n // up every flush and can stall the replica this barrier waits on.\n this.#barrierBacklogOverflows.add(1);\n throw new SQLiteChangeLogBarrierBacklogError(\n `backlog for subscriber ${subscriber.id} reached its high water ` +\n `mark while waiting for SQLite head ${observedHead} to ` +\n `reach required head ${requiredHead}`,\n );\n });\n\n return await Promise.race([planReady, backlogOverflow]);\n } finally {\n signal.removeEventListener('abort', abortBarrier);\n barrier.abort();\n backlogFull.cancel();\n }\n }\n\n /**\n * Waits for the change-log writer's ACK of `requiredHead`, or the poll\n * interval as a backstop.\n *\n * No ACK can be missed by registering after `plan()` was read, because the\n * ACK lags the write it acknowledges: if the writer had already acked\n * `requiredHead`, `plan()` would have seen it.\n */\n async #awaitChangeLogProgress(\n requiredHead: string,\n remainingMs: number,\n signal: AbortSignal,\n ): Promise<void> {\n const acked = resolver<void>();\n const waiter = {watermark: requiredHead, resolve: acked.resolve};\n this.#ackWaiters.add(waiter);\n // Scopes the backstop timer to this wait so that an ACK cancels it rather\n // than leaving it to fire against a barrier that has already moved on.\n const poll = new AbortController();\n const abortPoll = () => poll.abort();\n signal.addEventListener('abort', abortPoll, {once: true});\n try {\n const wokenBy = await Promise.race([\n acked.promise.then(() => 'ack' as const),\n this.#sleep(\n Math.min(this.#barrierPollIntervalMs, remainingMs),\n poll.signal,\n ).then(() => 'poll' as const),\n ]);\n this.#barrierWakeups.add(1, {'wakeup.source': wokenBy});\n } finally {\n this.#ackWaiters.delete(waiter);\n signal.removeEventListener('abort', abortPoll);\n poll.abort();\n }\n }\n\n #throwIfAborted(signal: AbortSignal): void {\n if (this.#closed || signal.aborted) {\n throw new AbortError('SQLite change-log catchup aborted');\n }\n }\n}\n\ntype AckWaiter = {watermark: string; resolve: () => void};\n\n/**\n * A barrier that gave up on the replica reaching the required head. Both\n * reasons mean \"not yet\", not \"never\", so both end the subscription cleanly\n * for a retry rather than failing it.\n */\nexport class SQLiteChangeLogBarrierError extends Error {}\n\n/** The replica did not reach the required head before the deadline. */\nexport class SQLiteChangeLogBarrierTimeoutError extends SQLiteChangeLogBarrierError {\n readonly name = 'SQLiteChangeLogBarrierTimeoutError';\n}\n\n/** Waiting any longer would have cost more than reconnecting. */\nexport class SQLiteChangeLogBarrierBacklogError extends SQLiteChangeLogBarrierError {\n readonly name = 'SQLiteChangeLogBarrierBacklogError';\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"sqlite-change-log-catchup.js","names":["#lc","#forwarder","#reader","#batchSize","#barrierTimeoutMs","#barrierPollIntervalMs","#cleanupGuard","#sleep","#now","#barrierTimeouts","#barrierBacklogOverflows","#barrierWakeups","#catchups","#ackWaiters","#closed","#throwIfAborted","#run","#awaitRequiredHead","#waitForPlan","#awaitChangeLogProgress"],"sources":["../../../../../../zero-cache/src/services/change-streamer/sqlite-change-log-catchup.ts"],"sourcesContent":["import type {LogContext} from '@rocicorp/logger';\nimport {resolver} from '@rocicorp/resolver';\nimport {AbortError} from '../../../../shared/src/abort-error.ts';\nimport {sleep} from '../../../../shared/src/sleep.ts';\nimport {getOrCreateCounter} from '../../observability/metrics.ts';\nimport type {WatermarkedChange} from './change-streamer.ts';\nimport * as ErrorType from './error-type-enum.ts';\nimport type {Forwarder} from './forwarder.ts';\nimport type {CatchupPlan} from './sqlite-change-log-reader.ts';\nimport type {Subscriber} from './subscriber.ts';\n\nexport interface SQLiteChangeLogCatchupReader {\n plan(fromWatermark: string): CatchupPlan;\n read(\n fromWatermark: string,\n throughWatermark: string,\n batchSize: number,\n signal?: AbortSignal,\n ): AsyncIterable<readonly WatermarkedChange[]>;\n close(): void;\n}\n\n/**\n * Slice 9 replaces this no-op implementation with a guard that waits for an\n * in-flight purge and blocks new purge dispatch while the subscriber's ACK is\n * made visible to the Forwarder.\n */\nexport interface SQLiteChangeLogCleanupGuard {\n runWhilePurgeBlocked<T>(register: () => T): Promise<T>;\n}\n\n// How long the barrier waits for the change-log writer's ACK before falling\n// back to reading the replica. The ACK normally arrives first, so this is a\n// backstop for the windows in which no ACK is coming: no writer is subscribed\n// (in which case the change log cannot be advancing either), the writer is\n// mid-reconnect, or it predates the `logsChangeStream` subscription parameter.\nconst DEFAULT_BARRIER_POLL_INTERVAL_MS = 1000;\n\nexport type SQLiteChangeLogCatchupOptions = {\n batchSize: number;\n barrierTimeoutMs: number;\n barrierPollIntervalMs?: number | undefined;\n cleanupGuard?: SQLiteChangeLogCleanupGuard | undefined;\n sleep?: typeof sleep | undefined;\n now?: (() => number) | undefined;\n};\n\nconst NOOP_CLEANUP_GUARD: SQLiteChangeLogCleanupGuard = {\n runWhilePurgeBlocked: register => Promise.resolve(register()),\n};\n\n/**\n * Coordinates a serving subscriber's gap-free transition from replica-local\n * SQLite catchup to the Forwarder's live stream.\n *\n * Registration and required-head capture happen in one synchronous callback.\n * The subscriber therefore either sees a transaction in SQLite catchup or in\n * its live backlog (duplicates across that boundary are filtered by\n * Subscriber), never in neither place.\n */\nexport class SQLiteChangeLogCatchup implements Disposable {\n readonly #lc: LogContext;\n readonly #forwarder: Forwarder;\n readonly #reader: SQLiteChangeLogCatchupReader;\n readonly #batchSize: number;\n readonly #barrierTimeoutMs: number;\n readonly #barrierPollIntervalMs: number;\n readonly #cleanupGuard: SQLiteChangeLogCleanupGuard;\n readonly #sleep: typeof sleep;\n readonly #now: () => number;\n readonly #barrierTimeouts = getOrCreateCounter(\n 'replication',\n 'sqlite_change_log.barrier_timeouts',\n 'SQLite change-log catchups that timed out waiting for the required head.',\n );\n readonly #barrierBacklogOverflows = getOrCreateCounter(\n 'replication',\n 'sqlite_change_log.barrier_backlog_overflows',\n 'SQLite change-log catchups abandoned because the subscriber backlog ' +\n 'reached its high water mark while waiting for the required head.',\n );\n readonly #barrierWakeups = getOrCreateCounter(\n 'replication',\n 'sqlite_change_log.barrier_wakeups',\n 'SQLite catchup barrier waits, labeled by what ended the wait. A ' +\n 'population dominated by \"poll\" means the change-log writer ACK is not ' +\n 'reaching the barrier.',\n );\n readonly #catchups = new Map<Subscriber, AbortController>();\n readonly #ackWaiters = new Set<AckWaiter>();\n #closed = false;\n\n constructor(\n lc: LogContext,\n forwarder: Forwarder,\n reader: SQLiteChangeLogCatchupReader,\n opts: SQLiteChangeLogCatchupOptions,\n ) {\n this.#lc = lc.withContext('component', 'sqlite-change-log-catchup');\n this.#forwarder = forwarder;\n this.#reader = reader;\n this.#batchSize = opts.batchSize;\n this.#barrierTimeoutMs = opts.barrierTimeoutMs;\n this.#barrierPollIntervalMs =\n opts.barrierPollIntervalMs ?? DEFAULT_BARRIER_POLL_INTERVAL_MS;\n this.#cleanupGuard = opts.cleanupGuard ?? NOOP_CLEANUP_GUARD;\n this.#sleep = opts.sleep ?? sleep;\n this.#now = opts.now ?? Date.now;\n }\n\n /**\n * Registers the subscriber before resolving. Catchup itself continues in\n * the background so subscribe() does not wait for SQLite to reach the\n * required head.\n */\n async catchup(\n subscriber: Subscriber,\n captureRequiredHead: () => string | Promise<string>,\n ): Promise<void> {\n if (this.#closed) {\n subscriber.fail(new AbortError('SQLite change-log catchup is closed'));\n return;\n }\n\n const abort = new AbortController();\n this.#catchups.set(subscriber, abort);\n let requiredHead: string | Promise<string> | undefined;\n try {\n await this.#cleanupGuard.runWhilePurgeBlocked(() => {\n this.#throwIfAborted(abort.signal);\n requiredHead = captureRequiredHead();\n this.#forwarder.add(subscriber);\n });\n } catch (error) {\n this.#catchups.delete(subscriber);\n if (!abort.signal.aborted) {\n this.#lc.error?.(\n `error while registering SQLite catchup subscriber ${subscriber.id}`,\n error,\n );\n subscriber.fail(error);\n }\n return;\n }\n\n void this.#run(subscriber, requiredHead as string | Promise<string>, abort);\n }\n\n /**\n * Notes that the subscriber writing the SQLite change log has acked\n * `watermark`, i.e. has durably applied it to the replica this coordinator\n * reads. That is the only event that advances the change log, so it is what\n * the barrier waits on instead of polling the replica for it.\n *\n * The ACK is a wakeup, not an authority: `plan()` still decides what can be\n * read. An ACK that never arrives costs the barrier a poll interval, and one\n * that arrives early costs an extra `plan()` call.\n */\n onChangeLogWriterAck(watermark: string): void {\n for (const waiter of this.#ackWaiters) {\n if (watermark >= waiter.watermark) {\n this.#ackWaiters.delete(waiter);\n waiter.resolve();\n }\n }\n }\n\n remove(subscriber: Subscriber): void {\n this.#catchups.get(subscriber)?.abort();\n this.#catchups.delete(subscriber);\n this.#forwarder.remove(subscriber);\n }\n\n close(): void {\n if (this.#closed) {\n return;\n }\n this.#closed = true;\n for (const [subscriber, abort] of this.#catchups) {\n abort.abort();\n this.#forwarder.remove(subscriber);\n }\n this.#catchups.clear();\n this.#reader.close();\n }\n\n [Symbol.dispose](): void {\n this.close();\n }\n\n async #run(\n subscriber: Subscriber,\n requiredHead: string | Promise<string>,\n abort: AbortController,\n ): Promise<void> {\n const {signal} = abort;\n try {\n const requiredHeadStart = this.#now();\n const required = await this.#awaitRequiredHead(requiredHead, signal);\n const requiredHeadWaitMs = this.#now() - requiredHeadStart;\n if (requiredHeadWaitMs > this.#barrierTimeoutMs) {\n this.#lc.info?.(\n `waited ${requiredHeadWaitMs} ms for the forwarded transaction to ` +\n `commit before starting the SQLite barrier for ${subscriber.id}`,\n );\n }\n // The barrier bounds replica lag, not upstream transaction duration, so\n // its deadline starts once the required head is known. Sharing one\n // deadline with the wait above would fail subscribers that registered\n // during a transaction longer than barrierTimeoutMs, however current\n // the replica is.\n const deadline = this.#now() + this.#barrierTimeoutMs;\n const plan = await this.#waitForPlan(\n subscriber,\n required,\n deadline,\n signal,\n );\n this.#throwIfAborted(signal);\n\n if (plan.kind === 'too-old') {\n const message =\n `earliest supported watermark is ${plan.minWatermark} ` +\n `(requested ${subscriber.watermark})`;\n this.#lc.warn?.(\n `rejecting subscriber at watermark ${subscriber.watermark} ` +\n `(earliest watermark: ${plan.minWatermark})`,\n );\n subscriber.close(ErrorType.WatermarkTooOld, message);\n return;\n }\n\n let count = 0;\n const start = this.#now();\n if (plan.kind === 'range') {\n let lastBatchConsumed: Promise<unknown> | undefined;\n for await (const changes of this.#reader.read(\n subscriber.watermark,\n plan.headWatermark,\n this.#batchSize,\n signal,\n )) {\n const waitStart = this.#now();\n await lastBatchConsumed;\n const elapsed = this.#now() - waitStart;\n if (lastBatchConsumed) {\n this.#lc[elapsed > 100 ? 'info' : 'debug']?.(\n `waited ${elapsed.toFixed(3)} ms for ${subscriber.id} to consume ` +\n `the previous SQLite catchup batch`,\n );\n }\n this.#throwIfAborted(signal);\n for (const change of changes) {\n lastBatchConsumed = subscriber.catchup(change);\n count++;\n }\n }\n await lastBatchConsumed;\n } else {\n this.#lc.warn?.(\n `subscriber ${subscriber.id} at watermark ${subscriber.watermark} ` +\n `is ahead of the SQLite change-log head ${plan.headWatermark}; ` +\n `waiting for the replica to catch up`,\n );\n }\n\n this.#throwIfAborted(signal);\n this.#lc.info?.(\n `caught up ${subscriber.id} from SQLite with ${count} changes ` +\n `(${this.#now() - start} ms)`,\n );\n // Keep buffering live sends until the asynchronous backlog drain has\n // established its ordering boundary.\n void subscriber.setCaughtUp();\n } catch (error) {\n if (signal.aborted) {\n return;\n }\n if (error instanceof SQLiteChangeLogBarrierError) {\n if (error instanceof SQLiteChangeLogBarrierTimeoutError) {\n this.#barrierTimeouts.add(1);\n }\n // Giving up on the barrier means the replica has not caught up *yet*,\n // not that it cannot serve this subscriber. End the subscription\n // cleanly so the client reconnects and retries: IncrementalSyncer\n // treats any ['error', ...] message as terminal and restores a fresh\n // replica from litestream, whereas a clean end backs off and\n // re-subscribes. If retries keep failing until the change log is purged\n // past the subscriber's watermark, plan() returns 'too-old', which is\n // terminal by design.\n this.#lc.warn?.(\n `ending subscription for ${subscriber.id} to retry SQLite catchup`,\n error,\n );\n subscriber.close();\n return;\n }\n this.#lc.error?.(\n `error while catching up subscriber ${subscriber.id} from SQLite`,\n error,\n );\n subscriber.fail(error);\n } finally {\n if (this.#catchups.get(subscriber) === abort) {\n this.#catchups.delete(subscriber);\n }\n }\n }\n\n /**\n * Waits for the transaction that was in flight at registration to finish.\n *\n * This wait is bounded by the upstream transaction rather than by\n * `barrierTimeoutMs`: the completion settles on both commit and rollback,\n * and an interrupted change stream forwards a synthetic rollback, so it\n * always settles. Aborting -- the subscriber disconnecting or the service\n * shutting down -- is what releases it early.\n */\n async #awaitRequiredHead(\n requiredHead: string | Promise<string>,\n signal: AbortSignal,\n ): Promise<string> {\n if (typeof requiredHead === 'string') {\n return requiredHead;\n }\n this.#throwIfAborted(signal);\n let onAbort: (() => void) | undefined;\n try {\n return await Promise.race([\n requiredHead,\n new Promise<never>((_, reject) => {\n onAbort = () =>\n reject(new AbortError('SQLite change-log catchup aborted'));\n signal.addEventListener('abort', onAbort, {once: true});\n }),\n ]);\n } finally {\n if (onAbort) {\n signal.removeEventListener('abort', onAbort);\n }\n }\n }\n\n async #waitForPlan(\n subscriber: Subscriber,\n requiredHead: string,\n deadline: number,\n signal: AbortSignal,\n ): Promise<Exclude<CatchupPlan, {kind: 'not-ready'}>> {\n // Race backlog growth once around the whole polling loop. Attaching the\n // same unresolved promise to every per-poll race would retain one reaction\n // per iteration until the subscriber's backlog eventually filled.\n const backlogFull = subscriber.whenBacklogFull();\n const barrier = new AbortController();\n const abortBarrier = () => barrier.abort();\n signal.addEventListener('abort', abortBarrier, {once: true});\n if (signal.aborted) {\n barrier.abort();\n }\n\n let observedHead = subscriber.watermark;\n try {\n const planReady = (async () => {\n while (true) {\n this.#throwIfAborted(barrier.signal);\n const plan = this.#reader.plan(subscriber.watermark);\n // A not-ready log has no head to compare, so it can never satisfy\n // the required head: keep waiting for the writer to create and\n // reconcile it, and let the barrier deadline end the subscription\n // cleanly for a retry. Selection declines a not-ready log before a\n // subscriber gets here, and neither purging nor reconciliation can\n // empty a log that had content, so this is a belt-and-braces path.\n if (plan.kind !== 'not-ready') {\n observedHead = plan.headWatermark;\n if (plan.headWatermark >= requiredHead) {\n return plan;\n }\n }\n const remaining = deadline - this.#now();\n if (remaining <= 0) {\n throw new SQLiteChangeLogBarrierTimeoutError(\n `timed out waiting for SQLite head ${observedHead} to ` +\n `reach required head ${requiredHead}`,\n );\n }\n await this.#awaitChangeLogProgress(\n requiredHead,\n remaining,\n barrier.signal,\n );\n }\n })();\n\n const backlogOverflow = backlogFull.promise.then(() => {\n this.#throwIfAborted(signal);\n // close() also resolves backlog waiters so none are stranded. The\n // backlog is cleared first, which distinguishes close from overflow.\n if (!subscriber.backlogFull) {\n return new Promise<never>(() => {});\n }\n this.#barrierWakeups.add(1, {'wakeup.source': 'backlog'});\n // Bytes, not time, are what the barrier actually costs: past the high\n // water mark this subscriber's send() no longer resolves, so it holds\n // up every flush and can stall the replica this barrier waits on.\n this.#barrierBacklogOverflows.add(1);\n throw new SQLiteChangeLogBarrierBacklogError(\n `backlog for subscriber ${subscriber.id} reached its high water ` +\n `mark while waiting for SQLite head ${observedHead} to ` +\n `reach required head ${requiredHead}`,\n );\n });\n\n return await Promise.race([planReady, backlogOverflow]);\n } finally {\n signal.removeEventListener('abort', abortBarrier);\n barrier.abort();\n backlogFull.cancel();\n }\n }\n\n /**\n * Waits for the change-log writer's ACK of `requiredHead`, or the poll\n * interval as a backstop.\n *\n * No ACK can be missed by registering after `plan()` was read, because the\n * ACK lags the write it acknowledges: if the writer had already acked\n * `requiredHead`, `plan()` would have seen it.\n */\n async #awaitChangeLogProgress(\n requiredHead: string,\n remainingMs: number,\n signal: AbortSignal,\n ): Promise<void> {\n const acked = resolver<void>();\n const waiter = {watermark: requiredHead, resolve: acked.resolve};\n this.#ackWaiters.add(waiter);\n // Scopes the backstop timer to this wait so that an ACK cancels it rather\n // than leaving it to fire against a barrier that has already moved on.\n const poll = new AbortController();\n const abortPoll = () => poll.abort();\n signal.addEventListener('abort', abortPoll, {once: true});\n try {\n const wokenBy = await Promise.race([\n acked.promise.then(() => 'ack' as const),\n this.#sleep(\n Math.min(this.#barrierPollIntervalMs, remainingMs),\n poll.signal,\n ).then(() => 'poll' as const),\n ]);\n this.#barrierWakeups.add(1, {'wakeup.source': wokenBy});\n } finally {\n this.#ackWaiters.delete(waiter);\n signal.removeEventListener('abort', abortPoll);\n poll.abort();\n }\n }\n\n #throwIfAborted(signal: AbortSignal): void {\n if (this.#closed || signal.aborted) {\n throw new AbortError('SQLite change-log catchup aborted');\n }\n }\n}\n\ntype AckWaiter = {watermark: string; resolve: () => void};\n\n/**\n * A barrier that gave up on the replica reaching the required head. Both\n * reasons mean \"not yet\", not \"never\", so both end the subscription cleanly\n * for a retry rather than failing it.\n */\nexport class SQLiteChangeLogBarrierError extends Error {}\n\n/** The replica did not reach the required head before the deadline. */\nexport class SQLiteChangeLogBarrierTimeoutError extends SQLiteChangeLogBarrierError {\n readonly name = 'SQLiteChangeLogBarrierTimeoutError';\n}\n\n/** Waiting any longer would have cost more than reconnecting. */\nexport class SQLiteChangeLogBarrierBacklogError extends SQLiteChangeLogBarrierError {\n readonly name = 'SQLiteChangeLogBarrierBacklogError';\n}\n"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"sqlite-change-log-reader.d.ts","sourceRoot":"","sources":["../../../../../../zero-cache/src/services/change-streamer/sqlite-change-log-reader.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAC,UAAU,EAAC,MAAM,kBAAkB,CAAC;AASjD,OAAO,KAAK,EAAC,iBAAiB,EAAC,MAAM,sBAAsB,CAAC;AAE5D,MAAM,MAAM,WAAW,GACnB;IACE,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;IACvB,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;CAChC,GACD;IAAC,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;IAAC,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAA;CAAC,GACxD;IACE,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC;IACzB,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;CAChC,GAID;IAAC,QAAQ,CAAC,IAAI,EAAE,WAAW,CAAA;CAAC,CAAC;AAiBjC;;;;GAIG;AACH,eAAO,MAAM,8BAA8B,iPAQ1C,CAAC;AAEF;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,0BAA0B,ieAOtC,CAAC;AAEF,2EAA2E;AAC3E,eAAO,MAAM,gCAAgC,6QAW5C,CAAC;AAMF;;;;;;;;;;;;;;GAcG;AACH,qBAAa,qBAAsB,YAAW,UAAU;;gBAK1C,EAAE,EAAE,UAAU,EAAE,aAAa,EAAE,MAAM;IAQjD,IAAI,CAAC,aAAa,EAAE,MAAM,GAAG,WAAW;IAqBjC,IAAI,CACT,aAAa,EAAE,MAAM,EACrB,gBAAgB,EAAE,MAAM,EACxB,SAAS,EAAE,MAAM,EACjB,MAAM,CAAC,EAAE,WAAW,GAAG,SAAS,GAC/B,aAAa,CAAC,SAAS,iBAAiB,EAAE,CAAC;IA2D9C,KAAK,IAAI,IAAI;IAQb,CAAC,MAAM,CAAC,OAAO,CAAC,IAAI,IAAI;CAuCzB"}
1
+ {"version":3,"file":"sqlite-change-log-reader.d.ts","sourceRoot":"","sources":["../../../../../../zero-cache/src/services/change-streamer/sqlite-change-log-reader.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAC,UAAU,EAAC,MAAM,kBAAkB,CAAC;AASjD,OAAO,KAAK,EAAC,iBAAiB,EAAC,MAAM,sBAAsB,CAAC;AAE5D,MAAM,MAAM,WAAW,GACnB;IACE,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;IACvB,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;CAChC,GACD;IAAC,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;IAAC,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAA;CAAC,GACxD;IACE,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC;IACzB,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;CAChC,GAKD;IAAC,QAAQ,CAAC,IAAI,EAAE,WAAW,CAAA;CAAC,CAAC;AAiBjC;;;;GAIG;AACH,eAAO,MAAM,8BAA8B,iPAQ1C,CAAC;AAEF;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,0BAA0B,ieAOtC,CAAC;AAEF,2EAA2E;AAC3E,eAAO,MAAM,gCAAgC,6QAW5C,CAAC;AAMF;;;;;;;;;;;;;;GAcG;AACH,qBAAa,qBAAsB,YAAW,UAAU;;gBAK1C,EAAE,EAAE,UAAU,EAAE,aAAa,EAAE,MAAM;IAQjD,IAAI,CAAC,aAAa,EAAE,MAAM,GAAG,WAAW;IAqBjC,IAAI,CACT,aAAa,EAAE,MAAM,EACrB,gBAAgB,EAAE,MAAM,EACxB,SAAS,EAAE,MAAM,EACjB,MAAM,CAAC,EAAE,WAAW,GAAG,SAAS,GAC/B,aAAa,CAAC,SAAS,iBAAiB,EAAE,CAAC;IA2D9C,KAAK,IAAI,IAAI;IAQb,CAAC,MAAM,CAAC,OAAO,CAAC,IAAI,IAAI;CAuCzB"}
@@ -1 +1 @@
1
- {"version":3,"file":"sqlite-change-log-reader.js","names":["#db","#assertOpen","#prepare","#throwIfAborted","#closed","#statements","#streamTableExists"],"sources":["../../../../../../zero-cache/src/services/change-streamer/sqlite-change-log-reader.ts"],"sourcesContent":["import type {LogContext} from '@rocicorp/logger';\nimport {AbortError} from '../../../../shared/src/abort-error.ts';\nimport {assert} from '../../../../shared/src/asserts.ts';\nimport {Database, type Statement} from '../../../../zqlite/src/db.ts';\nimport {CHANGE_LOG_STREAM_TABLE} from '../replicator/schema/change-log-stream.ts';\nimport {\n reconstructWatermarkedChange,\n type ChangeLogEntry,\n} from './change-log-codec.ts';\nimport type {WatermarkedChange} from './change-streamer.ts';\n\nexport type CatchupPlan =\n | {\n readonly kind: 'range';\n readonly minWatermark: string;\n readonly headWatermark: string;\n }\n | {readonly kind: 'ahead'; readonly headWatermark: string}\n | {\n readonly kind: 'too-old';\n readonly minWatermark: string;\n readonly headWatermark: string;\n }\n // The log exists as a file but has no content to serve: the replicator has\n // not created or reconciled it yet. Slice 7E declines and falls back to PG\n // catchup; there is no production caller before then.\n | {readonly kind: 'not-ready'};\n\ntype PlanRow = {\n readonly headWatermark: string | null;\n readonly minWatermark: string | null;\n readonly boundaryExists: number;\n};\n\ntype ChangeLogRow = ChangeLogEntry & {\n readonly pos: number;\n};\n\ntype PreparedStatements = {\n readonly plan: Statement;\n readonly readBatch: Statement;\n};\n\n/**\n * Seeks to the final position so validating a boundary does not scan every row\n * of a large transaction. Exported so the focused query-plan test covers the\n * production subquery.\n */\nexport const SQLITE_CHANGE_LOG_BOUNDARY_SQL = /*sql*/ `\n SELECT\n \"precommit\" = @fromWatermark AND\n json_extract(\"change\", '$.tag') = 'commit' AS \"boundaryExists\"\n FROM \"${CHANGE_LOG_STREAM_TABLE}\"\n WHERE \"watermark\" = @fromWatermark\n ORDER BY \"pos\" DESC\n LIMIT 1\n`;\n\n/**\n * The log describes its own bounds; nothing here reads the replica.\n * `max(\"watermark\")` is the head because a transaction's rows commit atomically,\n * so the log never ends mid-transaction.\n *\n * `min` and `max` are deliberately kept as *separate* scalar subqueries: SQLite\n * applies its single-aggregate index optimization only when the aggregate is\n * alone in its query, so `SELECT min(x), max(x) FROM t` degrades to a full scan\n * while two scalar subqueries are two index seeks. The query-plan test pins\n * this.\n *\n * Exported so that test can cover the production query.\n */\nexport const SQLITE_CHANGE_LOG_PLAN_SQL = /*sql*/ `\n SELECT\n (SELECT max(\"watermark\") FROM \"${CHANGE_LOG_STREAM_TABLE}\")\n AS \"headWatermark\",\n (SELECT min(\"watermark\") FROM \"${CHANGE_LOG_STREAM_TABLE}\")\n AS \"minWatermark\",\n coalesce((${SQLITE_CHANGE_LOG_BOUNDARY_SQL}), 0) AS \"boundaryExists\"\n`;\n\n/** Exported so the focused query-plan test covers the production query. */\nexport const SQLITE_CHANGE_LOG_READ_BATCH_SQL = /*sql*/ `\n SELECT\n \"watermark\",\n \"pos\",\n json_extract(\"change\", '$.tag') AS \"tag\",\n \"change\"\n FROM \"${CHANGE_LOG_STREAM_TABLE}\"\n WHERE (\"watermark\", \"pos\") > (?, ?)\n AND \"watermark\" <= ?\n ORDER BY \"watermark\", \"pos\"\n LIMIT ?\n`;\n\nconst TABLE_EXISTS_SQL = /*sql*/ `\n SELECT 1 FROM \"sqlite_master\" WHERE \"type\" = 'table' AND \"name\" = ?\n`;\n\n/**\n * Reads the change log without retaining a SQLite snapshot while a consumer\n * processes a batch.\n *\n * The log lives in its own database beside the replica\n * (`changeLogFileName(replicaFile)`), which this reader opens read-only; it\n * never opens the replica. The connection therefore keeps the journal mode\n * configured by the change-log writer. Callers pin a head with {@link plan}\n * before passing it to {@link read}.\n *\n * A change-streamer can start before the replicator has created the log, so the\n * stream table may not exist yet. Statements are prepared on first use rather\n * than in the constructor, and {@link plan} reports `not-ready` until they can\n * be; a reader constructed too early becomes usable once the writer catches up.\n */\nexport class SQLiteChangeLogReader implements Disposable {\n readonly #db: Database;\n #statements: PreparedStatements | undefined;\n #closed = false;\n\n constructor(lc: LogContext, changeLogFile: string) {\n this.#db = new Database(\n lc.withContext('component', 'sqlite-change-log-reader'),\n changeLogFile,\n {readonly: true},\n );\n }\n\n plan(fromWatermark: string): CatchupPlan {\n this.#assertOpen();\n const statements = this.#prepare();\n if (statements === undefined) {\n return {kind: 'not-ready'};\n }\n const row = statements.plan.get<PlanRow>({fromWatermark});\n if (row.headWatermark === null || row.minWatermark === null) {\n return {kind: 'not-ready'};\n }\n const {headWatermark, minWatermark} = row;\n\n if (fromWatermark > headWatermark) {\n return {kind: 'ahead', headWatermark};\n }\n if (fromWatermark < minWatermark || row.boundaryExists === 0) {\n return {kind: 'too-old', minWatermark, headWatermark};\n }\n return {kind: 'range', minWatermark, headWatermark};\n }\n\n async *read(\n fromWatermark: string,\n throughWatermark: string,\n batchSize: number,\n signal?: AbortSignal | undefined,\n ): AsyncIterable<readonly WatermarkedChange[]> {\n assert(\n Number.isSafeInteger(batchSize) && batchSize > 0,\n 'SQLite change log batch size must be a positive safe integer',\n );\n this.#assertOpen();\n const statements = this.#prepare();\n assert(\n statements !== undefined,\n 'SQLite change log must be ready before it is read',\n );\n\n let lastWatermark = fromWatermark;\n // The first query must be strictly after the requested transaction. Every\n // real stream position is far below this sentinel.\n let lastPos = Number.MAX_SAFE_INTEGER;\n let lastTag: string | undefined;\n\n while (true) {\n this.#throwIfAborted(signal);\n const rows = statements.readBatch.all<ChangeLogRow>(\n lastWatermark,\n lastPos,\n throughWatermark,\n batchSize,\n );\n this.#throwIfAborted(signal);\n\n if (rows.length === 0) {\n break;\n }\n\n const changes = rows.map(reconstructWatermarkedChange);\n const last = rows.at(-1);\n assert(last !== undefined, 'non-empty batch must have a final row');\n lastWatermark = last.watermark;\n lastPos = last.pos;\n lastTag = last.tag;\n\n // all() has exhausted the SELECT and closed its implicit read\n // transaction. This yield therefore cannot pin the WAL while subscriber\n // flow control is pending.\n yield changes;\n }\n\n // `throughWatermark` was pinned from `max(\"watermark\")` of this same table,\n // so reaching it no longer depends on the log agreeing with the replica's\n // `_zero.replicationState`, which is what this assertion used to guard.\n // What remains is a concurrent purge of the head transaction, which the\n // purge policy (never past the latest durable transaction) does not do.\n if (fromWatermark !== throughWatermark) {\n assert(\n lastWatermark === throughWatermark && lastTag === 'commit',\n () =>\n `SQLite change log did not end at pinned head ${throughWatermark}`,\n );\n }\n }\n\n close(): void {\n if (this.#closed) {\n return;\n }\n this.#closed = true;\n this.#db.close();\n }\n\n [Symbol.dispose](): void {\n this.close();\n }\n\n /**\n * Prepares the statements once the stream table exists, memoizing them.\n * Returns `undefined` while the log has not been created, which is a normal\n * startup state rather than an error.\n */\n #prepare(): PreparedStatements | undefined {\n if (this.#statements === undefined && this.#streamTableExists()) {\n this.#statements = {\n plan: this.#db.prepare(SQLITE_CHANGE_LOG_PLAN_SQL),\n readBatch: this.#db.prepare(SQLITE_CHANGE_LOG_READ_BATCH_SQL),\n };\n }\n return this.#statements;\n }\n\n #streamTableExists(): boolean {\n return (\n this.#db\n .prepare(TABLE_EXISTS_SQL)\n .get<{1: number} | undefined>(CHANGE_LOG_STREAM_TABLE) !== undefined\n );\n }\n\n #assertOpen(): void {\n if (this.#closed) {\n throw new AbortError('SQLite change log reader is closed');\n }\n }\n\n #throwIfAborted(signal: AbortSignal | undefined): void {\n this.#assertOpen();\n if (signal?.aborted) {\n throw new AbortError('SQLite change log read aborted');\n }\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;AAuEA,IAAa,6BAAqC;;qCAEb,wBAAwB;;qCAExB,wBAAwB;;gBAE7C;;;;UAzBN,wBAAwB;;;;EAyBa;;;AAI/C,IAAa,mCAA2C;;;;;;UAM9C,wBAAwB;;;;;;AAOlC,IAAM,mBAA2B;;;;;;;;;;;;;;;;;;AAmBjC,IAAa,wBAAb,MAAyD;CACvD;CACA;CACA,UAAU;CAEV,YAAY,IAAgB,eAAuB;EACjD,KAAKA,MAAM,IAAI,SACb,GAAG,YAAY,aAAa,0BAA0B,GACtD,eACA,EAAC,UAAU,KAAI,CACjB;CACF;CAEA,KAAK,eAAoC;EACvC,KAAKC,YAAY;EACjB,MAAM,aAAa,KAAKC,SAAS;EACjC,IAAI,eAAe,KAAA,GACjB,OAAO,EAAC,MAAM,YAAW;EAE3B,MAAM,MAAM,WAAW,KAAK,IAAa,EAAC,cAAa,CAAC;EACxD,IAAI,IAAI,kBAAkB,QAAQ,IAAI,iBAAiB,MACrD,OAAO,EAAC,MAAM,YAAW;EAE3B,MAAM,EAAC,eAAe,iBAAgB;EAEtC,IAAI,gBAAgB,eAClB,OAAO;GAAC,MAAM;GAAS;EAAa;EAEtC,IAAI,gBAAgB,gBAAgB,IAAI,mBAAmB,GACzD,OAAO;GAAC,MAAM;GAAW;GAAc;EAAa;EAEtD,OAAO;GAAC,MAAM;GAAS;GAAc;EAAa;CACpD;CAEA,OAAO,KACL,eACA,kBACA,WACA,QAC6C;EAC7C,OACE,OAAO,cAAc,SAAS,KAAK,YAAY,GAC/C,8DACF;EACA,KAAKD,YAAY;EACjB,MAAM,aAAa,KAAKC,SAAS;EACjC,OACE,eAAe,KAAA,GACf,mDACF;EAEA,IAAI,gBAAgB;EAGpB,IAAI,UAAU,OAAO;EACrB,IAAI;EAEJ,OAAO,MAAM;GACX,KAAKC,gBAAgB,MAAM;GAC3B,MAAM,OAAO,WAAW,UAAU,IAChC,eACA,SACA,kBACA,SACF;GACA,KAAKA,gBAAgB,MAAM;GAE3B,IAAI,KAAK,WAAW,GAClB;GAGF,MAAM,UAAU,KAAK,IAAI,4BAA4B;GACrD,MAAM,OAAO,KAAK,GAAG,EAAE;GACvB,OAAO,SAAS,KAAA,GAAW,uCAAuC;GAClE,gBAAgB,KAAK;GACrB,UAAU,KAAK;GACf,UAAU,KAAK;GAKf,MAAM;EACR;EAOA,IAAI,kBAAkB,kBACpB,OACE,kBAAkB,oBAAoB,YAAY,gBAEhD,gDAAgD,kBACpD;CAEJ;CAEA,QAAc;EACZ,IAAI,KAAKC,SACP;EAEF,KAAKA,UAAU;EACf,KAAKJ,IAAI,MAAM;CACjB;CAEA,CAAC,OAAO,WAAiB;EACvB,KAAK,MAAM;CACb;;;;;;CAOA,WAA2C;EACzC,IAAI,KAAKK,gBAAgB,KAAA,KAAa,KAAKC,mBAAmB,GAC5D,KAAKD,cAAc;GACjB,MAAM,KAAKL,IAAI,QAAQ,0BAA0B;GACjD,WAAW,KAAKA,IAAI,QAAQ,gCAAgC;EAC9D;EAEF,OAAO,KAAKK;CACd;CAEA,qBAA8B;EAC5B,OACE,KAAKL,IACF,QAAQ,gBAAgB,EACxB,IAA6B,uBAAuB,MAAM,KAAA;CAEjE;CAEA,cAAoB;EAClB,IAAI,KAAKI,SACP,MAAM,IAAI,WAAW,oCAAoC;CAE7D;CAEA,gBAAgB,QAAuC;EACrD,KAAKH,YAAY;EACjB,IAAI,QAAQ,SACV,MAAM,IAAI,WAAW,gCAAgC;CAEzD;AACF"}
1
+ {"version":3,"file":"sqlite-change-log-reader.js","names":["#db","#assertOpen","#prepare","#throwIfAborted","#closed","#statements","#streamTableExists"],"sources":["../../../../../../zero-cache/src/services/change-streamer/sqlite-change-log-reader.ts"],"sourcesContent":["import type {LogContext} from '@rocicorp/logger';\nimport {AbortError} from '../../../../shared/src/abort-error.ts';\nimport {assert} from '../../../../shared/src/asserts.ts';\nimport {Database, type Statement} from '../../../../zqlite/src/db.ts';\nimport {CHANGE_LOG_STREAM_TABLE} from '../replicator/schema/change-log-stream.ts';\nimport {\n reconstructWatermarkedChange,\n type ChangeLogEntry,\n} from './change-log-codec.ts';\nimport type {WatermarkedChange} from './change-streamer.ts';\n\nexport type CatchupPlan =\n | {\n readonly kind: 'range';\n readonly minWatermark: string;\n readonly headWatermark: string;\n }\n | {readonly kind: 'ahead'; readonly headWatermark: string}\n | {\n readonly kind: 'too-old';\n readonly minWatermark: string;\n readonly headWatermark: string;\n }\n // The log exists as a file but has no content to serve: the replicator has\n // not created or reconciled it yet. Subscriber selection treats this as\n // \"decline and fall back to PG catchup\", which is why it is a plan rather\n // than an error.\n | {readonly kind: 'not-ready'};\n\ntype PlanRow = {\n readonly headWatermark: string | null;\n readonly minWatermark: string | null;\n readonly boundaryExists: number;\n};\n\ntype ChangeLogRow = ChangeLogEntry & {\n readonly pos: number;\n};\n\ntype PreparedStatements = {\n readonly plan: Statement;\n readonly readBatch: Statement;\n};\n\n/**\n * Seeks to the final position so validating a boundary does not scan every row\n * of a large transaction. Exported so the focused query-plan test covers the\n * production subquery.\n */\nexport const SQLITE_CHANGE_LOG_BOUNDARY_SQL = /*sql*/ `\n SELECT\n \"precommit\" = @fromWatermark AND\n json_extract(\"change\", '$.tag') = 'commit' AS \"boundaryExists\"\n FROM \"${CHANGE_LOG_STREAM_TABLE}\"\n WHERE \"watermark\" = @fromWatermark\n ORDER BY \"pos\" DESC\n LIMIT 1\n`;\n\n/**\n * The log describes its own bounds; nothing here reads the replica.\n * `max(\"watermark\")` is the head because a transaction's rows commit atomically,\n * so the log never ends mid-transaction.\n *\n * `min` and `max` are deliberately kept as *separate* scalar subqueries: SQLite\n * applies its single-aggregate index optimization only when the aggregate is\n * alone in its query, so `SELECT min(x), max(x) FROM t` degrades to a full scan\n * while two scalar subqueries are two index seeks. The query-plan test pins\n * this.\n *\n * Exported so that test can cover the production query.\n */\nexport const SQLITE_CHANGE_LOG_PLAN_SQL = /*sql*/ `\n SELECT\n (SELECT max(\"watermark\") FROM \"${CHANGE_LOG_STREAM_TABLE}\")\n AS \"headWatermark\",\n (SELECT min(\"watermark\") FROM \"${CHANGE_LOG_STREAM_TABLE}\")\n AS \"minWatermark\",\n coalesce((${SQLITE_CHANGE_LOG_BOUNDARY_SQL}), 0) AS \"boundaryExists\"\n`;\n\n/** Exported so the focused query-plan test covers the production query. */\nexport const SQLITE_CHANGE_LOG_READ_BATCH_SQL = /*sql*/ `\n SELECT\n \"watermark\",\n \"pos\",\n json_extract(\"change\", '$.tag') AS \"tag\",\n \"change\"\n FROM \"${CHANGE_LOG_STREAM_TABLE}\"\n WHERE (\"watermark\", \"pos\") > (?, ?)\n AND \"watermark\" <= ?\n ORDER BY \"watermark\", \"pos\"\n LIMIT ?\n`;\n\nconst TABLE_EXISTS_SQL = /*sql*/ `\n SELECT 1 FROM \"sqlite_master\" WHERE \"type\" = 'table' AND \"name\" = ?\n`;\n\n/**\n * Reads the change log without retaining a SQLite snapshot while a consumer\n * processes a batch.\n *\n * The log lives in its own database beside the replica\n * (`changeLogFileName(replicaFile)`), which this reader opens read-only; it\n * never opens the replica. The connection therefore keeps the journal mode\n * configured by the change-log writer. Callers pin a head with {@link plan}\n * before passing it to {@link read}.\n *\n * A change-streamer can start before the replicator has created the log, so the\n * stream table may not exist yet. Statements are prepared on first use rather\n * than in the constructor, and {@link plan} reports `not-ready` until they can\n * be; a reader constructed too early becomes usable once the writer catches up.\n */\nexport class SQLiteChangeLogReader implements Disposable {\n readonly #db: Database;\n #statements: PreparedStatements | undefined;\n #closed = false;\n\n constructor(lc: LogContext, changeLogFile: string) {\n this.#db = new Database(\n lc.withContext('component', 'sqlite-change-log-reader'),\n changeLogFile,\n {readonly: true},\n );\n }\n\n plan(fromWatermark: string): CatchupPlan {\n this.#assertOpen();\n const statements = this.#prepare();\n if (statements === undefined) {\n return {kind: 'not-ready'};\n }\n const row = statements.plan.get<PlanRow>({fromWatermark});\n if (row.headWatermark === null || row.minWatermark === null) {\n return {kind: 'not-ready'};\n }\n const {headWatermark, minWatermark} = row;\n\n if (fromWatermark > headWatermark) {\n return {kind: 'ahead', headWatermark};\n }\n if (fromWatermark < minWatermark || row.boundaryExists === 0) {\n return {kind: 'too-old', minWatermark, headWatermark};\n }\n return {kind: 'range', minWatermark, headWatermark};\n }\n\n async *read(\n fromWatermark: string,\n throughWatermark: string,\n batchSize: number,\n signal?: AbortSignal | undefined,\n ): AsyncIterable<readonly WatermarkedChange[]> {\n assert(\n Number.isSafeInteger(batchSize) && batchSize > 0,\n 'SQLite change log batch size must be a positive safe integer',\n );\n this.#assertOpen();\n const statements = this.#prepare();\n assert(\n statements !== undefined,\n 'SQLite change log must be ready before it is read',\n );\n\n let lastWatermark = fromWatermark;\n // The first query must be strictly after the requested transaction. Every\n // real stream position is far below this sentinel.\n let lastPos = Number.MAX_SAFE_INTEGER;\n let lastTag: string | undefined;\n\n while (true) {\n this.#throwIfAborted(signal);\n const rows = statements.readBatch.all<ChangeLogRow>(\n lastWatermark,\n lastPos,\n throughWatermark,\n batchSize,\n );\n this.#throwIfAborted(signal);\n\n if (rows.length === 0) {\n break;\n }\n\n const changes = rows.map(reconstructWatermarkedChange);\n const last = rows.at(-1);\n assert(last !== undefined, 'non-empty batch must have a final row');\n lastWatermark = last.watermark;\n lastPos = last.pos;\n lastTag = last.tag;\n\n // all() has exhausted the SELECT and closed its implicit read\n // transaction. This yield therefore cannot pin the WAL while subscriber\n // flow control is pending.\n yield changes;\n }\n\n // `throughWatermark` was pinned from `max(\"watermark\")` of this same table,\n // so reaching it no longer depends on the log agreeing with the replica's\n // `_zero.replicationState`, which is what this assertion used to guard.\n // What remains is a concurrent purge of the head transaction, which the\n // purge policy (never past the latest durable transaction) does not do.\n if (fromWatermark !== throughWatermark) {\n assert(\n lastWatermark === throughWatermark && lastTag === 'commit',\n () =>\n `SQLite change log did not end at pinned head ${throughWatermark}`,\n );\n }\n }\n\n close(): void {\n if (this.#closed) {\n return;\n }\n this.#closed = true;\n this.#db.close();\n }\n\n [Symbol.dispose](): void {\n this.close();\n }\n\n /**\n * Prepares the statements once the stream table exists, memoizing them.\n * Returns `undefined` while the log has not been created, which is a normal\n * startup state rather than an error.\n */\n #prepare(): PreparedStatements | undefined {\n if (this.#statements === undefined && this.#streamTableExists()) {\n this.#statements = {\n plan: this.#db.prepare(SQLITE_CHANGE_LOG_PLAN_SQL),\n readBatch: this.#db.prepare(SQLITE_CHANGE_LOG_READ_BATCH_SQL),\n };\n }\n return this.#statements;\n }\n\n #streamTableExists(): boolean {\n return (\n this.#db\n .prepare(TABLE_EXISTS_SQL)\n .get<{1: number} | undefined>(CHANGE_LOG_STREAM_TABLE) !== undefined\n );\n }\n\n #assertOpen(): void {\n if (this.#closed) {\n throw new AbortError('SQLite change log reader is closed');\n }\n }\n\n #throwIfAborted(signal: AbortSignal | undefined): void {\n this.#assertOpen();\n if (signal?.aborted) {\n throw new AbortError('SQLite change log read aborted');\n }\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;AAwEA,IAAa,6BAAqC;;qCAEb,wBAAwB;;qCAExB,wBAAwB;;gBAE7C;;;;UAzBN,wBAAwB;;;;EAyBa;;;AAI/C,IAAa,mCAA2C;;;;;;UAM9C,wBAAwB;;;;;;AAOlC,IAAM,mBAA2B;;;;;;;;;;;;;;;;;;AAmBjC,IAAa,wBAAb,MAAyD;CACvD;CACA;CACA,UAAU;CAEV,YAAY,IAAgB,eAAuB;EACjD,KAAKA,MAAM,IAAI,SACb,GAAG,YAAY,aAAa,0BAA0B,GACtD,eACA,EAAC,UAAU,KAAI,CACjB;CACF;CAEA,KAAK,eAAoC;EACvC,KAAKC,YAAY;EACjB,MAAM,aAAa,KAAKC,SAAS;EACjC,IAAI,eAAe,KAAA,GACjB,OAAO,EAAC,MAAM,YAAW;EAE3B,MAAM,MAAM,WAAW,KAAK,IAAa,EAAC,cAAa,CAAC;EACxD,IAAI,IAAI,kBAAkB,QAAQ,IAAI,iBAAiB,MACrD,OAAO,EAAC,MAAM,YAAW;EAE3B,MAAM,EAAC,eAAe,iBAAgB;EAEtC,IAAI,gBAAgB,eAClB,OAAO;GAAC,MAAM;GAAS;EAAa;EAEtC,IAAI,gBAAgB,gBAAgB,IAAI,mBAAmB,GACzD,OAAO;GAAC,MAAM;GAAW;GAAc;EAAa;EAEtD,OAAO;GAAC,MAAM;GAAS;GAAc;EAAa;CACpD;CAEA,OAAO,KACL,eACA,kBACA,WACA,QAC6C;EAC7C,OACE,OAAO,cAAc,SAAS,KAAK,YAAY,GAC/C,8DACF;EACA,KAAKD,YAAY;EACjB,MAAM,aAAa,KAAKC,SAAS;EACjC,OACE,eAAe,KAAA,GACf,mDACF;EAEA,IAAI,gBAAgB;EAGpB,IAAI,UAAU,OAAO;EACrB,IAAI;EAEJ,OAAO,MAAM;GACX,KAAKC,gBAAgB,MAAM;GAC3B,MAAM,OAAO,WAAW,UAAU,IAChC,eACA,SACA,kBACA,SACF;GACA,KAAKA,gBAAgB,MAAM;GAE3B,IAAI,KAAK,WAAW,GAClB;GAGF,MAAM,UAAU,KAAK,IAAI,4BAA4B;GACrD,MAAM,OAAO,KAAK,GAAG,EAAE;GACvB,OAAO,SAAS,KAAA,GAAW,uCAAuC;GAClE,gBAAgB,KAAK;GACrB,UAAU,KAAK;GACf,UAAU,KAAK;GAKf,MAAM;EACR;EAOA,IAAI,kBAAkB,kBACpB,OACE,kBAAkB,oBAAoB,YAAY,gBAEhD,gDAAgD,kBACpD;CAEJ;CAEA,QAAc;EACZ,IAAI,KAAKC,SACP;EAEF,KAAKA,UAAU;EACf,KAAKJ,IAAI,MAAM;CACjB;CAEA,CAAC,OAAO,WAAiB;EACvB,KAAK,MAAM;CACb;;;;;;CAOA,WAA2C;EACzC,IAAI,KAAKK,gBAAgB,KAAA,KAAa,KAAKC,mBAAmB,GAC5D,KAAKD,cAAc;GACjB,MAAM,KAAKL,IAAI,QAAQ,0BAA0B;GACjD,WAAW,KAAKA,IAAI,QAAQ,gCAAgC;EAC9D;EAEF,OAAO,KAAKK;CACd;CAEA,qBAA8B;EAC5B,OACE,KAAKL,IACF,QAAQ,gBAAgB,EACxB,IAA6B,uBAAuB,MAAM,KAAA;CAEjE;CAEA,cAAoB;EAClB,IAAI,KAAKI,SACP,MAAM,IAAI,WAAW,oCAAoC;CAE7D;CAEA,gBAAgB,QAAuC;EACrD,KAAKH,YAAY;EACjB,IAAI,QAAQ,SACV,MAAM,IAAI,WAAW,gCAAgC;CAEzD;AACF"}
@@ -2,7 +2,7 @@
2
2
  /**
3
3
  * The current version of Zero.
4
4
  */
5
- var version = "1.10.0-head-e8decd93-20260729";
5
+ var version = "1.10.0-head-3dcaaf2a-20260729";
6
6
  //#endregion
7
7
  export { version };
8
8
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rocicorp/zero",
3
- "version": "1.10.0-head-e8decd93-20260729",
3
+ "version": "1.10.0-head-3dcaaf2a-20260729",
4
4
  "description": "Zero is a web framework for serverless web development.",
5
5
  "homepage": "https://zero.rocicorp.dev",
6
6
  "bugs": {