pipework 0.251.0 → 0.252.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/dist/core/config/load.d.ts +2 -0
- package/dist/core/config/load.d.ts.map +1 -1
- package/dist/core/config/load.js +1 -0
- package/dist/core/config/load.js.map +1 -1
- package/dist/core/config/namespace.d.ts +1 -0
- package/dist/core/config/namespace.d.ts.map +1 -1
- package/dist/core/config/schema.d.ts +2 -0
- package/dist/core/config/schema.d.ts.map +1 -1
- package/dist/core/config/schema.js +1 -0
- package/dist/core/config/schema.js.map +1 -1
- package/dist/core/surface/types.d.ts +1 -0
- package/dist/core/surface/types.d.ts.map +1 -1
- package/dist/data/db/namespace.d.ts +3 -0
- package/dist/data/db/namespace.d.ts.map +1 -1
- package/dist/data/db/namespace.js +3 -0
- package/dist/data/db/namespace.js.map +1 -1
- package/dist/data/db/pool.d.ts.map +1 -1
- package/dist/data/db/pool.js +6 -1
- package/dist/data/db/pool.js.map +1 -1
- package/dist/data/db/query-label.d.ts +5 -0
- package/dist/data/db/query-label.d.ts.map +1 -0
- package/dist/data/db/query-label.js +11 -0
- package/dist/data/db/query-label.js.map +1 -0
- package/dist/data/db/query-watch.d.ts +21 -0
- package/dist/data/db/query-watch.d.ts.map +1 -0
- package/dist/data/db/query-watch.js +90 -0
- package/dist/data/db/query-watch.js.map +1 -0
- package/dist/data/query/postgres-js/driver.js +1 -1
- package/dist/data/query/postgres-js/driver.js.map +1 -1
- package/dist/data/query/postgres-js/session.d.ts +5 -1
- package/dist/data/query/postgres-js/session.d.ts.map +1 -1
- package/dist/data/query/postgres-js/session.js +16 -11
- package/dist/data/query/postgres-js/session.js.map +1 -1
- package/dist/data/query/utils.d.ts +2 -0
- package/dist/data/query/utils.d.ts.map +1 -1
- package/dist/data/query/utils.js.map +1 -1
- package/dist/infra/credits/schema.d.ts +1 -1
- package/dist/test/plugin.d.ts.map +1 -1
- package/dist/test/plugin.js +10 -4
- package/dist/test/plugin.js.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 +854 -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,1012 @@
|
|
|
1
|
+
import { sql } from '../../data/db/sql.js'
|
|
2
|
+
import type { DB, SQL } from '../../data/db/index.js'
|
|
3
|
+
import { assertValidIdentifier, quoteIdentifier } from '../../data/db/identifiers.js'
|
|
4
|
+
import { serializable, isTransientConcurrencyError } from '../../data/db/serializable.js'
|
|
5
|
+
import type { DefinedTable } from '../../data/domain/types.js'
|
|
6
|
+
import { abortableDelay } from './abortable-delay.js'
|
|
7
|
+
import { JobListener } from './listener.js'
|
|
8
|
+
import type { ListenClient, WaitOptions, WaitResult } from './listener.js'
|
|
9
|
+
import { createScheduler } from './scheduler.js'
|
|
10
|
+
import type { ScheduleSpec, ScheduleInfo, SchedulerOptions } from './scheduler.js'
|
|
11
|
+
import { resolveExclusion, exclusionClaimClause } from './exclusion.js'
|
|
12
|
+
import type { ExclusionConfig } from './exclusion.js'
|
|
13
|
+
import type { JobLaterError } from './errors.js'
|
|
14
|
+
import { openClaim, closeClaim, parseClaims } from './claims.js'
|
|
15
|
+
import type { JobClaim, JobClaimReason } from './claims.js'
|
|
16
|
+
import { serializeError } from './serialize-error.js'
|
|
17
|
+
import { queueDefinition, registerProgressColumn, liveKeyIndexName, LIVE_KEY_TENANT_EXPR, LIVE_KEY_PREDICATE, pendingKeyIndexName, PENDING_KEY_TENANT_EXPR, PENDING_KEY_PREDICATE } from './table.js'
|
|
18
|
+
import { queueDoorName } from './queue-door.js'
|
|
19
|
+
import { unscopedExecutor, requireUnscopedExecutor } from '../../auth/tenant/scoped-db.js'
|
|
20
|
+
import { getContext } from '../../request/context/store.js'
|
|
21
|
+
import { resolveActorId } from '../../data/write/audit/actor.js'
|
|
22
|
+
import { getBaseLogger } from '../../core/logging/index.js'
|
|
23
|
+
import { recordFault, faultMessage } from '../../infra/alerts/faults.js'
|
|
24
|
+
import type { ParentTraceRef } from '../../trace/entry/open-trace.js'
|
|
25
|
+
|
|
26
|
+
export interface BackoffConfig {
|
|
27
|
+
readonly type: 'exponential' | 'fixed'
|
|
28
|
+
readonly delayMs: number
|
|
29
|
+
readonly maxDelayMs?: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Queue configuration — table name, stale timeout, max attempts, backoff strategy, cross-job exclusion. */
|
|
33
|
+
export interface QueueConfig {
|
|
34
|
+
readonly table?: string
|
|
35
|
+
readonly staleTimeoutMs?: number
|
|
36
|
+
readonly notifyChannel?: string
|
|
37
|
+
readonly claimFilter?: (candidateAlias: string) => string
|
|
38
|
+
readonly maxAttempts?: number
|
|
39
|
+
readonly backoff?: BackoffConfig
|
|
40
|
+
/** An app-added column on the queue table whose value is recorded as each claim's `progressMark` when the claim is released. */
|
|
41
|
+
readonly progressColumn?: string
|
|
42
|
+
/** Cross-job exclusion gate — overlap/blocks-all/tenant columns checked against claimed/running jobs at claim time. */
|
|
43
|
+
readonly exclusion?: ExclusionConfig
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Exclusion values for one job — mapped onto the configured exclusion columns. */
|
|
47
|
+
export interface EnqueueExclusion {
|
|
48
|
+
/** Conflict set for the configured arrayColumn; null/omitted = non-participating. */
|
|
49
|
+
readonly array?: readonly string[] | null
|
|
50
|
+
/** TRUE makes this job exclusive against every participating job in its scope. */
|
|
51
|
+
readonly blocksAll?: boolean
|
|
52
|
+
/** Tenant value for the configured tenantColumn — required when one is configured. */
|
|
53
|
+
readonly tenant?: string
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Options for enqueuing a job — type, payload, priority, max attempts. */
|
|
57
|
+
export interface EnqueueOptions {
|
|
58
|
+
readonly type: string
|
|
59
|
+
readonly priority?: number
|
|
60
|
+
readonly payload: unknown
|
|
61
|
+
readonly maxAttempts?: number
|
|
62
|
+
readonly exclusion?: EnqueueExclusion
|
|
63
|
+
/**
|
|
64
|
+
* Record the enqueuing Flow's trace, open step and tenant so the worker runs
|
|
65
|
+
* the job as a child of that trace under that tenant. Default true; false
|
|
66
|
+
* records none of them (the job runs as a root trace with no tenant).
|
|
67
|
+
*/
|
|
68
|
+
readonly continueFlow?: boolean
|
|
69
|
+
/** Run the job under this tenant instead of the Flow's; `null` runs it without a tenant. */
|
|
70
|
+
readonly tenant?: string | null
|
|
71
|
+
/** Also record the Flow's actor (`auth.userId`); the job's `auth` is `{ userId }` when set. Default false. */
|
|
72
|
+
readonly carryActor?: boolean
|
|
73
|
+
/** Record this actor instead of the Flow's (`null`: none); overrides `carryActor`. Ignored with `continueFlow: false`. */
|
|
74
|
+
readonly actor?: string | null
|
|
75
|
+
/**
|
|
76
|
+
* The trace (and open step) the job continues, in place of the Flow's own.
|
|
77
|
+
* `null` records no trace parent at all. Tenant and actor still follow `tenant` and `carryActor`.
|
|
78
|
+
*/
|
|
79
|
+
readonly parent?: ParentTraceRef | null
|
|
80
|
+
/**
|
|
81
|
+
* While a job on this queue with the same key and the same tenant is live (pending, claimed or running),
|
|
82
|
+
* enqueue returns that job's id and inserts nothing — the payload and other options of this call are
|
|
83
|
+
* ignored. Once that job completes, fails for good or is cancelled, the next enqueue inserts a new job.
|
|
84
|
+
* A non-empty string.
|
|
85
|
+
*/
|
|
86
|
+
readonly liveKey?: string
|
|
87
|
+
/**
|
|
88
|
+
* While a job on this queue with the same key and the same tenant is pending (not yet claimed), enqueue
|
|
89
|
+
* replaces that job's payload with this call's and returns its id; every other option of this call is
|
|
90
|
+
* ignored and the job keeps its place in line. Once the job is claimed, the next enqueue inserts a new job.
|
|
91
|
+
* A non-empty string; not combinable with `liveKey`.
|
|
92
|
+
*/
|
|
93
|
+
readonly pendingKey?: string
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** A job that has just been dead-lettered — handed to every `onDeadLetter` listener inside the transaction that did it. */
|
|
97
|
+
export interface DeadLetterInfo {
|
|
98
|
+
readonly id: string
|
|
99
|
+
readonly type: string
|
|
100
|
+
readonly tenant: string | null
|
|
101
|
+
readonly payload: unknown
|
|
102
|
+
/** The serialized error of the last attempt. */
|
|
103
|
+
readonly error: unknown
|
|
104
|
+
/** The Flow that enqueued the job. */
|
|
105
|
+
readonly flow: ClaimedFlow
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The enqueuing Flow a claimed job continues — every field null when none was recorded. */
|
|
109
|
+
export interface ClaimedFlow {
|
|
110
|
+
readonly traceId: string | null
|
|
111
|
+
readonly rootId: string | null
|
|
112
|
+
readonly stepId: string | null
|
|
113
|
+
readonly tenant: string | null
|
|
114
|
+
readonly actor: string | null
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The exclusion values a claimed job carries — present when the queue configures exclusion. */
|
|
118
|
+
export interface ClaimedExclusion {
|
|
119
|
+
readonly array: readonly string[] | null
|
|
120
|
+
readonly blocksAll: boolean
|
|
121
|
+
readonly tenant: string | null
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export type { JobClaim, JobClaimReason }
|
|
125
|
+
|
|
126
|
+
/** A job claimed by a worker — includes ID, type, payload, and created LSN. */
|
|
127
|
+
export interface ClaimedJob {
|
|
128
|
+
readonly id: string
|
|
129
|
+
readonly type: string
|
|
130
|
+
readonly priority: number
|
|
131
|
+
readonly payload: unknown
|
|
132
|
+
readonly createdLsn: string
|
|
133
|
+
/** Minted by this claim's own UPDATE; nulled when the claim ends. Compare it exactly to fence writes made under this claim. */
|
|
134
|
+
readonly claimToken: string
|
|
135
|
+
/** 1-based number of the attempt this claim starts. */
|
|
136
|
+
readonly attempt: number
|
|
137
|
+
readonly maxAttempts: number
|
|
138
|
+
/** Why the previous claim ended without a failure of its own — a reap or a hand-back — and the worker that held it; null otherwise. */
|
|
139
|
+
readonly previous: { readonly reason: 'reaped' | 'handed-back' | 'later'; readonly workerId: string | null } | null
|
|
140
|
+
readonly exclusion?: ClaimedExclusion
|
|
141
|
+
/** The Flow that enqueued this job; the worker surface restores its trace, tenant and actor. */
|
|
142
|
+
readonly flow: ClaimedFlow
|
|
143
|
+
/** The `liveKey` the job was enqueued with; null when none. */
|
|
144
|
+
readonly liveKey: string | null
|
|
145
|
+
/** The `pendingKey` the job was enqueued with; null when none. */
|
|
146
|
+
readonly pendingKey: string | null
|
|
147
|
+
/** The claims before this one, oldest first, each closed with its release time, reason and progress mark. */
|
|
148
|
+
readonly claims: readonly JobClaim[]
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** One job `reap` took from a lost worker. `attempt` is the 1-based number of the attempt that was lost. */
|
|
152
|
+
export interface ReapedJob {
|
|
153
|
+
readonly jobId: string
|
|
154
|
+
readonly type: string
|
|
155
|
+
readonly attempt: number
|
|
156
|
+
readonly outcome: 'retry' | 'dead_letter' | 'superseded'
|
|
157
|
+
readonly maxAttempts: number
|
|
158
|
+
readonly flow: ClaimedFlow
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** What `failAttempt` did: `outcome` and the error as stored in the row's `error_payload`. */
|
|
162
|
+
export interface FailedAttempt {
|
|
163
|
+
readonly outcome: 'retry' | 'dead_letter' | 'superseded'
|
|
164
|
+
readonly error: unknown
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export interface JobResult {
|
|
168
|
+
readonly status: 'completed' | 'failed' | 'cancelled' | 'timeout' | 'dead_letter'
|
|
169
|
+
readonly output?: unknown
|
|
170
|
+
readonly error?: unknown
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Jobs ready to claim, jobs claimed or running, and how long the oldest ready job has waited since it was queued (null when none is ready). */
|
|
174
|
+
export interface QueueTypeTotals {
|
|
175
|
+
readonly ready: number
|
|
176
|
+
readonly running: number
|
|
177
|
+
readonly oldestReadyWaitMs: number | null
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** The whole queue's totals; `byType` omits a type with nothing ready and nothing running. */
|
|
181
|
+
export interface QueueTotals {
|
|
182
|
+
readonly overall: QueueTypeTotals
|
|
183
|
+
readonly byType: Readonly<Record<string, QueueTypeTotals>>
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Job queue instance with claim/complete/fail lifecycle, LISTEN/NOTIFY, and cron scheduling. */
|
|
187
|
+
export interface Queue {
|
|
188
|
+
/** The queue table as a pipe.define() definition — `pipework generate` emits its DDL. */
|
|
189
|
+
readonly definition: DefinedTable
|
|
190
|
+
/**
|
|
191
|
+
* The framework door (`pipework.queue.<table>`) that claim, reap, heartbeat and complete need on a platform-mode
|
|
192
|
+
* database; null when the queue has no `exclusion.tenantColumn`, whose table is not row-secured by tenant.
|
|
193
|
+
*/
|
|
194
|
+
readonly door: string | null
|
|
195
|
+
enqueue(db: DB, opts: EnqueueOptions): Promise<string>
|
|
196
|
+
/** Claims the next eligible job (of one of `types`, when given), retrying transient conflicts until clean. The signal ends the retry loop on shutdown — claim resolves null promptly. */
|
|
197
|
+
claim(db: DB, workerId: string, signal?: AbortSignal, types?: readonly string[], release?: string | null): Promise<ClaimedJob | null>
|
|
198
|
+
/**
|
|
199
|
+
* Each of heartbeat, complete and fail takes the claim's token and applies only to that claim:
|
|
200
|
+
* once the row was reaped, handed back or claimed again they change nothing and return false.
|
|
201
|
+
* Without a token they match on the id alone.
|
|
202
|
+
*/
|
|
203
|
+
heartbeat(db: DB, jobId: string, claimToken?: string): Promise<boolean>
|
|
204
|
+
complete(db: DB, jobId: string, output?: unknown, claimToken?: string): Promise<boolean>
|
|
205
|
+
/** Counts the attempt as failed: retried with backoff while attempts remain, dead-lettered after. One transaction, row locked. */
|
|
206
|
+
fail(db: DB, jobId: string, error: unknown, claimToken?: string, opts?: { permanent?: boolean }): Promise<boolean>
|
|
207
|
+
/** `fail` that says what it did: null when the claim no longer holds the row, else the outcome and the stored error. */
|
|
208
|
+
failAttempt(db: DB, jobId: string, error: unknown, claimToken?: string, opts?: { permanent?: boolean }): Promise<FailedAttempt | null>
|
|
209
|
+
/** Returns a claimed job to pending after the error's `delayMs` (else the backoff, at least 1 s) without counting the attempt. False when the claim no longer holds the row. */
|
|
210
|
+
later(db: DB, jobId: string, error: JobLaterError, claimToken: string): Promise<boolean>
|
|
211
|
+
/** Returns a claimed job to pending without counting the attempt. False when the claim no longer holds the row. */
|
|
212
|
+
handBack(db: DB, jobId: string, claimToken: string): Promise<boolean>
|
|
213
|
+
/**
|
|
214
|
+
* Cancels a pending or claimed job; no-op on any other status. `error_payload` becomes exactly
|
|
215
|
+
* `{ reason, message }`, replacing what an earlier attempt left, and a held claim is closed 'cancelled'
|
|
216
|
+
* with the same object as its `cancel`. An empty `reason.reason` throws.
|
|
217
|
+
*/
|
|
218
|
+
cancel(db: DB, jobId: string, reason: { reason: string; message: string }): Promise<void>
|
|
219
|
+
/** For tests and support tools: makes pending jobs waiting on a retry time due now — the one job, or every one. Returns how many it changed. */
|
|
220
|
+
retryNow(db: DB, jobId?: string): Promise<number>
|
|
221
|
+
/** For tests and support tools: the Flow a job was enqueued under, by its id; null when no such job. */
|
|
222
|
+
flowOf(db: DB, jobId: string): Promise<ClaimedFlow | null>
|
|
223
|
+
/** Ready (pending and not waiting on a retry time), running (claimed or running) and the oldest ready job's wait, per job type and overall. One query. */
|
|
224
|
+
totals(db: DB): Promise<QueueTotals>
|
|
225
|
+
/** Fails every stale claimed job through the same retry/dead-letter rule as `fail`, and reports each. */
|
|
226
|
+
reap(db: DB): Promise<ReapedJob[]>
|
|
227
|
+
/**
|
|
228
|
+
* Registers a listener called, inside the transaction that dead-letters a job (by `fail` or `reap`), after the
|
|
229
|
+
* row is updated. A listener's throw is logged and rolled back to a savepoint; it never fails the dead-lettering.
|
|
230
|
+
*/
|
|
231
|
+
onDeadLetter(listener: (tx: DB, job: DeadLetterInfo) => Promise<void>): () => void
|
|
232
|
+
requeueDeadLetter(db: DB, jobId: string): Promise<void>
|
|
233
|
+
startListener(client: ListenClient): Promise<void>
|
|
234
|
+
stopListener(): Promise<void>
|
|
235
|
+
waitFor(db: DB, jobId: string, opts: WaitOptions): Promise<WaitResult>
|
|
236
|
+
enqueueAndWait(db: DB, opts: EnqueueOptions, waitOpts: WaitOptions): Promise<WaitResult>
|
|
237
|
+
schedule(db: DB, spec: ScheduleSpec): Promise<void>
|
|
238
|
+
/** Upserts every declared schedule and removes schedules named in `owned` that are not declared. Returns the removed names. */
|
|
239
|
+
syncSchedules(db: DB, declared: readonly ScheduleSpec[], owned: readonly string[]): Promise<string[]>
|
|
240
|
+
/** Test hook: enqueues the named schedule's job now, as a scheduler tick would, without moving its next run. Returns the job id. */
|
|
241
|
+
fireSchedule(db: DB, name: string): Promise<string>
|
|
242
|
+
/** Fires every due schedule once (one transaction each, so processes sharing a database fire it once between them). Returns the count fired. */
|
|
243
|
+
runDueSchedules(db: DB, names?: readonly string[]): Promise<number>
|
|
244
|
+
unschedule(db: DB, name: string): Promise<boolean>
|
|
245
|
+
listSchedules(db: DB): Promise<ScheduleInfo[]>
|
|
246
|
+
startScheduler(db: DB, opts?: SchedulerOptions): void
|
|
247
|
+
stopScheduler(): void
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Whether the error (or a cause in its chain) is the unique violation of the queue's live-key index. */
|
|
251
|
+
function isLiveKeyViolation(err: unknown, constraint: string): boolean {
|
|
252
|
+
let current: unknown = err
|
|
253
|
+
for (let depth = 0; depth < 4 && typeof current === 'object' && current !== null; depth++) {
|
|
254
|
+
const e = current as { code?: unknown; constraint_name?: unknown }
|
|
255
|
+
if (e.code === '23505' && e.constraint_name === constraint) return true
|
|
256
|
+
current = (current as { cause?: unknown }).cause
|
|
257
|
+
}
|
|
258
|
+
return false
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Serializes values into a Postgres array literal (`{"a","b"}`) — sent as one text param and cast to the element type. */
|
|
262
|
+
function pgArrayLiteral(values: readonly string[]): string {
|
|
263
|
+
const escaped = values.map(v => `"${v.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`)
|
|
264
|
+
return `{${escaped.join(',')}}`
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** The fields of a locked job row that the retry/dead-letter rule needs. */
|
|
268
|
+
interface JobRow {
|
|
269
|
+
readonly jobId: string
|
|
270
|
+
readonly type: string
|
|
271
|
+
readonly payload: unknown
|
|
272
|
+
readonly tenant: string | null
|
|
273
|
+
readonly attemptCount: number
|
|
274
|
+
readonly maxAttempts: number
|
|
275
|
+
readonly flow: ClaimedFlow
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function flowOf(row: Record<string, unknown>): ClaimedFlow {
|
|
279
|
+
return {
|
|
280
|
+
traceId: (row['flow_trace_id'] as string | null) ?? null,
|
|
281
|
+
rootId: (row['flow_root_id'] as string | null) ?? null,
|
|
282
|
+
stepId: (row['flow_step_id'] as string | null) ?? null,
|
|
283
|
+
tenant: (row['flow_tenant'] as string | null) ?? null,
|
|
284
|
+
actor: (row['flow_actor'] as string | null) ?? null,
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export function createQueue(config: QueueConfig = {}): Queue {
|
|
289
|
+
const table = config.table ?? 'pipework_jobs'
|
|
290
|
+
const staleTimeoutMs = config.staleTimeoutMs ?? 120_000
|
|
291
|
+
const channel = config.notifyChannel ?? 'pipework_job_complete'
|
|
292
|
+
const maxAttempts = config.maxAttempts ?? 1
|
|
293
|
+
const backoff = config.backoff
|
|
294
|
+
|
|
295
|
+
assertValidIdentifier(table, 'queue table')
|
|
296
|
+
assertValidIdentifier(channel, 'notify channel')
|
|
297
|
+
if (config.progressColumn !== undefined) assertValidIdentifier(config.progressColumn, 'queue progress column')
|
|
298
|
+
const progressQ = config.progressColumn === undefined ? null : quoteIdentifier(config.progressColumn, 'queue progress column')
|
|
299
|
+
registerProgressColumn(table, config.progressColumn)
|
|
300
|
+
|
|
301
|
+
const tableQ = quoteIdentifier(table, 'queue table')
|
|
302
|
+
const listener = new JobListener(channel, tableQ)
|
|
303
|
+
|
|
304
|
+
const deadLetterListeners = new Set<(tx: DB, job: DeadLetterInfo) => Promise<void>>()
|
|
305
|
+
|
|
306
|
+
const exclusion = resolveExclusion(config.exclusion)
|
|
307
|
+
const definition = queueDefinition(table, exclusion)
|
|
308
|
+
|
|
309
|
+
/** What the active Flow contributes to a job: its trace, open step, tenant and (on request) actor. */
|
|
310
|
+
function enqueuingFlow(opts: EnqueueOptions): ClaimedFlow {
|
|
311
|
+
const none: ClaimedFlow = { traceId: null, rootId: null, stepId: null, tenant: null, actor: null }
|
|
312
|
+
if (opts.continueFlow === false) return none
|
|
313
|
+
const flow = getContext()
|
|
314
|
+
if (flow === undefined) {
|
|
315
|
+
const parent = opts.parent ?? null
|
|
316
|
+
return {
|
|
317
|
+
...none,
|
|
318
|
+
traceId: parent?.id ?? null,
|
|
319
|
+
rootId: parent?.rootId ?? null,
|
|
320
|
+
stepId: parent?.stepId ?? null,
|
|
321
|
+
...(opts.tenant !== undefined ? { tenant: opts.tenant } : {}),
|
|
322
|
+
actor: opts.actor ?? null,
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const trace = flow.trace !== null && !flow.trace.buffer.truncated ? flow.trace : null
|
|
327
|
+
const parent = opts.parent !== undefined ? opts.parent : null
|
|
328
|
+
return {
|
|
329
|
+
traceId: opts.parent !== undefined ? (parent?.id ?? null) : (trace?.id ?? null),
|
|
330
|
+
rootId: opts.parent !== undefined ? (parent?.rootId ?? null) : (trace?.rootId ?? null),
|
|
331
|
+
stepId: opts.parent !== undefined ? (parent?.stepId ?? null) : (trace?.buffer.openStepId ?? null),
|
|
332
|
+
tenant: opts.tenant !== undefined ? opts.tenant : flow.tenant,
|
|
333
|
+
actor: opts.actor !== undefined ? opts.actor : opts.carryActor === true ? resolveActorId(flow) : null,
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** `AND flow_tenant = T` for a tenant-scoped caller, nothing otherwise. */
|
|
338
|
+
function tenantAnd(tenant: string | null): SQL {
|
|
339
|
+
return tenant === null ? sql`` : sql` AND flow_tenant = ${tenant}::text`
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Worker and cross-tenant ops refuse a tenant-scoped handle up front and run on the handle they were given. */
|
|
343
|
+
function crossTenant(db: DB, op: string): DB {
|
|
344
|
+
return requireUnscopedExecutor(db, `queue ${op}()`, `${op} acts across tenants on queue "${table}"`)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function foreignTenant(op: string, scoped: string, named: string, what: string): never {
|
|
348
|
+
throw new Error(
|
|
349
|
+
`[pipework] ${op}() on queue "${table}" was given a handle scoped to tenant "${scoped}" but ${what} "${named}".\n\n` +
|
|
350
|
+
' A tenant-scoped connection enqueues only its own tenant\'s jobs. Enqueue for another tenant\n' +
|
|
351
|
+
' from an unscoped handle (pipe.system(), or pool.getOrCreate(...).drizzle).\n',
|
|
352
|
+
)
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async function enqueue(handle: DB, opts: EnqueueOptions): Promise<string> {
|
|
356
|
+
return (await enqueueJob(handle, opts)).id
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** The tries an enqueue with a liveKey makes while the live job it conflicts with keeps finishing before it can be read. */
|
|
360
|
+
const LIVE_KEY_TRIES = 3
|
|
361
|
+
|
|
362
|
+
/** Enqueues, or with a liveKey returns the live job holding the key (`created: false`). */
|
|
363
|
+
async function enqueueJob(handle: DB, opts: EnqueueOptions): Promise<{ id: string; created: boolean }> {
|
|
364
|
+
if (opts.liveKey !== undefined && opts.liveKey === '') {
|
|
365
|
+
throw new Error(
|
|
366
|
+
`[pipework] enqueue() on queue "${table}" received an empty liveKey.\n\n` +
|
|
367
|
+
' An empty key would make every such job share one slot. Pass a non-empty string, or omit liveKey.\n',
|
|
368
|
+
)
|
|
369
|
+
}
|
|
370
|
+
if (opts.pendingKey !== undefined && opts.pendingKey === '') {
|
|
371
|
+
throw new Error(
|
|
372
|
+
`[pipework] enqueue() on queue "${table}" received an empty pendingKey.\n\n` +
|
|
373
|
+
' An empty key would make every such job share one slot. Pass a non-empty string, or omit pendingKey.\n',
|
|
374
|
+
)
|
|
375
|
+
}
|
|
376
|
+
if (opts.pendingKey !== undefined && opts.liveKey !== undefined) {
|
|
377
|
+
throw new Error(
|
|
378
|
+
`[pipework] enqueue() on queue "${table}" received both liveKey and pendingKey.\n\n` +
|
|
379
|
+
' liveKey returns the live job and keeps its payload; pendingKey replaces the pending job\'s payload. Pass one.\n',
|
|
380
|
+
)
|
|
381
|
+
}
|
|
382
|
+
const { db, tenant: scopedTenant } = unscopedExecutor(handle)
|
|
383
|
+
const priority = opts.priority ?? 0
|
|
384
|
+
const payload = JSON.stringify(opts.payload)
|
|
385
|
+
const attempts = opts.maxAttempts ?? maxAttempts
|
|
386
|
+
|
|
387
|
+
const columns = ['job_type', 'priority', 'payload', 'max_attempts']
|
|
388
|
+
const values: SQL[] = [sql`${opts.type}`, sql`${priority}`, sql`${payload}::jsonb`, sql`${attempts}`]
|
|
389
|
+
|
|
390
|
+
const flow: { -readonly [K in keyof ClaimedFlow]: ClaimedFlow[K] } = { ...enqueuingFlow(opts) }
|
|
391
|
+
if (scopedTenant !== null) {
|
|
392
|
+
if (flow.tenant !== null && flow.tenant !== scopedTenant) {
|
|
393
|
+
foreignTenant('enqueue', scopedTenant, flow.tenant, opts.tenant !== undefined ? 'opts.tenant names' : 'the flow carries tenant')
|
|
394
|
+
}
|
|
395
|
+
flow.tenant = scopedTenant
|
|
396
|
+
}
|
|
397
|
+
columns.push('flow_trace_id', 'flow_root_id', 'flow_step_id', 'flow_tenant', 'flow_actor')
|
|
398
|
+
values.push(
|
|
399
|
+
sql`${flow.traceId}::uuid`, sql`${flow.rootId}::uuid`, sql`${flow.stepId}::uuid`,
|
|
400
|
+
sql`${flow.tenant}::text`, sql`${flow.actor}::text`,
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
if (exclusion === undefined) {
|
|
404
|
+
if (opts.exclusion !== undefined) {
|
|
405
|
+
throw new Error(
|
|
406
|
+
`[pipework] enqueue() received exclusion values, but queue "${table}" has no exclusion config.\n\n` +
|
|
407
|
+
' Silently dropping them would enqueue a job without its conflict data.\n' +
|
|
408
|
+
' Configure the queue: createQueue({ exclusion: { arrayColumn: ..., blocksAllColumn: ..., tenantColumn: ... } }).\n',
|
|
409
|
+
)
|
|
410
|
+
}
|
|
411
|
+
} else {
|
|
412
|
+
const given = opts.exclusion ?? {}
|
|
413
|
+
if (given.array !== undefined && given.array !== null && exclusion.arrayColumn === undefined) {
|
|
414
|
+
throw new Error(
|
|
415
|
+
`[pipework] enqueue() received exclusion.array, but queue "${table}" configures no arrayColumn.\n\n` +
|
|
416
|
+
' Add arrayColumn to the queue exclusion config, or drop the value.\n',
|
|
417
|
+
)
|
|
418
|
+
}
|
|
419
|
+
if (given.blocksAll !== undefined && exclusion.blocksAllColumn === undefined) {
|
|
420
|
+
throw new Error(
|
|
421
|
+
`[pipework] enqueue() received exclusion.blocksAll, but queue "${table}" configures no blocksAllColumn.\n\n` +
|
|
422
|
+
' Add blocksAllColumn to the queue exclusion config, or drop the value.\n',
|
|
423
|
+
)
|
|
424
|
+
}
|
|
425
|
+
if (given.tenant !== undefined && exclusion.tenantColumn === undefined) {
|
|
426
|
+
throw new Error(
|
|
427
|
+
`[pipework] enqueue() received exclusion.tenant, but queue "${table}" configures no tenantColumn.\n\n` +
|
|
428
|
+
' Add tenantColumn to the queue exclusion config, or drop the value.\n',
|
|
429
|
+
)
|
|
430
|
+
}
|
|
431
|
+
if (exclusion.tenantColumn !== undefined) {
|
|
432
|
+
if (given.tenant === undefined) {
|
|
433
|
+
throw new Error(
|
|
434
|
+
`[pipework] enqueue() requires exclusion.tenant — queue "${table}" scopes exclusion by "${exclusion.tenantColumn}".\n\n` +
|
|
435
|
+
' Every job needs a tenant: enqueue(db, { ..., exclusion: { tenant: <id> } }).\n',
|
|
436
|
+
)
|
|
437
|
+
}
|
|
438
|
+
if (scopedTenant !== null && given.tenant !== scopedTenant) {
|
|
439
|
+
foreignTenant('enqueue', scopedTenant, String(given.tenant), 'exclusion.tenant names')
|
|
440
|
+
}
|
|
441
|
+
columns.push(quoteIdentifier(exclusion.tenantColumn, 'exclusion column'))
|
|
442
|
+
values.push(sql`${given.tenant}::uuid`)
|
|
443
|
+
}
|
|
444
|
+
if (exclusion.arrayColumn !== undefined && given.array !== undefined && given.array !== null) {
|
|
445
|
+
columns.push(quoteIdentifier(exclusion.arrayColumn, 'exclusion column'))
|
|
446
|
+
values.push(sql`${pgArrayLiteral(given.array)}::${sql.raw(exclusion.arrayElementType)}[]`)
|
|
447
|
+
}
|
|
448
|
+
if (exclusion.blocksAllColumn !== undefined && given.blocksAll !== undefined) {
|
|
449
|
+
columns.push(quoteIdentifier(exclusion.blocksAllColumn, 'exclusion column'))
|
|
450
|
+
values.push(sql`${given.blocksAll}`)
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if (opts.pendingKey !== undefined) {
|
|
455
|
+
columns.push('pending_key')
|
|
456
|
+
values.push(sql`${opts.pendingKey}::text`)
|
|
457
|
+
const pendingKey = opts.pendingKey
|
|
458
|
+
const rows = await db.transaction(async (tx) => {
|
|
459
|
+
await tx.execute(sql`SELECT ${pendingKeyLock(sql`${pendingKey}::text`, sql`coalesce(${flow.tenant}::text, '')`)}`)
|
|
460
|
+
return tx.execute(
|
|
461
|
+
sql`INSERT INTO ${sql.raw(tableQ)} (${sql.raw(columns.join(', '))})
|
|
462
|
+
VALUES (${sql.join(values, sql`, `)})
|
|
463
|
+
ON CONFLICT (pending_key, (${sql.raw(PENDING_KEY_TENANT_EXPR)})) WHERE ${sql.raw(PENDING_KEY_PREDICATE)}
|
|
464
|
+
DO UPDATE SET payload = EXCLUDED.payload
|
|
465
|
+
RETURNING id::text, (xmax = 0) AS created`
|
|
466
|
+
)
|
|
467
|
+
})
|
|
468
|
+
return { id: rows[0]!['id'] as string, created: rows[0]!['created'] === true }
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
if (opts.liveKey === undefined) {
|
|
472
|
+
const rows = await db.execute(
|
|
473
|
+
sql`INSERT INTO ${sql.raw(tableQ)} (${sql.raw(columns.join(', '))})
|
|
474
|
+
VALUES (${sql.join(values, sql`, `)})
|
|
475
|
+
RETURNING id::text`
|
|
476
|
+
)
|
|
477
|
+
return { id: rows[0]!['id'] as string, created: true }
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
columns.push('live_key')
|
|
481
|
+
values.push(sql`${opts.liveKey}::text`)
|
|
482
|
+
const insert = sql`INSERT INTO ${sql.raw(tableQ)} (${sql.raw(columns.join(', '))})
|
|
483
|
+
VALUES (${sql.join(values, sql`, `)})
|
|
484
|
+
ON CONFLICT (live_key, (${sql.raw(LIVE_KEY_TENANT_EXPR)})) WHERE ${sql.raw(LIVE_KEY_PREDICATE)} DO NOTHING
|
|
485
|
+
RETURNING id::text`
|
|
486
|
+
// A separate statement for the lookup: a CTE would share the insert's snapshot and could not see
|
|
487
|
+
// a conflicting row a concurrent enqueuer committed after it began.
|
|
488
|
+
const lookup = sql`SELECT id::text FROM ${sql.raw(tableQ)}
|
|
489
|
+
WHERE live_key = ${opts.liveKey}::text AND ${sql.raw(LIVE_KEY_TENANT_EXPR)} = coalesce(${flow.tenant}::text, '')
|
|
490
|
+
AND status IN ('pending', 'claimed', 'running')
|
|
491
|
+
LIMIT 1`
|
|
492
|
+
for (let attempt = 0; attempt < LIVE_KEY_TRIES; attempt++) {
|
|
493
|
+
const inserted = await db.execute(insert)
|
|
494
|
+
if (inserted.length > 0) return { id: inserted[0]!['id'] as string, created: true }
|
|
495
|
+
const live = await db.execute(lookup)
|
|
496
|
+
if (live.length > 0) return { id: live[0]!['id'] as string, created: false }
|
|
497
|
+
}
|
|
498
|
+
throw new Error(
|
|
499
|
+
`[pipework] enqueue() on queue "${table}" could not settle liveKey "${opts.liveKey}" after ${LIVE_KEY_TRIES} tries.\n\n` +
|
|
500
|
+
' Each try found the key held by a live job that finished before it could be read, then lost the\n' +
|
|
501
|
+
' insert again. Retry the enqueue.\n',
|
|
502
|
+
)
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function buildClaimQuery(workerId: string, types?: readonly string[], release?: string | null): SQL {
|
|
506
|
+
const typeClause = types !== undefined ? sql` AND candidate.job_type = ANY(${pgArrayLiteral(types)}::text[])` : sql``
|
|
507
|
+
const claimFilterClause = config.claimFilter
|
|
508
|
+
? ` AND (${config.claimFilter('candidate')})`
|
|
509
|
+
: ''
|
|
510
|
+
const exclusionClause = exclusion !== undefined
|
|
511
|
+
? ` AND ${exclusionClaimClause(tableQ, exclusion)}`
|
|
512
|
+
: ''
|
|
513
|
+
const exclusionReturning = exclusion !== undefined
|
|
514
|
+
? [exclusion.tenantColumn, exclusion.arrayColumn, exclusion.blocksAllColumn]
|
|
515
|
+
.filter((c): c is string => c !== undefined)
|
|
516
|
+
.map(c => `, ${quoteIdentifier(c, 'exclusion column')}`)
|
|
517
|
+
.join('')
|
|
518
|
+
: ''
|
|
519
|
+
|
|
520
|
+
return sql`
|
|
521
|
+
UPDATE ${sql.raw(tableQ)} SET
|
|
522
|
+
${openClaim(workerId, release ?? null)},
|
|
523
|
+
status = 'claimed',
|
|
524
|
+
claimed_at = now(),
|
|
525
|
+
heartbeat_at = now(),
|
|
526
|
+
worker_id = ${workerId},
|
|
527
|
+
worker_release = ${release ?? null},
|
|
528
|
+
claim_token = gen_random_uuid()
|
|
529
|
+
WHERE id = (
|
|
530
|
+
SELECT candidate.id FROM ${sql.raw(tableQ)} candidate
|
|
531
|
+
WHERE candidate.status = 'pending' AND (candidate.next_retry_at IS NULL OR candidate.next_retry_at <= now())${sql.raw(exclusionClause)}${sql.raw(claimFilterClause)}${typeClause}
|
|
532
|
+
ORDER BY candidate.priority DESC, candidate.queued_at ASC
|
|
533
|
+
FOR UPDATE SKIP LOCKED
|
|
534
|
+
LIMIT 1
|
|
535
|
+
)
|
|
536
|
+
RETURNING id::text, job_type, priority, payload, created_lsn::text, claim_token::text, attempt_count, max_attempts, error_payload->>'reason' AS previous_reason, error_payload->>'workerId' AS previous_worker_id, flow_trace_id::text, flow_root_id::text, flow_step_id::text, flow_tenant, flow_actor, live_key, pending_key, claims #- ARRAY[(jsonb_array_length(claims) - 1)::text] AS claims_before${sql.raw(exclusionReturning)}`
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function previousOf(row: Record<string, unknown>): ClaimedJob['previous'] {
|
|
540
|
+
const reason = row['previous_reason']
|
|
541
|
+
if (reason !== 'reaped' && reason !== 'handed-back' && reason !== 'later') return null
|
|
542
|
+
return { reason, workerId: (row['previous_worker_id'] as string | null | undefined) ?? null }
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function mapClaimedRow(row: Record<string, unknown>): ClaimedJob {
|
|
546
|
+
const base = {
|
|
547
|
+
id: row['id'] as string,
|
|
548
|
+
type: row['job_type'] as string,
|
|
549
|
+
priority: row['priority'] as number,
|
|
550
|
+
payload: row['payload'],
|
|
551
|
+
createdLsn: row['created_lsn'] as string,
|
|
552
|
+
claimToken: row['claim_token'] as string,
|
|
553
|
+
attempt: (row['attempt_count'] as number) + 1,
|
|
554
|
+
maxAttempts: row['max_attempts'] as number,
|
|
555
|
+
previous: previousOf(row),
|
|
556
|
+
claims: parseClaims(row['claims_before']),
|
|
557
|
+
liveKey: (row['live_key'] as string | null | undefined) ?? null,
|
|
558
|
+
pendingKey: (row['pending_key'] as string | null | undefined) ?? null,
|
|
559
|
+
flow: {
|
|
560
|
+
traceId: (row['flow_trace_id'] as string | null | undefined) ?? null,
|
|
561
|
+
rootId: (row['flow_root_id'] as string | null | undefined) ?? null,
|
|
562
|
+
stepId: (row['flow_step_id'] as string | null | undefined) ?? null,
|
|
563
|
+
tenant: (row['flow_tenant'] as string | null | undefined) ?? null,
|
|
564
|
+
actor: (row['flow_actor'] as string | null | undefined) ?? null,
|
|
565
|
+
},
|
|
566
|
+
}
|
|
567
|
+
if (exclusion === undefined) return base
|
|
568
|
+
return {
|
|
569
|
+
...base,
|
|
570
|
+
exclusion: {
|
|
571
|
+
array: exclusion.arrayColumn !== undefined ? (row[exclusion.arrayColumn] as string[] | null) : null,
|
|
572
|
+
blocksAll: exclusion.blocksAllColumn !== undefined ? (row[exclusion.blocksAllColumn] as boolean) : false,
|
|
573
|
+
tenant: exclusion.tenantColumn !== undefined ? (row[exclusion.tenantColumn] as string) : null,
|
|
574
|
+
},
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/** Full-jitter backoff for transient claim conflicts — identical schedules re-collide, jitter decorrelates the herd. */
|
|
579
|
+
function conflictDelay(attempt: number): number {
|
|
580
|
+
return Math.random() * Math.min(1000, 50 * 2 ** attempt)
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
async function claim(handle: DB, workerId: string, signal?: AbortSignal, types?: readonly string[], release?: string | null): Promise<ClaimedJob | null> {
|
|
584
|
+
const db = crossTenant(handle, 'claim')
|
|
585
|
+
const query = buildClaimQuery(workerId, types, release)
|
|
586
|
+
|
|
587
|
+
// Serialization failures (40001) carry Postgres's safe-retry guarantee:
|
|
588
|
+
// the conflicting transaction committed, so every abort means another
|
|
589
|
+
// claimer made progress and a retry cannot fail the same way again.
|
|
590
|
+
// Retrying until a clean attempt keeps the contract exact — null means
|
|
591
|
+
// "nothing claimable", never "gave up under contention". A fixed retry
|
|
592
|
+
// budget here starves non-conflicting claimers under sustained load.
|
|
593
|
+
// The loop's only other exit is shutdown: the signal cancels the backoff
|
|
594
|
+
// sleep and the next attempt, returning null — without it a worker stuck
|
|
595
|
+
// under sustained contention can never observe shutdown.
|
|
596
|
+
for (let attempt = 0; ; attempt++) {
|
|
597
|
+
if (signal?.aborted === true) return null
|
|
598
|
+
try {
|
|
599
|
+
// Two workers evaluating the exclusion NOT EXISTS checks against the
|
|
600
|
+
// same MVCC snapshot can both pass and claim conflicting jobs (write
|
|
601
|
+
// skew) — the exclusion claim query must run under SERIALIZABLE.
|
|
602
|
+
const rows = exclusion !== undefined
|
|
603
|
+
? await serializable(db, async (tx) => tx.execute(query), { maxAttempts: 1 })
|
|
604
|
+
: await db.execute(query)
|
|
605
|
+
if (rows.length === 0) return null
|
|
606
|
+
return mapClaimedRow(rows[0]!)
|
|
607
|
+
} catch (err: unknown) {
|
|
608
|
+
if (!isTransientConcurrencyError(err)) throw err
|
|
609
|
+
await abortableDelay(conflictDelay(attempt), signal)
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/** Rows a claim-scoped statement may touch: the claim's own token while it is still held. */
|
|
615
|
+
function claimScope(claimToken: string | undefined): SQL {
|
|
616
|
+
return claimToken === undefined
|
|
617
|
+
? sql``
|
|
618
|
+
: sql` AND claim_token = ${claimToken}::uuid AND status IN ('claimed', 'running')`
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
async function heartbeat(handle: DB, jobId: string, claimToken?: string): Promise<boolean> {
|
|
622
|
+
const db = crossTenant(handle, 'heartbeat')
|
|
623
|
+
const rows = await db.execute(
|
|
624
|
+
sql`UPDATE ${sql.raw(tableQ)} SET heartbeat_at = now() WHERE id = ${jobId}::uuid${claimScope(claimToken)} RETURNING id`
|
|
625
|
+
)
|
|
626
|
+
return rows.length > 0
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
async function complete(handle: DB, jobId: string, output?: unknown, claimToken?: string): Promise<boolean> {
|
|
630
|
+
const db = crossTenant(handle, 'complete')
|
|
631
|
+
const outputJson = output !== undefined ? JSON.stringify(output) : null
|
|
632
|
+
const rows = await db.execute(
|
|
633
|
+
sql`UPDATE ${sql.raw(tableQ)} SET ${closeClaim('completed', progressQ)}, status = 'completed', completed_at = now(), claim_token = NULL, output = ${outputJson !== null ? sql`${outputJson}::jsonb` : sql`NULL`}
|
|
634
|
+
WHERE id = ${jobId}::uuid${claimScope(claimToken)} RETURNING id`
|
|
635
|
+
)
|
|
636
|
+
if (rows.length === 0) return false
|
|
637
|
+
await db.execute(
|
|
638
|
+
sql`SELECT pg_notify(${channel}, ${jobId})`
|
|
639
|
+
)
|
|
640
|
+
return true
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function calculateDelay(attemptCount: number): number {
|
|
644
|
+
if (!backoff) return 0
|
|
645
|
+
if (backoff.type === 'fixed') return backoff.delayMs
|
|
646
|
+
const delay = backoff.delayMs * Math.pow(2, attemptCount)
|
|
647
|
+
return backoff.maxDelayMs !== undefined ? Math.min(delay, backoff.maxDelayMs) : delay
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/** The advisory lock an enqueue and a return-to-pending both take, per (table, pendingKey, tenant), before touching the index. */
|
|
651
|
+
function pendingKeyLock(key: SQL, tenant: SQL): SQL {
|
|
652
|
+
return sql`pg_advisory_xact_lock(hashtextextended(${table}::text || chr(31) || ${key} || chr(31) || ${tenant}, 0))`
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/** Takes the lock for a job's own key and tenant; nothing when the job has no pendingKey. */
|
|
656
|
+
async function lockPendingKeyOf(tx: DB, jobId: string): Promise<void> {
|
|
657
|
+
await tx.execute(
|
|
658
|
+
sql`SELECT ${pendingKeyLock(sql`pending_key`, sql`coalesce(flow_tenant, '')`)} FROM ${sql.raw(tableQ)}
|
|
659
|
+
WHERE id = ${jobId}::uuid AND pending_key IS NOT NULL`
|
|
660
|
+
)
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* The SET fragments that put a claimed job back to pending — unless a newer pending job holds its
|
|
665
|
+
* pendingKey, in which case the job is cancelled instead (claim closed as 'cancelled' with `cancel`
|
|
666
|
+
* `{ reason: 'superseded', message }`, error_payload `{ reason: 'superseded', by: <twin id> }`), so the twin with the newer payload is the one that runs.
|
|
667
|
+
* Decided inside the UPDATE, from the row as it is when the statement runs. Callers must hold
|
|
668
|
+
* `lockPendingKeyOf` for the row first, in the same transaction, so a racing enqueue cannot add a twin unseen.
|
|
669
|
+
*/
|
|
670
|
+
function returnToPending(reason: JobClaimReason, errorPayload: SQL): SQL {
|
|
671
|
+
const twinId = sql`(SELECT twin.id::text FROM ${sql.raw(tableQ)} AS twin
|
|
672
|
+
WHERE twin.pending_key = ${sql.raw(tableQ)}.pending_key
|
|
673
|
+
AND coalesce(twin.flow_tenant, '') = coalesce(${sql.raw(tableQ)}.flow_tenant, '')
|
|
674
|
+
AND twin.status = 'pending' AND twin.id <> ${sql.raw(tableQ)}.id
|
|
675
|
+
LIMIT 1)`
|
|
676
|
+
const superseded = sql`(pending_key IS NOT NULL AND ${twinId} IS NOT NULL)`
|
|
677
|
+
const supersededClaim = sql`CASE WHEN ${superseded}
|
|
678
|
+
THEN jsonb_build_object('cancel', jsonb_build_object('reason', 'superseded', 'message', 'superseded by ' || ${twinId}))
|
|
679
|
+
ELSE '{}'::jsonb END`
|
|
680
|
+
return sql`${closeClaim(sql`CASE WHEN ${superseded} THEN 'cancelled' ELSE ${reason}::text END`, progressQ, supersededClaim)},
|
|
681
|
+
status = CASE WHEN ${superseded} THEN 'cancelled' ELSE 'pending' END,
|
|
682
|
+
completed_at = CASE WHEN ${superseded} THEN now() ELSE completed_at END,
|
|
683
|
+
error_payload = CASE WHEN ${superseded} THEN jsonb_build_object('reason', 'superseded', 'by', ${twinId}) ELSE ${errorPayload} END`
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/** Wakes waitFor callers of a job `returnToPending` cancelled. */
|
|
687
|
+
async function notifyIfSuperseded(tx: DB, jobId: string, rows: readonly Record<string, unknown>[]): Promise<boolean> {
|
|
688
|
+
const superseded = rows[0]?.['status'] === 'cancelled'
|
|
689
|
+
if (superseded) await tx.execute(sql`SELECT pg_notify(${channel}, ${jobId})`)
|
|
690
|
+
return superseded
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* Counts the attempt as failed: back to pending with backoff while attempts
|
|
695
|
+
* remain, dead letter (and a notify) after. The row must already be locked by
|
|
696
|
+
* the caller's transaction. Shared by fail and reap so the two cannot drift.
|
|
697
|
+
*/
|
|
698
|
+
async function retryOrDeadLetter(
|
|
699
|
+
tx: DB, job: JobRow, errorJson: string, reason: 'failed' | 'reaped-stale', permanent = false,
|
|
700
|
+
): Promise<'retry' | 'dead_letter' | 'superseded'> {
|
|
701
|
+
const { jobId, attemptCount, maxAttempts: jobMaxAttempts } = job
|
|
702
|
+
const nextAttempt = attemptCount + 1
|
|
703
|
+
|
|
704
|
+
if (!permanent && nextAttempt < jobMaxAttempts) {
|
|
705
|
+
const delayMs = calculateDelay(attemptCount)
|
|
706
|
+
const delaySeconds = `${Math.ceil(delayMs / 1000)} seconds`
|
|
707
|
+
await lockPendingKeyOf(tx, jobId)
|
|
708
|
+
const rows = await tx.execute(
|
|
709
|
+
sql`UPDATE ${sql.raw(tableQ)} SET
|
|
710
|
+
${returnToPending(reason, sql`${errorJson}::jsonb`)},
|
|
711
|
+
attempt_count = ${nextAttempt},
|
|
712
|
+
next_retry_at = ${delayMs > 0 ? sql`now() + ${delaySeconds}::interval` : sql`NULL`},
|
|
713
|
+
claimed_at = NULL,
|
|
714
|
+
heartbeat_at = NULL,
|
|
715
|
+
worker_id = NULL,
|
|
716
|
+
claim_token = NULL
|
|
717
|
+
WHERE id = ${jobId}::uuid
|
|
718
|
+
RETURNING status`
|
|
719
|
+
)
|
|
720
|
+
return (await notifyIfSuperseded(tx, jobId, rows)) ? 'superseded' : 'retry'
|
|
721
|
+
}
|
|
722
|
+
await tx.execute(
|
|
723
|
+
sql`UPDATE ${sql.raw(tableQ)} SET
|
|
724
|
+
${closeClaim(reason, progressQ)},
|
|
725
|
+
status = 'dead_letter',
|
|
726
|
+
attempt_count = ${nextAttempt},
|
|
727
|
+
completed_at = now(),
|
|
728
|
+
claim_token = NULL,
|
|
729
|
+
error_payload = ${errorJson}::jsonb
|
|
730
|
+
WHERE id = ${jobId}::uuid`
|
|
731
|
+
)
|
|
732
|
+
await tx.execute(
|
|
733
|
+
sql`SELECT pg_notify(${channel}, ${jobId})`
|
|
734
|
+
)
|
|
735
|
+
for (const listener of deadLetterListeners) {
|
|
736
|
+
try {
|
|
737
|
+
await tx.transaction(async (savepoint) => {
|
|
738
|
+
await listener(savepoint, {
|
|
739
|
+
id: jobId, type: job.type, tenant: job.tenant, payload: job.payload, error: JSON.parse(errorJson) as unknown, flow: job.flow,
|
|
740
|
+
})
|
|
741
|
+
})
|
|
742
|
+
} catch (err) {
|
|
743
|
+
getBaseLogger().error({ err, jobId, jobType: job.type }, 'Dead-letter listener failed')
|
|
744
|
+
void recordFault('queue-listener', faultMessage(err), { jobId, jobType: job.type })
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
return 'dead_letter'
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
async function fail(db: DB, jobId: string, error: unknown, claimToken?: string, opts?: { permanent?: boolean }): Promise<boolean> {
|
|
751
|
+
crossTenant(db, 'fail')
|
|
752
|
+
return (await failAttempt(db, jobId, error, claimToken, opts)) !== null
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
async function failAttempt(
|
|
756
|
+
handle: DB, jobId: string, error: unknown, claimToken?: string, opts?: { permanent?: boolean },
|
|
757
|
+
): Promise<FailedAttempt | null> {
|
|
758
|
+
const db = crossTenant(handle, 'failAttempt')
|
|
759
|
+
const errorJson = JSON.stringify(serializeError(error))
|
|
760
|
+
|
|
761
|
+
return db.transaction(async (tx) => {
|
|
762
|
+
const jobRows = await tx.execute(
|
|
763
|
+
sql`SELECT job_type, payload, flow_tenant, attempt_count, max_attempts, flow_trace_id::text, flow_root_id::text,
|
|
764
|
+
flow_step_id::text, flow_actor FROM ${sql.raw(tableQ)} WHERE id = ${jobId}::uuid${claimScope(claimToken)} FOR UPDATE`
|
|
765
|
+
)
|
|
766
|
+
const job = jobRows[0]
|
|
767
|
+
if (!job) {
|
|
768
|
+
if (claimToken !== undefined) return null
|
|
769
|
+
throw new Error(`[pipework] Cannot fail job "${jobId}" — job not found.`)
|
|
770
|
+
}
|
|
771
|
+
const outcome = await retryOrDeadLetter(tx, {
|
|
772
|
+
jobId,
|
|
773
|
+
type: job['job_type'] as string,
|
|
774
|
+
payload: job['payload'],
|
|
775
|
+
tenant: (job['flow_tenant'] as string | null) ?? null,
|
|
776
|
+
attemptCount: job['attempt_count'] as number,
|
|
777
|
+
maxAttempts: job['max_attempts'] as number,
|
|
778
|
+
flow: flowOf(job),
|
|
779
|
+
}, errorJson, 'failed', opts?.permanent === true)
|
|
780
|
+
return { outcome, error: JSON.parse(errorJson) as unknown }
|
|
781
|
+
})
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
async function handBack(handle: DB, jobId: string, claimToken: string): Promise<boolean> {
|
|
785
|
+
const db = crossTenant(handle, 'handBack')
|
|
786
|
+
return db.transaction(async (tx) => {
|
|
787
|
+
await lockPendingKeyOf(tx, jobId)
|
|
788
|
+
const rows = await tx.execute(
|
|
789
|
+
sql`UPDATE ${sql.raw(tableQ)} SET
|
|
790
|
+
${returnToPending('shutdown-returned', sql`jsonb_build_object('reason', 'handed-back', 'workerId', worker_id)`)},
|
|
791
|
+
next_retry_at = NULL,
|
|
792
|
+
claimed_at = NULL,
|
|
793
|
+
heartbeat_at = NULL,
|
|
794
|
+
claim_token = NULL,
|
|
795
|
+
worker_id = NULL
|
|
796
|
+
WHERE id = ${jobId}::uuid${claimScope(claimToken)}
|
|
797
|
+
RETURNING status`
|
|
798
|
+
)
|
|
799
|
+
await notifyIfSuperseded(tx, jobId, rows)
|
|
800
|
+
return rows.length > 0
|
|
801
|
+
})
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
async function later(handle: DB, jobId: string, error: JobLaterError, claimToken: string): Promise<boolean> {
|
|
805
|
+
const db = crossTenant(handle, 'later')
|
|
806
|
+
return db.transaction(async (tx) => {
|
|
807
|
+
const found = await tx.execute(
|
|
808
|
+
sql`SELECT attempt_count FROM ${sql.raw(tableQ)} WHERE id = ${jobId}::uuid${claimScope(claimToken)} FOR UPDATE`
|
|
809
|
+
)
|
|
810
|
+
const row = found[0]
|
|
811
|
+
if (!row) return false
|
|
812
|
+
const delayMs = error.delayMs !== undefined
|
|
813
|
+
? Math.max(0, error.delayMs)
|
|
814
|
+
: Math.max(1000, calculateDelay(row['attempt_count'] as number))
|
|
815
|
+
await lockPendingKeyOf(tx, jobId)
|
|
816
|
+
const updated = await tx.execute(
|
|
817
|
+
sql`UPDATE ${sql.raw(tableQ)} SET
|
|
818
|
+
${returnToPending('later', sql`jsonb_build_object('reason', 'later', 'message', ${error.message}::text, 'workerId', worker_id)`)},
|
|
819
|
+
next_retry_at = now() + make_interval(secs => ${delayMs / 1000}::double precision),
|
|
820
|
+
claimed_at = NULL,
|
|
821
|
+
heartbeat_at = NULL,
|
|
822
|
+
claim_token = NULL,
|
|
823
|
+
worker_id = NULL
|
|
824
|
+
WHERE id = ${jobId}::uuid
|
|
825
|
+
RETURNING status`
|
|
826
|
+
)
|
|
827
|
+
await notifyIfSuperseded(tx, jobId, updated)
|
|
828
|
+
return true
|
|
829
|
+
})
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
async function cancel(handle: DB, jobId: string, why: { reason: string; message: string }): Promise<void> {
|
|
833
|
+
if (why.reason === '') {
|
|
834
|
+
throw new Error(
|
|
835
|
+
`[pipework] cancel() on queue "${table}" received an empty reason.\n\n` +
|
|
836
|
+
' The reason is stored in error_payload so a cancelled job says why. Pass a non-empty reason, e.g. { reason: "operator", message: "..." }.\n',
|
|
837
|
+
)
|
|
838
|
+
}
|
|
839
|
+
const { db, tenant } = unscopedExecutor(handle)
|
|
840
|
+
const cancel = sql`jsonb_build_object('reason', ${why.reason}::text, 'message', ${why.message}::text)`
|
|
841
|
+
await db.execute(
|
|
842
|
+
sql`UPDATE ${sql.raw(tableQ)} SET ${closeClaim('cancelled', progressQ, sql`jsonb_build_object('cancel', ${cancel})`)}, status = 'cancelled', completed_at = now(), claim_token = NULL,
|
|
843
|
+
error_payload = ${cancel}
|
|
844
|
+
WHERE id = ${jobId}::uuid AND status IN ('pending', 'claimed')${tenantAnd(tenant)}`
|
|
845
|
+
)
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
async function retryNow(handle: DB, jobId?: string): Promise<number> {
|
|
849
|
+
const { db, tenant } = unscopedExecutor(handle)
|
|
850
|
+
const rows = await db.execute(
|
|
851
|
+
sql`UPDATE ${sql.raw(tableQ)} SET next_retry_at = now()
|
|
852
|
+
WHERE status = 'pending' AND next_retry_at > now()${jobId === undefined ? sql`` : sql` AND id = ${jobId}::uuid`}${tenantAnd(tenant)}
|
|
853
|
+
RETURNING id`
|
|
854
|
+
)
|
|
855
|
+
return rows.length
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
async function flowOfJob(handle: DB, jobId: string): Promise<ClaimedFlow | null> {
|
|
859
|
+
const { db, tenant } = unscopedExecutor(handle)
|
|
860
|
+
const rows = await db.execute(
|
|
861
|
+
sql`SELECT flow_trace_id::text, flow_root_id::text, flow_step_id::text, flow_tenant, flow_actor
|
|
862
|
+
FROM ${sql.raw(tableQ)} WHERE id = ${jobId}::uuid${tenantAnd(tenant)}`
|
|
863
|
+
)
|
|
864
|
+
return rows.length === 0 ? null : flowOf(rows[0] as Record<string, unknown>)
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
async function totals(handle: DB): Promise<QueueTotals> {
|
|
868
|
+
const { db, tenant } = unscopedExecutor(handle)
|
|
869
|
+
const rows = await db.execute(
|
|
870
|
+
sql`SELECT job_type,
|
|
871
|
+
(count(*) FILTER (WHERE status = 'pending' AND (next_retry_at IS NULL OR next_retry_at <= clock_timestamp())))::int AS ready,
|
|
872
|
+
(count(*) FILTER (WHERE status IN ('claimed', 'running')))::int AS running,
|
|
873
|
+
(extract(epoch FROM clock_timestamp() - min(queued_at) FILTER (WHERE status = 'pending' AND (next_retry_at IS NULL OR next_retry_at <= clock_timestamp()))) * 1000)::bigint AS oldest_ms
|
|
874
|
+
FROM ${sql.raw(tableQ)}
|
|
875
|
+
WHERE status IN ('pending', 'claimed', 'running')${tenantAnd(tenant)}
|
|
876
|
+
GROUP BY GROUPING SETS ((job_type), ())`
|
|
877
|
+
)
|
|
878
|
+
let overall: QueueTypeTotals = { ready: 0, running: 0, oldestReadyWaitMs: null }
|
|
879
|
+
const byType: Record<string, QueueTypeTotals> = {}
|
|
880
|
+
for (const r of rows) {
|
|
881
|
+
const t: QueueTypeTotals = {
|
|
882
|
+
ready: Number(r['ready']),
|
|
883
|
+
running: Number(r['running']),
|
|
884
|
+
oldestReadyWaitMs: r['oldest_ms'] === null ? null : Number(r['oldest_ms']),
|
|
885
|
+
}
|
|
886
|
+
if (r['job_type'] === null) overall = t
|
|
887
|
+
else if (t.ready > 0 || t.running > 0) byType[String(r['job_type'])] = t
|
|
888
|
+
}
|
|
889
|
+
return { overall, byType }
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
async function reap(handle: DB): Promise<ReapedJob[]> {
|
|
893
|
+
const db = crossTenant(handle, 'reap')
|
|
894
|
+
const staleInterval = `${Math.floor(staleTimeoutMs / 1000)} seconds`
|
|
895
|
+
return db.transaction(async (tx) => {
|
|
896
|
+
const stale = await tx.execute(
|
|
897
|
+
sql`SELECT id::text, job_type, payload, attempt_count, max_attempts, worker_id, flow_trace_id::text, flow_root_id::text,
|
|
898
|
+
flow_step_id::text, flow_tenant, flow_actor
|
|
899
|
+
FROM ${sql.raw(tableQ)}
|
|
900
|
+
WHERE status IN ('claimed', 'running')
|
|
901
|
+
AND heartbeat_at < now() - ${staleInterval}::interval
|
|
902
|
+
FOR UPDATE SKIP LOCKED`
|
|
903
|
+
)
|
|
904
|
+
const reaped: ReapedJob[] = []
|
|
905
|
+
for (const row of stale) {
|
|
906
|
+
const id = row['id'] as string
|
|
907
|
+
const attemptCount = row['attempt_count'] as number
|
|
908
|
+
const jobMaxAttempts = row['max_attempts'] as number
|
|
909
|
+
const errorJson = JSON.stringify({
|
|
910
|
+
reason: 'reaped',
|
|
911
|
+
message: 'worker lost: heartbeat timeout',
|
|
912
|
+
workerId: (row['worker_id'] as string | null) ?? null,
|
|
913
|
+
})
|
|
914
|
+
const outcome = await retryOrDeadLetter(tx, {
|
|
915
|
+
jobId: id,
|
|
916
|
+
type: row['job_type'] as string,
|
|
917
|
+
payload: row['payload'],
|
|
918
|
+
tenant: (row['flow_tenant'] as string | null) ?? null,
|
|
919
|
+
attemptCount,
|
|
920
|
+
maxAttempts: jobMaxAttempts,
|
|
921
|
+
flow: flowOf(row),
|
|
922
|
+
}, errorJson, 'reaped-stale')
|
|
923
|
+
reaped.push({
|
|
924
|
+
jobId: id,
|
|
925
|
+
type: row['job_type'] as string,
|
|
926
|
+
attempt: attemptCount + 1,
|
|
927
|
+
outcome,
|
|
928
|
+
maxAttempts: jobMaxAttempts,
|
|
929
|
+
flow: flowOf(row),
|
|
930
|
+
})
|
|
931
|
+
}
|
|
932
|
+
return reaped
|
|
933
|
+
})
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
async function requeueDeadLetter(handle: DB, jobId: string): Promise<void> {
|
|
937
|
+
const { db, tenant } = unscopedExecutor(handle)
|
|
938
|
+
let rows: readonly Record<string, unknown>[]
|
|
939
|
+
try {
|
|
940
|
+
rows = await db.execute(
|
|
941
|
+
sql`UPDATE ${sql.raw(tableQ)} SET
|
|
942
|
+
status = 'pending', attempt_count = 0, max_attempts = ${maxAttempts},
|
|
943
|
+
completed_at = NULL, next_retry_at = NULL, claimed_at = NULL,
|
|
944
|
+
heartbeat_at = NULL, worker_id = NULL, claim_token = NULL
|
|
945
|
+
WHERE id = ${jobId}::uuid AND status = 'dead_letter'${tenantAnd(tenant)}
|
|
946
|
+
RETURNING id`
|
|
947
|
+
)
|
|
948
|
+
} catch (err) {
|
|
949
|
+
if (isLiveKeyViolation(err, pendingKeyIndexName(table))) {
|
|
950
|
+
const twin = await db.execute(
|
|
951
|
+
sql`SELECT t.pending_key, (SELECT p.id::text FROM ${sql.raw(tableQ)} p
|
|
952
|
+
WHERE p.pending_key = t.pending_key AND coalesce(p.flow_tenant, '') = coalesce(t.flow_tenant, '') AND p.status = 'pending' LIMIT 1) AS twin
|
|
953
|
+
FROM ${sql.raw(tableQ)} t WHERE t.id = ${jobId}::uuid`
|
|
954
|
+
)
|
|
955
|
+
throw new Error(
|
|
956
|
+
`[pipework] Cannot requeue job "${jobId}" — pending job "${String(twin[0]?.['twin'])}" already holds its pendingKey "${String(twin[0]?.['pending_key'])}".\n\n` +
|
|
957
|
+
' Requeueing would make a second pending job with the same key. Let the pending job run or cancel it, then requeue.\n',
|
|
958
|
+
{ cause: err },
|
|
959
|
+
)
|
|
960
|
+
}
|
|
961
|
+
if (!isLiveKeyViolation(err, liveKeyIndexName(table))) throw err
|
|
962
|
+
const held = await db.execute(sql`SELECT live_key FROM ${sql.raw(tableQ)} WHERE id = ${jobId}::uuid`)
|
|
963
|
+
throw new Error(
|
|
964
|
+
`[pipework] Cannot requeue job "${jobId}" — a live job already holds its liveKey "${String(held[0]?.['live_key'])}".\n\n` +
|
|
965
|
+
' Requeueing would make a second live job with the same key. Let the live job finish or cancel it, then requeue.\n',
|
|
966
|
+
{ cause: err },
|
|
967
|
+
)
|
|
968
|
+
}
|
|
969
|
+
if (rows.length === 0) {
|
|
970
|
+
throw new Error(`[pipework] Cannot requeue job "${jobId}" — job not found or not in dead_letter status.`)
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
async function startListener(client: ListenClient): Promise<void> {
|
|
975
|
+
await listener.start(client)
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
async function stopListener(): Promise<void> {
|
|
979
|
+
await listener.stop()
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
async function waitFor(handle: DB, jobId: string, opts: WaitOptions): Promise<WaitResult> {
|
|
983
|
+
const { db, tenant } = unscopedExecutor(handle)
|
|
984
|
+
return listener.waitFor(db, jobId, opts, tenant)
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
async function enqueueAndWait(db: DB, opts: EnqueueOptions, waitOpts: WaitOptions): Promise<WaitResult> {
|
|
988
|
+
const jobId = await enqueue(db, opts)
|
|
989
|
+
return waitFor(db, jobId, waitOpts)
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
const sched = createScheduler({ enqueue, enqueueJob }, tableQ)
|
|
993
|
+
|
|
994
|
+
return {
|
|
995
|
+
definition,
|
|
996
|
+
door: exclusion?.tenantColumn !== undefined ? queueDoorName(table) : null,
|
|
997
|
+
onDeadLetter(listener) {
|
|
998
|
+
deadLetterListeners.add(listener)
|
|
999
|
+
return () => { deadLetterListeners.delete(listener) }
|
|
1000
|
+
},
|
|
1001
|
+
enqueue, claim, heartbeat, complete, fail, failAttempt, later, handBack, cancel, retryNow, flowOf: flowOfJob, totals, reap, requeueDeadLetter,
|
|
1002
|
+
startListener, stopListener, waitFor, enqueueAndWait,
|
|
1003
|
+
schedule: (db, spec) => sched.schedule(crossTenant(db, 'schedule'), spec),
|
|
1004
|
+
syncSchedules: (db, declared, owned) => sched.sync(crossTenant(db, 'syncSchedules'), declared, owned),
|
|
1005
|
+
fireSchedule: (db, name) => sched.fire(crossTenant(db, 'fireSchedule'), name),
|
|
1006
|
+
runDueSchedules: (db, names) => sched.tick(crossTenant(db, 'runDueSchedules'), names),
|
|
1007
|
+
unschedule: (db, name) => sched.unschedule(crossTenant(db, 'unschedule'), name),
|
|
1008
|
+
listSchedules: (db) => sched.listSchedules(crossTenant(db, 'listSchedules')),
|
|
1009
|
+
startScheduler: (db, opts) => sched.start(crossTenant(db, 'startScheduler'), opts),
|
|
1010
|
+
stopScheduler: () => sched.stop(),
|
|
1011
|
+
}
|
|
1012
|
+
}
|