pipework 0.252.0 → 0.253.0
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/CHANGELOG.md +10 -0
- package/dist/infra/support/handle.d.ts +8 -1
- package/dist/infra/support/handle.d.ts.map +1 -1
- package/dist/infra/support/handle.js +9 -2
- package/dist/infra/support/handle.js.map +1 -1
- package/dist/infra/support/namespace.d.ts.map +1 -1
- package/package.json +4 -3
- package/src/async/jobs/abortable-delay.ts +18 -0
- package/src/async/jobs/claims.ts +52 -0
- package/src/async/jobs/cron.ts +179 -0
- package/src/async/jobs/errors.ts +56 -0
- package/src/async/jobs/exclusion.ts +117 -0
- package/src/async/jobs/execute.ts +219 -0
- package/src/async/jobs/index.ts +10 -0
- package/src/async/jobs/listener.ts +135 -0
- package/src/async/jobs/namespace.ts +20 -0
- package/src/async/jobs/queue-door.ts +44 -0
- package/src/async/jobs/queue.ts +1012 -0
- package/src/async/jobs/run-script.ts +67 -0
- package/src/async/jobs/scheduler.ts +218 -0
- package/src/async/jobs/schedules-table.ts +37 -0
- package/src/async/jobs/serialize-error.ts +48 -0
- package/src/async/jobs/table.ts +191 -0
- package/src/async/pipeline/define.ts +53 -0
- package/src/async/pipeline/errors.ts +35 -0
- package/src/async/pipeline/execute.ts +53 -0
- package/src/async/pipeline/index.ts +13 -0
- package/src/async/pipeline/namespace.ts +12 -0
- package/src/async/pipeline/resume.ts +67 -0
- package/src/async/pipeline/run-steps.ts +98 -0
- package/src/async/pipeline/types.ts +57 -0
- package/src/async/state-machine/define.ts +64 -0
- package/src/async/state-machine/errors.ts +29 -0
- package/src/async/state-machine/index.ts +3 -0
- package/src/async/state-machine/namespace.ts +12 -0
- package/src/async/state-machine/transition.ts +90 -0
- package/src/async/state-machine/types.ts +37 -0
- package/src/async/steps/errors.ts +31 -0
- package/src/async/steps/graph.ts +1214 -0
- package/src/async/steps/hash.ts +40 -0
- package/src/async/steps/index.ts +7 -0
- package/src/async/steps/namespace.ts +7 -0
- package/src/async/steps/table.ts +97 -0
- package/src/async/steps/types.ts +318 -0
- package/src/audit/chain/index.ts +9 -0
- package/src/audit/chain/install.ts +113 -0
- package/src/audit/chain/retire.ts +52 -0
- package/src/audit/chain/verify.ts +257 -0
- package/src/audit/query/cursor.ts +37 -0
- package/src/audit/query/index.ts +9 -0
- package/src/audit/query/query.ts +223 -0
- package/src/audit/schema/audit-record-fields.ts +68 -0
- package/src/audit/schema/audit-record.ts +23 -0
- package/src/auth/auth/audit.ts +31 -0
- package/src/auth/auth/chain.ts +84 -0
- package/src/auth/auth/cookie.ts +136 -0
- package/src/auth/auth/email-links.ts +311 -0
- package/src/auth/auth/guard.ts +128 -0
- package/src/auth/auth/index.ts +18 -0
- package/src/auth/auth/multi-org.ts +337 -0
- package/src/auth/auth/namespace.ts +34 -0
- package/src/auth/auth/parse-expiry.ts +13 -0
- package/src/auth/auth/passwords.ts +110 -0
- package/src/auth/auth/schema.ts +110 -0
- package/src/auth/auth/sessions.ts +565 -0
- package/src/auth/auth/sign-in.ts +156 -0
- package/src/auth/auth/signing-keys.ts +69 -0
- package/src/auth/auth/types.ts +26 -0
- package/src/auth/auth/uuid.ts +10 -0
- package/src/auth/rbac/cache.ts +55 -0
- package/src/auth/rbac/check.ts +30 -0
- package/src/auth/rbac/create.ts +35 -0
- package/src/auth/rbac/index.ts +5 -0
- package/src/auth/rbac/middleware.ts +20 -0
- package/src/auth/rbac/namespace.ts +15 -0
- package/src/auth/rbac/resolve.ts +31 -0
- package/src/auth/rbac/schema.sql +31 -0
- package/src/auth/rbac/seed.ts +67 -0
- package/src/auth/rbac/tables.ts +32 -0
- package/src/auth/rbac/types.ts +65 -0
- package/src/auth/tenant/extract.ts +33 -0
- package/src/auth/tenant/in-tenant.ts +49 -0
- package/src/auth/tenant/index.ts +7 -0
- package/src/auth/tenant/namespace.ts +23 -0
- package/src/auth/tenant/propagate.ts +20 -0
- package/src/auth/tenant/rls.ts +120 -0
- package/src/auth/tenant/scope.ts +33 -0
- package/src/auth/tenant/scoped-db.ts +581 -0
- package/src/auth/tenant/types.ts +14 -0
- package/src/auth/tenant/validate.ts +27 -0
- package/src/cli/commands/add.ts +158 -0
- package/src/cli/commands/alerts.ts +98 -0
- package/src/cli/commands/check.ts +1418 -0
- package/src/cli/commands/codemod.ts +78 -0
- package/src/cli/commands/db.ts +356 -0
- package/src/cli/commands/dev.ts +101 -0
- package/src/cli/commands/generate.ts +75 -0
- package/src/cli/commands/init.ts +36 -0
- package/src/cli/commands/install.ts +28 -0
- package/src/cli/commands/lint.ts +62 -0
- package/src/cli/commands/migrate.ts +132 -0
- package/src/cli/commands/openapi.ts +63 -0
- package/src/cli/commands/remove.ts +72 -0
- package/src/cli/commands/run.ts +50 -0
- package/src/cli/commands/scope.ts +54 -0
- package/src/cli/commands/serve.ts +68 -0
- package/src/cli/commands/shard.ts +31 -0
- package/src/cli/commands/shared/config-pipework-version.ts +28 -0
- package/src/cli/commands/shared/find-config.ts +22 -0
- package/src/cli/commands/shared/load-config.ts +35 -0
- package/src/cli/commands/shared/pnpm-install.ts +95 -0
- package/src/cli/commands/shared/report-removed.ts +8 -0
- package/src/cli/commands/sync.ts +32 -0
- package/src/cli/commands/test.ts +307 -0
- package/src/cli/commands/upgrade.ts +48 -0
- package/src/cli/dev-bootstrap.ts +24 -0
- package/src/cli/errors.ts +10 -0
- package/src/cli/index.ts +303 -0
- package/src/cli/output.ts +17 -0
- package/src/cli/port.ts +15 -0
- package/src/cli/surface-kinds.ts +20 -0
- package/src/client/index.ts +4 -0
- package/src/client/poll.ts +130 -0
- package/src/client/report-fault.ts +119 -0
- package/src/core/config/app-pool.ts +19 -0
- package/src/core/config/aws-env.ts +29 -0
- package/src/core/config/base-dir.ts +24 -0
- package/src/core/config/discover.ts +64 -0
- package/src/core/config/env-resolve.ts +87 -0
- package/src/core/config/env-types.ts +116 -0
- package/src/core/config/env.ts +55 -0
- package/src/core/config/errors.ts +9 -0
- package/src/core/config/import-config.ts +40 -0
- package/src/core/config/index.ts +8 -0
- package/src/core/config/load.ts +417 -0
- package/src/core/config/namespace.ts +14 -0
- package/src/core/config/resolve.ts +195 -0
- package/src/core/config/schema.ts +488 -0
- package/src/core/config/statement-observer.ts +48 -0
- package/src/core/config/ts-inline-hooks.ts +75 -0
- package/src/core/config/ts-register.ts +4 -0
- package/src/core/config/ts-resolve-hooks.ts +101 -0
- package/src/core/errors/base.ts +49 -0
- package/src/core/errors/http.ts +146 -0
- package/src/core/errors/index.ts +2 -0
- package/src/core/errors/namespace.ts +31 -0
- package/src/core/lifecycle/drain.ts +33 -0
- package/src/core/lifecycle/health.ts +72 -0
- package/src/core/lifecycle/index.ts +4 -0
- package/src/core/lifecycle/namespace.ts +7 -0
- package/src/core/lifecycle/startup.ts +102 -0
- package/src/core/lifecycle/types.ts +36 -0
- package/src/core/logging/context.ts +60 -0
- package/src/core/logging/error-chain.ts +68 -0
- package/src/core/logging/index.ts +5 -0
- package/src/core/logging/logger.ts +65 -0
- package/src/core/logging/namespace.ts +16 -0
- package/src/core/logging/proxy.ts +22 -0
- package/src/core/logging/redact-match.ts +24 -0
- package/src/core/logging/types.ts +47 -0
- package/src/core/pipework.ts +566 -0
- package/src/core/runtime/broker.ts +69 -0
- package/src/core/surface/http.ts +133 -0
- package/src/core/surface/index.ts +11 -0
- package/src/core/surface/job-timeout.ts +40 -0
- package/src/core/surface/namespace.ts +13 -0
- package/src/core/surface/script.ts +33 -0
- package/src/core/surface/start.ts +142 -0
- package/src/core/surface/types.ts +225 -0
- package/src/core/surface/validate.ts +88 -0
- package/src/core/surface/worker.ts +528 -0
- package/src/core/validation/connectivity.ts +49 -0
- package/src/core/validation/domain.ts +99 -0
- package/src/core/validation/index.ts +5 -0
- package/src/core/validation/queue-tables.ts +312 -0
- package/src/core/validation/rls-role.ts +138 -0
- package/src/core/validation/startup.ts +51 -0
- package/src/core/validation/surfaces.ts +65 -0
- package/src/core/validation/types.ts +25 -0
- package/src/data/db/advisory-lock.ts +55 -0
- package/src/data/db/aggregates.ts +172 -0
- package/src/data/db/array-elements.ts +111 -0
- package/src/data/db/bulk-set.ts +53 -0
- package/src/data/db/col/index.ts +14 -0
- package/src/data/db/composed.ts +53 -0
- package/src/data/db/db.ts +276 -0
- package/src/data/db/excluded.ts +16 -0
- package/src/data/db/expressions.ts +473 -0
- package/src/data/db/filter.ts +90 -0
- package/src/data/db/function-source.ts +114 -0
- package/src/data/db/identifiers.ts +16 -0
- package/src/data/db/idx/index.ts +9 -0
- package/src/data/db/in-list.ts +49 -0
- package/src/data/db/index.ts +9 -0
- package/src/data/db/insert-split.ts +137 -0
- package/src/data/db/instrumentation.ts +82 -0
- package/src/data/db/maintenance.ts +28 -0
- package/src/data/db/namespace.ts +107 -0
- package/src/data/db/owner-labels.ts +9 -0
- package/src/data/db/pool.ts +187 -0
- package/src/data/db/query-label.ts +13 -0
- package/src/data/db/query-watch.ts +116 -0
- package/src/data/db/raw-client-warning.ts +37 -0
- package/src/data/db/readonly.ts +23 -0
- package/src/data/db/reservation.ts +151 -0
- package/src/data/db/schema-features.ts +14 -0
- package/src/data/db/serializable.ts +83 -0
- package/src/data/db/sql.ts +1 -0
- package/src/data/db/timeouts.ts +76 -0
- package/src/data/db/types.ts +18 -0
- package/src/data/db/unbound.ts +33 -0
- package/src/data/db/values.ts +29 -0
- package/src/data/domain/brand.ts +15 -0
- package/src/data/domain/build-factory.ts +193 -0
- package/src/data/domain/build-schema.ts +161 -0
- package/src/data/domain/build-table.ts +355 -0
- package/src/data/domain/define.ts +239 -0
- package/src/data/domain/external-registry.ts +34 -0
- package/src/data/domain/external.ts +77 -0
- package/src/data/domain/field.ts +279 -0
- package/src/data/domain/index.ts +13 -0
- package/src/data/domain/naming.ts +6 -0
- package/src/data/domain/projections.ts +57 -0
- package/src/data/domain/trace-safe-projection.ts +31 -0
- package/src/data/domain/traits.ts +108 -0
- package/src/data/domain/types.ts +289 -0
- package/src/data/jsonb/composed.ts +18 -0
- package/src/data/jsonb/index.ts +1 -0
- package/src/data/jsonb/namespace.ts +30 -0
- package/src/data/jsonb/query.ts +86 -0
- package/src/data/migrate/append-only-generate.ts +47 -0
- package/src/data/migrate/applied.ts +47 -0
- package/src/data/migrate/apply.ts +309 -0
- package/src/data/migrate/check.ts +119 -0
- package/src/data/migrate/diff.ts +357 -0
- package/src/data/migrate/door-generate.ts +130 -0
- package/src/data/migrate/framework-rls.ts +58 -0
- package/src/data/migrate/generate.ts +201 -0
- package/src/data/migrate/history.ts +215 -0
- package/src/data/migrate/index.ts +9 -0
- package/src/data/migrate/internal-definitions.ts +175 -0
- package/src/data/migrate/post-process.ts +73 -0
- package/src/data/migrate/pragma.ts +73 -0
- package/src/data/migrate/provision-framework.ts +147 -0
- package/src/data/migrate/rls-generate.ts +61 -0
- package/src/data/migrate/run.ts +124 -0
- package/src/data/migrate/safety.ts +167 -0
- package/src/data/migrate/snapshot.ts +368 -0
- package/src/data/migrate/sql-emit.ts +392 -0
- package/src/data/migrate/statements.ts +312 -0
- package/src/data/query/alias.ts +126 -0
- package/src/data/query/batch.ts +8 -0
- package/src/data/query/cache/core/cache.ts +78 -0
- package/src/data/query/cache/core/index.ts +1 -0
- package/src/data/query/cache/core/types.ts +29 -0
- package/src/data/query/cache/readme.md +230 -0
- package/src/data/query/casing.ts +76 -0
- package/src/data/query/collation.ts +11 -0
- package/src/data/query/column-builder.ts +370 -0
- package/src/data/query/column.ts +150 -0
- package/src/data/query/entity.ts +42 -0
- package/src/data/query/errors.ts +33 -0
- package/src/data/query/index.ts +14 -0
- package/src/data/query/logger.ts +47 -0
- package/src/data/query/operations.ts +45 -0
- package/src/data/query/pg-core/alias.ts +12 -0
- package/src/data/query/pg-core/checks.ts +32 -0
- package/src/data/query/pg-core/columns/all.ts +71 -0
- package/src/data/query/pg-core/columns/bigint.ts +110 -0
- package/src/data/query/pg-core/columns/bigserial.ts +128 -0
- package/src/data/query/pg-core/columns/boolean.ts +43 -0
- package/src/data/query/pg-core/columns/char.ts +85 -0
- package/src/data/query/pg-core/columns/cidr.ts +43 -0
- package/src/data/query/pg-core/columns/common.ts +366 -0
- package/src/data/query/pg-core/columns/custom.ts +232 -0
- package/src/data/query/pg-core/columns/date.common.ts +15 -0
- package/src/data/query/pg-core/columns/date.ts +112 -0
- package/src/data/query/pg-core/columns/double-precision.ts +55 -0
- package/src/data/query/pg-core/columns/enum.ts +201 -0
- package/src/data/query/pg-core/columns/index.ts +35 -0
- package/src/data/query/pg-core/columns/inet.ts +43 -0
- package/src/data/query/pg-core/columns/int.common.ts +57 -0
- package/src/data/query/pg-core/columns/integer.ts +53 -0
- package/src/data/query/pg-core/columns/interval.ts +83 -0
- package/src/data/query/pg-core/columns/json.ts +58 -0
- package/src/data/query/pg-core/columns/jsonb.ts +56 -0
- package/src/data/query/pg-core/columns/line.ts +117 -0
- package/src/data/query/pg-core/columns/macaddr.ts +43 -0
- package/src/data/query/pg-core/columns/macaddr8.ts +43 -0
- package/src/data/query/pg-core/columns/numeric.ts +227 -0
- package/src/data/query/pg-core/columns/point.ts +127 -0
- package/src/data/query/pg-core/columns/postgis_extension/geometry.ts +119 -0
- package/src/data/query/pg-core/columns/postgis_extension/utils.ts +47 -0
- package/src/data/query/pg-core/columns/real.ts +58 -0
- package/src/data/query/pg-core/columns/serial.ts +55 -0
- package/src/data/query/pg-core/columns/smallint.ts +53 -0
- package/src/data/query/pg-core/columns/smallserial.ts +60 -0
- package/src/data/query/pg-core/columns/text.ts +67 -0
- package/src/data/query/pg-core/columns/time.ts +72 -0
- package/src/data/query/pg-core/columns/timestamp.ts +163 -0
- package/src/data/query/pg-core/columns/uuid.ts +51 -0
- package/src/data/query/pg-core/columns/varchar.ts +89 -0
- package/src/data/query/pg-core/columns/vector_extension/bit.ts +68 -0
- package/src/data/query/pg-core/columns/vector_extension/halfvec.ts +80 -0
- package/src/data/query/pg-core/columns/vector_extension/sparsevec.ts +67 -0
- package/src/data/query/pg-core/columns/vector_extension/vector.ts +80 -0
- package/src/data/query/pg-core/db.ts +674 -0
- package/src/data/query/pg-core/dialect.ts +1404 -0
- package/src/data/query/pg-core/expressions.ts +25 -0
- package/src/data/query/pg-core/foreign-keys.ts +130 -0
- package/src/data/query/pg-core/index.ts +21 -0
- package/src/data/query/pg-core/indexes.ts +276 -0
- package/src/data/query/pg-core/policies.ts +56 -0
- package/src/data/query/pg-core/primary-keys.ts +62 -0
- package/src/data/query/pg-core/query-builders/count.ts +86 -0
- package/src/data/query/pg-core/query-builders/delete.ts +293 -0
- package/src/data/query/pg-core/query-builders/index.ts +7 -0
- package/src/data/query/pg-core/query-builders/insert.ts +452 -0
- package/src/data/query/pg-core/query-builders/query-builder.ts +123 -0
- package/src/data/query/pg-core/query-builders/query.ts +157 -0
- package/src/data/query/pg-core/query-builders/raw.ts +49 -0
- package/src/data/query/pg-core/query-builders/refresh-materialized-view.ts +108 -0
- package/src/data/query/pg-core/query-builders/select.ts +1346 -0
- package/src/data/query/pg-core/query-builders/select.types.ts +437 -0
- package/src/data/query/pg-core/query-builders/update.ts +626 -0
- package/src/data/query/pg-core/roles.ts +41 -0
- package/src/data/query/pg-core/schema.ts +72 -0
- package/src/data/query/pg-core/sequence.ts +41 -0
- package/src/data/query/pg-core/session.ts +292 -0
- package/src/data/query/pg-core/subquery.ts +46 -0
- package/src/data/query/pg-core/table.ts +252 -0
- package/src/data/query/pg-core/unique-constraint.ts +73 -0
- package/src/data/query/pg-core/utils/array.ts +95 -0
- package/src/data/query/pg-core/utils/index.ts +1 -0
- package/src/data/query/pg-core/utils.ts +104 -0
- package/src/data/query/pg-core/view-base.ts +14 -0
- package/src/data/query/pg-core/view-common.ts +1 -0
- package/src/data/query/pg-core/view.ts +435 -0
- package/src/data/query/pg-core/with-builder.ts +41 -0
- package/src/data/query/postgres-js/README.md +46 -0
- package/src/data/query/postgres-js/array-parser.ts +45 -0
- package/src/data/query/postgres-js/driver-error-boundary.ts +149 -0
- package/src/data/query/postgres-js/driver.ts +163 -0
- package/src/data/query/postgres-js/index.ts +2 -0
- package/src/data/query/postgres-js/session.ts +234 -0
- package/src/data/query/primary-key.ts +11 -0
- package/src/data/query/query-builders/query-builder.ts +19 -0
- package/src/data/query/query-builders/select.types.ts +175 -0
- package/src/data/query/query-promise.ts +35 -0
- package/src/data/query/relations.ts +725 -0
- package/src/data/query/runnable-query.ts +12 -0
- package/src/data/query/selection-proxy.ts +121 -0
- package/src/data/query/session.ts +8 -0
- package/src/data/query/sql/expressions/conditions.ts +741 -0
- package/src/data/query/sql/expressions/index.ts +2 -0
- package/src/data/query/sql/expressions/select.ts +45 -0
- package/src/data/query/sql/functions/aggregate.ts +129 -0
- package/src/data/query/sql/functions/index.ts +2 -0
- package/src/data/query/sql/functions/vector.ts +178 -0
- package/src/data/query/sql/index.ts +3 -0
- package/src/data/query/sql/sql.ts +794 -0
- package/src/data/query/subquery.ts +53 -0
- package/src/data/query/table.ts +208 -0
- package/src/data/query/table.utils.ts +2 -0
- package/src/data/query/tracing-utils.ts +3 -0
- package/src/data/query/tracing.ts +11 -0
- package/src/data/query/utils.ts +357 -0
- package/src/data/query/version.ts +4 -0
- package/src/data/query/view-common.ts +1 -0
- package/src/data/schema/branded.ts +13 -0
- package/src/data/schema/generated/coerce.ts +5 -0
- package/src/data/schema/generated/combinator.ts +6 -0
- package/src/data/schema/generated/encoding.ts +5 -0
- package/src/data/schema/generated/error.ts +6 -0
- package/src/data/schema/generated/format.ts +5 -0
- package/src/data/schema/generated/type-constructor.ts +10 -0
- package/src/data/schema/generated/type.ts +6 -0
- package/src/data/schema/index.ts +6 -0
- package/src/data/schema/json-schema.ts +33 -0
- package/src/data/schema/namespace.ts +55 -0
- package/src/data/schema/parse.ts +39 -0
- package/src/data/schema/types.ts +39 -0
- package/src/data/temporal/append-only.ts +134 -0
- package/src/data/temporal/close.ts +27 -0
- package/src/data/temporal/columns.ts +10 -0
- package/src/data/temporal/definition-queries.ts +70 -0
- package/src/data/temporal/errors.ts +10 -0
- package/src/data/temporal/get-current.ts +22 -0
- package/src/data/temporal/index.ts +9 -0
- package/src/data/temporal/namespace.ts +36 -0
- package/src/data/temporal/query.ts +25 -0
- package/src/data/temporal/resolve.ts +17 -0
- package/src/data/temporal/revise.ts +67 -0
- package/src/data/temporal/types.ts +22 -0
- package/src/data/valve/artifact.ts +33 -0
- package/src/data/valve/contract-diff.ts +21 -0
- package/src/data/valve/cross.ts +57 -0
- package/src/data/valve/crossing-ledger.ts +50 -0
- package/src/data/valve/decision.ts +44 -0
- package/src/data/valve/durable-crossing.ts +52 -0
- package/src/data/valve/execute-crossing.ts +77 -0
- package/src/data/valve/faucet.ts +56 -0
- package/src/data/valve/gate.ts +114 -0
- package/src/data/valve/graph.ts +77 -0
- package/src/data/valve/index.ts +53 -0
- package/src/data/valve/namespace.ts +57 -0
- package/src/data/valve/registry.ts +50 -0
- package/src/data/valve/schema/valve-crossing-fields.ts +23 -0
- package/src/data/valve/schema/valve-crossing.ts +9 -0
- package/src/data/valve/supply.ts +154 -0
- package/src/data/valve/types.ts +143 -0
- package/src/data/vector/columns.ts +27 -0
- package/src/data/vector/index.ts +4 -0
- package/src/data/vector/namespace.ts +33 -0
- package/src/data/vector/query.ts +85 -0
- package/src/data/vector/validate.ts +16 -0
- package/src/data/write/audit/actor.ts +15 -0
- package/src/data/write/audit/audit-columns.ts +18 -0
- package/src/data/write/audit/audit-select.ts +111 -0
- package/src/data/write/audit/bound-transaction.ts +14 -0
- package/src/data/write/audit/cte-compile.ts +32 -0
- package/src/data/write/audit/decode-returning.ts +47 -0
- package/src/data/write/audit/final-select.ts +43 -0
- package/src/data/write/audit/infra-audit.ts +36 -0
- package/src/data/write/audit/primary-key.ts +65 -0
- package/src/data/write/audit/projection.ts +41 -0
- package/src/data/write/audit/read-touches.ts +86 -0
- package/src/data/write/audit/touch-match.ts +78 -0
- package/src/data/write/audit/verb-delete.ts +62 -0
- package/src/data/write/audit/verb-insert.ts +36 -0
- package/src/data/write/audit/verb-select.ts +47 -0
- package/src/data/write/audit/verb-update.ts +60 -0
- package/src/data/write/audit/verb-upsert.ts +104 -0
- package/src/data/write/conflict.ts +43 -0
- package/src/data/write/delete.ts +38 -0
- package/src/data/write/execute.ts +243 -0
- package/src/data/write/index.ts +11 -0
- package/src/data/write/insert.ts +63 -0
- package/src/data/write/state.ts +43 -0
- package/src/data/write/update.ts +54 -0
- package/src/data/write/upsert.ts +58 -0
- package/src/data/write/values.ts +19 -0
- package/src/index.ts +230 -0
- package/src/infra/access-links/code.ts +70 -0
- package/src/infra/access-links/handle.ts +892 -0
- package/src/infra/access-links/index.ts +9 -0
- package/src/infra/access-links/namespace.ts +30 -0
- package/src/infra/access-links/person-check.ts +177 -0
- package/src/infra/access-links/schema.ts +71 -0
- package/src/infra/alerts/async-sources.ts +231 -0
- package/src/infra/alerts/audit.ts +36 -0
- package/src/infra/alerts/beat.ts +123 -0
- package/src/infra/alerts/client-faults.ts +111 -0
- package/src/infra/alerts/faults.ts +156 -0
- package/src/infra/alerts/handle.ts +778 -0
- package/src/infra/alerts/http.ts +192 -0
- package/src/infra/alerts/index.ts +12 -0
- package/src/infra/alerts/infra-sources.ts +180 -0
- package/src/infra/alerts/namespace.ts +81 -0
- package/src/infra/alerts/redact.ts +21 -0
- package/src/infra/alerts/request-faults.ts +41 -0
- package/src/infra/alerts/schema.ts +124 -0
- package/src/infra/alerts/sinks.ts +125 -0
- package/src/infra/alerts/source-options.ts +26 -0
- package/src/infra/alerts/sources.ts +503 -0
- package/src/infra/alerts/subsystem-sources.ts +137 -0
- package/src/infra/audit/emit.ts +87 -0
- package/src/infra/audit/index.ts +2 -0
- package/src/infra/audit/namespace.ts +22 -0
- package/src/infra/audit/schema.sql +35 -0
- package/src/infra/audit/types.ts +18 -0
- package/src/infra/aws/credentials.ts +147 -0
- package/src/infra/aws/sigv4.ts +140 -0
- package/src/infra/cache/create.ts +58 -0
- package/src/infra/cache/entry.ts +81 -0
- package/src/infra/cache/index.ts +4 -0
- package/src/infra/cache/namespace.ts +10 -0
- package/src/infra/cache/tenant.ts +93 -0
- package/src/infra/cache/types.ts +24 -0
- package/src/infra/crawl/audit.ts +35 -0
- package/src/infra/crawl/errors.ts +25 -0
- package/src/infra/crawl/handle.ts +693 -0
- package/src/infra/crawl/index.ts +9 -0
- package/src/infra/crawl/links.ts +160 -0
- package/src/infra/crawl/namespace.ts +36 -0
- package/src/infra/crawl/robots.ts +100 -0
- package/src/infra/crawl/schema.ts +89 -0
- package/src/infra/credit-line/handle.ts +883 -0
- package/src/infra/credit-line/index.ts +6 -0
- package/src/infra/credit-line/namespace.ts +7 -0
- package/src/infra/credit-line/schema.ts +96 -0
- package/src/infra/credits/handle.ts +706 -0
- package/src/infra/credits/index.ts +5 -0
- package/src/infra/credits/namespace.ts +7 -0
- package/src/infra/credits/schema.ts +83 -0
- package/src/infra/email/audit.ts +36 -0
- package/src/infra/email/inbound.ts +622 -0
- package/src/infra/email/index.ts +11 -0
- package/src/infra/email/mail.ts +517 -0
- package/src/infra/email/mime.ts +447 -0
- package/src/infra/email/namespace.ts +22 -0
- package/src/infra/email/providers.ts +111 -0
- package/src/infra/email/schema.ts +96 -0
- package/src/infra/email/sns.ts +200 -0
- package/src/infra/email/unsubscribe.ts +44 -0
- package/src/infra/fetch/address.ts +88 -0
- package/src/infra/fetch/connect-retry.ts +87 -0
- package/src/infra/fetch/handle.ts +329 -0
- package/src/infra/fetch/index.ts +7 -0
- package/src/infra/fetch/namespace.ts +70 -0
- package/src/infra/idempotency/index.ts +3 -0
- package/src/infra/idempotency/middleware.ts +87 -0
- package/src/infra/idempotency/store.ts +92 -0
- package/src/infra/idempotency/types.ts +30 -0
- package/src/infra/join-requests/handle.ts +312 -0
- package/src/infra/join-requests/index.ts +2 -0
- package/src/infra/join-requests/namespace.ts +7 -0
- package/src/infra/join-requests/schema.ts +33 -0
- package/src/infra/outbox/core.ts +165 -0
- package/src/infra/quota/errors.ts +32 -0
- package/src/infra/quota/handle.ts +215 -0
- package/src/infra/quota/index.ts +5 -0
- package/src/infra/quota/namespace.ts +7 -0
- package/src/infra/quota/schema.ts +15 -0
- package/src/infra/storage/backend.ts +34 -0
- package/src/infra/storage/handle.ts +513 -0
- package/src/infra/storage/index.ts +9 -0
- package/src/infra/storage/local.ts +104 -0
- package/src/infra/storage/namespace.ts +13 -0
- package/src/infra/storage/s3.ts +257 -0
- package/src/infra/storage/schema.ts +24 -0
- package/src/infra/support/handle.ts +869 -0
- package/src/infra/support/http.ts +229 -0
- package/src/infra/support/index.ts +7 -0
- package/src/infra/support/namespace.ts +12 -0
- package/src/infra/support/schema.ts +96 -0
- package/src/infra/webhook/inbound.ts +45 -0
- package/src/infra/webhook/index.ts +5 -0
- package/src/infra/webhook/namespace.ts +24 -0
- package/src/infra/webhook/outbound.ts +315 -0
- package/src/infra/webhook/sign.ts +32 -0
- package/src/infra/webhook/verify.ts +82 -0
- package/src/invariants/assert.ts +26 -0
- package/src/invariants/index.ts +1 -0
- package/src/lint/config.ts +56 -0
- package/src/lint/doctrine.ts +163 -0
- package/src/lint/include.ts +97 -0
- package/src/lint/index.ts +3 -0
- package/src/lint/preset.ts +10 -0
- package/src/lint/resolver.ts +218 -0
- package/src/lint/rules/no-reexports.ts +46 -0
- package/src/request/behavior/audited.ts +85 -0
- package/src/request/behavior/cached.ts +62 -0
- package/src/request/behavior/compose.ts +28 -0
- package/src/request/behavior/index.ts +5 -0
- package/src/request/behavior/namespace.ts +16 -0
- package/src/request/behavior/types.ts +26 -0
- package/src/request/behavior/versioned.ts +54 -0
- package/src/request/context/create.ts +103 -0
- package/src/request/context/guard.ts +49 -0
- package/src/request/context/index.ts +4 -0
- package/src/request/context/run-in-transaction.ts +200 -0
- package/src/request/context/store.ts +28 -0
- package/src/request/context/types.ts +50 -0
- package/src/request/di/builder.ts +535 -0
- package/src/request/di/index.ts +8 -0
- package/src/request/di/resolve.ts +271 -0
- package/src/request/di/types.ts +262 -0
- package/src/request/fitting/namespace.ts +7 -0
- package/src/request/fixture/batch.ts +71 -0
- package/src/request/fixture/builder.ts +26 -0
- package/src/request/fixture/errors.ts +10 -0
- package/src/request/fixture/index.ts +8 -0
- package/src/request/fixture/namespace.ts +23 -0
- package/src/request/fixture/paginate.ts +44 -0
- package/src/request/fixture/to-handlers.ts +260 -0
- package/src/request/fixture/types.ts +79 -0
- package/src/request/flow/act-as.ts +78 -0
- package/src/request/flow/namespace.ts +17 -0
- package/src/request/http/error-step.ts +42 -0
- package/src/request/http/file.ts +141 -0
- package/src/request/http/index.ts +4 -0
- package/src/request/http/middleware.ts +141 -0
- package/src/request/http/namespace.ts +5 -0
- package/src/request/http/rate-limit-classes.ts +170 -0
- package/src/request/http/response.ts +82 -0
- package/src/request/http/server.ts +916 -0
- package/src/request/http/sniff.ts +261 -0
- package/src/request/http/types.ts +250 -0
- package/src/request/http/upload.ts +284 -0
- package/src/request/internal/text.ts +6 -0
- package/src/request/openapi/generate.ts +205 -0
- package/src/request/openapi/index.ts +2 -0
- package/src/request/openapi/namespace.ts +7 -0
- package/src/test/auth.ts +13 -0
- package/src/test/auto-setup.ts +45 -0
- package/src/test/context.ts +91 -0
- package/src/test/crawl-fixture.ts +157 -0
- package/src/test/discover-fork.ts +17 -0
- package/src/test/fingerprint.ts +135 -0
- package/src/test/globals.d.ts +7 -0
- package/src/test/index.ts +19 -0
- package/src/test/namespace.ts +87 -0
- package/src/test/plugin.ts +189 -0
- package/src/test/progress.ts +185 -0
- package/src/test/reap-after-run.ts +28 -0
- package/src/test/reap.ts +286 -0
- package/src/test/reset.ts +253 -0
- package/src/test/savepoints.ts +172 -0
- package/src/test/setup.ts +1422 -0
- package/src/test/system-channel.ts +50 -0
- package/src/test/vitest-plugin.ts +142 -0
- package/src/test/vitest-workspace.ts +507 -0
- package/src/test/vitest.ts +333 -0
- package/src/test/watch.ts +105 -0
- package/src/trace/capture/bounded-serializer.ts +146 -0
- package/src/trace/cardinality/analyze.ts +173 -0
- package/src/trace/cardinality/branch-context.ts +214 -0
- package/src/trace/cardinality/check.ts +33 -0
- package/src/trace/cardinality/classify-edge.ts +64 -0
- package/src/trace/cardinality/structural-max.ts +107 -0
- package/src/trace/check/coverage-check.ts +188 -0
- package/src/trace/check/flow-check.ts +103 -0
- package/src/trace/check/flow-step-site.ts +234 -0
- package/src/trace/check/io-pattern.ts +116 -0
- package/src/trace/codemod/add-cardinality.ts +152 -0
- package/src/trace/codemod/run.ts +80 -0
- package/src/trace/entry/child-trace.ts +21 -0
- package/src/trace/entry/close-trace.ts +87 -0
- package/src/trace/entry/finalize-trace.ts +28 -0
- package/src/trace/entry/open-trace.ts +138 -0
- package/src/trace/entry/parent-trace.ts +20 -0
- package/src/trace/entry/parse-traceparent.ts +39 -0
- package/src/trace/entry/process-start.ts +38 -0
- package/src/trace/entry/trace-context.ts +26 -0
- package/src/trace/entry/tracing-enabled.ts +10 -0
- package/src/trace/external/record.ts +195 -0
- package/src/trace/external/report.ts +66 -0
- package/src/trace/namespace.ts +19 -0
- package/src/trace/partition/day.ts +47 -0
- package/src/trace/partition/index.ts +14 -0
- package/src/trace/partition/partition-name.ts +23 -0
- package/src/trace/partition/plan.ts +82 -0
- package/src/trace/partition/schedule.ts +26 -0
- package/src/trace/partition/startup-hook.ts +39 -0
- package/src/trace/partition/sync.ts +154 -0
- package/src/trace/partition/tables.ts +53 -0
- package/src/trace/read/tree.ts +83 -0
- package/src/trace/retain/copy-on-error.ts +76 -0
- package/src/trace/runtime/expire-stale-traces.ts +54 -0
- package/src/trace/runtime/flow-step.ts +147 -0
- package/src/trace/runtime/flush-steps.ts +31 -0
- package/src/trace/runtime/pending-writes.ts +54 -0
- package/src/trace/runtime/step-buffer.ts +298 -0
- package/src/trace/runtime/step-error.ts +21 -0
- package/src/trace/runtime/step-meta.ts +55 -0
- package/src/trace/runtime/thenable.ts +12 -0
- package/src/trace/runtime/truncate-trace.ts +62 -0
- package/src/trace/schema/trace-fields.ts +52 -0
- package/src/trace/schema/trace-retained.ts +22 -0
- package/src/trace/schema/trace-step-fields.ts +44 -0
- package/src/trace/schema/trace-step-retained.ts +25 -0
- package/src/trace/schema/trace-step.ts +18 -0
- package/src/trace/schema/trace.ts +12 -0
- package/src/trace/transformer/apply.ts +79 -0
- package/src/trace/transformer/build-meta-literal.ts +30 -0
- package/src/trace/transformer/find-flow-step-sites.ts +85 -0
- package/src/trace/transformer/inject-flow-step-import.ts +39 -0
- package/src/trace/transformer/transform-error.ts +14 -0
- package/src/trace/transformer/transform-flow-steps.ts +123 -0
- package/src/trace/transformer/wrap-function-declaration.ts +62 -0
- package/src/trace/transformer/wrap-variable-declaration.ts +67 -0
- package/src/workspace/affected.ts +110 -0
- package/src/workspace/changed-files.ts +163 -0
- package/src/workspace/check-deps.ts +213 -0
- package/src/workspace/checksum.ts +20 -0
- package/src/workspace/dependency-graph.ts +67 -0
- package/src/workspace/detect-pipework-copies.ts +93 -0
- package/src/workspace/discover.ts +206 -0
- package/src/workspace/generate.ts +401 -0
- package/src/workspace/index.ts +7 -0
- package/src/workspace/module-imports-pipework.ts +39 -0
- package/src/workspace/module-package-name.ts +12 -0
- package/src/workspace/scrub-git-env.ts +23 -0
- package/src/workspace/source-paths.ts +36 -0
- package/src/workspace/sync.ts +182 -0
- package/dist/REFERENCE.md +0 -1477
- package/dist/infra/access-links/rate-limit.d.ts +0 -10
- package/dist/infra/access-links/rate-limit.d.ts.map +0 -1
- package/dist/infra/access-links/rate-limit.js +0 -28
- package/dist/infra/access-links/rate-limit.js.map +0 -1
- package/dist/infra/email/sigv4.d.ts +0 -24
- package/dist/infra/email/sigv4.d.ts.map +0 -1
- package/dist/infra/email/sigv4.js +0 -55
- package/dist/infra/email/sigv4.js.map +0 -1
|
@@ -0,0 +1,1422 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto'
|
|
2
|
+
import postgres from 'postgres'
|
|
3
|
+
import { drizzle } from '../data/query/postgres-js/index.js'
|
|
4
|
+
import { sql } from '../data/query/index.js'
|
|
5
|
+
import type { Manifold } from '../core/pipework.js'
|
|
6
|
+
import type { Blueprint, ResolvedDatabase } from '../core/config/index.js'
|
|
7
|
+
import { quoteIdentifier } from '../data/db/identifiers.js'
|
|
8
|
+
import { applyMigrations } from '../data/migrate/apply.js'
|
|
9
|
+
import { queueTableProblems, queueTableProblemMessage } from '../core/validation/queue-tables.js'
|
|
10
|
+
import { probeRlsRole, rlsProbeTarget, rlsRoleMessage } from '../core/validation/rls-role.js'
|
|
11
|
+
import { provisionFrameworkTables } from '../data/migrate/provision-framework.js'
|
|
12
|
+
import { getAdminUrl, reapOrphanTestDatabases, runLockKey, templateBuildKey, templateUseKey } from './reap.js'
|
|
13
|
+
import { testDbPrefix, appRolePrefix, templateDbPrefix } from './namespace.js'
|
|
14
|
+
import { templateFingerprint, templateInputs } from './fingerprint.js'
|
|
15
|
+
import {
|
|
16
|
+
PhaseTracker,
|
|
17
|
+
withStallWatchdog,
|
|
18
|
+
DEFAULT_SETUP_STALL_MS,
|
|
19
|
+
type SetupProgressListener,
|
|
20
|
+
} from './progress.js'
|
|
21
|
+
import { describeError } from '../core/logging/error-chain.js'
|
|
22
|
+
import { captureBaseline, resetDatabase, TEST_RESET_LOG_PREFIX, type Baseline } from './reset.js'
|
|
23
|
+
|
|
24
|
+
export interface TestDatabase {
|
|
25
|
+
name: string
|
|
26
|
+
url: string
|
|
27
|
+
appUrl: string
|
|
28
|
+
appRole: string
|
|
29
|
+
/** The per-file database that gets dropped on teardown. */
|
|
30
|
+
database: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// A template per configured database, built once — migrations, extensions,
|
|
34
|
+
// framework tables, role grants — then every test file's database is a
|
|
35
|
+
// `CREATE DATABASE ... WITH TEMPLATE` clone of it. A clone is a filesystem copy
|
|
36
|
+
// (tens of ms); replaying the migration stack per file is seconds. The
|
|
37
|
+
// template's app role and its table/schema/default-privilege grants are
|
|
38
|
+
// catalog rows that the clone copies for free. Issue #321.
|
|
39
|
+
//
|
|
40
|
+
// The template is shared across PROCESSES, keyed by the hash of everything that
|
|
41
|
+
// determines its contents. A consumer running vitest with `pool: 'forks'` and
|
|
42
|
+
// `isolate: true` gets a fresh process per test file; when the template was
|
|
43
|
+
// keyed by pid and run nonce, every one of those processes replayed the entire
|
|
44
|
+
// migration stack for a byte-identical result. Now the first process to want a
|
|
45
|
+
// given content hash builds it and the rest clone from it, across processes and
|
|
46
|
+
// across runs. Per-file CLONES stay pid+nonce-keyed exactly as before — nothing
|
|
47
|
+
// about isolation between test files changes. Issue #340.
|
|
48
|
+
interface Template {
|
|
49
|
+
templateDb: string
|
|
50
|
+
appRole: string
|
|
51
|
+
fingerprint: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** One database's template identity, computed before any connection is opened. */
|
|
55
|
+
interface TemplatePlan {
|
|
56
|
+
name: string
|
|
57
|
+
dbConfig: ResolvedDatabase
|
|
58
|
+
fingerprint: string
|
|
59
|
+
templateDb: string
|
|
60
|
+
appRole: string
|
|
61
|
+
isDefaultDatabase: boolean
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Marker table written inside a template as the last act of a successful build.
|
|
65
|
+
// A template database that exists without this row is the wreckage of a builder
|
|
66
|
+
// that died mid-migration: it must be rebuilt, never cloned.
|
|
67
|
+
const TEMPLATE_MARKER_TABLE = 'pipework_test_template'
|
|
68
|
+
|
|
69
|
+
const testDatabases = new Map<string, TestDatabase>()
|
|
70
|
+
|
|
71
|
+
// A per-file database this process keeps across files, with the state it is
|
|
72
|
+
// returned to between them (`reset.ts`). Keyed by database name.
|
|
73
|
+
interface RetainedDatabase {
|
|
74
|
+
testDb: TestDatabase
|
|
75
|
+
baseline: Baseline
|
|
76
|
+
/** Which template, server and clone settings it was made from. */
|
|
77
|
+
key: string
|
|
78
|
+
inUse: boolean
|
|
79
|
+
/** Its file has ended; the next setup resets it before handing it out. */
|
|
80
|
+
needsReset: boolean
|
|
81
|
+
}
|
|
82
|
+
const retained = new Map<string, RetainedDatabase>()
|
|
83
|
+
let databasePerFile = false
|
|
84
|
+
let runAdminUrl = ''
|
|
85
|
+
let adminIsSuperuser: boolean | null = null
|
|
86
|
+
|
|
87
|
+
async function readSuperuser(): Promise<boolean> {
|
|
88
|
+
const rows = await adminClient!`SELECT rolsuper FROM pg_roles WHERE rolname = current_user`
|
|
89
|
+
return rows[0]?.['rolsuper'] === true
|
|
90
|
+
}
|
|
91
|
+
const templates = new Map<string, Template>()
|
|
92
|
+
let adminClient: postgres.Sql | null = null
|
|
93
|
+
|
|
94
|
+
// A random per-process fingerprint, embedded in every database and role name
|
|
95
|
+
// this run creates. It keys the advisory lock the run holds for its lifetime,
|
|
96
|
+
// letting another run's reaper tell this run's databases apart from orphans.
|
|
97
|
+
const runNonce = randomBytes(6).toString('hex')
|
|
98
|
+
let lockClient: postgres.Sql | null = null
|
|
99
|
+
let reaped = false
|
|
100
|
+
let workerShutdownArmed = false
|
|
101
|
+
|
|
102
|
+
function templateDbName(baseName: string, fingerprint: string): string {
|
|
103
|
+
return `${templateDbPrefix()}${baseName}_${fingerprint}`
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Log prefix for what the orphan reaper left alone (#391). */
|
|
107
|
+
export const TEST_REAP_LOG_PREFIX = 'pipework:test-reap'
|
|
108
|
+
|
|
109
|
+
// One name per CLONE, not per process. A finished database may still be queued
|
|
110
|
+
// for drop (see `pendingDrops`), so the next clone must not ask for its name.
|
|
111
|
+
let cloneSeq = 0
|
|
112
|
+
|
|
113
|
+
function testDbName(baseName: string): string {
|
|
114
|
+
return `${testDbPrefix()}${baseName}_${cloneSeq++}_${process.pid}_${runNonce}`
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// The app role is named to match what the orphan reaper derives from the
|
|
118
|
+
// *template* database name (`reap.ts` `pairedTemplateRoleName`): the template
|
|
119
|
+
// outlives every per-file clone and every run, so when the reaper finally drops
|
|
120
|
+
// a stale template it drops this role with it. The role must be content-keyed
|
|
121
|
+
// too — its grants are baked into the template's catalog by OID, so a template
|
|
122
|
+
// shared across processes needs a role shared across processes.
|
|
123
|
+
function appRoleName(baseName: string, fingerprint: string): string {
|
|
124
|
+
return `${appRolePrefix()}${baseName}_${fingerprint}`
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface SetupOptions {
|
|
128
|
+
/** Told about every step setup reaches, in order. Issue #380. */
|
|
129
|
+
onProgress?: SetupProgressListener
|
|
130
|
+
/** Overrides `test.setupStallMs`. Injectable for tests. */
|
|
131
|
+
stallMs?: number
|
|
132
|
+
/** How often the stall watchdog looks. Injectable for tests. */
|
|
133
|
+
stallPollMs?: number
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export async function setupTestDatabases(
|
|
137
|
+
instance: Manifold,
|
|
138
|
+
options: SetupOptions = {},
|
|
139
|
+
): Promise<Map<string, TestDatabase>> {
|
|
140
|
+
const tracker = new PhaseTracker(options.onProgress)
|
|
141
|
+
return withStallWatchdog(
|
|
142
|
+
() => runSetup(instance, tracker),
|
|
143
|
+
tracker,
|
|
144
|
+
{
|
|
145
|
+
stallMs: options.stallMs ?? instance.config.raw.test?.setupStallMs ?? DEFAULT_SETUP_STALL_MS,
|
|
146
|
+
...(options.stallPollMs === undefined ? {} : { pollMs: options.stallPollMs }),
|
|
147
|
+
},
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function runSetup(instance: Manifold, tracker: PhaseTracker): Promise<Map<string, TestDatabase>> {
|
|
152
|
+
const databases = [...instance.config.databases()]
|
|
153
|
+
|
|
154
|
+
// D4b at test bootstrap: for an rls-configured database, the role tests
|
|
155
|
+
// actually execute on must not bypass RLS — otherwise every policy is inert
|
|
156
|
+
// and the suite proves nothing about isolation. Which connection that is
|
|
157
|
+
// depends on the isolation strategy: transaction isolation collapses every
|
|
158
|
+
// role onto the resolved `url` connection; database/container isolation
|
|
159
|
+
// resolves roles normally (`appUrl ?? url`). Runs before prepareRun so a
|
|
160
|
+
// refused bootstrap opens no admin connection and leaves no state behind.
|
|
161
|
+
for (const [name, dbConfig] of databases) {
|
|
162
|
+
if (dbConfig.rls === undefined) continue
|
|
163
|
+
tracker.reached('rls-probe', name)
|
|
164
|
+
const target = rlsProbeTarget(dbConfig, instance.config.isolationStrategy(name))
|
|
165
|
+
const probe = await namingDatabase(name, 'rls-probe', () => probeRlsRole(target, null))
|
|
166
|
+
if (probe.superuser || probe.bypassRls) {
|
|
167
|
+
throw new Error(
|
|
168
|
+
`[pipework] Test bootstrap failed:\n\n ${rlsRoleMessage(name, probe)}\n`,
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Template identities are pure functions of config and migration content, so
|
|
174
|
+
// they are computed before any connection opens — the reaper needs the set of
|
|
175
|
+
// templates this run intends to use before it decides which templates are
|
|
176
|
+
// stale enough to drop.
|
|
177
|
+
const today = new Date()
|
|
178
|
+
const defaultName = instance.config.databaseNames()[0]
|
|
179
|
+
const plans: TemplatePlan[] = databases.map(([name, dbConfig]) => {
|
|
180
|
+
const fingerprint = templateFingerprint(
|
|
181
|
+
templateInputs(name, dbConfig, instance.config.raw, name === defaultName, today),
|
|
182
|
+
)
|
|
183
|
+
return {
|
|
184
|
+
name,
|
|
185
|
+
dbConfig,
|
|
186
|
+
fingerprint,
|
|
187
|
+
templateDb: templateDbName(name, fingerprint),
|
|
188
|
+
appRole: appRoleName(name, fingerprint),
|
|
189
|
+
isDefaultDatabase: name === defaultName,
|
|
190
|
+
}
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
dropBatchSize = instance.config.raw.test?.dropBatchSize ?? DEFAULT_DROP_BATCH_SIZE
|
|
194
|
+
databasePerFile = instance.config.raw.test?.databasePerFile ?? false
|
|
195
|
+
const cloneGuards = resolveCloneGuards(instance.config.raw)
|
|
196
|
+
|
|
197
|
+
const firstDb = databases[0]
|
|
198
|
+
if (firstDb !== undefined) {
|
|
199
|
+
tracker.reached('run-prepare')
|
|
200
|
+
await prepareRun(getAdminUrl(firstDb[1].url), new Set(plans.map(plan => plan.templateDb)), tracker)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
for (const plan of plans) {
|
|
204
|
+
const template = await ensureTemplate(instance, plan, tracker)
|
|
205
|
+
const key = JSON.stringify([plan.name, plan.dbConfig.url, template.templateDb, cloneGuards])
|
|
206
|
+
const kept = databasePerFile ? undefined : await takeKept(key)
|
|
207
|
+
if (kept !== undefined) {
|
|
208
|
+
testDatabases.set(plan.name, kept.testDb)
|
|
209
|
+
continue
|
|
210
|
+
}
|
|
211
|
+
tracker.reached('clone-start', plan.name)
|
|
212
|
+
const testDb = await cloneTestDatabase(plan.name, plan.dbConfig, template, cloneGuards,
|
|
213
|
+
n => tracker.reached('clone-start', plan.name, `retry ${n}`))
|
|
214
|
+
tracker.reached('clone-done', plan.name)
|
|
215
|
+
if (!databasePerFile) {
|
|
216
|
+
const baseline = await captureBaseline(withDatabase(runAdminUrl, testDb.database))
|
|
217
|
+
if (baseline.hidden.length === 0) {
|
|
218
|
+
retained.set(testDb.database, { testDb, baseline, key, inUse: true, needsReset: false })
|
|
219
|
+
} else {
|
|
220
|
+
// Rows the admin role cannot read cannot be restored: this file's clone is dropped, as with databasePerFile.
|
|
221
|
+
console.warn(`${TEST_RESET_LOG_PREFIX} db=${testDb.database} not kept reason=rls tables=${baseline.hidden.join(',')}`)
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
testDatabases.set(plan.name, testDb)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
tracker.reached('ready')
|
|
228
|
+
return testDatabases
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Acquire this run's advisory lock and reap orphans before provisioning. The
|
|
232
|
+
// lock is taken before the first CREATE DATABASE so a concurrent run's reaper
|
|
233
|
+
// can never mistake this run's freshly-created, backend-less databases — the
|
|
234
|
+
// per-run clones included — for orphans. It is held on a dedicated
|
|
235
|
+
// connection (idle/lifetime timeouts off) so Postgres releases it only when
|
|
236
|
+
// this process dies, and is never unlocked per file: the template outlives
|
|
237
|
+
// every file's database, so the lock that protects it must outlive them too.
|
|
238
|
+
async function prepareRun(adminUrl: string, liveTemplates: ReadonlySet<string>, tracker: PhaseTracker): Promise<void> {
|
|
239
|
+
runAdminUrl = adminUrl
|
|
240
|
+
if (adminClient === null) {
|
|
241
|
+
adminClient = postgres(adminUrl, { max: 1, onnotice: () => {} })
|
|
242
|
+
}
|
|
243
|
+
if (lockClient === null) {
|
|
244
|
+
lockClient = postgres(adminUrl, { max: 1, idle_timeout: 0, max_lifetime: 0, onnotice: () => {} })
|
|
245
|
+
await lockClient`SELECT pg_advisory_lock(${runLockKey(runNonce).toString()}::bigint)`
|
|
246
|
+
}
|
|
247
|
+
armWorkerShutdown()
|
|
248
|
+
if (!reaped) {
|
|
249
|
+
reaped = true
|
|
250
|
+
try {
|
|
251
|
+
const result = await reapOrphanTestDatabases(adminClient, liveTemplates, {
|
|
252
|
+
onReap: (datname, i, total) => tracker.reached('run-reap', null, `${i}/${total} ${datname}`),
|
|
253
|
+
})
|
|
254
|
+
// #391: databases sharing this project's prefix that pipework did not create
|
|
255
|
+
// are left alone. Naming them once per run is what keeps a deliberate skip
|
|
256
|
+
// from looking like the reaper having nothing to do.
|
|
257
|
+
if (result.skippedDatabases.length > 0) {
|
|
258
|
+
process.stderr.write(
|
|
259
|
+
`${TEST_REAP_LOG_PREFIX} left alone (not created by pipework): ${result.skippedDatabases.join(', ')}\n`,
|
|
260
|
+
)
|
|
261
|
+
}
|
|
262
|
+
} catch {
|
|
263
|
+
// Reaping is best effort — never let stale-orphan cleanup break setup.
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Get the shared template for one database, building it only if no process has
|
|
270
|
+
* built it yet.
|
|
271
|
+
*
|
|
272
|
+
* The protocol, in order, and the order is the point:
|
|
273
|
+
*
|
|
274
|
+
* 1. Take a SHARED advisory lock on the template's content key, on this run's
|
|
275
|
+
* lifetime connection. That pins the template: the reaper drops a template
|
|
276
|
+
* only under an EXCLUSIVE lock, so from here until this process dies, no
|
|
277
|
+
* reaper can pull the template out from under a clone.
|
|
278
|
+
* 2. Ask whether the template is ready. Because the pin is already held, a
|
|
279
|
+
* `yes` cannot go stale.
|
|
280
|
+
* 3. If not ready, TRY the separate BUILD key without blocking. Winning it
|
|
281
|
+
* means nobody else is building: ask once more (a builder may have finished
|
|
282
|
+
* between step 2 and the lock) and then build. Losing it means someone else
|
|
283
|
+
* is building right now — so wait by re-asking whether the template is
|
|
284
|
+
* ready, never by queueing on the exclusive key. Under a batch of forks
|
|
285
|
+
* that is the difference between one lock queue everyone traverses in turn
|
|
286
|
+
* and one builder with N cheap pollers: waiters proceed on the shared
|
|
287
|
+
* use-lock alone, with no replay and no exclusive acquisition. The wait is
|
|
288
|
+
* bounded; overrunning it fails here, by name, instead of surfacing as a
|
|
289
|
+
* hook timeout somewhere else. Issue #362.
|
|
290
|
+
*
|
|
291
|
+
* There is deliberately no fallback: if the shared template cannot be built or
|
|
292
|
+
* validated, setup fails loudly. Quietly reverting to a private per-process
|
|
293
|
+
* build would re-hide exactly the cost this exists to remove. Issue #340.
|
|
294
|
+
*/
|
|
295
|
+
async function ensureTemplate(instance: Manifold, plan: TemplatePlan, tracker: PhaseTracker): Promise<Template> {
|
|
296
|
+
const cached = templates.get(plan.name)
|
|
297
|
+
if (cached !== undefined) return cached
|
|
298
|
+
|
|
299
|
+
const useKey = templateUseKey(plan.fingerprint).toString()
|
|
300
|
+
const buildKey = templateBuildKey(plan.fingerprint).toString()
|
|
301
|
+
|
|
302
|
+
tracker.reached('template-pin', plan.name)
|
|
303
|
+
await lockClient!`SELECT pg_advisory_lock_shared(${useKey}::bigint)`
|
|
304
|
+
|
|
305
|
+
await awaitTemplateBuild({
|
|
306
|
+
buildKey,
|
|
307
|
+
timeoutMs: templateBuildWaitMs(instance),
|
|
308
|
+
isReady: () => templateIsReady(plan),
|
|
309
|
+
tryLock: async () => {
|
|
310
|
+
const rows = await lockClient!<{ locked: boolean }[]>`
|
|
311
|
+
SELECT pg_try_advisory_lock(${buildKey}::bigint) AS locked
|
|
312
|
+
`
|
|
313
|
+
return rows[0]?.locked === true
|
|
314
|
+
},
|
|
315
|
+
unlock: async () => {
|
|
316
|
+
await lockClient!`SELECT pg_advisory_unlock(${buildKey}::bigint)`
|
|
317
|
+
},
|
|
318
|
+
build: () => {
|
|
319
|
+
tracker.reached('template-build', plan.name)
|
|
320
|
+
return buildTemplate(instance, plan, tracker)
|
|
321
|
+
},
|
|
322
|
+
builderPid: () => advisoryLockHolder(buildKey),
|
|
323
|
+
onWait: polls => tracker.reached('template-wait', plan.name, `polls=${polls}`),
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
tracker.reached('template-ready', plan.name)
|
|
327
|
+
|
|
328
|
+
// Checked on every process, not just the one that happened to build. A
|
|
329
|
+
// template is keyed by the migrations that made it; the QUEUES a manifold
|
|
330
|
+
// registers are not part of that key, so two instances can legitimately share
|
|
331
|
+
// one template while disagreeing about which queue tables must exist. Running
|
|
332
|
+
// this only inside the build would make the check depend on who got there
|
|
333
|
+
// first — which is to say, it would silently stop running.
|
|
334
|
+
tracker.reached('queue-check', plan.name)
|
|
335
|
+
await assertQueueTables(instance, plan)
|
|
336
|
+
|
|
337
|
+
const template: Template = {
|
|
338
|
+
templateDb: plan.templateDb,
|
|
339
|
+
appRole: plan.appRole,
|
|
340
|
+
fingerprint: plan.fingerprint,
|
|
341
|
+
}
|
|
342
|
+
templates.set(plan.name, template)
|
|
343
|
+
return template
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// D2 loudness at test bootstrap: a registered queue whose table did not come
|
|
347
|
+
// out of the migrations — or came out of an older one, missing a column the
|
|
348
|
+
// current definition requires — would otherwise fail at first insert or first
|
|
349
|
+
// claim, deep in a test.
|
|
350
|
+
// Checked on the database queue migrations are emitted into — the sole
|
|
351
|
+
// database, or the declared defaultDatabase (same rule as startup validation).
|
|
352
|
+
async function assertQueueTables(instance: Manifold, plan: TemplatePlan): Promise<void> {
|
|
353
|
+
const declaredNames = instance.config.databaseNames()
|
|
354
|
+
const queueDbName = declaredNames.length === 1 ? declaredNames[0] : instance.config.raw.defaultDatabase
|
|
355
|
+
if (plan.name !== queueDbName) return
|
|
356
|
+
|
|
357
|
+
const problems = await namingDatabase(
|
|
358
|
+
plan.name, 'queue-check', () => queueTableProblems(siblingUrl(plan.dbConfig, plan.templateDb), null),
|
|
359
|
+
)
|
|
360
|
+
if (problems.length > 0) {
|
|
361
|
+
throw new Error(
|
|
362
|
+
`[pipework] Test bootstrap failed:\n\n` +
|
|
363
|
+
problems.map(p => ` ${queueTableProblemMessage(p, plan.name)}`).join('\n') + '\n',
|
|
364
|
+
)
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Setup's probes leave the connect timeout to the driver (PGCONNECT_TIMEOUT,
|
|
370
|
+
* else 30 s), so a loaded server can be given longer than a pinned 5 s. When
|
|
371
|
+
* the probe still cannot connect, the error names the database and the step.
|
|
372
|
+
*/
|
|
373
|
+
async function namingDatabase<T>(database: string, step: string, probe: () => Promise<T>): Promise<T> {
|
|
374
|
+
try {
|
|
375
|
+
return await probe()
|
|
376
|
+
} catch (err) {
|
|
377
|
+
if (!isTransientProbeFailure(err)) throw err
|
|
378
|
+
const reason = describeError(err).message
|
|
379
|
+
throw new Error(
|
|
380
|
+
`[pipework] Test bootstrap could not reach database "${database}" (${step}): ${reason}. ` +
|
|
381
|
+
`The connect timeout is PGCONNECT_TIMEOUT seconds (default 30).`,
|
|
382
|
+
{ cause: err },
|
|
383
|
+
)
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** Connection URL for a database on the same server as `dbConfig.url`. */
|
|
388
|
+
function siblingUrl(dbConfig: ResolvedDatabase, database: string): string {
|
|
389
|
+
return withDatabase(dbConfig.url, database)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** The same connection — role, password, server, parameters — pointed at `database`. */
|
|
393
|
+
function withDatabase(connectionUrl: string, database: string): string {
|
|
394
|
+
const url = new URL(connectionUrl)
|
|
395
|
+
url.pathname = `/${database}`
|
|
396
|
+
return url.toString()
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Is this template complete and usable?
|
|
401
|
+
*
|
|
402
|
+
* `false` means absent or unbuilt — no database (3D000), no marker table
|
|
403
|
+
* (42P01), a fingerprint that is not ours, or a cluster missing the app or
|
|
404
|
+
* platform role the build makes — and the caller rebuilds under
|
|
405
|
+
* the exclusive build lock. A connection that merely failed (57P01 from a peer's
|
|
406
|
+
* terminate, ECONNRESET, refused, timed out) is NOT an answer: it is retried,
|
|
407
|
+
* and if it never answers it throws. Answering `false` there would let one
|
|
408
|
+
* fork's transient blip drop and rebuild the template every other fork is
|
|
409
|
+
* cloning from. Issue #340.
|
|
410
|
+
*/
|
|
411
|
+
async function templateIsReady(plan: TemplatePlan): Promise<boolean> {
|
|
412
|
+
return probeTemplateReady(() => readTemplateMarker(plan))
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** One probe: connect, read the marker, disconnect. Throws on any failure. */
|
|
416
|
+
async function readTemplateMarker(plan: TemplatePlan): Promise<boolean> {
|
|
417
|
+
const client = postgres(siblingUrl(plan.dbConfig, plan.templateDb), {
|
|
418
|
+
max: 1,
|
|
419
|
+
onnotice: () => {},
|
|
420
|
+
})
|
|
421
|
+
try {
|
|
422
|
+
const rows = await client<{ fingerprint: string }[]>`
|
|
423
|
+
SELECT fingerprint FROM ${client(TEMPLATE_MARKER_TABLE)} LIMIT 1
|
|
424
|
+
`
|
|
425
|
+
if (rows[0]?.fingerprint !== plan.fingerprint) return false
|
|
426
|
+
// Roles are cluster-global and do not travel with the database, so a
|
|
427
|
+
// marked template whose roles are gone cannot be cloned. Issue #812.
|
|
428
|
+
const wanted = templateRoles(plan)
|
|
429
|
+
const present = await client<{ rolname: string }[]>`
|
|
430
|
+
SELECT rolname FROM pg_roles WHERE rolname IN ${client(wanted)}
|
|
431
|
+
`
|
|
432
|
+
return present.length === wanted.length
|
|
433
|
+
} finally {
|
|
434
|
+
await client.end().catch(() => {})
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** The cluster-global roles a clone of this template depends on. */
|
|
439
|
+
function templateRoles(plan: TemplatePlan): string[] {
|
|
440
|
+
const platformRole = plan.dbConfig.rls?.platformRole
|
|
441
|
+
return platformRole === undefined ? [plan.appRole] : [...new Set([plan.appRole, platformRole])]
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** The template is absent or unbuilt — a real `no`, and grounds for a rebuild. */
|
|
445
|
+
const TEMPLATE_ABSENT = new Set(['3D000', '42P01'])
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* The connection failed rather than answered. 57P01 is `admin_shutdown`: a peer
|
|
449
|
+
* terminated this backend. The rest are socket-level failures of the same kind.
|
|
450
|
+
*/
|
|
451
|
+
const TEMPLATE_TRANSIENT = new Set([
|
|
452
|
+
'57P01', '57P02', '57P03', '08006', '08003', '08001',
|
|
453
|
+
'ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'EPIPE', 'CONNECT_TIMEOUT', 'CONNECTION_CLOSED',
|
|
454
|
+
])
|
|
455
|
+
|
|
456
|
+
function errCode(err: unknown): string | undefined {
|
|
457
|
+
const code = (err as { code?: unknown } | null)?.code
|
|
458
|
+
return typeof code === 'string' ? code : undefined
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function isTransientProbeFailure(err: unknown): boolean {
|
|
462
|
+
if (err === null || typeof err !== 'object') return false
|
|
463
|
+
const code = errCode(err)
|
|
464
|
+
if (code !== undefined && TEMPLATE_TRANSIENT.has(code)) return true
|
|
465
|
+
return isTransientProbeFailure((err as { cause?: unknown }).cause)
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** A probe that never answered. Never a reason to rebuild. */
|
|
469
|
+
export class TemplateProbeFailed extends Error {
|
|
470
|
+
constructor(attempts: number, cause: unknown) {
|
|
471
|
+
super(`[pipework] Could not reach the shared test template after ${attempts} attempts`, { cause })
|
|
472
|
+
this.name = 'TemplateProbeFailed'
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const PROBE_RETRY_DELAYS_MS: readonly number[] = [50, 150, 400, 1000]
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Run `probe` until it answers. An absent/unbuilt template answers `false`; a
|
|
480
|
+
* transient connection failure is retried and then thrown as
|
|
481
|
+
* `TemplateProbeFailed`; anything else propagates. Injectable for tests.
|
|
482
|
+
*/
|
|
483
|
+
export async function probeTemplateReady(
|
|
484
|
+
probe: () => Promise<boolean>,
|
|
485
|
+
sleep: (ms: number) => Promise<void> = ms => new Promise(resolve => setTimeout(resolve, ms)),
|
|
486
|
+
delaysMs: readonly number[] = PROBE_RETRY_DELAYS_MS,
|
|
487
|
+
): Promise<boolean> {
|
|
488
|
+
for (let i = 0; ; i++) {
|
|
489
|
+
try {
|
|
490
|
+
return await probe()
|
|
491
|
+
} catch (err) {
|
|
492
|
+
const code = errCode(err)
|
|
493
|
+
if (code !== undefined && TEMPLATE_ABSENT.has(code)) return false
|
|
494
|
+
if (!isTransientProbeFailure(err)) throw err
|
|
495
|
+
if (i >= delaysMs.length) throw new TemplateProbeFailed(i + 1, err)
|
|
496
|
+
await sleep(delaysMs[i]!)
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** Default bound on waiting for another process's template build. */
|
|
502
|
+
const DEFAULT_TEMPLATE_BUILD_WAIT_MS = 120_000
|
|
503
|
+
|
|
504
|
+
/** Base gap between readiness re-asks while another process builds. */
|
|
505
|
+
const BUILD_POLL_INTERVAL_MS = 500
|
|
506
|
+
|
|
507
|
+
function templateBuildWaitMs(instance: Manifold): number {
|
|
508
|
+
return instance.config.raw.test?.templateBuildWaitMs ?? DEFAULT_TEMPLATE_BUILD_WAIT_MS
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/** Waited out another process's template build. Names the key, the builder, the wait. */
|
|
512
|
+
export class TemplateBuildWaitTimeout extends Error {
|
|
513
|
+
constructor(buildKey: string, builderPid: number | null, waitedMs: number) {
|
|
514
|
+
const who = builderPid === null
|
|
515
|
+
? 'unknown — no backend currently holds the key'
|
|
516
|
+
: String(builderPid)
|
|
517
|
+
super(
|
|
518
|
+
`[pipework] Test bootstrap failed:\n\n` +
|
|
519
|
+
` Waited ${(waitedMs / 1000).toFixed(1)}s for another process to finish building the shared\n` +
|
|
520
|
+
` test template, and it has not finished.\n\n` +
|
|
521
|
+
` build lock key: ${buildKey}\n` +
|
|
522
|
+
` builder pid: ${who}\n\n` +
|
|
523
|
+
` Inspect that backend (pg_stat_activity), or raise the bound with\n` +
|
|
524
|
+
` test.templateBuildWaitMs in pipework.config.ts.\n`,
|
|
525
|
+
)
|
|
526
|
+
this.name = 'TemplateBuildWaitTimeout'
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
export interface TemplateBuildWait {
|
|
531
|
+
buildKey: string
|
|
532
|
+
timeoutMs: number
|
|
533
|
+
/** Is the template built and ours? Never touches the build key. */
|
|
534
|
+
isReady: () => Promise<boolean>
|
|
535
|
+
/** Non-blocking `pg_try_advisory_lock` on the build key. */
|
|
536
|
+
tryLock: () => Promise<boolean>
|
|
537
|
+
unlock: () => Promise<void>
|
|
538
|
+
build: () => Promise<void>
|
|
539
|
+
builderPid: () => Promise<number | null>
|
|
540
|
+
sleep?: (ms: number) => Promise<void>
|
|
541
|
+
pollIntervalMs?: number
|
|
542
|
+
now?: () => number
|
|
543
|
+
/** Called before each wait for another process's build, with the poll count. */
|
|
544
|
+
onWait?: (polls: number) => void
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Make sure the template exists, building it here only if no other process is
|
|
549
|
+
* already building it.
|
|
550
|
+
*
|
|
551
|
+
* Exactly one process per fingerprint builds: the one that wins the build key.
|
|
552
|
+
* Everyone else re-asks `isReady` on an interval — a poll, not a lock queue —
|
|
553
|
+
* so a batch of forks costs one build and N cheap probes rather than N serial
|
|
554
|
+
* acquisitions of the exclusive key. The try-lock in the loop is what recovers
|
|
555
|
+
* a builder that died: its lock goes with its connection, and the next poller
|
|
556
|
+
* wins the key and builds. Injectable for tests. Issue #362.
|
|
557
|
+
*/
|
|
558
|
+
export async function awaitTemplateBuild(wait: TemplateBuildWait): Promise<'ready' | 'built'> {
|
|
559
|
+
const sleep = wait.sleep ?? (ms => new Promise<void>(resolve => setTimeout(resolve, ms)))
|
|
560
|
+
const now = wait.now ?? (() => Date.now())
|
|
561
|
+
const interval = wait.pollIntervalMs ?? BUILD_POLL_INTERVAL_MS
|
|
562
|
+
const start = now()
|
|
563
|
+
let polls = 0
|
|
564
|
+
|
|
565
|
+
for (;;) {
|
|
566
|
+
if (await wait.isReady()) return 'ready'
|
|
567
|
+
|
|
568
|
+
if (await wait.tryLock()) {
|
|
569
|
+
try {
|
|
570
|
+
// A builder may have finished between the probe above and this lock.
|
|
571
|
+
if (await wait.isReady()) return 'ready'
|
|
572
|
+
await wait.build()
|
|
573
|
+
return 'built'
|
|
574
|
+
} finally {
|
|
575
|
+
await wait.unlock()
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const waited = now() - start
|
|
580
|
+
if (waited >= wait.timeoutMs) {
|
|
581
|
+
throw new TemplateBuildWaitTimeout(wait.buildKey, await wait.builderPid(), waited)
|
|
582
|
+
}
|
|
583
|
+
// Jittered so a batch of forks does not re-probe in lockstep. Each wait is
|
|
584
|
+
// reported: a process queued behind a peer's build is making progress, and
|
|
585
|
+
// setup's stall watchdog must see that rather than count it as silence.
|
|
586
|
+
polls++
|
|
587
|
+
wait.onWait?.(polls)
|
|
588
|
+
await sleep(interval + Math.floor(Math.random() * interval))
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* The backend holding an advisory lock, or null. Diagnostic only: a failure to
|
|
594
|
+
* answer must never be the thing that fails a bootstrap — which is exactly why
|
|
595
|
+
* the predicate is covered by a real-Postgres test.
|
|
596
|
+
*/
|
|
597
|
+
export async function advisoryLockHolder(
|
|
598
|
+
key: string,
|
|
599
|
+
client: postgres.Sql = adminClient!,
|
|
600
|
+
): Promise<number | null> {
|
|
601
|
+
try {
|
|
602
|
+
const rows = await client<{ pid: number }[]>`
|
|
603
|
+
SELECT pid FROM pg_locks
|
|
604
|
+
WHERE locktype = 'advisory'
|
|
605
|
+
AND granted
|
|
606
|
+
AND objsubid = 1
|
|
607
|
+
AND ((classid::bigint << 32) | objid::bigint) = ${key}::bigint
|
|
608
|
+
LIMIT 1
|
|
609
|
+
`
|
|
610
|
+
return rows[0]?.pid ?? null
|
|
611
|
+
} catch {
|
|
612
|
+
return null
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// Build one shared template from scratch. Runs under the exclusive build lock,
|
|
617
|
+
// with this process's shared use-lock already held. Issue #321, #340.
|
|
618
|
+
async function buildTemplate(instance: Manifold, plan: TemplatePlan, tracker: PhaseTracker): Promise<void> {
|
|
619
|
+
const { dbConfig } = plan
|
|
620
|
+
const templateDb = plan.templateDb
|
|
621
|
+
const templateIdent = quoteIdentifier(templateDb, 'database')
|
|
622
|
+
const admin = drizzle(adminClient!)
|
|
623
|
+
|
|
624
|
+
// A template database that exists at this point failed its readiness check —
|
|
625
|
+
// the leftovers of a builder that died. Drop and start over.
|
|
626
|
+
tracker.reached('template-build', plan.name, 'drop')
|
|
627
|
+
await closeTemplateClients(admin, templateDb)
|
|
628
|
+
await withTemplateRetry(
|
|
629
|
+
() => admin.execute(sql.raw(`DROP DATABASE IF EXISTS ${templateIdent}`)),
|
|
630
|
+
{ prefix: TEMPLATE_DROP_LOG_PREFIX, onRetry: n => tracker.reached('template-build', plan.name, `drop retry ${n}`) },
|
|
631
|
+
)
|
|
632
|
+
tracker.reached('template-build', plan.name, 'create')
|
|
633
|
+
await admin.execute(sql.raw(`CREATE DATABASE ${templateIdent}`))
|
|
634
|
+
// #325: a test database is dropped or rolled back — durable commits are pure
|
|
635
|
+
// overhead. `synchronous_commit` is a per-database setting and does not copy
|
|
636
|
+
// with `WITH TEMPLATE`, so it is set on the template and on every clone.
|
|
637
|
+
await admin.execute(sql.raw(`ALTER DATABASE ${templateIdent} SET synchronous_commit = off`))
|
|
638
|
+
|
|
639
|
+
const templateUrlStr = siblingUrl(dbConfig, templateDb)
|
|
640
|
+
|
|
641
|
+
const templateClient = postgres(templateUrlStr, { max: 1, onnotice: () => {} })
|
|
642
|
+
const templateDbHandle = drizzle(templateClient)
|
|
643
|
+
|
|
644
|
+
// The template's migrations run as the configured migration owner, so every
|
|
645
|
+
// object a migration creates is owned by it — the one thing that decides
|
|
646
|
+
// what a `SECURITY DEFINER` function made by a migration can read, and the
|
|
647
|
+
// one thing test mode used to get differently from production. Everything
|
|
648
|
+
// else stays on this run's own connection: the template is created, cloned
|
|
649
|
+
// and connected to as `url`, which is what keeps the RLS bootstrap probe and
|
|
650
|
+
// tenant isolation biting exactly as before.
|
|
651
|
+
const migrateUrlStr = dbConfig.testOwnerUrl !== undefined
|
|
652
|
+
? withDatabase(dbConfig.testOwnerUrl, templateDb)
|
|
653
|
+
: undefined
|
|
654
|
+
if (migrateUrlStr !== undefined) {
|
|
655
|
+
tracker.reached('template-build', plan.name, 'owner')
|
|
656
|
+
const ownerIdent = quoteIdentifier(new URL(migrateUrlStr).username, 'role')
|
|
657
|
+
await admin.execute(sql.raw(`GRANT CONNECT ON DATABASE ${templateIdent} TO ${ownerIdent}`))
|
|
658
|
+
// PG15+: `public` belongs to the database owner and grants CREATE to
|
|
659
|
+
// nobody. Without this the first migration fails on its first CREATE.
|
|
660
|
+
await templateDbHandle.execute(sql.raw(`GRANT CREATE, USAGE ON SCHEMA public TO ${ownerIdent}`))
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// Door policies in the migrations say `TO <platform role>`, so the role must
|
|
664
|
+
// exist before they run.
|
|
665
|
+
if (dbConfig.rls?.platformRole !== undefined) {
|
|
666
|
+
tracker.reached('template-build', plan.name, 'platform role')
|
|
667
|
+
await provisionPlatformRole(adminClient!, dbConfig.rls.platformRole, new URL(templateUrlStr).username)
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
if (dbConfig.extensions.length > 0) {
|
|
671
|
+
for (const ext of dbConfig.extensions) {
|
|
672
|
+
tracker.reached('template-build', plan.name, `extension ${ext}`)
|
|
673
|
+
await templateDbHandle.execute(sql.raw(`CREATE EXTENSION IF NOT EXISTS ${quoteIdentifier(ext, 'extension')}`))
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
if (dbConfig.migrations !== undefined) {
|
|
678
|
+
tracker.reached('template-migrate', plan.name, 'start')
|
|
679
|
+
await applyMigrations({
|
|
680
|
+
migrationsFolder: dbConfig.migrations,
|
|
681
|
+
migrationsTable: dbConfig.migrationTable,
|
|
682
|
+
connectionUrl: migrateUrlStr ?? templateUrlStr,
|
|
683
|
+
stripConcurrently: true,
|
|
684
|
+
unloggedTables: true,
|
|
685
|
+
// A migration stack is the one step long enough to look like a stall.
|
|
686
|
+
// Each file applied is an advance. Issue #380.
|
|
687
|
+
onMigration: (tag, index, total) =>
|
|
688
|
+
tracker.reached('template-migrate', plan.name, `${index}/${total} ${tag}`),
|
|
689
|
+
// Statements inside a long file, and the steps before the first file,
|
|
690
|
+
// are advances too. Issue #871.
|
|
691
|
+
onStatement: (tag, statement, statements, index, total) =>
|
|
692
|
+
tracker.reached('template-migrate', plan.name, `${index}/${total} ${tag} stmt ${statement}/${statements}`),
|
|
693
|
+
onPrepare: detail => tracker.reached('template-migrate', plan.name, `prepare ${detail}`),
|
|
694
|
+
})
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// Framework-owned partitioned tables (trace family) are kept out of app
|
|
698
|
+
// migrations (#313) — pipework creates them and their day partitions here so
|
|
699
|
+
// a traced test never hits a missing relation or a missing partition.
|
|
700
|
+
tracker.reached('template-framework', plan.name)
|
|
701
|
+
await provisionFrameworkTables(
|
|
702
|
+
templateDbHandle,
|
|
703
|
+
instance.config.raw,
|
|
704
|
+
{
|
|
705
|
+
hotRetentionDays: instance.config.trace.hotRetentionDays,
|
|
706
|
+
errorRetentionDays: instance.config.trace.errorRetentionDays,
|
|
707
|
+
auditRetentionDays: instance.config.audit.retentionDays === 'keep' ? null : instance.config.audit.retentionDays,
|
|
708
|
+
partitionLeadDays: instance.config.trace.partitionLeadDays,
|
|
709
|
+
},
|
|
710
|
+
plan.isDefaultDatabase,
|
|
711
|
+
{
|
|
712
|
+
unlogged: true,
|
|
713
|
+
rls: dbConfig.rls,
|
|
714
|
+
// Every partition made is an advance: a year of audit days is hundreds
|
|
715
|
+
// of CREATEs, each of which can wait on the disk. Issue #806.
|
|
716
|
+
onProgress: (step, detail) => step === 'partition'
|
|
717
|
+
? tracker.reached('template-partitions', plan.name, detail)
|
|
718
|
+
: tracker.reached('template-framework', plan.name, detail === undefined ? step : `${step} ${detail}`),
|
|
719
|
+
},
|
|
720
|
+
)
|
|
721
|
+
|
|
722
|
+
const grantBeat = (detail: string): void => tracker.reached('template-grants', plan.name, detail)
|
|
723
|
+
grantBeat('role')
|
|
724
|
+
await provisionTemplateRole(adminClient!, templateUrlStr, plan.appRole, migrateUrlStr, grantBeat)
|
|
725
|
+
if (dbConfig.rls !== undefined) await provisionTestSystemPolicy(templateUrlStr, migrateUrlStr, grantBeat)
|
|
726
|
+
|
|
727
|
+
// The marker is written last, and its presence is what makes this template
|
|
728
|
+
// clonable by any other process. The roles, grants and policies above must
|
|
729
|
+
// have succeeded first; a builder that dies before this point leaves a
|
|
730
|
+
// database that every reader correctly refuses to clone. Issue #812.
|
|
731
|
+
tracker.reached('template-framework', plan.name, 'marker')
|
|
732
|
+
await templateDbHandle.execute(sql.raw(
|
|
733
|
+
`CREATE TABLE ${quoteIdentifier(TEMPLATE_MARKER_TABLE, 'table')} (` +
|
|
734
|
+
`fingerprint text PRIMARY KEY, built_at timestamptz NOT NULL DEFAULT now(), builder_pid integer NOT NULL)`,
|
|
735
|
+
))
|
|
736
|
+
await templateDbHandle.execute(sql`
|
|
737
|
+
INSERT INTO ${sql.identifier(TEMPLATE_MARKER_TABLE)} (fingerprint, builder_pid)
|
|
738
|
+
VALUES (${plan.fingerprint}, ${process.pid})
|
|
739
|
+
`)
|
|
740
|
+
|
|
741
|
+
await templateClient.end()
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* Create the platform role if the cluster lacks it: NOLOGIN, and never
|
|
746
|
+
* SUPERUSER or BYPASSRLS, which would make its door policies inert. The test
|
|
747
|
+
* login becomes a member so a test can `SET ROLE` to it. A role that already
|
|
748
|
+
* exists with either flag is refused rather than altered.
|
|
749
|
+
*/
|
|
750
|
+
async function provisionPlatformRole(client: postgres.Sql, roleName: string, testLogin: string): Promise<void> {
|
|
751
|
+
const roleIdent = quoteIdentifier(roleName, 'role')
|
|
752
|
+
await refusingWithoutCreateRole(client, roleName, () => client.unsafe(
|
|
753
|
+
`DO $$ BEGIN ` +
|
|
754
|
+
`IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '${roleName}') THEN ` +
|
|
755
|
+
`CREATE ROLE ${roleIdent} NOLOGIN NOSUPERUSER NOBYPASSRLS NOCREATEDB NOCREATEROLE; ` +
|
|
756
|
+
`END IF; ` +
|
|
757
|
+
`EXCEPTION WHEN duplicate_object THEN NULL; END $$`,
|
|
758
|
+
))
|
|
759
|
+
const [flags] = await client<{ rolsuper: boolean; rolbypassrls: boolean }[]>`
|
|
760
|
+
SELECT rolsuper, rolbypassrls FROM pg_roles WHERE rolname = ${roleName}
|
|
761
|
+
`
|
|
762
|
+
if (flags === undefined || flags.rolsuper || flags.rolbypassrls) {
|
|
763
|
+
throw new Error(
|
|
764
|
+
`[pipework] rls.platformRole "${roleName}" is ${flags?.rolsuper === true ? 'SUPERUSER' : 'BYPASSRLS'} on the test cluster — ` +
|
|
765
|
+
`row-level security never applies to it, so its doors would mean nothing. Use a role without either flag, or drop this one.`,
|
|
766
|
+
)
|
|
767
|
+
}
|
|
768
|
+
if (testLogin !== roleName) await client.unsafe(`GRANT ${roleIdent} TO ${quoteIdentifier(testLogin, 'role')}`)
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* Test template only: a permissive policy on every row-secured table that
|
|
773
|
+
* opens while `pipework.test_system` is `on`, which the test harness sets
|
|
774
|
+
* around statements sent through `pipe.system()`. It stands in for the owner's
|
|
775
|
+
* BYPASSRLS that production gives the system channel and a test login cannot
|
|
776
|
+
* have. Each policy is created by the table's owner, so the two grantors each
|
|
777
|
+
* take what they own.
|
|
778
|
+
*/
|
|
779
|
+
async function provisionTestSystemPolicy(
|
|
780
|
+
templateUrl: string,
|
|
781
|
+
migrateUrl: string | undefined,
|
|
782
|
+
beat: (detail: string) => void,
|
|
783
|
+
): Promise<void> {
|
|
784
|
+
for (const url of migrateUrl === undefined ? [templateUrl] : [templateUrl, migrateUrl]) {
|
|
785
|
+
const client = postgres(url, { max: 1, onnotice: () => {} })
|
|
786
|
+
try {
|
|
787
|
+
const tables = await client<{ ident: string; name: string }[]>`
|
|
788
|
+
SELECT c.oid::regclass::text AS ident, c.relname AS name
|
|
789
|
+
FROM pg_class c
|
|
790
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
791
|
+
WHERE c.relkind IN ('r', 'p')
|
|
792
|
+
AND c.relrowsecurity
|
|
793
|
+
AND NOT c.relispartition
|
|
794
|
+
AND n.nspname NOT IN ('pg_catalog', 'information_schema')
|
|
795
|
+
AND n.nspname NOT LIKE 'pg\_toast%'
|
|
796
|
+
AND pg_get_userbyid(c.relowner) = current_user
|
|
797
|
+
AND NOT EXISTS (SELECT FROM pg_policy p WHERE p.polrelid = c.oid AND p.polname = 'pipework_test_system')
|
|
798
|
+
`
|
|
799
|
+
const open = `current_setting('pipework.test_system', true) = 'on'`
|
|
800
|
+
for (const [i, t] of tables.entries()) {
|
|
801
|
+
beat(`policy ${i + 1}/${tables.length} ${t.name}`)
|
|
802
|
+
await client.unsafe(`CREATE POLICY pipework_test_system ON ${t.ident} AS PERMISSIVE FOR ALL USING (${open}) WITH CHECK (${open})`)
|
|
803
|
+
}
|
|
804
|
+
} finally {
|
|
805
|
+
await client.end()
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// Clone one test file's database from the shared template. A clone copies
|
|
811
|
+
// the template's tables, partitions, and in-database grants; only the cluster
|
|
812
|
+
// catalog's CONNECT privilege has to be re-granted on the new database. Issue #321.
|
|
813
|
+
// Session guards set on every per-test database. Teardown terminates a file's
|
|
814
|
+
// backends before dropping (#379), which covers an orderly finish. A process
|
|
815
|
+
// killed by pid is not covered: its backends keep running whatever statement
|
|
816
|
+
// they are inside, because Postgres learns the client is gone only when it
|
|
817
|
+
// next writes to the socket — a long statement never gets there. The orphan
|
|
818
|
+
// holds locks, raises 55006 against the template, and blocks the drop. These
|
|
819
|
+
// are set on the CLONE only, never on the template: a migration replay on the
|
|
820
|
+
// template is legitimately long, and per-database settings do not copy with
|
|
821
|
+
// `WITH TEMPLATE`. Issue #384.
|
|
822
|
+
interface CloneGuards {
|
|
823
|
+
readonly statementTimeoutMs: number
|
|
824
|
+
readonly idleInTransactionTimeoutMs: number
|
|
825
|
+
readonly clientCheckIntervalMs: number
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
const DEFAULT_STATEMENT_TIMEOUT_MS = 120_000
|
|
829
|
+
const DEFAULT_IDLE_IN_TRANSACTION_TIMEOUT_MS = 120_000
|
|
830
|
+
const DEFAULT_CLIENT_CHECK_INTERVAL_MS = 1_000
|
|
831
|
+
|
|
832
|
+
function resolveCloneGuards(raw: Blueprint): CloneGuards {
|
|
833
|
+
return {
|
|
834
|
+
statementTimeoutMs: raw.test?.statementTimeoutMs ?? DEFAULT_STATEMENT_TIMEOUT_MS,
|
|
835
|
+
idleInTransactionTimeoutMs: raw.test?.idleInTransactionTimeoutMs ?? DEFAULT_IDLE_IN_TRANSACTION_TIMEOUT_MS,
|
|
836
|
+
clientCheckIntervalMs: raw.test?.clientCheckIntervalMs ?? DEFAULT_CLIENT_CHECK_INTERVAL_MS,
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/** The `ALTER DATABASE ... SET` statements for one clone. 0 means "leave it alone". */
|
|
841
|
+
export function cloneGuardStatements(dbIdent: string, guards: CloneGuards): string[] {
|
|
842
|
+
const settings: [string, number][] = [
|
|
843
|
+
['statement_timeout', guards.statementTimeoutMs],
|
|
844
|
+
['idle_in_transaction_session_timeout', guards.idleInTransactionTimeoutMs],
|
|
845
|
+
['client_connection_check_interval', guards.clientCheckIntervalMs],
|
|
846
|
+
]
|
|
847
|
+
return settings
|
|
848
|
+
.filter(([, ms]) => ms > 0)
|
|
849
|
+
.map(([name, ms]) => `ALTER DATABASE ${dbIdent} SET ${name} = ${ms}`)
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
async function cloneTestDatabase(name: string, dbConfig: ResolvedDatabase, template: Template, guards: CloneGuards, onRetry: (n: number) => void): Promise<TestDatabase> {
|
|
853
|
+
const dbName = testDbName(name)
|
|
854
|
+
const dbIdent = quoteIdentifier(dbName, 'database')
|
|
855
|
+
const templateIdent = quoteIdentifier(template.templateDb, 'database')
|
|
856
|
+
const roleIdent = quoteIdentifier(template.appRole, 'role')
|
|
857
|
+
const admin = drizzle(adminClient!)
|
|
858
|
+
|
|
859
|
+
// No `DROP DATABASE IF EXISTS` here. The name carries a per-clone sequence
|
|
860
|
+
// number, so it has never existed before — and a DROP of a database that DOES
|
|
861
|
+
// exist would force a checkpoint on the create path too (#379).
|
|
862
|
+
// CREATE ... WITH TEMPLATE refuses to run while any backend is connected to
|
|
863
|
+
// the template; close out stragglers, then create with a retry that waits out
|
|
864
|
+
// anything we couldn't (an autovacuum worker we have no right to terminate).
|
|
865
|
+
await closeTemplateClients(admin, template.templateDb)
|
|
866
|
+
await withTemplateRetry(() =>
|
|
867
|
+
admin.execute(sql.raw(`CREATE DATABASE ${dbIdent} WITH TEMPLATE ${templateIdent}`)),
|
|
868
|
+
{ onRetry },
|
|
869
|
+
)
|
|
870
|
+
await admin.execute(sql.raw(`ALTER DATABASE ${dbIdent} SET synchronous_commit = off`))
|
|
871
|
+
for (const statement of cloneGuardStatements(dbIdent, guards)) {
|
|
872
|
+
await admin.execute(sql.raw(statement))
|
|
873
|
+
}
|
|
874
|
+
await admin.execute(sql.raw(`GRANT CONNECT ON DATABASE ${dbIdent} TO ${roleIdent}`))
|
|
875
|
+
|
|
876
|
+
const testUrl = new URL(dbConfig.url)
|
|
877
|
+
testUrl.pathname = `/${dbName}`
|
|
878
|
+
|
|
879
|
+
const appUrl = new URL(testUrl.toString())
|
|
880
|
+
appUrl.username = template.appRole
|
|
881
|
+
appUrl.password = 'pipework_test'
|
|
882
|
+
|
|
883
|
+
return {
|
|
884
|
+
name,
|
|
885
|
+
url: testUrl.toString(),
|
|
886
|
+
appUrl: appUrl.toString(),
|
|
887
|
+
appRole: template.appRole,
|
|
888
|
+
database: dbName,
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// Close straggler client connections to the template so the clone can proceed.
|
|
893
|
+
// Three guards keep this from aborting the clone — or from sabotaging a peer:
|
|
894
|
+
// - `backend_type = 'client backend'` excludes background workers. An
|
|
895
|
+
// autovacuum worker attaches to every database and runs as superuser, and a
|
|
896
|
+
// non-superuser test role cannot signal it — targeting it would raise
|
|
897
|
+
// `permission denied to terminate process` (42501) and abort the statement.
|
|
898
|
+
// - `state = 'idle'` AND idle for 5s excludes backends doing work AND live
|
|
899
|
+
// peers. A peer fork's readiness probe is an idle client backend for a few
|
|
900
|
+
// ms between connecting and disconnecting; terminating it made the peer
|
|
901
|
+
// read the blip as "template absent" and rebuild the shared template out
|
|
902
|
+
// from under this clone. Only a backend idle for seconds is a straggler.
|
|
903
|
+
// - best-effort: if a client backend is still unkillable (owned by a role we
|
|
904
|
+
// cannot signal), swallow it. The `CREATE` retry waits out whatever remains.
|
|
905
|
+
async function closeTemplateClients(admin: ReturnType<typeof drizzle>, templateDb: string): Promise<void> {
|
|
906
|
+
try {
|
|
907
|
+
await admin.execute(sql`
|
|
908
|
+
SELECT pg_terminate_backend(pid)
|
|
909
|
+
FROM pg_stat_activity
|
|
910
|
+
WHERE datname = ${templateDb}
|
|
911
|
+
AND pid <> pg_backend_pid()
|
|
912
|
+
AND backend_type = 'client backend'
|
|
913
|
+
AND state = 'idle'
|
|
914
|
+
AND state_change < now() - interval '5 seconds'
|
|
915
|
+
`)
|
|
916
|
+
} catch {
|
|
917
|
+
// best-effort — withTemplateRetry tolerates any backend we couldn't close
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
// SQLSTATE 55006 (object_in_use): `CREATE DATABASE ... WITH TEMPLATE` raises this
|
|
922
|
+
// while any session is still attached to the template — including a transient
|
|
923
|
+
// autovacuum worker, which a non-superuser test role cannot terminate. The
|
|
924
|
+
// worker detaches in well under a second, so retry with capped backoff rather
|
|
925
|
+
// than depend on superuser termination rights.
|
|
926
|
+
const OBJECT_IN_USE = '55006'
|
|
927
|
+
|
|
928
|
+
// The query builder wraps the PostgresError, so the SQLSTATE may sit on `.cause`.
|
|
929
|
+
function isObjectInUse(err: unknown): boolean {
|
|
930
|
+
if (err === null || typeof err !== 'object') return false
|
|
931
|
+
return errCode(err) === OBJECT_IN_USE || isObjectInUse((err as { cause?: unknown }).cause)
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
// ~9.6 s in total: long enough to wait out real contention on a wide run,
|
|
935
|
+
// where several forks clone from one template and each probe attaches to it
|
|
936
|
+
// for a few ms.
|
|
937
|
+
const TEMPLATE_RETRY_DELAYS_MS: readonly number[] = [50, 100, 200, 400, 800, 1600, 1600, 1600, 1600, 1600]
|
|
938
|
+
|
|
939
|
+
// src/test has no logger — it runs before a Manifold exists — so retry lines go
|
|
940
|
+
// to stderr. Injectable so a test can read them without a global. Every line
|
|
941
|
+
// starts with the operation's prefix and carries key=value fields, so an infra
|
|
942
|
+
// script can grep the prefix and pull the numbers out without parsing prose.
|
|
943
|
+
// The two operations that retry 55006 get separate prefixes rather than one
|
|
944
|
+
// prefix and an op= field: a grep for template-clone then tallies clone
|
|
945
|
+
// contention only, and never a drop that a dead builder's leftovers forced.
|
|
946
|
+
export type RetryLog = (message: string) => void
|
|
947
|
+
|
|
948
|
+
export const TEMPLATE_CLONE_LOG_PREFIX = 'pipework:template-clone'
|
|
949
|
+
export const TEMPLATE_DROP_LOG_PREFIX = 'pipework:template-drop'
|
|
950
|
+
|
|
951
|
+
export interface TemplateRetryOptions {
|
|
952
|
+
/** Injectable for tests. */
|
|
953
|
+
sleep?: (ms: number) => Promise<void>
|
|
954
|
+
/** Injectable for tests. */
|
|
955
|
+
delaysMs?: readonly number[]
|
|
956
|
+
/** Injectable for tests. */
|
|
957
|
+
log?: RetryLog
|
|
958
|
+
/** Injectable for tests. */
|
|
959
|
+
now?: () => number
|
|
960
|
+
/** Names the operation the lines are about. Defaults to the clone prefix. */
|
|
961
|
+
prefix?: string
|
|
962
|
+
/** Called with the retry number before each backoff sleep. */
|
|
963
|
+
onRetry?: (n: number) => void
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
/**
|
|
967
|
+
* Run `attempt`, retrying only on SQLSTATE 55006 with capped exponential
|
|
968
|
+
* backoff. Any other error propagates immediately; 55006 past the last delay
|
|
969
|
+
* propagates too. Every retry, and the eventual recovery or exhaustion, is
|
|
970
|
+
* logged under `prefix`, so contention on the shared template is visible in a
|
|
971
|
+
* run's output and exhaustion is stated rather than inferred from a missing
|
|
972
|
+
* line; an attempt that succeeds first try says nothing. Reported elapsed is
|
|
973
|
+
* wall clock from before the first attempt, so it counts the failed
|
|
974
|
+
* round-trips, not just the sleeps.
|
|
975
|
+
*/
|
|
976
|
+
export async function withTemplateRetry<T>(
|
|
977
|
+
attempt: () => Promise<T>,
|
|
978
|
+
options: TemplateRetryOptions = {},
|
|
979
|
+
): Promise<T> {
|
|
980
|
+
const {
|
|
981
|
+
sleep = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms)),
|
|
982
|
+
delaysMs = TEMPLATE_RETRY_DELAYS_MS,
|
|
983
|
+
log = (message: string) => console.warn(message),
|
|
984
|
+
now = () => Date.now(),
|
|
985
|
+
prefix = TEMPLATE_CLONE_LOG_PREFIX,
|
|
986
|
+
onRetry,
|
|
987
|
+
} = options
|
|
988
|
+
|
|
989
|
+
const start = now()
|
|
990
|
+
for (let i = 0; ; i++) {
|
|
991
|
+
try {
|
|
992
|
+
const result = await attempt()
|
|
993
|
+
if (i > 0) {
|
|
994
|
+
log(`${prefix} recovered sqlstate=${OBJECT_IN_USE} retries=${i} elapsed_ms=${now() - start}`)
|
|
995
|
+
}
|
|
996
|
+
return result
|
|
997
|
+
} catch (err) {
|
|
998
|
+
// A non-55006 error is not a contention event and stays out of this
|
|
999
|
+
// channel; an exhausted ladder says so, so a tally never has to read a
|
|
1000
|
+
// fork killed mid-retry as an exhaustion.
|
|
1001
|
+
if (!isObjectInUse(err)) throw err
|
|
1002
|
+
if (i >= delaysMs.length) {
|
|
1003
|
+
log(`${prefix} exhausted sqlstate=${OBJECT_IN_USE} retries=${i} elapsed_ms=${now() - start}`)
|
|
1004
|
+
throw err
|
|
1005
|
+
}
|
|
1006
|
+
const delay = delaysMs[i]!
|
|
1007
|
+
log(
|
|
1008
|
+
`${prefix} retry sqlstate=${OBJECT_IN_USE} ` +
|
|
1009
|
+
`retry=${i + 1}/${delaysMs.length} delay_ms=${delay} elapsed_ms=${now() - start}`,
|
|
1010
|
+
)
|
|
1011
|
+
onRetry?.(i + 1)
|
|
1012
|
+
await sleep(delay)
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
const INSUFFICIENT_PRIVILEGE = '42501'
|
|
1018
|
+
|
|
1019
|
+
function hasSqlState(err: unknown, code: string): boolean {
|
|
1020
|
+
if (err === null || typeof err !== 'object') return false
|
|
1021
|
+
return errCode(err) === code || hasSqlState((err as { cause?: unknown }).cause, code)
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
/** Run a CREATE ROLE; a refusal for lack of CREATEROLE names the login and the fix. Issue #812. */
|
|
1025
|
+
async function refusingWithoutCreateRole(client: postgres.Sql, roleName: string, create: () => PromiseLike<unknown>): Promise<void> {
|
|
1026
|
+
try {
|
|
1027
|
+
await create()
|
|
1028
|
+
} catch (err) {
|
|
1029
|
+
if (!hasSqlState(err, INSUFFICIENT_PRIVILEGE)) throw err
|
|
1030
|
+
const [who] = await client<{ login: string }[]>`SELECT current_user AS login`.catch(() => [])
|
|
1031
|
+
const login = who?.login ?? 'the test login'
|
|
1032
|
+
throw new Error(
|
|
1033
|
+
`[pipework] The test login "${login}" needs CREATEROLE on the test cluster to make role "${roleName}" ` +
|
|
1034
|
+
`(ALTER ROLE "${login}" CREATEROLE).`,
|
|
1035
|
+
{ cause: err },
|
|
1036
|
+
)
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
// Create the app role (cluster-global, once per process) and grant it on the
|
|
1041
|
+
// template's tables, sequences, schema, and future objects. Those grants are
|
|
1042
|
+
// in-database catalog rows, so every clone of the template inherits them — the
|
|
1043
|
+
// role keeps a stable OID for the process lifetime, so the inherited ACL
|
|
1044
|
+
// entries stay valid. Issue #321.
|
|
1045
|
+
async function provisionTemplateRole(
|
|
1046
|
+
client: postgres.Sql,
|
|
1047
|
+
templateUrl: string,
|
|
1048
|
+
roleName: string,
|
|
1049
|
+
migrateUrl: string | undefined,
|
|
1050
|
+
beat: (detail: string) => void,
|
|
1051
|
+
): Promise<void> {
|
|
1052
|
+
const admin = drizzle(client)
|
|
1053
|
+
const roleIdent = quoteIdentifier(roleName, 'role')
|
|
1054
|
+
|
|
1055
|
+
await refusingWithoutCreateRole(client, roleName, () => admin.execute(sql.raw(
|
|
1056
|
+
`DO $$ BEGIN ` +
|
|
1057
|
+
`IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '${roleName}') THEN ` +
|
|
1058
|
+
`CREATE ROLE ${roleIdent} WITH LOGIN PASSWORD 'pipework_test' NOSUPERUSER NOCREATEDB NOCREATEROLE; ` +
|
|
1059
|
+
`END IF; ` +
|
|
1060
|
+
`END $$`,
|
|
1061
|
+
)))
|
|
1062
|
+
|
|
1063
|
+
const templateClient = postgres(templateUrl, { max: 1, onnotice: () => {} })
|
|
1064
|
+
const templateDb = drizzle(templateClient)
|
|
1065
|
+
|
|
1066
|
+
await templateDb.execute(sql.raw(`GRANT USAGE ON SCHEMA public TO ${roleIdent}`))
|
|
1067
|
+
|
|
1068
|
+
if (migrateUrl === undefined) {
|
|
1069
|
+
beat('tables')
|
|
1070
|
+
await templateDb.execute(sql.raw(`GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO ${roleIdent}`))
|
|
1071
|
+
await templateDb.execute(sql.raw(`GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO ${roleIdent}`))
|
|
1072
|
+
await templateDb.execute(sql.raw(`ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ${roleIdent}`))
|
|
1073
|
+
await templateDb.execute(sql.raw(`ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO ${roleIdent}`))
|
|
1074
|
+
// Routines are granted here, after the migrations have run, because this is
|
|
1075
|
+
// the one place where roles are created AFTER the objects they must reach:
|
|
1076
|
+
// a migration that creates a function and revokes EXECUTE from PUBLIC would
|
|
1077
|
+
// otherwise leave the app role unable to call it.
|
|
1078
|
+
beat('routines')
|
|
1079
|
+
await templateDb.execute(sql.raw(`GRANT EXECUTE ON ALL ROUTINES IN SCHEMA public TO ${roleIdent}`))
|
|
1080
|
+
await templateDb.execute(sql.raw(`ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT EXECUTE ON ROUTINES TO ${roleIdent}`))
|
|
1081
|
+
await templateClient.end()
|
|
1082
|
+
return
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
// Two owners in one template: the migration owner made everything the
|
|
1086
|
+
// migrations made, this run's own role made the extensions' objects, the
|
|
1087
|
+
// framework tables. `GRANT ... ON ALL TABLES` refuses on the
|
|
1088
|
+
// first object its grantor does not own instead of skipping it, so each
|
|
1089
|
+
// grantor grants what it owns and nothing else.
|
|
1090
|
+
await grantOwnedObjects(templateClient, roleIdent, APP_ROLE_GRANTS, beat)
|
|
1091
|
+
|
|
1092
|
+
const migrateClient = postgres(migrateUrl, { max: 1, onnotice: () => {} })
|
|
1093
|
+
try {
|
|
1094
|
+
await grantOwnedObjects(migrateClient, roleIdent, APP_ROLE_GRANTS, beat)
|
|
1095
|
+
// The test login is the role tests connect as, and it no longer owns what
|
|
1096
|
+
// the migrations made. Everything short of ownership is granted back to
|
|
1097
|
+
// it; DDL that requires ownership (ALTER, DROP) needs the test login to be
|
|
1098
|
+
// a MEMBER of the migration owner, which is the venue's business — pipework
|
|
1099
|
+
// has no right to hand out role membership.
|
|
1100
|
+
await grantOwnedObjects(migrateClient, quoteIdentifier(new URL(templateUrl).username, 'role'), TEST_LOGIN_GRANTS, beat)
|
|
1101
|
+
} finally {
|
|
1102
|
+
await migrateClient.end()
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
await templateClient.end()
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
/** The privileges one grantor hands one role, by object kind. */
|
|
1109
|
+
interface GrantSet {
|
|
1110
|
+
readonly tables: string
|
|
1111
|
+
readonly sequences: string
|
|
1112
|
+
readonly routines: string
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
const APP_ROLE_GRANTS: GrantSet = {
|
|
1116
|
+
tables: 'SELECT, INSERT, UPDATE, DELETE',
|
|
1117
|
+
sequences: 'USAGE, SELECT',
|
|
1118
|
+
routines: 'EXECUTE',
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
const TEST_LOGIN_GRANTS: GrantSet = {
|
|
1122
|
+
tables: 'ALL PRIVILEGES',
|
|
1123
|
+
sequences: 'ALL PRIVILEGES',
|
|
1124
|
+
routines: 'EXECUTE',
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
// Grant on the objects in `public` that THIS connection's role owns, plus the
|
|
1128
|
+
// default privileges for what it creates later. Ownership is read from the
|
|
1129
|
+
// catalog rather than assumed, so a template with two object owners is granted
|
|
1130
|
+
// correctly from either side and neither one's sweep can fail on the other's
|
|
1131
|
+
// objects. Object names come back as `regclass`/`regprocedure` text, already
|
|
1132
|
+
// quoted by Postgres and carrying a routine's argument types.
|
|
1133
|
+
async function grantOwnedObjects(
|
|
1134
|
+
client: postgres.Sql,
|
|
1135
|
+
roleIdent: string,
|
|
1136
|
+
grants: GrantSet,
|
|
1137
|
+
beat: (detail: string) => void,
|
|
1138
|
+
): Promise<void> {
|
|
1139
|
+
const relations = await client<{ ident: string; kind: string }[]>`
|
|
1140
|
+
SELECT c.oid::regclass::text AS ident, c.relkind AS kind
|
|
1141
|
+
FROM pg_class c
|
|
1142
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
1143
|
+
WHERE n.nspname = 'public'
|
|
1144
|
+
AND c.relkind IN ('r', 'p', 'v', 'm', 'f', 'S')
|
|
1145
|
+
AND pg_get_userbyid(c.relowner) = current_user
|
|
1146
|
+
`
|
|
1147
|
+
const tables = relations.filter(r => r.kind !== 'S').map(r => r.ident)
|
|
1148
|
+
const sequences = relations.filter(r => r.kind === 'S').map(r => r.ident)
|
|
1149
|
+
|
|
1150
|
+
const routines = await client<{ ident: string }[]>`
|
|
1151
|
+
SELECT p.oid::regprocedure::text AS ident
|
|
1152
|
+
FROM pg_proc p
|
|
1153
|
+
JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
1154
|
+
WHERE n.nspname = 'public'
|
|
1155
|
+
AND pg_get_userbyid(p.proowner) = current_user
|
|
1156
|
+
`
|
|
1157
|
+
|
|
1158
|
+
if (tables.length > 0) {
|
|
1159
|
+
await client.unsafe(`GRANT ${grants.tables} ON TABLE ${tables.join(', ')} TO ${roleIdent}`)
|
|
1160
|
+
}
|
|
1161
|
+
if (sequences.length > 0) {
|
|
1162
|
+
await client.unsafe(`GRANT ${grants.sequences} ON SEQUENCE ${sequences.join(', ')} TO ${roleIdent}`)
|
|
1163
|
+
}
|
|
1164
|
+
for (const [i, routine] of routines.entries()) {
|
|
1165
|
+
beat(`routine ${i + 1}/${routines.length} ${routine.ident}`)
|
|
1166
|
+
await client.unsafe(`GRANT ${grants.routines} ON ROUTINE ${routine.ident} TO ${roleIdent}`)
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
await client.unsafe(`ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ${grants.tables} ON TABLES TO ${roleIdent}`)
|
|
1170
|
+
await client.unsafe(`ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ${grants.sequences} ON SEQUENCES TO ${roleIdent}`)
|
|
1171
|
+
await client.unsafe(`ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ${grants.routines} ON ROUTINES TO ${roleIdent}`)
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
// Close this run's persistent admin and lock connections. Releasing
|
|
1175
|
+
// `lockClient` drops the run's advisory lock — so this runs exactly once, at
|
|
1176
|
+
// worker shutdown, never per file: these connections hold the run's advisory
|
|
1177
|
+
// lock and this run's shared pin on every template it uses, and both must
|
|
1178
|
+
// outlive every file in the worker.
|
|
1179
|
+
async function closeRunConnections(): Promise<void> {
|
|
1180
|
+
if (adminClient !== null) {
|
|
1181
|
+
await adminClient.end()
|
|
1182
|
+
adminClient = null
|
|
1183
|
+
adminIsSuperuser = null
|
|
1184
|
+
}
|
|
1185
|
+
if (lockClient !== null) {
|
|
1186
|
+
await lockClient.end()
|
|
1187
|
+
lockClient = null
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
// Arm a one-time close of the persistent connections at worker shutdown.
|
|
1192
|
+
// Under `pool: 'forks'` each vitest worker is a forked child with an IPC
|
|
1193
|
+
// channel; vitest disconnects that channel when the worker has no more files,
|
|
1194
|
+
// firing `disconnect` — the only once-per-worker signal, since a per-file
|
|
1195
|
+
// `afterAll` cannot know which file is the worker's last. The admin and lock
|
|
1196
|
+
// connections are opened with no idle timeout, so their open sockets keep the
|
|
1197
|
+
// worker's event loop alive; closing them here lets the worker exit naturally
|
|
1198
|
+
// (#774). Holding them until then keeps the advisory lock for the worker's
|
|
1199
|
+
// whole life, so a sibling worker's reaper can never mistake this run's live
|
|
1200
|
+
// template for an orphan. Issue #321. Vitest 4 never sends `disconnect` to a
|
|
1201
|
+
// fork (it SIGTERMs it), so there the plugin's `reap-after-run` drops the
|
|
1202
|
+
// remainder from the parent instead (#810).
|
|
1203
|
+
function armWorkerShutdown(): void {
|
|
1204
|
+
if (workerShutdownArmed) return
|
|
1205
|
+
workerShutdownArmed = true
|
|
1206
|
+
if (typeof process.send === 'function') {
|
|
1207
|
+
process.once('disconnect', () => {
|
|
1208
|
+
// Flush FIRST: the drops need the admin connection this then closes.
|
|
1209
|
+
void flushPendingDrops('shutdown').finally(() => closeRunConnections())
|
|
1210
|
+
})
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
// Drop this file's clones. The shared template and its app role deliberately
|
|
1215
|
+
// outlive teardown — the next file, the next process, and the next run all
|
|
1216
|
+
// reuse it, and a reaper drops it only once its content key goes stale (#340). The run's admin and lock
|
|
1217
|
+
// connections also survive: `lockClient` holds the advisory lock that protects
|
|
1218
|
+
// the live template from a sibling worker's reaper, so it must stay open for
|
|
1219
|
+
// the worker's whole life — `armWorkerShutdown` closes both once, at worker
|
|
1220
|
+
// exit. A non-forked run has no IPC channel and so no `disconnect` signal, but
|
|
1221
|
+
// also no sibling worker and so no concurrent reaper: closing the connections
|
|
1222
|
+
// per file is then both safe and necessary for the process to exit. Issue #321.
|
|
1223
|
+
export async function teardownTestDatabases(): Promise<void> {
|
|
1224
|
+
if (adminClient === null) {
|
|
1225
|
+
testDatabases.clear()
|
|
1226
|
+
return
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
const admin = drizzle(adminClient)
|
|
1230
|
+
|
|
1231
|
+
// Kept databases are reset by the next setup that takes them, not here: a
|
|
1232
|
+
// process that ends after one file never pays for it. Without an IPC channel
|
|
1233
|
+
// nothing follows this teardown (see below): drop instead.
|
|
1234
|
+
const canReuse = typeof process.send === 'function'
|
|
1235
|
+
for (const [, testDb] of testDatabases) {
|
|
1236
|
+
try {
|
|
1237
|
+
await admin.execute(sql`SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = ${testDb.database} AND pid != pg_backend_pid()`)
|
|
1238
|
+
const keep = retained.get(testDb.database)
|
|
1239
|
+
if (keep !== undefined && canReuse) {
|
|
1240
|
+
keep.inUse = false
|
|
1241
|
+
keep.needsReset = true
|
|
1242
|
+
} else {
|
|
1243
|
+
retained.delete(testDb.database)
|
|
1244
|
+
pendingDrops.push(testDb.database)
|
|
1245
|
+
}
|
|
1246
|
+
} catch {
|
|
1247
|
+
// Best effort cleanup — a leftover clone is reaped by the next run.
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
testDatabases.clear()
|
|
1252
|
+
|
|
1253
|
+
if (pendingDrops.length >= dropBatchSize) {
|
|
1254
|
+
await flushPendingDrops('batch')
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
if (typeof process.send !== 'function') {
|
|
1258
|
+
// No IPC channel means no `disconnect` signal and so no later flush: this
|
|
1259
|
+
// teardown is the last chance to settle what is queued.
|
|
1260
|
+
await flushPendingDrops('shutdown')
|
|
1261
|
+
await closeRunConnections()
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
/**
|
|
1266
|
+
* An idle kept database for this key, reset and marked in use. One whose reset
|
|
1267
|
+
* fails is queued for drop and the next candidate tried; none left means the
|
|
1268
|
+
* caller clones.
|
|
1269
|
+
*/
|
|
1270
|
+
async function takeKept(key: string): Promise<RetainedDatabase | undefined> {
|
|
1271
|
+
for (const keep of retained.values()) {
|
|
1272
|
+
if (keep.key !== key || keep.inUse) continue
|
|
1273
|
+
if (keep.needsReset && !(await renameKept(keep) && await resetKept(keep))) {
|
|
1274
|
+
retained.delete(keep.testDb.database)
|
|
1275
|
+
pendingDrops.push(keep.testDb.database)
|
|
1276
|
+
continue
|
|
1277
|
+
}
|
|
1278
|
+
keep.needsReset = false
|
|
1279
|
+
keep.inUse = true
|
|
1280
|
+
return keep
|
|
1281
|
+
}
|
|
1282
|
+
return undefined
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
/**
|
|
1286
|
+
* Give a kept database a new name before its next file, so that anything the
|
|
1287
|
+
* last file left running — a timer, a job loop, a pool — and that connects by
|
|
1288
|
+
* the old URL finds no database, as it would with a dropped clone, instead of
|
|
1289
|
+
* writing into the next file's. Sessions still attached are ended first; the
|
|
1290
|
+
* rename waits out one that attaches in between. False: it cannot be renamed
|
|
1291
|
+
* and needs a fresh clone.
|
|
1292
|
+
*/
|
|
1293
|
+
async function renameKept(keep: RetainedDatabase): Promise<boolean> {
|
|
1294
|
+
const oldName = keep.testDb.database
|
|
1295
|
+
const newName = testDbName(keep.testDb.name)
|
|
1296
|
+
const admin = drizzle(adminClient!)
|
|
1297
|
+
for (let attempt = 0; attempt < RENAME_ATTEMPTS; attempt++) {
|
|
1298
|
+
try {
|
|
1299
|
+
await admin.execute(sql`SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = ${oldName} AND pid <> pg_backend_pid()`)
|
|
1300
|
+
await admin.execute(sql.raw(`ALTER DATABASE ${quoteIdentifier(oldName, 'database')} RENAME TO ${quoteIdentifier(newName, 'database')}`))
|
|
1301
|
+
} catch {
|
|
1302
|
+
await new Promise(resolve => setTimeout(resolve, 50 * (attempt + 1)))
|
|
1303
|
+
continue
|
|
1304
|
+
}
|
|
1305
|
+
retained.delete(oldName)
|
|
1306
|
+
keep.testDb = {
|
|
1307
|
+
...keep.testDb,
|
|
1308
|
+
database: newName,
|
|
1309
|
+
url: withDatabase(keep.testDb.url, newName),
|
|
1310
|
+
appUrl: withDatabase(keep.testDb.appUrl, newName),
|
|
1311
|
+
}
|
|
1312
|
+
retained.set(newName, keep)
|
|
1313
|
+
return true
|
|
1314
|
+
}
|
|
1315
|
+
console.warn(`${TEST_RESET_LOG_PREFIX} db=${oldName} fresh-clone reason=rename`)
|
|
1316
|
+
return false
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
const RENAME_ATTEMPTS = 5
|
|
1320
|
+
|
|
1321
|
+
/** Reset a kept database to its baseline and say so. False: it needs a fresh clone. */
|
|
1322
|
+
async function resetKept(keep: RetainedDatabase): Promise<boolean> {
|
|
1323
|
+
const start = Date.now()
|
|
1324
|
+
adminIsSuperuser ??= await readSuperuser()
|
|
1325
|
+
const outcome = await resetDatabase(withDatabase(runAdminUrl, keep.testDb.database), keep.baseline, adminIsSuperuser)
|
|
1326
|
+
const db = keep.testDb.database
|
|
1327
|
+
console.warn(outcome.ok
|
|
1328
|
+
? `${TEST_RESET_LOG_PREFIX} db=${db} cleared=${outcome.cleared} reloaded=${outcome.reloaded} elapsed_ms=${Date.now() - start}`
|
|
1329
|
+
: `${TEST_RESET_LOG_PREFIX} db=${db} fresh-clone reason=${outcome.reason}`)
|
|
1330
|
+
return outcome.ok
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
// Per-file databases that are done with, waiting to be dropped together.
|
|
1334
|
+
//
|
|
1335
|
+
// A worker keeps its databases across files (`test.databasePerFile` off), so
|
|
1336
|
+
// what reaches this queue is a database whose reset fell back to a fresh clone,
|
|
1337
|
+
// every kept database at worker shutdown, and every database with
|
|
1338
|
+
// `databasePerFile`.
|
|
1339
|
+
//
|
|
1340
|
+
// Every `DROP DATABASE` forces an IMMEDIATE cluster-wide checkpoint and waits
|
|
1341
|
+
// for it — `RequestCheckpoint(CHECKPOINT_IMMEDIATE | FORCE | WAIT)` in
|
|
1342
|
+
// Postgres's own `dropdb()`. `CREATE DATABASE` does not: since PG15 its default
|
|
1343
|
+
// strategy is WAL_LOG, which needs no checkpoint at all. So the drop, not the
|
|
1344
|
+
// clone, is what a test file pays for, and it pays SYNCHRONOUSLY inside the
|
|
1345
|
+
// teardown hook: on a saturated cluster those checkpoints were measured at
|
|
1346
|
+
// 20-47 s apiece, which is the teardown hook timing out.
|
|
1347
|
+
//
|
|
1348
|
+
// The count of checkpoints is fixed by the count of databases — batching cannot
|
|
1349
|
+
// change it. What batching changes is WHERE the wait lands: one teardown in
|
|
1350
|
+
// `dropBatchSize` pays it instead of every one, and the rest of a worker's
|
|
1351
|
+
// drops are settled at worker shutdown, off the critical path of a wide start
|
|
1352
|
+
// burst. A worker that dies with drops still queued leaks nothing permanent —
|
|
1353
|
+
// its run lock dies with it and the next run's reaper drops them. Issue #379.
|
|
1354
|
+
const pendingDrops: string[] = []
|
|
1355
|
+
|
|
1356
|
+
const DEFAULT_DROP_BATCH_SIZE = 8
|
|
1357
|
+
|
|
1358
|
+
let dropBatchSize = DEFAULT_DROP_BATCH_SIZE
|
|
1359
|
+
|
|
1360
|
+
export const TEST_DROP_LOG_PREFIX = 'pipework:test-drop'
|
|
1361
|
+
|
|
1362
|
+
/**
|
|
1363
|
+
* Drop every queued database, back to back. Best effort per database: one that
|
|
1364
|
+
* refuses (a backend attached between teardown and here) stays for the reaper
|
|
1365
|
+
* rather than failing the flush for the rest.
|
|
1366
|
+
*/
|
|
1367
|
+
export async function flushPendingDrops(
|
|
1368
|
+
reason: 'batch' | 'shutdown',
|
|
1369
|
+
log: RetryLog = message => console.warn(message),
|
|
1370
|
+
now: () => number = () => Date.now(),
|
|
1371
|
+
): Promise<number> {
|
|
1372
|
+
if (reason === 'shutdown') {
|
|
1373
|
+
pendingDrops.push(...retained.keys())
|
|
1374
|
+
retained.clear()
|
|
1375
|
+
}
|
|
1376
|
+
if (adminClient === null || pendingDrops.length === 0) return 0
|
|
1377
|
+
|
|
1378
|
+
const admin = drizzle(adminClient)
|
|
1379
|
+
const batch = pendingDrops.splice(0, pendingDrops.length)
|
|
1380
|
+
const start = now()
|
|
1381
|
+
let dropped = 0
|
|
1382
|
+
|
|
1383
|
+
for (const database of batch) {
|
|
1384
|
+
try {
|
|
1385
|
+
await admin.execute(sql.raw(`DROP DATABASE IF EXISTS ${quoteIdentifier(database, 'database')}`))
|
|
1386
|
+
dropped++
|
|
1387
|
+
} catch {
|
|
1388
|
+
// Left for the next run's reaper.
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
// Each drop is a forced checkpoint the cluster had to wait out, so what the
|
|
1393
|
+
// batch cost is worth stating — an infra script greps the prefix and reads
|
|
1394
|
+
// the numbers without parsing prose, as the template-retry lines do.
|
|
1395
|
+
log(`${TEST_DROP_LOG_PREFIX} ${reason} queued=${batch.length} dropped=${dropped} elapsed_ms=${now() - start}`)
|
|
1396
|
+
return dropped
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
/** Test-only: how many databases are waiting to be dropped. */
|
|
1400
|
+
export function pendingDropCount(): number {
|
|
1401
|
+
return pendingDrops.length
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
export function getTestDatabase(name: string): TestDatabase | undefined {
|
|
1405
|
+
return testDatabases.get(name)
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
export function getTestDatabases(): ReadonlyMap<string, TestDatabase> {
|
|
1409
|
+
return testDatabases
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
/**
|
|
1413
|
+
* Forget the in-process template cache, so the next `setupTestDatabases` takes
|
|
1414
|
+
* the same path a freshly-forked worker takes: shared lock, readiness check,
|
|
1415
|
+
* clone. This is how a single process can prove the cross-process claim — that
|
|
1416
|
+
* a second fork reuses the template rather than rebuilding it — without
|
|
1417
|
+
* actually forking. Test-only. Issue #340.
|
|
1418
|
+
*/
|
|
1419
|
+
export function forgetTemplateCacheForTests(): void {
|
|
1420
|
+
templates.clear()
|
|
1421
|
+
reaped = false
|
|
1422
|
+
}
|