@opendatalabs/personal-server-ts-core 1.34.2 → 1.35.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/dist/additions/durable.d.ts +32 -2
- package/dist/additions/durable.d.ts.map +1 -1
- package/dist/additions/durable.js +237 -88
- package/dist/additions/durable.js.map +1 -1
- package/dist/additions/version-inventory.d.ts +4 -0
- package/dist/additions/version-inventory.d.ts.map +1 -1
- package/dist/additions/version-inventory.js +11 -1
- package/dist/additions/version-inventory.js.map +1 -1
- package/dist/api/index.d.ts +18 -1
- package/dist/api/index.d.ts.map +1 -1
- package/dist/api/index.js +172 -4
- package/dist/api/index.js.map +1 -1
- package/dist/api/oura-collection-requirements.d.ts +16 -0
- package/dist/api/oura-collection-requirements.d.ts.map +1 -0
- package/dist/api/oura-collection-requirements.js +70 -0
- package/dist/api/oura-collection-requirements.js.map +1 -0
- package/dist/api/scope-upload-batches.d.ts +18 -0
- package/dist/api/scope-upload-batches.d.ts.map +1 -0
- package/dist/api/scope-upload-batches.js +71 -0
- package/dist/api/scope-upload-batches.js.map +1 -0
- package/dist/api/scope-uploads.d.ts +112 -0
- package/dist/api/scope-uploads.d.ts.map +1 -0
- package/dist/api/scope-uploads.js +369 -0
- package/dist/api/scope-uploads.js.map +1 -0
- package/dist/client.d.ts +70 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +1 -0
- package/dist/client.js.map +1 -1
- package/dist/contracts/data.d.ts +8 -0
- package/dist/contracts/data.d.ts.map +1 -1
- package/dist/contracts/data.js +31 -1
- package/dist/contracts/data.js.map +1 -1
- package/dist/contracts/grants.d.ts.map +1 -1
- package/dist/contracts/grants.js +3 -0
- package/dist/contracts/grants.js.map +1 -1
- package/dist/contracts/index.d.ts +3 -1
- package/dist/contracts/index.d.ts.map +1 -1
- package/dist/contracts/index.js +3 -1
- package/dist/contracts/index.js.map +1 -1
- package/dist/contracts/oura-collection-requirements.d.ts +17 -0
- package/dist/contracts/oura-collection-requirements.d.ts.map +1 -0
- package/dist/contracts/oura-collection-requirements.js +49 -0
- package/dist/contracts/oura-collection-requirements.js.map +1 -0
- package/dist/legacy-projection/bindings.d.ts +1 -7
- package/dist/legacy-projection/bindings.d.ts.map +1 -1
- package/dist/legacy-projection/bindings.js +162 -28
- package/dist/legacy-projection/bindings.js.map +1 -1
- package/dist/legacy-projection/index.d.ts +6 -5
- package/dist/legacy-projection/index.d.ts.map +1 -1
- package/dist/legacy-projection/index.js +9 -21
- package/dist/legacy-projection/index.js.map +1 -1
- package/dist/legacy-projection/oura-browser-bindings.d.ts +2 -0
- package/dist/legacy-projection/oura-browser-bindings.d.ts.map +1 -1
- package/dist/legacy-projection/oura-browser-bindings.js +2 -0
- package/dist/legacy-projection/oura-browser-bindings.js.map +1 -1
- package/dist/legacy-projection/strava-browser-bindings.d.ts +3 -0
- package/dist/legacy-projection/strava-browser-bindings.d.ts.map +1 -0
- package/dist/legacy-projection/strava-browser-bindings.js +32 -0
- package/dist/legacy-projection/strava-browser-bindings.js.map +1 -0
- package/dist/legacy-projection/types.d.ts +44 -12
- package/dist/legacy-projection/types.d.ts.map +1 -1
- package/dist/mcp/connection-api.d.ts +11 -1
- package/dist/mcp/connection-api.d.ts.map +1 -1
- package/dist/mcp/connection-api.js +94 -9
- package/dist/mcp/connection-api.js.map +1 -1
- package/dist/mcp/index.d.ts +1 -0
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +1 -0
- package/dist/mcp/index.js.map +1 -1
- package/dist/mcp/server.d.ts +9 -0
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +19 -6
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp/store.d.ts +10 -1
- package/dist/mcp/store.d.ts.map +1 -1
- package/dist/mcp/store.js +51 -0
- package/dist/mcp/store.js.map +1 -1
- package/dist/mcp/tools.d.ts +52 -2
- package/dist/mcp/tools.d.ts.map +1 -1
- package/dist/mcp/tools.js +395 -11
- package/dist/mcp/tools.js.map +1 -1
- package/dist/mcp/types.d.ts +36 -2
- package/dist/mcp/types.d.ts.map +1 -1
- package/dist/mcp/types.js +12 -0
- package/dist/mcp/types.js.map +1 -1
- package/dist/mcp/write-client.d.ts +81 -0
- package/dist/mcp/write-client.d.ts.map +1 -0
- package/dist/mcp/write-client.js +140 -0
- package/dist/mcp/write-client.js.map +1 -0
- package/dist/oura/history-policy.d.ts +12 -0
- package/dist/oura/history-policy.d.ts.map +1 -0
- package/dist/oura/history-policy.js +54 -0
- package/dist/oura/history-policy.js.map +1 -0
- package/dist/oura/index.d.ts +51 -0
- package/dist/oura/index.d.ts.map +1 -0
- package/dist/oura/index.js +315 -0
- package/dist/oura/index.js.map +1 -0
- package/dist/pdpp/approval.d.ts +1 -1
- package/dist/pdpp/approval.d.ts.map +1 -1
- package/dist/pdpp/approval.js +29 -10
- package/dist/pdpp/approval.js.map +1 -1
- package/dist/pdpp/declaration-limits.d.ts +7 -0
- package/dist/pdpp/declaration-limits.d.ts.map +1 -0
- package/dist/pdpp/declaration-limits.js +7 -0
- package/dist/pdpp/declaration-limits.js.map +1 -0
- package/dist/pdpp/declaration.d.ts +2 -1
- package/dist/pdpp/declaration.d.ts.map +1 -1
- package/dist/pdpp/declaration.js +2 -1
- package/dist/pdpp/declaration.js.map +1 -1
- package/dist/pdpp/index.d.ts +3 -2
- package/dist/pdpp/index.d.ts.map +1 -1
- package/dist/pdpp/index.js +3 -2
- package/dist/pdpp/index.js.map +1 -1
- package/dist/pdpp/issuance.d.ts.map +1 -1
- package/dist/pdpp/issuance.js +6 -2
- package/dist/pdpp/issuance.js.map +1 -1
- package/dist/pdpp/resolve.d.ts +12 -1
- package/dist/pdpp/resolve.d.ts.map +1 -1
- package/dist/pdpp/resolve.js +156 -58
- package/dist/pdpp/resolve.js.map +1 -1
- package/dist/pdpp/selection.d.ts +30 -1
- package/dist/pdpp/selection.d.ts.map +1 -1
- package/dist/pdpp/selection.js +77 -26
- package/dist/pdpp/selection.js.map +1 -1
- package/dist/pdpp/time.d.ts +39 -0
- package/dist/pdpp/time.d.ts.map +1 -0
- package/dist/pdpp/time.js +173 -0
- package/dist/pdpp/time.js.map +1 -0
- package/dist/pdpp/tokens.d.ts.map +1 -1
- package/dist/pdpp/tokens.js +4 -2
- package/dist/pdpp/tokens.js.map +1 -1
- package/dist/pdpp/types.d.ts +18 -2
- package/dist/pdpp/types.d.ts.map +1 -1
- package/dist/pdpp/types.js +7 -0
- package/dist/pdpp/types.js.map +1 -1
- package/dist/policy/reserved-scopes.d.ts +7 -0
- package/dist/policy/reserved-scopes.d.ts.map +1 -1
- package/dist/policy/reserved-scopes.js +12 -1
- package/dist/policy/reserved-scopes.js.map +1 -1
- package/dist/ports/index.d.ts +85 -1
- package/dist/ports/index.d.ts.map +1 -1
- package/dist/ports/index.js.map +1 -1
- package/dist/ports/pdpp-auth.d.ts +17 -27
- package/dist/ports/pdpp-auth.d.ts.map +1 -1
- package/dist/ports/pdpp-auth.js +14 -28
- package/dist/ports/pdpp-auth.js.map +1 -1
- package/dist/publication/publish.d.ts +96 -0
- package/dist/publication/publish.d.ts.map +1 -0
- package/dist/publication/publish.js +261 -0
- package/dist/publication/publish.js.map +1 -0
- package/dist/replication/completion.d.ts +20 -0
- package/dist/replication/completion.d.ts.map +1 -0
- package/dist/replication/completion.js +73 -0
- package/dist/replication/completion.js.map +1 -0
- package/dist/replication/coordinator-state.d.ts +29 -0
- package/dist/replication/coordinator-state.d.ts.map +1 -0
- package/dist/replication/coordinator-state.js +88 -0
- package/dist/replication/coordinator-state.js.map +1 -0
- package/dist/replication/coordinator-state.test-utils.d.ts +32 -0
- package/dist/replication/coordinator-state.test-utils.d.ts.map +1 -0
- package/dist/replication/coordinator-state.test-utils.js +113 -0
- package/dist/replication/coordinator-state.test-utils.js.map +1 -0
- package/dist/replication/coordinator.d.ts +264 -0
- package/dist/replication/coordinator.d.ts.map +1 -0
- package/dist/replication/coordinator.js +499 -0
- package/dist/replication/coordinator.js.map +1 -0
- package/dist/replication/copies.d.ts +126 -0
- package/dist/replication/copies.d.ts.map +1 -0
- package/dist/replication/copies.js +437 -0
- package/dist/replication/copies.js.map +1 -0
- package/dist/replication/copy-promotion.d.ts +132 -0
- package/dist/replication/copy-promotion.d.ts.map +1 -0
- package/dist/replication/copy-promotion.js +126 -0
- package/dist/replication/copy-promotion.js.map +1 -0
- package/dist/replication/door.d.ts +33 -0
- package/dist/replication/door.d.ts.map +1 -0
- package/dist/replication/door.js +10 -0
- package/dist/replication/door.js.map +1 -0
- package/dist/replication/dpv2.d.ts +141 -0
- package/dist/replication/dpv2.d.ts.map +1 -0
- package/dist/replication/dpv2.js +320 -0
- package/dist/replication/dpv2.js.map +1 -0
- package/dist/replication/effects.d.ts +68 -0
- package/dist/replication/effects.d.ts.map +1 -0
- package/dist/replication/effects.js +89 -0
- package/dist/replication/effects.js.map +1 -0
- package/dist/replication/engine.d.ts +378 -0
- package/dist/replication/engine.d.ts.map +1 -0
- package/dist/replication/engine.js +3197 -0
- package/dist/replication/engine.js.map +1 -0
- package/dist/replication/envelope.d.ts +481 -0
- package/dist/replication/envelope.d.ts.map +1 -0
- package/dist/replication/envelope.js +192 -0
- package/dist/replication/envelope.js.map +1 -0
- package/dist/replication/evidence.d.ts +118 -0
- package/dist/replication/evidence.d.ts.map +1 -0
- package/dist/replication/evidence.js +198 -0
- package/dist/replication/evidence.js.map +1 -0
- package/dist/replication/finality.d.ts +16 -0
- package/dist/replication/finality.d.ts.map +1 -0
- package/dist/replication/finality.js +11 -0
- package/dist/replication/finality.js.map +1 -0
- package/dist/replication/fixtures.d.ts +16 -0
- package/dist/replication/fixtures.d.ts.map +1 -0
- package/dist/replication/fixtures.js +47 -0
- package/dist/replication/fixtures.js.map +1 -0
- package/dist/replication/host-config.d.ts +52 -0
- package/dist/replication/host-config.d.ts.map +1 -0
- package/dist/replication/host-config.js +111 -0
- package/dist/replication/host-config.js.map +1 -0
- package/dist/replication/host-driver.d.ts +95 -0
- package/dist/replication/host-driver.d.ts.map +1 -0
- package/dist/replication/host-driver.js +202 -0
- package/dist/replication/host-driver.js.map +1 -0
- package/dist/replication/host-ports.d.ts +48 -0
- package/dist/replication/host-ports.d.ts.map +1 -0
- package/dist/replication/host-ports.js +69 -0
- package/dist/replication/host-ports.js.map +1 -0
- package/dist/replication/identity.d.ts +13 -0
- package/dist/replication/identity.d.ts.map +1 -0
- package/dist/replication/identity.js +22 -0
- package/dist/replication/identity.js.map +1 -0
- package/dist/replication/limits.d.ts +124 -0
- package/dist/replication/limits.d.ts.map +1 -0
- package/dist/replication/limits.js +182 -0
- package/dist/replication/limits.js.map +1 -0
- package/dist/replication/merge-plan.d.ts +92 -0
- package/dist/replication/merge-plan.d.ts.map +1 -0
- package/dist/replication/merge-plan.js +229 -0
- package/dist/replication/merge-plan.js.map +1 -0
- package/dist/replication/namespace.d.ts +4 -0
- package/dist/replication/namespace.d.ts.map +1 -0
- package/dist/replication/namespace.js +7 -0
- package/dist/replication/namespace.js.map +1 -0
- package/dist/replication/outcome.d.ts +178 -0
- package/dist/replication/outcome.d.ts.map +1 -0
- package/dist/replication/outcome.js +83 -0
- package/dist/replication/outcome.js.map +1 -0
- package/dist/replication/payload.d.ts +39 -0
- package/dist/replication/payload.d.ts.map +1 -0
- package/dist/replication/payload.js +137 -0
- package/dist/replication/payload.js.map +1 -0
- package/dist/replication/preparation.d.ts +42 -0
- package/dist/replication/preparation.d.ts.map +1 -0
- package/dist/replication/preparation.js +118 -0
- package/dist/replication/preparation.js.map +1 -0
- package/dist/replication/rebaseline.d.ts +33 -0
- package/dist/replication/rebaseline.d.ts.map +1 -0
- package/dist/replication/rebaseline.js +40 -0
- package/dist/replication/rebaseline.js.map +1 -0
- package/dist/replication/rebinding.d.ts +43 -0
- package/dist/replication/rebinding.d.ts.map +1 -0
- package/dist/replication/rebinding.js +52 -0
- package/dist/replication/rebinding.js.map +1 -0
- package/dist/replication/receive.d.ts +57 -0
- package/dist/replication/receive.d.ts.map +1 -0
- package/dist/replication/receive.js +114 -0
- package/dist/replication/receive.js.map +1 -0
- package/dist/replication/record-reducer.d.ts +16 -0
- package/dist/replication/record-reducer.d.ts.map +1 -0
- package/dist/replication/record-reducer.js +52 -0
- package/dist/replication/record-reducer.js.map +1 -0
- package/dist/replication/revision.d.ts +24 -0
- package/dist/replication/revision.d.ts.map +1 -0
- package/dist/replication/revision.js +35 -0
- package/dist/replication/revision.js.map +1 -0
- package/dist/replication/roster.d.ts +53 -0
- package/dist/replication/roster.d.ts.map +1 -0
- package/dist/replication/roster.js +115 -0
- package/dist/replication/roster.js.map +1 -0
- package/dist/replication/schedule.d.ts +73 -0
- package/dist/replication/schedule.d.ts.map +1 -0
- package/dist/replication/schedule.js +182 -0
- package/dist/replication/schedule.js.map +1 -0
- package/dist/replication/stream.d.ts +73 -0
- package/dist/replication/stream.d.ts.map +1 -0
- package/dist/replication/stream.js +542 -0
- package/dist/replication/stream.js.map +1 -0
- package/dist/replication/transport.d.ts +236 -0
- package/dist/replication/transport.d.ts.map +1 -0
- package/dist/replication/transport.js +440 -0
- package/dist/replication/transport.js.map +1 -0
- package/dist/replication/visibility-engine.d.ts +337 -0
- package/dist/replication/visibility-engine.d.ts.map +1 -0
- package/dist/replication/visibility-engine.js +602 -0
- package/dist/replication/visibility-engine.js.map +1 -0
- package/dist/replication/visibility-policy.d.ts +26 -0
- package/dist/replication/visibility-policy.d.ts.map +1 -0
- package/dist/replication/visibility-policy.js +33 -0
- package/dist/replication/visibility-policy.js.map +1 -0
- package/dist/replication/writer-identity-store.d.ts +100 -0
- package/dist/replication/writer-identity-store.d.ts.map +1 -0
- package/dist/replication/writer-identity-store.js +218 -0
- package/dist/replication/writer-identity-store.js.map +1 -0
- package/dist/replication/writer-identity.d.ts +252 -0
- package/dist/replication/writer-identity.d.ts.map +1 -0
- package/dist/replication/writer-identity.js +281 -0
- package/dist/replication/writer-identity.js.map +1 -0
- package/dist/replication/writer-identity.test-utils.d.ts +24 -0
- package/dist/replication/writer-identity.test-utils.d.ts.map +1 -0
- package/dist/replication/writer-identity.test-utils.js +86 -0
- package/dist/replication/writer-identity.test-utils.js.map +1 -0
- package/dist/schemas/server-config.d.ts +13 -0
- package/dist/schemas/server-config.d.ts.map +1 -1
- package/dist/schemas/server-config.js +24 -0
- package/dist/schemas/server-config.js.map +1 -1
- package/dist/storage/adapters/index.d.ts +3 -1
- package/dist/storage/adapters/index.d.ts.map +1 -1
- package/dist/storage/adapters/index.js +2 -0
- package/dist/storage/adapters/index.js.map +1 -1
- package/dist/storage/adapters/interface.d.ts +18 -1
- package/dist/storage/adapters/interface.d.ts.map +1 -1
- package/dist/storage/adapters/interface.js +13 -1
- package/dist/storage/adapters/interface.js.map +1 -1
- package/dist/storage/adapters/sdk.d.ts +2 -1
- package/dist/storage/adapters/sdk.d.ts.map +1 -1
- package/dist/storage/adapters/sdk.js +21 -3
- package/dist/storage/adapters/sdk.js.map +1 -1
- package/dist/storage/adapters/vana-audience.d.ts +17 -5
- package/dist/storage/adapters/vana-audience.d.ts.map +1 -1
- package/dist/storage/adapters/vana-audience.js +24 -9
- package/dist/storage/adapters/vana-audience.js.map +1 -1
- package/dist/storage/adapters/vana.d.ts +2 -2
- package/dist/storage/adapters/vana.d.ts.map +1 -1
- package/dist/storage/adapters/vana.js +30 -22
- package/dist/storage/adapters/vana.js.map +1 -1
- package/dist/storage/canonical-legacy-projection.d.ts +1 -1
- package/dist/storage/canonical-legacy-projection.d.ts.map +1 -1
- package/dist/storage/canonical-legacy-projection.js +13 -15
- package/dist/storage/canonical-legacy-projection.js.map +1 -1
- package/dist/storage/legacy-projection.d.ts.map +1 -1
- package/dist/storage/legacy-projection.js +16 -1
- package/dist/storage/legacy-projection.js.map +1 -1
- package/dist/storage/legacy-scope-coverage-exceptions.d.ts +5 -0
- package/dist/storage/legacy-scope-coverage-exceptions.d.ts.map +1 -1
- package/dist/storage/legacy-scope-coverage-exceptions.js +4 -0
- package/dist/storage/legacy-scope-coverage-exceptions.js.map +1 -1
- package/dist/storage/legacy-scope-coverage.d.ts.map +1 -1
- package/dist/storage/legacy-scope-coverage.js +14 -1
- package/dist/storage/legacy-scope-coverage.js.map +1 -1
- package/dist/storage/legacy-scope-entity-keys.d.ts +14 -1
- package/dist/storage/legacy-scope-entity-keys.d.ts.map +1 -1
- package/dist/storage/legacy-scope-entity-keys.js +18 -1
- package/dist/storage/legacy-scope-entity-keys.js.map +1 -1
- package/dist/storage/legacy-scope-view.d.ts +57 -5
- package/dist/storage/legacy-scope-view.d.ts.map +1 -1
- package/dist/storage/legacy-scope-view.js +644 -318
- package/dist/storage/legacy-scope-view.js.map +1 -1
- package/dist/storage/pdpp-records/cursor.d.ts +47 -2
- package/dist/storage/pdpp-records/cursor.d.ts.map +1 -1
- package/dist/storage/pdpp-records/cursor.js +7 -0
- package/dist/storage/pdpp-records/cursor.js.map +1 -1
- package/dist/storage/pdpp-records/enforcement.d.ts +3 -16
- package/dist/storage/pdpp-records/enforcement.d.ts.map +1 -1
- package/dist/storage/pdpp-records/enforcement.js +40 -13
- package/dist/storage/pdpp-records/enforcement.js.map +1 -1
- package/dist/storage/pdpp-records/index.d.ts +2 -0
- package/dist/storage/pdpp-records/index.d.ts.map +1 -1
- package/dist/storage/pdpp-records/index.js +1 -0
- package/dist/storage/pdpp-records/index.js.map +1 -1
- package/dist/storage/pdpp-records/interface.d.ts +8 -1
- package/dist/storage/pdpp-records/interface.d.ts.map +1 -1
- package/dist/storage/pdpp-records/list-page.d.ts +35 -0
- package/dist/storage/pdpp-records/list-page.d.ts.map +1 -0
- package/dist/storage/pdpp-records/list-page.js +106 -0
- package/dist/storage/pdpp-records/list-page.js.map +1 -0
- package/dist/storage/pdpp-records/memory-store.d.ts.map +1 -1
- package/dist/storage/pdpp-records/memory-store.js +95 -99
- package/dist/storage/pdpp-records/memory-store.js.map +1 -1
- package/dist/storage/pdpp-records/portable.d.ts +3 -0
- package/dist/storage/pdpp-records/portable.d.ts.map +1 -1
- package/dist/storage/pdpp-records/portable.js +3 -0
- package/dist/storage/pdpp-records/portable.js.map +1 -1
- package/dist/storage/pdpp-records/replication-journal.d.ts +20 -0
- package/dist/storage/pdpp-records/replication-journal.d.ts.map +1 -0
- package/dist/storage/pdpp-records/replication-journal.js +2 -0
- package/dist/storage/pdpp-records/replication-journal.js.map +1 -0
- package/dist/storage/pdpp-records/stream-declaration.d.ts +0 -2
- package/dist/storage/pdpp-records/stream-declaration.d.ts.map +1 -1
- package/dist/storage/pdpp-records/stream-declaration.js +0 -9
- package/dist/storage/pdpp-records/stream-declaration.js.map +1 -1
- package/dist/storage/pdpp-records/sync-selection.d.ts +4 -0
- package/dist/storage/pdpp-records/sync-selection.d.ts.map +1 -0
- package/dist/storage/pdpp-records/sync-selection.js +37 -0
- package/dist/storage/pdpp-records/sync-selection.js.map +1 -0
- package/dist/storage/pdpp-records/types.d.ts +64 -0
- package/dist/storage/pdpp-records/types.d.ts.map +1 -1
- package/dist/storage/pdpp-records/types.js.map +1 -1
- package/dist/sync/delete-data-port.d.ts +15 -0
- package/dist/sync/delete-data-port.d.ts.map +1 -1
- package/dist/sync/delete-data-port.js +19 -7
- package/dist/sync/delete-data-port.js.map +1 -1
- package/dist/sync/index.d.ts +2 -1
- package/dist/sync/index.d.ts.map +1 -1
- package/dist/sync/index.js +2 -1
- package/dist/sync/index.js.map +1 -1
- package/dist/sync/pending-blob-deletions.d.ts +18 -2
- package/dist/sync/pending-blob-deletions.d.ts.map +1 -1
- package/dist/sync/pending-blob-deletions.js +69 -23
- package/dist/sync/pending-blob-deletions.js.map +1 -1
- package/dist/sync/workers/delete.d.ts.map +1 -1
- package/dist/sync/workers/delete.js +332 -142
- package/dist/sync/workers/delete.js.map +1 -1
- package/dist/sync/workers/download.d.ts.map +1 -1
- package/dist/sync/workers/download.js +53 -5
- package/dist/sync/workers/download.js.map +1 -1
- package/dist/sync/workers/upload.d.ts +24 -5
- package/dist/sync/workers/upload.d.ts.map +1 -1
- package/dist/sync/workers/upload.js +160 -275
- package/dist/sync/workers/upload.js.map +1 -1
- package/dist/test-utils/memory-storage.d.ts.map +1 -1
- package/dist/test-utils/memory-storage.js +17 -2
- package/dist/test-utils/memory-storage.js.map +1 -1
- package/package.json +24 -2
|
@@ -0,0 +1,3197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The replication phase engine: retain, prepare, publish, discover,
|
|
3
|
+
* download, fetch, revalidate, import and reconcile under one coordinator.
|
|
4
|
+
*
|
|
5
|
+
* The phase logic is written once, as storage effects (./effects.ts), and
|
|
6
|
+
* two hosts run it: Node over better-sqlite3, synchronously
|
|
7
|
+
* (packages/server `pdpp-replication-transport.ts`), and PS-Lite over its
|
|
8
|
+
* native SQLite transaction, asynchronously (packages/lite
|
|
9
|
+
* `replication-engine.ts`). A host supplies storage and its transactions,
|
|
10
|
+
* the replication door, the writer lock and identity, blob staging, payload
|
|
11
|
+
* bytes, its exporter and its receiver (`ReplicaEngineHost`). The phase
|
|
12
|
+
* order, SQL, outcomes and network protocol live here only.
|
|
13
|
+
*
|
|
14
|
+
* On a synchronous host every storage call answers with a plain value, so a
|
|
15
|
+
* phase's storage work between two network awaits runs to completion as one
|
|
16
|
+
* synchronous block, as it did before the engine was shared.
|
|
17
|
+
*/
|
|
18
|
+
import { sha256 } from "@noble/hashes/sha256";
|
|
19
|
+
import { bytesToHex } from "@noble/hashes/utils";
|
|
20
|
+
import { encodeReplicaPayload, fragmentReplicaStream, assembleReplicaStream, inspectReplicaPayload, replicaPayloadOperationCrossings, replicaStreamCiphertextByteLength, hashReplicaBytes, DEFAULT_REPLICA_STREAMED_LIMITS, } from "./stream.js";
|
|
21
|
+
import { admit, ReplicaByteBudget, ReplicaResourceBusyError, ReplicaResourceLimitError, REPLICA_DEFAULT_BYTE_BUDGET_BYTES, REPLICA_DEFAULT_FRAGMENT_BYTES, REPLICA_MAX_FRAGMENTS, REPLICA_BATCH_OPERATION_LIMIT, REPLICA_BATCH_TARGET_BYTES, REPLICA_LIVE_COPY_FACTOR, REPLICA_ENVELOPE_LIVE_COPY_FACTOR, replicaResourceLimits, REPLICA_LIMITS_VERSION, } from "./limits.js";
|
|
22
|
+
import { canonicalJson, decodeReplicaEnvelope, REPLICA_TEXT_PART_BYTES, replicaTextParts, } from "./envelope.js";
|
|
23
|
+
import { prepareReplicaFragment, prepareReplicaManifest, decodeReplicaManifest, replicaDigest, replicaManifestFacts, replicaScope, replicaWriterOf, REPLICA_MAX_BLOB_BYTES, } from "./transport.js";
|
|
24
|
+
import { replicaRegistrationState, replicaAwaitingFinality, replicaRegistrationInactive, replicaChainCommitment, ReplicaObjectRefused, } from "./dpv2.js";
|
|
25
|
+
import { receiveReplicaManifest, receiveReplicaFragment, REPLICA_RECEIVE_TIMEOUT_MS, } from "./receive.js";
|
|
26
|
+
import { abortable, createAsyncReplicaCoordinator, REPLICA_REPORT_LIMIT, REPLICA_STEP_BYTES, validateReplicaStep, } from "./coordinator.js";
|
|
27
|
+
import { REPLICA_COORDINATOR_SCHEMA } from "./coordinator-state.js";
|
|
28
|
+
import { dependencyKey, outcomeReason, retryFromError, ReplicaFramingUnsupported, ReplicaProblemError, } from "./outcome.js";
|
|
29
|
+
import { replicaCopyKey, REPLICA_REFETCH_BUDGET, } from "./copy-promotion.js";
|
|
30
|
+
import { replicaCopyEnvelope } from "./copies.js";
|
|
31
|
+
import { defaultReplicaBackoff, REPLICA_TERMINAL, REPLICA_WAIT_FALLBACK_MS, replicaEligibleAt, replicaEligibleAtSql, } from "./schedule.js";
|
|
32
|
+
import { createCachedReplicaRoster } from "./roster.js";
|
|
33
|
+
import { authorizeReplicaBatch, replicaOperationWriterRefusal, signReplicaBatch, } from "./writer-identity.js";
|
|
34
|
+
import { resolveDeclaration } from "./merge-plan.js";
|
|
35
|
+
import { sql, } from "./effects.js";
|
|
36
|
+
import { deleteReplicaPayload, readReplicaPayload, readReplicaPayloadWindow, replicaPayloadWriter, verifyReplicaPayload, } from "./payload.js";
|
|
37
|
+
import { REPLICA_PREPARATION_MIGRATION, classifyReplicaPreparationError, preparationOperationFailures, snapshotReplicaOperation, } from "./preparation.js";
|
|
38
|
+
const isPromiseLike = (value) => typeof value === "object" &&
|
|
39
|
+
value !== null &&
|
|
40
|
+
typeof value.then === "function";
|
|
41
|
+
/** Sequence without awaiting, so a synchronous host stays synchronous. */
|
|
42
|
+
function then(value, next) {
|
|
43
|
+
return isPromiseLike(value)
|
|
44
|
+
? value.then(next)
|
|
45
|
+
: next(value);
|
|
46
|
+
}
|
|
47
|
+
// Each process shares one default ledger, whichever host it runs.
|
|
48
|
+
const processReplicaByteBudget = new ReplicaByteBudget(REPLICA_DEFAULT_BYTE_BUDGET_BYTES);
|
|
49
|
+
/**
|
|
50
|
+
* One reconcile attempt's work: retained operations it may load, and their
|
|
51
|
+
* JSON bytes. The attempt reserves the bytes, times the live-copy factor,
|
|
52
|
+
* from the process byte budget. A single key larger than a page still runs
|
|
53
|
+
* alone.
|
|
54
|
+
*/
|
|
55
|
+
export const REPLICA_RECONCILE_PAGE = {
|
|
56
|
+
operations: 2_000,
|
|
57
|
+
bytes: 8 * 1024 * 1024,
|
|
58
|
+
};
|
|
59
|
+
/** Default bound on the operations one batch (one registration) covers. */
|
|
60
|
+
export const REPLICA_BATCH_OPERATIONS = REPLICA_BATCH_OPERATION_LIMIT;
|
|
61
|
+
/**
|
|
62
|
+
* Default bound on the estimated payload bytes of one batch. One operation
|
|
63
|
+
* always fits, so a single larger blob makes a larger batch.
|
|
64
|
+
*/
|
|
65
|
+
export const REPLICA_BATCH_BYTES = REPLICA_BATCH_TARGET_BYTES;
|
|
66
|
+
/** The export's page limit. A batch reads several pages. */
|
|
67
|
+
const EXPORT_PAGE = 1000;
|
|
68
|
+
/** New slots one discover pass admits per writer scope. */
|
|
69
|
+
const DISCOVER_SLOTS = 256;
|
|
70
|
+
/** An unresolved AddData intent is retried only after this bounded window. */
|
|
71
|
+
export const REPLICA_REGISTRATION_INTENT_TIMEOUT_MS = 5 * 60 * 1000;
|
|
72
|
+
/** Per-writer recovery budget; only explicit owner acknowledgement resumes an exhausted writer. */
|
|
73
|
+
export const REPLICA_VERSION_SQUAT_ATTEMPTS = 8;
|
|
74
|
+
export const REPLICA_VERSION_SQUAT_WINDOW_MS = 60 * 60 * 1000;
|
|
75
|
+
/** Durable outcome columns shared by every per-item work table. */
|
|
76
|
+
const WORK = `outcome TEXT, reason TEXT, cause_json TEXT, dependency TEXT, error TEXT,
|
|
77
|
+
attempts INTEGER NOT NULL DEFAULT 0, next_attempt INTEGER NOT NULL DEFAULT 0,
|
|
78
|
+
wait_since INTEGER, checked_block TEXT`;
|
|
79
|
+
/*
|
|
80
|
+
* Sender: a retained batch is prepared into content-addressed fragments and
|
|
81
|
+
* one manifest. Fragments are stored unregistered; the manifest is the one
|
|
82
|
+
* registration, at the next version of the writer's single scope.
|
|
83
|
+
* Receiver: each discovered version of a writer scope is a slot. A slot's
|
|
84
|
+
* manifest is fetched only under finalized evidence, and each fragment is
|
|
85
|
+
* checked against the manifest's digest before it is decrypted.
|
|
86
|
+
*/
|
|
87
|
+
export const REPLICA_ENGINE_MIGRATION = `
|
|
88
|
+
CREATE TABLE IF NOT EXISTS pdpp_transport_lock (id INTEGER PRIMARY KEY CHECK(id=1), pid INTEGER NOT NULL, token TEXT NOT NULL);
|
|
89
|
+
CREATE TABLE IF NOT EXISTS pdpp_transport_state (
|
|
90
|
+
id INTEGER PRIMARY KEY CHECK(id=1), owner TEXT NOT NULL, writer TEXT NOT NULL,
|
|
91
|
+
baseline_cursor INTEGER NOT NULL DEFAULT 0, baseline_done INTEGER NOT NULL DEFAULT 0,
|
|
92
|
+
tail_cursor INTEGER, sequence INTEGER NOT NULL DEFAULT 0, previous_json TEXT, scan_cursor TEXT);
|
|
93
|
+
CREATE TABLE IF NOT EXISTS pdpp_transport_batches (
|
|
94
|
+
batch TEXT PRIMARY KEY, sequence INTEGER UNIQUE NOT NULL, previous_json TEXT,
|
|
95
|
+
collected_at TEXT NOT NULL, digest TEXT NOT NULL, total_bytes INTEGER NOT NULL,
|
|
96
|
+
prepared INTEGER NOT NULL DEFAULT 0, admission_json TEXT, ${WORK});
|
|
97
|
+
CREATE TABLE IF NOT EXISTS pdpp_transport_staged_fragments (
|
|
98
|
+
token TEXT NOT NULL, position INTEGER NOT NULL, digest TEXT NOT NULL, encrypted BLOB NOT NULL,
|
|
99
|
+
PRIMARY KEY(token,position));
|
|
100
|
+
CREATE TABLE IF NOT EXISTS pdpp_transport_fragments_out (
|
|
101
|
+
batch TEXT NOT NULL, position INTEGER NOT NULL, digest TEXT NOT NULL, encrypted BLOB NOT NULL,
|
|
102
|
+
state TEXT NOT NULL DEFAULT 'ready', ${WORK}, PRIMARY KEY(batch,position));
|
|
103
|
+
CREATE TABLE IF NOT EXISTS pdpp_transport_preparation_progress (
|
|
104
|
+
batch TEXT PRIMARY KEY, statement TEXT NOT NULL, chunk_bytes INTEGER NOT NULL,
|
|
105
|
+
next_index INTEGER NOT NULL, source_position INTEGER NOT NULL, source_offset INTEGER NOT NULL);
|
|
106
|
+
CREATE TABLE IF NOT EXISTS pdpp_transport_manifests_out (
|
|
107
|
+
batch TEXT PRIMARY KEY, scope TEXT NOT NULL, version INTEGER NOT NULL,
|
|
108
|
+
plaintext BLOB NOT NULL, encrypted BLOB NOT NULL, data_hash TEXT NOT NULL, metadata_hash TEXT NOT NULL,
|
|
109
|
+
state TEXT NOT NULL DEFAULT 'ready', ${WORK}, UNIQUE(scope,version));
|
|
110
|
+
CREATE TABLE IF NOT EXISTS pdpp_transport_registration_intents (
|
|
111
|
+
scope TEXT NOT NULL, version INTEGER NOT NULL, owner TEXT NOT NULL,
|
|
112
|
+
manifest_hash TEXT NOT NULL, request_id TEXT NOT NULL UNIQUE,
|
|
113
|
+
created_at INTEGER NOT NULL, attempted_at INTEGER NOT NULL,
|
|
114
|
+
attempts INTEGER NOT NULL DEFAULT 1, status TEXT NOT NULL DEFAULT 'unresolved',
|
|
115
|
+
PRIMARY KEY(scope,version));
|
|
116
|
+
CREATE TABLE IF NOT EXISTS pdpp_transport_manifests_in (
|
|
117
|
+
slot TEXT PRIMARY KEY, scope TEXT NOT NULL, version INTEGER NOT NULL, writer TEXT NOT NULL,
|
|
118
|
+
manifest_json TEXT, data_hash TEXT, metadata_hash TEXT, batch TEXT, sequence INTEGER,
|
|
119
|
+
state TEXT NOT NULL DEFAULT 'ready', evidence_checked INTEGER NOT NULL DEFAULT 0, ${WORK},
|
|
120
|
+
UNIQUE(scope,version));
|
|
121
|
+
CREATE TABLE IF NOT EXISTS pdpp_transport_fragments_in (
|
|
122
|
+
slot TEXT NOT NULL, position INTEGER NOT NULL, digest TEXT NOT NULL, bytes INTEGER NOT NULL,
|
|
123
|
+
fragment_json TEXT, state TEXT NOT NULL DEFAULT 'ready', ${WORK}, PRIMARY KEY(slot,position));
|
|
124
|
+
CREATE TABLE IF NOT EXISTS pdpp_transport_fragment_parts (
|
|
125
|
+
slot TEXT NOT NULL, position INTEGER NOT NULL, part INTEGER NOT NULL, json TEXT NOT NULL,
|
|
126
|
+
PRIMARY KEY(slot,position,part));
|
|
127
|
+
CREATE TABLE IF NOT EXISTS pdpp_transport_discover_floor (scope TEXT PRIMARY KEY, version INTEGER NOT NULL);
|
|
128
|
+
CREATE INDEX IF NOT EXISTS pdpp_manifests_in_batch ON pdpp_transport_manifests_in(writer,batch);
|
|
129
|
+
CREATE INDEX IF NOT EXISTS pdpp_manifests_in_checked ON pdpp_transport_manifests_in(evidence_checked) WHERE state='imported';
|
|
130
|
+
CREATE TABLE IF NOT EXISTS pdpp_transport_imported (
|
|
131
|
+
writer TEXT NOT NULL, batch TEXT NOT NULL, sequence INTEGER NOT NULL, digest TEXT NOT NULL,
|
|
132
|
+
PRIMARY KEY(writer,batch), UNIQUE(writer,sequence));
|
|
133
|
+
CREATE TABLE IF NOT EXISTS pdpp_transport_import_work (
|
|
134
|
+
writer TEXT NOT NULL, batch TEXT NOT NULL, sequence INTEGER NOT NULL,
|
|
135
|
+
${WORK}, refetch_attempts INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(writer,batch));
|
|
136
|
+
CREATE INDEX IF NOT EXISTS pdpp_import_work_dependency ON pdpp_transport_import_work(dependency) WHERE outcome='wait';
|
|
137
|
+
CREATE TABLE IF NOT EXISTS pdpp_transport_phase_state (
|
|
138
|
+
phase TEXT PRIMARY KEY, ${WORK}, fingerprint TEXT);
|
|
139
|
+
CREATE TABLE IF NOT EXISTS pdpp_transport_reconcile_pass (
|
|
140
|
+
id INTEGER PRIMARY KEY CHECK(id=1), dependency_json TEXT);
|
|
141
|
+
`;
|
|
142
|
+
const admissionMigration = `
|
|
143
|
+
CREATE TABLE IF NOT EXISTS pdpp_transport_version_squats (
|
|
144
|
+
scope TEXT NOT NULL, version INTEGER NOT NULL, digest TEXT NOT NULL,
|
|
145
|
+
detected_at INTEGER NOT NULL, batch TEXT NOT NULL, state TEXT NOT NULL CHECK(state IN ('squatted','resigning','registering','committed')),
|
|
146
|
+
PRIMARY KEY(scope,version));
|
|
147
|
+
CREATE TABLE IF NOT EXISTS pdpp_transport_registration_recovery (
|
|
148
|
+
batch TEXT PRIMARY KEY, version INTEGER NOT NULL, state TEXT NOT NULL CHECK(state IN ('resigning','registering','committed','own_key_conflict','version_squat')));
|
|
149
|
+
CREATE TABLE IF NOT EXISTS pdpp_transport_repository (
|
|
150
|
+
id INTEGER PRIMARY KEY CHECK(id=1), repository_id TEXT NOT NULL);
|
|
151
|
+
CREATE TABLE IF NOT EXISTS pdpp_transport_staged_objects (
|
|
152
|
+
object_id TEXT PRIMARY KEY, admission_id TEXT NOT NULL, repository_id TEXT NOT NULL,
|
|
153
|
+
owner TEXT NOT NULL, namespace_key TEXT NOT NULL, created_at INTEGER NOT NULL,
|
|
154
|
+
state TEXT NOT NULL CHECK(state IN ('receiving','complete','verified','attached','aborted')),
|
|
155
|
+
size_bytes INTEGER, sha256 TEXT, attached_at INTEGER);
|
|
156
|
+
CREATE INDEX IF NOT EXISTS pdpp_staged_objects_cleanup
|
|
157
|
+
ON pdpp_transport_staged_objects(state,created_at,object_id);
|
|
158
|
+
`;
|
|
159
|
+
const workState = (row) => ({
|
|
160
|
+
attempts: row.attempts,
|
|
161
|
+
nextEligible: row.next_attempt,
|
|
162
|
+
waitSince: row.wait_since ?? null,
|
|
163
|
+
checkedBlock: row.checked_block ?? null,
|
|
164
|
+
});
|
|
165
|
+
/** Values for WORK_SET, in order. `error` keeps a closed code, never exception text. */
|
|
166
|
+
function workValues(outcome, state) {
|
|
167
|
+
const reason = outcome.tag === "progress" ? null : outcomeReason(outcome);
|
|
168
|
+
return [
|
|
169
|
+
outcome.tag,
|
|
170
|
+
reason,
|
|
171
|
+
outcome.tag === "retry" && outcome.cause
|
|
172
|
+
? canonicalJson(outcome.cause)
|
|
173
|
+
: null,
|
|
174
|
+
outcome.tag === "wait" ? dependencyKey(outcome.dependency) : null,
|
|
175
|
+
outcome.tag === "progress" || outcome.tag === "wait" ? null : reason,
|
|
176
|
+
state.attempts,
|
|
177
|
+
state.nextEligible,
|
|
178
|
+
state.waitSince,
|
|
179
|
+
state.checkedBlock,
|
|
180
|
+
];
|
|
181
|
+
}
|
|
182
|
+
const WORK_SET = "outcome=?,reason=?,cause_json=?,dependency=?,error=?,attempts=?,next_attempt=?,wait_since=?,checked_block=?";
|
|
183
|
+
/** A durable quarantine with its closed reason, for a slot or fragment row. */
|
|
184
|
+
const QUARANTINE_SET = (reason) => `state='quarantined',outcome='quarantine',reason='${reason}',error='${reason}'`;
|
|
185
|
+
/** Column reset that makes a receiver row fresh work again. */
|
|
186
|
+
export const REPLICA_RESET_WORK = "outcome=NULL,reason=NULL,cause_json=NULL,dependency=NULL,error=NULL,attempts=0,wait_since=NULL,checked_block=NULL";
|
|
187
|
+
const RESET_WORK = REPLICA_RESET_WORK;
|
|
188
|
+
/** A slot's storage and evidence identity: the DPv2 `<scope>/<version>` key. */
|
|
189
|
+
const slotKey = (scope, version) => `${scope}/${version}`;
|
|
190
|
+
const encoder = new TextEncoder();
|
|
191
|
+
const blobsOf = (op) => op.kind === "blob-claim" ? [op.blob] : op.kind === "upsert" ? op.blobs : [];
|
|
192
|
+
const sameBytes = (a, b) => a.length === b.length && a.every((byte, index) => byte === b[index]);
|
|
193
|
+
/** `sha256:<hex>` of bytes. */
|
|
194
|
+
const sha256Digest = (bytes) => `sha256:${bytesToHex(sha256(bytes))}`;
|
|
195
|
+
/** The owner binding every phase runs under, validated before any phase. */
|
|
196
|
+
export class ReplicaAuthorityError extends Error {
|
|
197
|
+
code = "replica_authority_mismatch";
|
|
198
|
+
constructor() {
|
|
199
|
+
super("replica transport owner binding mismatch");
|
|
200
|
+
this.name = "ReplicaAuthorityError";
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* The database holds state of the earlier carrier (one registration per
|
|
205
|
+
* fragment). This worker cannot read it, and mixing the two layouts would
|
|
206
|
+
* corrupt both. Testers start such a database fresh.
|
|
207
|
+
*/
|
|
208
|
+
export class ReplicaCarrierMismatchError extends Error {
|
|
209
|
+
code = "replica_carrier_mismatch";
|
|
210
|
+
constructor() {
|
|
211
|
+
super("replica transport state is from the per-fragment carrier");
|
|
212
|
+
this.name = "ReplicaCarrierMismatchError";
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
/** Tables only the per-fragment carrier created. */
|
|
216
|
+
const PER_FRAGMENT_TABLES = [
|
|
217
|
+
"pdpp_transport_outbox",
|
|
218
|
+
"pdpp_transport_inbox",
|
|
219
|
+
"pdpp_transport_preparing",
|
|
220
|
+
];
|
|
221
|
+
/** Internal experiment only. No bootstrap wiring; false by default. */
|
|
222
|
+
export function createReplicaEngine(host, deps) {
|
|
223
|
+
const { transport } = deps;
|
|
224
|
+
const now = deps.now ?? Date.now;
|
|
225
|
+
const resourceBudget = deps.resourceBudget ?? processReplicaByteBudget;
|
|
226
|
+
const reconcilePage = deps.reconcilePage ?? REPLICA_RECONCILE_PAGE;
|
|
227
|
+
const resourceLimits = replicaResourceLimits(deps.resourceLimits);
|
|
228
|
+
const streamedLimits = {
|
|
229
|
+
...DEFAULT_REPLICA_STREAMED_LIMITS,
|
|
230
|
+
maxOperations: resourceLimits.maxOperations,
|
|
231
|
+
maxOperationBytes: resourceLimits.maxOperationBytes,
|
|
232
|
+
maxSingleBlobBytes: resourceLimits.maxSingleBlobBytes,
|
|
233
|
+
maxBlobTotalBytes: resourceLimits.maxBlobTotalBytes,
|
|
234
|
+
maxPayloadBytes: resourceLimits.maxTotalBytes,
|
|
235
|
+
};
|
|
236
|
+
const roster = createCachedReplicaRoster(deps.authority.roster, { now });
|
|
237
|
+
const server = deps.authority.signer.address.toLowerCase();
|
|
238
|
+
const registrationIntentTimeout = deps.registrationIntentTimeoutMs ?? REPLICA_REGISTRATION_INTENT_TIMEOUT_MS;
|
|
239
|
+
if (!Number.isSafeInteger(registrationIntentTimeout) ||
|
|
240
|
+
registrationIntentTimeout < 1 ||
|
|
241
|
+
registrationIntentTimeout > REPLICA_REGISTRATION_INTENT_TIMEOUT_MS)
|
|
242
|
+
throw new RangeError("invalid registration intent timeout");
|
|
243
|
+
const cap = deps.maxBlobBytes ?? REPLICA_MAX_BLOB_BYTES;
|
|
244
|
+
if (!Number.isSafeInteger(cap) || cap < 1 || cap > REPLICA_MAX_BLOB_BYTES)
|
|
245
|
+
throw new RangeError("invalid storage cap");
|
|
246
|
+
const maxOperations = deps.maxBatchOperations ?? REPLICA_BATCH_OPERATIONS;
|
|
247
|
+
const maxBytes = deps.maxBatchBytes ?? REPLICA_BATCH_BYTES;
|
|
248
|
+
if (!Number.isSafeInteger(maxOperations) ||
|
|
249
|
+
maxOperations < 1 ||
|
|
250
|
+
maxOperations > resourceLimits.maxOperations ||
|
|
251
|
+
!Number.isSafeInteger(maxBytes) ||
|
|
252
|
+
maxBytes < 1)
|
|
253
|
+
throw new RangeError("invalid replica batch bounds");
|
|
254
|
+
const { door, exporter, receiver } = host;
|
|
255
|
+
const enabled = () => deps.enabled === true;
|
|
256
|
+
let activeStepSignal;
|
|
257
|
+
let coordinatorOwner;
|
|
258
|
+
/** One effect now; Node runs it outside a transaction, as plain statements. */
|
|
259
|
+
const query = (effect) => host.run(effect);
|
|
260
|
+
/** One effect in its own immediate write transaction. */
|
|
261
|
+
const atomic = (effect) => host.run(host.immediate(effect));
|
|
262
|
+
/** One effect in its own deferred transaction. */
|
|
263
|
+
const deferred = (effect) => host.run(host.transaction(effect));
|
|
264
|
+
const payloadChunks = (key) => readReplicaPayload(query, key);
|
|
265
|
+
function* state() {
|
|
266
|
+
return (yield* sql.get("SELECT * FROM pdpp_transport_state WHERE id=1"));
|
|
267
|
+
}
|
|
268
|
+
function* ensureAdmissionSchema() {
|
|
269
|
+
if (!enabled())
|
|
270
|
+
return;
|
|
271
|
+
yield* sql.exec(admissionMigration);
|
|
272
|
+
yield* sql.exec(REPLICA_COORDINATOR_SCHEMA);
|
|
273
|
+
yield* sql.exec(REPLICA_PREPARATION_MIGRATION);
|
|
274
|
+
// #504 refusals have not advanced the source cursor. Let the new per-op
|
|
275
|
+
// classifier retain that exact pending page, instead of keeping its wedge.
|
|
276
|
+
yield* sql.run(`UPDATE pdpp_transport_phase_state SET ${RESET_WORK},next_attempt=0
|
|
277
|
+
WHERE phase='retain' AND outcome='unsupported' AND reason='resource_unsupported'`);
|
|
278
|
+
yield* sql.run(`UPDATE pdpp_transport_batches SET prepared=0,${RESET_WORK},next_attempt=0
|
|
279
|
+
WHERE prepared=-2 AND NOT EXISTS (SELECT 1 FROM pdpp_transport_preparation p WHERE p.batch=pdpp_transport_batches.batch)`);
|
|
280
|
+
const columns = yield* sql.all("PRAGMA table_info(pdpp_transport_manifests_in)");
|
|
281
|
+
if (!columns.some((column) => column.name === "resource_limits_version"))
|
|
282
|
+
yield* sql.exec("ALTER TABLE pdpp_transport_manifests_in ADD COLUMN resource_limits_version INTEGER");
|
|
283
|
+
// The tail cursor after a tail batch's operations; NULL for a baseline
|
|
284
|
+
// batch and for batches retained before this column.
|
|
285
|
+
if (!(yield* sql.all("PRAGMA table_info(pdpp_transport_batches)")).some((column) => column.name === "export_next"))
|
|
286
|
+
yield* sql.exec("ALTER TABLE pdpp_transport_batches ADD COLUMN export_next INTEGER");
|
|
287
|
+
yield* sql.run("INSERT OR IGNORE INTO pdpp_transport_repository (id,repository_id) VALUES (1,?)", host.randomId());
|
|
288
|
+
yield* sql.run(`UPDATE pdpp_transport_manifests_in
|
|
289
|
+
SET ${RESET_WORK},resource_limits_version=NULL
|
|
290
|
+
WHERE outcome='unsupported' AND reason='resource_unsupported'
|
|
291
|
+
AND coalesce(resource_limits_version,0) < ?`, REPLICA_LIMITS_VERSION);
|
|
292
|
+
yield* sql.run(`UPDATE pdpp_transport_import_work SET ${RESET_WORK}
|
|
293
|
+
WHERE outcome='unsupported' AND reason='resource_unsupported'
|
|
294
|
+
AND EXISTS (
|
|
295
|
+
SELECT 1 FROM pdpp_transport_manifests_in m
|
|
296
|
+
WHERE m.writer=pdpp_transport_import_work.writer
|
|
297
|
+
AND m.batch=pdpp_transport_import_work.batch
|
|
298
|
+
AND m.outcome IS NULL
|
|
299
|
+
)`);
|
|
300
|
+
}
|
|
301
|
+
function* repositoryId() {
|
|
302
|
+
const row = yield* sql.get("SELECT repository_id FROM pdpp_transport_repository WHERE id=1");
|
|
303
|
+
if (!row)
|
|
304
|
+
throw new Error("replication repository identity is missing");
|
|
305
|
+
return row.repository_id;
|
|
306
|
+
}
|
|
307
|
+
function stagingStore(injected = true) {
|
|
308
|
+
if (!enabled())
|
|
309
|
+
throw new Error("replication admission is disabled");
|
|
310
|
+
return then(query((function* () {
|
|
311
|
+
yield* ensureAdmissionSchema();
|
|
312
|
+
return yield* repositoryId();
|
|
313
|
+
})()), (id) => host.staging({
|
|
314
|
+
injected,
|
|
315
|
+
repositoryId: id,
|
|
316
|
+
owner: deps.expected().owner,
|
|
317
|
+
now,
|
|
318
|
+
assertOpen: () => {
|
|
319
|
+
const signal = activeStepSignal;
|
|
320
|
+
if (signal)
|
|
321
|
+
door.assertStepOpen(signal);
|
|
322
|
+
else if (!door.admitted())
|
|
323
|
+
throw new Error("replication door is closed");
|
|
324
|
+
},
|
|
325
|
+
}));
|
|
326
|
+
}
|
|
327
|
+
/** Construction-time storage work: synchronous on Node, as before. */
|
|
328
|
+
const ready = then(query((function* () {
|
|
329
|
+
if (yield* sql.get(`SELECT 1 FROM sqlite_master WHERE type='table' AND name IN (${PER_FRAGMENT_TABLES.map(() => "?").join(",")})`, ...PER_FRAGMENT_TABLES))
|
|
330
|
+
throw new ReplicaCarrierMismatchError();
|
|
331
|
+
yield* sql.exec(REPLICA_ENGINE_MIGRATION);
|
|
332
|
+
})()), () => {
|
|
333
|
+
receiver.setPendingEnabled(deps.enabled === true);
|
|
334
|
+
if (!enabled())
|
|
335
|
+
return;
|
|
336
|
+
return then(query(ensureAdmissionSchema()), () => {
|
|
337
|
+
if (!door.admitted())
|
|
338
|
+
return;
|
|
339
|
+
return then(stagingStore(false), (stages) => then(stages.cleanup(), () => then(stages.cleanupAttachedTemporaryBytes(), () => undefined)));
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
// Owner and writer of the running step, read under the writer lock. The
|
|
343
|
+
// owner binding never changes, and only authority() changes the writer.
|
|
344
|
+
let bound;
|
|
345
|
+
const stepState = () => {
|
|
346
|
+
if (!bound)
|
|
347
|
+
throw new Error("replication state read outside a worker step");
|
|
348
|
+
return bound;
|
|
349
|
+
};
|
|
350
|
+
const owner = () => stepState().owner;
|
|
351
|
+
const writer = () => stepState().writer;
|
|
352
|
+
/**
|
|
353
|
+
* One validated authority context for every phase: the expected owner, the
|
|
354
|
+
* persisted binding, the transport port's owner and the writer identity's
|
|
355
|
+
* owner must agree. Checked before any phase runs, so a wrong-owner reopen
|
|
356
|
+
* cannot encrypt, publish or import. False, with nothing written, when the
|
|
357
|
+
* replication door closed since the step began.
|
|
358
|
+
*/
|
|
359
|
+
function authority(identity) {
|
|
360
|
+
const expectedOwner = deps.expected().owner;
|
|
361
|
+
return atomic((function* () {
|
|
362
|
+
if (!door.admitted())
|
|
363
|
+
return false;
|
|
364
|
+
yield* sql.run("INSERT OR IGNORE INTO pdpp_transport_state (id,owner,writer) VALUES (1,?,?)", expectedOwner, identity.transportWriter);
|
|
365
|
+
const current = yield* state();
|
|
366
|
+
if (current.owner !== expectedOwner ||
|
|
367
|
+
identity.seed.owner !== expectedOwner.toLowerCase() ||
|
|
368
|
+
(transport.owner !== undefined &&
|
|
369
|
+
transport.owner.toLowerCase() !== expectedOwner.toLowerCase()))
|
|
370
|
+
throw new ReplicaAuthorityError();
|
|
371
|
+
if (current.writer !== identity.transportWriter)
|
|
372
|
+
yield* startWriter(identity);
|
|
373
|
+
return { owner: current.owner, writer: identity.transportWriter };
|
|
374
|
+
})());
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* A new writer identity (re-registration, reset or restore) starts its own
|
|
378
|
+
* chain at sequence 1, and its first publication is a full current-state
|
|
379
|
+
* baseline under the new writer, in the new writer's own scope from
|
|
380
|
+
* version 1. The old writer's batches, fragments and manifests that are
|
|
381
|
+
* not yet published are abandoned, so its scope gets no new version: their
|
|
382
|
+
* operations name the old operation writer, which no receiver accepts from
|
|
383
|
+
* the new one. The baseline carries their effect instead, so a peer that
|
|
384
|
+
* missed any old batch still converges. The same transaction switches the
|
|
385
|
+
* writer and pins the baseline; a crash resumes it from its durable
|
|
386
|
+
* cursor. The rebaseline is a capture, so the door's fence does not count
|
|
387
|
+
* it as a bypass.
|
|
388
|
+
*/
|
|
389
|
+
function* startWriter(identity) {
|
|
390
|
+
const baseline = yield* host.captureWrites(exporter.rebaseline());
|
|
391
|
+
yield* sql.run("UPDATE pdpp_transport_state SET writer=?,sequence=0,previous_json=NULL,baseline_cursor=?,baseline_done=0,tail_cursor=NULL WHERE id=1", identity.transportWriter, baseline?.after ?? 0);
|
|
392
|
+
yield* sql.run(`UPDATE pdpp_transport_discover_floor SET version=min(version,(
|
|
393
|
+
SELECT min(o.version)-1 FROM pdpp_transport_manifests_out o
|
|
394
|
+
WHERE o.scope=pdpp_transport_discover_floor.scope))
|
|
395
|
+
WHERE scope IN (SELECT scope FROM pdpp_transport_manifests_out)`);
|
|
396
|
+
for (const table of [
|
|
397
|
+
"pdpp_transport_batches",
|
|
398
|
+
"pdpp_transport_staged_fragments",
|
|
399
|
+
"pdpp_transport_fragments_out",
|
|
400
|
+
"pdpp_transport_manifests_out",
|
|
401
|
+
"pdpp_transport_preparation",
|
|
402
|
+
"pdpp_transport_preparation_progress",
|
|
403
|
+
"pdpp_transport_operation_outcomes",
|
|
404
|
+
"pdpp_transport_upload_attempts",
|
|
405
|
+
"pdpp_transport_registration_recovery",
|
|
406
|
+
])
|
|
407
|
+
yield* sql.run(`DELETE FROM ${table}`);
|
|
408
|
+
}
|
|
409
|
+
/** This receiver's partition: its owner, chain and environment. */
|
|
410
|
+
const partition = () => ({
|
|
411
|
+
owner: owner(),
|
|
412
|
+
chainId: deps.authority.chainId,
|
|
413
|
+
environment: deps.authority.environment,
|
|
414
|
+
});
|
|
415
|
+
/**
|
|
416
|
+
* Receiver checks 2-5 (host contract §3.4) for the manifest registered at
|
|
417
|
+
* `slot`: its statement must sign this manifest at this version, and the
|
|
418
|
+
* writer it derives must be the one the slot's scope names.
|
|
419
|
+
*/
|
|
420
|
+
const authorizeManifest = (manifest, slot, signal) => authorizeReplicaBatch({
|
|
421
|
+
authority: manifest.authority,
|
|
422
|
+
facts: replicaManifestFacts(manifest, slot.version),
|
|
423
|
+
transportWriter: slot.writer,
|
|
424
|
+
receiver: partition(),
|
|
425
|
+
roster: (address) => roster.server(address, { signal }),
|
|
426
|
+
});
|
|
427
|
+
const rosterWait = (address) => ({
|
|
428
|
+
tag: "wait",
|
|
429
|
+
dependency: { kind: "roster", key: address },
|
|
430
|
+
});
|
|
431
|
+
/** An attempt with no durable effect besides its outcome. */
|
|
432
|
+
const verdict = (outcome) => ({
|
|
433
|
+
outcome,
|
|
434
|
+
});
|
|
435
|
+
/** An attempt whose effect commits only together with its own outcome. */
|
|
436
|
+
const withPlan = (outcome, effect) => ({
|
|
437
|
+
outcome,
|
|
438
|
+
plan: function* () {
|
|
439
|
+
yield* effect();
|
|
440
|
+
},
|
|
441
|
+
});
|
|
442
|
+
const quarantine = (reason, key) => ({ tag: "quarantine", proof: { reason, key } });
|
|
443
|
+
/**
|
|
444
|
+
* Every adapter's commit: in one transaction run this attempt's plan,
|
|
445
|
+
* settle the (possibly refined) verdict and write the item's state.
|
|
446
|
+
*/
|
|
447
|
+
const commitIn = (write, isCurrent) => (item, attempt, settle, context) => {
|
|
448
|
+
const signal = activeStepSignal;
|
|
449
|
+
if (!signal)
|
|
450
|
+
throw new Error("replication commit outside a worker step");
|
|
451
|
+
// The coordinator's outer transaction has already raised the capture
|
|
452
|
+
// guard. The host keeps the pre-transaction check for direct callers.
|
|
453
|
+
if (!enabled())
|
|
454
|
+
throw new ReplicaProblemError("prepare_failed");
|
|
455
|
+
return host.commit((function* () {
|
|
456
|
+
door.assertTransactionOpen(signal);
|
|
457
|
+
// A receipt belongs to the exact row state selected by `due`. If a
|
|
458
|
+
// newer worker changed it while this attempt was in flight, discard
|
|
459
|
+
// the older receipt and its plan before any projection can advance.
|
|
460
|
+
if (isCurrent && !(yield* isCurrent(item)))
|
|
461
|
+
return {
|
|
462
|
+
tag: "retry",
|
|
463
|
+
problem: "storage_failed",
|
|
464
|
+
nextEligible: item.state.nextEligible,
|
|
465
|
+
};
|
|
466
|
+
const planned = attempt.plan ? yield* attempt.plan() : undefined;
|
|
467
|
+
const settled = settle(planned ?? attempt.outcome);
|
|
468
|
+
yield* write(settled.outcome, settled.state, item);
|
|
469
|
+
return settled.outcome;
|
|
470
|
+
})(), context, signal);
|
|
471
|
+
};
|
|
472
|
+
/** Network calls stop at the attempt's deadline or the host's abort. */
|
|
473
|
+
const call = (signal, start) => abortable(signal, async () => {
|
|
474
|
+
if (!enabled())
|
|
475
|
+
throw new ReplicaProblemError("prepare_failed");
|
|
476
|
+
door.assertStepOpen(signal);
|
|
477
|
+
const value = await start();
|
|
478
|
+
if (!enabled())
|
|
479
|
+
throw new ReplicaProblemError("prepare_failed");
|
|
480
|
+
door.assertStepOpen(signal);
|
|
481
|
+
return value;
|
|
482
|
+
});
|
|
483
|
+
/** The coordinator's eligibility policy over a work table's columns. */
|
|
484
|
+
const due = (e) => `${replicaEligibleAtSql(e)} <= ${e.now}`;
|
|
485
|
+
/** Earliest eligibility among rows of `from` (a table plus WHERE clause). */
|
|
486
|
+
function* earliestIn(e, from) {
|
|
487
|
+
const row = yield* sql.get(`SELECT min(${replicaEligibleAtSql(e)}) AS next FROM ${from}`);
|
|
488
|
+
return row?.next ?? undefined;
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* Writer-fair due selection (see ReplicaPhaseHandler.due). Parameters:
|
|
492
|
+
* perWriter, the writer served last ('' for none), total.
|
|
493
|
+
*/
|
|
494
|
+
const dueWindow = (from, writer, order, where) => `SELECT * FROM (SELECT *, ROW_NUMBER() OVER (PARTITION BY ${writer} ORDER BY ${order}) AS writer_rank FROM ${from} WHERE ${where}) WHERE writer_rank<=? ORDER BY writer_rank, ${writer}<=?, ${writer}, ${order} LIMIT ?`;
|
|
495
|
+
const finalityWait = (evidence, key) => ({
|
|
496
|
+
tag: "wait",
|
|
497
|
+
dependency: {
|
|
498
|
+
kind: "finality",
|
|
499
|
+
key,
|
|
500
|
+
checkedBlock: String(evidence.chain.blockNumber),
|
|
501
|
+
},
|
|
502
|
+
});
|
|
503
|
+
/** The verdict for a slot whose registration is not confirmed yet. */
|
|
504
|
+
const unconfirmed = (evidence, slot) => replicaAwaitingFinality(evidence, slot) ||
|
|
505
|
+
replicaRegistrationInactive(evidence, slot)
|
|
506
|
+
? finalityWait(evidence, slotKey(slot.scope, slot.version))
|
|
507
|
+
: { tag: "retry", problem: "registration_unconfirmed" };
|
|
508
|
+
function* singleton(phase) {
|
|
509
|
+
return ((yield* sql.get("SELECT * FROM pdpp_transport_phase_state WHERE phase=?", phase)) ?? {
|
|
510
|
+
outcome: null,
|
|
511
|
+
attempts: 0,
|
|
512
|
+
next_attempt: 0,
|
|
513
|
+
wait_since: null,
|
|
514
|
+
checked_block: null,
|
|
515
|
+
fingerprint: null,
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
function* settleSingleton(phase, outcome, work, fingerprint) {
|
|
519
|
+
yield* sql.run(`INSERT INTO pdpp_transport_phase_state (phase) VALUES (?) ON CONFLICT(phase) DO NOTHING`, phase);
|
|
520
|
+
yield* sql.run(`UPDATE pdpp_transport_phase_state SET ${WORK_SET}${fingerprint === undefined ? "" : ",fingerprint=?"} WHERE phase=?`, ...workValues(outcome, work), ...(fingerprint === undefined ? [] : [fingerprint]), phase);
|
|
521
|
+
}
|
|
522
|
+
/** A singleton phase's durable deadline, by the shared eligibility policy. */
|
|
523
|
+
function singletonEligibleAt(row, e) {
|
|
524
|
+
return replicaEligibleAt({
|
|
525
|
+
finalityWait: false,
|
|
526
|
+
nextEligible: row.next_attempt,
|
|
527
|
+
waitSince: row.wait_since,
|
|
528
|
+
checkedBlock: row.checked_block,
|
|
529
|
+
}, e);
|
|
530
|
+
}
|
|
531
|
+
/** Only an outstanding Retry or Wait of a singleton is a deadline. */
|
|
532
|
+
function* singletonDeadline(phase, e) {
|
|
533
|
+
const row = yield* singleton(phase);
|
|
534
|
+
return row.outcome === "retry" || row.outcome === "wait"
|
|
535
|
+
? singletonEligibleAt(row, e)
|
|
536
|
+
: undefined;
|
|
537
|
+
}
|
|
538
|
+
/** The local export has operations not yet retained. One indexed probe. */
|
|
539
|
+
function* tailPending() {
|
|
540
|
+
const s = yield* state();
|
|
541
|
+
if (!s.baseline_done)
|
|
542
|
+
return true;
|
|
543
|
+
const page = yield* exporter.readTailPage({
|
|
544
|
+
limit: 1,
|
|
545
|
+
...(s.tail_cursor === null ? {} : { afterSequence: s.tail_cursor }),
|
|
546
|
+
});
|
|
547
|
+
return page.operations.length > 0;
|
|
548
|
+
}
|
|
549
|
+
/**
|
|
550
|
+
* Keep collecting source operations behind one unresolved publication. The
|
|
551
|
+
* current manifest reports its typed wait. Only an explicit no-slot
|
|
552
|
+
* Unsupported batch, a committed manifest, or a proven immutable-slot
|
|
553
|
+
* conflict frees a claim. Every other or unknown batch state blocks.
|
|
554
|
+
*/
|
|
555
|
+
const batchSettled = (b) => `(
|
|
556
|
+
(${b}.prepared=-1 AND NOT EXISTS (SELECT 1 FROM pdpp_transport_preparation p WHERE p.batch=${b}.batch AND p.wait_json IS NOT NULL))
|
|
557
|
+
OR (${b}.prepared=1 AND NOT EXISTS (SELECT 1 FROM pdpp_transport_preparation p WHERE p.batch=${b}.batch AND p.state<>'prepared')
|
|
558
|
+
AND EXISTS (SELECT 1 FROM pdpp_transport_manifests_out m WHERE m.batch=${b}.batch
|
|
559
|
+
AND (m.state='published' OR (m.state='quarantined' AND m.outcome='quarantine' AND m.reason='immutable_registration_conflict')))))`;
|
|
560
|
+
function* batchInFlight() {
|
|
561
|
+
return !!(yield* sql.get(`SELECT 1 FROM pdpp_transport_batches b WHERE NOT ${batchSettled("b")} LIMIT 1`));
|
|
562
|
+
}
|
|
563
|
+
// A legacy queue can contain several retained batches. It follows the same
|
|
564
|
+
// claim gate as new journal work, including unknown future predecessor states.
|
|
565
|
+
const prepareUnblocked = `NOT EXISTS (SELECT 1 FROM pdpp_transport_batches predecessor
|
|
566
|
+
WHERE predecessor.sequence<b.sequence AND NOT ${batchSettled("predecessor")})`;
|
|
567
|
+
/**
|
|
568
|
+
* Wire bytes an operation adds to a payload: its JSON and each new blob.
|
|
569
|
+
* `shares` is true when it references a blob the payload already carries.
|
|
570
|
+
*/
|
|
571
|
+
function operationBytes(op, seen) {
|
|
572
|
+
const snapshot = snapshotReplicaOperation(op);
|
|
573
|
+
if (snapshot.encoding_failed)
|
|
574
|
+
return { json: 0, blobs: 0, shares: false };
|
|
575
|
+
const json = encoder.encode(snapshot.operation_json).length + 1;
|
|
576
|
+
let blobs = 0;
|
|
577
|
+
let shares = false;
|
|
578
|
+
for (const blob of blobsOf(op))
|
|
579
|
+
if (seen.has(blob.blobId))
|
|
580
|
+
shares = true;
|
|
581
|
+
else {
|
|
582
|
+
seen.add(blob.blobId);
|
|
583
|
+
blobs += 4 * Math.ceil(blob.sizeBytes / 3) + blob.blobId.length + 16;
|
|
584
|
+
}
|
|
585
|
+
return { json, blobs, shares };
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* Read export pages from the cursor until a batch bound is reached or the
|
|
589
|
+
* export has no more. A page that overflows the byte bound is read again
|
|
590
|
+
* with a smaller limit, so the cursor ends exactly after the last operation
|
|
591
|
+
* taken. Baseline and tail operations never share a batch.
|
|
592
|
+
* Every batch carries the blobs its operations reference. An operation that
|
|
593
|
+
* only shares blobs already in the batch is bounded by JSON bytes alone:
|
|
594
|
+
* cutting it into the next batch would send those blobs again.
|
|
595
|
+
*/
|
|
596
|
+
function* readBatch(s) {
|
|
597
|
+
const read = (afterSequence, limit) => s.baseline_done
|
|
598
|
+
? exporter.readTailPage({
|
|
599
|
+
limit,
|
|
600
|
+
...(afterSequence === undefined ? {} : { afterSequence }),
|
|
601
|
+
})
|
|
602
|
+
: exporter.readBaselinePage({
|
|
603
|
+
limit,
|
|
604
|
+
afterSequence: afterSequence ?? s.baseline_cursor,
|
|
605
|
+
});
|
|
606
|
+
const operations = [];
|
|
607
|
+
const seen = new Set();
|
|
608
|
+
let bytes = 0;
|
|
609
|
+
let jsonBytes = 0;
|
|
610
|
+
let cursor = s.baseline_done
|
|
611
|
+
? (s.tail_cursor ?? undefined)
|
|
612
|
+
: s.baseline_cursor;
|
|
613
|
+
let hasMore = true;
|
|
614
|
+
let next = cursor ?? 0;
|
|
615
|
+
while (hasMore && operations.length < maxOperations) {
|
|
616
|
+
let page = yield* read(cursor, Math.min(EXPORT_PAGE, maxOperations - operations.length));
|
|
617
|
+
const trial = new Set(seen);
|
|
618
|
+
let fit = 0;
|
|
619
|
+
let added = 0;
|
|
620
|
+
let addedJson = 0;
|
|
621
|
+
for (const op of page.operations) {
|
|
622
|
+
const size = operationBytes(op, trial);
|
|
623
|
+
const fits = operations.length + fit === 0 ||
|
|
624
|
+
bytes + added + size.json + size.blobs <= maxBytes ||
|
|
625
|
+
(size.shares &&
|
|
626
|
+
size.blobs === 0 &&
|
|
627
|
+
jsonBytes + addedJson + size.json <= maxBytes);
|
|
628
|
+
if (!fits)
|
|
629
|
+
break;
|
|
630
|
+
fit++;
|
|
631
|
+
added += size.json + size.blobs;
|
|
632
|
+
addedJson += size.json;
|
|
633
|
+
}
|
|
634
|
+
const full = fit < page.operations.length;
|
|
635
|
+
if (full && fit === 0)
|
|
636
|
+
break;
|
|
637
|
+
if (full)
|
|
638
|
+
page = yield* read(cursor, fit);
|
|
639
|
+
for (const op of page.operations) {
|
|
640
|
+
operations.push(op);
|
|
641
|
+
operationBytes(op, seen);
|
|
642
|
+
}
|
|
643
|
+
bytes += added;
|
|
644
|
+
jsonBytes += addedJson;
|
|
645
|
+
cursor = next = page.nextAfterSequence;
|
|
646
|
+
hasMore = full || page.hasMore;
|
|
647
|
+
if (full)
|
|
648
|
+
break;
|
|
649
|
+
}
|
|
650
|
+
return { operations, next, hasMore };
|
|
651
|
+
}
|
|
652
|
+
const retain = {
|
|
653
|
+
phase: "retain",
|
|
654
|
+
due(e) {
|
|
655
|
+
return query((function* () {
|
|
656
|
+
const row = yield* singleton("retain");
|
|
657
|
+
const at = singletonEligibleAt(row, e);
|
|
658
|
+
if (at === undefined || at > e.now)
|
|
659
|
+
return [];
|
|
660
|
+
// An idle tail costs one indexed probe and no budget unit.
|
|
661
|
+
if ((yield* batchInFlight()) || !(yield* tailPending()))
|
|
662
|
+
return [];
|
|
663
|
+
return [
|
|
664
|
+
{
|
|
665
|
+
id: "retain",
|
|
666
|
+
phase: "retain",
|
|
667
|
+
writer: "",
|
|
668
|
+
state: workState(row),
|
|
669
|
+
row,
|
|
670
|
+
},
|
|
671
|
+
];
|
|
672
|
+
})());
|
|
673
|
+
},
|
|
674
|
+
async run() {
|
|
675
|
+
const s = await query(state());
|
|
676
|
+
const batch = host.randomId().replaceAll("-", "");
|
|
677
|
+
let page;
|
|
678
|
+
let digest = "", totalBytes = 0;
|
|
679
|
+
try {
|
|
680
|
+
page = await query(readBatch(s));
|
|
681
|
+
if (page.operations.length) {
|
|
682
|
+
const envelope = {
|
|
683
|
+
format: "vana-pdpp-replica",
|
|
684
|
+
version: 1,
|
|
685
|
+
owner: s.owner,
|
|
686
|
+
batchId: batch,
|
|
687
|
+
operations: page.operations.filter((op) => !snapshotReplicaOperation(op).encoding_failed),
|
|
688
|
+
};
|
|
689
|
+
const payload = await query(replicaPayloadWriter(batch));
|
|
690
|
+
const hash = sha256.create();
|
|
691
|
+
for await (const bytes of encodeReplicaPayload(envelope, async function* (id) {
|
|
692
|
+
yield* exporter.readPinnedBlobChunks(id);
|
|
693
|
+
})) {
|
|
694
|
+
await deferred(payload.write(bytes));
|
|
695
|
+
hash.update(bytes);
|
|
696
|
+
totalBytes += bytes.length;
|
|
697
|
+
}
|
|
698
|
+
await query(payload.finish());
|
|
699
|
+
digest = `sha256:${bytesToHex(hash.digest())}`;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
catch (error) {
|
|
703
|
+
await query(deleteReplicaPayload(batch));
|
|
704
|
+
throw new ReplicaProblemError("retain_failed", { cause: error });
|
|
705
|
+
}
|
|
706
|
+
// Queue + export cursor commit together with the phase outcome. They
|
|
707
|
+
// acknowledge durable local responsibility, not publication.
|
|
708
|
+
return withPlan({ tag: "progress", receipt: { phase: "retain", key: batch } }, function* () {
|
|
709
|
+
const current = yield* state();
|
|
710
|
+
if (current.sequence !== s.sequence ||
|
|
711
|
+
current.baseline_cursor !== s.baseline_cursor ||
|
|
712
|
+
current.baseline_done !== s.baseline_done ||
|
|
713
|
+
current.tail_cursor !== s.tail_cursor)
|
|
714
|
+
throw new ReplicaProblemError("retain_failed");
|
|
715
|
+
if (page.operations.length) {
|
|
716
|
+
if (!(yield* verifyReplicaPayload(batch, digest, totalBytes)))
|
|
717
|
+
throw new ReplicaProblemError("retain_failed");
|
|
718
|
+
yield* sql.run("INSERT INTO pdpp_transport_batches (batch,sequence,previous_json,collected_at,digest,total_bytes,export_next) VALUES (?,?,?,?,?,?,?)", batch, s.sequence + 1, s.previous_json, new Date(now()).toISOString(), digest, totalBytes, s.baseline_done ? page.next : null);
|
|
719
|
+
yield* retainPreparationOperations(batch, page.operations);
|
|
720
|
+
yield* sql.run("UPDATE pdpp_transport_state SET sequence=?, previous_json=? WHERE id=1", s.sequence + 1, canonicalJson({ batch, digest }));
|
|
721
|
+
}
|
|
722
|
+
if (!s.baseline_done) {
|
|
723
|
+
yield* exporter.acknowledgeBaselinePage(yield* exporter.getBaselineCheckpoint(), page.next);
|
|
724
|
+
yield* sql.run("UPDATE pdpp_transport_state SET baseline_cursor=?, baseline_done=? WHERE id=1", page.next, page.hasMore ? 0 : 1);
|
|
725
|
+
}
|
|
726
|
+
else
|
|
727
|
+
yield* sql.run("UPDATE pdpp_transport_state SET tail_cursor=? WHERE id=1", page.next);
|
|
728
|
+
});
|
|
729
|
+
},
|
|
730
|
+
commit: commitIn((outcome, work) => settleSingleton("retain", outcome, work)),
|
|
731
|
+
commitProblem: "retain_failed",
|
|
732
|
+
earliest(e) {
|
|
733
|
+
return query((function* () {
|
|
734
|
+
const deadline = yield* singletonDeadline("retain", e);
|
|
735
|
+
if (deadline !== undefined)
|
|
736
|
+
return deadline;
|
|
737
|
+
return !(yield* batchInFlight()) && (yield* tailPending())
|
|
738
|
+
? e.now
|
|
739
|
+
: undefined;
|
|
740
|
+
})());
|
|
741
|
+
},
|
|
742
|
+
};
|
|
743
|
+
/**
|
|
744
|
+
* The exact retained operations, independent of later journal writes. Read
|
|
745
|
+
* in pages that start below REPLICA_TEXT_PART_BYTES of operation JSON, so
|
|
746
|
+
* one call returns that much plus at most one operation: a batch can hold
|
|
747
|
+
* far more than one phone SQL call carries.
|
|
748
|
+
*/
|
|
749
|
+
function* preparationRows(batch) {
|
|
750
|
+
const rows = [];
|
|
751
|
+
for (let after = -1;;) {
|
|
752
|
+
const page = yield* sql.all(`SELECT position,operation_json,encoding_failed FROM (
|
|
753
|
+
SELECT position,operation_json,encoding_failed,
|
|
754
|
+
sum(length(CAST(coalesce(operation_json,'') AS BLOB))) OVER (ORDER BY position)
|
|
755
|
+
- length(CAST(coalesce(operation_json,'') AS BLOB)) AS before
|
|
756
|
+
FROM pdpp_transport_operation_outcomes
|
|
757
|
+
WHERE batch=? AND outcome IS NULL AND position>?)
|
|
758
|
+
WHERE before<? ORDER BY position`, batch, after, REPLICA_TEXT_PART_BYTES);
|
|
759
|
+
if (!page.length)
|
|
760
|
+
return rows;
|
|
761
|
+
rows.push(...page);
|
|
762
|
+
after = page.at(-1).position;
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
function preparationOf(batch) {
|
|
766
|
+
return sql.get("SELECT * FROM pdpp_transport_preparation WHERE batch=?", batch);
|
|
767
|
+
}
|
|
768
|
+
/** Released framing claims are history; every other reserved version counts. */
|
|
769
|
+
function* nextPreparationVersion() {
|
|
770
|
+
// An owner call between steps (readmitUnsupported) reads the stored writer.
|
|
771
|
+
const current = bound ? bound.writer : (yield* state()).writer;
|
|
772
|
+
return (yield* sql.get(`SELECT coalesce(max(version),0)+1 AS version FROM (
|
|
773
|
+
SELECT version FROM pdpp_transport_manifests_out WHERE scope=?
|
|
774
|
+
UNION ALL SELECT version FROM pdpp_transport_preparation WHERE state<>'released')`, replicaScope(current))).version;
|
|
775
|
+
}
|
|
776
|
+
function* claimPreparation(batch) {
|
|
777
|
+
if (yield* preparationOf(batch))
|
|
778
|
+
return;
|
|
779
|
+
const manifest = yield* sql.get("SELECT version FROM pdpp_transport_manifests_out WHERE batch=?", batch);
|
|
780
|
+
yield* sql.run("INSERT INTO pdpp_transport_preparation (batch,version,state) VALUES (?,?,'preparing')", batch, manifest?.version ?? (yield* nextPreparationVersion()));
|
|
781
|
+
}
|
|
782
|
+
function* outstandingPreparationClaim(except) {
|
|
783
|
+
return Boolean(yield* sql.get(`SELECT 1 FROM pdpp_transport_preparation p
|
|
784
|
+
LEFT JOIN pdpp_transport_batches b ON b.batch=p.batch
|
|
785
|
+
WHERE p.batch<>? AND p.state<>'released' AND (b.batch IS NULL OR NOT ${batchSettled("b")}) LIMIT 1`, except));
|
|
786
|
+
}
|
|
787
|
+
/** Reserve readmission atomically; leave outstanding successors free to settle. */
|
|
788
|
+
function* reserveReadmission(batch) {
|
|
789
|
+
if (yield* outstandingPreparationClaim(batch))
|
|
790
|
+
return false;
|
|
791
|
+
const version = yield* nextPreparationVersion();
|
|
792
|
+
yield* sql.run(`INSERT INTO pdpp_transport_preparation (batch,version,state) VALUES (?,?,'preparing')
|
|
793
|
+
ON CONFLICT(batch) DO UPDATE SET version=excluded.version,state='preparing',wait_json=NULL`, batch, version);
|
|
794
|
+
yield* sql.run(`UPDATE pdpp_transport_batches SET prepared=0,${RESET_WORK},next_attempt=0 WHERE batch=?`, batch);
|
|
795
|
+
return true;
|
|
796
|
+
}
|
|
797
|
+
function assertPreparationClaim(batch, version, signal) {
|
|
798
|
+
// Claim ownership and the final slot are checked together immediately before
|
|
799
|
+
// signing. Signing itself is async and never holds a SQLite transaction open.
|
|
800
|
+
return atomic((function* () {
|
|
801
|
+
door.assertStepOpen(signal);
|
|
802
|
+
const current = yield* preparationOf(batch);
|
|
803
|
+
const next = (yield* sql.get("SELECT coalesce(max(version),0)+1 AS version FROM pdpp_transport_manifests_out WHERE scope=?", replicaScope(writer()))).version;
|
|
804
|
+
const claims = (yield* sql.get("SELECT count(*) AS n FROM pdpp_transport_preparation WHERE version=? AND state<>'released'", version)).n;
|
|
805
|
+
if (current?.state !== "preparing" ||
|
|
806
|
+
current.version !== version ||
|
|
807
|
+
version !== next ||
|
|
808
|
+
claims !== 1)
|
|
809
|
+
throw new ReplicaProblemError("prepare_failed");
|
|
810
|
+
})());
|
|
811
|
+
}
|
|
812
|
+
function* retainPreparationOperations(batch, operations) {
|
|
813
|
+
yield* claimPreparation(batch);
|
|
814
|
+
for (const [position, op] of operations.entries()) {
|
|
815
|
+
const snapshot = snapshotReplicaOperation(op);
|
|
816
|
+
yield* sql.run(`INSERT INTO pdpp_transport_operation_outcomes
|
|
817
|
+
(batch,position,operation_json,encoding_failed,writer_id,operation_id) VALUES (?,?,?,?,?,?)`, batch, position, snapshot.operation_json, snapshot.encoding_failed, typeof op.revision?.writerId === "string" ? op.revision.writerId : null, typeof op.revision?.operationId === "string"
|
|
818
|
+
? op.revision.operationId
|
|
819
|
+
: null);
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
/** A durable pre-I/O fence also covers a PUT whose result is lost on crash. */
|
|
823
|
+
function recordUploadAttempt(batch, kind, position, signal) {
|
|
824
|
+
return atomic((function* () {
|
|
825
|
+
door.assertStepOpen(signal);
|
|
826
|
+
const prep = yield* preparationOf(batch);
|
|
827
|
+
if (prep && prep.state !== "prepared")
|
|
828
|
+
throw new ReplicaProblemError("publish_failed");
|
|
829
|
+
yield* sql.run(`INSERT OR IGNORE INTO pdpp_transport_upload_attempts (batch,kind,position,started_at) VALUES (?,?,?,?)`, batch, kind, position, now());
|
|
830
|
+
})());
|
|
831
|
+
}
|
|
832
|
+
/** Never infer safety from prepared=0: legacy state and interrupted PUTs count. */
|
|
833
|
+
function* preparationOwnEffects(batch, version) {
|
|
834
|
+
return Boolean(yield* sql.get(`SELECT 1 FROM pdpp_transport_upload_attempts WHERE batch=?
|
|
835
|
+
UNION ALL SELECT 1 FROM pdpp_transport_registration_intents WHERE scope=? AND version=?
|
|
836
|
+
UNION ALL SELECT 1 FROM pdpp_transport_fragments_out WHERE batch=? AND (state<>'ready' OR attempts>0)
|
|
837
|
+
UNION ALL SELECT 1 FROM pdpp_transport_manifests_out WHERE batch=? AND (state<>'ready' OR attempts>0) LIMIT 1`, batch.batch, replicaScope(writer()), version, batch.batch, batch.batch));
|
|
838
|
+
}
|
|
839
|
+
function* preparationHasExternalEffects(batch, version) {
|
|
840
|
+
if (yield* preparationOwnEffects(batch, version))
|
|
841
|
+
return true;
|
|
842
|
+
return Boolean(yield* sql.get(`
|
|
843
|
+
SELECT 1 FROM pdpp_transport_manifests_out m JOIN pdpp_transport_batches b ON b.batch=m.batch
|
|
844
|
+
WHERE b.sequence>? LIMIT 1`, batch.sequence));
|
|
845
|
+
}
|
|
846
|
+
/** Release is legal only at the tail of both durable claim orders. */
|
|
847
|
+
function* preparationIsTail(batch, version) {
|
|
848
|
+
if ((yield* state()).sequence !== batch.sequence)
|
|
849
|
+
return false;
|
|
850
|
+
return !(yield* sql.get(`
|
|
851
|
+
SELECT 1 FROM pdpp_transport_batches WHERE sequence>?
|
|
852
|
+
UNION ALL SELECT 1 FROM pdpp_transport_preparation WHERE batch<>? AND state<>'released' AND version>=?
|
|
853
|
+
UNION ALL SELECT 1 FROM pdpp_transport_manifests_out WHERE scope=? AND batch<>? AND version>=? LIMIT 1`, batch.sequence, batch.batch, version, replicaScope(writer()), batch.batch, version));
|
|
854
|
+
}
|
|
855
|
+
const preparationWait = (batch, reason) => ({
|
|
856
|
+
tag: "wait",
|
|
857
|
+
dependency: { kind: "preparation", key: batch, reason },
|
|
858
|
+
});
|
|
859
|
+
function* blockPreparation(batch, reason = "upload_started") {
|
|
860
|
+
const wait = preparationWait(batch, reason);
|
|
861
|
+
yield* sql.run("UPDATE pdpp_transport_preparation SET state='prep_failed_permanent',wait_json=? WHERE batch=?", canonicalJson(wait), batch);
|
|
862
|
+
return wait;
|
|
863
|
+
}
|
|
864
|
+
/** Mark first, rebuild in a later durable transition; replay cannot duplicate an outcome. */
|
|
865
|
+
function permanentPreparation(batch, admission, positions) {
|
|
866
|
+
return {
|
|
867
|
+
outcome: { tag: "unsupported", admission },
|
|
868
|
+
plan: function* () {
|
|
869
|
+
const preparation = (yield* preparationOf(batch.batch));
|
|
870
|
+
if (yield* preparationOwnEffects(batch, preparation.version))
|
|
871
|
+
return yield* blockPreparation(batch.batch);
|
|
872
|
+
const refused = new Set(positions);
|
|
873
|
+
const remaining = (yield* preparationRows(batch.batch)).filter((row) => !refused.has(row.position));
|
|
874
|
+
if (remaining.length &&
|
|
875
|
+
(yield* preparationHasExternalEffects(batch, preparation.version)))
|
|
876
|
+
return yield* blockPreparation(batch.batch);
|
|
877
|
+
if (!positions.length)
|
|
878
|
+
throw new ReplicaProblemError("prepare_failed");
|
|
879
|
+
for (const position of positions)
|
|
880
|
+
yield* sql.run(`UPDATE pdpp_transport_operation_outcomes
|
|
881
|
+
SET outcome='unsupported',reason=?,admission_json=? WHERE batch=? AND position=? AND outcome IS NULL`, admission.reason, canonicalJson(admission), batch.batch, position);
|
|
882
|
+
yield* sql.run("UPDATE pdpp_transport_batches SET prepared=-2,admission_json=? WHERE batch=?", canonicalJson(admission), batch.batch);
|
|
883
|
+
if (!remaining.length &&
|
|
884
|
+
!(yield* preparationIsTail(batch, preparation.version)))
|
|
885
|
+
return yield* blockPreparation(batch.batch, "all_ops_unsupported");
|
|
886
|
+
yield* sql.run("UPDATE pdpp_transport_preparation SET state='prep_failed_permanent',wait_json=NULL WHERE batch=?", batch.batch);
|
|
887
|
+
},
|
|
888
|
+
};
|
|
889
|
+
}
|
|
890
|
+
/** Stream a new payload under a temporary key; the rebuild publishes it atomically. */
|
|
891
|
+
async function rebuiltPayload(batch, operations) {
|
|
892
|
+
const key = `rebuild:${host.randomId()}`;
|
|
893
|
+
const payload = await query(replicaPayloadWriter(key));
|
|
894
|
+
const hash = sha256.create();
|
|
895
|
+
let totalBytes = 0;
|
|
896
|
+
try {
|
|
897
|
+
for await (const bytes of encodeReplicaPayload({
|
|
898
|
+
format: "vana-pdpp-replica",
|
|
899
|
+
version: 1,
|
|
900
|
+
owner: owner(),
|
|
901
|
+
batchId: batch,
|
|
902
|
+
operations,
|
|
903
|
+
}, async function* (id) {
|
|
904
|
+
yield* exporter.readPinnedBlobChunks(id);
|
|
905
|
+
})) {
|
|
906
|
+
if (!enabled())
|
|
907
|
+
throw new ReplicaProblemError("prepare_failed");
|
|
908
|
+
door.assertStepOpen(activeStepSignal);
|
|
909
|
+
await query(payload.write(bytes));
|
|
910
|
+
hash.update(bytes);
|
|
911
|
+
totalBytes += bytes.length;
|
|
912
|
+
}
|
|
913
|
+
await query(payload.finish());
|
|
914
|
+
return {
|
|
915
|
+
key,
|
|
916
|
+
digest: `sha256:${bytesToHex(hash.digest())}`,
|
|
917
|
+
totalBytes,
|
|
918
|
+
};
|
|
919
|
+
}
|
|
920
|
+
catch (error) {
|
|
921
|
+
await query(deleteReplicaPayload(key));
|
|
922
|
+
throw error;
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
function unknownPreparation(batch, error) {
|
|
926
|
+
return withPlan(retryFromError(error, "prepare_failed"), function* () {
|
|
927
|
+
yield* sql.run("UPDATE pdpp_transport_preparation SET state='prep_failed_unknown',wait_json=? WHERE batch=?", canonicalJson(preparationWait(batch.batch, "unknown")), batch.batch);
|
|
928
|
+
});
|
|
929
|
+
}
|
|
930
|
+
/** One read-only trial preserves unknown-error classification. Every crossing
|
|
931
|
+
* is then attributed by incremental payload bytes and the codec's exact size.
|
|
932
|
+
*/
|
|
933
|
+
async function aggregatePreparationFailures(batch, error, signal, chunkBytes) {
|
|
934
|
+
const rows = await query(preparationRows(batch.batch));
|
|
935
|
+
const envelope = {
|
|
936
|
+
format: "vana-pdpp-replica",
|
|
937
|
+
version: 1,
|
|
938
|
+
owner: owner(),
|
|
939
|
+
batchId: batch.batch,
|
|
940
|
+
operations: rows.map((row) => JSON.parse(row.operation_json)),
|
|
941
|
+
};
|
|
942
|
+
if (error.limit === "aggregate_ciphertext_bytes") {
|
|
943
|
+
// An unknown crypto failure in a trial remains Retry; no outcome
|
|
944
|
+
// is persisted until the whole attribution pass completes.
|
|
945
|
+
const bytes = () => encodeReplicaPayload({ ...envelope, operations: [] }, async function* () { });
|
|
946
|
+
const empty = await hashReplicaBytes(bytes);
|
|
947
|
+
let measured = 0;
|
|
948
|
+
try {
|
|
949
|
+
for await (const fragment of fragmentReplicaStream({
|
|
950
|
+
owner: owner(),
|
|
951
|
+
writer: writer(),
|
|
952
|
+
batch: batch.batch,
|
|
953
|
+
sequence: batch.sequence,
|
|
954
|
+
previous: batch.previous_json
|
|
955
|
+
? JSON.parse(batch.previous_json)
|
|
956
|
+
: null,
|
|
957
|
+
collectedAt: batch.collected_at,
|
|
958
|
+
...empty,
|
|
959
|
+
bytes,
|
|
960
|
+
encrypt: (scope, plaintext) => call(signal, () => transport.encrypt(scope, plaintext)),
|
|
961
|
+
maxBlobBytes: cap,
|
|
962
|
+
targetChunkBytes: chunkBytes,
|
|
963
|
+
}))
|
|
964
|
+
measured += fragment.encrypted.length;
|
|
965
|
+
}
|
|
966
|
+
catch (trialError) {
|
|
967
|
+
if (trialError instanceof ReplicaResourceLimitError &&
|
|
968
|
+
trialError.limit === error.limit)
|
|
969
|
+
return rows.map((row) => row.position);
|
|
970
|
+
throw trialError;
|
|
971
|
+
}
|
|
972
|
+
if (measured > error.maximum)
|
|
973
|
+
return rows.map((row) => row.position);
|
|
974
|
+
if (!transport.encryptedByteLength)
|
|
975
|
+
throw new ReplicaProblemError("prepare_failed");
|
|
976
|
+
}
|
|
977
|
+
const sizes = new Map();
|
|
978
|
+
const exceeds = (payloadBytes) => {
|
|
979
|
+
if (error.limit === "total_bytes")
|
|
980
|
+
return payloadBytes > error.maximum;
|
|
981
|
+
let ciphertext = sizes.get(payloadBytes);
|
|
982
|
+
if (ciphertext === undefined) {
|
|
983
|
+
ciphertext = replicaStreamCiphertextByteLength({
|
|
984
|
+
owner: owner(),
|
|
985
|
+
writer: writer(),
|
|
986
|
+
batch: batch.batch,
|
|
987
|
+
sequence: batch.sequence,
|
|
988
|
+
previous: batch.previous_json
|
|
989
|
+
? JSON.parse(batch.previous_json)
|
|
990
|
+
: null,
|
|
991
|
+
collectedAt: batch.collected_at,
|
|
992
|
+
digest: batch.digest,
|
|
993
|
+
totalBytes: payloadBytes,
|
|
994
|
+
targetChunkBytes: chunkBytes,
|
|
995
|
+
}, transport.encryptedByteLength);
|
|
996
|
+
sizes.set(payloadBytes, ciphertext);
|
|
997
|
+
}
|
|
998
|
+
return ciphertext > error.maximum;
|
|
999
|
+
};
|
|
1000
|
+
return replicaPayloadOperationCrossings(envelope, exceeds).map((index) => rows[index].position);
|
|
1001
|
+
}
|
|
1002
|
+
async function failedPreparation(batch, error, signal, chunkBytes = deps.targetChunkBytes ?? REPLICA_DEFAULT_FRAGMENT_BYTES, attributedPositions) {
|
|
1003
|
+
const failure = classifyReplicaPreparationError(error);
|
|
1004
|
+
const prep = await query(preparationOf(batch.batch));
|
|
1005
|
+
if (failure.kind !== "permanent" || !prep)
|
|
1006
|
+
return unknownPreparation(batch, error);
|
|
1007
|
+
// Refuse before probing or marking ops whenever durable I/O evidence exists.
|
|
1008
|
+
if (await query(preparationOwnEffects(batch, prep.version)))
|
|
1009
|
+
return permanentPreparation(batch, failure.admission, []);
|
|
1010
|
+
try {
|
|
1011
|
+
let positions = failure.positions ?? attributedPositions;
|
|
1012
|
+
if (!positions &&
|
|
1013
|
+
error instanceof ReplicaResourceLimitError &&
|
|
1014
|
+
(error.limit === "aggregate_ciphertext_bytes" ||
|
|
1015
|
+
error.limit === "total_bytes"))
|
|
1016
|
+
positions = await aggregatePreparationFailures(batch, error, signal, chunkBytes);
|
|
1017
|
+
if (positions?.length)
|
|
1018
|
+
return permanentPreparation(batch, failure.admission, positions);
|
|
1019
|
+
}
|
|
1020
|
+
catch (trialError) {
|
|
1021
|
+
return unknownPreparation(batch, trialError);
|
|
1022
|
+
}
|
|
1023
|
+
// A typed error without attributable operations cannot justify dropping data.
|
|
1024
|
+
return unknownPreparation(batch, error);
|
|
1025
|
+
}
|
|
1026
|
+
async function rebuildPreparation(batch, prep) {
|
|
1027
|
+
const progress = {
|
|
1028
|
+
tag: "progress",
|
|
1029
|
+
receipt: { phase: "prepare", key: batch.batch },
|
|
1030
|
+
};
|
|
1031
|
+
const rows = await query(preparationRows(batch.batch));
|
|
1032
|
+
if (prep.state === "prep_failed_permanent" && rows.length)
|
|
1033
|
+
return {
|
|
1034
|
+
outcome: progress,
|
|
1035
|
+
plan: function* () {
|
|
1036
|
+
if (yield* preparationHasExternalEffects(batch, prep.version))
|
|
1037
|
+
return yield* blockPreparation(batch.batch);
|
|
1038
|
+
yield* sql.run("UPDATE pdpp_transport_preparation SET state='rebuilding' WHERE batch=?", batch.batch);
|
|
1039
|
+
},
|
|
1040
|
+
};
|
|
1041
|
+
if (!rows.length)
|
|
1042
|
+
return {
|
|
1043
|
+
outcome: progress,
|
|
1044
|
+
plan: function* () {
|
|
1045
|
+
if (yield* preparationOwnEffects(batch, prep.version))
|
|
1046
|
+
return yield* blockPreparation(batch.batch);
|
|
1047
|
+
if (!(yield* preparationIsTail(batch, prep.version)))
|
|
1048
|
+
return yield* blockPreparation(batch.batch, "all_ops_unsupported");
|
|
1049
|
+
// The unused tail alone may give back its sequence and wire version.
|
|
1050
|
+
yield* discardPreparedFragments(batch.batch);
|
|
1051
|
+
yield* releaseClaim(batch.batch);
|
|
1052
|
+
yield* deleteReplicaPayload(batch.batch);
|
|
1053
|
+
yield* sql.run("DELETE FROM pdpp_transport_batches WHERE batch=?", batch.batch);
|
|
1054
|
+
yield* sql.run("UPDATE pdpp_transport_state SET sequence=?,previous_json=? WHERE id=1", batch.sequence - 1, batch.previous_json);
|
|
1055
|
+
yield* sql.run("UPDATE pdpp_transport_preparation SET state='released',wait_json=NULL WHERE batch=?", batch.batch);
|
|
1056
|
+
},
|
|
1057
|
+
};
|
|
1058
|
+
const payload = await rebuiltPayload(batch.batch, rows.map((row) => JSON.parse(row.operation_json)));
|
|
1059
|
+
return {
|
|
1060
|
+
outcome: progress,
|
|
1061
|
+
plan: function* () {
|
|
1062
|
+
if (yield* preparationHasExternalEffects(batch, prep.version))
|
|
1063
|
+
return yield* blockPreparation(batch.batch);
|
|
1064
|
+
yield* discardPreparedFragments(batch.batch);
|
|
1065
|
+
yield* releaseClaim(batch.batch);
|
|
1066
|
+
yield* deleteReplicaPayload(batch.batch);
|
|
1067
|
+
for (const table of ["pdpp_binary_chunks", "pdpp_binary_manifests"])
|
|
1068
|
+
yield* sql.run(`UPDATE ${table} SET blob_id=? WHERE namespace='payload' AND blob_id=?`, batch.batch, payload.key);
|
|
1069
|
+
yield* sql.run(`UPDATE pdpp_transport_batches SET prepared=0,digest=?,total_bytes=?,admission_json=NULL WHERE batch=?`, payload.digest, payload.totalBytes, batch.batch);
|
|
1070
|
+
const previous = canonicalJson({
|
|
1071
|
+
batch: batch.batch,
|
|
1072
|
+
digest: payload.digest,
|
|
1073
|
+
});
|
|
1074
|
+
yield* sql.run("UPDATE pdpp_transport_state SET previous_json=? WHERE id=1 AND json_extract(previous_json,'$.batch')=?", previous, batch.batch);
|
|
1075
|
+
yield* sql.run("UPDATE pdpp_transport_batches SET previous_json=? WHERE json_extract(previous_json,'$.batch')=?", previous, batch.batch);
|
|
1076
|
+
yield* sql.run("UPDATE pdpp_transport_preparation SET state='preparing',wait_json=NULL WHERE batch=?", batch.batch);
|
|
1077
|
+
},
|
|
1078
|
+
release: () => host.cleanup(deleteReplicaPayload(payload.key)),
|
|
1079
|
+
};
|
|
1080
|
+
}
|
|
1081
|
+
const PROGRESS_COLUMNS = "statement,chunk_bytes,next_index,source_position,source_offset";
|
|
1082
|
+
function preparationStatement(batch) {
|
|
1083
|
+
return {
|
|
1084
|
+
owner: owner(),
|
|
1085
|
+
writer: writer(),
|
|
1086
|
+
batch: batch.batch,
|
|
1087
|
+
sequence: batch.sequence,
|
|
1088
|
+
previous: batch.previous_json
|
|
1089
|
+
? JSON.parse(batch.previous_json)
|
|
1090
|
+
: null,
|
|
1091
|
+
collectedAt: batch.collected_at,
|
|
1092
|
+
digest: batch.digest,
|
|
1093
|
+
totalBytes: batch.total_bytes,
|
|
1094
|
+
};
|
|
1095
|
+
}
|
|
1096
|
+
/** The stored cursor when it names this statement, else fragment 0. */
|
|
1097
|
+
function progressFor(batch, row) {
|
|
1098
|
+
const statement = canonicalJson(preparationStatement(batch));
|
|
1099
|
+
return row?.statement === statement
|
|
1100
|
+
? row
|
|
1101
|
+
: {
|
|
1102
|
+
statement,
|
|
1103
|
+
chunk_bytes: deps.targetChunkBytes ?? REPLICA_DEFAULT_FRAGMENT_BYTES,
|
|
1104
|
+
next_index: 0,
|
|
1105
|
+
source_position: 0,
|
|
1106
|
+
source_offset: 0,
|
|
1107
|
+
};
|
|
1108
|
+
}
|
|
1109
|
+
function* preparationProgress(batch) {
|
|
1110
|
+
return progressFor(batch, yield* sql.get(`SELECT ${PROGRESS_COLUMNS} FROM pdpp_transport_preparation_progress WHERE batch=?`, batch.batch));
|
|
1111
|
+
}
|
|
1112
|
+
function* savePreparationProgress(batch, next) {
|
|
1113
|
+
yield* sql.run(`INSERT INTO pdpp_transport_preparation_progress (batch,${PROGRESS_COLUMNS}) VALUES (?,?,?,?,?,?)
|
|
1114
|
+
ON CONFLICT(batch) DO UPDATE SET statement=excluded.statement,chunk_bytes=excluded.chunk_bytes,next_index=excluded.next_index,source_position=excluded.source_position,source_offset=excluded.source_offset`, batch, next.statement, next.chunk_bytes, next.next_index, next.source_position, next.source_offset);
|
|
1115
|
+
}
|
|
1116
|
+
/**
|
|
1117
|
+
* In a plan: false when the batch is no longer unprepared (nothing to do).
|
|
1118
|
+
* Throws when its cursor moved since this attempt read it, so a stale
|
|
1119
|
+
* attempt never commits a second fragment at one position.
|
|
1120
|
+
*/
|
|
1121
|
+
function* preparationCurrent(batch, started) {
|
|
1122
|
+
const row = yield* sql.get("SELECT * FROM pdpp_transport_batches WHERE batch=?", batch.batch);
|
|
1123
|
+
if (!row || row.prepared !== 0)
|
|
1124
|
+
return false;
|
|
1125
|
+
const current = yield* preparationProgress(row);
|
|
1126
|
+
if (current.statement !== started.statement ||
|
|
1127
|
+
current.chunk_bytes !== started.chunk_bytes ||
|
|
1128
|
+
current.next_index !== started.next_index ||
|
|
1129
|
+
current.source_position !== started.source_position ||
|
|
1130
|
+
current.source_offset !== started.source_offset)
|
|
1131
|
+
throw new ReplicaProblemError("prepare_failed");
|
|
1132
|
+
return true;
|
|
1133
|
+
}
|
|
1134
|
+
/** Drop an unpublished batch's fragments and its cursor. */
|
|
1135
|
+
function* discardPreparedFragments(batch) {
|
|
1136
|
+
yield* sql.run("DELETE FROM pdpp_transport_fragments_out WHERE batch=?", batch);
|
|
1137
|
+
yield* sql.run("DELETE FROM pdpp_transport_preparation_progress WHERE batch=?", batch);
|
|
1138
|
+
}
|
|
1139
|
+
/**
|
|
1140
|
+
* Admission of a batch's next prepare attempt: the base64 content of its
|
|
1141
|
+
* next fragment, and nothing for the final attempt, whose payload check
|
|
1142
|
+
* streams. A rebuild streams the payload like retain and is not charged.
|
|
1143
|
+
*/
|
|
1144
|
+
function preparationAdmission(batch, row) {
|
|
1145
|
+
const progress = progressFor(batch, row);
|
|
1146
|
+
const offset = progress.next_index * progress.chunk_bytes;
|
|
1147
|
+
// A failed attempt leaves the cursor, so the batch waits for the next step.
|
|
1148
|
+
const part = `${progress.chunk_bytes}:${progress.next_index}`;
|
|
1149
|
+
if (batch.prepared !== 0)
|
|
1150
|
+
return { part };
|
|
1151
|
+
return {
|
|
1152
|
+
part,
|
|
1153
|
+
bytes: offset >= batch.total_bytes
|
|
1154
|
+
? 0
|
|
1155
|
+
: 4 *
|
|
1156
|
+
Math.ceil(Math.min(progress.chunk_bytes, batch.total_bytes - offset) / 3),
|
|
1157
|
+
};
|
|
1158
|
+
}
|
|
1159
|
+
const unsupported = (error) => {
|
|
1160
|
+
const admission = {
|
|
1161
|
+
actual: error.actual,
|
|
1162
|
+
limit: error.limit,
|
|
1163
|
+
capability: error.dimension === "fragment_count"
|
|
1164
|
+
? `fragments:${error.limit}`
|
|
1165
|
+
: `cap:${cap}`,
|
|
1166
|
+
};
|
|
1167
|
+
return { reason: "replica_framing_unsupported", ...admission };
|
|
1168
|
+
};
|
|
1169
|
+
const prepare = {
|
|
1170
|
+
phase: "prepare",
|
|
1171
|
+
due(e, limit) {
|
|
1172
|
+
return then(query(sql.all(`SELECT b.* FROM pdpp_transport_batches b WHERE ${prepareUnblocked} AND
|
|
1173
|
+
((b.prepared=0 AND ${due(e)}) OR (b.prepared=-2 AND EXISTS (
|
|
1174
|
+
SELECT 1 FROM pdpp_transport_preparation p WHERE p.batch=b.batch AND
|
|
1175
|
+
((p.state IN ('prep_failed_permanent','rebuilding') AND p.wait_json IS NULL)
|
|
1176
|
+
OR (p.state='prep_failed_unknown' AND ${due(e)}))))) ORDER BY b.next_attempt,b.sequence LIMIT ?`, limit.total)), (rows) => then(query((function* () {
|
|
1177
|
+
const progress = new Map();
|
|
1178
|
+
for (const row of rows) {
|
|
1179
|
+
const found = yield* sql.get(`SELECT ${PROGRESS_COLUMNS} FROM pdpp_transport_preparation_progress WHERE batch=?`, row.batch);
|
|
1180
|
+
if (found)
|
|
1181
|
+
progress.set(row.batch, found);
|
|
1182
|
+
}
|
|
1183
|
+
return progress;
|
|
1184
|
+
})()), (progress) => rows.map((row) => ({
|
|
1185
|
+
id: row.batch,
|
|
1186
|
+
phase: "prepare",
|
|
1187
|
+
writer: "",
|
|
1188
|
+
state: workState(row),
|
|
1189
|
+
...preparationAdmission(row, progress.get(row.batch)),
|
|
1190
|
+
row,
|
|
1191
|
+
}))));
|
|
1192
|
+
},
|
|
1193
|
+
async run(item, signal) {
|
|
1194
|
+
const batch = item.row;
|
|
1195
|
+
try {
|
|
1196
|
+
let preparation = await query(preparationOf(batch.batch));
|
|
1197
|
+
// Upgrade a legacy retained batch from its frozen payload, never from the journal.
|
|
1198
|
+
if (!preparation ||
|
|
1199
|
+
!(await query(sql.get("SELECT 1 FROM pdpp_transport_operation_outcomes WHERE batch=? LIMIT 1", batch.batch)))) {
|
|
1200
|
+
await atomic((function* () {
|
|
1201
|
+
door.assertStepOpen(signal);
|
|
1202
|
+
yield* claimPreparation(batch.batch);
|
|
1203
|
+
})());
|
|
1204
|
+
const payload = await inspectReplicaPayload(() => payloadChunks(batch.batch), undefined);
|
|
1205
|
+
await atomic((function* () {
|
|
1206
|
+
door.assertStepOpen(signal);
|
|
1207
|
+
yield* retainPreparationOperations(batch.batch, payload.envelope.operations);
|
|
1208
|
+
})());
|
|
1209
|
+
preparation = (await query(preparationOf(batch.batch)));
|
|
1210
|
+
}
|
|
1211
|
+
if (preparation.state === "released") {
|
|
1212
|
+
// A pre-upgrade readmission may still be queued without a reservation.
|
|
1213
|
+
// Defer it rather than blocking the successor that already owns a slot.
|
|
1214
|
+
const reserved = await atomic((function* () {
|
|
1215
|
+
door.assertStepOpen(signal);
|
|
1216
|
+
return yield* reserveReadmission(batch.batch);
|
|
1217
|
+
})());
|
|
1218
|
+
if (!reserved) {
|
|
1219
|
+
const saved = (await query(sql.get("SELECT admission_json FROM pdpp_transport_batches WHERE batch=?", batch.batch)));
|
|
1220
|
+
const admission = {
|
|
1221
|
+
reason: "replica_framing_unsupported",
|
|
1222
|
+
...JSON.parse(saved.admission_json),
|
|
1223
|
+
};
|
|
1224
|
+
return withPlan({ tag: "unsupported", admission }, () => sql.run("UPDATE pdpp_transport_batches SET prepared=-1 WHERE batch=?", batch.batch));
|
|
1225
|
+
}
|
|
1226
|
+
preparation = (await query(preparationOf(batch.batch)));
|
|
1227
|
+
}
|
|
1228
|
+
if (preparation.wait_json) {
|
|
1229
|
+
if (preparation.state === "prep_failed_permanent")
|
|
1230
|
+
return verdict(JSON.parse(preparation.wait_json));
|
|
1231
|
+
}
|
|
1232
|
+
if (preparation.state === "prep_failed_unknown" &&
|
|
1233
|
+
batch.prepared === -2) {
|
|
1234
|
+
await atomic((function* () {
|
|
1235
|
+
door.assertStepOpen(signal);
|
|
1236
|
+
yield* sql.run("UPDATE pdpp_transport_preparation SET state='rebuilding',wait_json=NULL WHERE batch=?", batch.batch);
|
|
1237
|
+
})());
|
|
1238
|
+
preparation = {
|
|
1239
|
+
...preparation,
|
|
1240
|
+
state: "rebuilding",
|
|
1241
|
+
wait_json: null,
|
|
1242
|
+
};
|
|
1243
|
+
}
|
|
1244
|
+
if (preparation.state === "prep_failed_permanent" ||
|
|
1245
|
+
preparation.state === "rebuilding")
|
|
1246
|
+
return await rebuildPreparation(batch, preparation);
|
|
1247
|
+
await atomic((function* () {
|
|
1248
|
+
door.assertStepOpen(signal);
|
|
1249
|
+
yield* sql.run("UPDATE pdpp_transport_preparation SET state='preparing',wait_json=NULL WHERE batch=?", batch.batch);
|
|
1250
|
+
})());
|
|
1251
|
+
const rows = await query(preparationRows(batch.batch));
|
|
1252
|
+
const own = rows.map((row) => JSON.parse(row.operation_json));
|
|
1253
|
+
const failures = preparationOperationFailures(rows, resourceLimits);
|
|
1254
|
+
if (failures)
|
|
1255
|
+
return failedPreparation(batch, failures.error, signal, undefined, failures.positions);
|
|
1256
|
+
if (batch.total_bytes > resourceLimits.maxTotalBytes)
|
|
1257
|
+
return failedPreparation(batch, new ReplicaResourceLimitError("total_bytes", batch.total_bytes, resourceLimits.maxTotalBytes), signal);
|
|
1258
|
+
decodeReplicaEnvelope(canonicalJson({
|
|
1259
|
+
format: "vana-pdpp-replica",
|
|
1260
|
+
version: 1,
|
|
1261
|
+
owner: owner(),
|
|
1262
|
+
batchId: batch.batch,
|
|
1263
|
+
operations: own,
|
|
1264
|
+
}), ownBindings(own, await query(exporter.declarationHistory())));
|
|
1265
|
+
const identity = await query(host.identity.current());
|
|
1266
|
+
const statement = preparationStatement(batch);
|
|
1267
|
+
const encrypt = (scope, bytes) => call(signal, () => transport.encrypt(scope, bytes));
|
|
1268
|
+
const progress = await query(preparationProgress(batch));
|
|
1269
|
+
const receipt = {
|
|
1270
|
+
tag: "progress",
|
|
1271
|
+
receipt: { phase: "prepare", key: batch.batch },
|
|
1272
|
+
};
|
|
1273
|
+
const unsupportedPlan = (error) => {
|
|
1274
|
+
const admission = unsupported(error);
|
|
1275
|
+
return withPlan({ tag: "unsupported", admission }, function* () {
|
|
1276
|
+
yield* sql.run("UPDATE pdpp_transport_preparation SET state='released',wait_json=NULL WHERE batch=?", batch.batch);
|
|
1277
|
+
yield* sql.run("UPDATE pdpp_transport_batches SET prepared=-1,admission_json=? WHERE batch=?", canonicalJson({
|
|
1278
|
+
actual: admission.actual,
|
|
1279
|
+
limit: admission.limit,
|
|
1280
|
+
capability: admission.capability,
|
|
1281
|
+
}), batch.batch);
|
|
1282
|
+
yield* discardPreparedFragments(batch.batch);
|
|
1283
|
+
});
|
|
1284
|
+
};
|
|
1285
|
+
try {
|
|
1286
|
+
// Batches belong to the current writer; startWriter drops older ones.
|
|
1287
|
+
if (identity?.transportWriter !== statement.writer)
|
|
1288
|
+
throw new ReplicaProblemError("prepare_failed");
|
|
1289
|
+
const count = Math.ceil(batch.total_bytes / progress.chunk_bytes);
|
|
1290
|
+
if (count > REPLICA_MAX_FRAGMENTS)
|
|
1291
|
+
return unsupportedPlan(new ReplicaFramingUnsupported(count, REPLICA_MAX_FRAGMENTS, "fragment_count"));
|
|
1292
|
+
if (progress.next_index < count) {
|
|
1293
|
+
// One fragment: read its window, frame, encrypt, commit with the cursor.
|
|
1294
|
+
const index = progress.next_index;
|
|
1295
|
+
const offset = index * progress.chunk_bytes;
|
|
1296
|
+
const window = await query(readReplicaPayloadWindow(batch.batch, {
|
|
1297
|
+
position: progress.source_position,
|
|
1298
|
+
offset: progress.source_offset,
|
|
1299
|
+
}, Math.min(progress.chunk_bytes, batch.total_bytes - offset)));
|
|
1300
|
+
let part;
|
|
1301
|
+
let framing;
|
|
1302
|
+
try {
|
|
1303
|
+
part = await prepareReplicaFragment({
|
|
1304
|
+
...statement,
|
|
1305
|
+
count,
|
|
1306
|
+
index,
|
|
1307
|
+
offset,
|
|
1308
|
+
chunk: window.bytes,
|
|
1309
|
+
encrypt,
|
|
1310
|
+
});
|
|
1311
|
+
if (part.encrypted.length > cap)
|
|
1312
|
+
framing = new ReplicaFramingUnsupported(part.encrypted.length, cap);
|
|
1313
|
+
}
|
|
1314
|
+
catch (error) {
|
|
1315
|
+
if (!(error instanceof ReplicaFramingUnsupported))
|
|
1316
|
+
throw error;
|
|
1317
|
+
framing = error;
|
|
1318
|
+
}
|
|
1319
|
+
if (framing) {
|
|
1320
|
+
// Resource Unsupported blocks this writer through its durable
|
|
1321
|
+
// rebuild; it never quarantines. Framing Unsupported may narrow
|
|
1322
|
+
// the chunk size; the batch then starts again from fragment 0.
|
|
1323
|
+
// If framing remains impossible, it settles only this batch
|
|
1324
|
+
// without claiming a version. Successors may publish but peers
|
|
1325
|
+
// wait for the missing predecessor until readmitUnsupported()
|
|
1326
|
+
// under a new capability. Other errors retry.
|
|
1327
|
+
if (framing.dimension === "fragment_bytes" &&
|
|
1328
|
+
progress.chunk_bytes > 1)
|
|
1329
|
+
return withPlan(receipt, function* () {
|
|
1330
|
+
if (!(yield* preparationCurrent(batch, progress)))
|
|
1331
|
+
return;
|
|
1332
|
+
yield* discardPreparedFragments(batch.batch);
|
|
1333
|
+
yield* savePreparationProgress(batch.batch, {
|
|
1334
|
+
statement: progress.statement,
|
|
1335
|
+
chunk_bytes: Math.max(1, Math.floor(progress.chunk_bytes / 2)),
|
|
1336
|
+
next_index: 0,
|
|
1337
|
+
source_position: 0,
|
|
1338
|
+
source_offset: 0,
|
|
1339
|
+
});
|
|
1340
|
+
});
|
|
1341
|
+
return unsupportedPlan(framing);
|
|
1342
|
+
}
|
|
1343
|
+
const prepared = part;
|
|
1344
|
+
if (transport.encryptedByteLength &&
|
|
1345
|
+
transport.encryptedByteLength(prepared.plaintext.length) !==
|
|
1346
|
+
prepared.encrypted.length)
|
|
1347
|
+
throw new ReplicaProblemError("prepare_failed");
|
|
1348
|
+
// One fragment's receipt; the batch's own comes with its manifest.
|
|
1349
|
+
const fragmentReceipt = {
|
|
1350
|
+
tag: "progress",
|
|
1351
|
+
receipt: { phase: "prepare", key: `${batch.batch}:${index}` },
|
|
1352
|
+
};
|
|
1353
|
+
return withPlan(fragmentReceipt, function* () {
|
|
1354
|
+
// Randomized ciphertext: only the first committed fragment at
|
|
1355
|
+
// this cursor can go on wire.
|
|
1356
|
+
if (!(yield* preparationCurrent(batch, progress)))
|
|
1357
|
+
return;
|
|
1358
|
+
if (index === 0)
|
|
1359
|
+
yield* discardPreparedFragments(batch.batch);
|
|
1360
|
+
yield* sql.run("INSERT INTO pdpp_transport_fragments_out (batch,position,digest,encrypted) VALUES (?,?,?,?)", batch.batch, index, prepared.digest, prepared.encrypted);
|
|
1361
|
+
yield* savePreparationProgress(batch.batch, {
|
|
1362
|
+
statement: progress.statement,
|
|
1363
|
+
chunk_bytes: progress.chunk_bytes,
|
|
1364
|
+
next_index: index + 1,
|
|
1365
|
+
source_position: window.next.position,
|
|
1366
|
+
source_offset: window.next.offset,
|
|
1367
|
+
});
|
|
1368
|
+
});
|
|
1369
|
+
}
|
|
1370
|
+
// Every fragment is committed. The single-run stream checked the
|
|
1371
|
+
// whole-batch digest at EOF; check that the retained payload is
|
|
1372
|
+
// still the one the fragments were read from, one chunk per call.
|
|
1373
|
+
const retained = await hashReplicaBytes(() => payloadChunks(batch.batch));
|
|
1374
|
+
if (retained.digest !== batch.digest ||
|
|
1375
|
+
retained.totalBytes !== batch.total_bytes)
|
|
1376
|
+
throw new Error("corrupt replica batch");
|
|
1377
|
+
const entries = await query(sql.all("SELECT digest,length(encrypted) AS bytes FROM pdpp_transport_fragments_out WHERE batch=? ORDER BY position", batch.batch));
|
|
1378
|
+
// A committed fragment went missing: start the batch again.
|
|
1379
|
+
if (entries.length !== count)
|
|
1380
|
+
return withPlan({ tag: "retry", problem: "prepare_failed" }, function* () {
|
|
1381
|
+
if (yield* preparationCurrent(batch, progress))
|
|
1382
|
+
yield* discardPreparedFragments(batch.batch);
|
|
1383
|
+
});
|
|
1384
|
+
const aggregateCiphertextBytes = entries.reduce((total, entry) => total + entry.bytes, 0);
|
|
1385
|
+
if (aggregateCiphertextBytes >
|
|
1386
|
+
resourceLimits.maxAggregateCiphertextBytes)
|
|
1387
|
+
throw new ReplicaResourceLimitError("aggregate_ciphertext_bytes", aggregateCiphertextBytes, resourceLimits.maxAggregateCiphertextBytes);
|
|
1388
|
+
// Sign the durably reserved version. The commit plan rechecks that
|
|
1389
|
+
// this is still the scope's next slot before retaining ciphertext.
|
|
1390
|
+
const version = preparation.version;
|
|
1391
|
+
const manifest = await prepareReplicaManifest({
|
|
1392
|
+
...statement,
|
|
1393
|
+
version,
|
|
1394
|
+
fragments: entries,
|
|
1395
|
+
sign: async (facts) => {
|
|
1396
|
+
await assertPreparationClaim(batch.batch, version, signal);
|
|
1397
|
+
return signReplicaBatch({
|
|
1398
|
+
signer: deps.authority.signer,
|
|
1399
|
+
partition: partition(),
|
|
1400
|
+
seed: identity.seed,
|
|
1401
|
+
facts,
|
|
1402
|
+
});
|
|
1403
|
+
},
|
|
1404
|
+
encrypt,
|
|
1405
|
+
});
|
|
1406
|
+
if (manifest.encrypted.length > cap)
|
|
1407
|
+
return unsupportedPlan(new ReplicaFramingUnsupported(manifest.encrypted.length, cap, "manifest_bytes"));
|
|
1408
|
+
return withPlan(receipt, function* () {
|
|
1409
|
+
if (!(yield* preparationCurrent(batch, progress)))
|
|
1410
|
+
return;
|
|
1411
|
+
const stored = (yield* sql.get("SELECT count(*) AS count,coalesce(max(position),-1) AS last FROM pdpp_transport_fragments_out WHERE batch=?", batch.batch));
|
|
1412
|
+
if (stored.count !== count || stored.last !== count - 1)
|
|
1413
|
+
throw new ReplicaProblemError("prepare_failed");
|
|
1414
|
+
// The batch keeps the version it signed only if that is still the
|
|
1415
|
+
// scope's next one, in the same transaction as its manifest, so a
|
|
1416
|
+
// retry or a lost acknowledgement can never register it at a
|
|
1417
|
+
// second version.
|
|
1418
|
+
const { next } = (yield* sql.get("SELECT coalesce(max(version),0)+1 AS next FROM pdpp_transport_manifests_out WHERE scope=?", manifest.scope));
|
|
1419
|
+
if (next !== manifest.version)
|
|
1420
|
+
throw new ReplicaProblemError("prepare_failed");
|
|
1421
|
+
yield* sql.run("INSERT INTO pdpp_transport_manifests_out (batch,scope,version,plaintext,encrypted,data_hash,metadata_hash) VALUES (?,?,?,?,?,?,?)", batch.batch, manifest.scope, manifest.version, manifest.plaintext, manifest.encrypted, manifest.dataHash, manifest.metadataHash);
|
|
1422
|
+
yield* sql.run("UPDATE pdpp_transport_batches SET prepared=1 WHERE batch=?", batch.batch);
|
|
1423
|
+
yield* sql.run("UPDATE pdpp_transport_preparation SET state='prepared',wait_json=NULL WHERE batch=?", batch.batch);
|
|
1424
|
+
yield* sql.run("DELETE FROM pdpp_transport_preparation_progress WHERE batch=?", batch.batch);
|
|
1425
|
+
});
|
|
1426
|
+
}
|
|
1427
|
+
catch (error) {
|
|
1428
|
+
return failedPreparation(batch, error, signal, progress.chunk_bytes);
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
catch (error) {
|
|
1432
|
+
return failedPreparation(batch, error, signal);
|
|
1433
|
+
}
|
|
1434
|
+
},
|
|
1435
|
+
commit: commitIn((outcome, work, item) => sql.run(`UPDATE pdpp_transport_batches SET ${WORK_SET} WHERE batch=?`, ...workValues(outcome, work), item.row.batch)),
|
|
1436
|
+
commitProblem: "prepare_failed",
|
|
1437
|
+
earliest(e) {
|
|
1438
|
+
return query((function* () {
|
|
1439
|
+
if (yield* sql.get(`SELECT 1 FROM pdpp_transport_batches b JOIN pdpp_transport_preparation p ON p.batch=b.batch WHERE ${prepareUnblocked} AND b.prepared=-2 AND p.state IN ('prep_failed_permanent','rebuilding') AND p.wait_json IS NULL LIMIT 1`))
|
|
1440
|
+
return e.now;
|
|
1441
|
+
return yield* earliestIn(e, `pdpp_transport_batches b WHERE ${prepareUnblocked} AND (b.prepared=0 OR (b.prepared=-2 AND EXISTS (SELECT 1 FROM pdpp_transport_preparation p WHERE p.batch=b.batch AND p.state='prep_failed_unknown')))`);
|
|
1442
|
+
})());
|
|
1443
|
+
},
|
|
1444
|
+
};
|
|
1445
|
+
// -------------------------------------------------------------- publish
|
|
1446
|
+
/** Fragment PUTs and manifests ready for ordered registration. */
|
|
1447
|
+
const OUTBOUND = `(SELECT 'fragment' AS kind, batch||':'||position AS id, batch, position,
|
|
1448
|
+
NULL AS scope,NULL AS version,attempts,next_attempt,wait_since,checked_block,outcome,dependency
|
|
1449
|
+
FROM pdpp_transport_fragments_out WHERE state='ready' AND NOT EXISTS (SELECT 1 FROM pdpp_transport_preparation p WHERE p.batch=pdpp_transport_fragments_out.batch AND p.state<>'prepared')
|
|
1450
|
+
UNION ALL
|
|
1451
|
+
SELECT 'manifest' AS kind, m.scope||'/'||m.version AS id, m.batch, -1 AS position,
|
|
1452
|
+
m.scope,m.version,m.attempts,m.next_attempt,m.wait_since,m.checked_block,m.outcome,m.dependency
|
|
1453
|
+
FROM pdpp_transport_manifests_out m WHERE m.state='ready' AND NOT EXISTS (SELECT 1 FROM pdpp_transport_preparation p WHERE p.batch=m.batch AND p.state<>'prepared')
|
|
1454
|
+
AND NOT EXISTS (SELECT 1 FROM pdpp_transport_fragments_out f WHERE f.batch=m.batch AND f.state<>'stored'))`;
|
|
1455
|
+
const registrationIntent = (scope, version) => sql.get("SELECT owner,manifest_hash,request_id,created_at,attempted_at,attempts,status FROM pdpp_transport_registration_intents WHERE scope=? AND version=?", scope, version);
|
|
1456
|
+
const pendingRegistration = (key, version, since, checkedBlock, nextCheckAt) => ({
|
|
1457
|
+
tag: "wait",
|
|
1458
|
+
dependency: {
|
|
1459
|
+
kind: "registration_pending",
|
|
1460
|
+
key,
|
|
1461
|
+
version,
|
|
1462
|
+
since,
|
|
1463
|
+
...(checkedBlock !== undefined && { checkedBlock }),
|
|
1464
|
+
...(nextCheckAt !== undefined && { nextCheckAt }),
|
|
1465
|
+
},
|
|
1466
|
+
});
|
|
1467
|
+
function recordRegistrationAttempt(expected, encrypted, signal) {
|
|
1468
|
+
const slotOwner = expected.owner;
|
|
1469
|
+
const manifestHash = sha256Digest(encrypted);
|
|
1470
|
+
const attemptedAt = now();
|
|
1471
|
+
return atomic((function* () {
|
|
1472
|
+
door.assertStepOpen(signal);
|
|
1473
|
+
const held = yield* sql.get("SELECT encrypted FROM pdpp_transport_manifests_out WHERE scope=? AND version=? AND state='ready'", expected.scope, expected.version);
|
|
1474
|
+
if (!held || replicaDigest(held.encrypted) !== manifestHash)
|
|
1475
|
+
throw new ReplicaProblemError("publish_failed");
|
|
1476
|
+
const current = yield* registrationIntent(expected.scope, expected.version);
|
|
1477
|
+
if (current) {
|
|
1478
|
+
if (current.owner !== slotOwner ||
|
|
1479
|
+
current.manifest_hash !== manifestHash)
|
|
1480
|
+
throw new ReplicaProblemError("publish_failed");
|
|
1481
|
+
yield* sql.run("UPDATE pdpp_transport_registration_intents SET attempted_at=?,attempts=attempts+1 WHERE scope=? AND version=? AND status IN ('unresolved','accepted')", attemptedAt, expected.scope, expected.version);
|
|
1482
|
+
return;
|
|
1483
|
+
}
|
|
1484
|
+
yield* sql.run(`INSERT INTO pdpp_transport_registration_intents
|
|
1485
|
+
(scope,version,owner,manifest_hash,request_id,created_at,attempted_at,attempts,status)
|
|
1486
|
+
VALUES (?,?,?,?,?,?,?,?, 'unresolved')`, expected.scope, expected.version, slotOwner, manifestHash, host.randomId(), attemptedAt, attemptedAt, 1);
|
|
1487
|
+
const { batch } = (yield* sql.get("SELECT batch FROM pdpp_transport_manifests_out WHERE scope=? AND version=?", expected.scope, expected.version));
|
|
1488
|
+
if (yield* recoveryOf(batch))
|
|
1489
|
+
yield* sql.run("UPDATE pdpp_transport_registration_recovery SET state='registering' WHERE version=? AND batch IN (SELECT batch FROM pdpp_transport_manifests_out WHERE scope=?)", expected.version, expected.scope);
|
|
1490
|
+
})());
|
|
1491
|
+
}
|
|
1492
|
+
const registrationWait = (reason) => ({
|
|
1493
|
+
tag: "wait",
|
|
1494
|
+
dependency: { kind: "registration", key: writer(), reason },
|
|
1495
|
+
});
|
|
1496
|
+
function recoveryOf(batch) {
|
|
1497
|
+
return sql.get("SELECT version,state FROM pdpp_transport_registration_recovery WHERE batch=?", batch);
|
|
1498
|
+
}
|
|
1499
|
+
function setRecovery(batch, version, status) {
|
|
1500
|
+
return sql.run(`INSERT INTO pdpp_transport_registration_recovery (batch,version,state) VALUES (?,?,?)
|
|
1501
|
+
ON CONFLICT(batch) DO UPDATE SET version=excluded.version,state=excluded.state`, batch, version, status);
|
|
1502
|
+
}
|
|
1503
|
+
function* registered(m, evidence) {
|
|
1504
|
+
if (m.state === "published")
|
|
1505
|
+
return true;
|
|
1506
|
+
if (["accepted", "confirmed"].includes((yield* registrationIntent(m.scope, m.version))?.status ?? ""))
|
|
1507
|
+
return true;
|
|
1508
|
+
return ((evidence.chain !== null &&
|
|
1509
|
+
evidence.chain.owner.toLowerCase() === owner().toLowerCase() &&
|
|
1510
|
+
evidence.chain.scope === m.scope &&
|
|
1511
|
+
evidence.chain.version === String(m.version) &&
|
|
1512
|
+
BigInt(evidence.chain.currentVersion) >= BigInt(m.version) &&
|
|
1513
|
+
evidence.chain.commitment.toLowerCase() ===
|
|
1514
|
+
replicaChainCommitment(m.data_hash, m.metadata_hash).toLowerCase()) ||
|
|
1515
|
+
(evidence.head?.version === String(m.version) &&
|
|
1516
|
+
evidence.head.owner.toLowerCase() === owner().toLowerCase() &&
|
|
1517
|
+
evidence.head.scope === m.scope &&
|
|
1518
|
+
!evidence.head.deleted &&
|
|
1519
|
+
evidence.head.dataHash.toLowerCase() === m.data_hash.toLowerCase() &&
|
|
1520
|
+
evidence.head.metadataHash.toLowerCase() ===
|
|
1521
|
+
m.metadata_hash.toLowerCase()));
|
|
1522
|
+
}
|
|
1523
|
+
function registeredHeight(fact, scope) {
|
|
1524
|
+
for (const record of [fact.head, fact.chain])
|
|
1525
|
+
if (record &&
|
|
1526
|
+
(record.owner.toLowerCase() !== owner().toLowerCase() ||
|
|
1527
|
+
record.scope !== scope))
|
|
1528
|
+
throw new ReplicaProblemError("registration_unconfirmed");
|
|
1529
|
+
const height = Math.max(Number(fact.chain?.currentVersion ?? 0), Number(fact.head?.version ?? 0));
|
|
1530
|
+
if (!Number.isSafeInteger(height) || height < 0)
|
|
1531
|
+
throw new ReplicaProblemError("registration_unconfirmed");
|
|
1532
|
+
return height;
|
|
1533
|
+
}
|
|
1534
|
+
/** Every pass uses the same classification; a snapshot cannot cancel an in-flight request. */
|
|
1535
|
+
function* recoveryDisposition(m, fact, authenticatedForeign = false) {
|
|
1536
|
+
if (m.state !== "ready" && m.state !== "published")
|
|
1537
|
+
return "retry";
|
|
1538
|
+
if (yield* registered(m, fact))
|
|
1539
|
+
return "accepted";
|
|
1540
|
+
const status = replicaRegistrationState(fact, {
|
|
1541
|
+
owner: owner(),
|
|
1542
|
+
scope: m.scope,
|
|
1543
|
+
version: m.version,
|
|
1544
|
+
dataHash: m.data_hash,
|
|
1545
|
+
metadataHash: m.metadata_hash,
|
|
1546
|
+
});
|
|
1547
|
+
// An authenticated immutable foreign holder proves that this particular
|
|
1548
|
+
// request cannot ever own its old slot, even if its ACK was lost.
|
|
1549
|
+
if (authenticatedForeign && status === "conflict")
|
|
1550
|
+
return "movable";
|
|
1551
|
+
if (status !== "absent" || (yield* registrationIntent(m.scope, m.version)))
|
|
1552
|
+
return "retry";
|
|
1553
|
+
return "movable";
|
|
1554
|
+
}
|
|
1555
|
+
/** Stage signatures without I/O; the outcome transaction moves the entire unaccepted suffix. */
|
|
1556
|
+
async function recoverRegistration(row, evidence, signal) {
|
|
1557
|
+
const received = await receiveReplicaManifest({
|
|
1558
|
+
scope: row.scope,
|
|
1559
|
+
owner: owner(),
|
|
1560
|
+
maxBytes: cap,
|
|
1561
|
+
signal,
|
|
1562
|
+
read: (readSignal) => call(signal, () => transport.get(row.scope, row.version, {
|
|
1563
|
+
maxBytes: cap,
|
|
1564
|
+
signal: readSignal,
|
|
1565
|
+
})),
|
|
1566
|
+
decrypt: (bytes) => call(signal, () => transport.decrypt(row.scope, bytes)),
|
|
1567
|
+
committed: (dataHash, metadataHash) => replicaChainCommitment(dataHash, metadataHash).toLowerCase() ===
|
|
1568
|
+
evidence.chain?.commitment.toLowerCase(),
|
|
1569
|
+
});
|
|
1570
|
+
if (received.tag !== "manifest")
|
|
1571
|
+
return verdict({ tag: "retry", problem: "commitment_mismatch" });
|
|
1572
|
+
const holder = received.manifest;
|
|
1573
|
+
const identity = (await query(host.identity.current()));
|
|
1574
|
+
const original = decodeReplicaManifest(row.plaintext, row.scope, owner());
|
|
1575
|
+
if (holder.authority.statement.server === identity.seed.server) {
|
|
1576
|
+
const authorized = await authorizeManifest(holder, { writer: writer(), version: row.version }, signal);
|
|
1577
|
+
if (authorized.tag === "wait")
|
|
1578
|
+
return verdict(rosterWait(authorized.server));
|
|
1579
|
+
if (authorized.tag === "authorized") {
|
|
1580
|
+
if (holder.batch === original.batch &&
|
|
1581
|
+
holder.digest === original.digest)
|
|
1582
|
+
return verdict({
|
|
1583
|
+
tag: "progress",
|
|
1584
|
+
receipt: { phase: "publish", key: slotKey(row.scope, row.version) },
|
|
1585
|
+
});
|
|
1586
|
+
return withPlan(registrationWait("own_key_conflict"), () => setRecovery(row.batch, row.version, "own_key_conflict"));
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
const { attempts, claims } = await query((function* () {
|
|
1590
|
+
const attempts = (yield* sql.get("SELECT count(*) AS n FROM pdpp_transport_version_squats WHERE scope=? AND detected_at>=?", row.scope, now() - REPLICA_VERSION_SQUAT_WINDOW_MS)).n;
|
|
1591
|
+
if (attempts >= REPLICA_VERSION_SQUAT_ATTEMPTS)
|
|
1592
|
+
return { attempts, claims: [] };
|
|
1593
|
+
const claims = yield* sql.all(`SELECT p.batch,p.version,b.sequence,b.prepared,m.plaintext,m.encrypted,m.data_hash,m.metadata_hash,m.state
|
|
1594
|
+
FROM pdpp_transport_preparation p JOIN pdpp_transport_batches b ON b.batch=p.batch
|
|
1595
|
+
LEFT JOIN pdpp_transport_manifests_out m ON m.batch=p.batch
|
|
1596
|
+
WHERE p.version>=? AND p.state<>'released' ORDER BY b.sequence`, row.version);
|
|
1597
|
+
return { attempts, claims };
|
|
1598
|
+
})());
|
|
1599
|
+
if (attempts >= REPLICA_VERSION_SQUAT_ATTEMPTS)
|
|
1600
|
+
return withPlan(registrationWait("version_squat"), () => setRecovery(row.batch, row.version, "version_squat"));
|
|
1601
|
+
const moving = [];
|
|
1602
|
+
const accepted = [];
|
|
1603
|
+
let highest = registeredHeight(evidence, row.scope);
|
|
1604
|
+
for (const claim of claims) {
|
|
1605
|
+
if (![0, 1, -1, -2].includes(claim.prepared) ||
|
|
1606
|
+
(!claim.plaintext &&
|
|
1607
|
+
(await query(registrationIntent(row.scope, claim.version)))))
|
|
1608
|
+
return verdict({ tag: "retry", problem: "registration_unconfirmed" });
|
|
1609
|
+
if (claim.plaintext) {
|
|
1610
|
+
const m = { ...claim, scope: row.scope };
|
|
1611
|
+
const fact = claim.batch === row.batch
|
|
1612
|
+
? evidence
|
|
1613
|
+
: await call(signal, () => transport.evidence(row.scope, claim.version));
|
|
1614
|
+
highest = Math.max(highest, registeredHeight(fact, row.scope));
|
|
1615
|
+
const disposition = await query(recoveryDisposition(m, fact, claim.batch === row.batch));
|
|
1616
|
+
if (disposition === "accepted") {
|
|
1617
|
+
if (claim.batch === row.batch)
|
|
1618
|
+
return verdict({
|
|
1619
|
+
tag: "retry",
|
|
1620
|
+
problem: "registration_unconfirmed",
|
|
1621
|
+
});
|
|
1622
|
+
highest = Math.max(highest, claim.version);
|
|
1623
|
+
accepted.push(claim);
|
|
1624
|
+
continue;
|
|
1625
|
+
}
|
|
1626
|
+
if (disposition !== "movable")
|
|
1627
|
+
return verdict({ tag: "retry", problem: "registration_unconfirmed" });
|
|
1628
|
+
}
|
|
1629
|
+
moving.push(claim);
|
|
1630
|
+
}
|
|
1631
|
+
const first = highest + 1;
|
|
1632
|
+
if (!Number.isSafeInteger(first + moving.length) ||
|
|
1633
|
+
!moving.some((c) => c.batch === row.batch))
|
|
1634
|
+
throw new ReplicaProblemError("publish_failed");
|
|
1635
|
+
const staged = new Map();
|
|
1636
|
+
for (const [index, claim] of moving.entries()) {
|
|
1637
|
+
if (!claim.plaintext)
|
|
1638
|
+
continue;
|
|
1639
|
+
const manifest = decodeReplicaManifest(claim.plaintext, row.scope, owner());
|
|
1640
|
+
const replacement = await prepareReplicaManifest({
|
|
1641
|
+
...manifest,
|
|
1642
|
+
version: first + index,
|
|
1643
|
+
// The predecessor is a batch/digest pointer, so moving wire slots leaves
|
|
1644
|
+
// the same valid pointer (and every content-addressed fragment) intact.
|
|
1645
|
+
previous: manifest.previous,
|
|
1646
|
+
sign: (facts) => signReplicaBatch({
|
|
1647
|
+
signer: deps.authority.signer,
|
|
1648
|
+
partition: partition(),
|
|
1649
|
+
seed: identity.seed,
|
|
1650
|
+
facts,
|
|
1651
|
+
}),
|
|
1652
|
+
encrypt: (scope, bytes) => call(signal, () => transport.encrypt(scope, bytes)),
|
|
1653
|
+
});
|
|
1654
|
+
if (replacement.encrypted.length > cap)
|
|
1655
|
+
throw new ReplicaProblemError("publish_failed");
|
|
1656
|
+
staged.set(claim.batch, replacement);
|
|
1657
|
+
}
|
|
1658
|
+
// I/O in another already-started upload may have completed while signing.
|
|
1659
|
+
// Recheck acceptance before releasing any claim; no SQLite transaction spans I/O.
|
|
1660
|
+
for (const claim of moving) {
|
|
1661
|
+
if (claim.batch === row.batch || !claim.plaintext)
|
|
1662
|
+
continue;
|
|
1663
|
+
const fact = await call(signal, () => transport.evidence(row.scope, claim.version));
|
|
1664
|
+
if ((await query(recoveryDisposition({ ...claim, scope: row.scope }, fact))) !== "movable")
|
|
1665
|
+
return verdict({ tag: "retry", problem: "registration_unconfirmed" });
|
|
1666
|
+
}
|
|
1667
|
+
return withPlan(pendingRegistration(slotKey(row.scope, first), first, now(), undefined, now()), function* () {
|
|
1668
|
+
// commitIn wraps this whole plan, including its outcome, in one immediate transaction.
|
|
1669
|
+
for (const claim of moving) {
|
|
1670
|
+
const current = yield* preparationOf(claim.batch);
|
|
1671
|
+
const held = yield* sql.get("SELECT version,encrypted,state FROM pdpp_transport_manifests_out WHERE batch=?", claim.batch);
|
|
1672
|
+
if (current?.version !== claim.version ||
|
|
1673
|
+
(claim.encrypted &&
|
|
1674
|
+
(!held ||
|
|
1675
|
+
held.version !== claim.version ||
|
|
1676
|
+
held.state !== "ready" ||
|
|
1677
|
+
!sameBytes(held.encrypted, claim.encrypted))) ||
|
|
1678
|
+
(claim.batch !== row.batch &&
|
|
1679
|
+
(yield* registrationIntent(row.scope, claim.version)) !==
|
|
1680
|
+
undefined) ||
|
|
1681
|
+
["accepted", "confirmed"].includes((yield* registrationIntent(row.scope, claim.version))?.status ??
|
|
1682
|
+
""))
|
|
1683
|
+
throw new ReplicaProblemError("publish_failed");
|
|
1684
|
+
}
|
|
1685
|
+
yield* sql.run("INSERT INTO pdpp_transport_version_squats (scope,version,digest,detected_at,batch,state) VALUES (?,?,?,?,?,'squatted')", row.scope, row.version, received.dataHash, now(), row.batch);
|
|
1686
|
+
// Release every old slot before assigning the suffix, including unsigned claims.
|
|
1687
|
+
for (const claim of moving) {
|
|
1688
|
+
yield* releaseClaim(claim.batch);
|
|
1689
|
+
yield* sql.run("DELETE FROM pdpp_transport_registration_intents WHERE scope=? AND version=?", row.scope, claim.version);
|
|
1690
|
+
yield* sql.run("DELETE FROM pdpp_transport_upload_attempts WHERE batch=? AND kind='manifest'", claim.batch);
|
|
1691
|
+
yield* sql.run("UPDATE pdpp_transport_preparation SET version=0 WHERE batch=?", claim.batch);
|
|
1692
|
+
}
|
|
1693
|
+
for (const [index, claim] of moving.entries()) {
|
|
1694
|
+
const version = first + index;
|
|
1695
|
+
const m = staged.get(claim.batch);
|
|
1696
|
+
if (m)
|
|
1697
|
+
yield* sql.run("INSERT INTO pdpp_transport_manifests_out (batch,scope,version,plaintext,encrypted,data_hash,metadata_hash) VALUES (?,?,?,?,?,?,?)", claim.batch, m.scope, m.version, m.plaintext, m.encrypted, m.dataHash, m.metadataHash);
|
|
1698
|
+
yield* sql.run("UPDATE pdpp_transport_preparation SET version=? WHERE batch=?", version, claim.batch);
|
|
1699
|
+
yield* setRecovery(claim.batch, version, "resigning");
|
|
1700
|
+
}
|
|
1701
|
+
for (const claim of accepted)
|
|
1702
|
+
yield* setRecovery(claim.batch, claim.version, "registering");
|
|
1703
|
+
yield* sql.run("UPDATE pdpp_transport_version_squats SET state='resigning' WHERE scope=? AND version=?", row.scope, row.version);
|
|
1704
|
+
});
|
|
1705
|
+
}
|
|
1706
|
+
/** The verdict for a slot whose registration is pending or awaiting finality. */
|
|
1707
|
+
function* pendingVerdict(row, key, evidence, intent, previousWaitSince) {
|
|
1708
|
+
return (yield* tailPending())
|
|
1709
|
+
? pendingRegistration(key, row.version, intent?.created_at ?? previousWaitSince ?? now(), evidence.chain?.blockNumber.toString())
|
|
1710
|
+
: finalityWait(evidence, key);
|
|
1711
|
+
}
|
|
1712
|
+
async function publishManifest(batch, previousWaitSince, signal) {
|
|
1713
|
+
const row = (await query(sql.get("SELECT * FROM pdpp_transport_manifests_out WHERE batch=?", batch)));
|
|
1714
|
+
const key = slotKey(row.scope, row.version);
|
|
1715
|
+
const expected = {
|
|
1716
|
+
owner: owner(),
|
|
1717
|
+
scope: row.scope,
|
|
1718
|
+
version: row.version,
|
|
1719
|
+
dataHash: row.data_hash,
|
|
1720
|
+
metadataHash: row.metadata_hash,
|
|
1721
|
+
};
|
|
1722
|
+
try {
|
|
1723
|
+
// Local order before any network call: a recovery or an unsettled
|
|
1724
|
+
// predecessor decides this slot's verdict without evidence.
|
|
1725
|
+
const ordered = await query((function* () {
|
|
1726
|
+
const recovery = yield* recoveryOf(batch);
|
|
1727
|
+
if (recovery?.state === "own_key_conflict" ||
|
|
1728
|
+
recovery?.state === "version_squat")
|
|
1729
|
+
return { outcome: registrationWait(recovery.state) };
|
|
1730
|
+
if (recovery) {
|
|
1731
|
+
const prior = yield* sql.get(`SELECT p.version,m.checked_block,m.next_attempt FROM pdpp_transport_batches b
|
|
1732
|
+
JOIN pdpp_transport_preparation p ON p.batch=b.batch LEFT JOIN pdpp_transport_manifests_out m ON m.batch=b.batch
|
|
1733
|
+
WHERE b.sequence<(SELECT sequence FROM pdpp_transport_batches WHERE batch=?) AND NOT ${batchSettled("b")}
|
|
1734
|
+
ORDER BY b.sequence LIMIT 1`, batch);
|
|
1735
|
+
if (prior)
|
|
1736
|
+
return {
|
|
1737
|
+
outcome: pendingRegistration(slotKey(row.scope, prior.version), prior.version, previousWaitSince ?? now(), prior.checked_block ?? undefined, prior.next_attempt ?? undefined),
|
|
1738
|
+
};
|
|
1739
|
+
}
|
|
1740
|
+
if (row.version > 1 && !recovery) {
|
|
1741
|
+
const predecessor = yield* sql.get("SELECT state,outcome,reason,checked_block,next_attempt FROM pdpp_transport_manifests_out WHERE scope=? AND version=?", row.scope, row.version - 1);
|
|
1742
|
+
const predecessorSettled = predecessor?.state === "published" ||
|
|
1743
|
+
(predecessor?.state === "quarantined" &&
|
|
1744
|
+
predecessor.outcome === "quarantine" &&
|
|
1745
|
+
predecessor.reason === "immutable_registration_conflict");
|
|
1746
|
+
if (!predecessorSettled) {
|
|
1747
|
+
const intent = yield* registrationIntent(row.scope, row.version - 1);
|
|
1748
|
+
return {
|
|
1749
|
+
outcome: pendingRegistration(slotKey(row.scope, row.version - 1), row.version - 1, intent?.created_at ?? previousWaitSince ?? now(), predecessor?.checked_block ?? undefined, predecessor?.next_attempt),
|
|
1750
|
+
};
|
|
1751
|
+
}
|
|
1752
|
+
}
|
|
1753
|
+
return {};
|
|
1754
|
+
})());
|
|
1755
|
+
if (ordered.outcome)
|
|
1756
|
+
return verdict(ordered.outcome);
|
|
1757
|
+
let evidence = await call(signal, () => transport.evidence(row.scope, row.version));
|
|
1758
|
+
let status = replicaRegistrationState(evidence, expected);
|
|
1759
|
+
if (status === "conflict")
|
|
1760
|
+
return await recoverRegistration(row, evidence, signal);
|
|
1761
|
+
if (status === "confirmed")
|
|
1762
|
+
return verdict({
|
|
1763
|
+
tag: "progress",
|
|
1764
|
+
receipt: { phase: "publish", key },
|
|
1765
|
+
});
|
|
1766
|
+
let intent = await query(registrationIntent(row.scope, row.version));
|
|
1767
|
+
if (status === "pending")
|
|
1768
|
+
return verdict(await query(pendingVerdict(row, key, evidence, intent, previousWaitSince)));
|
|
1769
|
+
if (status === "absent") {
|
|
1770
|
+
const timestamp = now();
|
|
1771
|
+
if (intent &&
|
|
1772
|
+
timestamp - intent.attempted_at < registrationIntentTimeout)
|
|
1773
|
+
return verdict(pendingRegistration(key, row.version, intent.created_at));
|
|
1774
|
+
const manifest = {
|
|
1775
|
+
scope: row.scope,
|
|
1776
|
+
version: row.version,
|
|
1777
|
+
plaintext: row.plaintext,
|
|
1778
|
+
encrypted: row.encrypted,
|
|
1779
|
+
dataHash: row.data_hash,
|
|
1780
|
+
metadataHash: row.metadata_hash,
|
|
1781
|
+
};
|
|
1782
|
+
await recordUploadAttempt(batch, "manifest", -1, signal);
|
|
1783
|
+
await call(signal, () => transport.put(manifest, signal));
|
|
1784
|
+
await recordRegistrationAttempt(expected, row.encrypted, signal);
|
|
1785
|
+
try {
|
|
1786
|
+
await call(signal, () => transport.register(manifest));
|
|
1787
|
+
await atomic((function* () {
|
|
1788
|
+
door.assertStepOpen(signal);
|
|
1789
|
+
yield* sql.run("UPDATE pdpp_transport_registration_intents SET status='accepted' WHERE scope=? AND version=? AND manifest_hash=?", row.scope, row.version, replicaDigest(row.encrypted));
|
|
1790
|
+
})());
|
|
1791
|
+
}
|
|
1792
|
+
catch {
|
|
1793
|
+
return verdict(pendingRegistration(key, row.version, intent?.created_at ?? timestamp));
|
|
1794
|
+
}
|
|
1795
|
+
evidence = await call(signal, () => transport.evidence(row.scope, row.version));
|
|
1796
|
+
status = replicaRegistrationState(evidence, expected);
|
|
1797
|
+
}
|
|
1798
|
+
if (status === "conflict")
|
|
1799
|
+
return await recoverRegistration(row, evidence, signal);
|
|
1800
|
+
if (status === "confirmed")
|
|
1801
|
+
return verdict({
|
|
1802
|
+
tag: "progress",
|
|
1803
|
+
receipt: { phase: "publish", key },
|
|
1804
|
+
});
|
|
1805
|
+
if (status === "pending")
|
|
1806
|
+
return verdict(await query(pendingVerdict(row, key, evidence, intent, previousWaitSince)));
|
|
1807
|
+
intent = await query(registrationIntent(row.scope, row.version));
|
|
1808
|
+
return verdict(pendingRegistration(key, row.version, intent?.created_at ?? previousWaitSince ?? now()));
|
|
1809
|
+
}
|
|
1810
|
+
catch (error) {
|
|
1811
|
+
return verdict(retryFromError(error, "publish_failed"));
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1814
|
+
/**
|
|
1815
|
+
* The batch's registration is finalized: drop its payload and fragment
|
|
1816
|
+
* ciphertext in the caller's transaction (host contract §6, "Reclaim").
|
|
1817
|
+
* The fragment rows keep their digests and the manifest keeps every
|
|
1818
|
+
* fragment's length, which is all later chain decisions read. No outbound
|
|
1819
|
+
* phase selects a published batch's fragments or payload again.
|
|
1820
|
+
*/
|
|
1821
|
+
function* reclaimPublished(batch) {
|
|
1822
|
+
yield* deleteReplicaPayload(batch);
|
|
1823
|
+
yield* sql.run("UPDATE pdpp_transport_fragments_out SET encrypted=x'' WHERE batch=? AND length(encrypted)>0", batch);
|
|
1824
|
+
}
|
|
1825
|
+
/** Revisit a released slot if another writer registers it. */
|
|
1826
|
+
function* releaseClaim(batch) {
|
|
1827
|
+
yield* sql.run(`UPDATE pdpp_transport_discover_floor
|
|
1828
|
+
SET version=min(version,(SELECT version-1 FROM pdpp_transport_manifests_out WHERE batch=?))
|
|
1829
|
+
WHERE scope=(SELECT scope FROM pdpp_transport_manifests_out WHERE batch=?)`, batch, batch);
|
|
1830
|
+
yield* sql.run("DELETE FROM pdpp_transport_manifests_out WHERE batch=?", batch);
|
|
1831
|
+
}
|
|
1832
|
+
/** Reclaim batches finalized before reclaim existed. Once per engine. */
|
|
1833
|
+
let reclaimSwept = false;
|
|
1834
|
+
function* sweepPublished() {
|
|
1835
|
+
if (reclaimSwept)
|
|
1836
|
+
return;
|
|
1837
|
+
const batches = yield* sql.all(`SELECT m.batch FROM pdpp_transport_manifests_out m WHERE m.state='published'
|
|
1838
|
+
AND (EXISTS (SELECT 1 FROM pdpp_binary_manifests p WHERE p.namespace='payload' AND p.blob_id=m.batch)
|
|
1839
|
+
OR EXISTS (SELECT 1 FROM pdpp_transport_fragments_out f WHERE f.batch=m.batch AND length(f.encrypted)>0))`);
|
|
1840
|
+
for (const { batch } of batches)
|
|
1841
|
+
yield* reclaimPublished(batch);
|
|
1842
|
+
const ownAdmissions = `SELECT i.slot FROM pdpp_transport_manifests_in i
|
|
1843
|
+
JOIN pdpp_transport_manifests_out o ON o.scope=i.scope AND o.version=i.version AND o.state='published'
|
|
1844
|
+
WHERE (i.state='ready' AND i.manifest_json IS NULL) OR (i.state='imported' AND i.batch=o.batch)`;
|
|
1845
|
+
yield* sql.run(`DELETE FROM pdpp_transport_fragment_parts WHERE slot IN (${ownAdmissions})`);
|
|
1846
|
+
yield* sql.run(`DELETE FROM pdpp_transport_fragments_in WHERE slot IN (${ownAdmissions})`);
|
|
1847
|
+
yield* sql.run(`DELETE FROM pdpp_transport_manifests_in WHERE slot IN (${ownAdmissions})`);
|
|
1848
|
+
reclaimSwept = true;
|
|
1849
|
+
}
|
|
1850
|
+
const publish = {
|
|
1851
|
+
phase: "publish",
|
|
1852
|
+
due(e, limit) {
|
|
1853
|
+
// Parked waits are filtered before LIMIT so they cannot hide fresh or error work.
|
|
1854
|
+
return then(query(sql.all(dueWindow(OUTBOUND, "batch", "next_attempt,kind,coalesce(version,0),position", due(e)), limit.perWriter, limit.after ?? "", limit.total)), (rows) => rows.map((row) => ({
|
|
1855
|
+
id: row.id,
|
|
1856
|
+
phase: "publish",
|
|
1857
|
+
// Local batches take turns like remote writers do.
|
|
1858
|
+
writer: row.batch,
|
|
1859
|
+
state: workState(row),
|
|
1860
|
+
row,
|
|
1861
|
+
})));
|
|
1862
|
+
},
|
|
1863
|
+
async run(item, signal) {
|
|
1864
|
+
const row = item.row;
|
|
1865
|
+
if (row.kind === "manifest")
|
|
1866
|
+
return publishManifest(row.batch, row.wait_since, signal);
|
|
1867
|
+
try {
|
|
1868
|
+
const fragment = (await query(sql.get("SELECT digest,encrypted FROM pdpp_transport_fragments_out WHERE batch=? AND position=?", row.batch, row.position)));
|
|
1869
|
+
// Unregistered and content-addressed: a PUT is safe to repeat, and
|
|
1870
|
+
// nothing references it until the manifest is finalized.
|
|
1871
|
+
await recordUploadAttempt(row.batch, "fragment", row.position, signal);
|
|
1872
|
+
await call(signal, () => transport.putFragment({
|
|
1873
|
+
scope: replicaScope(writer()),
|
|
1874
|
+
digest: fragment.digest,
|
|
1875
|
+
encrypted: fragment.encrypted,
|
|
1876
|
+
}, signal));
|
|
1877
|
+
return verdict({
|
|
1878
|
+
tag: "progress",
|
|
1879
|
+
receipt: { phase: "publish", key: row.id },
|
|
1880
|
+
});
|
|
1881
|
+
}
|
|
1882
|
+
catch (error) {
|
|
1883
|
+
return verdict(retryFromError(error, "publish_failed"));
|
|
1884
|
+
}
|
|
1885
|
+
},
|
|
1886
|
+
commit: (item, attempt, settle, context) => commitIn(function* (outcome, work) {
|
|
1887
|
+
const next = outcome.tag === "progress"
|
|
1888
|
+
? item.row.kind === "manifest"
|
|
1889
|
+
? "published"
|
|
1890
|
+
: "stored"
|
|
1891
|
+
: outcome.tag === "quarantine"
|
|
1892
|
+
? "quarantined"
|
|
1893
|
+
: "ready";
|
|
1894
|
+
if (item.row.kind === "manifest")
|
|
1895
|
+
yield* sql.run(`UPDATE pdpp_transport_manifests_out SET ${WORK_SET},state=? WHERE batch=?`, ...workValues(outcome, work), next, item.row.batch);
|
|
1896
|
+
if (item.row.kind === "manifest" && outcome.tag === "progress")
|
|
1897
|
+
yield* sql.run("UPDATE pdpp_transport_registration_recovery SET state='committed' WHERE batch=?", item.row.batch);
|
|
1898
|
+
if (item.row.kind === "manifest" && outcome.tag === "progress") {
|
|
1899
|
+
yield* sql.run("UPDATE pdpp_transport_registration_intents SET status='confirmed' WHERE scope=? AND version=?", item.row.scope, item.row.version);
|
|
1900
|
+
// The batch is durable on the gateway: its operations leave the
|
|
1901
|
+
// host's outbox in the same transaction.
|
|
1902
|
+
const exported = yield* sql.get("SELECT export_next FROM pdpp_transport_batches WHERE batch=?", item.row.batch);
|
|
1903
|
+
if (exported?.export_next != null)
|
|
1904
|
+
yield* exporter.acknowledgePublished(exported.export_next);
|
|
1905
|
+
yield* reclaimPublished(item.row.batch);
|
|
1906
|
+
}
|
|
1907
|
+
if (item.row.kind === "manifest" && outcome.tag === "quarantine")
|
|
1908
|
+
yield* sql.run("UPDATE pdpp_transport_registration_intents SET status='conflict' WHERE scope=? AND version=?", item.row.scope, item.row.version);
|
|
1909
|
+
else
|
|
1910
|
+
yield* sql.run(`UPDATE pdpp_transport_fragments_out SET ${WORK_SET},state=? WHERE batch=? AND position=?`, ...workValues(outcome, work), next, item.row.batch, item.row.position);
|
|
1911
|
+
if (item.row.kind === "manifest")
|
|
1912
|
+
yield* sql.run(`UPDATE pdpp_transport_manifests_out SET next_attempt=?
|
|
1913
|
+
WHERE scope=? AND version>? AND state='ready'
|
|
1914
|
+
AND dependency=?`, outcome.tag === "quarantine" ? now() : work.nextEligible, item.row.scope, item.row.version, `registration_pending:${slotKey(item.row.scope, item.row.version)}`);
|
|
1915
|
+
}, function* (selected) {
|
|
1916
|
+
const row = selected.row;
|
|
1917
|
+
const current = row.kind === "manifest"
|
|
1918
|
+
? yield* sql.get("SELECT state,version,attempts,next_attempt,wait_since,checked_block,outcome FROM pdpp_transport_manifests_out WHERE batch=?", row.batch)
|
|
1919
|
+
: yield* sql.get("SELECT state,attempts,next_attempt,wait_since,checked_block,outcome FROM pdpp_transport_fragments_out WHERE batch=? AND position=?", row.batch, row.position);
|
|
1920
|
+
return (current !== undefined &&
|
|
1921
|
+
(row.kind !== "manifest" || current.version === row.version) &&
|
|
1922
|
+
current.state === "ready" &&
|
|
1923
|
+
current.attempts === row.attempts &&
|
|
1924
|
+
current.next_attempt === row.next_attempt &&
|
|
1925
|
+
current.wait_since === row.wait_since &&
|
|
1926
|
+
current.checked_block === row.checked_block &&
|
|
1927
|
+
current.outcome === row.outcome);
|
|
1928
|
+
})(item, attempt, settle, context),
|
|
1929
|
+
commitProblem: "publish_failed",
|
|
1930
|
+
earliest(e) {
|
|
1931
|
+
return query(earliestIn(e, `${OUTBOUND} outbound`));
|
|
1932
|
+
},
|
|
1933
|
+
};
|
|
1934
|
+
// ------------------------------------------------------------- discover
|
|
1935
|
+
const discover = {
|
|
1936
|
+
phase: "discover",
|
|
1937
|
+
due(e) {
|
|
1938
|
+
return then(query(singleton("discover")), (row) => (singletonEligibleAt(row, e) ?? Infinity) <= e.now
|
|
1939
|
+
? [
|
|
1940
|
+
{
|
|
1941
|
+
id: "discover",
|
|
1942
|
+
phase: "discover",
|
|
1943
|
+
writer: "",
|
|
1944
|
+
state: workState(row),
|
|
1945
|
+
row,
|
|
1946
|
+
},
|
|
1947
|
+
]
|
|
1948
|
+
: []);
|
|
1949
|
+
},
|
|
1950
|
+
async run(_item, signal) {
|
|
1951
|
+
let page;
|
|
1952
|
+
try {
|
|
1953
|
+
page = await call(signal, async () => transport.list((await query(state())).scan_cursor));
|
|
1954
|
+
}
|
|
1955
|
+
catch (error) {
|
|
1956
|
+
return verdict(retryFromError(error, "discovery_failed"));
|
|
1957
|
+
}
|
|
1958
|
+
// Page admissions and its cursor commit together. A null cursor restarts
|
|
1959
|
+
// from page one next step: no timestamp high-water mark can hide a late
|
|
1960
|
+
// registration. The Gateway version only says how many slots to admit;
|
|
1961
|
+
// each slot is verified against finalized evidence before any fetch.
|
|
1962
|
+
return withPlan({
|
|
1963
|
+
tag: "progress",
|
|
1964
|
+
receipt: { phase: "discover", key: page.cursor ?? "" },
|
|
1965
|
+
}, function* () {
|
|
1966
|
+
for (const point of page.points) {
|
|
1967
|
+
const pointWriter = replicaWriterOf(point.scope);
|
|
1968
|
+
if (!pointWriter || !/^[1-9][0-9]{0,14}$/.test(point.version))
|
|
1969
|
+
continue;
|
|
1970
|
+
// Every version through the floor is claimed or admitted.
|
|
1971
|
+
const head = Number(point.version);
|
|
1972
|
+
const floor = (yield* sql.get("SELECT version FROM pdpp_transport_discover_floor WHERE scope=?", point.scope))?.version ?? 0;
|
|
1973
|
+
const covered = new Set((yield* sql.all(`SELECT version FROM pdpp_transport_manifests_out WHERE scope=? AND version>? AND version<=?
|
|
1974
|
+
UNION ALL SELECT version FROM pdpp_transport_manifests_in WHERE scope=? AND version>? AND version<=?`, point.scope, floor, head, point.scope, floor, head)).map((row) => row.version));
|
|
1975
|
+
let admitted = 0;
|
|
1976
|
+
let version = floor + 1;
|
|
1977
|
+
for (; version <= head; version++) {
|
|
1978
|
+
if (covered.has(version))
|
|
1979
|
+
continue;
|
|
1980
|
+
if (admitted === DISCOVER_SLOTS)
|
|
1981
|
+
break;
|
|
1982
|
+
yield* sql.run("INSERT OR IGNORE INTO pdpp_transport_manifests_in (slot,scope,version,writer) VALUES (?,?,?,?)", slotKey(point.scope, version), point.scope, version, pointWriter);
|
|
1983
|
+
admitted++;
|
|
1984
|
+
}
|
|
1985
|
+
yield* sql.run("INSERT INTO pdpp_transport_discover_floor (scope,version) VALUES (?,?) ON CONFLICT(scope) DO UPDATE SET version=excluded.version", point.scope, version - 1);
|
|
1986
|
+
}
|
|
1987
|
+
yield* sql.run("UPDATE pdpp_transport_state SET scan_cursor=? WHERE id=1", page.cursor);
|
|
1988
|
+
});
|
|
1989
|
+
},
|
|
1990
|
+
commit: commitIn((outcome, work) => settleSingleton("discover", outcome, work)),
|
|
1991
|
+
commitProblem: "discovery_failed",
|
|
1992
|
+
earliest(e) {
|
|
1993
|
+
// A polling source: only an outstanding Retry is a deadline.
|
|
1994
|
+
return query(singletonDeadline("discover", e));
|
|
1995
|
+
},
|
|
1996
|
+
};
|
|
1997
|
+
// ------------------------------------------------------------- download
|
|
1998
|
+
const slotItem = (phase) => (row) => ({
|
|
1999
|
+
id: row.slot,
|
|
2000
|
+
phase,
|
|
2001
|
+
writer: row.writer,
|
|
2002
|
+
state: workState(row),
|
|
2003
|
+
row,
|
|
2004
|
+
});
|
|
2005
|
+
/** Make matching waits due now. Durable: survives a lost notification. */
|
|
2006
|
+
/**
|
|
2007
|
+
* A received fragment's JSON. Fragments fetched before the parts table
|
|
2008
|
+
* keep it in `fragment_json`; later ones leave that empty and use parts.
|
|
2009
|
+
*/
|
|
2010
|
+
function* fragmentJson(slot, position) {
|
|
2011
|
+
const whole = yield* sql.get("SELECT fragment_json FROM pdpp_transport_fragments_in WHERE slot=? AND position=?", slot, position);
|
|
2012
|
+
if (whole?.fragment_json)
|
|
2013
|
+
return whole.fragment_json;
|
|
2014
|
+
let json = "";
|
|
2015
|
+
for (let part = 0;; part++) {
|
|
2016
|
+
const row = yield* sql.get("SELECT json FROM pdpp_transport_fragment_parts WHERE slot=? AND position=? AND part=?", slot, position, part);
|
|
2017
|
+
if (!row)
|
|
2018
|
+
return json;
|
|
2019
|
+
json += row.json;
|
|
2020
|
+
}
|
|
2021
|
+
}
|
|
2022
|
+
function* dropFragments(slot) {
|
|
2023
|
+
yield* sql.run("DELETE FROM pdpp_transport_fragment_parts WHERE slot=?", slot);
|
|
2024
|
+
yield* sql.run("DELETE FROM pdpp_transport_fragments_in WHERE slot=?", slot);
|
|
2025
|
+
}
|
|
2026
|
+
function* wake(key, at) {
|
|
2027
|
+
return (yield* sql.run("UPDATE pdpp_transport_import_work SET next_attempt=? WHERE outcome='wait' AND dependency=? AND next_attempt>?", at, key, at)).changes;
|
|
2028
|
+
}
|
|
2029
|
+
/** A slot whose finalized manifest can never be valid: terminal for that slot only. */
|
|
2030
|
+
const refuseSlot = (slot, reason) => withPlan(quarantine(reason, slot), () => sql.run(`UPDATE pdpp_transport_manifests_in SET ${QUARANTINE_SET(reason)} WHERE slot=?`, slot));
|
|
2031
|
+
const download = {
|
|
2032
|
+
phase: "download",
|
|
2033
|
+
due(e, limit) {
|
|
2034
|
+
return then(query(sql.all(dueWindow(`(SELECT rowid AS position,slot,scope,version,writer,attempts,next_attempt,wait_since,checked_block,outcome,dependency FROM pdpp_transport_manifests_in WHERE state='ready' AND manifest_json IS NULL)`, "writer", "next_attempt,version", due(e)), limit.perWriter, limit.after ?? "", limit.total)), (rows) => rows.map(slotItem("download")));
|
|
2035
|
+
},
|
|
2036
|
+
async run(item, signal) {
|
|
2037
|
+
const row = item.row;
|
|
2038
|
+
let manifestReservation;
|
|
2039
|
+
let keepManifestReservation = false;
|
|
2040
|
+
const slot = {
|
|
2041
|
+
owner: owner(),
|
|
2042
|
+
scope: row.scope,
|
|
2043
|
+
version: row.version,
|
|
2044
|
+
};
|
|
2045
|
+
try {
|
|
2046
|
+
// A manifest is trusted only through its finalized slot commitment,
|
|
2047
|
+
// so nothing is fetched before that commitment exists.
|
|
2048
|
+
const evidence = await call(signal, () => transport.evidence(row.scope, row.version));
|
|
2049
|
+
if (replicaRegistrationState(evidence, slot) !== "confirmed")
|
|
2050
|
+
return verdict(unconfirmed(evidence, slot));
|
|
2051
|
+
const reserved = resourceBudget.reserve(resourceLimits.maxManifestCiphertextBytes * REPLICA_LIVE_COPY_FACTOR);
|
|
2052
|
+
if (typeof reserved !== "function")
|
|
2053
|
+
return verdict({ tag: "retry", problem: "resource_busy" });
|
|
2054
|
+
manifestReservation = reserved;
|
|
2055
|
+
// Every peer-supplied manifest crosses this one boundary. Bytes that
|
|
2056
|
+
// are not the finalized commitment are a refetch, never data, so
|
|
2057
|
+
// replacing stored bytes cannot get a real manifest refused; after
|
|
2058
|
+
// the commitment every failure is terminal. No fragment is fetched
|
|
2059
|
+
// before the manifest is authorized.
|
|
2060
|
+
const receipt = await receiveReplicaManifest({
|
|
2061
|
+
scope: row.scope,
|
|
2062
|
+
owner: owner(),
|
|
2063
|
+
maxBytes: cap,
|
|
2064
|
+
signal,
|
|
2065
|
+
read: (readSignal) => call(signal, () => transport.get(row.scope, row.version, {
|
|
2066
|
+
maxBytes: cap,
|
|
2067
|
+
signal: readSignal,
|
|
2068
|
+
})),
|
|
2069
|
+
decrypt: (encrypted) => call(signal, () => transport.decrypt(row.scope, encrypted)),
|
|
2070
|
+
committed: (dataHash, metadataHash) => replicaRegistrationState(evidence, {
|
|
2071
|
+
...slot,
|
|
2072
|
+
dataHash,
|
|
2073
|
+
metadataHash,
|
|
2074
|
+
}) === "confirmed",
|
|
2075
|
+
});
|
|
2076
|
+
if (receipt.tag === "refused")
|
|
2077
|
+
return refuseSlot(row.slot, receipt.reason);
|
|
2078
|
+
if (receipt.tag === "mismatch")
|
|
2079
|
+
throw new ReplicaProblemError("commitment_mismatch");
|
|
2080
|
+
const { manifest, dataHash, metadataHash } = receipt;
|
|
2081
|
+
const authorized = await authorizeManifest(manifest, row, signal);
|
|
2082
|
+
if (authorized.tag === "refused")
|
|
2083
|
+
return refuseSlot(row.slot, authorized.reason);
|
|
2084
|
+
if (authorized.tag === "wait")
|
|
2085
|
+
return verdict(rosterWait(authorized.server));
|
|
2086
|
+
const admitted = admit(manifest, resourceBudget, {
|
|
2087
|
+
repositoryId: await query(repositoryId()),
|
|
2088
|
+
owner: owner(),
|
|
2089
|
+
manifestDigest: manifest.digest,
|
|
2090
|
+
}, resourceLimits);
|
|
2091
|
+
if ("kind" in admitted) {
|
|
2092
|
+
if (admitted.kind === "resource_busy")
|
|
2093
|
+
return verdict({ tag: "retry", problem: "resource_busy" });
|
|
2094
|
+
return withPlan({
|
|
2095
|
+
tag: "unsupported",
|
|
2096
|
+
admission: {
|
|
2097
|
+
reason: "resource_unsupported",
|
|
2098
|
+
limit: admitted.limit,
|
|
2099
|
+
limitsVersion: admitted.limitsVersion,
|
|
2100
|
+
},
|
|
2101
|
+
}, () => sql.run(`UPDATE pdpp_transport_manifests_in
|
|
2102
|
+
SET resource_limits_version=? WHERE slot=?`, admitted.limitsVersion, row.slot));
|
|
2103
|
+
}
|
|
2104
|
+
const record = factOf(row, evidence, manifest);
|
|
2105
|
+
keepManifestReservation = true;
|
|
2106
|
+
return {
|
|
2107
|
+
outcome: {
|
|
2108
|
+
tag: "progress",
|
|
2109
|
+
receipt: { phase: "download", key: row.slot },
|
|
2110
|
+
},
|
|
2111
|
+
plan: function* () {
|
|
2112
|
+
// A correct writer gives each batch one version. Another slot
|
|
2113
|
+
// naming the same batch is equivocation: only the first is used.
|
|
2114
|
+
const holder = yield* sql.get("SELECT slot FROM pdpp_transport_manifests_in WHERE writer=? AND batch=? AND slot<>? AND state<>'quarantined'", row.writer, manifest.batch, row.slot);
|
|
2115
|
+
if (holder) {
|
|
2116
|
+
yield* sql.run(`UPDATE pdpp_transport_manifests_in SET ${QUARANTINE_SET("batch_equivocation")} WHERE slot=?`, row.slot);
|
|
2117
|
+
return quarantine("batch_equivocation", row.slot);
|
|
2118
|
+
}
|
|
2119
|
+
yield* sql.run("UPDATE pdpp_transport_manifests_in SET manifest_json=?,data_hash=?,metadata_hash=?,batch=?,sequence=? WHERE slot=?", canonicalJson(manifest), dataHash, metadataHash, manifest.batch, manifest.sequence, row.slot);
|
|
2120
|
+
for (const [position, entry] of manifest.fragments.entries())
|
|
2121
|
+
yield* sql.run("INSERT OR IGNORE INTO pdpp_transport_fragments_in (slot,position,digest,bytes) VALUES (?,?,?,?)", row.slot, position, entry.digest, entry.bytes);
|
|
2122
|
+
if (record)
|
|
2123
|
+
yield* record();
|
|
2124
|
+
return undefined;
|
|
2125
|
+
},
|
|
2126
|
+
release: () => manifestReservation?.(),
|
|
2127
|
+
};
|
|
2128
|
+
}
|
|
2129
|
+
catch (error) {
|
|
2130
|
+
// Network and unavailable bytes are retryable; authenticated malformed bytes back off.
|
|
2131
|
+
return verdict(retryFromError(error, "download_failed"));
|
|
2132
|
+
}
|
|
2133
|
+
finally {
|
|
2134
|
+
if (!keepManifestReservation)
|
|
2135
|
+
manifestReservation?.();
|
|
2136
|
+
}
|
|
2137
|
+
},
|
|
2138
|
+
commit: commitIn((outcome, work, item) => sql.run(`UPDATE pdpp_transport_manifests_in SET ${WORK_SET} WHERE slot=? AND state='ready'`, ...workValues(outcome, work), item.id)),
|
|
2139
|
+
commitProblem: "download_failed",
|
|
2140
|
+
earliest(e) {
|
|
2141
|
+
return query(earliestIn(e, "pdpp_transport_manifests_in WHERE state='ready' AND manifest_json IS NULL"));
|
|
2142
|
+
},
|
|
2143
|
+
};
|
|
2144
|
+
// ---------------------------------------------------------------- fetch
|
|
2145
|
+
function* manifestOf(slot) {
|
|
2146
|
+
return JSON.parse((yield* sql.get("SELECT manifest_json FROM pdpp_transport_manifests_in WHERE slot=?", slot)).manifest_json);
|
|
2147
|
+
}
|
|
2148
|
+
/** A fragment that disproves its slot quarantines the slot with it. */
|
|
2149
|
+
const quarantineFragment = (row, reason) => withPlan(quarantine(reason, `${row.slot}#${row.position}`), () => sql.run(`UPDATE pdpp_transport_manifests_in SET ${QUARANTINE_SET(reason)} WHERE slot=? AND state='ready'`, row.slot));
|
|
2150
|
+
const FETCHABLE = `(SELECT f.rowid AS rowid_,f.slot,f.position,f.digest,f.bytes,m.writer,f.attempts,f.next_attempt,f.wait_since,f.checked_block,f.outcome,f.dependency
|
|
2151
|
+
FROM pdpp_transport_fragments_in f JOIN pdpp_transport_manifests_in m ON m.slot=f.slot
|
|
2152
|
+
WHERE f.state='ready' AND f.fragment_json IS NULL AND m.state='ready')`;
|
|
2153
|
+
const fetchFragment = {
|
|
2154
|
+
phase: "fetch",
|
|
2155
|
+
due(e, limit) {
|
|
2156
|
+
return then(query(sql.all(dueWindow(FETCHABLE, "writer", "next_attempt,rowid_", due(e)), limit.perWriter, limit.after ?? "", limit.total)), (rows) => rows.map((row) => ({
|
|
2157
|
+
id: `${row.slot}#${row.position}`,
|
|
2158
|
+
phase: "fetch",
|
|
2159
|
+
writer: row.writer,
|
|
2160
|
+
state: workState(row),
|
|
2161
|
+
bytes: row.bytes,
|
|
2162
|
+
row,
|
|
2163
|
+
})));
|
|
2164
|
+
},
|
|
2165
|
+
async run(item, signal) {
|
|
2166
|
+
const row = item.row;
|
|
2167
|
+
const scope = replicaScope(row.writer);
|
|
2168
|
+
let fragment;
|
|
2169
|
+
let fragmentReservation;
|
|
2170
|
+
let keepFragmentReservation = false;
|
|
2171
|
+
try {
|
|
2172
|
+
const manifest = await query(manifestOf(row.slot));
|
|
2173
|
+
const admitted = admit(manifest, resourceBudget, {
|
|
2174
|
+
repositoryId: await query(repositoryId()),
|
|
2175
|
+
owner: owner(),
|
|
2176
|
+
manifestDigest: manifest.digest,
|
|
2177
|
+
}, resourceLimits);
|
|
2178
|
+
if ("kind" in admitted) {
|
|
2179
|
+
if (admitted.kind === "resource_busy")
|
|
2180
|
+
return verdict({ tag: "retry", problem: "resource_busy" });
|
|
2181
|
+
return verdict({ tag: "retry", problem: "resource_busy" });
|
|
2182
|
+
}
|
|
2183
|
+
const reserved = admitted.reserveObject(row.bytes);
|
|
2184
|
+
if (typeof reserved !== "function")
|
|
2185
|
+
return verdict({ tag: "retry", problem: "resource_busy" });
|
|
2186
|
+
fragmentReservation = reserved;
|
|
2187
|
+
const receipt = await receiveReplicaFragment({
|
|
2188
|
+
scope,
|
|
2189
|
+
owner: owner(),
|
|
2190
|
+
manifest,
|
|
2191
|
+
index: row.position,
|
|
2192
|
+
maxBytes: row.bytes,
|
|
2193
|
+
signal,
|
|
2194
|
+
read: (readSignal) => call(readSignal, () => transport.getFragment(scope, row.digest, {
|
|
2195
|
+
maxBytes: row.bytes,
|
|
2196
|
+
signal: readSignal,
|
|
2197
|
+
})),
|
|
2198
|
+
decrypt: (encrypted) => transport.decrypt(scope, encrypted),
|
|
2199
|
+
});
|
|
2200
|
+
if (receipt.tag === "refused")
|
|
2201
|
+
return quarantineFragment(row, receipt.reason);
|
|
2202
|
+
fragment = receipt.fragment;
|
|
2203
|
+
keepFragmentReservation = true;
|
|
2204
|
+
}
|
|
2205
|
+
catch (error) {
|
|
2206
|
+
if (signal.aborted)
|
|
2207
|
+
return verdict(retryFromError(error, "download_failed"));
|
|
2208
|
+
if (error instanceof ReplicaObjectRefused)
|
|
2209
|
+
return quarantineFragment(row, "fragment_mismatch");
|
|
2210
|
+
// Only definitive absence proves a missing fragment. Transient read,
|
|
2211
|
+
// deadline and cancellation failures remain retries.
|
|
2212
|
+
try {
|
|
2213
|
+
if (!(await call(signal, () => transport.hasFragment(scope, row.digest, signal))))
|
|
2214
|
+
return quarantineFragment(row, "fragment_missing");
|
|
2215
|
+
}
|
|
2216
|
+
catch {
|
|
2217
|
+
// Preserve the original transient failure.
|
|
2218
|
+
}
|
|
2219
|
+
return verdict(retryFromError(error, "download_failed"));
|
|
2220
|
+
}
|
|
2221
|
+
finally {
|
|
2222
|
+
if (!keepFragmentReservation)
|
|
2223
|
+
fragmentReservation?.();
|
|
2224
|
+
}
|
|
2225
|
+
return {
|
|
2226
|
+
outcome: {
|
|
2227
|
+
tag: "progress",
|
|
2228
|
+
receipt: { phase: "fetch", key: item.id },
|
|
2229
|
+
},
|
|
2230
|
+
plan: function* () {
|
|
2231
|
+
// In parts: a Node fragment is 4 MiB, a phone SQL value 1 MiB.
|
|
2232
|
+
const parts = replicaTextParts(canonicalJson(fragment), REPLICA_TEXT_PART_BYTES);
|
|
2233
|
+
yield* sql.run("DELETE FROM pdpp_transport_fragment_parts WHERE slot=? AND position=?", row.slot, row.position);
|
|
2234
|
+
for (const [part, json] of parts.entries())
|
|
2235
|
+
yield* sql.run("INSERT INTO pdpp_transport_fragment_parts VALUES (?,?,?,?)", row.slot, row.position, part, json);
|
|
2236
|
+
yield* sql.run("UPDATE pdpp_transport_fragments_in SET fragment_json='' WHERE slot=? AND position=?", row.slot, row.position);
|
|
2237
|
+
yield* wake(`fragments:${fragment.writer}:${fragment.batch}`, now());
|
|
2238
|
+
},
|
|
2239
|
+
release: () => fragmentReservation?.(),
|
|
2240
|
+
};
|
|
2241
|
+
},
|
|
2242
|
+
commit: commitIn((outcome, work, item) => sql.run(`UPDATE pdpp_transport_fragments_in SET ${WORK_SET},state=? WHERE slot=? AND position=?`, ...workValues(outcome, work), outcome.tag === "quarantine" ? "quarantined" : "ready", item.row.slot, item.row.position)),
|
|
2243
|
+
commitProblem: "download_failed",
|
|
2244
|
+
earliest(e) {
|
|
2245
|
+
return query(earliestIn(e, `${FETCHABLE} fetchable`));
|
|
2246
|
+
},
|
|
2247
|
+
};
|
|
2248
|
+
// ----------------------------------------- revalidate (imported slots)
|
|
2249
|
+
/**
|
|
2250
|
+
* A finalized fact disagrees with an imported slot. The batch records the
|
|
2251
|
+
* fact and takes the copy decision, which is terminal for a disproved
|
|
2252
|
+
* promoted copy (contract A, H).
|
|
2253
|
+
*/
|
|
2254
|
+
async function importedConflict(row, evidence) {
|
|
2255
|
+
const manifest = await query(manifestOf(row.slot));
|
|
2256
|
+
const id = canonicalJson([manifest.writer, manifest.batch]);
|
|
2257
|
+
const record = factOf(row, evidence, manifest);
|
|
2258
|
+
return {
|
|
2259
|
+
outcome: quarantine("immutable_registration_conflict", row.slot),
|
|
2260
|
+
plan: function* () {
|
|
2261
|
+
if (record)
|
|
2262
|
+
yield* record();
|
|
2263
|
+
const decision = yield* receiver.copies.decide(id, {
|
|
2264
|
+
attempts: 0,
|
|
2265
|
+
budget: deps.refetchBudget ?? REPLICA_REFETCH_BUDGET,
|
|
2266
|
+
});
|
|
2267
|
+
if (decision.kind !== "terminal")
|
|
2268
|
+
return { tag: "retry", problem: "commitment_mismatch" };
|
|
2269
|
+
yield* receiver.copies.apply({
|
|
2270
|
+
credential: deps.credential,
|
|
2271
|
+
id,
|
|
2272
|
+
decision,
|
|
2273
|
+
});
|
|
2274
|
+
yield* quarantineWriterFrom(manifest.writer, manifest.sequence);
|
|
2275
|
+
return quarantine("finalized_conflict", id);
|
|
2276
|
+
},
|
|
2277
|
+
};
|
|
2278
|
+
}
|
|
2279
|
+
const revalidate = {
|
|
2280
|
+
phase: "revalidate",
|
|
2281
|
+
due(e, limit) {
|
|
2282
|
+
// Round-robin by last check, so a fixed prefix cannot starve later slots.
|
|
2283
|
+
return then(query(sql.all(`SELECT slot,scope,version,writer,data_hash,metadata_hash,attempts,next_attempt,wait_since,checked_block FROM pdpp_transport_manifests_in WHERE state='imported' AND ${due(e)} ORDER BY evidence_checked,rowid LIMIT ?`, limit.total)), (rows) => rows.map(slotItem("revalidate")));
|
|
2284
|
+
},
|
|
2285
|
+
async run(item, signal) {
|
|
2286
|
+
const row = item.row;
|
|
2287
|
+
const slot = {
|
|
2288
|
+
owner: owner(),
|
|
2289
|
+
scope: row.scope,
|
|
2290
|
+
version: row.version,
|
|
2291
|
+
dataHash: row.data_hash ?? undefined,
|
|
2292
|
+
metadataHash: row.metadata_hash ?? undefined,
|
|
2293
|
+
};
|
|
2294
|
+
try {
|
|
2295
|
+
const evidence = await call(signal, () => transport.evidence(row.scope, row.version));
|
|
2296
|
+
const status = replicaRegistrationState(evidence, slot);
|
|
2297
|
+
if (status === "conflict")
|
|
2298
|
+
return await importedConflict(row, evidence);
|
|
2299
|
+
// Only a confirmed check is Progress. Unavailable or inactive evidence
|
|
2300
|
+
// is not a successful check (an imported slot stays visible meanwhile).
|
|
2301
|
+
if (status === "confirmed")
|
|
2302
|
+
return verdict({
|
|
2303
|
+
tag: "progress",
|
|
2304
|
+
receipt: { phase: "revalidate", key: row.slot },
|
|
2305
|
+
});
|
|
2306
|
+
return verdict(replicaRegistrationInactive(evidence, slot)
|
|
2307
|
+
? finalityWait(evidence, row.slot)
|
|
2308
|
+
: { tag: "retry", problem: "evidence_unavailable" });
|
|
2309
|
+
}
|
|
2310
|
+
catch (error) {
|
|
2311
|
+
return verdict(retryFromError(error, "evidence_unavailable"));
|
|
2312
|
+
}
|
|
2313
|
+
},
|
|
2314
|
+
commit: commitIn(function* (outcome, work, item) {
|
|
2315
|
+
const checked = (yield* sql.get("SELECT COALESCE(MAX(evidence_checked),0) AS value FROM pdpp_transport_manifests_in WHERE state='imported'")).value;
|
|
2316
|
+
const interval = deps.revalidateIntervalMs ?? 0;
|
|
2317
|
+
// A revalidated slot is never terminal: it is checked again on the sweep interval.
|
|
2318
|
+
const next = outcome.tag === "progress"
|
|
2319
|
+
? { ...work, nextEligible: now() + interval }
|
|
2320
|
+
: work;
|
|
2321
|
+
if (outcome.tag === "quarantine")
|
|
2322
|
+
yield* sql.run(`UPDATE pdpp_transport_manifests_in SET ${QUARANTINE_SET("immutable_registration_conflict")} WHERE slot=? AND state='imported'`, item.id);
|
|
2323
|
+
else
|
|
2324
|
+
yield* sql.run(`UPDATE pdpp_transport_manifests_in SET ${WORK_SET},evidence_checked=? WHERE slot=?`, ...workValues(outcome, next), checked + 1, item.id);
|
|
2325
|
+
}),
|
|
2326
|
+
commitProblem: "evidence_unavailable",
|
|
2327
|
+
earliest(e) {
|
|
2328
|
+
// A sweep on the host's cadence: only Retry and Wait are deadlines.
|
|
2329
|
+
return query(earliestIn(e, "pdpp_transport_manifests_in WHERE state='imported' AND outcome IN ('retry','wait')"));
|
|
2330
|
+
},
|
|
2331
|
+
};
|
|
2332
|
+
/** Ordered verified fragments of one complete slot, read one row at a time. */
|
|
2333
|
+
async function retainedBytes(slot, admission, signal, reservationGroup) {
|
|
2334
|
+
const positions = await query(sql.all("SELECT position,bytes FROM pdpp_transport_fragments_in WHERE slot=? ORDER BY position", slot));
|
|
2335
|
+
return () => assembleReplicaStream(async function* () {
|
|
2336
|
+
for (const row of positions) {
|
|
2337
|
+
const reservation = admission?.reserveObject(row.bytes, reservationGroup);
|
|
2338
|
+
try {
|
|
2339
|
+
if (admission && typeof reservation !== "function")
|
|
2340
|
+
throw new ReplicaResourceBusyError();
|
|
2341
|
+
yield JSON.parse(await query(fragmentJson(slot, row.position)));
|
|
2342
|
+
}
|
|
2343
|
+
finally {
|
|
2344
|
+
if (typeof reservation === "function")
|
|
2345
|
+
reservation();
|
|
2346
|
+
}
|
|
2347
|
+
}
|
|
2348
|
+
}, {
|
|
2349
|
+
...(admission && { maxBytes: admission.payloadBytes }),
|
|
2350
|
+
...(signal && { signal }),
|
|
2351
|
+
});
|
|
2352
|
+
}
|
|
2353
|
+
/**
|
|
2354
|
+
* Fail closed at writer scope (contract H): every retained slot of the
|
|
2355
|
+
* writer from `sequence` on leaves import and revalidation.
|
|
2356
|
+
*/
|
|
2357
|
+
function quarantineWriterFrom(slotWriter, sequence) {
|
|
2358
|
+
return sql.run(`UPDATE pdpp_transport_manifests_in SET ${QUARANTINE_SET("finalized_conflict")} WHERE writer=? AND sequence>=?`, slotWriter, sequence);
|
|
2359
|
+
}
|
|
2360
|
+
/** Durable acknowledgement of an imported batch, and its successor's wake. */
|
|
2361
|
+
function* acknowledge(group, manifest) {
|
|
2362
|
+
if (!(yield* sql.get("SELECT 1 FROM pdpp_transport_imported WHERE writer=? AND batch=?", group.writer, group.batch)))
|
|
2363
|
+
yield* sql.run("INSERT INTO pdpp_transport_imported VALUES (?,?,?,?)", group.writer, group.batch, manifest.sequence, manifest.digest);
|
|
2364
|
+
yield* sql.run("UPDATE pdpp_transport_manifests_in SET state='imported',error=NULL WHERE slot=?", group.slot);
|
|
2365
|
+
// The receiver retains its own copy: the fragment bodies are scratch now.
|
|
2366
|
+
yield* dropFragments(group.slot);
|
|
2367
|
+
yield* wake(`predecessor:${group.writer}:${manifest.sequence}`, now());
|
|
2368
|
+
}
|
|
2369
|
+
/**
|
|
2370
|
+
* The copy decision for one complete download of a batch (contract v3.1
|
|
2371
|
+
* §3 "Coordinator step"). Runs inside the import outcome transaction, over
|
|
2372
|
+
* the durable copies, facts and disposition it commits with.
|
|
2373
|
+
*/
|
|
2374
|
+
function* decideImport(group, manifest, stage) {
|
|
2375
|
+
const { writer: groupWriter, batch } = group;
|
|
2376
|
+
const id = canonicalJson([groupWriter, batch]);
|
|
2377
|
+
const key = `${groupWriter}:${batch}`;
|
|
2378
|
+
const credential = deps.credential;
|
|
2379
|
+
yield* sql.run("INSERT INTO pdpp_transport_import_work (writer,batch,sequence) VALUES (?,?,?) ON CONFLICT(writer,batch) DO NOTHING", groupWriter, batch, group.sequence);
|
|
2380
|
+
const refetch = {
|
|
2381
|
+
attempts: (yield* sql.get("SELECT refetch_attempts FROM pdpp_transport_import_work WHERE writer=? AND batch=?", groupWriter, batch)).refetch_attempts,
|
|
2382
|
+
budget: deps.refetchBudget ?? REPLICA_REFETCH_BUDGET,
|
|
2383
|
+
};
|
|
2384
|
+
if (stage)
|
|
2385
|
+
yield* stage(refetch);
|
|
2386
|
+
const decision = yield* receiver.copies.decide(id, refetch);
|
|
2387
|
+
function* terminal() {
|
|
2388
|
+
yield* quarantineWriterFrom(groupWriter, manifest.sequence);
|
|
2389
|
+
return quarantine("finalized_conflict", id);
|
|
2390
|
+
}
|
|
2391
|
+
const progress = {
|
|
2392
|
+
tag: "progress",
|
|
2393
|
+
receipt: { phase: "import", key },
|
|
2394
|
+
};
|
|
2395
|
+
switch (decision.kind) {
|
|
2396
|
+
case "terminal":
|
|
2397
|
+
yield* receiver.copies.apply({ credential, id, decision });
|
|
2398
|
+
return yield* terminal();
|
|
2399
|
+
case "wait":
|
|
2400
|
+
yield* receiver.copies.apply({ credential, id, decision });
|
|
2401
|
+
return { tag: "wait", dependency: { kind: "fragments", key } };
|
|
2402
|
+
case "owner":
|
|
2403
|
+
yield* receiver.copies.apply({ credential, id, decision });
|
|
2404
|
+
return { tag: "wait", dependency: { kind: "owner", key } };
|
|
2405
|
+
case "refetch": {
|
|
2406
|
+
// One completed non-matching observation spends one unit of budget,
|
|
2407
|
+
// in the transaction that commits it; a discarded attempt spends none.
|
|
2408
|
+
yield* receiver.copies.apply({ credential, id, decision });
|
|
2409
|
+
yield* sql.run("UPDATE pdpp_transport_import_work SET refetch_attempts=refetch_attempts+1 WHERE writer=? AND batch=?", groupWriter, batch);
|
|
2410
|
+
const at = now() + defaultReplicaBackoff(refetch.attempts + 1);
|
|
2411
|
+
yield* dropFragments(group.slot);
|
|
2412
|
+
yield* sql.run(`UPDATE pdpp_transport_manifests_in SET manifest_json=NULL,data_hash=NULL,metadata_hash=NULL,batch=NULL,sequence=NULL,state='ready',${RESET_WORK},next_attempt=? WHERE slot=?`, at, group.slot);
|
|
2413
|
+
return { tag: "wait", dependency: { kind: "fragments", key } };
|
|
2414
|
+
}
|
|
2415
|
+
case "reconcile_promoted":
|
|
2416
|
+
yield* receiver.copies.apply({ credential, id, decision });
|
|
2417
|
+
yield* acknowledge(group, manifest);
|
|
2418
|
+
return progress;
|
|
2419
|
+
case "promote": {
|
|
2420
|
+
const result = yield* receiver.copies.promote({
|
|
2421
|
+
credential,
|
|
2422
|
+
id,
|
|
2423
|
+
copyKey: decision.copyKey,
|
|
2424
|
+
});
|
|
2425
|
+
if (result.kind === "terminal")
|
|
2426
|
+
return yield* terminal();
|
|
2427
|
+
if (result.kind === "wait_predecessor")
|
|
2428
|
+
return {
|
|
2429
|
+
tag: "wait",
|
|
2430
|
+
dependency: {
|
|
2431
|
+
kind: "predecessor",
|
|
2432
|
+
key: `${groupWriter}:${manifest.sequence - 1}`,
|
|
2433
|
+
},
|
|
2434
|
+
};
|
|
2435
|
+
// Operations under a missing or incompatible declaration are now
|
|
2436
|
+
// retained but pending: reconcile reports them (host contract §4.5).
|
|
2437
|
+
// A compatible version applies with the batch and needs no reconcile.
|
|
2438
|
+
const untrusted = unretainedDeclarations((yield* replicaCopyEnvelope(id, decision.copyKey)).operations).find((entry) => entry.status !== "compatible");
|
|
2439
|
+
if (untrusted)
|
|
2440
|
+
yield* wakeReconcile(`declaration:${untrusted.sourceId}:${untrusted.binding.stream}`, now());
|
|
2441
|
+
yield* acknowledge(group, manifest);
|
|
2442
|
+
return progress;
|
|
2443
|
+
}
|
|
2444
|
+
}
|
|
2445
|
+
}
|
|
2446
|
+
/**
|
|
2447
|
+
* The verified finalized fact a slot's evidence carries (contract F2), as
|
|
2448
|
+
* an effect for the caller's outcome transaction. Unavailable evidence
|
|
2449
|
+
* (thrown) and a chain object for another owner or slot are not facts.
|
|
2450
|
+
* The effect returns true when the fact is new, and then wakes the batch.
|
|
2451
|
+
*/
|
|
2452
|
+
function factOf(row, evidence, manifest) {
|
|
2453
|
+
const chain = evidence.chain;
|
|
2454
|
+
if (!chain ||
|
|
2455
|
+
chain.owner.toLowerCase() !== owner().toLowerCase() ||
|
|
2456
|
+
chain.scope !== row.scope ||
|
|
2457
|
+
chain.version !== String(row.version) ||
|
|
2458
|
+
!/^(0|[1-9][0-9]*)$/.test(chain.currentVersion))
|
|
2459
|
+
return undefined;
|
|
2460
|
+
const { writer: factWriter, batch } = manifest;
|
|
2461
|
+
return function* () {
|
|
2462
|
+
const added = yield* receiver.copies.recordFact({
|
|
2463
|
+
credential: deps.credential,
|
|
2464
|
+
id: canonicalJson([factWriter, batch]),
|
|
2465
|
+
scope: row.scope,
|
|
2466
|
+
// A manifest batch has exactly one registered slot.
|
|
2467
|
+
fragmentIndex: 0,
|
|
2468
|
+
fact: {
|
|
2469
|
+
chainId: chain.chainId,
|
|
2470
|
+
registry: chain.registry,
|
|
2471
|
+
owner: chain.owner,
|
|
2472
|
+
version: chain.version,
|
|
2473
|
+
currentVersion: chain.currentVersion,
|
|
2474
|
+
status: chain.status,
|
|
2475
|
+
commitment: chain.commitment,
|
|
2476
|
+
blockNumber: chain.blockNumber,
|
|
2477
|
+
blockHash: chain.blockHash,
|
|
2478
|
+
// Eligibility is the fact's own: Active, with a live head that
|
|
2479
|
+
// agrees with the slot (contract D, I).
|
|
2480
|
+
eligible: replicaRegistrationState(evidence, {
|
|
2481
|
+
owner: owner(),
|
|
2482
|
+
scope: row.scope,
|
|
2483
|
+
version: row.version,
|
|
2484
|
+
}) === "confirmed",
|
|
2485
|
+
},
|
|
2486
|
+
});
|
|
2487
|
+
if (added) {
|
|
2488
|
+
yield* wake(`fragments:${factWriter}:${batch}`, now());
|
|
2489
|
+
yield* wake(`owner:${factWriter}:${batch}`, now());
|
|
2490
|
+
}
|
|
2491
|
+
return added;
|
|
2492
|
+
};
|
|
2493
|
+
}
|
|
2494
|
+
const importRun = {
|
|
2495
|
+
phase: "import",
|
|
2496
|
+
due(e, limit) {
|
|
2497
|
+
// Slot metadata only: no fragment bodies from incomplete or other slots.
|
|
2498
|
+
return then(query(sql.all(`WITH complete AS (SELECT m.slot,m.version,m.writer,m.batch,m.sequence FROM pdpp_transport_manifests_in m WHERE m.state='ready' AND m.manifest_json IS NOT NULL
|
|
2499
|
+
AND NOT EXISTS (SELECT 1 FROM pdpp_transport_fragments_in f WHERE f.slot=m.slot AND (f.fragment_json IS NULL OR f.state<>'ready'))),
|
|
2500
|
+
candidates AS (SELECT c.slot,c.version,c.writer,c.batch,c.sequence,coalesce(w.attempts,0) AS attempts,coalesce(w.next_attempt,0) AS next_attempt,w.wait_since,w.checked_block FROM complete c LEFT JOIN pdpp_transport_import_work w ON w.writer=c.writer AND w.batch=c.batch WHERE w.writer IS NULL OR (${replicaEligibleAtSql(e, { outcome: "w.outcome", dependency: "w.dependency", next: "w.next_attempt", since: "w.wait_since", checked: "w.checked_block" })} <= ${e.now} AND coalesce(w.outcome,'') NOT IN ('quarantine','unsupported')))
|
|
2501
|
+
SELECT * FROM (SELECT *, ROW_NUMBER() OVER (PARTITION BY writer ORDER BY next_attempt,sequence,batch) AS writer_rank FROM candidates) WHERE writer_rank<=? ORDER BY writer_rank, writer<=?, writer, next_attempt, sequence, batch LIMIT ?`, limit.perWriter, limit.after ?? "", limit.total)), (rows) => rows.map((row) => ({
|
|
2502
|
+
id: `${row.writer}:${row.batch}`,
|
|
2503
|
+
phase: "import",
|
|
2504
|
+
writer: row.writer,
|
|
2505
|
+
state: workState(row),
|
|
2506
|
+
row,
|
|
2507
|
+
})));
|
|
2508
|
+
},
|
|
2509
|
+
async run(item, signal) {
|
|
2510
|
+
const { writer: groupWriter, batch, slot } = item.row;
|
|
2511
|
+
const id = item.id;
|
|
2512
|
+
const pendingId = canonicalJson([groupWriter, batch]);
|
|
2513
|
+
const { manifest, hashes, imported, sequenceHolder } = await query((function* () {
|
|
2514
|
+
const manifest = yield* manifestOf(slot);
|
|
2515
|
+
const hashes = (yield* sql.get("SELECT data_hash,metadata_hash FROM pdpp_transport_manifests_in WHERE slot=?", slot));
|
|
2516
|
+
const imported = yield* sql.get("SELECT digest,sequence FROM pdpp_transport_imported WHERE writer=? AND batch=?", groupWriter, batch);
|
|
2517
|
+
const sequenceHolder = yield* sql.get("SELECT batch FROM pdpp_transport_imported WHERE writer=? AND sequence=?", groupWriter, manifest.sequence);
|
|
2518
|
+
return { manifest, hashes, imported, sequenceHolder };
|
|
2519
|
+
})());
|
|
2520
|
+
/** Terminal for this batch; the receiver drops any staged copy of it. */
|
|
2521
|
+
const refuse = (reason, receiverReason = reason) => withPlan(quarantine(reason, id), function* () {
|
|
2522
|
+
yield* receiver.quarantine({
|
|
2523
|
+
credential: deps.credential,
|
|
2524
|
+
id: pendingId,
|
|
2525
|
+
reason: receiverReason,
|
|
2526
|
+
});
|
|
2527
|
+
yield* sql.run(`UPDATE pdpp_transport_manifests_in SET ${QUARANTINE_SET(reason)} WHERE slot=?`, slot);
|
|
2528
|
+
});
|
|
2529
|
+
// A different batch reusing an imported writer sequence (or a changed
|
|
2530
|
+
// digest) is proven equivocation: terminal, never a hot retry.
|
|
2531
|
+
if ((imported &&
|
|
2532
|
+
(imported.digest !== manifest.digest ||
|
|
2533
|
+
imported.sequence !== manifest.sequence)) ||
|
|
2534
|
+
(sequenceHolder && sequenceHolder.batch !== batch))
|
|
2535
|
+
return refuse("batch_equivocation", "batch equivocation");
|
|
2536
|
+
// Every import attempt asks the roster again, so a batch admitted at
|
|
2537
|
+
// download on an answer up to REPLICA_ROSTER_MAX_AGE_MS old is
|
|
2538
|
+
// revalidated before it is staged or promoted.
|
|
2539
|
+
const authorized = await authorizeManifest(manifest, item.row, signal);
|
|
2540
|
+
if (authorized.tag === "wait")
|
|
2541
|
+
return verdict(rosterWait(authorized.server));
|
|
2542
|
+
if (authorized.tag === "refused")
|
|
2543
|
+
return refuse(authorized.reason);
|
|
2544
|
+
const admitted = admit(manifest, resourceBudget, {
|
|
2545
|
+
repositoryId: await query(repositoryId()),
|
|
2546
|
+
owner: owner(),
|
|
2547
|
+
manifestDigest: manifest.digest,
|
|
2548
|
+
}, resourceLimits);
|
|
2549
|
+
if ("kind" in admitted) {
|
|
2550
|
+
if (admitted.kind === "resource_busy")
|
|
2551
|
+
return verdict({ tag: "retry", problem: "resource_busy" });
|
|
2552
|
+
return withPlan({
|
|
2553
|
+
tag: "unsupported",
|
|
2554
|
+
admission: {
|
|
2555
|
+
reason: "resource_unsupported",
|
|
2556
|
+
limit: admitted.limit,
|
|
2557
|
+
limitsVersion: admitted.limitsVersion,
|
|
2558
|
+
},
|
|
2559
|
+
}, () => sql.run(`UPDATE pdpp_transport_manifests_in
|
|
2560
|
+
SET resource_limits_version=? WHERE slot=?`, admitted.limitsVersion, slot));
|
|
2561
|
+
}
|
|
2562
|
+
const admission = admitted;
|
|
2563
|
+
let stageStore;
|
|
2564
|
+
const stagedObjects = new Map();
|
|
2565
|
+
/**
|
|
2566
|
+
* Copy custody of this download, if its copy is not retained yet. It
|
|
2567
|
+
* commits only inside this attempt's outcome transaction, so a crash
|
|
2568
|
+
* or a discarded attempt never leaves custody without its outcome.
|
|
2569
|
+
*/
|
|
2570
|
+
let stage;
|
|
2571
|
+
let keepForPlan = false;
|
|
2572
|
+
let transferEnvelopeReservations = false;
|
|
2573
|
+
const envelopeReservations = [];
|
|
2574
|
+
const envelopeReservationGroup = {};
|
|
2575
|
+
// A batch's one registered slot is its copy's one commitment.
|
|
2576
|
+
const fragments = [
|
|
2577
|
+
{
|
|
2578
|
+
dataHash: hashes.data_hash.toLowerCase(),
|
|
2579
|
+
metadataHash: hashes.metadata_hash.toLowerCase(),
|
|
2580
|
+
},
|
|
2581
|
+
];
|
|
2582
|
+
try {
|
|
2583
|
+
// Keep both fallible staging calls before the first byte reservation.
|
|
2584
|
+
stageStore = await stagingStore();
|
|
2585
|
+
await stageStore.reclaimAdmission(admission);
|
|
2586
|
+
const currentStageStore = stageStore;
|
|
2587
|
+
const target = (blobIndex) => {
|
|
2588
|
+
let staged = stagedObjects.get(blobIndex);
|
|
2589
|
+
if (!staged) {
|
|
2590
|
+
staged = currentStageStore.begin(admission);
|
|
2591
|
+
stagedObjects.set(blobIndex, staged);
|
|
2592
|
+
}
|
|
2593
|
+
return staged;
|
|
2594
|
+
};
|
|
2595
|
+
const copyKey = replicaCopyKey([
|
|
2596
|
+
replicaChainCommitment(hashes.data_hash, hashes.metadata_hash),
|
|
2597
|
+
]);
|
|
2598
|
+
const retained = (await query(receiver.copies.list(pendingId))).some((copy) => copy.copyKey === copyKey);
|
|
2599
|
+
if (!retained) {
|
|
2600
|
+
const payload = await inspectReplicaPayload(await retainedBytes(slot, admission, signal, envelopeReservationGroup), undefined, (_blobId, chunk, blobIndex) => target(blobIndex).write(chunk), {
|
|
2601
|
+
limits: streamedLimits,
|
|
2602
|
+
signal,
|
|
2603
|
+
reserveEnvelopeBytes: (bytes) => {
|
|
2604
|
+
const reservation = admission.reserveEnvelope(bytes, envelopeReservationGroup);
|
|
2605
|
+
if (typeof reservation !== "function")
|
|
2606
|
+
throw new ReplicaResourceBusyError();
|
|
2607
|
+
envelopeReservations.push(reservation);
|
|
2608
|
+
},
|
|
2609
|
+
});
|
|
2610
|
+
// A discarded attempt stops before it prepares any custody.
|
|
2611
|
+
signal.throwIfAborted();
|
|
2612
|
+
if (payload.envelope.batchId !== manifest.batch)
|
|
2613
|
+
throw new ReplicaProblemError("payload_invalid");
|
|
2614
|
+
// Check 7: every operation names the signer's operation writer.
|
|
2615
|
+
const misattributed = replicaOperationWriterRefusal(payload.envelope.operations, authorized.writers);
|
|
2616
|
+
if (misattributed)
|
|
2617
|
+
return refuse(misattributed);
|
|
2618
|
+
// Trusted retained declarations, never claims copied from the
|
|
2619
|
+
// envelope. An operation under a declaration this host does not
|
|
2620
|
+
// retain (another version of a retained stream, a stream it does
|
|
2621
|
+
// not declare yet, or an incompatible shape) is retained without
|
|
2622
|
+
// trust, and the merge decides it. It must not stop the writer's
|
|
2623
|
+
// chain, or one upgrade would hold back every later batch of that
|
|
2624
|
+
// writer (host contract §4.5).
|
|
2625
|
+
const expected = deps.expected();
|
|
2626
|
+
const unretained = unretainedDeclarations(payload.envelope.operations);
|
|
2627
|
+
// Decoded only for shape; trust is decided by the merge.
|
|
2628
|
+
decodeReplicaEnvelope(canonicalJson(payload.envelope), {
|
|
2629
|
+
owner: expected.owner,
|
|
2630
|
+
declarations: [...expected.declarations, ...unretained],
|
|
2631
|
+
});
|
|
2632
|
+
const stagedBlobs = [];
|
|
2633
|
+
for (const [index, blob] of payload.blobs.entries()) {
|
|
2634
|
+
const handle = await target(index).complete();
|
|
2635
|
+
await currentStageStore.verify(handle, blob);
|
|
2636
|
+
stagedBlobs.push({
|
|
2637
|
+
blobId: blob.blobId,
|
|
2638
|
+
handle,
|
|
2639
|
+
expected: blob,
|
|
2640
|
+
sizeBytes: blob.sizeBytes,
|
|
2641
|
+
});
|
|
2642
|
+
}
|
|
2643
|
+
stage = function* (refetch) {
|
|
2644
|
+
const attached = [];
|
|
2645
|
+
for (const blob of stagedBlobs)
|
|
2646
|
+
attached.push({
|
|
2647
|
+
blobId: blob.blobId,
|
|
2648
|
+
key: yield* currentStageStore.attach(blob.handle, blob.expected),
|
|
2649
|
+
sizeBytes: blob.sizeBytes,
|
|
2650
|
+
});
|
|
2651
|
+
yield* receiver.copies.stage({
|
|
2652
|
+
credential: deps.credential,
|
|
2653
|
+
copy: {
|
|
2654
|
+
id: pendingId,
|
|
2655
|
+
writer: groupWriter,
|
|
2656
|
+
batch,
|
|
2657
|
+
sequence: manifest.sequence,
|
|
2658
|
+
previous: manifest.previous,
|
|
2659
|
+
envelopeDigest: manifest.digest,
|
|
2660
|
+
envelope: payload.envelope,
|
|
2661
|
+
fragments,
|
|
2662
|
+
},
|
|
2663
|
+
stagedBlobs: attached,
|
|
2664
|
+
refetch,
|
|
2665
|
+
});
|
|
2666
|
+
};
|
|
2667
|
+
keepForPlan = true;
|
|
2668
|
+
}
|
|
2669
|
+
const plan = stage;
|
|
2670
|
+
// The copy decision runs inside the outcome transaction, over the
|
|
2671
|
+
// durable copies, facts and disposition it commits with.
|
|
2672
|
+
transferEnvelopeReservations = true;
|
|
2673
|
+
return {
|
|
2674
|
+
outcome: {
|
|
2675
|
+
tag: "progress",
|
|
2676
|
+
receipt: { phase: "import", key: id },
|
|
2677
|
+
},
|
|
2678
|
+
plan: () => decideImport(item.row, manifest, plan),
|
|
2679
|
+
release: () => {
|
|
2680
|
+
for (const release of envelopeReservations)
|
|
2681
|
+
release();
|
|
2682
|
+
},
|
|
2683
|
+
};
|
|
2684
|
+
}
|
|
2685
|
+
catch (error) {
|
|
2686
|
+
stage = undefined;
|
|
2687
|
+
keepForPlan = false;
|
|
2688
|
+
if (error instanceof ReplicaResourceBusyError)
|
|
2689
|
+
return verdict({ tag: "retry", problem: "resource_busy" });
|
|
2690
|
+
if (error instanceof ReplicaResourceLimitError) {
|
|
2691
|
+
return withPlan({
|
|
2692
|
+
tag: "unsupported",
|
|
2693
|
+
admission: {
|
|
2694
|
+
reason: "resource_unsupported",
|
|
2695
|
+
limit: error.limit,
|
|
2696
|
+
limitsVersion: REPLICA_LIMITS_VERSION,
|
|
2697
|
+
},
|
|
2698
|
+
}, () => sql.run(`UPDATE pdpp_transport_manifests_in
|
|
2699
|
+
SET resource_limits_version=? WHERE slot=?`, REPLICA_LIMITS_VERSION, slot));
|
|
2700
|
+
}
|
|
2701
|
+
return verdict(retryFromError(error, "import_failed"));
|
|
2702
|
+
}
|
|
2703
|
+
finally {
|
|
2704
|
+
// A completed but uncommitted handle is a leased orphan after a crash.
|
|
2705
|
+
try {
|
|
2706
|
+
if (!keepForPlan)
|
|
2707
|
+
await stageStore?.abort(admission);
|
|
2708
|
+
}
|
|
2709
|
+
finally {
|
|
2710
|
+
if (!transferEnvelopeReservations)
|
|
2711
|
+
for (const release of envelopeReservations)
|
|
2712
|
+
release();
|
|
2713
|
+
}
|
|
2714
|
+
}
|
|
2715
|
+
},
|
|
2716
|
+
commit: commitIn(function* (outcome, work, item) {
|
|
2717
|
+
yield* sql.run("INSERT INTO pdpp_transport_import_work (writer,batch,sequence) VALUES (?,?,?) ON CONFLICT(writer,batch) DO NOTHING", item.row.writer, item.row.batch, item.row.sequence);
|
|
2718
|
+
yield* sql.run(`UPDATE pdpp_transport_import_work SET ${WORK_SET} WHERE writer=? AND batch=?`, ...workValues(outcome, work), item.row.writer, item.row.batch);
|
|
2719
|
+
}),
|
|
2720
|
+
commitProblem: "import_failed",
|
|
2721
|
+
earliest(e) {
|
|
2722
|
+
return query(earliestIn(e, "pdpp_transport_import_work WHERE coalesce(outcome,'') NOT IN ('progress','quarantine','unsupported')"));
|
|
2723
|
+
},
|
|
2724
|
+
};
|
|
2725
|
+
// ----------------------------------------- reconcile (retained dependencies)
|
|
2726
|
+
/**
|
|
2727
|
+
* Durable fingerprint of what retained operations outside an import can wait
|
|
2728
|
+
* on: local connection mappings and trusted declarations. Two small reads,
|
|
2729
|
+
* never retained history. An import reconciles inside its own transaction,
|
|
2730
|
+
* so received operations are not part of it. A validator change that keeps
|
|
2731
|
+
* the declaration digest arrives as onDependencyChanged("declaration:...").
|
|
2732
|
+
*/
|
|
2733
|
+
function* dependencyFingerprint() {
|
|
2734
|
+
const bindings = yield* host.bindings();
|
|
2735
|
+
const declarations = deps
|
|
2736
|
+
.expected()
|
|
2737
|
+
.declarations.map((d) => [d.sourceId, d.binding.digest, d.binding.stream])
|
|
2738
|
+
.sort();
|
|
2739
|
+
return bytesToHex(sha256(encoder.encode(canonicalJson({ bindings, declarations }))));
|
|
2740
|
+
}
|
|
2741
|
+
let fingerprint = "";
|
|
2742
|
+
/**
|
|
2743
|
+
* What this device's own operations may name: the host's trusted set plus
|
|
2744
|
+
* every binding it retained, and a version of a retained stream that a
|
|
2745
|
+
* rebaseline copied from a peer's operation (`resolveDeclaration`
|
|
2746
|
+
* compatible). A missing or incompatible binding is never published.
|
|
2747
|
+
*/
|
|
2748
|
+
function ownBindings(operations, history) {
|
|
2749
|
+
const expected = deps.expected();
|
|
2750
|
+
const retained = [...expected.declarations, ...history];
|
|
2751
|
+
return {
|
|
2752
|
+
owner: expected.owner,
|
|
2753
|
+
declarations: [
|
|
2754
|
+
...retained,
|
|
2755
|
+
...operations.flatMap((op) => "declaration" in op &&
|
|
2756
|
+
resolveDeclaration(retained, op.sourceId, op.declaration).status ===
|
|
2757
|
+
"compatible"
|
|
2758
|
+
? [{ sourceId: op.sourceId, binding: op.declaration }]
|
|
2759
|
+
: []),
|
|
2760
|
+
],
|
|
2761
|
+
};
|
|
2762
|
+
}
|
|
2763
|
+
/**
|
|
2764
|
+
* The declarations operations name that the host does not retain, with how
|
|
2765
|
+
* the merge resolves each (`resolveDeclaration`).
|
|
2766
|
+
*/
|
|
2767
|
+
function unretainedDeclarations(operations) {
|
|
2768
|
+
const retained = deps.expected().declarations;
|
|
2769
|
+
return operations.flatMap((op) => {
|
|
2770
|
+
if (!("declaration" in op))
|
|
2771
|
+
return [];
|
|
2772
|
+
const { status } = resolveDeclaration(retained, op.sourceId, op.declaration);
|
|
2773
|
+
return status === "retained"
|
|
2774
|
+
? []
|
|
2775
|
+
: [{ sourceId: op.sourceId, binding: op.declaration, status }];
|
|
2776
|
+
});
|
|
2777
|
+
}
|
|
2778
|
+
/**
|
|
2779
|
+
* Bring the reconcile phase forward to `at`. A declaration notification can
|
|
2780
|
+
* change validation of any retained operation; other keys matter only when
|
|
2781
|
+
* they are what it waits on. Returns how many phases it woke.
|
|
2782
|
+
*/
|
|
2783
|
+
function* wakeReconcile(key, at) {
|
|
2784
|
+
const row = yield* singleton("reconcile");
|
|
2785
|
+
const outstanding = (yield* sql.get("SELECT dependency FROM pdpp_transport_phase_state WHERE phase='reconcile'"))?.dependency;
|
|
2786
|
+
if (row.next_attempt <= at ||
|
|
2787
|
+
(!key.startsWith("declaration:") && key !== outstanding))
|
|
2788
|
+
return 0;
|
|
2789
|
+
yield* sql.run("UPDATE pdpp_transport_phase_state SET next_attempt=? WHERE phase='reconcile'", at);
|
|
2790
|
+
return 1;
|
|
2791
|
+
}
|
|
2792
|
+
/** The first unmet dependency a reconcile reported, in a stable order. */
|
|
2793
|
+
function pendingDependency(plan) {
|
|
2794
|
+
const found = [];
|
|
2795
|
+
for (const issue of plan.pending)
|
|
2796
|
+
for (const op of issue.operations)
|
|
2797
|
+
if (issue.reason === "missing_local_connection")
|
|
2798
|
+
found.push({ kind: "mapping", key: op.connectionId });
|
|
2799
|
+
else if (issue.reason === "missing_declaration" && "declaration" in op)
|
|
2800
|
+
found.push({
|
|
2801
|
+
kind: "declaration",
|
|
2802
|
+
key: `${op.sourceId}:${op.declaration.stream}`,
|
|
2803
|
+
});
|
|
2804
|
+
// This device retains another shape of that stream, and a rebinding
|
|
2805
|
+
// cannot change a retained shape: only the owner can resolve it.
|
|
2806
|
+
else if (issue.reason === "incompatible_declaration" &&
|
|
2807
|
+
"declaration" in op)
|
|
2808
|
+
found.push({
|
|
2809
|
+
kind: "owner",
|
|
2810
|
+
key: `declaration:${op.sourceId}:${op.declaration.stream}`,
|
|
2811
|
+
});
|
|
2812
|
+
else
|
|
2813
|
+
found.push({
|
|
2814
|
+
kind: "reset",
|
|
2815
|
+
key: `${op.connectionId}:${op.generation}`,
|
|
2816
|
+
});
|
|
2817
|
+
return found.sort((x, y) => dependencyKey(x).localeCompare(dependencyKey(y)))[0];
|
|
2818
|
+
}
|
|
2819
|
+
/**
|
|
2820
|
+
* Whether the reconcile phase runs now, and whether it starts a new pass.
|
|
2821
|
+
* A Retry inside an open pass keeps its backoff and then continues the
|
|
2822
|
+
* pass; a changed fingerprint or a due Wait starts a new one.
|
|
2823
|
+
*/
|
|
2824
|
+
function* reconcileDue(row, e) {
|
|
2825
|
+
const eligible = (singletonEligibleAt(row, e) ?? Infinity) <= e.now;
|
|
2826
|
+
const retrying = row.outcome === "retry";
|
|
2827
|
+
if (row.fingerprint !== fingerprint)
|
|
2828
|
+
return { restart: true, run: true };
|
|
2829
|
+
if (retrying && !eligible)
|
|
2830
|
+
return { restart: false, run: false };
|
|
2831
|
+
const resume = retrying && (yield* openPass()) !== undefined;
|
|
2832
|
+
if (eligible && !resume)
|
|
2833
|
+
return { restart: true, run: true };
|
|
2834
|
+
return {
|
|
2835
|
+
restart: false,
|
|
2836
|
+
run: resume || (yield* receiver.reconcileQueued()),
|
|
2837
|
+
};
|
|
2838
|
+
}
|
|
2839
|
+
/** The dependency a pass reported so far; absent when no pass is open. */
|
|
2840
|
+
function* openPass() {
|
|
2841
|
+
const row = yield* sql.get("SELECT dependency_json FROM pdpp_transport_reconcile_pass WHERE id=1");
|
|
2842
|
+
return (row && {
|
|
2843
|
+
dependency: row.dependency_json && JSON.parse(row.dependency_json),
|
|
2844
|
+
});
|
|
2845
|
+
}
|
|
2846
|
+
/**
|
|
2847
|
+
* Canonical reconcile of retained history, in bounded pages. A full pass
|
|
2848
|
+
* starts only when a dependency changed: the fingerprint moved, a host
|
|
2849
|
+
* notification woke it, or an outstanding Wait reached its bounded
|
|
2850
|
+
* fallback. Each attempt then does one page, and the phase stays due while
|
|
2851
|
+
* the receiver has queued work. With nothing queued and nothing
|
|
2852
|
+
* outstanding it is never due on a timer, and its probe reads no retained
|
|
2853
|
+
* history.
|
|
2854
|
+
*/
|
|
2855
|
+
const reconcile = {
|
|
2856
|
+
phase: "reconcile",
|
|
2857
|
+
due(e) {
|
|
2858
|
+
return query((function* () {
|
|
2859
|
+
const row = yield* singleton("reconcile");
|
|
2860
|
+
const { restart, run } = yield* reconcileDue(row, e);
|
|
2861
|
+
return run
|
|
2862
|
+
? [
|
|
2863
|
+
{
|
|
2864
|
+
id: "reconcile",
|
|
2865
|
+
phase: "reconcile",
|
|
2866
|
+
writer: "",
|
|
2867
|
+
state: workState(row),
|
|
2868
|
+
row,
|
|
2869
|
+
restart,
|
|
2870
|
+
bytes: reconcilePage.bytes,
|
|
2871
|
+
},
|
|
2872
|
+
]
|
|
2873
|
+
: [];
|
|
2874
|
+
})());
|
|
2875
|
+
},
|
|
2876
|
+
async run(item) {
|
|
2877
|
+
const release = resourceBudget.reserve(reconcilePage.bytes * REPLICA_ENVELOPE_LIVE_COPY_FACTOR);
|
|
2878
|
+
if (typeof release !== "function")
|
|
2879
|
+
return { outcome: { tag: "retry", problem: "resource_busy" } };
|
|
2880
|
+
// The canonical commit runs inside this attempt's outcome transaction:
|
|
2881
|
+
// the plan reconciles one page and returns the verdict it implies.
|
|
2882
|
+
const seen = fingerprint;
|
|
2883
|
+
return {
|
|
2884
|
+
outcome: {
|
|
2885
|
+
tag: "progress",
|
|
2886
|
+
receipt: { phase: "reconcile", key: seen },
|
|
2887
|
+
},
|
|
2888
|
+
plan: function* () {
|
|
2889
|
+
const { plan, complete } = yield* receiver.reconcileStep({
|
|
2890
|
+
credential: deps.credential,
|
|
2891
|
+
page: reconcilePage,
|
|
2892
|
+
restart: item.restart,
|
|
2893
|
+
});
|
|
2894
|
+
const pass = item.restart ? { dependency: null } : yield* openPass();
|
|
2895
|
+
if (!pass)
|
|
2896
|
+
return undefined; // Evidence work outside a pass waits on nothing.
|
|
2897
|
+
const dependency = [pass.dependency, pendingDependency(plan)]
|
|
2898
|
+
.filter((found) => !!found)
|
|
2899
|
+
.sort((x, y) => dependencyKey(x).localeCompare(dependencyKey(y)))[0];
|
|
2900
|
+
if (!complete) {
|
|
2901
|
+
yield* sql.run("INSERT INTO pdpp_transport_reconcile_pass VALUES (1, ?) ON CONFLICT(id) DO UPDATE SET dependency_json=excluded.dependency_json", dependency ? JSON.stringify(dependency) : null);
|
|
2902
|
+
return undefined;
|
|
2903
|
+
}
|
|
2904
|
+
yield* sql.run("DELETE FROM pdpp_transport_reconcile_pass");
|
|
2905
|
+
return dependency
|
|
2906
|
+
? { tag: "wait", dependency }
|
|
2907
|
+
: undefined;
|
|
2908
|
+
},
|
|
2909
|
+
release,
|
|
2910
|
+
};
|
|
2911
|
+
},
|
|
2912
|
+
commit: commitIn((outcome, work) => settleSingleton("reconcile", outcome,
|
|
2913
|
+
// With nothing outstanding it waits for a dependency change only;
|
|
2914
|
+
// queued pages keep it due without a timer.
|
|
2915
|
+
outcome.tag === "progress"
|
|
2916
|
+
? { ...work, nextEligible: REPLICA_TERMINAL }
|
|
2917
|
+
: work, fingerprint)),
|
|
2918
|
+
commitProblem: "reconcile_failed",
|
|
2919
|
+
earliest(e) {
|
|
2920
|
+
return query((function* () {
|
|
2921
|
+
const row = yield* singleton("reconcile");
|
|
2922
|
+
if (row.fingerprint !== fingerprint)
|
|
2923
|
+
return e.now;
|
|
2924
|
+
// Queued pages are due now, unless a Retry is backing off.
|
|
2925
|
+
if (row.outcome !== "retry" && (yield* receiver.reconcileQueued()))
|
|
2926
|
+
return e.now;
|
|
2927
|
+
return singletonEligibleAt(row, e);
|
|
2928
|
+
})());
|
|
2929
|
+
},
|
|
2930
|
+
wake: (key, at) => query(wakeReconcile(key, at)),
|
|
2931
|
+
};
|
|
2932
|
+
const coordinator = createAsyncReplicaCoordinator({
|
|
2933
|
+
state: host.coordinatorState({
|
|
2934
|
+
owner: () => coordinatorOwner,
|
|
2935
|
+
signal: () => activeStepSignal,
|
|
2936
|
+
enabled,
|
|
2937
|
+
}),
|
|
2938
|
+
phases: [
|
|
2939
|
+
retain,
|
|
2940
|
+
prepare,
|
|
2941
|
+
publish,
|
|
2942
|
+
discover,
|
|
2943
|
+
download,
|
|
2944
|
+
fetchFragment,
|
|
2945
|
+
revalidate,
|
|
2946
|
+
importRun,
|
|
2947
|
+
reconcile,
|
|
2948
|
+
],
|
|
2949
|
+
quantum: {
|
|
2950
|
+
retain: 1,
|
|
2951
|
+
// One prepare unit is one fragment, as for publish and fetch.
|
|
2952
|
+
prepare: 50,
|
|
2953
|
+
publish: 50,
|
|
2954
|
+
discover: 1,
|
|
2955
|
+
fetch: 50,
|
|
2956
|
+
revalidate: 50,
|
|
2957
|
+
import: 50,
|
|
2958
|
+
reconcile: 1,
|
|
2959
|
+
...deps.quantum,
|
|
2960
|
+
// A host may reduce this quantum, but a deep inbox cannot remove the
|
|
2961
|
+
// per-step memory bound by increasing it (even to Infinity).
|
|
2962
|
+
download: Math.min(50, deps.quantum?.download ?? 50),
|
|
2963
|
+
},
|
|
2964
|
+
perWriter: deps.perWriter,
|
|
2965
|
+
deadlineMs: deps.deadlineMs ?? REPLICA_RECEIVE_TIMEOUT_MS,
|
|
2966
|
+
background: ["revalidate"],
|
|
2967
|
+
clock: now,
|
|
2968
|
+
...(transport.finality && {
|
|
2969
|
+
hint: {
|
|
2970
|
+
head: (signal) => abortable(signal, () => transport.finality.head()),
|
|
2971
|
+
},
|
|
2972
|
+
}),
|
|
2973
|
+
});
|
|
2974
|
+
let running = false;
|
|
2975
|
+
const idle = () => ({
|
|
2976
|
+
receipts: [],
|
|
2977
|
+
waits: [],
|
|
2978
|
+
problems: [],
|
|
2979
|
+
quarantines: [],
|
|
2980
|
+
unsupported: [],
|
|
2981
|
+
nextWake: undefined,
|
|
2982
|
+
});
|
|
2983
|
+
/**
|
|
2984
|
+
* The host's step({now,budget,signal}). The optional clock and budget use
|
|
2985
|
+
* finite host defaults; this is not a whole-I/O/history bound. The
|
|
2986
|
+
* authority context is validated before any phase; a mismatch rejects the
|
|
2987
|
+
* step without encrypt, network or receiver calls.
|
|
2988
|
+
*/
|
|
2989
|
+
async function step(input = {}) {
|
|
2990
|
+
if (isPromiseLike(ready))
|
|
2991
|
+
await ready;
|
|
2992
|
+
const context = {
|
|
2993
|
+
now: input.now ?? now(),
|
|
2994
|
+
// Bytes omitted: the coordinator's 64 MiB default, under which a batch
|
|
2995
|
+
// larger than the whole budget still runs alone.
|
|
2996
|
+
budget: input.budget ?? { units: 256 },
|
|
2997
|
+
signal: input.signal,
|
|
2998
|
+
};
|
|
2999
|
+
validateReplicaStep(context);
|
|
3000
|
+
// A closed door stops replication for good: no identity, capture or
|
|
3001
|
+
// publish work.
|
|
3002
|
+
if (!enabled() || running || host.lock.busy() || !door.admitted())
|
|
3003
|
+
return idle();
|
|
3004
|
+
const lifetime = door.beginStep(input.signal);
|
|
3005
|
+
let writerToken;
|
|
3006
|
+
try {
|
|
3007
|
+
if (lifetime.signal.aborted)
|
|
3008
|
+
return idle();
|
|
3009
|
+
// Writer ids come from this server's signed registration. Until it is
|
|
3010
|
+
// registered under the owner, no replication phase runs. The same
|
|
3011
|
+
// lifetime signal also stops this read if the door closes mid-step.
|
|
3012
|
+
running = true;
|
|
3013
|
+
let own;
|
|
3014
|
+
try {
|
|
3015
|
+
own = await host.identity.enable({
|
|
3016
|
+
owner: deps.expected().owner,
|
|
3017
|
+
server,
|
|
3018
|
+
roster,
|
|
3019
|
+
signal: lifetime.signal,
|
|
3020
|
+
});
|
|
3021
|
+
}
|
|
3022
|
+
finally {
|
|
3023
|
+
running = false;
|
|
3024
|
+
}
|
|
3025
|
+
if (lifetime.signal.aborted || own.tag === "refused")
|
|
3026
|
+
return idle();
|
|
3027
|
+
if (own.tag === "waiting")
|
|
3028
|
+
return {
|
|
3029
|
+
...idle(),
|
|
3030
|
+
waits: [
|
|
3031
|
+
{
|
|
3032
|
+
id: "writer-identity",
|
|
3033
|
+
phase: "retain",
|
|
3034
|
+
dependency: { kind: "roster", key: server },
|
|
3035
|
+
},
|
|
3036
|
+
],
|
|
3037
|
+
nextWake: now() + REPLICA_WAIT_FALLBACK_MS,
|
|
3038
|
+
};
|
|
3039
|
+
writerToken = host.randomId();
|
|
3040
|
+
// Claimed before the lock, so no second step starts while it is taken.
|
|
3041
|
+
running = true;
|
|
3042
|
+
await host.lock.acquire(writerToken);
|
|
3043
|
+
coordinatorOwner = writerToken;
|
|
3044
|
+
activeStepSignal = lifetime.signal;
|
|
3045
|
+
// Under the writer lock: a new identity resets its chain transactionally.
|
|
3046
|
+
const authorized = await authority(own.identity);
|
|
3047
|
+
if (!authorized)
|
|
3048
|
+
return idle();
|
|
3049
|
+
bound = authorized;
|
|
3050
|
+
const stagedObjects = await stagingStore(false);
|
|
3051
|
+
await stagedObjects.cleanup();
|
|
3052
|
+
await stagedObjects.cleanupAttachedTemporaryBytes();
|
|
3053
|
+
// Only this process owns the worker. None of these hidden stages can be published.
|
|
3054
|
+
await atomic((function* () {
|
|
3055
|
+
yield* sql.run("DELETE FROM pdpp_transport_staged_fragments");
|
|
3056
|
+
yield* host.sweepStages();
|
|
3057
|
+
for (const table of ["pdpp_binary_chunks", "pdpp_binary_manifests"])
|
|
3058
|
+
yield* sql.run(`DELETE FROM ${table} WHERE namespace='payload' AND NOT EXISTS (SELECT 1 FROM pdpp_transport_batches b WHERE b.batch=${table}.blob_id)`);
|
|
3059
|
+
yield* sweepPublished();
|
|
3060
|
+
if (host.compactCanonical)
|
|
3061
|
+
yield* host.compactCanonical(Math.min(4 * 1024 * 1024, Math.floor((context.budget.bytes ?? REPLICA_STEP_BYTES) / 4)));
|
|
3062
|
+
})());
|
|
3063
|
+
const current = await query(dependencyFingerprint());
|
|
3064
|
+
if (current !== fingerprint) {
|
|
3065
|
+
fingerprint = current;
|
|
3066
|
+
await query(sql.run("UPDATE pdpp_transport_import_work SET next_attempt=? WHERE outcome='wait' AND (dependency LIKE 'mapping:%' OR dependency LIKE 'declaration:%') AND next_attempt>?", now(), now()));
|
|
3067
|
+
}
|
|
3068
|
+
const report = await coordinator.step({
|
|
3069
|
+
...context,
|
|
3070
|
+
signal: lifetime.signal,
|
|
3071
|
+
});
|
|
3072
|
+
const diagnosticRoom = () => REPLICA_REPORT_LIMIT -
|
|
3073
|
+
report.receipts.length -
|
|
3074
|
+
report.waits.length -
|
|
3075
|
+
report.problems.length -
|
|
3076
|
+
report.quarantines.length -
|
|
3077
|
+
report.unsupported.length;
|
|
3078
|
+
const { waits, recoveries } = await query((function* () {
|
|
3079
|
+
const waits = yield* sql.all("SELECT batch,wait_json FROM pdpp_transport_preparation WHERE wait_json IS NOT NULL ORDER BY batch LIMIT ?", REPLICA_REPORT_LIMIT + 1);
|
|
3080
|
+
const recoveries = yield* sql.all("SELECT batch,state FROM pdpp_transport_registration_recovery WHERE state IN ('own_key_conflict','version_squat') ORDER BY batch LIMIT ?", REPLICA_REPORT_LIMIT + 1);
|
|
3081
|
+
return { waits, recoveries };
|
|
3082
|
+
})());
|
|
3083
|
+
for (const row of waits) {
|
|
3084
|
+
if (diagnosticRoom() <= 0) {
|
|
3085
|
+
report.diagnosticsTruncated = true;
|
|
3086
|
+
break;
|
|
3087
|
+
}
|
|
3088
|
+
const wait = JSON.parse(row.wait_json);
|
|
3089
|
+
if (!report.waits.some((entry) => entry.id === row.batch && entry.phase === "prepare"))
|
|
3090
|
+
report.waits.push({
|
|
3091
|
+
id: row.batch,
|
|
3092
|
+
phase: "prepare",
|
|
3093
|
+
dependency: wait.dependency,
|
|
3094
|
+
});
|
|
3095
|
+
}
|
|
3096
|
+
for (const row of recoveries) {
|
|
3097
|
+
if (diagnosticRoom() <= 0) {
|
|
3098
|
+
report.diagnosticsTruncated = true;
|
|
3099
|
+
break;
|
|
3100
|
+
}
|
|
3101
|
+
if (!report.waits.some((entry) => entry.phase === "publish" &&
|
|
3102
|
+
entry.dependency.kind === "registration" &&
|
|
3103
|
+
entry.dependency.reason === row.state))
|
|
3104
|
+
report.waits.push({
|
|
3105
|
+
id: row.batch,
|
|
3106
|
+
phase: "publish",
|
|
3107
|
+
dependency: {
|
|
3108
|
+
kind: "registration",
|
|
3109
|
+
key: writer(),
|
|
3110
|
+
reason: row.state,
|
|
3111
|
+
},
|
|
3112
|
+
});
|
|
3113
|
+
}
|
|
3114
|
+
return report;
|
|
3115
|
+
}
|
|
3116
|
+
finally {
|
|
3117
|
+
lifetime.close();
|
|
3118
|
+
activeStepSignal = undefined;
|
|
3119
|
+
coordinatorOwner = undefined;
|
|
3120
|
+
bound = undefined;
|
|
3121
|
+
running = false;
|
|
3122
|
+
if (writerToken)
|
|
3123
|
+
await host.lock.release(writerToken);
|
|
3124
|
+
}
|
|
3125
|
+
}
|
|
3126
|
+
return {
|
|
3127
|
+
ready,
|
|
3128
|
+
step,
|
|
3129
|
+
/** Owner acknowledgement resumes an exhausted writer after its wait and recovery window. */
|
|
3130
|
+
retryVersionSquats() {
|
|
3131
|
+
return then(ready, () => {
|
|
3132
|
+
if (!enabled() || !door.admitted())
|
|
3133
|
+
return 0;
|
|
3134
|
+
return atomic((function* () {
|
|
3135
|
+
if (!enabled() || !door.admitted())
|
|
3136
|
+
return 0;
|
|
3137
|
+
return (yield* sql.run(`UPDATE pdpp_transport_registration_recovery SET state='resigning'
|
|
3138
|
+
WHERE state='version_squat' AND batch IN (SELECT m.batch FROM pdpp_transport_manifests_out m
|
|
3139
|
+
WHERE m.next_attempt<=? AND (SELECT count(*) FROM pdpp_transport_version_squats s WHERE s.scope=m.scope AND s.detected_at>=?)<?)`, now(), now() - REPLICA_VERSION_SQUAT_WINDOW_MS, REPLICA_VERSION_SQUAT_ATTEMPTS)).changes;
|
|
3140
|
+
})());
|
|
3141
|
+
});
|
|
3142
|
+
},
|
|
3143
|
+
/** Legacy entry point: one finite default step. Rejects when a whole phase failed. */
|
|
3144
|
+
async cycle() {
|
|
3145
|
+
const report = await step();
|
|
3146
|
+
const failed = report.problems.filter((p) => ["retain", "discover", "reconcile"].includes(p.phase));
|
|
3147
|
+
if (failed.length)
|
|
3148
|
+
throw new AggregateError(failed.map((p) => new ReplicaProblemError(p.problem, {
|
|
3149
|
+
...(p.cause && { cause: p.cause }),
|
|
3150
|
+
})), "Replica transport cycle incomplete");
|
|
3151
|
+
},
|
|
3152
|
+
/**
|
|
3153
|
+
* Explicit capability change: re-admit Unsupported batches that were
|
|
3154
|
+
* measured against a different capability than the current cap. Same
|
|
3155
|
+
* batch, digest and operations; the old admission stays as history. A
|
|
3156
|
+
* plain restart never re-admits. Returns the number re-admitted.
|
|
3157
|
+
*/
|
|
3158
|
+
readmitUnsupported() {
|
|
3159
|
+
return then(ready, () => {
|
|
3160
|
+
if (!enabled() || !door.admitted())
|
|
3161
|
+
return 0;
|
|
3162
|
+
return atomic((function* () {
|
|
3163
|
+
if (!enabled() || !door.admitted())
|
|
3164
|
+
return 0;
|
|
3165
|
+
// One outstanding admission at a time also permits a repeated framing
|
|
3166
|
+
// refusal to release its unused wire slot before another batch claims it.
|
|
3167
|
+
const row = yield* sql.get(`SELECT batch FROM pdpp_transport_batches
|
|
3168
|
+
WHERE prepared=-1 AND coalesce(json_extract(admission_json,'$.capability'),'')<>? ORDER BY sequence LIMIT 1`, `cap:${cap}`);
|
|
3169
|
+
return row && (yield* reserveReadmission(row.batch)) ? 1 : 0;
|
|
3170
|
+
})());
|
|
3171
|
+
});
|
|
3172
|
+
},
|
|
3173
|
+
/**
|
|
3174
|
+
* Owner decision after storage was repaired: fetch again every slot that
|
|
3175
|
+
* was quarantined for a missing or mismatched fragment. The finalized
|
|
3176
|
+
* manifest still decides which bytes are accepted. Returns the number of
|
|
3177
|
+
* slots re-admitted.
|
|
3178
|
+
*/
|
|
3179
|
+
readmitFragments() {
|
|
3180
|
+
return then(ready, () => deferred((function* () {
|
|
3181
|
+
const reasons = "('fragment_missing','fragment_mismatch')";
|
|
3182
|
+
const slots = (yield* sql.all(`SELECT slot FROM pdpp_transport_manifests_in WHERE state='quarantined' AND reason IN ${reasons}`)).map((r) => r.slot);
|
|
3183
|
+
for (const slot of slots) {
|
|
3184
|
+
yield* sql.run("DELETE FROM pdpp_transport_fragment_parts WHERE slot=? AND position IN (SELECT position FROM pdpp_transport_fragments_in WHERE slot=? AND state='quarantined')", slot, slot);
|
|
3185
|
+
yield* sql.run(`UPDATE pdpp_transport_fragments_in SET state='ready',fragment_json=NULL,${RESET_WORK},next_attempt=0 WHERE slot=? AND state='quarantined'`, slot);
|
|
3186
|
+
yield* sql.run(`UPDATE pdpp_transport_manifests_in SET state='ready',${RESET_WORK},next_attempt=0 WHERE slot=?`, slot);
|
|
3187
|
+
}
|
|
3188
|
+
return slots.length;
|
|
3189
|
+
})()));
|
|
3190
|
+
},
|
|
3191
|
+
/** A host-observed dependency change (e.g. `mapping:<connection>`). Idempotent. */
|
|
3192
|
+
onDependencyChanged(key) {
|
|
3193
|
+
return then(ready, () => then(query(wake(key, now())), (woken) => then(coordinator.onDependencyChanged(key, now()), (phases) => woken + phases)));
|
|
3194
|
+
},
|
|
3195
|
+
};
|
|
3196
|
+
}
|
|
3197
|
+
//# sourceMappingURL=engine.js.map
|