@rocicorp/zero 1.11.0-head-86ca5eff-20261014 → 1.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/out/_virtual/{_@oxc-project_runtime@0.130.0/helpers → _@oxc-project_runtime@0.154.0/helpers/esm}/usingCtx.js +2 -2
- package/out/analyze-query/src/analyze-cli.js +2 -1
- package/out/analyze-query/src/analyze-cli.js.map +1 -1
- package/out/analyze-query/src/bin-analyze.js +5 -3
- package/out/analyze-query/src/bin-analyze.js.map +1 -1
- package/out/analyze-query/src/bin-transform.js +3 -2
- package/out/analyze-query/src/bin-transform.js.map +1 -1
- package/out/ast-to-zql/src/ast-to-zql.js.map +1 -1
- package/out/ast-to-zql/src/bin.js +3 -2
- package/out/ast-to-zql/src/bin.js.map +1 -1
- package/out/ast-to-zql/src/format.js.map +1 -1
- package/out/datadog/src/datadog-log-sink.js +5 -4
- package/out/datadog/src/datadog-log-sink.js.map +1 -1
- package/out/otel/src/log-options.js.map +1 -1
- package/out/replicache/src/btree/node.js +13 -11
- package/out/replicache/src/btree/node.js.map +1 -1
- package/out/replicache/src/btree/read.js +4 -2
- package/out/replicache/src/btree/read.js.map +1 -1
- package/out/replicache/src/btree/splice.js.map +1 -1
- package/out/replicache/src/btree/write.js +9 -5
- package/out/replicache/src/btree/write.js.map +1 -1
- package/out/replicache/src/config.js +1 -2
- package/out/replicache/src/connection-loop.js.map +1 -1
- package/out/replicache/src/dag/chunk.js +1 -1
- package/out/replicache/src/dag/gc.js.map +1 -1
- package/out/replicache/src/dag/lazy-store.js +8 -7
- package/out/replicache/src/dag/lazy-store.js.map +1 -1
- package/out/replicache/src/dag/store-impl.js +1 -1
- package/out/replicache/src/dag/store-impl.js.map +1 -1
- package/out/replicache/src/dag/visitor.js.map +1 -1
- package/out/replicache/src/db/commit.js +5 -3
- package/out/replicache/src/db/commit.js.map +1 -1
- package/out/replicache/src/db/read.js +1 -1
- package/out/replicache/src/db/read.js.map +1 -1
- package/out/replicache/src/db/rebase.js.map +1 -1
- package/out/replicache/src/db/write.js +5 -2
- package/out/replicache/src/db/write.js.map +1 -1
- package/out/replicache/src/deleted-clients.js +3 -2
- package/out/replicache/src/deleted-clients.js.map +1 -1
- package/out/replicache/src/expo-sqlite.js +1 -2
- package/out/replicache/src/frozen-json.js +1 -1
- package/out/replicache/src/frozen-json.js.map +1 -1
- package/out/replicache/src/get-default-puller.js +1 -1
- package/out/replicache/src/hash.js +1 -1
- package/out/replicache/src/hash.js.map +1 -1
- package/out/replicache/src/impl.js +1 -2
- package/out/replicache/src/index-defs.js +5 -3
- package/out/replicache/src/index-defs.js.map +1 -1
- package/out/replicache/src/kv/expo-sqlite/store.js +1 -1
- package/out/replicache/src/kv/expo-sqlite/store.js.map +1 -1
- package/out/replicache/src/kv/idb-store.js +1 -1
- package/out/replicache/src/kv/idb-store.js.map +1 -1
- package/out/replicache/src/kv/mem-fallback-store.js +1 -1
- package/out/replicache/src/kv/mem-fallback-store.js.map +1 -1
- package/out/replicache/src/kv/mem-store.js.map +1 -1
- package/out/replicache/src/kv/op-sqlite/store.js.map +1 -1
- package/out/replicache/src/kv/op-sqlite/types.js.map +1 -1
- package/out/replicache/src/kv/read-impl.js.map +1 -1
- package/out/replicache/src/kv/sqlite-store.js +1 -1
- package/out/replicache/src/kv/sqlite-store.js.map +1 -1
- package/out/replicache/src/kv/write-impl-base.js +4 -1
- package/out/replicache/src/kv/write-impl-base.js.map +1 -1
- package/out/replicache/src/kv/write-impl.js.map +1 -1
- package/out/replicache/src/log-options.js +2 -1
- package/out/replicache/src/log-options.js.map +1 -1
- package/out/replicache/src/make-idb-name.js +1 -1
- package/out/replicache/src/new-client-channel.js +9 -7
- package/out/replicache/src/new-client-channel.js.map +1 -1
- package/out/replicache/src/on-persist-channel.js +1 -1
- package/out/replicache/src/on-persist-channel.js.map +1 -1
- package/out/replicache/src/op-sqlite.js +1 -2
- package/out/replicache/src/pending-mutations.js +2 -1
- package/out/replicache/src/pending-mutations.js.map +1 -1
- package/out/replicache/src/persist/client-gc.js +2 -2
- package/out/replicache/src/persist/client-gc.js.map +1 -1
- package/out/replicache/src/persist/client-group-gc.js +2 -2
- package/out/replicache/src/persist/client-group-gc.js.map +1 -1
- package/out/replicache/src/persist/client-groups.js +1 -1
- package/out/replicache/src/persist/client-groups.js.map +1 -1
- package/out/replicache/src/persist/clients.js +1 -1
- package/out/replicache/src/persist/clients.js.map +1 -1
- package/out/replicache/src/persist/collect-idb-databases.js +5 -4
- package/out/replicache/src/persist/collect-idb-databases.js.map +1 -1
- package/out/replicache/src/persist/gather-mem-only-visitor.js.map +1 -1
- package/out/replicache/src/persist/gather-not-cached-visitor.js.map +1 -1
- package/out/replicache/src/persist/heartbeat.js +2 -2
- package/out/replicache/src/persist/heartbeat.js.map +1 -1
- package/out/replicache/src/persist/idb-databases-store.js +1 -1
- package/out/replicache/src/persist/idb-databases-store.js.map +1 -1
- package/out/replicache/src/persist/make-client-id.js +1 -1
- package/out/replicache/src/persist/make-client-id.js.map +1 -1
- package/out/replicache/src/persist/persist.js +3 -2
- package/out/replicache/src/persist/persist.js.map +1 -1
- package/out/replicache/src/persist/refresh.js +8 -5
- package/out/replicache/src/persist/refresh.js.map +1 -1
- package/out/replicache/src/process-scheduler.js.map +1 -1
- package/out/replicache/src/replicache-impl.js +3 -3
- package/out/replicache/src/replicache-impl.js.map +1 -1
- package/out/replicache/src/scan-iterator.js.map +1 -1
- package/out/replicache/src/scan-options.js +7 -6
- package/out/replicache/src/scan-options.js.map +1 -1
- package/out/replicache/src/sqlite.js +1 -2
- package/out/replicache/src/subscriptions.js +1 -1
- package/out/replicache/src/subscriptions.js.map +1 -1
- package/out/replicache/src/sync/diff.js +3 -1
- package/out/replicache/src/sync/diff.js.map +1 -1
- package/out/replicache/src/sync/ids.js.map +1 -1
- package/out/replicache/src/sync/patch.js +2 -4
- package/out/replicache/src/sync/patch.js.map +1 -1
- package/out/replicache/src/sync/pull.js +3 -1
- package/out/replicache/src/sync/pull.js.map +1 -1
- package/out/replicache/src/sync/push.js.map +1 -1
- package/out/replicache/src/sync/request-id.js +1 -1
- package/out/replicache/src/sync/request-id.js.map +1 -1
- package/out/replicache/src/transactions.js +1 -1
- package/out/replicache/src/transactions.js.map +1 -1
- package/out/shared/src/arrays.js +4 -1
- package/out/shared/src/arrays.js.map +1 -1
- package/out/shared/src/asserts.js +1 -1
- package/out/shared/src/bigint-json.js.map +1 -1
- package/out/shared/src/broadcast-channel.js +1 -1
- package/out/shared/src/btree-set.js +11 -8
- package/out/shared/src/btree-set.js.map +1 -1
- package/out/shared/src/cache.js.map +1 -1
- package/out/shared/src/config.js +1 -1
- package/out/shared/src/custom-key-map.js.map +1 -1
- package/out/shared/src/custom-key-set.js.map +1 -1
- package/out/shared/src/deep-clone.js +1 -1
- package/out/shared/src/document-visible.js.map +1 -1
- package/out/shared/src/dotenv.js.map +1 -1
- package/out/shared/src/error.js +1 -1
- package/out/shared/src/error.js.map +1 -1
- package/out/shared/src/hash.js +2 -2
- package/out/shared/src/hash.js.map +1 -1
- package/out/shared/src/iterables.js.map +1 -1
- package/out/shared/src/json-schema.js.map +1 -1
- package/out/shared/src/json.js.map +1 -1
- package/out/shared/src/logging-test-utils.js +1 -1
- package/out/shared/src/logging.js +1 -1
- package/out/shared/src/logging.js.map +1 -1
- package/out/shared/src/navigator.js +1 -1
- package/out/shared/src/options.js +9 -12
- package/out/shared/src/options.js.map +1 -1
- package/out/shared/src/promise-race.js.map +1 -1
- package/out/shared/src/queue.js.map +1 -1
- package/out/shared/src/record-proxy.js.map +1 -1
- package/out/shared/src/ring-buffer.js.map +1 -1
- package/out/shared/src/size-of-value.js +7 -10
- package/out/shared/src/size-of-value.js.map +1 -1
- package/out/shared/src/sorted-entries.js.map +1 -1
- package/out/shared/src/tdigest-schema.js.map +1 -1
- package/out/shared/src/tdigest.js.map +1 -1
- package/out/shared/src/valita.js +1 -1
- package/out/shared/src/valita.js.map +1 -1
- package/out/z2s/src/compiler.js +2 -4
- package/out/z2s/src/compiler.js.map +1 -1
- package/out/z2s/src/sql.js +1 -1
- package/out/z2s/src/sql.js.map +1 -1
- package/out/zero/package.js +212 -189
- package/out/zero/package.js.map +1 -1
- package/out/zero/src/adapters/drizzle.js +1 -2
- package/out/zero/src/adapters/kysely.js +1 -2
- package/out/zero/src/adapters/pg.js +1 -2
- package/out/zero/src/adapters/postgresjs.js +1 -2
- package/out/zero/src/adapters/prisma.js +1 -2
- package/out/zero/src/analyze.js +1 -2
- package/out/zero/src/change-protocol/v0.js +1 -2
- package/out/zero/src/expo-sqlite.js +1 -2
- package/out/zero/src/op-sqlite.js +1 -2
- package/out/zero/src/react-native.js.map +1 -1
- package/out/zero/src/sqlite.js +1 -2
- package/out/zero/src/zero-cache-dev.js +10 -9
- package/out/zero/src/zero-cache-dev.js.map +1 -1
- package/out/zero/src/zero-out.js +2 -1
- package/out/zero/src/zero-out.js.map +1 -1
- package/out/zero-cache/src/auth/auth.js +1 -1
- package/out/zero-cache/src/auth/auth.js.map +1 -1
- package/out/zero-cache/src/auth/jwt.js +4 -1
- package/out/zero-cache/src/auth/jwt.js.map +1 -1
- package/out/zero-cache/src/auth/load-permissions.js.map +1 -1
- package/out/zero-cache/src/auth/read-authorizer.js +3 -2
- package/out/zero-cache/src/auth/read-authorizer.js.map +1 -1
- package/out/zero-cache/src/auth/write-authorizer.js +5 -16
- package/out/zero-cache/src/auth/write-authorizer.js.map +1 -1
- package/out/zero-cache/src/config/network.js +1 -1
- package/out/zero-cache/src/config/network.js.map +1 -1
- package/out/zero-cache/src/config/server-context.js.map +1 -1
- package/out/zero-cache/src/config/zero-config.js +2 -2
- package/out/zero-cache/src/config/zero-config.js.map +1 -1
- package/out/zero-cache/src/custom/fetch.js +1 -1
- package/out/zero-cache/src/custom/fetch.js.map +1 -1
- package/out/zero-cache/src/custom-queries/transform-query.js.map +1 -1
- package/out/zero-cache/src/db/create.js.map +1 -1
- package/out/zero-cache/src/db/index-predicate.js.map +1 -1
- package/out/zero-cache/src/db/lite-tables.js.map +1 -1
- package/out/zero-cache/src/db/migration-lite.js +1 -1
- package/out/zero-cache/src/db/migration-lite.js.map +1 -1
- package/out/zero-cache/src/db/migration.js +5 -3
- package/out/zero-cache/src/db/migration.js.map +1 -1
- package/out/zero-cache/src/db/pg-copy-binary.js +4 -3
- package/out/zero-cache/src/db/pg-copy-binary.js.map +1 -1
- package/out/zero-cache/src/db/pg-copy.js +0 -1
- package/out/zero-cache/src/db/pg-copy.js.map +1 -1
- package/out/zero-cache/src/db/pg-to-lite.js +1 -1
- package/out/zero-cache/src/db/pg-to-lite.js.map +1 -1
- package/out/zero-cache/src/db/run-transaction.js.map +1 -1
- package/out/zero-cache/src/db/specs.js +7 -5
- package/out/zero-cache/src/db/specs.js.map +1 -1
- package/out/zero-cache/src/db/sqlite-corruption.js.map +1 -1
- package/out/zero-cache/src/db/transaction-pool.js +5 -3
- package/out/zero-cache/src/db/transaction-pool.js.map +1 -1
- package/out/zero-cache/src/db/warmup.js.map +1 -1
- package/out/zero-cache/src/observability/events.js.map +1 -1
- package/out/zero-cache/src/observability/log-throttle.js.map +1 -1
- package/out/zero-cache/src/observability/metrics.js.map +1 -1
- package/out/zero-cache/src/scripts/deploy-permissions.js +4 -2
- package/out/zero-cache/src/scripts/deploy-permissions.js.map +1 -1
- package/out/zero-cache/src/scripts/permissions.js.map +1 -1
- package/out/zero-cache/src/server/anonymous-otel-start.js +2 -1
- package/out/zero-cache/src/server/anonymous-otel-start.js.map +1 -1
- package/out/zero-cache/src/server/change-streamer.js.map +1 -1
- package/out/zero-cache/src/server/inspector-delegate.js.map +1 -1
- package/out/zero-cache/src/server/logging.js +1 -1
- package/out/zero-cache/src/server/logging.js.map +1 -1
- package/out/zero-cache/src/server/main.js.map +1 -1
- package/out/zero-cache/src/server/otel-log-sink.js.map +1 -1
- package/out/zero-cache/src/server/otel-start.js.map +1 -1
- package/out/zero-cache/src/server/reaper.js +2 -2
- package/out/zero-cache/src/server/reaper.js.map +1 -1
- package/out/zero-cache/src/server/replicator.js.map +1 -1
- package/out/zero-cache/src/server/runner/run-worker.js +2 -1
- package/out/zero-cache/src/server/runner/run-worker.js.map +1 -1
- package/out/zero-cache/src/server/runner/runtime.js +2 -1
- package/out/zero-cache/src/server/runner/runtime.js.map +1 -1
- package/out/zero-cache/src/server/runner/zero-dispatcher.js.map +1 -1
- package/out/zero-cache/src/server/shadow-syncer.js +1 -1
- package/out/zero-cache/src/server/shadow-syncer.js.map +1 -1
- package/out/zero-cache/src/server/slot-keeper.js.map +1 -1
- package/out/zero-cache/src/server/syncer-assigner.js.map +1 -1
- package/out/zero-cache/src/server/syncer.js.map +1 -1
- package/out/zero-cache/src/server/worker-dispatcher.js +1 -1
- package/out/zero-cache/src/server/worker-dispatcher.js.map +1 -1
- package/out/zero-cache/src/server/worker-urls.js.map +1 -1
- package/out/zero-cache/src/services/analyze.js +1 -1
- package/out/zero-cache/src/services/analyze.js.map +1 -1
- package/out/zero-cache/src/services/change-source/common/backfill-manager.js +1 -1
- package/out/zero-cache/src/services/change-source/common/backfill-manager.js.map +1 -1
- package/out/zero-cache/src/services/change-source/common/change-stream-multiplexer.js.map +1 -1
- package/out/zero-cache/src/services/change-source/common/init-cleanup.js.map +1 -1
- package/out/zero-cache/src/services/change-source/common/replica-restore.js.map +1 -1
- package/out/zero-cache/src/services/change-source/common/replica-schema.js +3 -3
- package/out/zero-cache/src/services/change-source/common/replica-schema.js.map +1 -1
- package/out/zero-cache/src/services/change-source/custom/change-source.js +1 -1
- package/out/zero-cache/src/services/change-source/custom/change-source.js.map +1 -1
- package/out/zero-cache/src/services/change-source/pg/backfill-metadata.js.map +1 -1
- package/out/zero-cache/src/services/change-source/pg/backfill-stream.js +3 -3
- package/out/zero-cache/src/services/change-source/pg/backfill-stream.js.map +1 -1
- package/out/zero-cache/src/services/change-source/pg/change-source-init.js +1 -1
- package/out/zero-cache/src/services/change-source/pg/change-source-init.js.map +1 -1
- package/out/zero-cache/src/services/change-source/pg/change-source.js +4 -6
- package/out/zero-cache/src/services/change-source/pg/change-source.js.map +1 -1
- package/out/zero-cache/src/services/change-source/pg/decommission.js +2 -1
- package/out/zero-cache/src/services/change-source/pg/decommission.js.map +1 -1
- package/out/zero-cache/src/services/change-source/pg/initial-sync.js +5 -3
- package/out/zero-cache/src/services/change-source/pg/initial-sync.js.map +1 -1
- package/out/zero-cache/src/services/change-source/pg/logical-replication/binary-reader.js.map +1 -1
- package/out/zero-cache/src/services/change-source/pg/logical-replication/pgoutput-parser.js.map +1 -1
- package/out/zero-cache/src/services/change-source/pg/logical-replication/stream.js +3 -2
- package/out/zero-cache/src/services/change-source/pg/logical-replication/stream.js.map +1 -1
- package/out/zero-cache/src/services/change-source/pg/lsn.js.map +1 -1
- package/out/zero-cache/src/services/change-source/pg/replication-slot-cleanup-monitor.js +2 -2
- package/out/zero-cache/src/services/change-source/pg/replication-slot-cleanup-monitor.js.map +1 -1
- package/out/zero-cache/src/services/change-source/pg/replication-slot-health.js +1 -1
- package/out/zero-cache/src/services/change-source/pg/replication-slot-health.js.map +1 -1
- package/out/zero-cache/src/services/change-source/pg/replication-slots.js +3 -3
- package/out/zero-cache/src/services/change-source/pg/replication-slots.js.map +1 -1
- package/out/zero-cache/src/services/change-source/pg/schema/ddl.js +1 -1
- package/out/zero-cache/src/services/change-source/pg/schema/ddl.js.map +1 -1
- package/out/zero-cache/src/services/change-source/pg/schema/index-predicate.js +13 -12
- package/out/zero-cache/src/services/change-source/pg/schema/index-predicate.js.map +1 -1
- package/out/zero-cache/src/services/change-source/pg/schema/init.js.map +1 -1
- package/out/zero-cache/src/services/change-source/pg/schema/published.js +1 -1
- package/out/zero-cache/src/services/change-source/pg/schema/published.js.map +1 -1
- package/out/zero-cache/src/services/change-source/pg/schema/replica-stage-enum.js.map +1 -1
- package/out/zero-cache/src/services/change-source/pg/schema/shard.js +9 -6
- package/out/zero-cache/src/services/change-source/pg/schema/shard.js.map +1 -1
- package/out/zero-cache/src/services/change-source/pg/schema/validation.js +1 -1
- package/out/zero-cache/src/services/change-source/pg/schema/validation.js.map +1 -1
- package/out/zero-cache/src/services/change-source/protocol/backfill-progress.js +1 -1
- package/out/zero-cache/src/services/change-source/protocol/backfill-progress.js.map +1 -1
- package/out/zero-cache/src/services/change-source/protocol/current/control.js.map +1 -1
- package/out/zero-cache/src/services/change-source/protocol/current/data.js.map +1 -1
- package/out/zero-cache/src/services/change-source/protocol/current/downstream.js.map +1 -1
- package/out/zero-cache/src/services/change-source/protocol/current/status.js.map +1 -1
- package/out/zero-cache/src/services/change-source/protocol/current/upstream.js.map +1 -1
- package/out/zero-cache/src/services/change-source/protocol/current.js +1 -1
- package/out/zero-cache/src/services/change-source/protocol/mod.js +1 -2
- package/out/zero-cache/src/services/change-streamer/backfill-state-tracker.js.map +1 -1
- package/out/zero-cache/src/services/change-streamer/backfill-state.js +1 -1
- package/out/zero-cache/src/services/change-streamer/backfill-state.js.map +1 -1
- package/out/zero-cache/src/services/change-streamer/backup-cleanup-monitor-factory.js.map +1 -1
- package/out/zero-cache/src/services/change-streamer/backup-monitor.js.map +1 -1
- package/out/zero-cache/src/services/change-streamer/broadcast.js +1 -1
- package/out/zero-cache/src/services/change-streamer/broadcast.js.map +1 -1
- package/out/zero-cache/src/services/change-streamer/change-log-initializer.js +2 -2
- package/out/zero-cache/src/services/change-streamer/change-log-initializer.js.map +1 -1
- package/out/zero-cache/src/services/change-streamer/change-log-transaction-hash.js.map +1 -1
- package/out/zero-cache/src/services/change-streamer/change-streamer-http.d.ts +3 -1
- package/out/zero-cache/src/services/change-streamer/change-streamer-http.d.ts.map +1 -1
- package/out/zero-cache/src/services/change-streamer/change-streamer-http.js +8 -3
- package/out/zero-cache/src/services/change-streamer/change-streamer-http.js.map +1 -1
- package/out/zero-cache/src/services/change-streamer/change-streamer-service.js +23 -23
- package/out/zero-cache/src/services/change-streamer/change-streamer-service.js.map +1 -1
- package/out/zero-cache/src/services/change-streamer/change-streamer.js +1 -1
- package/out/zero-cache/src/services/change-streamer/change-streamer.js.map +1 -1
- package/out/zero-cache/src/services/change-streamer/drain-tracker.d.ts +4 -4
- package/out/zero-cache/src/services/change-streamer/drain-tracker.d.ts.map +1 -1
- package/out/zero-cache/src/services/change-streamer/drain-tracker.js +4 -8
- package/out/zero-cache/src/services/change-streamer/drain-tracker.js.map +1 -1
- package/out/zero-cache/src/services/change-streamer/forwarder.js +11 -10
- package/out/zero-cache/src/services/change-streamer/forwarder.js.map +1 -1
- package/out/zero-cache/src/services/change-streamer/litestream-sync-requester.js +1 -1
- package/out/zero-cache/src/services/change-streamer/litestream-sync-requester.js.map +1 -1
- package/out/zero-cache/src/services/change-streamer/litestream3-prometheus-poller.js +3 -3
- package/out/zero-cache/src/services/change-streamer/litestream3-prometheus-poller.js.map +1 -1
- package/out/zero-cache/src/services/change-streamer/replica-poller.js +1 -1
- package/out/zero-cache/src/services/change-streamer/replica-poller.js.map +1 -1
- package/out/zero-cache/src/services/change-streamer/restore-admission.js +12 -1
- package/out/zero-cache/src/services/change-streamer/restore-admission.js.map +1 -1
- package/out/zero-cache/src/services/change-streamer/schema/init.js +1 -1
- package/out/zero-cache/src/services/change-streamer/schema/init.js.map +1 -1
- package/out/zero-cache/src/services/change-streamer/schema/tables.js +1 -1
- package/out/zero-cache/src/services/change-streamer/schema/tables.js.map +1 -1
- package/out/zero-cache/src/services/change-streamer/snapshot-message.js +1 -1
- package/out/zero-cache/src/services/change-streamer/snapshot-message.js.map +1 -1
- package/out/zero-cache/src/services/change-streamer/snapshot-reservations.js.map +1 -1
- package/out/zero-cache/src/services/change-streamer/sqlite-change-log-catchup.js +1 -1
- package/out/zero-cache/src/services/change-streamer/sqlite-change-log-catchup.js.map +1 -1
- package/out/zero-cache/src/services/change-streamer/sqlite-change-log-comparator.js +1 -1
- package/out/zero-cache/src/services/change-streamer/sqlite-change-log-comparator.js.map +1 -1
- package/out/zero-cache/src/services/change-streamer/sqlite-change-log-purge-scheduler.js.map +1 -1
- package/out/zero-cache/src/services/change-streamer/sqlite-change-log-read-router.js.map +1 -1
- package/out/zero-cache/src/services/change-streamer/sqlite-change-log-reader.js +1 -1
- package/out/zero-cache/src/services/change-streamer/sqlite-change-log-reader.js.map +1 -1
- package/out/zero-cache/src/services/change-streamer/sqlite-change-log-writer.js +1 -3
- package/out/zero-cache/src/services/change-streamer/sqlite-change-log-writer.js.map +1 -1
- package/out/zero-cache/src/services/change-streamer/storer.js +1 -1
- package/out/zero-cache/src/services/change-streamer/storer.js.map +1 -1
- package/out/zero-cache/src/services/change-streamer/subscribe.js +1 -1
- package/out/zero-cache/src/services/change-streamer/subscribe.js.map +1 -1
- package/out/zero-cache/src/services/change-streamer/subscriber.js +2 -2
- package/out/zero-cache/src/services/change-streamer/subscriber.js.map +1 -1
- package/out/zero-cache/src/services/change-streamer/upstream-acker.js +0 -1
- package/out/zero-cache/src/services/change-streamer/upstream-acker.js.map +1 -1
- package/out/zero-cache/src/services/heapz.js +2 -1
- package/out/zero-cache/src/services/heapz.js.map +1 -1
- package/out/zero-cache/src/services/http-service.d.ts +6 -0
- package/out/zero-cache/src/services/http-service.d.ts.map +1 -1
- package/out/zero-cache/src/services/http-service.js +11 -0
- package/out/zero-cache/src/services/http-service.js.map +1 -1
- package/out/zero-cache/src/services/life-cycle.d.ts +3 -2
- package/out/zero-cache/src/services/life-cycle.d.ts.map +1 -1
- package/out/zero-cache/src/services/life-cycle.js +18 -5
- package/out/zero-cache/src/services/life-cycle.js.map +1 -1
- package/out/zero-cache/src/services/limiter/sliding-window-limiter.js +4 -2
- package/out/zero-cache/src/services/limiter/sliding-window-limiter.js.map +1 -1
- package/out/zero-cache/src/services/litestream/commands.js +1 -1
- package/out/zero-cache/src/services/litestream/commands.js.map +1 -1
- package/out/zero-cache/src/services/litestream/litestream-checkpointer.js +2 -2
- package/out/zero-cache/src/services/litestream/litestream-checkpointer.js.map +1 -1
- package/out/zero-cache/src/services/litestream/litestream-controller.js.map +1 -1
- package/out/zero-cache/src/services/litestream/metrics.js.map +1 -1
- package/out/zero-cache/src/services/litestream/restore-progress.js.map +1 -1
- package/out/zero-cache/src/services/mutagen/mutagen.js +1 -1
- package/out/zero-cache/src/services/mutagen/mutagen.js.map +1 -1
- package/out/zero-cache/src/services/mutagen/pusher.js +3 -2
- package/out/zero-cache/src/services/mutagen/pusher.js.map +1 -1
- package/out/zero-cache/src/services/plannerz.js +4 -3
- package/out/zero-cache/src/services/plannerz.js.map +1 -1
- package/out/zero-cache/src/services/profz.js +5 -3
- package/out/zero-cache/src/services/profz.js.map +1 -1
- package/out/zero-cache/src/services/replicator/change-log-cookies.js.map +1 -1
- package/out/zero-cache/src/services/replicator/change-log-db.js +3 -2
- package/out/zero-cache/src/services/replicator/change-log-db.js.map +1 -1
- package/out/zero-cache/src/services/replicator/change-log-stream-writer.js +1 -1
- package/out/zero-cache/src/services/replicator/change-log-stream-writer.js.map +1 -1
- package/out/zero-cache/src/services/replicator/change-processor.js.map +1 -1
- package/out/zero-cache/src/services/replicator/drain-handler.d.ts +8 -0
- package/out/zero-cache/src/services/replicator/drain-handler.d.ts.map +1 -1
- package/out/zero-cache/src/services/replicator/drain-handler.js +38 -4
- package/out/zero-cache/src/services/replicator/drain-handler.js.map +1 -1
- package/out/zero-cache/src/services/replicator/incremental-sync.js +1 -1
- package/out/zero-cache/src/services/replicator/incremental-sync.js.map +1 -1
- package/out/zero-cache/src/services/replicator/notifier.js.map +1 -1
- package/out/zero-cache/src/services/replicator/replication-status.js +4 -3
- package/out/zero-cache/src/services/replicator/replication-status.js.map +1 -1
- package/out/zero-cache/src/services/replicator/replicator.js.map +1 -1
- package/out/zero-cache/src/services/replicator/reporter/recorder.js +1 -1
- package/out/zero-cache/src/services/replicator/reporter/recorder.js.map +1 -1
- package/out/zero-cache/src/services/replicator/reporter/report-schema.js +1 -1
- package/out/zero-cache/src/services/replicator/reporter/report-schema.js.map +1 -1
- package/out/zero-cache/src/services/replicator/schema/backfilling.js +1 -1
- package/out/zero-cache/src/services/replicator/schema/backfilling.js.map +1 -1
- package/out/zero-cache/src/services/replicator/schema/change-log.js.map +1 -1
- package/out/zero-cache/src/services/replicator/schema/column-metadata.js +1 -1
- package/out/zero-cache/src/services/replicator/schema/column-metadata.js.map +1 -1
- package/out/zero-cache/src/services/replicator/schema/replication-state.js +10 -7
- package/out/zero-cache/src/services/replicator/schema/replication-state.js.map +1 -1
- package/out/zero-cache/src/services/replicator/schema/table-metadata.js.map +1 -1
- package/out/zero-cache/src/services/replicator/sqlite-change-log-observability.js +1 -1
- package/out/zero-cache/src/services/replicator/sqlite-change-log-observability.js.map +1 -1
- package/out/zero-cache/src/services/replicator/sqlite-change-log-purger.js +1 -1
- package/out/zero-cache/src/services/replicator/sqlite-change-log-purger.js.map +1 -1
- package/out/zero-cache/src/services/replicator/write-worker-client.js +1 -1
- package/out/zero-cache/src/services/replicator/write-worker-client.js.map +1 -1
- package/out/zero-cache/src/services/replicator/write-worker.js.map +1 -1
- package/out/zero-cache/src/services/run-ast.js.map +1 -1
- package/out/zero-cache/src/services/runner.js.map +1 -1
- package/out/zero-cache/src/services/running-state.js.map +1 -1
- package/out/zero-cache/src/services/shadow-sync/shadow-sync-service.js.map +1 -1
- package/out/zero-cache/src/services/statz.js +2 -1
- package/out/zero-cache/src/services/statz.js.map +1 -1
- package/out/zero-cache/src/services/view-syncer/active-users-gauge.js +2 -2
- package/out/zero-cache/src/services/view-syncer/active-users-gauge.js.map +1 -1
- package/out/zero-cache/src/services/view-syncer/advancement-metrics.js.map +1 -1
- package/out/zero-cache/src/services/view-syncer/batch-pacer.js.map +1 -1
- package/out/zero-cache/src/services/view-syncer/client-handler.js +4 -2
- package/out/zero-cache/src/services/view-syncer/client-handler.js.map +1 -1
- package/out/zero-cache/src/services/view-syncer/client-schema.js.map +1 -1
- package/out/zero-cache/src/services/view-syncer/connection-context-manager.js.map +1 -1
- package/out/zero-cache/src/services/view-syncer/cvr-purger.js +2 -2
- package/out/zero-cache/src/services/view-syncer/cvr-purger.js.map +1 -1
- package/out/zero-cache/src/services/view-syncer/cvr-store.js +9 -5
- package/out/zero-cache/src/services/view-syncer/cvr-store.js.map +1 -1
- package/out/zero-cache/src/services/view-syncer/cvr.js +3 -2
- package/out/zero-cache/src/services/view-syncer/cvr.js.map +1 -1
- package/out/zero-cache/src/services/view-syncer/deferred-writes-budget.js +1 -1
- package/out/zero-cache/src/services/view-syncer/deferred-writes-budget.js.map +1 -1
- package/out/zero-cache/src/services/view-syncer/drain-coordinator.js.map +1 -1
- package/out/zero-cache/src/services/view-syncer/e2e-serving-lag.js.map +1 -1
- package/out/zero-cache/src/services/view-syncer/hydration-budget.js.map +1 -1
- package/out/zero-cache/src/services/view-syncer/hydration-circuit-breaker.js +2 -2
- package/out/zero-cache/src/services/view-syncer/hydration-circuit-breaker.js.map +1 -1
- package/out/zero-cache/src/services/view-syncer/inspect-handler.js +5 -3
- package/out/zero-cache/src/services/view-syncer/inspect-handler.js.map +1 -1
- package/out/zero-cache/src/services/view-syncer/pipeline-driver.js +6 -4
- package/out/zero-cache/src/services/view-syncer/pipeline-driver.js.map +1 -1
- package/out/zero-cache/src/services/view-syncer/plan-warnings.js.map +1 -1
- package/out/zero-cache/src/services/view-syncer/push-cost.js.map +1 -1
- package/out/zero-cache/src/services/view-syncer/push-cutoff.js.map +1 -1
- package/out/zero-cache/src/services/view-syncer/query-covering.js.map +1 -1
- package/out/zero-cache/src/services/view-syncer/query-shape.js.map +1 -1
- package/out/zero-cache/src/services/view-syncer/query-stats.js +1 -1
- package/out/zero-cache/src/services/view-syncer/query-stats.js.map +1 -1
- package/out/zero-cache/src/services/view-syncer/row-record-cache.js.map +1 -1
- package/out/zero-cache/src/services/view-syncer/schema/cvr.js +1 -1
- package/out/zero-cache/src/services/view-syncer/schema/types.js +1 -1
- package/out/zero-cache/src/services/view-syncer/schema/types.js.map +1 -1
- package/out/zero-cache/src/services/view-syncer/snapshot-row-cache.js +7 -1
- package/out/zero-cache/src/services/view-syncer/snapshot-row-cache.js.map +1 -1
- package/out/zero-cache/src/services/view-syncer/snapshotter.js +3 -2
- package/out/zero-cache/src/services/view-syncer/snapshotter.js.map +1 -1
- package/out/zero-cache/src/services/view-syncer/ttl-clock.js.map +1 -1
- package/out/zero-cache/src/services/view-syncer/view-syncer.js +4 -6
- package/out/zero-cache/src/services/view-syncer/view-syncer.js.map +1 -1
- package/out/zero-cache/src/types/error-with-level.js.map +1 -1
- package/out/zero-cache/src/types/lexi-version.js.map +1 -1
- package/out/zero-cache/src/types/lite.js +1 -1
- package/out/zero-cache/src/types/lite.js.map +1 -1
- package/out/zero-cache/src/types/pg-data-type.js +1 -1
- package/out/zero-cache/src/types/pg-data-type.js.map +1 -1
- package/out/zero-cache/src/types/pg.js +6 -4
- package/out/zero-cache/src/types/pg.js.map +1 -1
- package/out/zero-cache/src/types/poke-chunk.js +2 -2
- package/out/zero-cache/src/types/poke-chunk.js.map +1 -1
- package/out/zero-cache/src/types/processes.js +1 -1
- package/out/zero-cache/src/types/processes.js.map +1 -1
- package/out/zero-cache/src/types/profiler.js.map +1 -1
- package/out/zero-cache/src/types/row-key.js.map +1 -1
- package/out/zero-cache/src/types/sql.js.map +1 -1
- package/out/zero-cache/src/types/streams.js +10 -9
- package/out/zero-cache/src/types/streams.js.map +1 -1
- package/out/zero-cache/src/types/strings.js.map +1 -1
- package/out/zero-cache/src/types/subscription.js.map +1 -1
- package/out/zero-cache/src/types/timeout.js +1 -1
- package/out/zero-cache/src/types/websocket-handoff.js +1 -1
- package/out/zero-cache/src/types/ws.js +1 -1
- package/out/zero-cache/src/workers/connection.js +2 -3
- package/out/zero-cache/src/workers/connection.js.map +1 -1
- package/out/zero-cache/src/workers/mutator.js.map +1 -1
- package/out/zero-cache/src/workers/replicator.js +5 -4
- package/out/zero-cache/src/workers/replicator.js.map +1 -1
- package/out/zero-cache/src/workers/syncer-ws-message-handler.js.map +1 -1
- package/out/zero-cache/src/workers/syncer.js +2 -2
- package/out/zero-cache/src/workers/syncer.js.map +1 -1
- package/out/zero-client/src/client/active-clients-manager.js +1 -1
- package/out/zero-client/src/client/active-clients-manager.js.map +1 -1
- package/out/zero-client/src/client/bindings.js +11 -11
- package/out/zero-client/src/client/connection-manager.js.map +1 -1
- package/out/zero-client/src/client/connection.js +1 -1
- package/out/zero-client/src/client/connection.js.map +1 -1
- package/out/zero-client/src/client/context.js.map +1 -1
- package/out/zero-client/src/client/crud-impl.js +1 -1
- package/out/zero-client/src/client/crud-impl.js.map +1 -1
- package/out/zero-client/src/client/crud.js +1 -1
- package/out/zero-client/src/client/crud.js.map +1 -1
- package/out/zero-client/src/client/custom.js +1 -1
- package/out/zero-client/src/client/custom.js.map +1 -1
- package/out/zero-client/src/client/delete-clients-manager.js.map +1 -1
- package/out/zero-client/src/client/enable-analytics.js +1 -1
- package/out/zero-client/src/client/enable-analytics.js.map +1 -1
- package/out/zero-client/src/client/error.js.map +1 -1
- package/out/zero-client/src/client/inspector/client-group.js.map +1 -1
- package/out/zero-client/src/client/inspector/client.js.map +1 -1
- package/out/zero-client/src/client/inspector/html-dialog-prompt.js +1 -1
- package/out/zero-client/src/client/inspector/html-dialog-prompt.js.map +1 -1
- package/out/zero-client/src/client/inspector/inspector.js.map +1 -1
- package/out/zero-client/src/client/inspector/lazy-inspector.js.map +1 -1
- package/out/zero-client/src/client/inspector/query.js.map +1 -1
- package/out/zero-client/src/client/ivm-branch.js +1 -3
- package/out/zero-client/src/client/ivm-branch.js.map +1 -1
- package/out/zero-client/src/client/keys.js +2 -1
- package/out/zero-client/src/client/keys.js.map +1 -1
- package/out/zero-client/src/client/log-options.js.map +1 -1
- package/out/zero-client/src/client/make-replicache-mutators.js +5 -3
- package/out/zero-client/src/client/make-replicache-mutators.js.map +1 -1
- package/out/zero-client/src/client/metrics.js +3 -5
- package/out/zero-client/src/client/metrics.js.map +1 -1
- package/out/zero-client/src/client/mutation-tracker.js.map +1 -1
- package/out/zero-client/src/client/mutator-proxy.js.map +1 -1
- package/out/zero-client/src/client/options.js.map +1 -1
- package/out/zero-client/src/client/query-manager.js +2 -2
- package/out/zero-client/src/client/query-manager.js.map +1 -1
- package/out/zero-client/src/client/reload-error-handler.js +4 -3
- package/out/zero-client/src/client/reload-error-handler.js.map +1 -1
- package/out/zero-client/src/client/version.js +1 -1
- package/out/zero-client/src/client/zero-poke-handler.js +1 -1
- package/out/zero-client/src/client/zero-poke-handler.js.map +1 -1
- package/out/zero-client/src/client/zero-rep.js.map +1 -1
- package/out/zero-client/src/client/zero.js +19 -11
- package/out/zero-client/src/client/zero.js.map +1 -1
- package/out/zero-client/src/mod.js +23 -23
- package/out/zero-client/src/util/nanoid.js.map +1 -1
- package/out/zero-permissions/src/permissions.js +3 -2
- package/out/zero-permissions/src/permissions.js.map +1 -1
- package/out/zero-pg/src/mod.js +9 -9
- package/out/zero-protocol/src/analyze-query-result.js +1 -1
- package/out/zero-protocol/src/analyze-query-result.js.map +1 -1
- package/out/zero-protocol/src/application-error.js.map +1 -1
- package/out/zero-protocol/src/ast.js +19 -9
- package/out/zero-protocol/src/ast.js.map +1 -1
- package/out/zero-protocol/src/change-desired-queries.js.map +1 -1
- package/out/zero-protocol/src/client-schema.js +1 -1
- package/out/zero-protocol/src/client-schema.js.map +1 -1
- package/out/zero-protocol/src/close-connection.js +1 -1
- package/out/zero-protocol/src/close-connection.js.map +1 -1
- package/out/zero-protocol/src/connect.js +1 -1
- package/out/zero-protocol/src/connect.js.map +1 -1
- package/out/zero-protocol/src/custom-queries.js +1 -1
- package/out/zero-protocol/src/custom-queries.js.map +1 -1
- package/out/zero-protocol/src/data.js +4 -2
- package/out/zero-protocol/src/data.js.map +1 -1
- package/out/zero-protocol/src/delete-clients.js.map +1 -1
- package/out/zero-protocol/src/down.js.map +1 -1
- package/out/zero-protocol/src/error.js.map +1 -1
- package/out/zero-protocol/src/inspect-down.d.ts +81 -81
- package/out/zero-protocol/src/inspect-down.js +1 -1
- package/out/zero-protocol/src/inspect-down.js.map +1 -1
- package/out/zero-protocol/src/inspect-up.js +1 -1
- package/out/zero-protocol/src/inspect-up.js.map +1 -1
- package/out/zero-protocol/src/mutate-server.js +1 -1
- package/out/zero-protocol/src/mutate-server.js.map +1 -1
- package/out/zero-protocol/src/mutation-id.js.map +1 -1
- package/out/zero-protocol/src/mutation.js +1 -1
- package/out/zero-protocol/src/mutation.js.map +1 -1
- package/out/zero-protocol/src/mutations-patch.js +1 -1
- package/out/zero-protocol/src/mutations-patch.js.map +1 -1
- package/out/zero-protocol/src/ping.js +1 -1
- package/out/zero-protocol/src/ping.js.map +1 -1
- package/out/zero-protocol/src/poke.js +1 -1
- package/out/zero-protocol/src/poke.js.map +1 -1
- package/out/zero-protocol/src/pong.js +1 -1
- package/out/zero-protocol/src/pong.js.map +1 -1
- package/out/zero-protocol/src/primary-key.js.map +1 -1
- package/out/zero-protocol/src/protocol-version.js +1 -0
- package/out/zero-protocol/src/protocol-version.js.map +1 -1
- package/out/zero-protocol/src/pull.js +1 -1
- package/out/zero-protocol/src/pull.js.map +1 -1
- package/out/zero-protocol/src/push.js +1 -1
- package/out/zero-protocol/src/push.js.map +1 -1
- package/out/zero-protocol/src/queries-patch.js +1 -1
- package/out/zero-protocol/src/queries-patch.js.map +1 -1
- package/out/zero-protocol/src/query-hash-visitor.js.map +1 -1
- package/out/zero-protocol/src/query-hash.js +2 -2
- package/out/zero-protocol/src/query-server.js +1 -1
- package/out/zero-protocol/src/query-server.js.map +1 -1
- package/out/zero-protocol/src/row-patch.js.map +1 -1
- package/out/zero-protocol/src/up.js.map +1 -1
- package/out/zero-protocol/src/update-auth.js.map +1 -1
- package/out/zero-protocol/src/version.js.map +1 -1
- package/out/zero-react/src/bindings.js +11 -11
- package/out/zero-react/src/mod.js +5 -5
- package/out/zero-react/src/use-query.js +1 -1
- package/out/zero-react/src/use-query.js.map +1 -1
- package/out/zero-react/src/zero-provider.d.ts +1 -1
- package/out/zero-react/src/zero-provider.d.ts.map +1 -1
- package/out/zero-react/src/zero-provider.js +2 -1
- package/out/zero-react/src/zero-provider.js.map +1 -1
- package/out/zero-react/src/zero.js +23 -23
- package/out/zero-schema/src/builder/schema-builder.js +3 -2
- package/out/zero-schema/src/builder/schema-builder.js.map +1 -1
- package/out/zero-schema/src/builder/table-builder.js +1 -1
- package/out/zero-schema/src/builder/table-builder.js.map +1 -1
- package/out/zero-schema/src/compiled-permissions.js.map +1 -1
- package/out/zero-schema/src/name-mapper.js +7 -5
- package/out/zero-schema/src/name-mapper.js.map +1 -1
- package/out/zero-schema/src/schema-config.js +1 -1
- package/out/zero-schema/src/schema-config.js.map +1 -1
- package/out/zero-server/src/adapters/drizzle.js.map +1 -1
- package/out/zero-server/src/adapters/kysely.js.map +1 -1
- package/out/zero-server/src/adapters/pg.js.map +1 -1
- package/out/zero-server/src/adapters/postgresjs.js.map +1 -1
- package/out/zero-server/src/adapters/prisma.js.map +1 -1
- package/out/zero-server/src/closable-db-transaction.js.map +1 -1
- package/out/zero-server/src/custom.js +1 -1
- package/out/zero-server/src/custom.js.map +1 -1
- package/out/zero-server/src/mod.js +8 -8
- package/out/zero-server/src/process-mutations.js.map +1 -1
- package/out/zero-server/src/push-processor.js.map +1 -1
- package/out/zero-server/src/queries/process-queries.js +2 -1
- package/out/zero-server/src/queries/process-queries.js.map +1 -1
- package/out/zero-server/src/schema.js +7 -7
- package/out/zero-server/src/schema.js.map +1 -1
- package/out/zero-server/src/zql-database.js.map +1 -1
- package/out/zero-solid/src/bindings.js +11 -11
- package/out/zero-solid/src/mod.js +5 -5
- package/out/zero-solid/src/solid-view.js +1 -1
- package/out/zero-solid/src/solid-view.js.map +1 -1
- package/out/zero-solid/src/use-connection-state.js +2 -1
- package/out/zero-solid/src/use-connection-state.js.map +1 -1
- package/out/zero-solid/src/use-query.js.map +1 -1
- package/out/zero-solid/src/use-zero-online.js +2 -1
- package/out/zero-solid/src/use-zero-online.js.map +1 -1
- package/out/zero-solid/src/use-zero.js +3 -2
- package/out/zero-solid/src/use-zero.js.map +1 -1
- package/out/zero-solid/src/zero.js +23 -23
- package/out/zero-types/src/name-mapper.js.map +1 -1
- package/out/zql/src/builder/builder.js +15 -13
- package/out/zql/src/builder/builder.js.map +1 -1
- package/out/zql/src/builder/correlated-predicate-pushdown.js.map +1 -1
- package/out/zql/src/builder/debug-delegate.js.map +1 -1
- package/out/zql/src/builder/like.js +0 -1
- package/out/zql/src/builder/like.js.map +1 -1
- package/out/zql/src/ivm/array-view.js.map +1 -1
- package/out/zql/src/ivm/cap.js.map +1 -1
- package/out/zql/src/ivm/constraint.js +1 -1
- package/out/zql/src/ivm/constraint.js.map +1 -1
- package/out/zql/src/ivm/data.js.map +1 -1
- package/out/zql/src/ivm/deferred-input.js.map +1 -1
- package/out/zql/src/ivm/exists.js +20 -18
- package/out/zql/src/ivm/exists.js.map +1 -1
- package/out/zql/src/ivm/fan-in.js.map +1 -1
- package/out/zql/src/ivm/fan-out.js.map +1 -1
- package/out/zql/src/ivm/filter-operators.js +1 -1
- package/out/zql/src/ivm/filter-operators.js.map +1 -1
- package/out/zql/src/ivm/filter-push.js.map +1 -1
- package/out/zql/src/ivm/filter.js.map +1 -1
- package/out/zql/src/ivm/flipped-join.js +1 -3
- package/out/zql/src/ivm/flipped-join.js.map +1 -1
- package/out/zql/src/ivm/join-index-tap.js.map +1 -1
- package/out/zql/src/ivm/join-index.js.map +1 -1
- package/out/zql/src/ivm/join-utils.js +11 -13
- package/out/zql/src/ivm/join-utils.js.map +1 -1
- package/out/zql/src/ivm/join.js.map +1 -1
- package/out/zql/src/ivm/maybe-split-and-push-edit-change.js.map +1 -1
- package/out/zql/src/ivm/memory-source.js +26 -22
- package/out/zql/src/ivm/memory-source.js.map +1 -1
- package/out/zql/src/ivm/memory-storage.js.map +1 -1
- package/out/zql/src/ivm/operator.js +2 -2
- package/out/zql/src/ivm/push-accumulated.js +0 -1
- package/out/zql/src/ivm/push-accumulated.js.map +1 -1
- package/out/zql/src/ivm/skip-yields.js.map +1 -1
- package/out/zql/src/ivm/skip.js.map +1 -1
- package/out/zql/src/ivm/take-gate.js.map +1 -1
- package/out/zql/src/ivm/take.js +1 -1
- package/out/zql/src/ivm/take.js.map +1 -1
- package/out/zql/src/ivm/union-fan-in.js +1 -1
- package/out/zql/src/ivm/union-fan-in.js.map +1 -1
- package/out/zql/src/ivm/union-fan-out.js.map +1 -1
- package/out/zql/src/ivm/view-apply-change.js +1 -1
- package/out/zql/src/ivm/view-apply-change.js.map +1 -1
- package/out/zql/src/mutate/crud.js +3 -2
- package/out/zql/src/mutate/crud.js.map +1 -1
- package/out/zql/src/planner/planner-builder.js +1 -1
- package/out/zql/src/planner/planner-builder.js.map +1 -1
- package/out/zql/src/planner/planner-connection.js.map +1 -1
- package/out/zql/src/planner/planner-debug.js.map +1 -1
- package/out/zql/src/planner/planner-fan-in.js.map +1 -1
- package/out/zql/src/planner/planner-fan-out.js.map +1 -1
- package/out/zql/src/planner/planner-filter.js.map +1 -1
- package/out/zql/src/planner/planner-graph.js +1 -5
- package/out/zql/src/planner/planner-graph.js.map +1 -1
- package/out/zql/src/planner/planner-join.js +1 -1
- package/out/zql/src/planner/planner-join.js.map +1 -1
- package/out/zql/src/planner/planner-source.js.map +1 -1
- package/out/zql/src/planner/planner-terminus.js.map +1 -1
- package/out/zql/src/query/create-builder.js +2 -1
- package/out/zql/src/query/create-builder.js.map +1 -1
- package/out/zql/src/query/expression.js +1 -1
- package/out/zql/src/query/expression.js.map +1 -1
- package/out/zql/src/query/measure-push-operator.js.map +1 -1
- package/out/zql/src/query/named.js +2 -1
- package/out/zql/src/query/named.js.map +1 -1
- package/out/zql/src/query/query-delegate-base.js +1 -1
- package/out/zql/src/query/query-delegate-base.js.map +1 -1
- package/out/zql/src/query/query-impl.js +3 -2
- package/out/zql/src/query/query-impl.js.map +1 -1
- package/out/zql/src/query/query-registry.js +1 -1
- package/out/zql/src/query/query-registry.js.map +1 -1
- package/out/zql/src/query/query-transitions.js +1 -1
- package/out/zql/src/query/query-transitions.js.map +1 -1
- package/out/zql/src/query/runnable-query-impl.js +3 -2
- package/out/zql/src/query/runnable-query-impl.js.map +1 -1
- package/out/zql/src/query/static-query.js.map +1 -1
- package/out/zql/src/query/ttl.js +8 -8
- package/out/zql/src/query/ttl.js.map +1 -1
- package/out/zql/src/query/validate-input.js.map +1 -1
- package/out/zqlite/src/database-storage.js +2 -2
- package/out/zqlite/src/database-storage.js.map +1 -1
- package/out/zqlite/src/db.js +2 -2
- package/out/zqlite/src/db.js.map +1 -1
- package/out/zqlite/src/explain-queries.js.map +1 -1
- package/out/zqlite/src/index-usage.js.map +1 -1
- package/out/zqlite/src/internal/sql-inline.js.map +1 -1
- package/out/zqlite/src/internal/sql.js.map +1 -1
- package/out/zqlite/src/internal/statement-cache.js +1 -1
- package/out/zqlite/src/internal/statement-cache.js.map +1 -1
- package/out/zqlite/src/mod.js +5 -5
- package/out/zqlite/src/pending-delta.js.map +1 -1
- package/out/zqlite/src/query-builder.js +1 -1
- package/out/zqlite/src/query-builder.js.map +1 -1
- package/out/zqlite/src/query-delegate.js.map +1 -1
- package/out/zqlite/src/resolve-scalar-subqueries.js +2 -5
- package/out/zqlite/src/resolve-scalar-subqueries.js.map +1 -1
- package/out/zqlite/src/sqlite-cost-model.js +3 -2
- package/out/zqlite/src/sqlite-cost-model.js.map +1 -1
- package/out/zqlite/src/sqlite-stat-fanout.js.map +1 -1
- package/out/zqlite/src/table-source.js +1 -1
- package/out/zqlite/src/table-source.js.map +1 -1
- package/package.json +16 -14
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sqlite-change-log-comparator.js","names":["#lc","#shard","#changeLogFile","#identity","#pg","#opts","#now","#setTimeoutFn","#clearTimeoutFn","#yield","#compareResults","#compareCycles","#cycleDuration","#scheduleNext","#stopped","#running","#runCycle","#continueWithoutDelay","#rescheduleNext","#timer","#fileGeneration","#cursor","#skipped","#pinBounds","#reconfirm","#compared","#enumerateCommits","#compareTransaction","#readSQLiteBounds"],"sources":["../../../../../../zero-cache/src/services/change-streamer/sqlite-change-log-comparator.ts"],"sourcesContent":["import type {LogContext} from '@rocicorp/logger';\nimport {Database} from '../../../../zqlite/src/db.ts';\nimport {\n getOrCreateCounter,\n getOrCreateLatencyHistogram,\n} from '../../observability/metrics.ts';\nimport type {ShardID} from '../../types/shards.ts';\nimport {\n CHANGE_LOG_DB_SCHEMA_VERSION,\n CHANGE_LOG_STREAM_TABLE,\n readChangeLogMeta,\n type ChangeLogIdentity,\n type ChangeLogMeta,\n} from '../replicator/change-log-db.ts';\nimport {\n reconstructWatermarkedChange,\n type ChangeLogEntry,\n} from './change-log-codec.ts';\nimport {\n digestCatchupRange,\n type CatchupRangeDigest,\n} from './change-log-compare-digest.ts';\nimport type {WatermarkedChange} from './change-streamer.ts';\nimport {isSampledForShard} from './shard-sampling.ts';\nimport {SQLiteChangeLogReader} from './sqlite-change-log-reader.ts';\n\n/** Interval between comparison cycles. */\nconst DEFAULT_COMPARE_INTERVAL_MS = 30_000;\n\n/** Maximum transactions enumerated per cycle. */\nconst DEFAULT_MAX_TRANSACTIONS_PER_CYCLE = 256;\n\n/** Maximum catchup rows read from each store per cycle. */\nconst DEFAULT_MAX_ROWS_PER_SOURCE_PER_CYCLE = 1000;\n\n/**\n * Maximum catchup bytes read from each store per cycle.\n *\n * Rows bound the per-row overhead. Bytes bound the read and hash work,\n * which scales with payload size instead of row count. Normal traffic\n * reaches the row limit first. Wide payloads reach this one.\n */\nconst DEFAULT_MAX_BYTES_PER_SOURCE_PER_CYCLE = 32 * 1024 * 1024;\n\n/** Rows per SQLite catchup batch. */\nconst DEFAULT_READ_BATCH_ROWS = 1000;\n\n/**\n * Result for one committed transaction.\n *\n * `mismatch` covers missing, extra, changed, or reordered rows.\n * `inconclusive` means that store bounds changed during the comparison.\n */\nexport type CompareOutcome =\n | 'match'\n | 'mismatch'\n | 'inconclusive'\n | 'deferred'\n | 'oversized';\n\nexport type CompareSkipReason =\n // The comparator was stopped.\n | 'stopped'\n // The writer replaced the file during this cycle.\n | 'log-rebuilt'\n // The file is absent, unreadable, or uninitialized.\n | 'log-unavailable'\n // The schema version does not match this build.\n | 'ineligible-schema'\n // The log belongs to a different replica.\n | 'ineligible-identity'\n // The log is younger than the warm-up period.\n | 'cold-log'\n // The stores have no complete committed range in common.\n | 'nothing-to-compare'\n // The cycle failed. The error was logged and counted.\n | 'error';\n\nexport type CompareCycleResult =\n | {readonly kind: 'skipped'; readonly reason: CompareSkipReason}\n | {\n readonly kind: 'compared';\n /** Exclusive lower bound of the compared range. */\n readonly fromWatermark: string;\n /** Inclusive upper bound handled. The next cycle resumes from here. */\n readonly throughWatermark: string;\n /** Committed transactions handled through `throughWatermark`. */\n readonly transactions: number;\n /** Transactions with compared catchup output. */\n readonly sampled: number;\n readonly matched: number;\n readonly mismatched: number;\n /** Suspects rejected because store bounds changed. */\n readonly inconclusive: number;\n /** Sampled transactions deferred to a fresh budget. */\n readonly deferred: number;\n /** Sampled transactions that exceed a fresh budget. */\n readonly oversized: number;\n };\n\n/** Provides the Postgres side of the catchup comparison. */\nexport interface PGChangeLogRangeReader {\n getCatchupBounds(): Promise<{\n minWatermark: string | null;\n lastWatermark: string;\n }>;\n listCommitWatermarks(\n afterWatermark: string,\n throughWatermark: string,\n limit: number,\n ): Promise<string[]>;\n readCatchupRange(\n afterWatermark: string,\n throughWatermark: string,\n batchRows?: number | undefined,\n ): AsyncIterable<ChangeLogEntry[]>;\n}\n\nexport type SQLiteChangeLogCompareOptions = {\n /** Stable percentage of transactions selected for payload comparison. */\n readonly comparePercent: number;\n /** Warm-up time after a seed. The comparator skips a younger log. */\n readonly retentionMs: number;\n readonly intervalMs?: number | undefined;\n readonly maxTransactionsPerCycle?: number | undefined;\n /** Maximum catchup rows read from each store in one cycle. */\n readonly maxRowsPerSourcePerCycle?: number | undefined;\n /** Maximum catchup bytes read from each store in one cycle. */\n readonly maxBytesPerSourcePerCycle?: number | undefined;\n readonly readBatchRows?: number | undefined;\n /** Clock for the warm-up period. Defaults to `Date.now`. */\n readonly now?: (() => number) | undefined;\n readonly setTimeoutFn?: typeof setTimeout | undefined;\n readonly clearTimeoutFn?: typeof clearTimeout | undefined;\n /** Yields between sampled transactions. Defaults to `setImmediate`. */\n readonly yieldFn?: (() => Promise<void>) | undefined;\n};\n\n/** Uses separate aggregates so SQLite can use an index for each value. */\nconst SQLITE_BOUNDS_SQL = /*sql*/ `\n SELECT\n (SELECT min(\"watermark\") FROM \"${CHANGE_LOG_STREAM_TABLE}\")\n AS \"minWatermark\",\n (SELECT max(\"watermark\") FROM \"${CHANGE_LOG_STREAM_TABLE}\")\n AS \"headWatermark\"\n`;\n\n/** Lists committed transaction watermarks in `(@after, @through]`. */\nconst SQLITE_COMMITS_SQL = /*sql*/ `\n SELECT \"watermark\" FROM \"${CHANGE_LOG_STREAM_TABLE}\"\n WHERE \"precommit\" IS NOT NULL\n AND \"watermark\" > @after\n AND \"watermark\" <= @through\n ORDER BY \"watermark\"\n LIMIT @limit\n`;\n\ntype SQLiteBounds = {\n readonly minWatermark: string;\n readonly headWatermark: string;\n};\n\ntype PinnedBounds = {\n readonly meta: ChangeLogMeta;\n readonly sqlite: SQLiteBounds;\n readonly pgMinWatermark: string;\n readonly pgLastWatermark: string;\n};\n\ntype SampledComparison = {\n readonly outcome: CompareOutcome;\n readonly sqliteRowsRead: number;\n readonly pgRowsRead: number;\n readonly sqliteBytesRead: number;\n readonly pgBytesRead: number;\n};\n\n/**\n * Compares SQLite and Postgres catchup output without affecting subscribers.\n *\n * Each cycle compares complete ranges through the lower store head.\n * It checks every transaction for presence and samples payloads.\n * A bounds change makes the affected result `inconclusive`.\n * Postgres remains authoritative.\n */\nexport class SQLiteChangeLogComparator {\n readonly #lc: LogContext;\n readonly #shard: ShardID;\n readonly #changeLogFile: string;\n readonly #identity: ChangeLogIdentity;\n readonly #pg: PGChangeLogRangeReader;\n readonly #opts: SQLiteChangeLogCompareOptions;\n readonly #now: () => number;\n readonly #setTimeoutFn: typeof setTimeout;\n readonly #clearTimeoutFn: typeof clearTimeout;\n readonly #yield: () => Promise<void>;\n\n readonly #compareResults = getOrCreateCounter(\n 'replica',\n 'sqlite_change_log.compare_result',\n 'Outcomes of the dark comparison between what SQLite catchup serves and ' +\n 'what PG catchup serves, per committed transaction. `inconclusive` ' +\n 'means a purge or reseed moved the pinned bounds mid-comparison.',\n );\n readonly #compareCycles = getOrCreateCounter(\n 'replica',\n 'sqlite_change_log.compare_cycles',\n 'SQLite change-log comparison cycles, by result.',\n );\n readonly #cycleDuration = getOrCreateLatencyHistogram(\n 'replica',\n 'sqlite_change_log.compare_cycle_duration',\n 'Duration of one SQLite change-log comparison cycle.',\n );\n\n /** Last handled commit watermark. A restart derives it from current bounds. */\n #cursor: string | undefined;\n #running: Promise<CompareCycleResult> | undefined;\n #timer: ReturnType<typeof setTimeout> | undefined;\n #stopped = false;\n #continueWithoutDelay = false;\n #fileGeneration = 0;\n\n constructor(\n lc: LogContext,\n shard: ShardID,\n changeLogFile: string,\n identity: ChangeLogIdentity,\n pg: PGChangeLogRangeReader,\n opts: SQLiteChangeLogCompareOptions,\n ) {\n this.#lc = lc.withContext('component', 'sqlite-change-log-compare');\n this.#shard = shard;\n this.#changeLogFile = changeLogFile;\n this.#identity = identity;\n this.#pg = pg;\n this.#opts = opts;\n this.#now = opts.now ?? Date.now;\n this.#setTimeoutFn = opts.setTimeoutFn ?? setTimeout;\n this.#clearTimeoutFn = opts.clearTimeoutFn ?? clearTimeout;\n this.#yield =\n opts.yieldFn ??\n (() => new Promise<void>(resolve => setImmediate(resolve)));\n this.#scheduleNext();\n }\n\n /**\n * Runs one cycle. Concurrent calls share the same promise.\n * Errors produce `skipped: 'error'`.\n */\n compareOnce(): Promise<CompareCycleResult> {\n if (this.#stopped) {\n return Promise.resolve({kind: 'skipped', reason: 'stopped'});\n }\n if (this.#running === undefined) {\n this.#running = this.#runCycle()\n .then(result => {\n if (this.#continueWithoutDelay) {\n this.#rescheduleNext(0);\n }\n return result;\n })\n .finally(() => {\n this.#running = undefined;\n });\n }\n return this.#running;\n }\n\n stop(): void {\n this.#stopped = true;\n if (this.#timer !== undefined) {\n this.#clearTimeoutFn(this.#timer);\n this.#timer = undefined;\n }\n }\n\n /** Invalidates cycles that can still read a replaced file. */\n invalidate(): void {\n this.#fileGeneration++;\n this.#cursor = undefined;\n this.#continueWithoutDelay = false;\n }\n\n #scheduleNext(\n delayMs = this.#opts.intervalMs ?? DEFAULT_COMPARE_INTERVAL_MS,\n ): void {\n if (this.#stopped || this.#timer !== undefined) {\n return;\n }\n this.#timer = this.#setTimeoutFn(() => {\n this.#timer = undefined;\n void this.compareOnce().finally(() => this.#scheduleNext());\n }, delayMs);\n }\n\n #rescheduleNext(delayMs: number): void {\n if (this.#timer !== undefined) {\n this.#clearTimeoutFn(this.#timer);\n this.#timer = undefined;\n }\n this.#scheduleNext(delayMs);\n }\n\n async #runCycle(): Promise<CompareCycleResult> {\n const startedAt = performance.now();\n const fileGeneration = this.#fileGeneration;\n this.#continueWithoutDelay = false;\n let db: Database | undefined;\n let reader: SQLiteChangeLogReader | undefined;\n try {\n try {\n // A readonly handle does not create a missing file.\n db = new Database(this.#lc, this.#changeLogFile, {readonly: true});\n } catch {\n return this.#skipped('log-unavailable');\n }\n\n const pinned = await this.#pinBounds(db);\n if (fileGeneration !== this.#fileGeneration) {\n return this.#skipped('log-rebuilt');\n }\n if (typeof pinned === 'string') {\n return this.#skipped(pinned);\n }\n const {meta} = pinned;\n\n const from = maxWatermark(\n this.#cursor ?? meta.seedWatermark,\n // Exclude the synthetic SQLite seed transaction.\n // The Postgres seed has no `precommit`, so enumeration also excludes it.\n meta.seedWatermark,\n // A store cannot serve the transaction at its minimum watermark.\n pinned.pgMinWatermark,\n pinned.sqlite.minWatermark,\n );\n const through = minWatermark(\n pinned.pgLastWatermark,\n pinned.sqlite.headWatermark,\n );\n if (through <= from) {\n return this.#skipped('nothing-to-compare');\n }\n\n let transactions = 0;\n let sampled = 0;\n const outcomes: Record<CompareOutcome, number> = {\n match: 0,\n mismatch: 0,\n inconclusive: 0,\n deferred: 0,\n oversized: 0,\n };\n const record = (watermark: string, outcome: CompareOutcome) => {\n this.#compareResults.add(1, {outcome});\n outcomes[outcome]++;\n if (outcome === 'mismatch') {\n // Log only the watermark because payloads contain customer data.\n this.#lc.error?.(\n 'SQLite change-log catchup output diverged from Postgres',\n {sqliteChangeLogCompare: {watermark}},\n );\n }\n };\n\n reader = new SQLiteChangeLogReader(this.#lc, this.#changeLogFile);\n const plan = reader.plan(from);\n if (plan.kind === 'not-ready') {\n return this.#skipped('log-unavailable');\n }\n if (plan.kind === 'ahead') {\n return this.#skipped('nothing-to-compare');\n }\n if (plan.kind === 'too-old') {\n // A fixed lower bound makes this result a mismatch.\n // A changed lower bound makes it inconclusive.\n const reconfirmed = await this.#reconfirm(db, pinned, from);\n const outcome =\n fileGeneration === this.#fileGeneration\n ? reconfirmed\n : 'inconclusive';\n record(from, outcome);\n if (outcome === 'inconclusive') {\n // Keep the cursor unchanged because the bounds moved.\n return this.#compared(from, from, transactions, sampled, outcomes);\n }\n // Positional reads can continue after a missing boundary.\n // Advancing avoids reporting the same boundary in every cycle.\n }\n\n const limit = Math.max(\n 1,\n this.#opts.maxTransactionsPerCycle ??\n DEFAULT_MAX_TRANSACTIONS_PER_CYCLE,\n );\n const union = await this.#enumerateCommits(db, from, through, limit);\n if (fileGeneration !== this.#fileGeneration) {\n return this.#skipped('log-rebuilt');\n }\n const maxRowsPerSource = Math.max(\n 1,\n this.#opts.maxRowsPerSourcePerCycle ??\n DEFAULT_MAX_ROWS_PER_SOURCE_PER_CYCLE,\n );\n const maxBytesPerSource = Math.max(\n 1,\n this.#opts.maxBytesPerSourcePerCycle ??\n DEFAULT_MAX_BYTES_PER_SOURCE_PER_CYCLE,\n );\n\n let sqliteRowsRead = 0;\n let pgRowsRead = 0;\n let sqliteBytesRead = 0;\n let pgBytesRead = 0;\n let previousBoundary = from;\n let handledThrough = from;\n for (const {watermark, inSqlite, inPg} of union) {\n if (this.#stopped) {\n break;\n }\n let stopAfterTransaction = false;\n if (!inSqlite || !inPg) {\n // Check every transaction for presence because this needs no payload read.\n const outcome = await this.#reconfirm(db, pinned, watermark);\n record(\n watermark,\n fileGeneration === this.#fileGeneration ? outcome : 'inconclusive',\n );\n } else if (\n isSampledForShard(this.#shard, watermark, this.#opts.comparePercent)\n ) {\n const sqliteRowsRemaining = maxRowsPerSource - sqliteRowsRead;\n const pgRowsRemaining = maxRowsPerSource - pgRowsRead;\n // A read may pass the byte budget by its last row, so this\n // compares against zero rather than checking for equality.\n const sqliteBytesRemaining = maxBytesPerSource - sqliteBytesRead;\n const pgBytesRemaining = maxBytesPerSource - pgBytesRead;\n if (\n sqliteRowsRemaining === 0 ||\n pgRowsRemaining === 0 ||\n sqliteBytesRemaining <= 0 ||\n pgBytesRemaining <= 0\n ) {\n record(watermark, 'deferred');\n break;\n }\n sampled++;\n const comparison = await this.#compareTransaction(\n db,\n reader,\n pinned,\n previousBoundary,\n watermark,\n sqliteRowsRemaining,\n pgRowsRemaining,\n sqliteBytesRemaining,\n pgBytesRemaining,\n maxRowsPerSource,\n maxBytesPerSource,\n );\n sqliteRowsRead += comparison.sqliteRowsRead;\n pgRowsRead += comparison.pgRowsRead;\n sqliteBytesRead += comparison.sqliteBytesRead;\n pgBytesRead += comparison.pgBytesRead;\n const outcome =\n fileGeneration === this.#fileGeneration\n ? comparison.outcome\n : 'inconclusive';\n record(watermark, outcome);\n if (outcome === 'inconclusive') {\n break;\n }\n if (outcome === 'deferred') {\n break;\n }\n if (outcome === 'oversized') {\n // Advance past a transaction that cannot fit in a fresh budget.\n stopAfterTransaction = true;\n }\n // Yield between sampled reads so other stream work can continue.\n await this.#yield();\n }\n previousBoundary = watermark;\n handledThrough = watermark;\n transactions++;\n if (stopAfterTransaction) {\n break;\n }\n }\n\n if (\n !this.#stopped &&\n fileGeneration === this.#fileGeneration &&\n outcomes.inconclusive === 0\n ) {\n this.#cursor = handledThrough;\n this.#continueWithoutDelay = handledThrough < through;\n }\n return this.#compared(\n from,\n handledThrough,\n transactions,\n sampled,\n outcomes,\n );\n } catch (e) {\n this.#lc.warn?.('error comparing the SQLite change log', e);\n return this.#skipped('error');\n } finally {\n reader?.close();\n db?.close();\n this.#cycleDuration.recordMs(performance.now() - startedAt);\n }\n }\n\n /** Checks log eligibility and reads the bounds from both stores. */\n async #pinBounds(db: Database): Promise<PinnedBounds | CompareSkipReason> {\n let meta: ChangeLogMeta;\n try {\n meta = readChangeLogMeta(db);\n } catch {\n // The writer has not initialized the log.\n return 'log-unavailable';\n }\n if (meta.schemaVersion !== CHANGE_LOG_DB_SCHEMA_VERSION) {\n return 'ineligible-schema';\n }\n const {epoch, generation, replicaID} = this.#identity;\n if (\n meta.epoch !== epoch ||\n meta.generation !== generation ||\n meta.replicaID !== replicaID\n ) {\n return 'ineligible-identity';\n }\n if (this.#now() - meta.seededAtMs < this.#opts.retentionMs) {\n return 'cold-log';\n }\n const sqlite = this.#readSQLiteBounds(db);\n if (sqlite === undefined) {\n return 'log-unavailable';\n }\n const {minWatermark, lastWatermark} = await this.#pg.getCatchupBounds();\n if (minWatermark === null) {\n // Only a new replica has an empty Postgres change log.\n return 'nothing-to-compare';\n }\n return {\n meta,\n sqlite,\n pgMinWatermark: minWatermark,\n pgLastWatermark: lastWatermark,\n };\n }\n\n #readSQLiteBounds(db: Database): SQLiteBounds | undefined {\n const bounds = db\n .prepare(SQLITE_BOUNDS_SQL)\n .get<{minWatermark: string | null; headWatermark: string | null}>();\n return bounds.minWatermark === null || bounds.headWatermark === null\n ? undefined\n : {\n minWatermark: bounds.minWatermark,\n headWatermark: bounds.headWatermark,\n };\n }\n\n /**\n * Merges committed transaction watermarks from both stores.\n * If a list reaches the limit, the result ends at the last complete common range.\n */\n async #enumerateCommits(\n db: Database,\n from: string,\n through: string,\n limit: number,\n ): Promise<{watermark: string; inSqlite: boolean; inPg: boolean}[]> {\n let sqlite: string[] = db\n .prepare(SQLITE_COMMITS_SQL)\n .all<{watermark: string}>({after: from, through, limit: limit + 1})\n .map(({watermark}) => watermark);\n let pg = await this.#pg.listCommitWatermarks(from, through, limit + 1);\n\n let effectiveThrough = through;\n if (sqlite.length > limit) {\n sqlite.length = limit;\n effectiveThrough = minWatermark(effectiveThrough, sqlite[limit - 1]);\n }\n if (pg.length > limit) {\n pg.length = limit;\n effectiveThrough = minWatermark(effectiveThrough, pg[limit - 1]);\n }\n if (effectiveThrough !== through) {\n sqlite = sqlite.filter(w => w <= effectiveThrough);\n pg = pg.filter(w => w <= effectiveThrough);\n }\n\n const union: {watermark: string; inSqlite: boolean; inPg: boolean}[] = [];\n let s = 0;\n let p = 0;\n while (s < sqlite.length || p < pg.length) {\n const sw = s < sqlite.length ? sqlite[s] : undefined;\n const pw = p < pg.length ? pg[p] : undefined;\n if (sw !== undefined && (pw === undefined || sw < pw)) {\n union.push({watermark: sw, inSqlite: true, inPg: false});\n s++;\n } else if (pw !== undefined && (sw === undefined || pw < sw)) {\n union.push({watermark: pw, inSqlite: false, inPg: true});\n p++;\n } else if (sw !== undefined) {\n union.push({watermark: sw, inSqlite: true, inPg: true});\n s++;\n p++;\n }\n }\n return union;\n }\n\n /**\n * Compares the output in `(previousBoundary, watermark]`.\n * Positional reads do not require either store to contain the boundary row.\n */\n async #compareTransaction(\n db: Database,\n reader: SQLiteChangeLogReader,\n pinned: PinnedBounds,\n previousBoundary: string,\n watermark: string,\n sqliteMaxRows: number,\n pgMaxRows: number,\n sqliteMaxBytes: number,\n pgMaxBytes: number,\n maxRowsPerSource: number,\n maxBytesPerSource: number,\n ): Promise<SampledComparison> {\n let sqlite: CatchupRangeDigest;\n let pg: CatchupRangeDigest;\n try {\n const readBatchRows = this.#opts.readBatchRows ?? DEFAULT_READ_BATCH_ROWS;\n sqlite = await digestCatchupRange(\n reader.read(\n previousBoundary,\n watermark,\n Math.min(readBatchRows, sqliteMaxRows),\n undefined,\n sqliteMaxRows,\n ),\n watermark,\n sqliteMaxRows,\n sqliteMaxBytes,\n );\n pg = await digestCatchupRange(\n reconstructBatches(\n this.#pg.readCatchupRange(previousBoundary, watermark, pgMaxRows),\n ),\n watermark,\n pgMaxRows,\n pgMaxBytes,\n );\n } catch (e) {\n // A failed catchup read makes the transaction suspect.\n this.#lc.warn?.(\n `error reading transaction ${watermark} for comparison`,\n e,\n );\n return {\n outcome: await this.#reconfirm(db, pinned, watermark),\n sqliteRowsRead: 0,\n pgRowsRead: 0,\n sqliteBytesRead: 0,\n pgBytesRead: 0,\n };\n }\n const read = {\n sqliteRowsRead: sqlite.rows,\n pgRowsRead: pg.rows,\n sqliteBytesRead: sqlite.bytes,\n pgBytesRead: pg.bytes,\n };\n if (sqlite.limitReached || pg.limitReached) {\n // Skip a transaction that cannot fit in a fresh budget.\n // Defer one that does not fit only in the remaining budget.\n // A budget is fresh when neither dimension has been spent.\n const sqliteFresh =\n sqliteMaxRows === maxRowsPerSource &&\n sqliteMaxBytes === maxBytesPerSource;\n const pgFresh =\n pgMaxRows === maxRowsPerSource && pgMaxBytes === maxBytesPerSource;\n const outcome =\n (sqlite.limitReached && sqliteFresh) || (pg.limitReached && pgFresh)\n ? 'oversized'\n : 'deferred';\n return {outcome, ...read};\n }\n if (sqlite.digest === pg.digest) {\n return {outcome: 'match', ...read};\n }\n return {\n outcome: await this.#reconfirm(db, pinned, watermark),\n ...read,\n };\n }\n\n /**\n * Rechecks a suspect result against current store bounds.\n * A moved bound, changed seed, failed read, or lower head makes the result inconclusive.\n */\n async #reconfirm(\n db: Database,\n pinned: PinnedBounds,\n watermark: string,\n ): Promise<'mismatch' | 'inconclusive'> {\n try {\n const meta = readChangeLogMeta(db);\n if (\n meta.seedWatermark !== pinned.meta.seedWatermark ||\n meta.seededAtMs !== pinned.meta.seededAtMs\n ) {\n return 'inconclusive';\n }\n const sqlite = this.#readSQLiteBounds(db);\n const {minWatermark, lastWatermark} = await this.#pg.getCatchupBounds();\n if (sqlite === undefined || minWatermark === null) {\n return 'inconclusive';\n }\n if (\n (sqlite.minWatermark > pinned.sqlite.minWatermark &&\n watermark <= sqlite.minWatermark) ||\n (minWatermark > pinned.pgMinWatermark && watermark <= minWatermark) ||\n watermark > sqlite.headWatermark ||\n watermark > lastWatermark\n ) {\n return 'inconclusive';\n }\n return 'mismatch';\n } catch {\n return 'inconclusive';\n }\n }\n\n #skipped(reason: CompareSkipReason): CompareCycleResult {\n this.#compareCycles.add(1, {result: reason});\n if (reason === 'error') {\n // The caller logged the error.\n } else if (reason !== 'nothing-to-compare') {\n this.#lc.debug?.(`skipping SQLite change-log comparison: ${reason}`);\n }\n return {kind: 'skipped', reason};\n }\n\n #compared(\n fromWatermark: string,\n throughWatermark: string,\n transactions: number,\n sampled: number,\n outcomes: Record<CompareOutcome, number>,\n ): CompareCycleResult {\n this.#compareCycles.add(1, {result: 'compared'});\n const result = {\n kind: 'compared',\n fromWatermark,\n throughWatermark,\n transactions,\n sampled,\n matched: outcomes.match,\n mismatched: outcomes.mismatch,\n inconclusive: outcomes.inconclusive,\n deferred: outcomes.deferred,\n oversized: outcomes.oversized,\n } as const;\n this.#lc.debug?.('SQLite change-log comparison cycle', {\n sqliteChangeLogCompare: result,\n });\n return result;\n }\n}\n\n/** Applies the same reconstruction as Postgres catchup. */\nasync function* reconstructBatches(\n batches: AsyncIterable<ChangeLogEntry[]>,\n): AsyncIterable<WatermarkedChange[]> {\n for await (const batch of batches) {\n yield batch.map(reconstructWatermarkedChange);\n }\n}\n\nfunction minWatermark(a: string, b: string): string {\n return a < b ? a : b;\n}\n\nfunction maxWatermark(...watermarks: string[]): string {\n let max = watermarks[0];\n for (const w of watermarks) {\n if (w > max) {\n max = w;\n }\n }\n return max;\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"sqlite-change-log-comparator.js","names":[],"sources":["../../../../../../zero-cache/src/services/change-streamer/sqlite-change-log-comparator.ts"],"sourcesContent":["import type {LogContext} from '@rocicorp/logger';\nimport {Database} from '../../../../zqlite/src/db.ts';\nimport {\n getOrCreateCounter,\n getOrCreateLatencyHistogram,\n} from '../../observability/metrics.ts';\nimport type {ShardID} from '../../types/shards.ts';\nimport {\n CHANGE_LOG_DB_SCHEMA_VERSION,\n CHANGE_LOG_STREAM_TABLE,\n readChangeLogMeta,\n type ChangeLogIdentity,\n type ChangeLogMeta,\n} from '../replicator/change-log-db.ts';\nimport {\n reconstructWatermarkedChange,\n type ChangeLogEntry,\n} from './change-log-codec.ts';\nimport {\n digestCatchupRange,\n type CatchupRangeDigest,\n} from './change-log-compare-digest.ts';\nimport type {WatermarkedChange} from './change-streamer.ts';\nimport {isSampledForShard} from './shard-sampling.ts';\nimport {SQLiteChangeLogReader} from './sqlite-change-log-reader.ts';\n\n/** Interval between comparison cycles. */\nconst DEFAULT_COMPARE_INTERVAL_MS = 30_000;\n\n/** Maximum transactions enumerated per cycle. */\nconst DEFAULT_MAX_TRANSACTIONS_PER_CYCLE = 256;\n\n/** Maximum catchup rows read from each store per cycle. */\nconst DEFAULT_MAX_ROWS_PER_SOURCE_PER_CYCLE = 1000;\n\n/**\n * Maximum catchup bytes read from each store per cycle.\n *\n * Rows bound the per-row overhead. Bytes bound the read and hash work,\n * which scales with payload size instead of row count. Normal traffic\n * reaches the row limit first. Wide payloads reach this one.\n */\nconst DEFAULT_MAX_BYTES_PER_SOURCE_PER_CYCLE = 32 * 1024 * 1024;\n\n/** Rows per SQLite catchup batch. */\nconst DEFAULT_READ_BATCH_ROWS = 1000;\n\n/**\n * Result for one committed transaction.\n *\n * `mismatch` covers missing, extra, changed, or reordered rows.\n * `inconclusive` means that store bounds changed during the comparison.\n */\nexport type CompareOutcome =\n | 'match'\n | 'mismatch'\n | 'inconclusive'\n | 'deferred'\n | 'oversized';\n\nexport type CompareSkipReason =\n // The comparator was stopped.\n | 'stopped'\n // The writer replaced the file during this cycle.\n | 'log-rebuilt'\n // The file is absent, unreadable, or uninitialized.\n | 'log-unavailable'\n // The schema version does not match this build.\n | 'ineligible-schema'\n // The log belongs to a different replica.\n | 'ineligible-identity'\n // The log is younger than the warm-up period.\n | 'cold-log'\n // The stores have no complete committed range in common.\n | 'nothing-to-compare'\n // The cycle failed. The error was logged and counted.\n | 'error';\n\nexport type CompareCycleResult =\n | {readonly kind: 'skipped'; readonly reason: CompareSkipReason}\n | {\n readonly kind: 'compared';\n /** Exclusive lower bound of the compared range. */\n readonly fromWatermark: string;\n /** Inclusive upper bound handled. The next cycle resumes from here. */\n readonly throughWatermark: string;\n /** Committed transactions handled through `throughWatermark`. */\n readonly transactions: number;\n /** Transactions with compared catchup output. */\n readonly sampled: number;\n readonly matched: number;\n readonly mismatched: number;\n /** Suspects rejected because store bounds changed. */\n readonly inconclusive: number;\n /** Sampled transactions deferred to a fresh budget. */\n readonly deferred: number;\n /** Sampled transactions that exceed a fresh budget. */\n readonly oversized: number;\n };\n\n/** Provides the Postgres side of the catchup comparison. */\nexport interface PGChangeLogRangeReader {\n getCatchupBounds(): Promise<{\n minWatermark: string | null;\n lastWatermark: string;\n }>;\n listCommitWatermarks(\n afterWatermark: string,\n throughWatermark: string,\n limit: number,\n ): Promise<string[]>;\n readCatchupRange(\n afterWatermark: string,\n throughWatermark: string,\n batchRows?: number | undefined,\n ): AsyncIterable<ChangeLogEntry[]>;\n}\n\nexport type SQLiteChangeLogCompareOptions = {\n /** Stable percentage of transactions selected for payload comparison. */\n readonly comparePercent: number;\n /** Warm-up time after a seed. The comparator skips a younger log. */\n readonly retentionMs: number;\n readonly intervalMs?: number | undefined;\n readonly maxTransactionsPerCycle?: number | undefined;\n /** Maximum catchup rows read from each store in one cycle. */\n readonly maxRowsPerSourcePerCycle?: number | undefined;\n /** Maximum catchup bytes read from each store in one cycle. */\n readonly maxBytesPerSourcePerCycle?: number | undefined;\n readonly readBatchRows?: number | undefined;\n /** Clock for the warm-up period. Defaults to `Date.now`. */\n readonly now?: (() => number) | undefined;\n readonly setTimeoutFn?: typeof setTimeout | undefined;\n readonly clearTimeoutFn?: typeof clearTimeout | undefined;\n /** Yields between sampled transactions. Defaults to `setImmediate`. */\n readonly yieldFn?: (() => Promise<void>) | undefined;\n};\n\n/** Uses separate aggregates so SQLite can use an index for each value. */\nconst SQLITE_BOUNDS_SQL = /*sql*/ `\n SELECT\n (SELECT min(\"watermark\") FROM \"${CHANGE_LOG_STREAM_TABLE}\")\n AS \"minWatermark\",\n (SELECT max(\"watermark\") FROM \"${CHANGE_LOG_STREAM_TABLE}\")\n AS \"headWatermark\"\n`;\n\n/** Lists committed transaction watermarks in `(@after, @through]`. */\nconst SQLITE_COMMITS_SQL = /*sql*/ `\n SELECT \"watermark\" FROM \"${CHANGE_LOG_STREAM_TABLE}\"\n WHERE \"precommit\" IS NOT NULL\n AND \"watermark\" > @after\n AND \"watermark\" <= @through\n ORDER BY \"watermark\"\n LIMIT @limit\n`;\n\ntype SQLiteBounds = {\n readonly minWatermark: string;\n readonly headWatermark: string;\n};\n\ntype PinnedBounds = {\n readonly meta: ChangeLogMeta;\n readonly sqlite: SQLiteBounds;\n readonly pgMinWatermark: string;\n readonly pgLastWatermark: string;\n};\n\ntype SampledComparison = {\n readonly outcome: CompareOutcome;\n readonly sqliteRowsRead: number;\n readonly pgRowsRead: number;\n readonly sqliteBytesRead: number;\n readonly pgBytesRead: number;\n};\n\n/**\n * Compares SQLite and Postgres catchup output without affecting subscribers.\n *\n * Each cycle compares complete ranges through the lower store head.\n * It checks every transaction for presence and samples payloads.\n * A bounds change makes the affected result `inconclusive`.\n * Postgres remains authoritative.\n */\nexport class SQLiteChangeLogComparator {\n readonly #lc: LogContext;\n readonly #shard: ShardID;\n readonly #changeLogFile: string;\n readonly #identity: ChangeLogIdentity;\n readonly #pg: PGChangeLogRangeReader;\n readonly #opts: SQLiteChangeLogCompareOptions;\n readonly #now: () => number;\n readonly #setTimeoutFn: typeof setTimeout;\n readonly #clearTimeoutFn: typeof clearTimeout;\n readonly #yield: () => Promise<void>;\n\n readonly #compareResults = getOrCreateCounter(\n 'replica',\n 'sqlite_change_log.compare_result',\n 'Outcomes of the dark comparison between what SQLite catchup serves and ' +\n 'what PG catchup serves, per committed transaction. `inconclusive` ' +\n 'means a purge or reseed moved the pinned bounds mid-comparison.',\n );\n readonly #compareCycles = getOrCreateCounter(\n 'replica',\n 'sqlite_change_log.compare_cycles',\n 'SQLite change-log comparison cycles, by result.',\n );\n readonly #cycleDuration = getOrCreateLatencyHistogram(\n 'replica',\n 'sqlite_change_log.compare_cycle_duration',\n 'Duration of one SQLite change-log comparison cycle.',\n );\n\n /** Last handled commit watermark. A restart derives it from current bounds. */\n #cursor: string | undefined;\n #running: Promise<CompareCycleResult> | undefined;\n #timer: ReturnType<typeof setTimeout> | undefined;\n #stopped = false;\n #continueWithoutDelay = false;\n #fileGeneration = 0;\n\n constructor(\n lc: LogContext,\n shard: ShardID,\n changeLogFile: string,\n identity: ChangeLogIdentity,\n pg: PGChangeLogRangeReader,\n opts: SQLiteChangeLogCompareOptions,\n ) {\n this.#lc = lc.withContext('component', 'sqlite-change-log-compare');\n this.#shard = shard;\n this.#changeLogFile = changeLogFile;\n this.#identity = identity;\n this.#pg = pg;\n this.#opts = opts;\n this.#now = opts.now ?? Date.now;\n this.#setTimeoutFn = opts.setTimeoutFn ?? setTimeout;\n this.#clearTimeoutFn = opts.clearTimeoutFn ?? clearTimeout;\n this.#yield =\n opts.yieldFn ??\n (() => new Promise<void>(resolve => setImmediate(resolve)));\n this.#scheduleNext();\n }\n\n /**\n * Runs one cycle. Concurrent calls share the same promise.\n * Errors produce `skipped: 'error'`.\n */\n compareOnce(): Promise<CompareCycleResult> {\n if (this.#stopped) {\n return Promise.resolve({kind: 'skipped', reason: 'stopped'});\n }\n if (this.#running === undefined) {\n this.#running = this.#runCycle()\n .then(result => {\n if (this.#continueWithoutDelay) {\n this.#rescheduleNext(0);\n }\n return result;\n })\n .finally(() => {\n this.#running = undefined;\n });\n }\n return this.#running;\n }\n\n stop(): void {\n this.#stopped = true;\n if (this.#timer !== undefined) {\n this.#clearTimeoutFn(this.#timer);\n this.#timer = undefined;\n }\n }\n\n /** Invalidates cycles that can still read a replaced file. */\n invalidate(): void {\n this.#fileGeneration++;\n this.#cursor = undefined;\n this.#continueWithoutDelay = false;\n }\n\n #scheduleNext(\n delayMs = this.#opts.intervalMs ?? DEFAULT_COMPARE_INTERVAL_MS,\n ): void {\n if (this.#stopped || this.#timer !== undefined) {\n return;\n }\n this.#timer = this.#setTimeoutFn(() => {\n this.#timer = undefined;\n void this.compareOnce().finally(() => this.#scheduleNext());\n }, delayMs);\n }\n\n #rescheduleNext(delayMs: number): void {\n if (this.#timer !== undefined) {\n this.#clearTimeoutFn(this.#timer);\n this.#timer = undefined;\n }\n this.#scheduleNext(delayMs);\n }\n\n async #runCycle(): Promise<CompareCycleResult> {\n const startedAt = performance.now();\n const fileGeneration = this.#fileGeneration;\n this.#continueWithoutDelay = false;\n let db: Database | undefined;\n let reader: SQLiteChangeLogReader | undefined;\n try {\n try {\n // A readonly handle does not create a missing file.\n db = new Database(this.#lc, this.#changeLogFile, {readonly: true});\n } catch {\n return this.#skipped('log-unavailable');\n }\n\n const pinned = await this.#pinBounds(db);\n if (fileGeneration !== this.#fileGeneration) {\n return this.#skipped('log-rebuilt');\n }\n if (typeof pinned === 'string') {\n return this.#skipped(pinned);\n }\n const {meta} = pinned;\n\n const from = maxWatermark(\n this.#cursor ?? meta.seedWatermark,\n // Exclude the synthetic SQLite seed transaction.\n // The Postgres seed has no `precommit`, so enumeration also excludes it.\n meta.seedWatermark,\n // A store cannot serve the transaction at its minimum watermark.\n pinned.pgMinWatermark,\n pinned.sqlite.minWatermark,\n );\n const through = minWatermark(\n pinned.pgLastWatermark,\n pinned.sqlite.headWatermark,\n );\n if (through <= from) {\n return this.#skipped('nothing-to-compare');\n }\n\n let transactions = 0;\n let sampled = 0;\n const outcomes: Record<CompareOutcome, number> = {\n match: 0,\n mismatch: 0,\n inconclusive: 0,\n deferred: 0,\n oversized: 0,\n };\n const record = (watermark: string, outcome: CompareOutcome) => {\n this.#compareResults.add(1, {outcome});\n outcomes[outcome]++;\n if (outcome === 'mismatch') {\n // Log only the watermark because payloads contain customer data.\n this.#lc.error?.(\n 'SQLite change-log catchup output diverged from Postgres',\n {sqliteChangeLogCompare: {watermark}},\n );\n }\n };\n\n reader = new SQLiteChangeLogReader(this.#lc, this.#changeLogFile);\n const plan = reader.plan(from);\n if (plan.kind === 'not-ready') {\n return this.#skipped('log-unavailable');\n }\n if (plan.kind === 'ahead') {\n return this.#skipped('nothing-to-compare');\n }\n if (plan.kind === 'too-old') {\n // A fixed lower bound makes this result a mismatch.\n // A changed lower bound makes it inconclusive.\n const reconfirmed = await this.#reconfirm(db, pinned, from);\n const outcome =\n fileGeneration === this.#fileGeneration\n ? reconfirmed\n : 'inconclusive';\n record(from, outcome);\n if (outcome === 'inconclusive') {\n // Keep the cursor unchanged because the bounds moved.\n return this.#compared(from, from, transactions, sampled, outcomes);\n }\n // Positional reads can continue after a missing boundary.\n // Advancing avoids reporting the same boundary in every cycle.\n }\n\n const limit = Math.max(\n 1,\n this.#opts.maxTransactionsPerCycle ??\n DEFAULT_MAX_TRANSACTIONS_PER_CYCLE,\n );\n const union = await this.#enumerateCommits(db, from, through, limit);\n if (fileGeneration !== this.#fileGeneration) {\n return this.#skipped('log-rebuilt');\n }\n const maxRowsPerSource = Math.max(\n 1,\n this.#opts.maxRowsPerSourcePerCycle ??\n DEFAULT_MAX_ROWS_PER_SOURCE_PER_CYCLE,\n );\n const maxBytesPerSource = Math.max(\n 1,\n this.#opts.maxBytesPerSourcePerCycle ??\n DEFAULT_MAX_BYTES_PER_SOURCE_PER_CYCLE,\n );\n\n let sqliteRowsRead = 0;\n let pgRowsRead = 0;\n let sqliteBytesRead = 0;\n let pgBytesRead = 0;\n let previousBoundary = from;\n let handledThrough = from;\n for (const {watermark, inSqlite, inPg} of union) {\n if (this.#stopped) {\n break;\n }\n let stopAfterTransaction = false;\n if (!inSqlite || !inPg) {\n // Check every transaction for presence because this needs no payload read.\n const outcome = await this.#reconfirm(db, pinned, watermark);\n record(\n watermark,\n fileGeneration === this.#fileGeneration ? outcome : 'inconclusive',\n );\n } else if (\n isSampledForShard(this.#shard, watermark, this.#opts.comparePercent)\n ) {\n const sqliteRowsRemaining = maxRowsPerSource - sqliteRowsRead;\n const pgRowsRemaining = maxRowsPerSource - pgRowsRead;\n // A read may pass the byte budget by its last row, so this\n // compares against zero rather than checking for equality.\n const sqliteBytesRemaining = maxBytesPerSource - sqliteBytesRead;\n const pgBytesRemaining = maxBytesPerSource - pgBytesRead;\n if (\n sqliteRowsRemaining === 0 ||\n pgRowsRemaining === 0 ||\n sqliteBytesRemaining <= 0 ||\n pgBytesRemaining <= 0\n ) {\n record(watermark, 'deferred');\n break;\n }\n sampled++;\n const comparison = await this.#compareTransaction(\n db,\n reader,\n pinned,\n previousBoundary,\n watermark,\n sqliteRowsRemaining,\n pgRowsRemaining,\n sqliteBytesRemaining,\n pgBytesRemaining,\n maxRowsPerSource,\n maxBytesPerSource,\n );\n sqliteRowsRead += comparison.sqliteRowsRead;\n pgRowsRead += comparison.pgRowsRead;\n sqliteBytesRead += comparison.sqliteBytesRead;\n pgBytesRead += comparison.pgBytesRead;\n const outcome =\n fileGeneration === this.#fileGeneration\n ? comparison.outcome\n : 'inconclusive';\n record(watermark, outcome);\n if (outcome === 'inconclusive') {\n break;\n }\n if (outcome === 'deferred') {\n break;\n }\n if (outcome === 'oversized') {\n // Advance past a transaction that cannot fit in a fresh budget.\n stopAfterTransaction = true;\n }\n // Yield between sampled reads so other stream work can continue.\n await this.#yield();\n }\n previousBoundary = watermark;\n handledThrough = watermark;\n transactions++;\n if (stopAfterTransaction) {\n break;\n }\n }\n\n if (\n !this.#stopped &&\n fileGeneration === this.#fileGeneration &&\n outcomes.inconclusive === 0\n ) {\n this.#cursor = handledThrough;\n this.#continueWithoutDelay = handledThrough < through;\n }\n return this.#compared(\n from,\n handledThrough,\n transactions,\n sampled,\n outcomes,\n );\n } catch (e) {\n this.#lc.warn?.('error comparing the SQLite change log', e);\n return this.#skipped('error');\n } finally {\n reader?.close();\n db?.close();\n this.#cycleDuration.recordMs(performance.now() - startedAt);\n }\n }\n\n /** Checks log eligibility and reads the bounds from both stores. */\n async #pinBounds(db: Database): Promise<PinnedBounds | CompareSkipReason> {\n let meta: ChangeLogMeta;\n try {\n meta = readChangeLogMeta(db);\n } catch {\n // The writer has not initialized the log.\n return 'log-unavailable';\n }\n if (meta.schemaVersion !== CHANGE_LOG_DB_SCHEMA_VERSION) {\n return 'ineligible-schema';\n }\n const {epoch, generation, replicaID} = this.#identity;\n if (\n meta.epoch !== epoch ||\n meta.generation !== generation ||\n meta.replicaID !== replicaID\n ) {\n return 'ineligible-identity';\n }\n if (this.#now() - meta.seededAtMs < this.#opts.retentionMs) {\n return 'cold-log';\n }\n const sqlite = this.#readSQLiteBounds(db);\n if (sqlite === undefined) {\n return 'log-unavailable';\n }\n const {minWatermark, lastWatermark} = await this.#pg.getCatchupBounds();\n if (minWatermark === null) {\n // Only a new replica has an empty Postgres change log.\n return 'nothing-to-compare';\n }\n return {\n meta,\n sqlite,\n pgMinWatermark: minWatermark,\n pgLastWatermark: lastWatermark,\n };\n }\n\n #readSQLiteBounds(db: Database): SQLiteBounds | undefined {\n const bounds = db\n .prepare(SQLITE_BOUNDS_SQL)\n .get<{minWatermark: string | null; headWatermark: string | null}>();\n return bounds.minWatermark === null || bounds.headWatermark === null\n ? undefined\n : {\n minWatermark: bounds.minWatermark,\n headWatermark: bounds.headWatermark,\n };\n }\n\n /**\n * Merges committed transaction watermarks from both stores.\n * If a list reaches the limit, the result ends at the last complete common range.\n */\n async #enumerateCommits(\n db: Database,\n from: string,\n through: string,\n limit: number,\n ): Promise<{watermark: string; inSqlite: boolean; inPg: boolean}[]> {\n let sqlite: string[] = db\n .prepare(SQLITE_COMMITS_SQL)\n .all<{watermark: string}>({after: from, through, limit: limit + 1})\n .map(({watermark}) => watermark);\n let pg = await this.#pg.listCommitWatermarks(from, through, limit + 1);\n\n let effectiveThrough = through;\n if (sqlite.length > limit) {\n sqlite.length = limit;\n effectiveThrough = minWatermark(effectiveThrough, sqlite[limit - 1]);\n }\n if (pg.length > limit) {\n pg.length = limit;\n effectiveThrough = minWatermark(effectiveThrough, pg[limit - 1]);\n }\n if (effectiveThrough !== through) {\n sqlite = sqlite.filter(w => w <= effectiveThrough);\n pg = pg.filter(w => w <= effectiveThrough);\n }\n\n const union: {watermark: string; inSqlite: boolean; inPg: boolean}[] = [];\n let s = 0;\n let p = 0;\n while (s < sqlite.length || p < pg.length) {\n const sw = s < sqlite.length ? sqlite[s] : undefined;\n const pw = p < pg.length ? pg[p] : undefined;\n if (sw !== undefined && (pw === undefined || sw < pw)) {\n union.push({watermark: sw, inSqlite: true, inPg: false});\n s++;\n } else if (pw !== undefined && (sw === undefined || pw < sw)) {\n union.push({watermark: pw, inSqlite: false, inPg: true});\n p++;\n } else if (sw !== undefined) {\n union.push({watermark: sw, inSqlite: true, inPg: true});\n s++;\n p++;\n }\n }\n return union;\n }\n\n /**\n * Compares the output in `(previousBoundary, watermark]`.\n * Positional reads do not require either store to contain the boundary row.\n */\n async #compareTransaction(\n db: Database,\n reader: SQLiteChangeLogReader,\n pinned: PinnedBounds,\n previousBoundary: string,\n watermark: string,\n sqliteMaxRows: number,\n pgMaxRows: number,\n sqliteMaxBytes: number,\n pgMaxBytes: number,\n maxRowsPerSource: number,\n maxBytesPerSource: number,\n ): Promise<SampledComparison> {\n let sqlite: CatchupRangeDigest;\n let pg: CatchupRangeDigest;\n try {\n const readBatchRows = this.#opts.readBatchRows ?? DEFAULT_READ_BATCH_ROWS;\n sqlite = await digestCatchupRange(\n reader.read(\n previousBoundary,\n watermark,\n Math.min(readBatchRows, sqliteMaxRows),\n undefined,\n sqliteMaxRows,\n ),\n watermark,\n sqliteMaxRows,\n sqliteMaxBytes,\n );\n pg = await digestCatchupRange(\n reconstructBatches(\n this.#pg.readCatchupRange(previousBoundary, watermark, pgMaxRows),\n ),\n watermark,\n pgMaxRows,\n pgMaxBytes,\n );\n } catch (e) {\n // A failed catchup read makes the transaction suspect.\n this.#lc.warn?.(\n `error reading transaction ${watermark} for comparison`,\n e,\n );\n return {\n outcome: await this.#reconfirm(db, pinned, watermark),\n sqliteRowsRead: 0,\n pgRowsRead: 0,\n sqliteBytesRead: 0,\n pgBytesRead: 0,\n };\n }\n const read = {\n sqliteRowsRead: sqlite.rows,\n pgRowsRead: pg.rows,\n sqliteBytesRead: sqlite.bytes,\n pgBytesRead: pg.bytes,\n };\n if (sqlite.limitReached || pg.limitReached) {\n // Skip a transaction that cannot fit in a fresh budget.\n // Defer one that does not fit only in the remaining budget.\n // A budget is fresh when neither dimension has been spent.\n const sqliteFresh =\n sqliteMaxRows === maxRowsPerSource &&\n sqliteMaxBytes === maxBytesPerSource;\n const pgFresh =\n pgMaxRows === maxRowsPerSource && pgMaxBytes === maxBytesPerSource;\n const outcome =\n (sqlite.limitReached && sqliteFresh) || (pg.limitReached && pgFresh)\n ? 'oversized'\n : 'deferred';\n return {outcome, ...read};\n }\n if (sqlite.digest === pg.digest) {\n return {outcome: 'match', ...read};\n }\n return {\n outcome: await this.#reconfirm(db, pinned, watermark),\n ...read,\n };\n }\n\n /**\n * Rechecks a suspect result against current store bounds.\n * A moved bound, changed seed, failed read, or lower head makes the result inconclusive.\n */\n async #reconfirm(\n db: Database,\n pinned: PinnedBounds,\n watermark: string,\n ): Promise<'mismatch' | 'inconclusive'> {\n try {\n const meta = readChangeLogMeta(db);\n if (\n meta.seedWatermark !== pinned.meta.seedWatermark ||\n meta.seededAtMs !== pinned.meta.seededAtMs\n ) {\n return 'inconclusive';\n }\n const sqlite = this.#readSQLiteBounds(db);\n const {minWatermark, lastWatermark} = await this.#pg.getCatchupBounds();\n if (sqlite === undefined || minWatermark === null) {\n return 'inconclusive';\n }\n if (\n (sqlite.minWatermark > pinned.sqlite.minWatermark &&\n watermark <= sqlite.minWatermark) ||\n (minWatermark > pinned.pgMinWatermark && watermark <= minWatermark) ||\n watermark > sqlite.headWatermark ||\n watermark > lastWatermark\n ) {\n return 'inconclusive';\n }\n return 'mismatch';\n } catch {\n return 'inconclusive';\n }\n }\n\n #skipped(reason: CompareSkipReason): CompareCycleResult {\n this.#compareCycles.add(1, {result: reason});\n if (reason === 'error') {\n // The caller logged the error.\n } else if (reason !== 'nothing-to-compare') {\n this.#lc.debug?.(`skipping SQLite change-log comparison: ${reason}`);\n }\n return {kind: 'skipped', reason};\n }\n\n #compared(\n fromWatermark: string,\n throughWatermark: string,\n transactions: number,\n sampled: number,\n outcomes: Record<CompareOutcome, number>,\n ): CompareCycleResult {\n this.#compareCycles.add(1, {result: 'compared'});\n const result = {\n kind: 'compared',\n fromWatermark,\n throughWatermark,\n transactions,\n sampled,\n matched: outcomes.match,\n mismatched: outcomes.mismatch,\n inconclusive: outcomes.inconclusive,\n deferred: outcomes.deferred,\n oversized: outcomes.oversized,\n } as const;\n this.#lc.debug?.('SQLite change-log comparison cycle', {\n sqliteChangeLogCompare: result,\n });\n return result;\n }\n}\n\n/** Applies the same reconstruction as Postgres catchup. */\nasync function* reconstructBatches(\n batches: AsyncIterable<ChangeLogEntry[]>,\n): AsyncIterable<WatermarkedChange[]> {\n for await (const batch of batches) {\n yield batch.map(reconstructWatermarkedChange);\n }\n}\n\nfunction minWatermark(a: string, b: string): string {\n return a < b ? a : b;\n}\n\nfunction maxWatermark(...watermarks: string[]): string {\n let max = watermarks[0];\n for (const w of watermarks) {\n if (w > max) {\n max = w;\n }\n }\n return max;\n}\n"],"mappings":"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"}
|
package/out/zero-cache/src/services/change-streamer/sqlite-change-log-purge-scheduler.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sqlite-change-log-purge-scheduler.js","names":["#lc","#connection","#subscriberAcks","#opts","#now","#setTimeoutFn","#clearTimeoutFn","#yield","#lock","#pausedBy","#writerIdleWaiters","#purgedRows","#passDuration","#passBatches","#declines","#floorProbes","#stopped","#skipped","#runPass","#awaitWriterIdle","#gate","#cacheFor","#probeFloor","#continuation","#cache"],"sources":["../../../../../../zero-cache/src/services/change-streamer/sqlite-change-log-purge-scheduler.ts"],"sourcesContent":["import {Lock} from '@rocicorp/lock';\nimport type {LogContext} from '@rocicorp/logger';\nimport {assert} from '../../../../shared/src/asserts.ts';\nimport type {Database, Statement} from '../../../../zqlite/src/db.ts';\nimport {\n getOrCreateCounter,\n getOrCreateLatencyHistogram,\n getOrCreateValueHistogram,\n} from '../../observability/metrics.ts';\nimport {\n CHANGE_LOG_STREAM_TABLE,\n readChangeLogMeta,\n} from '../replicator/change-log-db.ts';\nimport {SQLiteChangeLogPurger} from '../replicator/sqlite-change-log-purger.ts';\nimport type {SQLiteChangeLogCleanupGuard} from './sqlite-change-log-catchup.ts';\nimport {SQLITE_CHANGE_LOG_PLAN_SQL} from './sqlite-change-log-reader.ts';\n\n/**\n * The duration target for any one purge statement, enforced by the purger's\n * measured-budget feedback. Purge runs on the writer's own\n * connection, in the stream loop's process, so a statement's duration stalls\n * the appending path — and the fan-out behind it — directly. The bench's\n * 100 ms bound was derived for the replicator's write worker; this budget is\n * measured against an event loop that also owes progress to fan-out, catchup,\n * and websocket keepalives, so it is deliberately tighter.\n */\nconst DEFAULT_STATEMENT_TARGET_MS = 20;\n\n/**\n * Batches per pass. This bounds one pass's bookkeeping, not the drain: a pass\n * that hits the limit asks its caller for an immediate continuation.\n */\nconst DEFAULT_MAX_BATCHES_PER_PASS = 100;\n\n/**\n * The backstop poll while waiting for the writer's open transaction to\n * commit. The commit notification normally arrives first; this covers an\n * interrupted stream, whose rollback does not notify.\n */\nconst DEFAULT_IN_TRANSACTION_POLL_MS = 1_000;\n\n/** Why a pass stopped before draining the eligible set. */\nexport type PurgeStopReason =\n // The scheduler was stopped.\n | 'stopped'\n // A snapshot reservation is open.\n | 'paused'\n // The writer has not opened the change log (the file does not exist yet),\n // or has failed soft and deleted it.\n | 'writer-unavailable'\n // A connected subscriber's ACK is behind the floor. Re-checked before every\n // batch so a registration that lands mid-drain is honored by the very next\n // batch.\n | 'subscriber-behind'\n // The per-pass batch limit was reached with history still eligible.\n | 'batch-limit'\n // The pass failed; the error was logged and counted.\n | 'error';\n\n/**\n * The invariant-14 probe's classification of `plan(floor)` after a pass.\n *\n * `too-old` at the floor is not by itself a violation: a log that never held\n * that history — routine at every fork, restore, and rolling restart — returns\n * `too-old` without having purged anything. Only the seed point distinguishes\n * the two, which is why `seedWatermark` is in the v2 meta row:\n *\n * ```\n * violation ⇔ plan(floor) is `too-old` AND seedWatermark <= floor\n * ```\n */\nexport type FloorProbeOutcome =\n // plan(floor) is `range`: the transaction at the floor survives as a\n // restoring follower's catchup boundary.\n | 'retained'\n // The floor is above the log's head, i.e. the log is stale/frozen and the\n // head cap is what held retention. Nothing at the floor was ever purged.\n | 'ahead'\n // plan(floor) is `too-old` but the log was seeded above the floor: it never\n // held that history. Charted, and feeds the fork/resumption reach\n // measurement; not an alert.\n | 'cold-log'\n // plan(floor) is `too-old` and the log demonstrably once held the floor.\n // This is an invariant-14 violation: the only copy a restoring follower\n // could catch up from was purged.\n | 'violation';\n\n/** When the cleanup coordinator should run another pass. */\nexport type PurgeContinuation =\n // The pass hit its batch limit while eligible history remained.\n | 'immediate'\n // Progress depends on time or external state: retention, a subscriber ACK,\n // a reservation, the writer becoming available, or a transient error.\n | 'deferred';\n\nexport type PurgePassResult = {\n /** The external floor the pass used. */\n readonly floor: string;\n readonly batches: number;\n readonly deletedRows: number;\n /** Why the pass ended before draining, if it did. */\n readonly stopped: PurgeStopReason | undefined;\n /** The invariant-14 probe's outcome, when the log was available to probe. */\n readonly probe: FloorProbeOutcome | undefined;\n /** Whether, and how soon, the cleanup coordinator should run another pass. */\n readonly continuation: PurgeContinuation | undefined;\n};\n\nexport type SQLiteChangeLogPurgeSchedulerOptions = {\n /**\n * `sqliteChangeLogRetentionMs`: the minimum retention window, ANDed on top\n * of the external floor. It can only make purge more conservative — the\n * purger never accepts it as a standalone bound.\n */\n readonly retentionMs: number;\n /**\n * `sqliteChangeLogPurgeBatchRows`: the starting chunk size per batch. Not\n * the bound — the statement duration target is.\n */\n readonly batchRows: number;\n readonly statementTargetMs?: number | undefined;\n readonly maxBatchesPerPass?: number | undefined;\n readonly inTransactionPollMs?: number | undefined;\n /** The clock the retention cutoff is measured against. Defaults to `Date.now`. */\n readonly now?: (() => number) | undefined;\n readonly setTimeoutFn?: typeof setTimeout | undefined;\n readonly clearTimeoutFn?: typeof clearTimeout | undefined;\n /**\n * Awaited between batches so fan-out and catchup make progress. Defaults to\n * a macrotask (`setImmediate`), so pending I/O runs before the next batch.\n */\n readonly yieldFn?: (() => Promise<void>) | undefined;\n};\n\ntype ConnectionCache = {\n readonly db: Database;\n readonly purger: SQLiteChangeLogPurger;\n readonly plan: Statement;\n readonly belowFloorRemains: Statement;\n};\n\ntype PlanRow = {\n readonly headWatermark: string | null;\n readonly minWatermark: string | null;\n readonly boundaryExists: number;\n};\n\n/**\n * Whether the cleanup coordinator still owes this floor a pass. Deliberately\n * not the purger's eligibility query, in two ways:\n *\n * - `remains`: rows younger than the retention cutoff are not eligible *yet*,\n * but they will be, and their presence asks the cleanup coordinator for a\n * deferred pass even on a quiet source.\n * - `headBehindFloor`: a head behind the floor means the writer is expected\n * to replay commits below it (e.g. after reconnecting while the backup\n * outran this log). Those rows do not exist to count yet, writer commits\n * only wake an in-flight waiter, and backup monitors never resend an\n * unchanged floor — so reporting quiescence here would disarm cleanup for\n * good. An empty log (no head) defers for the same reason.\n */\nconst BELOW_FLOOR_REMAINS_SQL = /*sql*/ `\n SELECT\n EXISTS (\n SELECT 1 FROM \"${CHANGE_LOG_STREAM_TABLE}\"\n WHERE \"watermark\" < @floor\n AND \"watermark\" < (SELECT max(\"watermark\") FROM \"${CHANGE_LOG_STREAM_TABLE}\")\n ) AS \"remains\",\n coalesce(\n (SELECT max(\"watermark\") FROM \"${CHANGE_LOG_STREAM_TABLE}\") < @floor,\n 1\n ) AS \"headBehindFloor\"\n`;\n\n/**\n * Runs bounded SQLite change-log purge passes on the writer's own connection.\n *\n * The caller supplies the current durable cleanup floor independently of any\n * other change-log implementation and owns the timing of subsequent passes.\n * Within a pass this class drains bounded batches, re-checking live subscriber\n * ACKs before each one and releasing the cleanup guard between them, so a\n * catchup registration or snapshot reservation never waits longer than one\n * batch.\n *\n * Purge windows exist only between the writer's commits: purge shares the\n * writer's connection, so it cannot run inside the writer's open transaction,\n * and a batch that finds one waits for the commit notification instead.\n *\n * The result tells the cleanup coordinator whether the next pass should be\n * immediate or deferred. This class deliberately owns no cleanup timer: the\n * same level-triggered coordinator continues a large drain, advances the\n * retention clock on a quiet source, and retries changing external state.\n */\nexport class SQLiteChangeLogPurgeScheduler {\n readonly #lc: LogContext;\n readonly #connection: () => Database | undefined;\n readonly #subscriberAcks: () => Iterable<string>;\n readonly #opts: SQLiteChangeLogPurgeSchedulerOptions;\n readonly #now: () => number;\n readonly #setTimeoutFn: typeof setTimeout;\n readonly #clearTimeoutFn: typeof clearTimeout;\n readonly #yield: () => Promise<void>;\n\n /**\n * The mutex that serializes purge batches with catchup registration and\n * reservation pauses. It is only ever held across a synchronous section —\n * one batch, or one registration — never across a yield, which is what\n * bounds any waiter to a single batch's duration.\n */\n readonly #lock = new Lock();\n\n /**\n * Open snapshot reservations, reference-counted by taskID. Purge pauses\n * while any is open. Counted rather than a set so that a reservation retry\n * -- whose cancel-then-recreate can resolve its old resume after its new\n * suspend -- still nets out.\n */\n readonly #pausedBy = new Map<string, number>();\n\n /** Waiters for the writer's next commit (or rollback). */\n readonly #writerIdleWaiters = new Set<() => void>();\n\n readonly #purgedRows = getOrCreateCounter(\n 'replica',\n 'sqlite_change_log.purged_rows',\n 'Rows removed from the SQLite change log by the purge scheduler.',\n );\n readonly #passDuration = getOrCreateLatencyHistogram(\n 'replica',\n 'sqlite_change_log.purge_cycle_duration',\n 'Duration of one SQLite change-log purge pass, including the yields ' +\n 'between its batches.',\n );\n readonly #passBatches = getOrCreateValueHistogram(\n 'replica',\n 'sqlite_change_log.purge_cycle_batches',\n {\n description: 'Purge batches run per SQLite change-log purge pass.',\n unit: '{batch}',\n bucketBoundaries: [1, 2, 5, 10, 25, 50, 100, 250],\n },\n );\n readonly #declines = getOrCreateCounter(\n 'replica',\n 'sqlite_change_log.purge_declined',\n 'SQLite change-log purge passes that ended before draining, by reason.',\n );\n readonly #floorProbes = getOrCreateCounter(\n 'replica',\n 'sqlite_change_log.purge_floor_probe',\n 'Invariant-14 probe outcomes: whether the transaction at the purge ' +\n 'floor is still servable after each pass. \"violation\" means history ' +\n 'at or above the floor was purged and alerts; \"cold-log\" means the ' +\n 'log never held it, which is routine at every fork and resumption.',\n );\n\n #cache: ConnectionCache | undefined;\n #stopped = false;\n\n /**\n * The guard that brackets catchup registration: required-head capture and\n * `Forwarder.add()` run under the same mutex as purge batches, so the\n * registered ACK is visible to `Forwarder.getAcks()` before the next batch\n * evaluates its gate.\n */\n readonly cleanupGuard: SQLiteChangeLogCleanupGuard = {\n runWhilePurgeBlocked: register => this.#lock.withLock(register),\n };\n\n /**\n * @param connection the writer's own connection (§3.3). `undefined` until\n * the writer's first reconcile creates the file, and again after the\n * writer fails soft and deletes it — both of which defer the pass rather\n * than fail it, so a later coordinator pass picks up a late-appearing\n * file without a restart.\n * @param subscriberAcks live subscriber ACKs, re-read before every batch so\n * the supplied floor remains safe throughout an asynchronous drain.\n */\n constructor(\n lc: LogContext,\n connection: () => Database | undefined,\n subscriberAcks: () => Iterable<string>,\n opts: SQLiteChangeLogPurgeSchedulerOptions,\n ) {\n assert(\n Number.isSafeInteger(opts.retentionMs) && opts.retentionMs > 0,\n 'sqliteChangeLogRetentionMs must be a positive integer',\n );\n assert(\n Number.isSafeInteger(opts.batchRows) && opts.batchRows > 0,\n 'sqliteChangeLogPurgeBatchRows must be a positive integer',\n );\n this.#lc = lc.withContext('component', 'sqlite-change-log-purge');\n this.#connection = connection;\n this.#subscriberAcks = subscriberAcks;\n this.#opts = opts;\n this.#now = opts.now ?? Date.now;\n this.#setTimeoutFn = opts.setTimeoutFn ?? setTimeout;\n this.#clearTimeoutFn = opts.clearTimeoutFn ?? clearTimeout;\n this.#yield =\n opts.yieldFn ??\n (() => new Promise<void>(resolve => setImmediate(resolve)));\n }\n\n /**\n * Runs one purge pass for the current durable cleanup `floor`.\n * Never rejects; a failed pass is logged and reported as `stopped: 'error'`.\n */\n async purge(floor: string): Promise<PurgePassResult> {\n if (this.#stopped) {\n return this.#skipped(floor, 'stopped');\n }\n try {\n return await this.#runPass(floor);\n } catch (e) {\n this.#lc.warn?.('error purging the SQLite change log', e);\n this.#declines.add(1, {reason: 'error'});\n return this.#skipped(floor, 'error', 'deferred');\n }\n }\n\n /**\n * Pauses purging while `taskID` holds a snapshot reservation. The returned\n * promise resolves once no purge batch is in flight, i.e. once the bounds a\n * backup monitor is about to advertise cannot be invalidated by a batch\n * that had already been dispatched.\n */\n pause(taskID: string): Promise<void> {\n this.#pausedBy.set(taskID, (this.#pausedBy.get(taskID) ?? 0) + 1);\n return this.#lock.withLock(() => {});\n }\n\n /**\n * Ends one of `taskID`'s pauses. A later coordinator pass can purge once\n * every reservation has ended. Unknown `taskID`s are ignored.\n */\n resume(taskID: string): void {\n const count = this.#pausedBy.get(taskID);\n if (count === undefined) {\n return;\n }\n if (count > 1) {\n this.#pausedBy.set(taskID, count - 1);\n return;\n }\n this.#pausedBy.delete(taskID);\n }\n\n /**\n * Notes that the writer's open transaction has ended, waking any batch\n * waiting for a purge window. A missed notification costs one poll\n * interval, never correctness.\n */\n onWriterIdle(): void {\n for (const waiter of this.#writerIdleWaiters) {\n waiter();\n }\n this.#writerIdleWaiters.clear();\n }\n\n stop(): void {\n this.#stopped = true;\n this.onWriterIdle();\n }\n\n async #runPass(floor: string): Promise<PurgePassResult> {\n const startedAt = performance.now();\n let batches = 0;\n let deletedRows = 0;\n let stopped: PurgeStopReason | undefined;\n let db: Database | undefined;\n\n try {\n for (;;) {\n if (this.#stopped) {\n stopped = 'stopped';\n break;\n }\n if (this.#pausedBy.size > 0) {\n stopped = 'paused';\n break;\n }\n const connection = this.#connection();\n if (connection === undefined) {\n db = undefined;\n this.#lc.debug?.(\n 'skipping SQLite change-log purge: the writer has not opened the log',\n );\n stopped = 'writer-unavailable';\n break;\n }\n db = connection;\n if (\n batches >=\n Math.max(\n 1,\n this.#opts.maxBatchesPerPass ?? DEFAULT_MAX_BATCHES_PER_PASS,\n )\n ) {\n stopped = 'batch-limit';\n break;\n }\n if (connection.inTransaction) {\n // Purge windows exist only between the writer's commits (§3.3).\n await this.#awaitWriterIdle();\n continue;\n }\n\n const outcome = await this.#lock.withLock(() => {\n // Everything the wait above established is re-checked under the lock:\n // the writer may have begun another transaction or failed soft while\n // this batch's acquisition was queued behind a registration.\n if (this.#connection() !== connection || connection.inTransaction) {\n return {kind: 'retry'} as const;\n }\n const declined = this.#gate(floor);\n if (declined !== undefined) {\n return {kind: 'declined', reason: declined} as const;\n }\n const result = this.#cacheFor(connection).purger.purgeBatch({\n externalFloor: floor,\n retentionCutoffMs: this.#now() - this.#opts.retentionMs,\n maxRows: this.#opts.batchRows,\n maxDurationMs:\n this.#opts.statementTargetMs ?? DEFAULT_STATEMENT_TARGET_MS,\n });\n return {kind: 'purged', result} as const;\n });\n\n if (outcome.kind === 'retry') {\n continue;\n }\n if (outcome.kind === 'declined') {\n stopped = outcome.reason;\n break;\n }\n batches++;\n deletedRows += outcome.result.deletedRows;\n if (!outcome.result.moreEligible) {\n break;\n }\n // Release the loop — and the guard, released above — so fan-out,\n // catchup, and registrations make progress between batches.\n await this.#yield();\n }\n } catch (e) {\n // Earlier batches committed independently and must remain visible in the\n // result and metrics. Finalize the partial pass, including its floor\n // probe, rather than replacing it with an empty skipped result.\n this.#lc.warn?.('error purging the SQLite change log', e);\n stopped = 'error';\n }\n\n const probe = db === undefined ? undefined : this.#probeFloor(db, floor);\n let continuation: PurgeContinuation | undefined;\n try {\n continuation = this.#continuation(floor, stopped);\n } catch (e) {\n // A connection can fail soft between the pass and this level check. Keep\n // completed batches visible and ask the coordinator to retry later.\n this.#lc.warn?.(\n 'unable to determine whether SQLite change-log purge should continue',\n e,\n );\n continuation = 'deferred';\n stopped ??= 'error';\n }\n if (deletedRows > 0) {\n this.#purgedRows.add(deletedRows);\n }\n if (batches > 0) {\n this.#passBatches.record(batches);\n this.#passDuration.recordMs(performance.now() - startedAt);\n }\n if (stopped !== undefined) {\n this.#declines.add(1, {reason: stopped});\n }\n if (batches > 0 || stopped !== 'writer-unavailable') {\n this.#lc.debug?.('SQLite change-log purge pass', {\n sqliteChangeLogPurge: {\n floor,\n batches,\n deletedRows,\n stopped,\n probe,\n continuation,\n },\n });\n }\n return {floor, batches, deletedRows, stopped, probe, continuation};\n }\n\n /**\n * Reports whether the level-triggered cleanup coordinator should run again.\n * Rows below the floor but inside the retention window require a deferred\n * pass even when no new backup watermark arrives, and so does a head behind\n * the floor: the rows the writer replays into that gap arrive after this\n * check, with no other event armed to reap them.\n */\n #continuation(\n floor: string,\n stopped: PurgeStopReason | undefined,\n ): PurgeContinuation | undefined {\n switch (stopped) {\n case 'batch-limit':\n return 'immediate';\n case 'stopped':\n return undefined;\n case 'paused':\n case 'subscriber-behind':\n case 'writer-unavailable':\n case 'error':\n return 'deferred';\n case undefined: {\n // A read is safe even inside the writer's open transaction; it just\n // observes the uncommitted head, which is never below the floor.\n const db = this.#connection();\n if (db === undefined) {\n return 'deferred';\n }\n const {remains, headBehindFloor} = this.#cacheFor(\n db,\n ).belowFloorRemains.get<{\n remains: number;\n headBehindFloor: number;\n }>({floor});\n return remains === 0 && headBehindFloor === 0 ? undefined : 'deferred';\n }\n }\n }\n\n /**\n * No purge while a connected subscriber is behind the supplied floor. The\n * cleanup coordinator has already provided the reconnect grace period; an\n * empty live set places no additional constraint on the durable floor.\n */\n #gate(floor: string): 'subscriber-behind' | undefined {\n for (const ack of this.#subscriberAcks()) {\n if (ack < floor) {\n return 'subscriber-behind';\n }\n }\n return undefined;\n }\n\n /**\n * The live check on invariant 14 — what makes running this slice dark worth\n * anything: in `write` mode nothing reads the log, so a wrong floor would\n * otherwise stay invisible until the canary rollout two slices later.\n */\n #probeFloor(db: Database, floor: string): FloorProbeOutcome | undefined {\n try {\n const cache = this.#cacheFor(db);\n const row = cache.plan.get<PlanRow>({fromWatermark: floor});\n const {headWatermark, minWatermark} = row;\n if (headWatermark === null || minWatermark === null) {\n return undefined;\n }\n let outcome: FloorProbeOutcome;\n if (floor > headWatermark) {\n outcome = 'ahead';\n } else if (floor < minWatermark || row.boundaryExists === 0) {\n const {seedWatermark} = readChangeLogMeta(db);\n outcome = seedWatermark <= floor ? 'violation' : 'cold-log';\n } else {\n outcome = 'retained';\n }\n this.#floorProbes.add(1, {outcome});\n if (outcome === 'violation') {\n this.#lc.error?.(\n 'SQLite change log purged history at or above the purge floor. A ' +\n 'follower restoring to that watermark would get `too-old` and ' +\n 'take a full restore.',\n {sqliteChangeLogFloorProbe: {floor, ...row}},\n );\n }\n return outcome;\n } catch (e) {\n this.#lc.warn?.('unable to probe the SQLite change-log purge floor', e);\n return undefined;\n }\n }\n\n /** Resolves once the writer's open transaction has committed or rolled back. */\n #awaitWriterIdle(): Promise<void> {\n return new Promise<void>(resolve => {\n let timer: ReturnType<typeof setTimeout> | undefined;\n const waiter = () => {\n if (timer !== undefined) {\n this.#clearTimeoutFn(timer);\n }\n this.#writerIdleWaiters.delete(waiter);\n resolve();\n };\n this.#writerIdleWaiters.add(waiter);\n timer = this.#setTimeoutFn(\n waiter,\n this.#opts.inTransactionPollMs ?? DEFAULT_IN_TRANSACTION_POLL_MS,\n );\n });\n }\n\n /**\n * The per-connection statements, rebuilt if the writer's connection ever\n * changes. The purger's prepared statements survive a reseed — SQLite\n * re-prepares on schema change — but not a different `Database`.\n */\n #cacheFor(db: Database): ConnectionCache {\n if (this.#cache?.db !== db) {\n this.#cache = {\n db,\n purger: new SQLiteChangeLogPurger(db),\n plan: db.prepare(SQLITE_CHANGE_LOG_PLAN_SQL),\n belowFloorRemains: db.prepare(BELOW_FLOOR_REMAINS_SQL),\n };\n }\n return this.#cache;\n }\n\n #skipped(\n floor: string,\n reason: PurgeStopReason,\n continuation?: PurgeContinuation | undefined,\n ): PurgePassResult {\n return {\n floor,\n batches: 0,\n deletedRows: 0,\n stopped: reason,\n probe: undefined,\n continuation,\n };\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"sqlite-change-log-purge-scheduler.js","names":[],"sources":["../../../../../../zero-cache/src/services/change-streamer/sqlite-change-log-purge-scheduler.ts"],"sourcesContent":["import {Lock} from '@rocicorp/lock';\nimport type {LogContext} from '@rocicorp/logger';\nimport {assert} from '../../../../shared/src/asserts.ts';\nimport type {Database, Statement} from '../../../../zqlite/src/db.ts';\nimport {\n getOrCreateCounter,\n getOrCreateLatencyHistogram,\n getOrCreateValueHistogram,\n} from '../../observability/metrics.ts';\nimport {\n CHANGE_LOG_STREAM_TABLE,\n readChangeLogMeta,\n} from '../replicator/change-log-db.ts';\nimport {SQLiteChangeLogPurger} from '../replicator/sqlite-change-log-purger.ts';\nimport type {SQLiteChangeLogCleanupGuard} from './sqlite-change-log-catchup.ts';\nimport {SQLITE_CHANGE_LOG_PLAN_SQL} from './sqlite-change-log-reader.ts';\n\n/**\n * The duration target for any one purge statement, enforced by the purger's\n * measured-budget feedback. Purge runs on the writer's own\n * connection, in the stream loop's process, so a statement's duration stalls\n * the appending path — and the fan-out behind it — directly. The bench's\n * 100 ms bound was derived for the replicator's write worker; this budget is\n * measured against an event loop that also owes progress to fan-out, catchup,\n * and websocket keepalives, so it is deliberately tighter.\n */\nconst DEFAULT_STATEMENT_TARGET_MS = 20;\n\n/**\n * Batches per pass. This bounds one pass's bookkeeping, not the drain: a pass\n * that hits the limit asks its caller for an immediate continuation.\n */\nconst DEFAULT_MAX_BATCHES_PER_PASS = 100;\n\n/**\n * The backstop poll while waiting for the writer's open transaction to\n * commit. The commit notification normally arrives first; this covers an\n * interrupted stream, whose rollback does not notify.\n */\nconst DEFAULT_IN_TRANSACTION_POLL_MS = 1_000;\n\n/** Why a pass stopped before draining the eligible set. */\nexport type PurgeStopReason =\n // The scheduler was stopped.\n | 'stopped'\n // A snapshot reservation is open.\n | 'paused'\n // The writer has not opened the change log (the file does not exist yet),\n // or has failed soft and deleted it.\n | 'writer-unavailable'\n // A connected subscriber's ACK is behind the floor. Re-checked before every\n // batch so a registration that lands mid-drain is honored by the very next\n // batch.\n | 'subscriber-behind'\n // The per-pass batch limit was reached with history still eligible.\n | 'batch-limit'\n // The pass failed; the error was logged and counted.\n | 'error';\n\n/**\n * The invariant-14 probe's classification of `plan(floor)` after a pass.\n *\n * `too-old` at the floor is not by itself a violation: a log that never held\n * that history — routine at every fork, restore, and rolling restart — returns\n * `too-old` without having purged anything. Only the seed point distinguishes\n * the two, which is why `seedWatermark` is in the v2 meta row:\n *\n * ```\n * violation ⇔ plan(floor) is `too-old` AND seedWatermark <= floor\n * ```\n */\nexport type FloorProbeOutcome =\n // plan(floor) is `range`: the transaction at the floor survives as a\n // restoring follower's catchup boundary.\n | 'retained'\n // The floor is above the log's head, i.e. the log is stale/frozen and the\n // head cap is what held retention. Nothing at the floor was ever purged.\n | 'ahead'\n // plan(floor) is `too-old` but the log was seeded above the floor: it never\n // held that history. Charted, and feeds the fork/resumption reach\n // measurement; not an alert.\n | 'cold-log'\n // plan(floor) is `too-old` and the log demonstrably once held the floor.\n // This is an invariant-14 violation: the only copy a restoring follower\n // could catch up from was purged.\n | 'violation';\n\n/** When the cleanup coordinator should run another pass. */\nexport type PurgeContinuation =\n // The pass hit its batch limit while eligible history remained.\n | 'immediate'\n // Progress depends on time or external state: retention, a subscriber ACK,\n // a reservation, the writer becoming available, or a transient error.\n | 'deferred';\n\nexport type PurgePassResult = {\n /** The external floor the pass used. */\n readonly floor: string;\n readonly batches: number;\n readonly deletedRows: number;\n /** Why the pass ended before draining, if it did. */\n readonly stopped: PurgeStopReason | undefined;\n /** The invariant-14 probe's outcome, when the log was available to probe. */\n readonly probe: FloorProbeOutcome | undefined;\n /** Whether, and how soon, the cleanup coordinator should run another pass. */\n readonly continuation: PurgeContinuation | undefined;\n};\n\nexport type SQLiteChangeLogPurgeSchedulerOptions = {\n /**\n * `sqliteChangeLogRetentionMs`: the minimum retention window, ANDed on top\n * of the external floor. It can only make purge more conservative — the\n * purger never accepts it as a standalone bound.\n */\n readonly retentionMs: number;\n /**\n * `sqliteChangeLogPurgeBatchRows`: the starting chunk size per batch. Not\n * the bound — the statement duration target is.\n */\n readonly batchRows: number;\n readonly statementTargetMs?: number | undefined;\n readonly maxBatchesPerPass?: number | undefined;\n readonly inTransactionPollMs?: number | undefined;\n /** The clock the retention cutoff is measured against. Defaults to `Date.now`. */\n readonly now?: (() => number) | undefined;\n readonly setTimeoutFn?: typeof setTimeout | undefined;\n readonly clearTimeoutFn?: typeof clearTimeout | undefined;\n /**\n * Awaited between batches so fan-out and catchup make progress. Defaults to\n * a macrotask (`setImmediate`), so pending I/O runs before the next batch.\n */\n readonly yieldFn?: (() => Promise<void>) | undefined;\n};\n\ntype ConnectionCache = {\n readonly db: Database;\n readonly purger: SQLiteChangeLogPurger;\n readonly plan: Statement;\n readonly belowFloorRemains: Statement;\n};\n\ntype PlanRow = {\n readonly headWatermark: string | null;\n readonly minWatermark: string | null;\n readonly boundaryExists: number;\n};\n\n/**\n * Whether the cleanup coordinator still owes this floor a pass. Deliberately\n * not the purger's eligibility query, in two ways:\n *\n * - `remains`: rows younger than the retention cutoff are not eligible *yet*,\n * but they will be, and their presence asks the cleanup coordinator for a\n * deferred pass even on a quiet source.\n * - `headBehindFloor`: a head behind the floor means the writer is expected\n * to replay commits below it (e.g. after reconnecting while the backup\n * outran this log). Those rows do not exist to count yet, writer commits\n * only wake an in-flight waiter, and backup monitors never resend an\n * unchanged floor — so reporting quiescence here would disarm cleanup for\n * good. An empty log (no head) defers for the same reason.\n */\nconst BELOW_FLOOR_REMAINS_SQL = /*sql*/ `\n SELECT\n EXISTS (\n SELECT 1 FROM \"${CHANGE_LOG_STREAM_TABLE}\"\n WHERE \"watermark\" < @floor\n AND \"watermark\" < (SELECT max(\"watermark\") FROM \"${CHANGE_LOG_STREAM_TABLE}\")\n ) AS \"remains\",\n coalesce(\n (SELECT max(\"watermark\") FROM \"${CHANGE_LOG_STREAM_TABLE}\") < @floor,\n 1\n ) AS \"headBehindFloor\"\n`;\n\n/**\n * Runs bounded SQLite change-log purge passes on the writer's own connection.\n *\n * The caller supplies the current durable cleanup floor independently of any\n * other change-log implementation and owns the timing of subsequent passes.\n * Within a pass this class drains bounded batches, re-checking live subscriber\n * ACKs before each one and releasing the cleanup guard between them, so a\n * catchup registration or snapshot reservation never waits longer than one\n * batch.\n *\n * Purge windows exist only between the writer's commits: purge shares the\n * writer's connection, so it cannot run inside the writer's open transaction,\n * and a batch that finds one waits for the commit notification instead.\n *\n * The result tells the cleanup coordinator whether the next pass should be\n * immediate or deferred. This class deliberately owns no cleanup timer: the\n * same level-triggered coordinator continues a large drain, advances the\n * retention clock on a quiet source, and retries changing external state.\n */\nexport class SQLiteChangeLogPurgeScheduler {\n readonly #lc: LogContext;\n readonly #connection: () => Database | undefined;\n readonly #subscriberAcks: () => Iterable<string>;\n readonly #opts: SQLiteChangeLogPurgeSchedulerOptions;\n readonly #now: () => number;\n readonly #setTimeoutFn: typeof setTimeout;\n readonly #clearTimeoutFn: typeof clearTimeout;\n readonly #yield: () => Promise<void>;\n\n /**\n * The mutex that serializes purge batches with catchup registration and\n * reservation pauses. It is only ever held across a synchronous section —\n * one batch, or one registration — never across a yield, which is what\n * bounds any waiter to a single batch's duration.\n */\n readonly #lock = new Lock();\n\n /**\n * Open snapshot reservations, reference-counted by taskID. Purge pauses\n * while any is open. Counted rather than a set so that a reservation retry\n * -- whose cancel-then-recreate can resolve its old resume after its new\n * suspend -- still nets out.\n */\n readonly #pausedBy = new Map<string, number>();\n\n /** Waiters for the writer's next commit (or rollback). */\n readonly #writerIdleWaiters = new Set<() => void>();\n\n readonly #purgedRows = getOrCreateCounter(\n 'replica',\n 'sqlite_change_log.purged_rows',\n 'Rows removed from the SQLite change log by the purge scheduler.',\n );\n readonly #passDuration = getOrCreateLatencyHistogram(\n 'replica',\n 'sqlite_change_log.purge_cycle_duration',\n 'Duration of one SQLite change-log purge pass, including the yields ' +\n 'between its batches.',\n );\n readonly #passBatches = getOrCreateValueHistogram(\n 'replica',\n 'sqlite_change_log.purge_cycle_batches',\n {\n description: 'Purge batches run per SQLite change-log purge pass.',\n unit: '{batch}',\n bucketBoundaries: [1, 2, 5, 10, 25, 50, 100, 250],\n },\n );\n readonly #declines = getOrCreateCounter(\n 'replica',\n 'sqlite_change_log.purge_declined',\n 'SQLite change-log purge passes that ended before draining, by reason.',\n );\n readonly #floorProbes = getOrCreateCounter(\n 'replica',\n 'sqlite_change_log.purge_floor_probe',\n 'Invariant-14 probe outcomes: whether the transaction at the purge ' +\n 'floor is still servable after each pass. \"violation\" means history ' +\n 'at or above the floor was purged and alerts; \"cold-log\" means the ' +\n 'log never held it, which is routine at every fork and resumption.',\n );\n\n #cache: ConnectionCache | undefined;\n #stopped = false;\n\n /**\n * The guard that brackets catchup registration: required-head capture and\n * `Forwarder.add()` run under the same mutex as purge batches, so the\n * registered ACK is visible to `Forwarder.getAcks()` before the next batch\n * evaluates its gate.\n */\n readonly cleanupGuard: SQLiteChangeLogCleanupGuard = {\n runWhilePurgeBlocked: register => this.#lock.withLock(register),\n };\n\n /**\n * @param connection the writer's own connection (§3.3). `undefined` until\n * the writer's first reconcile creates the file, and again after the\n * writer fails soft and deletes it — both of which defer the pass rather\n * than fail it, so a later coordinator pass picks up a late-appearing\n * file without a restart.\n * @param subscriberAcks live subscriber ACKs, re-read before every batch so\n * the supplied floor remains safe throughout an asynchronous drain.\n */\n constructor(\n lc: LogContext,\n connection: () => Database | undefined,\n subscriberAcks: () => Iterable<string>,\n opts: SQLiteChangeLogPurgeSchedulerOptions,\n ) {\n assert(\n Number.isSafeInteger(opts.retentionMs) && opts.retentionMs > 0,\n 'sqliteChangeLogRetentionMs must be a positive integer',\n );\n assert(\n Number.isSafeInteger(opts.batchRows) && opts.batchRows > 0,\n 'sqliteChangeLogPurgeBatchRows must be a positive integer',\n );\n this.#lc = lc.withContext('component', 'sqlite-change-log-purge');\n this.#connection = connection;\n this.#subscriberAcks = subscriberAcks;\n this.#opts = opts;\n this.#now = opts.now ?? Date.now;\n this.#setTimeoutFn = opts.setTimeoutFn ?? setTimeout;\n this.#clearTimeoutFn = opts.clearTimeoutFn ?? clearTimeout;\n this.#yield =\n opts.yieldFn ??\n (() => new Promise<void>(resolve => setImmediate(resolve)));\n }\n\n /**\n * Runs one purge pass for the current durable cleanup `floor`.\n * Never rejects; a failed pass is logged and reported as `stopped: 'error'`.\n */\n async purge(floor: string): Promise<PurgePassResult> {\n if (this.#stopped) {\n return this.#skipped(floor, 'stopped');\n }\n try {\n return await this.#runPass(floor);\n } catch (e) {\n this.#lc.warn?.('error purging the SQLite change log', e);\n this.#declines.add(1, {reason: 'error'});\n return this.#skipped(floor, 'error', 'deferred');\n }\n }\n\n /**\n * Pauses purging while `taskID` holds a snapshot reservation. The returned\n * promise resolves once no purge batch is in flight, i.e. once the bounds a\n * backup monitor is about to advertise cannot be invalidated by a batch\n * that had already been dispatched.\n */\n pause(taskID: string): Promise<void> {\n this.#pausedBy.set(taskID, (this.#pausedBy.get(taskID) ?? 0) + 1);\n return this.#lock.withLock(() => {});\n }\n\n /**\n * Ends one of `taskID`'s pauses. A later coordinator pass can purge once\n * every reservation has ended. Unknown `taskID`s are ignored.\n */\n resume(taskID: string): void {\n const count = this.#pausedBy.get(taskID);\n if (count === undefined) {\n return;\n }\n if (count > 1) {\n this.#pausedBy.set(taskID, count - 1);\n return;\n }\n this.#pausedBy.delete(taskID);\n }\n\n /**\n * Notes that the writer's open transaction has ended, waking any batch\n * waiting for a purge window. A missed notification costs one poll\n * interval, never correctness.\n */\n onWriterIdle(): void {\n for (const waiter of this.#writerIdleWaiters) {\n waiter();\n }\n this.#writerIdleWaiters.clear();\n }\n\n stop(): void {\n this.#stopped = true;\n this.onWriterIdle();\n }\n\n async #runPass(floor: string): Promise<PurgePassResult> {\n const startedAt = performance.now();\n let batches = 0;\n let deletedRows = 0;\n let stopped: PurgeStopReason | undefined;\n let db: Database | undefined;\n\n try {\n for (;;) {\n if (this.#stopped) {\n stopped = 'stopped';\n break;\n }\n if (this.#pausedBy.size > 0) {\n stopped = 'paused';\n break;\n }\n const connection = this.#connection();\n if (connection === undefined) {\n db = undefined;\n this.#lc.debug?.(\n 'skipping SQLite change-log purge: the writer has not opened the log',\n );\n stopped = 'writer-unavailable';\n break;\n }\n db = connection;\n if (\n batches >=\n Math.max(\n 1,\n this.#opts.maxBatchesPerPass ?? DEFAULT_MAX_BATCHES_PER_PASS,\n )\n ) {\n stopped = 'batch-limit';\n break;\n }\n if (connection.inTransaction) {\n // Purge windows exist only between the writer's commits (§3.3).\n await this.#awaitWriterIdle();\n continue;\n }\n\n const outcome = await this.#lock.withLock(() => {\n // Everything the wait above established is re-checked under the lock:\n // the writer may have begun another transaction or failed soft while\n // this batch's acquisition was queued behind a registration.\n if (this.#connection() !== connection || connection.inTransaction) {\n return {kind: 'retry'} as const;\n }\n const declined = this.#gate(floor);\n if (declined !== undefined) {\n return {kind: 'declined', reason: declined} as const;\n }\n const result = this.#cacheFor(connection).purger.purgeBatch({\n externalFloor: floor,\n retentionCutoffMs: this.#now() - this.#opts.retentionMs,\n maxRows: this.#opts.batchRows,\n maxDurationMs:\n this.#opts.statementTargetMs ?? DEFAULT_STATEMENT_TARGET_MS,\n });\n return {kind: 'purged', result} as const;\n });\n\n if (outcome.kind === 'retry') {\n continue;\n }\n if (outcome.kind === 'declined') {\n stopped = outcome.reason;\n break;\n }\n batches++;\n deletedRows += outcome.result.deletedRows;\n if (!outcome.result.moreEligible) {\n break;\n }\n // Release the loop — and the guard, released above — so fan-out,\n // catchup, and registrations make progress between batches.\n await this.#yield();\n }\n } catch (e) {\n // Earlier batches committed independently and must remain visible in the\n // result and metrics. Finalize the partial pass, including its floor\n // probe, rather than replacing it with an empty skipped result.\n this.#lc.warn?.('error purging the SQLite change log', e);\n stopped = 'error';\n }\n\n const probe = db === undefined ? undefined : this.#probeFloor(db, floor);\n let continuation: PurgeContinuation | undefined;\n try {\n continuation = this.#continuation(floor, stopped);\n } catch (e) {\n // A connection can fail soft between the pass and this level check. Keep\n // completed batches visible and ask the coordinator to retry later.\n this.#lc.warn?.(\n 'unable to determine whether SQLite change-log purge should continue',\n e,\n );\n continuation = 'deferred';\n stopped ??= 'error';\n }\n if (deletedRows > 0) {\n this.#purgedRows.add(deletedRows);\n }\n if (batches > 0) {\n this.#passBatches.record(batches);\n this.#passDuration.recordMs(performance.now() - startedAt);\n }\n if (stopped !== undefined) {\n this.#declines.add(1, {reason: stopped});\n }\n if (batches > 0 || stopped !== 'writer-unavailable') {\n this.#lc.debug?.('SQLite change-log purge pass', {\n sqliteChangeLogPurge: {\n floor,\n batches,\n deletedRows,\n stopped,\n probe,\n continuation,\n },\n });\n }\n return {floor, batches, deletedRows, stopped, probe, continuation};\n }\n\n /**\n * Reports whether the level-triggered cleanup coordinator should run again.\n * Rows below the floor but inside the retention window require a deferred\n * pass even when no new backup watermark arrives, and so does a head behind\n * the floor: the rows the writer replays into that gap arrive after this\n * check, with no other event armed to reap them.\n */\n #continuation(\n floor: string,\n stopped: PurgeStopReason | undefined,\n ): PurgeContinuation | undefined {\n switch (stopped) {\n case 'batch-limit':\n return 'immediate';\n case 'stopped':\n return undefined;\n case 'paused':\n case 'subscriber-behind':\n case 'writer-unavailable':\n case 'error':\n return 'deferred';\n case undefined: {\n // A read is safe even inside the writer's open transaction; it just\n // observes the uncommitted head, which is never below the floor.\n const db = this.#connection();\n if (db === undefined) {\n return 'deferred';\n }\n const {remains, headBehindFloor} = this.#cacheFor(\n db,\n ).belowFloorRemains.get<{\n remains: number;\n headBehindFloor: number;\n }>({floor});\n return remains === 0 && headBehindFloor === 0 ? undefined : 'deferred';\n }\n }\n }\n\n /**\n * No purge while a connected subscriber is behind the supplied floor. The\n * cleanup coordinator has already provided the reconnect grace period; an\n * empty live set places no additional constraint on the durable floor.\n */\n #gate(floor: string): 'subscriber-behind' | undefined {\n for (const ack of this.#subscriberAcks()) {\n if (ack < floor) {\n return 'subscriber-behind';\n }\n }\n return undefined;\n }\n\n /**\n * The live check on invariant 14 — what makes running this slice dark worth\n * anything: in `write` mode nothing reads the log, so a wrong floor would\n * otherwise stay invisible until the canary rollout two slices later.\n */\n #probeFloor(db: Database, floor: string): FloorProbeOutcome | undefined {\n try {\n const cache = this.#cacheFor(db);\n const row = cache.plan.get<PlanRow>({fromWatermark: floor});\n const {headWatermark, minWatermark} = row;\n if (headWatermark === null || minWatermark === null) {\n return undefined;\n }\n let outcome: FloorProbeOutcome;\n if (floor > headWatermark) {\n outcome = 'ahead';\n } else if (floor < minWatermark || row.boundaryExists === 0) {\n const {seedWatermark} = readChangeLogMeta(db);\n outcome = seedWatermark <= floor ? 'violation' : 'cold-log';\n } else {\n outcome = 'retained';\n }\n this.#floorProbes.add(1, {outcome});\n if (outcome === 'violation') {\n this.#lc.error?.(\n 'SQLite change log purged history at or above the purge floor. A ' +\n 'follower restoring to that watermark would get `too-old` and ' +\n 'take a full restore.',\n {sqliteChangeLogFloorProbe: {floor, ...row}},\n );\n }\n return outcome;\n } catch (e) {\n this.#lc.warn?.('unable to probe the SQLite change-log purge floor', e);\n return undefined;\n }\n }\n\n /** Resolves once the writer's open transaction has committed or rolled back. */\n #awaitWriterIdle(): Promise<void> {\n return new Promise<void>(resolve => {\n let timer: ReturnType<typeof setTimeout> | undefined;\n const waiter = () => {\n if (timer !== undefined) {\n this.#clearTimeoutFn(timer);\n }\n this.#writerIdleWaiters.delete(waiter);\n resolve();\n };\n this.#writerIdleWaiters.add(waiter);\n timer = this.#setTimeoutFn(\n waiter,\n this.#opts.inTransactionPollMs ?? DEFAULT_IN_TRANSACTION_POLL_MS,\n );\n });\n }\n\n /**\n * The per-connection statements, rebuilt if the writer's connection ever\n * changes. The purger's prepared statements survive a reseed — SQLite\n * re-prepares on schema change — but not a different `Database`.\n */\n #cacheFor(db: Database): ConnectionCache {\n if (this.#cache?.db !== db) {\n this.#cache = {\n db,\n purger: new SQLiteChangeLogPurger(db),\n plan: db.prepare(SQLITE_CHANGE_LOG_PLAN_SQL),\n belowFloorRemains: db.prepare(BELOW_FLOOR_REMAINS_SQL),\n };\n }\n return this.#cache;\n }\n\n #skipped(\n floor: string,\n reason: PurgeStopReason,\n continuation?: PurgeContinuation | undefined,\n ): PurgePassResult {\n return {\n floor,\n batches: 0,\n deletedRows: 0,\n stopped: reason,\n probe: undefined,\n continuation,\n };\n }\n}\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sqlite-change-log-read-router.js","names":["#shard","#readPercent","#coldReadPercent","#retentionMs","#inspect","#failureCooldownMs","#now","#pins","#choose","#asPinned","#permanentlyDisabled","#breakerUntilMs"],"sources":["../../../../../../zero-cache/src/services/change-streamer/sqlite-change-log-read-router.ts"],"sourcesContent":["import type {LogContext} from '@rocicorp/logger';\nimport {Database} from '../../../../zqlite/src/db.ts';\nimport type {ShardID} from '../../types/shards.ts';\nimport {\n CHANGE_LOG_DB_SCHEMA_VERSION,\n readChangeLogBounds,\n readChangeLogMeta,\n type ChangeLogIdentity,\n} from '../replicator/change-log-db.ts';\nimport {isSampledForShard} from './shard-sampling.ts';\n\nexport type ChangeLogReadSource = 'pg' | 'sqlite';\n\nexport type SQLiteChangeLogCoverage = {\n readonly seededAtMs: number;\n readonly seedWatermark: string;\n readonly minWatermark: string;\n readonly headWatermark: string;\n};\n\nexport type ChangeLogReadRouteReason =\n | 'selected'\n // Selected while the log was still inside its warm-up window. Kept\n // distinct from 'selected' so bootstrap serving is separable in metrics\n // from steady-state serving.\n | 'selected-cold'\n | 'percentage'\n | 'cold-log'\n | 'log-unavailable'\n | 'breaker-open'\n // Pinned to SQLite, then demoted because the log could not cover the\n // backup the reservation is restoring from. See {@link\n // SQLiteChangeLogReadRouter.demote}.\n | 'backup-uncovered';\n\nexport type ChangeLogReadRoute = {\n readonly source: ChangeLogReadSource;\n readonly reason: ChangeLogReadRouteReason;\n readonly pinned?: boolean | undefined;\n readonly coverage?: SQLiteChangeLogCoverage | undefined;\n};\n\nexport type SQLiteChangeLogReadRouterOptions = {\n readonly shard: ShardID;\n readonly readPercent: number;\n /**\n * Percentage of eligible tasks served from a log that has not yet aged\n * through {@link retentionMs}. Defaults to zero, which keeps every such\n * task on PG.\n */\n readonly coldReadPercent?: number | undefined;\n readonly retentionMs: number;\n readonly inspect: () => SQLiteChangeLogCoverage | undefined;\n readonly failureCooldownMs?: number | undefined;\n readonly now?: (() => number) | undefined;\n};\n\nconst DEFAULT_FAILURE_COOLDOWN_MS = 30_000;\n\n/**\n * Selects and pins the catchup source shared by `/snapshot` and `/changes`.\n *\n * A post-registration SQLite failure opens a process-local breaker. Requests\n * use PG during the cooldown; the first request after it expires is the\n * bounded probe. A successful readiness inspection closes the breaker, while\n * another unavailable result rearms it. A writer failure opens it permanently\n * because the writer remains disabled and its file stays absent for the life\n * of the process.\n */\nexport class SQLiteChangeLogReadRouter {\n readonly #shard: ShardID;\n readonly #readPercent: number;\n readonly #coldReadPercent: number;\n readonly #retentionMs: number;\n readonly #inspect: () => SQLiteChangeLogCoverage | undefined;\n readonly #failureCooldownMs: number;\n readonly #now: () => number;\n readonly #pins = new Map<string, ChangeLogReadRoute>();\n\n #breakerUntilMs: number | undefined;\n #permanentlyDisabled = false;\n\n constructor(opts: SQLiteChangeLogReadRouterOptions) {\n this.#shard = opts.shard;\n this.#readPercent = opts.readPercent;\n this.#coldReadPercent = opts.coldReadPercent ?? 0;\n this.#retentionMs = opts.retentionMs;\n this.#inspect = opts.inspect;\n this.#failureCooldownMs =\n opts.failureCooldownMs ?? DEFAULT_FAILURE_COOLDOWN_MS;\n this.#now = opts.now ?? Date.now;\n }\n\n /** Chooses and pins a source for a snapshot reservation. */\n pin(taskID: string): ChangeLogReadRoute {\n const route = this.#choose(taskID);\n this.#pins.set(taskID, route);\n return route;\n }\n\n /** Returns the source pinned by an active snapshot reservation. */\n peek(taskID: string): ChangeLogReadRoute | undefined {\n const route = this.#pins.get(taskID);\n return route && this.#asPinned(route);\n }\n\n /** Consumes a snapshot pin, or makes the stable choice for a direct retry. */\n consume(taskID: string): ChangeLogReadRoute {\n const route = this.#pins.get(taskID);\n if (route === undefined) {\n return this.#choose(taskID);\n }\n this.#pins.delete(taskID);\n return this.#asPinned(route);\n }\n\n /** Releases a pin whose snapshot reservation ended before subscribing. */\n release(taskID: string): void {\n this.#pins.delete(taskID);\n }\n\n /**\n * Replaces a pinned SQLite route with PG, for a reservation whose backup\n * the log cannot cover -- most often a log seeded after that backup.\n *\n * The pin moves, not just the reservation's advertised bounds. Confirming\n * with PG bounds while the task's `/changes` request still resolves to\n * SQLite is exactly the mismatch the reservation path exists to prevent,\n * so the two have to change together.\n *\n * A task with no pin is left alone: `consume` makes it a fresh choice.\n */\n demote(taskID: string): ChangeLogReadRoute {\n const route: ChangeLogReadRoute = {\n source: 'pg',\n reason: 'backup-uncovered',\n };\n if (this.#pins.has(taskID)) {\n this.#pins.set(taskID, route);\n }\n return route;\n }\n\n /** Opens the post-registration failure breaker. */\n trip(permanent = false): void {\n if (permanent) {\n this.#permanentlyDisabled = true;\n this.#breakerUntilMs = undefined;\n return;\n }\n if (!this.#permanentlyDisabled) {\n this.#breakerUntilMs = this.#now() + this.#failureCooldownMs;\n }\n }\n\n #choose(taskID: string): ChangeLogReadRoute {\n const now = this.#now();\n if (\n this.#permanentlyDisabled ||\n (this.#breakerUntilMs !== undefined && now < this.#breakerUntilMs)\n ) {\n return {source: 'pg', reason: 'breaker-open'};\n }\n\n // Inspect eligibility even at readPercent=0. Slice 11 starts its rollout\n // at zero specifically so warm/cold/unavailable rates are visible before\n // any subscriber is routed to SQLite.\n let coverage: SQLiteChangeLogCoverage | undefined;\n try {\n coverage = this.#inspect();\n } catch {\n coverage = undefined;\n }\n if (coverage === undefined) {\n // This was a breaker probe rather than ordinary startup unavailability.\n // Rearm it so a busy retry loop does not turn into an open/read loop.\n if (this.#breakerUntilMs !== undefined) {\n this.#breakerUntilMs = now + this.#failureCooldownMs;\n }\n return {source: 'pg', reason: 'log-unavailable'};\n }\n // A successful probe restores eligibility. This happens before the gates\n // below so that declining a task -- for a cold log or for the percentage\n // -- does not leave the cooldown armed against the next ordinary\n // unavailability.\n this.#breakerUntilMs = undefined;\n // A log seeded less than a retention window ago holds less history than\n // the purger promises to keep, so it is the case that most often cannot\n // cover a follower. It is also the only path that has no PG to fall back\n // to once PG is retired, which is the argument for exercising it now\n // rather than meeting it at the cutover: `coldReadPercent` is that dial.\n const cold = now - coverage.seededAtMs < this.#retentionMs;\n if (\n cold &&\n !isSampledForShard(this.#shard, taskID, this.#coldReadPercent)\n ) {\n return {source: 'pg', reason: 'cold-log', coverage};\n }\n\n // Keeping the percentage decision after readiness makes zero-percent\n // rollouts emit eligibility metrics without serving reads.\n if (!isSampledForShard(this.#shard, taskID, this.#readPercent)) {\n return {source: 'pg', reason: 'percentage', coverage};\n }\n return {\n source: 'sqlite',\n reason: cold ? 'selected-cold' : 'selected',\n coverage,\n };\n }\n\n #asPinned(route: ChangeLogReadRoute): ChangeLogReadRoute {\n return {...route, pinned: true};\n }\n}\n\n/**\n * Reads the coverage and validates the v2 schema and RMv2 identity in one\n * short-lived readonly connection. It never creates the log.\n */\nexport function inspectSQLiteChangeLog(\n lc: LogContext,\n changeLogFile: string,\n identity: ChangeLogIdentity,\n): SQLiteChangeLogCoverage | undefined {\n let db: Database | undefined;\n try {\n db = new Database(\n lc.withContext('component', 'sqlite-change-log-read-router'),\n changeLogFile,\n {readonly: true},\n );\n const meta = readChangeLogMeta(db);\n if (\n meta.schemaVersion !== CHANGE_LOG_DB_SCHEMA_VERSION ||\n meta.epoch !== identity.epoch ||\n meta.generation !== identity.generation ||\n meta.replicaID !== identity.replicaID\n ) {\n return undefined;\n }\n const bounds = readChangeLogBounds(db);\n if (bounds.minWatermark === null || bounds.headWatermark === null) {\n return undefined;\n }\n return {\n seededAtMs: meta.seededAtMs,\n seedWatermark: meta.seedWatermark,\n minWatermark: bounds.minWatermark,\n headWatermark: bounds.headWatermark,\n };\n } catch {\n return undefined;\n } finally {\n db?.close();\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"sqlite-change-log-read-router.js","names":[],"sources":["../../../../../../zero-cache/src/services/change-streamer/sqlite-change-log-read-router.ts"],"sourcesContent":["import type {LogContext} from '@rocicorp/logger';\nimport {Database} from '../../../../zqlite/src/db.ts';\nimport type {ShardID} from '../../types/shards.ts';\nimport {\n CHANGE_LOG_DB_SCHEMA_VERSION,\n readChangeLogBounds,\n readChangeLogMeta,\n type ChangeLogIdentity,\n} from '../replicator/change-log-db.ts';\nimport {isSampledForShard} from './shard-sampling.ts';\n\nexport type ChangeLogReadSource = 'pg' | 'sqlite';\n\nexport type SQLiteChangeLogCoverage = {\n readonly seededAtMs: number;\n readonly seedWatermark: string;\n readonly minWatermark: string;\n readonly headWatermark: string;\n};\n\nexport type ChangeLogReadRouteReason =\n | 'selected'\n // Selected while the log was still inside its warm-up window. Kept\n // distinct from 'selected' so bootstrap serving is separable in metrics\n // from steady-state serving.\n | 'selected-cold'\n | 'percentage'\n | 'cold-log'\n | 'log-unavailable'\n | 'breaker-open'\n // Pinned to SQLite, then demoted because the log could not cover the\n // backup the reservation is restoring from. See {@link\n // SQLiteChangeLogReadRouter.demote}.\n | 'backup-uncovered';\n\nexport type ChangeLogReadRoute = {\n readonly source: ChangeLogReadSource;\n readonly reason: ChangeLogReadRouteReason;\n readonly pinned?: boolean | undefined;\n readonly coverage?: SQLiteChangeLogCoverage | undefined;\n};\n\nexport type SQLiteChangeLogReadRouterOptions = {\n readonly shard: ShardID;\n readonly readPercent: number;\n /**\n * Percentage of eligible tasks served from a log that has not yet aged\n * through {@link retentionMs}. Defaults to zero, which keeps every such\n * task on PG.\n */\n readonly coldReadPercent?: number | undefined;\n readonly retentionMs: number;\n readonly inspect: () => SQLiteChangeLogCoverage | undefined;\n readonly failureCooldownMs?: number | undefined;\n readonly now?: (() => number) | undefined;\n};\n\nconst DEFAULT_FAILURE_COOLDOWN_MS = 30_000;\n\n/**\n * Selects and pins the catchup source shared by `/snapshot` and `/changes`.\n *\n * A post-registration SQLite failure opens a process-local breaker. Requests\n * use PG during the cooldown; the first request after it expires is the\n * bounded probe. A successful readiness inspection closes the breaker, while\n * another unavailable result rearms it. A writer failure opens it permanently\n * because the writer remains disabled and its file stays absent for the life\n * of the process.\n */\nexport class SQLiteChangeLogReadRouter {\n readonly #shard: ShardID;\n readonly #readPercent: number;\n readonly #coldReadPercent: number;\n readonly #retentionMs: number;\n readonly #inspect: () => SQLiteChangeLogCoverage | undefined;\n readonly #failureCooldownMs: number;\n readonly #now: () => number;\n readonly #pins = new Map<string, ChangeLogReadRoute>();\n\n #breakerUntilMs: number | undefined;\n #permanentlyDisabled = false;\n\n constructor(opts: SQLiteChangeLogReadRouterOptions) {\n this.#shard = opts.shard;\n this.#readPercent = opts.readPercent;\n this.#coldReadPercent = opts.coldReadPercent ?? 0;\n this.#retentionMs = opts.retentionMs;\n this.#inspect = opts.inspect;\n this.#failureCooldownMs =\n opts.failureCooldownMs ?? DEFAULT_FAILURE_COOLDOWN_MS;\n this.#now = opts.now ?? Date.now;\n }\n\n /** Chooses and pins a source for a snapshot reservation. */\n pin(taskID: string): ChangeLogReadRoute {\n const route = this.#choose(taskID);\n this.#pins.set(taskID, route);\n return route;\n }\n\n /** Returns the source pinned by an active snapshot reservation. */\n peek(taskID: string): ChangeLogReadRoute | undefined {\n const route = this.#pins.get(taskID);\n return route && this.#asPinned(route);\n }\n\n /** Consumes a snapshot pin, or makes the stable choice for a direct retry. */\n consume(taskID: string): ChangeLogReadRoute {\n const route = this.#pins.get(taskID);\n if (route === undefined) {\n return this.#choose(taskID);\n }\n this.#pins.delete(taskID);\n return this.#asPinned(route);\n }\n\n /** Releases a pin whose snapshot reservation ended before subscribing. */\n release(taskID: string): void {\n this.#pins.delete(taskID);\n }\n\n /**\n * Replaces a pinned SQLite route with PG, for a reservation whose backup\n * the log cannot cover -- most often a log seeded after that backup.\n *\n * The pin moves, not just the reservation's advertised bounds. Confirming\n * with PG bounds while the task's `/changes` request still resolves to\n * SQLite is exactly the mismatch the reservation path exists to prevent,\n * so the two have to change together.\n *\n * A task with no pin is left alone: `consume` makes it a fresh choice.\n */\n demote(taskID: string): ChangeLogReadRoute {\n const route: ChangeLogReadRoute = {\n source: 'pg',\n reason: 'backup-uncovered',\n };\n if (this.#pins.has(taskID)) {\n this.#pins.set(taskID, route);\n }\n return route;\n }\n\n /** Opens the post-registration failure breaker. */\n trip(permanent = false): void {\n if (permanent) {\n this.#permanentlyDisabled = true;\n this.#breakerUntilMs = undefined;\n return;\n }\n if (!this.#permanentlyDisabled) {\n this.#breakerUntilMs = this.#now() + this.#failureCooldownMs;\n }\n }\n\n #choose(taskID: string): ChangeLogReadRoute {\n const now = this.#now();\n if (\n this.#permanentlyDisabled ||\n (this.#breakerUntilMs !== undefined && now < this.#breakerUntilMs)\n ) {\n return {source: 'pg', reason: 'breaker-open'};\n }\n\n // Inspect eligibility even at readPercent=0. Slice 11 starts its rollout\n // at zero specifically so warm/cold/unavailable rates are visible before\n // any subscriber is routed to SQLite.\n let coverage: SQLiteChangeLogCoverage | undefined;\n try {\n coverage = this.#inspect();\n } catch {\n coverage = undefined;\n }\n if (coverage === undefined) {\n // This was a breaker probe rather than ordinary startup unavailability.\n // Rearm it so a busy retry loop does not turn into an open/read loop.\n if (this.#breakerUntilMs !== undefined) {\n this.#breakerUntilMs = now + this.#failureCooldownMs;\n }\n return {source: 'pg', reason: 'log-unavailable'};\n }\n // A successful probe restores eligibility. This happens before the gates\n // below so that declining a task -- for a cold log or for the percentage\n // -- does not leave the cooldown armed against the next ordinary\n // unavailability.\n this.#breakerUntilMs = undefined;\n // A log seeded less than a retention window ago holds less history than\n // the purger promises to keep, so it is the case that most often cannot\n // cover a follower. It is also the only path that has no PG to fall back\n // to once PG is retired, which is the argument for exercising it now\n // rather than meeting it at the cutover: `coldReadPercent` is that dial.\n const cold = now - coverage.seededAtMs < this.#retentionMs;\n if (\n cold &&\n !isSampledForShard(this.#shard, taskID, this.#coldReadPercent)\n ) {\n return {source: 'pg', reason: 'cold-log', coverage};\n }\n\n // Keeping the percentage decision after readiness makes zero-percent\n // rollouts emit eligibility metrics without serving reads.\n if (!isSampledForShard(this.#shard, taskID, this.#readPercent)) {\n return {source: 'pg', reason: 'percentage', coverage};\n }\n return {\n source: 'sqlite',\n reason: cold ? 'selected-cold' : 'selected',\n coverage,\n };\n }\n\n #asPinned(route: ChangeLogReadRoute): ChangeLogReadRoute {\n return {...route, pinned: true};\n }\n}\n\n/**\n * Reads the coverage and validates the v2 schema and RMv2 identity in one\n * short-lived readonly connection. It never creates the log.\n */\nexport function inspectSQLiteChangeLog(\n lc: LogContext,\n changeLogFile: string,\n identity: ChangeLogIdentity,\n): SQLiteChangeLogCoverage | undefined {\n let db: Database | undefined;\n try {\n db = new Database(\n lc.withContext('component', 'sqlite-change-log-read-router'),\n changeLogFile,\n {readonly: true},\n );\n const meta = readChangeLogMeta(db);\n if (\n meta.schemaVersion !== CHANGE_LOG_DB_SCHEMA_VERSION ||\n meta.epoch !== identity.epoch ||\n meta.generation !== identity.generation ||\n meta.replicaID !== identity.replicaID\n ) {\n return undefined;\n }\n const bounds = readChangeLogBounds(db);\n if (bounds.minWatermark === null || bounds.headWatermark === null) {\n return undefined;\n }\n return {\n seededAtMs: meta.seededAtMs,\n seedWatermark: meta.seedWatermark,\n minWatermark: bounds.minWatermark,\n headWatermark: bounds.headWatermark,\n };\n } catch {\n return undefined;\n } finally {\n db?.close();\n }\n}\n"],"mappings":"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"}
|
|
@@ -159,6 +159,6 @@ var SQLiteChangeLogReader = class {
|
|
|
159
159
|
}
|
|
160
160
|
};
|
|
161
161
|
//#endregion
|
|
162
|
-
export { SQLITE_CHANGE_LOG_BOUNDARY_SQL, SQLITE_CHANGE_LOG_PLAN_SQL, SQLiteChangeLogReader };
|
|
162
|
+
export { SQLITE_CHANGE_LOG_BOUNDARY_SQL, SQLITE_CHANGE_LOG_PLAN_SQL, SQLITE_CHANGE_LOG_READ_BATCH_SQL, SQLiteChangeLogReader };
|
|
163
163
|
|
|
164
164
|
//# sourceMappingURL=sqlite-change-log-reader.js.map
|
|
@@ -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/change-log-db.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 \"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 \"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 maxRows?: number | 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 assert(\n maxRows === undefined || (Number.isSafeInteger(maxRows) && maxRows > 0),\n 'SQLite change log row limit 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 let rowsRead = 0;\n\n while (true) {\n this.#throwIfAborted(signal);\n const remainingRows =\n maxRows === undefined ? batchSize : maxRows - rowsRead;\n if (remainingRows === 0) {\n break;\n }\n const rows = statements.readBatch.all<ChangeLogRow>(\n lastWatermark,\n lastPos,\n throughWatermark,\n Math.min(batchSize, remainingRows),\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 rowsRead += rows.length;\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 // A read that stopped at `maxRows` cannot know whether more rows follow,\n // so completeness is only asserted for a read that ran out on its own.\n // Detecting a short bounded read is left to the caller, which set the\n // limit and is the only side that knows what a full range should hold.\n if (\n fromWatermark !== throughWatermark &&\n (maxRows === undefined || rowsRead < maxRows)\n ) {\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":"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"}
|
|
1
|
+
{"version":3,"file":"sqlite-change-log-reader.js","names":[],"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/change-log-db.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 \"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 \"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 maxRows?: number | 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 assert(\n maxRows === undefined || (Number.isSafeInteger(maxRows) && maxRows > 0),\n 'SQLite change log row limit 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 let rowsRead = 0;\n\n while (true) {\n this.#throwIfAborted(signal);\n const remainingRows =\n maxRows === undefined ? batchSize : maxRows - rowsRead;\n if (remainingRows === 0) {\n break;\n }\n const rows = statements.readBatch.all<ChangeLogRow>(\n lastWatermark,\n lastPos,\n throughWatermark,\n Math.min(batchSize, remainingRows),\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 rowsRead += rows.length;\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 // A read that stopped at `maxRows` cannot know whether more rows follow,\n // so completeness is only asserted for a read that ran out on its own.\n // Detecting a short bounded read is left to the caller, which set the\n // limit and is the only side that knows what a full range should hold.\n if (\n fromWatermark !== throughWatermark &&\n (maxRows === undefined || rowsRead < maxRows)\n ) {\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":"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"}
|
|
@@ -198,9 +198,7 @@ var SQLiteChangeLogWriter = class {
|
|
|
198
198
|
case "rollback":
|
|
199
199
|
writer.rollback();
|
|
200
200
|
break;
|
|
201
|
-
default:
|
|
202
|
-
writer.append(json, msg, storedChange);
|
|
203
|
-
break;
|
|
201
|
+
default: writer.append(json, msg, storedChange);
|
|
204
202
|
}
|
|
205
203
|
observer?.messageProcessed(change, commit, startedAt === void 0 ? void 0 : performance.now() - startedAt);
|
|
206
204
|
if (commit !== null && commit.stats.cookieMutations.length > 0) observer?.cookiesMutated(commit.stats.cookieMutations, readCookieRowCounts(db));
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sqlite-change-log-writer.js","names":["#lc","#opts","#now","#shadowValidationPercent","#disabled","#db","#observer","#reconcile","#resumeFromLog","#writer","#failSoft","#validateTransaction","#isSelectedForShadowValidation"],"sources":["../../../../../../zero-cache/src/services/change-streamer/sqlite-change-log-writer.ts"],"sourcesContent":["import type {LogContext} from '@rocicorp/logger';\nimport {SqliteError} from '@rocicorp/zero-sqlite3';\nimport {assert} from '../../../../shared/src/asserts.ts';\nimport {h32} from '../../../../shared/src/hash.ts';\nimport {must} from '../../../../shared/src/must.ts';\nimport type {Database} from '../../../../zqlite/src/db.ts';\nimport {\n isSQLiteCorruption,\n logSQLiteCorruptionDiagnostics,\n} from '../../db/sqlite-corruption.ts';\nimport {StatementRunner} from '../../db/statements.ts';\nimport type {ChangeStreamData} from '../change-source/protocol/current/downstream.ts';\nimport {\n readCookieRowCounts,\n readCookies,\n} from '../replicator/change-log-cookies.ts';\nimport {\n ChangeLogRebuildRequired,\n changeLogFileName,\n changeLogWipeReason,\n deleteChangeLogDB,\n openChangeLogDBForWriting,\n readChangeLogHead,\n rebuildChangeLogDBForWriting,\n reconcileChangeLog,\n type ChangeLogAnchor,\n type ChangeLogIdentity,\n type ChangeLogResumePoint,\n type ReconcileResult,\n} from '../replicator/change-log-db.ts';\nimport {ChangeLogStreamWriter} from '../replicator/change-log-stream-writer.ts';\nimport {\n getSQLiteChangeLogInfo,\n logSQLiteChangeLogStartup,\n recordSQLiteChangeLogReconcile,\n SQLiteChangeLogObserver,\n type ChangeLogCommit,\n type SQLiteChangeLogObservabilityState,\n} from '../replicator/sqlite-change-log-observability.ts';\nimport {extractChangeSubstring} from './change-log-codec.ts';\n\nexport type SQLiteChangeLogWriterOptions = {\n /** The replica file the log is named after, i.e. `${replicaFile}-change-log`. */\n replicaFile: string;\n /** The replica whose contents this log belongs to. */\n identity: ChangeLogIdentity;\n /**\n * Called after each transaction's log commit, with the log's new head. This\n * is what releases the catchup barrier: after the writer moved in-process,\n * the head advances here rather than at a subscriber's ACK.\n */\n onCommit?: ((watermark: string) => void) | undefined;\n /**\n * Called when the writer disables itself and deletes the file. The reader is\n * cached on the service, so a delete that leaves it open would leave an\n * in-process reader serving an unlinked inode while every new open sees\n * nothing.\n */\n onDisabled?: (() => void) | undefined;\n /** Called when reconciliation replaces the file, to close cached readers. */\n onRebuilt?: (() => void) | undefined;\n /** Overridable for tests. */\n now?: (() => number) | undefined;\n /**\n * Percentage of transactions that retain the rollout-only per-message\n * timing and source-vs-stored hash validation. Defaults to 1.\n */\n shadowValidationPercent?: number | undefined;\n};\n\nconst DEFAULT_SHADOW_VALIDATION_PERCENT = 1;\n\n/**\n * Appends the canonical change stream to the SQLite change log, from the\n * change-streamer's own stream loop.\n *\n * ### Ordering\n *\n * The log's commit for a transaction precedes the forward of that transaction's\n * `commit` message, and nothing `await`s in between. Everything that can\n * advance the watermark the stream would resume from — the storer's Postgres\n * commit, `lastWatermark`, the upstream ACK — runs strictly after it, so the\n * log's head is always at or above the resume point. A *hole* is therefore\n * unreachable at any crash timing, and a *phantom* is the normal state, which is\n * what makes truncate-above the whole of reconciliation.\n *\n * ### Fail-soft\n *\n * The log is a catchup cache: nothing in it is not either already durable in the\n * replica's litestream backup or re-derivable from the replication slot. A write\n * failure must therefore not stop replication for the shard. Corruption is\n * rebuilt once at open; any other write error — a full disk, an I/O error, a\n * permissions problem — logs, disables the writer, deletes the file, closes the\n * reader, and lets the stream continue. The cost is catchup reach, and reach is\n * recoverable: `too-old` sends a subscriber to a restore.\n *\n * One carve-out keeps that from hiding the bug class this topology introduces. A\n * missed truncate-above surfaces as `SQLITE_CONSTRAINT` on the writer's plain\n * `INSERT`, which is \"any write error other than corruption\" and would be\n * absorbed quietly. It is counted as an invariant failure and alerted on before\n * failing soft.\n */\nexport class SQLiteChangeLogWriter {\n readonly #lc: LogContext;\n readonly #opts: SQLiteChangeLogWriterOptions;\n readonly #now: () => number;\n readonly #shadowValidationPercent: number;\n\n #db: Database | undefined;\n #writer: ChangeLogStreamWriter | undefined;\n #observer: SQLiteChangeLogObserver | undefined;\n #disabled = false;\n #validateTransaction = false;\n\n constructor(lc: LogContext, opts: SQLiteChangeLogWriterOptions) {\n this.#lc = lc.withContext('component', 'sqlite-change-log-writer');\n this.#opts = opts;\n this.#now = opts.now ?? Date.now;\n this.#shadowValidationPercent =\n opts.shadowValidationPercent ?? DEFAULT_SHADOW_VALIDATION_PERCENT;\n assert(\n Number.isInteger(this.#shadowValidationPercent) &&\n this.#shadowValidationPercent >= 0 &&\n this.#shadowValidationPercent <= 100,\n 'SQLite change-log shadow validation percentage must be an integer between 0 and 100',\n );\n }\n\n /** False once the writer has failed soft, or after {@link close}. */\n get enabled(): boolean {\n return !this.#disabled;\n }\n\n /**\n * The writer's own connection, which is the one purge runs on (§3.3): one\n * process appends *and* deletes, on one handle, with no second writable\n * connection and no cross-process barrier. `undefined` until the stream\n * loop's first reconcile creates the log, and again once the writer has\n * failed soft and deleted the file — both of which a purge cycle treats as\n * \"skip\", not as an error.\n */\n get connection(): Database | undefined {\n return this.#db;\n }\n\n /** Exposed for tests and for the integration assertions on the metrics. */\n state(): SQLiteChangeLogObservabilityState | undefined {\n return this.#observer?.state();\n }\n\n /**\n * Opens the log if necessary and reconciles it with the Postgres resume\n * point. Returns the resume point stored in the log.\n *\n * Returns `undefined` when the writer is disabled. The caller must then use\n * another source.\n *\n * This runs for every connection because Postgres supplies a new resume point\n * after each reconnect. The log can contain transactions above that point.\n * Reconciliation removes them before Postgres sends them again.\n *\n * `resumeFrom` includes cookies because the cookies must match the resume\n * watermark.\n */\n reconcile(\n resumeFrom: ChangeLogResumePoint,\n ): ChangeLogResumePoint | undefined {\n return this.#reconcile(() => resumeFrom);\n }\n\n /**\n * Reconciles the log without a Postgres resume point.\n *\n * A valid log uses its own head and cookies. A new or invalid log uses the\n * replica values returned by `seed`.\n */\n reconcileFromLog(\n seed: () => ChangeLogResumePoint,\n ): ChangeLogResumePoint | undefined {\n return this.#reconcile(db => this.#resumeFromLog(db, seed));\n }\n\n #reconcile(\n resolve: (db: Database) => ChangeLogResumePoint,\n ): ChangeLogResumePoint | undefined {\n if (this.#disabled) {\n return undefined;\n }\n const {replicaFile, identity} = this.#opts;\n const nowMs = this.#now();\n const anchor = (db: Database): ChangeLogAnchor => ({\n ...resolve(db),\n identity,\n nowMs,\n });\n try {\n const db = this.#db;\n if (db === undefined) {\n const opened = openChangeLogDBForWriting(this.#lc, replicaFile, anchor);\n this.#db = opened.db;\n if (opened.replacedFile) {\n this.#opts.onRebuilt?.();\n }\n recordSQLiteChangeLogReconcile(this.#lc, opened.result);\n const info = getSQLiteChangeLogInfo(opened.db);\n logSQLiteChangeLogStartup(\n this.#lc,\n changeLogFileName(replicaFile),\n info,\n );\n this.#observer = new SQLiteChangeLogObserver(this.#lc, info);\n } else {\n const resolved = anchor(db);\n let result: ReconcileResult;\n try {\n result = reconcileChangeLog(this.#lc, db, resolved, {\n rebuildInsteadOfUnboundedWork: true,\n });\n } catch (e) {\n if (!(e instanceof ChangeLogRebuildRequired)) {\n throw e;\n }\n // Close every handle to the old inode before unlinking it. The writer\n // is reconstructed below against the replacement connection.\n this.#writer = undefined;\n this.#db = undefined;\n db.close();\n this.#opts.onRebuilt?.();\n const rebuilt = rebuildChangeLogDBForWriting(\n this.#lc,\n replicaFile,\n resolved,\n e,\n );\n this.#db = rebuilt.db;\n result = rebuilt.result;\n }\n recordSQLiteChangeLogReconcile(this.#lc, result);\n if (result.action !== 'none') {\n this.#observer?.reconciled(\n getSQLiteChangeLogInfo(\n must(this.#db, 'the SQLite change log is not open'),\n ),\n );\n }\n }\n // A reseed drops and recreates the stream table, so the append statements\n // are prepared fresh against whatever the reconciliation left behind.\n const reconciledDB = must(this.#db, 'the SQLite change log is not open');\n this.#writer = new ChangeLogStreamWriter(\n new StatementRunner(reconciledDB),\n );\n // Read both values after reconciliation. A truncate or reseed updates the\n // head and cookies in the same transaction.\n return {\n resumeWatermark: must(\n readChangeLogHead(reconciledDB),\n 'the SQLite change log has no head after reconciliation',\n ),\n cookies: readCookies(reconciledDB),\n };\n } catch (e) {\n this.#failSoft('reconciling the SQLite change log', e);\n return undefined;\n }\n }\n\n /**\n * Returns the resume point supplied by the log.\n *\n * A valid log uses its own head and cookies. A log that must be recreated uses\n * the replica values returned by `seed`.\n */\n #resumeFromLog(\n db: Database,\n seed: () => ChangeLogResumePoint,\n ): ChangeLogResumePoint {\n if (changeLogWipeReason(db, this.#opts.identity) !== undefined) {\n return seed();\n }\n const head = readChangeLogHead(db);\n // An empty log has no resume point, so use the replica seed.\n return head === null\n ? seed()\n : {resumeWatermark: head, cookies: readCookies(db)};\n }\n\n /**\n * Writes one stream message, committing at transaction boundaries. Never\n * throws: a write failure disables the writer instead of failing the stream.\n */\n write(\n change: ChangeStreamData,\n json: string,\n storedChange = extractChangeSubstring(json, change[1].tag),\n ): void {\n const writer = this.#writer;\n const db = this.#db;\n if (this.#disabled || writer === undefined || db === undefined) {\n return;\n }\n const [type, msg] = change;\n if (type === 'begin') {\n this.#validateTransaction = this.#isSelectedForShadowValidation(\n change[2].commitWatermark,\n );\n }\n const validateTransaction = this.#validateTransaction;\n const observer = this.#observer;\n observer?.messageReceived(change, json, validateTransaction, storedChange);\n const startedAt = validateTransaction ? performance.now() : undefined;\n try {\n let commit: ChangeLogCommit | null = null;\n switch (type) {\n case 'begin':\n writer.begin(\n change[2].commitWatermark,\n json,\n storedChange,\n validateTransaction,\n );\n break;\n case 'commit': {\n const {watermark} = change[2];\n const commitStart = performance.now();\n const stats = writer.commit(\n watermark,\n json,\n this.#now(),\n storedChange,\n );\n commit = {\n watermark,\n stats,\n logCommitMs: performance.now() - commitStart,\n };\n break;\n }\n case 'rollback':\n writer.rollback();\n break;\n default:\n writer.append(json, msg, storedChange);\n break;\n }\n observer?.messageProcessed(\n change,\n commit,\n startedAt === undefined ? undefined : performance.now() - startedAt,\n );\n if (commit !== null && commit.stats.cookieMutations.length > 0) {\n // Only re-counted when a schema change actually moved the cookie jar,\n // which keeps the count off the forward path of every other\n // transaction. The counts are read after the commit, so they are what\n // a reader of the file would see.\n observer?.cookiesMutated(\n commit.stats.cookieMutations,\n readCookieRowCounts(db),\n );\n }\n if (commit !== null) {\n // The head advanced, and it is durable. Releasing the barrier here is\n // what makes its poll interval a backstop rather than the mechanism.\n this.#opts.onCommit?.(commit.watermark);\n }\n if (type === 'commit' || type === 'rollback') {\n this.#validateTransaction = false;\n }\n } catch (e) {\n observer?.messageFailed(\n change,\n e,\n startedAt === undefined ? undefined : performance.now() - startedAt,\n );\n this.#validateTransaction = false;\n if (isConstraintViolation(e)) {\n // Reconciliation should have truncated whatever this collided with.\n // Report it as an invariant failure before failing soft, so that a\n // truncate-above bug cannot look like a lost cache.\n observer?.invariantFailure(\n 'SQLite change-log insert violated a constraint, which means a ' +\n 'transaction above the resume watermark was not truncated',\n {tag: msg.tag, error: String(e)},\n );\n }\n this.#failSoft('writing to the SQLite change log', e);\n }\n }\n\n #isSelectedForShadowValidation(watermark: string): boolean {\n const percent = this.#shadowValidationPercent;\n return (\n percent >= 100 ||\n (percent > 0 &&\n h32(\n `${this.#opts.identity.generation}/${this.#opts.identity.replicaID ?? ''}:${watermark}`,\n ) %\n 100 <\n percent)\n );\n }\n\n /**\n * Discards the open transaction, if any. Called when the change stream is\n * interrupted mid-transaction.\n */\n abort(): void {\n const writer = this.#writer;\n this.#validateTransaction = false;\n if (this.#disabled || writer === undefined) {\n return;\n }\n this.#observer?.abort();\n try {\n writer.rollback();\n } catch (e) {\n this.#failSoft('rolling back the SQLite change log', e);\n }\n }\n\n close(): void {\n this.#disabled = true;\n this.#validateTransaction = false;\n this.#writer = undefined;\n this.#observer = undefined;\n const db = this.#db;\n this.#db = undefined;\n try {\n db?.close();\n } catch (e) {\n this.#lc.warn?.('error closing the SQLite change log', e);\n }\n }\n\n /**\n * Logs, disables the writer, deletes the file, and closes the reader, leaving\n * replication running. Disabling is for the life of the process: a log that\n * failed a write is not re-opened until the task restarts, so the behavior a\n * subscriber sees is the same as `sqliteChangeLogMode=off`.\n */\n #failSoft(what: string, e: unknown): void {\n const file = changeLogFileName(this.#opts.replicaFile);\n if (isSQLiteCorruption(e)) {\n // Open-time corruption is rebuilt by openChangeLogDBForWriting. Reaching\n // here means the file went bad under an open handle, which the same\n // delete-and-reseed answers -- on the next process start rather than now.\n logSQLiteCorruptionDiagnostics(this.#lc, 'change-log', file, e);\n }\n this.#lc.error?.(\n `error ${what}: disabling the writer and deleting ${file}. ` +\n `Replication continues; catchup reach is lost until this task restarts.`,\n e,\n );\n this.close();\n try {\n deleteChangeLogDB(this.#opts.replicaFile);\n } catch (deleteError) {\n this.#lc.error?.(`unable to delete ${file}`, deleteError);\n }\n this.#opts.onDisabled?.();\n }\n}\n\nfunction isConstraintViolation(e: unknown): boolean {\n return e instanceof SqliteError && e.code.startsWith('SQLITE_CONSTRAINT');\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"sqlite-change-log-writer.js","names":[],"sources":["../../../../../../zero-cache/src/services/change-streamer/sqlite-change-log-writer.ts"],"sourcesContent":["import type {LogContext} from '@rocicorp/logger';\nimport {SqliteError} from '@rocicorp/zero-sqlite3';\nimport {assert} from '../../../../shared/src/asserts.ts';\nimport {h32} from '../../../../shared/src/hash.ts';\nimport {must} from '../../../../shared/src/must.ts';\nimport type {Database} from '../../../../zqlite/src/db.ts';\nimport {\n isSQLiteCorruption,\n logSQLiteCorruptionDiagnostics,\n} from '../../db/sqlite-corruption.ts';\nimport {StatementRunner} from '../../db/statements.ts';\nimport type {ChangeStreamData} from '../change-source/protocol/current/downstream.ts';\nimport {\n readCookieRowCounts,\n readCookies,\n} from '../replicator/change-log-cookies.ts';\nimport {\n ChangeLogRebuildRequired,\n changeLogFileName,\n changeLogWipeReason,\n deleteChangeLogDB,\n openChangeLogDBForWriting,\n readChangeLogHead,\n rebuildChangeLogDBForWriting,\n reconcileChangeLog,\n type ChangeLogAnchor,\n type ChangeLogIdentity,\n type ChangeLogResumePoint,\n type ReconcileResult,\n} from '../replicator/change-log-db.ts';\nimport {ChangeLogStreamWriter} from '../replicator/change-log-stream-writer.ts';\nimport {\n getSQLiteChangeLogInfo,\n logSQLiteChangeLogStartup,\n recordSQLiteChangeLogReconcile,\n SQLiteChangeLogObserver,\n type ChangeLogCommit,\n type SQLiteChangeLogObservabilityState,\n} from '../replicator/sqlite-change-log-observability.ts';\nimport {extractChangeSubstring} from './change-log-codec.ts';\n\nexport type SQLiteChangeLogWriterOptions = {\n /** The replica file the log is named after, i.e. `${replicaFile}-change-log`. */\n replicaFile: string;\n /** The replica whose contents this log belongs to. */\n identity: ChangeLogIdentity;\n /**\n * Called after each transaction's log commit, with the log's new head. This\n * is what releases the catchup barrier: after the writer moved in-process,\n * the head advances here rather than at a subscriber's ACK.\n */\n onCommit?: ((watermark: string) => void) | undefined;\n /**\n * Called when the writer disables itself and deletes the file. The reader is\n * cached on the service, so a delete that leaves it open would leave an\n * in-process reader serving an unlinked inode while every new open sees\n * nothing.\n */\n onDisabled?: (() => void) | undefined;\n /** Called when reconciliation replaces the file, to close cached readers. */\n onRebuilt?: (() => void) | undefined;\n /** Overridable for tests. */\n now?: (() => number) | undefined;\n /**\n * Percentage of transactions that retain the rollout-only per-message\n * timing and source-vs-stored hash validation. Defaults to 1.\n */\n shadowValidationPercent?: number | undefined;\n};\n\nconst DEFAULT_SHADOW_VALIDATION_PERCENT = 1;\n\n/**\n * Appends the canonical change stream to the SQLite change log, from the\n * change-streamer's own stream loop.\n *\n * ### Ordering\n *\n * The log's commit for a transaction precedes the forward of that transaction's\n * `commit` message, and nothing `await`s in between. Everything that can\n * advance the watermark the stream would resume from — the storer's Postgres\n * commit, `lastWatermark`, the upstream ACK — runs strictly after it, so the\n * log's head is always at or above the resume point. A *hole* is therefore\n * unreachable at any crash timing, and a *phantom* is the normal state, which is\n * what makes truncate-above the whole of reconciliation.\n *\n * ### Fail-soft\n *\n * The log is a catchup cache: nothing in it is not either already durable in the\n * replica's litestream backup or re-derivable from the replication slot. A write\n * failure must therefore not stop replication for the shard. Corruption is\n * rebuilt once at open; any other write error — a full disk, an I/O error, a\n * permissions problem — logs, disables the writer, deletes the file, closes the\n * reader, and lets the stream continue. The cost is catchup reach, and reach is\n * recoverable: `too-old` sends a subscriber to a restore.\n *\n * One carve-out keeps that from hiding the bug class this topology introduces. A\n * missed truncate-above surfaces as `SQLITE_CONSTRAINT` on the writer's plain\n * `INSERT`, which is \"any write error other than corruption\" and would be\n * absorbed quietly. It is counted as an invariant failure and alerted on before\n * failing soft.\n */\nexport class SQLiteChangeLogWriter {\n readonly #lc: LogContext;\n readonly #opts: SQLiteChangeLogWriterOptions;\n readonly #now: () => number;\n readonly #shadowValidationPercent: number;\n\n #db: Database | undefined;\n #writer: ChangeLogStreamWriter | undefined;\n #observer: SQLiteChangeLogObserver | undefined;\n #disabled = false;\n #validateTransaction = false;\n\n constructor(lc: LogContext, opts: SQLiteChangeLogWriterOptions) {\n this.#lc = lc.withContext('component', 'sqlite-change-log-writer');\n this.#opts = opts;\n this.#now = opts.now ?? Date.now;\n this.#shadowValidationPercent =\n opts.shadowValidationPercent ?? DEFAULT_SHADOW_VALIDATION_PERCENT;\n assert(\n Number.isInteger(this.#shadowValidationPercent) &&\n this.#shadowValidationPercent >= 0 &&\n this.#shadowValidationPercent <= 100,\n 'SQLite change-log shadow validation percentage must be an integer between 0 and 100',\n );\n }\n\n /** False once the writer has failed soft, or after {@link close}. */\n get enabled(): boolean {\n return !this.#disabled;\n }\n\n /**\n * The writer's own connection, which is the one purge runs on (§3.3): one\n * process appends *and* deletes, on one handle, with no second writable\n * connection and no cross-process barrier. `undefined` until the stream\n * loop's first reconcile creates the log, and again once the writer has\n * failed soft and deleted the file — both of which a purge cycle treats as\n * \"skip\", not as an error.\n */\n get connection(): Database | undefined {\n return this.#db;\n }\n\n /** Exposed for tests and for the integration assertions on the metrics. */\n state(): SQLiteChangeLogObservabilityState | undefined {\n return this.#observer?.state();\n }\n\n /**\n * Opens the log if necessary and reconciles it with the Postgres resume\n * point. Returns the resume point stored in the log.\n *\n * Returns `undefined` when the writer is disabled. The caller must then use\n * another source.\n *\n * This runs for every connection because Postgres supplies a new resume point\n * after each reconnect. The log can contain transactions above that point.\n * Reconciliation removes them before Postgres sends them again.\n *\n * `resumeFrom` includes cookies because the cookies must match the resume\n * watermark.\n */\n reconcile(\n resumeFrom: ChangeLogResumePoint,\n ): ChangeLogResumePoint | undefined {\n return this.#reconcile(() => resumeFrom);\n }\n\n /**\n * Reconciles the log without a Postgres resume point.\n *\n * A valid log uses its own head and cookies. A new or invalid log uses the\n * replica values returned by `seed`.\n */\n reconcileFromLog(\n seed: () => ChangeLogResumePoint,\n ): ChangeLogResumePoint | undefined {\n return this.#reconcile(db => this.#resumeFromLog(db, seed));\n }\n\n #reconcile(\n resolve: (db: Database) => ChangeLogResumePoint,\n ): ChangeLogResumePoint | undefined {\n if (this.#disabled) {\n return undefined;\n }\n const {replicaFile, identity} = this.#opts;\n const nowMs = this.#now();\n const anchor = (db: Database): ChangeLogAnchor => ({\n ...resolve(db),\n identity,\n nowMs,\n });\n try {\n const db = this.#db;\n if (db === undefined) {\n const opened = openChangeLogDBForWriting(this.#lc, replicaFile, anchor);\n this.#db = opened.db;\n if (opened.replacedFile) {\n this.#opts.onRebuilt?.();\n }\n recordSQLiteChangeLogReconcile(this.#lc, opened.result);\n const info = getSQLiteChangeLogInfo(opened.db);\n logSQLiteChangeLogStartup(\n this.#lc,\n changeLogFileName(replicaFile),\n info,\n );\n this.#observer = new SQLiteChangeLogObserver(this.#lc, info);\n } else {\n const resolved = anchor(db);\n let result: ReconcileResult;\n try {\n result = reconcileChangeLog(this.#lc, db, resolved, {\n rebuildInsteadOfUnboundedWork: true,\n });\n } catch (e) {\n if (!(e instanceof ChangeLogRebuildRequired)) {\n throw e;\n }\n // Close every handle to the old inode before unlinking it. The writer\n // is reconstructed below against the replacement connection.\n this.#writer = undefined;\n this.#db = undefined;\n db.close();\n this.#opts.onRebuilt?.();\n const rebuilt = rebuildChangeLogDBForWriting(\n this.#lc,\n replicaFile,\n resolved,\n e,\n );\n this.#db = rebuilt.db;\n result = rebuilt.result;\n }\n recordSQLiteChangeLogReconcile(this.#lc, result);\n if (result.action !== 'none') {\n this.#observer?.reconciled(\n getSQLiteChangeLogInfo(\n must(this.#db, 'the SQLite change log is not open'),\n ),\n );\n }\n }\n // A reseed drops and recreates the stream table, so the append statements\n // are prepared fresh against whatever the reconciliation left behind.\n const reconciledDB = must(this.#db, 'the SQLite change log is not open');\n this.#writer = new ChangeLogStreamWriter(\n new StatementRunner(reconciledDB),\n );\n // Read both values after reconciliation. A truncate or reseed updates the\n // head and cookies in the same transaction.\n return {\n resumeWatermark: must(\n readChangeLogHead(reconciledDB),\n 'the SQLite change log has no head after reconciliation',\n ),\n cookies: readCookies(reconciledDB),\n };\n } catch (e) {\n this.#failSoft('reconciling the SQLite change log', e);\n return undefined;\n }\n }\n\n /**\n * Returns the resume point supplied by the log.\n *\n * A valid log uses its own head and cookies. A log that must be recreated uses\n * the replica values returned by `seed`.\n */\n #resumeFromLog(\n db: Database,\n seed: () => ChangeLogResumePoint,\n ): ChangeLogResumePoint {\n if (changeLogWipeReason(db, this.#opts.identity) !== undefined) {\n return seed();\n }\n const head = readChangeLogHead(db);\n // An empty log has no resume point, so use the replica seed.\n return head === null\n ? seed()\n : {resumeWatermark: head, cookies: readCookies(db)};\n }\n\n /**\n * Writes one stream message, committing at transaction boundaries. Never\n * throws: a write failure disables the writer instead of failing the stream.\n */\n write(\n change: ChangeStreamData,\n json: string,\n storedChange = extractChangeSubstring(json, change[1].tag),\n ): void {\n const writer = this.#writer;\n const db = this.#db;\n if (this.#disabled || writer === undefined || db === undefined) {\n return;\n }\n const [type, msg] = change;\n if (type === 'begin') {\n this.#validateTransaction = this.#isSelectedForShadowValidation(\n change[2].commitWatermark,\n );\n }\n const validateTransaction = this.#validateTransaction;\n const observer = this.#observer;\n observer?.messageReceived(change, json, validateTransaction, storedChange);\n const startedAt = validateTransaction ? performance.now() : undefined;\n try {\n let commit: ChangeLogCommit | null = null;\n switch (type) {\n case 'begin':\n writer.begin(\n change[2].commitWatermark,\n json,\n storedChange,\n validateTransaction,\n );\n break;\n case 'commit': {\n const {watermark} = change[2];\n const commitStart = performance.now();\n const stats = writer.commit(\n watermark,\n json,\n this.#now(),\n storedChange,\n );\n commit = {\n watermark,\n stats,\n logCommitMs: performance.now() - commitStart,\n };\n break;\n }\n case 'rollback':\n writer.rollback();\n break;\n default:\n writer.append(json, msg, storedChange);\n break;\n }\n observer?.messageProcessed(\n change,\n commit,\n startedAt === undefined ? undefined : performance.now() - startedAt,\n );\n if (commit !== null && commit.stats.cookieMutations.length > 0) {\n // Only re-counted when a schema change actually moved the cookie jar,\n // which keeps the count off the forward path of every other\n // transaction. The counts are read after the commit, so they are what\n // a reader of the file would see.\n observer?.cookiesMutated(\n commit.stats.cookieMutations,\n readCookieRowCounts(db),\n );\n }\n if (commit !== null) {\n // The head advanced, and it is durable. Releasing the barrier here is\n // what makes its poll interval a backstop rather than the mechanism.\n this.#opts.onCommit?.(commit.watermark);\n }\n if (type === 'commit' || type === 'rollback') {\n this.#validateTransaction = false;\n }\n } catch (e) {\n observer?.messageFailed(\n change,\n e,\n startedAt === undefined ? undefined : performance.now() - startedAt,\n );\n this.#validateTransaction = false;\n if (isConstraintViolation(e)) {\n // Reconciliation should have truncated whatever this collided with.\n // Report it as an invariant failure before failing soft, so that a\n // truncate-above bug cannot look like a lost cache.\n observer?.invariantFailure(\n 'SQLite change-log insert violated a constraint, which means a ' +\n 'transaction above the resume watermark was not truncated',\n {tag: msg.tag, error: String(e)},\n );\n }\n this.#failSoft('writing to the SQLite change log', e);\n }\n }\n\n #isSelectedForShadowValidation(watermark: string): boolean {\n const percent = this.#shadowValidationPercent;\n return (\n percent >= 100 ||\n (percent > 0 &&\n h32(\n `${this.#opts.identity.generation}/${this.#opts.identity.replicaID ?? ''}:${watermark}`,\n ) %\n 100 <\n percent)\n );\n }\n\n /**\n * Discards the open transaction, if any. Called when the change stream is\n * interrupted mid-transaction.\n */\n abort(): void {\n const writer = this.#writer;\n this.#validateTransaction = false;\n if (this.#disabled || writer === undefined) {\n return;\n }\n this.#observer?.abort();\n try {\n writer.rollback();\n } catch (e) {\n this.#failSoft('rolling back the SQLite change log', e);\n }\n }\n\n close(): void {\n this.#disabled = true;\n this.#validateTransaction = false;\n this.#writer = undefined;\n this.#observer = undefined;\n const db = this.#db;\n this.#db = undefined;\n try {\n db?.close();\n } catch (e) {\n this.#lc.warn?.('error closing the SQLite change log', e);\n }\n }\n\n /**\n * Logs, disables the writer, deletes the file, and closes the reader, leaving\n * replication running. Disabling is for the life of the process: a log that\n * failed a write is not re-opened until the task restarts, so the behavior a\n * subscriber sees is the same as `sqliteChangeLogMode=off`.\n */\n #failSoft(what: string, e: unknown): void {\n const file = changeLogFileName(this.#opts.replicaFile);\n if (isSQLiteCorruption(e)) {\n // Open-time corruption is rebuilt by openChangeLogDBForWriting. Reaching\n // here means the file went bad under an open handle, which the same\n // delete-and-reseed answers -- on the next process start rather than now.\n logSQLiteCorruptionDiagnostics(this.#lc, 'change-log', file, e);\n }\n this.#lc.error?.(\n `error ${what}: disabling the writer and deleting ${file}. ` +\n `Replication continues; catchup reach is lost until this task restarts.`,\n e,\n );\n this.close();\n try {\n deleteChangeLogDB(this.#opts.replicaFile);\n } catch (deleteError) {\n this.#lc.error?.(`unable to delete ${file}`, deleteError);\n }\n this.#opts.onDisabled?.();\n }\n}\n\nfunction isConstraintViolation(e: unknown): boolean {\n return e instanceof SqliteError && e.code.startsWith('SQLITE_CONSTRAINT');\n}\n"],"mappings":"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"}
|