@cosmicdrift/kumiko-framework 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +159 -0
- package/package.json +91 -0
- package/src/__tests__/anonymous-access.integration.ts +325 -0
- package/src/__tests__/error-contract.integration.ts +435 -0
- package/src/__tests__/field-access.integration.ts +269 -0
- package/src/__tests__/full-stack.integration.ts +914 -0
- package/src/__tests__/ownership.integration.ts +449 -0
- package/src/__tests__/reference-data.integration.ts +198 -0
- package/src/__tests__/transition-guard.integration.ts +340 -0
- package/src/api/__tests__/api.test.ts +337 -0
- package/src/api/__tests__/auth-middleware-transport.test.ts +80 -0
- package/src/api/__tests__/auth-routes-cookie.test.ts +179 -0
- package/src/api/__tests__/batch.integration.ts +404 -0
- package/src/api/__tests__/body-limit.test.ts +88 -0
- package/src/api/__tests__/csrf-middleware.test.ts +97 -0
- package/src/api/__tests__/dispatcher-live.integration.ts +216 -0
- package/src/api/__tests__/metrics-endpoint.test.ts +126 -0
- package/src/api/__tests__/nested-write.integration.ts +213 -0
- package/src/api/__tests__/readiness.test.ts +76 -0
- package/src/api/__tests__/request-id-middleware.test.ts +72 -0
- package/src/api/__tests__/sse-broker.test.ts +58 -0
- package/src/api/__tests__/sse-route.test.ts +112 -0
- package/src/api/anonymous-cookie.ts +60 -0
- package/src/api/api-constants.ts +64 -0
- package/src/api/auth-middleware.ts +418 -0
- package/src/api/auth-routes.ts +982 -0
- package/src/api/csrf-middleware.ts +77 -0
- package/src/api/index.ts +31 -0
- package/src/api/jwt.ts +66 -0
- package/src/api/observability-middleware.ts +89 -0
- package/src/api/readiness.ts +132 -0
- package/src/api/request-context.ts +49 -0
- package/src/api/request-id-middleware.ts +50 -0
- package/src/api/route-registrars.ts +195 -0
- package/src/api/routes.ts +135 -0
- package/src/api/server.ts +640 -0
- package/src/api/sse-broker.ts +71 -0
- package/src/api/sse-route.ts +62 -0
- package/src/api/tokens.ts +16 -0
- package/src/db/__tests__/apply-entity-event-tenant.integration.ts +159 -0
- package/src/db/__tests__/compound-types.test.ts +114 -0
- package/src/db/__tests__/connection-options.test.ts +68 -0
- package/src/db/__tests__/cursor.test.ts +41 -0
- package/src/db/__tests__/db-helpers.test.ts +369 -0
- package/src/db/__tests__/dialect-instant.test.ts +50 -0
- package/src/db/__tests__/drizzle-helpers.integration.ts +186 -0
- package/src/db/__tests__/drizzle-table-types.test.ts +162 -0
- package/src/db/__tests__/encryption.test.ts +39 -0
- package/src/db/__tests__/event-store-executor-list.integration.ts +313 -0
- package/src/db/__tests__/event-store-executor.integration.ts +235 -0
- package/src/db/__tests__/implicit-projection-equivalence.integration.ts +304 -0
- package/src/db/__tests__/located-timestamp.test.ts +184 -0
- package/src/db/__tests__/money.test.ts +199 -0
- package/src/db/__tests__/multi-row-insert.integration.ts +76 -0
- package/src/db/__tests__/parse-auto-verb.test.ts +70 -0
- package/src/db/__tests__/required-not-null-migration-safety.integration.ts +105 -0
- package/src/db/__tests__/row-helpers.test.ts +59 -0
- package/src/db/__tests__/schema-migration.integration.ts +273 -0
- package/src/db/__tests__/table-builder-indexes.test.ts +153 -0
- package/src/db/__tests__/table-builder-required.test.ts +216 -0
- package/src/db/__tests__/tenant-db.integration.ts +606 -0
- package/src/db/__tests__/unique-violation-mapping.integration.ts +166 -0
- package/src/db/apply-entity-event.ts +188 -0
- package/src/db/assert-exists-in.ts +59 -0
- package/src/db/compound-types.ts +47 -0
- package/src/db/connection.ts +104 -0
- package/src/db/cursor.ts +83 -0
- package/src/db/dialect.ts +109 -0
- package/src/db/eagerload.ts +174 -0
- package/src/db/encryption.ts +39 -0
- package/src/db/event-store-executor.ts +906 -0
- package/src/db/index.ts +55 -0
- package/src/db/located-timestamp.ts +114 -0
- package/src/db/money.ts +120 -0
- package/src/db/pg-error.ts +46 -0
- package/src/db/reference-data.ts +77 -0
- package/src/db/row-helpers.ts +53 -0
- package/src/db/schema-inspection.ts +25 -0
- package/src/db/table-builder.ts +475 -0
- package/src/db/tenant-db.ts +434 -0
- package/src/engine/__tests__/auth-claims-registrar.test.ts +74 -0
- package/src/engine/__tests__/boot-validator-located-timestamps.test.ts +108 -0
- package/src/engine/__tests__/boot-validator.test.ts +1865 -0
- package/src/engine/__tests__/build-app-schema.test.ts +154 -0
- package/src/engine/__tests__/claim-keys.test.ts +274 -0
- package/src/engine/__tests__/config-helpers.test.ts +236 -0
- package/src/engine/__tests__/effective-features.test.ts +86 -0
- package/src/engine/__tests__/engine.test.ts +1461 -0
- package/src/engine/__tests__/entity-handlers.test.ts +274 -0
- package/src/engine/__tests__/event-helpers.test.ts +68 -0
- package/src/engine/__tests__/extends-registrar.test.ts +159 -0
- package/src/engine/__tests__/factories-long-text.test.ts +84 -0
- package/src/engine/__tests__/factories-time.test.ts +158 -0
- package/src/engine/__tests__/field-predicates.test.ts +48 -0
- package/src/engine/__tests__/hook-phases.test.ts +132 -0
- package/src/engine/__tests__/identifiers.test.ts +35 -0
- package/src/engine/__tests__/lifecycle-hooks.test.ts +237 -0
- package/src/engine/__tests__/nav.test.ts +267 -0
- package/src/engine/__tests__/ownership.test.ts +421 -0
- package/src/engine/__tests__/parse-ref-target.test.ts +43 -0
- package/src/engine/__tests__/projection-helpers.test.ts +62 -0
- package/src/engine/__tests__/projection.test.ts +191 -0
- package/src/engine/__tests__/qualified-name.test.ts +264 -0
- package/src/engine/__tests__/resolve-config-or-param.test.ts +315 -0
- package/src/engine/__tests__/run-in.test.ts +38 -0
- package/src/engine/__tests__/schema-builder.test.ts +380 -0
- package/src/engine/__tests__/screen.test.ts +408 -0
- package/src/engine/__tests__/state-machine.test.ts +148 -0
- package/src/engine/__tests__/system-user.test.ts +57 -0
- package/src/engine/__tests__/validation-hooks.test.ts +71 -0
- package/src/engine/access.ts +23 -0
- package/src/engine/boot-validator.ts +1528 -0
- package/src/engine/build-app-schema.ts +125 -0
- package/src/engine/config-helpers.ts +115 -0
- package/src/engine/constants.ts +85 -0
- package/src/engine/create-app.ts +98 -0
- package/src/engine/define-feature.ts +702 -0
- package/src/engine/define-handler.ts +78 -0
- package/src/engine/define-roles.ts +19 -0
- package/src/engine/effective-features.ts +87 -0
- package/src/engine/entity-handlers.ts +364 -0
- package/src/engine/event-helpers.ts +73 -0
- package/src/engine/factories.ts +328 -0
- package/src/engine/feature-ast/__tests__/canonical-form.test.ts +416 -0
- package/src/engine/feature-ast/__tests__/parse-happy-path.test.ts +197 -0
- package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +128 -0
- package/src/engine/feature-ast/__tests__/parse.test.ts +888 -0
- package/src/engine/feature-ast/__tests__/patch.test.ts +360 -0
- package/src/engine/feature-ast/__tests__/patcher.test.ts +469 -0
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +287 -0
- package/src/engine/feature-ast/extractors.ts +2562 -0
- package/src/engine/feature-ast/index.ts +105 -0
- package/src/engine/feature-ast/parse.ts +369 -0
- package/src/engine/feature-ast/patch.ts +525 -0
- package/src/engine/feature-ast/patcher.ts +518 -0
- package/src/engine/feature-ast/patterns.ts +434 -0
- package/src/engine/feature-ast/render.ts +602 -0
- package/src/engine/feature-ast/source-location.ts +45 -0
- package/src/engine/field-access.ts +120 -0
- package/src/engine/index.ts +254 -0
- package/src/engine/ownership.ts +337 -0
- package/src/engine/parse-ref-target.ts +22 -0
- package/src/engine/pattern-library/__tests__/library.test.ts +351 -0
- package/src/engine/pattern-library/index.ts +24 -0
- package/src/engine/pattern-library/library.ts +1117 -0
- package/src/engine/pattern-library/types.ts +255 -0
- package/src/engine/projection-helpers.ts +85 -0
- package/src/engine/qualified-name.ts +122 -0
- package/src/engine/read-claim.ts +31 -0
- package/src/engine/registry.ts +1325 -0
- package/src/engine/resolve-config-or-param.ts +153 -0
- package/src/engine/run-in.ts +29 -0
- package/src/engine/schema-builder.ts +175 -0
- package/src/engine/screen-filter-ops.ts +51 -0
- package/src/engine/state-machine.ts +70 -0
- package/src/engine/system-user.ts +32 -0
- package/src/engine/types/config.ts +306 -0
- package/src/engine/types/event-type-map.ts +37 -0
- package/src/engine/types/feature.ts +574 -0
- package/src/engine/types/fields.ts +422 -0
- package/src/engine/types/handlers.ts +742 -0
- package/src/engine/types/hooks.ts +142 -0
- package/src/engine/types/http-route.ts +54 -0
- package/src/engine/types/identifiers.ts +47 -0
- package/src/engine/types/index.ts +208 -0
- package/src/engine/types/nav.ts +46 -0
- package/src/engine/types/projection.ts +132 -0
- package/src/engine/types/relations.ts +51 -0
- package/src/engine/types/screen.ts +452 -0
- package/src/engine/types/workspace.ts +42 -0
- package/src/engine/validation.ts +33 -0
- package/src/entrypoint/__tests__/entrypoint-job-wiring.integration.ts +173 -0
- package/src/entrypoint/__tests__/split-deploy.integration.ts +297 -0
- package/src/entrypoint/index.ts +442 -0
- package/src/errors/__tests__/classes.test.ts +371 -0
- package/src/errors/__tests__/write-failures.test.ts +109 -0
- package/src/errors/classes.ts +249 -0
- package/src/errors/i18n/de.yaml +83 -0
- package/src/errors/i18n/en.yaml +80 -0
- package/src/errors/index.ts +41 -0
- package/src/errors/kumiko-error.ts +67 -0
- package/src/errors/reasons.ts +36 -0
- package/src/errors/serialize.ts +136 -0
- package/src/errors/transition-details.ts +30 -0
- package/src/errors/write-error-info.ts +123 -0
- package/src/errors/zod-bridge.ts +49 -0
- package/src/event-store/__tests__/admin-api.integration.ts +361 -0
- package/src/event-store/__tests__/event-store.integration.ts +584 -0
- package/src/event-store/__tests__/get-stream-version-perf.integration.ts +83 -0
- package/src/event-store/__tests__/perf.integration.ts +255 -0
- package/src/event-store/__tests__/snapshot.integration.ts +267 -0
- package/src/event-store/__tests__/upcaster-dead-letter.integration.ts +204 -0
- package/src/event-store/__tests__/upcaster.integration.ts +460 -0
- package/src/event-store/admin-api.ts +257 -0
- package/src/event-store/archive.ts +106 -0
- package/src/event-store/errors.ts +35 -0
- package/src/event-store/event-store.ts +405 -0
- package/src/event-store/events-schema.ts +90 -0
- package/src/event-store/index.ts +50 -0
- package/src/event-store/snapshot.ts +210 -0
- package/src/event-store/upcaster-dead-letter.ts +119 -0
- package/src/event-store/upcaster.ts +147 -0
- package/src/files/__tests__/content-disposition.test.ts +123 -0
- package/src/files/__tests__/file-field-column.integration.ts +103 -0
- package/src/files/__tests__/file-field-pipeline.integration.ts +211 -0
- package/src/files/__tests__/file-handle.test.ts +122 -0
- package/src/files/__tests__/files.integration.ts +830 -0
- package/src/files/__tests__/storage-tracking.integration.ts +153 -0
- package/src/files/content-disposition.ts +55 -0
- package/src/files/file-handle.ts +63 -0
- package/src/files/file-ref-table.ts +22 -0
- package/src/files/file-routes.ts +353 -0
- package/src/files/in-memory-provider.ts +62 -0
- package/src/files/index.ts +29 -0
- package/src/files/local-provider.ts +35 -0
- package/src/files/storage-tracking.ts +60 -0
- package/src/files/types.ts +118 -0
- package/src/i18n/__tests__/i18n.test.ts +72 -0
- package/src/i18n/index.ts +29 -0
- package/src/jobs/__tests__/job-event-trigger.integration.ts +172 -0
- package/src/jobs/__tests__/job-multi-trigger.integration.ts +144 -0
- package/src/jobs/__tests__/jobs.integration.ts +566 -0
- package/src/jobs/index.ts +2 -0
- package/src/jobs/job-runner.ts +574 -0
- package/src/lifecycle/__tests__/create-test-lifecycle.ts +19 -0
- package/src/lifecycle/__tests__/lifecycle-server.integration.ts +108 -0
- package/src/lifecycle/__tests__/lifecycle.test.ts +212 -0
- package/src/lifecycle/__tests__/signal-handlers.test.ts +106 -0
- package/src/lifecycle/index.ts +13 -0
- package/src/lifecycle/lifecycle.ts +160 -0
- package/src/lifecycle/signal-handlers.ts +62 -0
- package/src/logging/__tests__/pino-trace-bridge.test.ts +50 -0
- package/src/logging/index.ts +3 -0
- package/src/logging/pino-logger.ts +64 -0
- package/src/logging/types.ts +7 -0
- package/src/migrations/__tests__/compare-snapshots.test.ts +150 -0
- package/src/migrations/__tests__/detect-drift.integration.ts +320 -0
- package/src/migrations/__tests__/detect-projections-to-rebuild.integration.ts +134 -0
- package/src/migrations/__tests__/rebuild-marker.test.ts +79 -0
- package/src/migrations/index.ts +28 -0
- package/src/migrations/projection-detection.ts +149 -0
- package/src/migrations/rebuild-marker.ts +64 -0
- package/src/migrations/schema-drift.ts +395 -0
- package/src/observability/__tests__/console-provider.test.ts +67 -0
- package/src/observability/__tests__/metric-validator.test.ts +87 -0
- package/src/observability/__tests__/noop-provider.test.ts +82 -0
- package/src/observability/__tests__/observability.integration.ts +559 -0
- package/src/observability/__tests__/prometheus-meter.test.ts +144 -0
- package/src/observability/__tests__/recording-meter.test.ts +101 -0
- package/src/observability/__tests__/recording-tracer.test.ts +110 -0
- package/src/observability/__tests__/sensitive-filter.test.ts +98 -0
- package/src/observability/console-provider.ts +130 -0
- package/src/observability/context.ts +26 -0
- package/src/observability/fallback.ts +34 -0
- package/src/observability/ids.ts +25 -0
- package/src/observability/index.ts +79 -0
- package/src/observability/metric-validator.ts +86 -0
- package/src/observability/metrics-handle.ts +56 -0
- package/src/observability/noop-provider.ts +146 -0
- package/src/observability/prometheus-meter.ts +284 -0
- package/src/observability/recording-meter.ts +156 -0
- package/src/observability/recording-tracer.ts +198 -0
- package/src/observability/redis-wrapper.ts +132 -0
- package/src/observability/sensitive-filter.ts +108 -0
- package/src/observability/standard-metrics.ts +213 -0
- package/src/observability/types/index.ts +29 -0
- package/src/observability/types/metric.ts +56 -0
- package/src/observability/types/provider.ts +32 -0
- package/src/observability/types/span.ts +64 -0
- package/src/pipeline/__tests__/archive-stream.integration.ts +220 -0
- package/src/pipeline/__tests__/auth-claims-resolver.test.ts +279 -0
- package/src/pipeline/__tests__/cascade-handler.integration.ts +419 -0
- package/src/pipeline/__tests__/cascade-handler.test.ts +52 -0
- package/src/pipeline/__tests__/causation-chain.integration.ts +206 -0
- package/src/pipeline/__tests__/ctx-bridge.integration.ts +234 -0
- package/src/pipeline/__tests__/dispatcher.test.ts +379 -0
- package/src/pipeline/__tests__/distributed-lock.integration.ts +67 -0
- package/src/pipeline/__tests__/domain-events-projections.integration.ts +323 -0
- package/src/pipeline/__tests__/event-dedup.integration.ts +153 -0
- package/src/pipeline/__tests__/event-define-event-strict.integration.ts +202 -0
- package/src/pipeline/__tests__/event-dispatcher-lifecycle.integration.ts +220 -0
- package/src/pipeline/__tests__/event-dispatcher-multi-instance.integration.ts +423 -0
- package/src/pipeline/__tests__/event-dispatcher-pg-listen.integration.ts +123 -0
- package/src/pipeline/__tests__/event-dispatcher-recovery.integration.ts +202 -0
- package/src/pipeline/__tests__/event-dispatcher-second-audit.integration.ts +290 -0
- package/src/pipeline/__tests__/event-dispatcher-strict.test.ts +65 -0
- package/src/pipeline/__tests__/event-dispatcher.integration.ts +287 -0
- package/src/pipeline/__tests__/event-retention.integration.ts +239 -0
- package/src/pipeline/__tests__/fetch-for-writing.integration.ts +281 -0
- package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +430 -0
- package/src/pipeline/__tests__/load-aggregate-query.integration.ts +266 -0
- package/src/pipeline/__tests__/msp-error-mode.integration.ts +149 -0
- package/src/pipeline/__tests__/msp-multi-hop.integration.ts +228 -0
- package/src/pipeline/__tests__/msp-rebuild.integration.ts +368 -0
- package/src/pipeline/__tests__/multi-stream-projection.integration.ts +341 -0
- package/src/pipeline/__tests__/perf-rebuild.integration.ts +147 -0
- package/src/pipeline/__tests__/projection-rebuild.integration.ts +551 -0
- package/src/pipeline/__tests__/query-projection.integration.ts +201 -0
- package/src/pipeline/__tests__/redis-pipeline.integration.ts +306 -0
- package/src/pipeline/append-event-core.ts +117 -0
- package/src/pipeline/auth-claims-resolver.ts +103 -0
- package/src/pipeline/cascade-handler.ts +113 -0
- package/src/pipeline/dispatcher.ts +1585 -0
- package/src/pipeline/distributed-lock.ts +37 -0
- package/src/pipeline/entity-cache.ts +113 -0
- package/src/pipeline/event-consumer-state.ts +108 -0
- package/src/pipeline/event-dedup.ts +23 -0
- package/src/pipeline/event-dispatcher.ts +1016 -0
- package/src/pipeline/event-retention.ts +154 -0
- package/src/pipeline/idempotency.ts +76 -0
- package/src/pipeline/index.ts +66 -0
- package/src/pipeline/lifecycle-pipeline.ts +409 -0
- package/src/pipeline/msp-rebuild.ts +242 -0
- package/src/pipeline/multi-stream-apply-context.ts +115 -0
- package/src/pipeline/projection-rebuild.ts +334 -0
- package/src/pipeline/projection-state.ts +72 -0
- package/src/pipeline/projections-runner.ts +56 -0
- package/src/pipeline/redis-keys.ts +11 -0
- package/src/pipeline/system-hooks.ts +190 -0
- package/src/random/__tests__/generate.test.ts +149 -0
- package/src/random/generate.ts +141 -0
- package/src/random/index.ts +8 -0
- package/src/random/words.ts +392 -0
- package/src/rate-limit/__tests__/dispatcher-l3.integration.ts +111 -0
- package/src/rate-limit/__tests__/middleware.integration.ts +189 -0
- package/src/rate-limit/__tests__/resolver.integration.ts +189 -0
- package/src/rate-limit/bucket.ts +36 -0
- package/src/rate-limit/index.ts +14 -0
- package/src/rate-limit/middleware.ts +152 -0
- package/src/rate-limit/resolver.ts +267 -0
- package/src/redis/__tests__/redis-options.test.ts +54 -0
- package/src/redis/index.ts +74 -0
- package/src/search/__tests__/meilisearch-adapter.integration.ts +236 -0
- package/src/search/__tests__/search-adapter.test.ts +256 -0
- package/src/search/in-memory-adapter.ts +123 -0
- package/src/search/index.ts +12 -0
- package/src/search/meilisearch-adapter.ts +106 -0
- package/src/search/types.ts +39 -0
- package/src/secrets/__tests__/dek-cache.test.ts +213 -0
- package/src/secrets/__tests__/env-master-key-provider.test.ts +119 -0
- package/src/secrets/__tests__/envelope.test.ts +74 -0
- package/src/secrets/__tests__/leak-guard.test.ts +92 -0
- package/src/secrets/__tests__/rotation.test.ts +149 -0
- package/src/secrets/dek-cache.ts +116 -0
- package/src/secrets/env-master-key-provider.ts +162 -0
- package/src/secrets/envelope.ts +55 -0
- package/src/secrets/index.ts +19 -0
- package/src/secrets/leak-guard.ts +87 -0
- package/src/secrets/rotation.ts +34 -0
- package/src/secrets/types.ts +107 -0
- package/src/stack/db.ts +104 -0
- package/src/stack/event-collector.ts +23 -0
- package/src/stack/index.ts +32 -0
- package/src/stack/redis.ts +44 -0
- package/src/stack/request-helper.ts +168 -0
- package/src/stack/table-helpers.ts +104 -0
- package/src/stack/test-stack.ts +357 -0
- package/src/stack/test-users.ts +37 -0
- package/src/testing/__tests__/e2e-generator.test.ts +230 -0
- package/src/testing/__tests__/ensure-entity-table.integration.ts +54 -0
- package/src/testing/access-assertions.ts +15 -0
- package/src/testing/assertions.ts +35 -0
- package/src/testing/e2e-generator.ts +465 -0
- package/src/testing/expect-error.ts +25 -0
- package/src/testing/handler-context.ts +125 -0
- package/src/testing/http-cookies.ts +52 -0
- package/src/testing/index.ts +41 -0
- package/src/testing/late-bound.ts +39 -0
- package/src/testing/mutable-master-key-provider.ts +31 -0
- package/src/testing/observability-recorder.ts +54 -0
- package/src/testing/shared-entities.ts +49 -0
- package/src/testing/utils.ts +1 -0
- package/src/testing/wait-for.ts +31 -0
- package/src/time/__tests__/polyfill.test.ts +73 -0
- package/src/time/__tests__/tz-context.test.ts +121 -0
- package/src/time/index.ts +21 -0
- package/src/time/polyfill.ts +70 -0
- package/src/time/tz-context.ts +107 -0
- package/src/ui-types/app-schema.ts +57 -0
- package/src/ui-types/index.ts +65 -0
- package/src/utils/__tests__/assert.test.ts +17 -0
- package/src/utils/__tests__/env-parse.test.ts +54 -0
- package/src/utils/assert.ts +18 -0
- package/src/utils/env-parse.ts +16 -0
- package/src/utils/ids.ts +16 -0
- package/src/utils/index.ts +5 -0
- package/src/utils/safe-json.ts +30 -0
- package/src/utils/serialization.ts +7 -0
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
import { type Job, Queue, Worker } from "bullmq";
|
|
2
|
+
import { Redis } from "ioredis";
|
|
3
|
+
import { requestContext } from "../api/request-context";
|
|
4
|
+
import type { DbRow } from "../db/connection";
|
|
5
|
+
import { createSystemUser } from "../engine/system-user";
|
|
6
|
+
import {
|
|
7
|
+
type AppContext,
|
|
8
|
+
type JobRunIn,
|
|
9
|
+
type Registry,
|
|
10
|
+
type SessionUser,
|
|
11
|
+
SYSTEM_TENANT_ID,
|
|
12
|
+
} from "../engine/types";
|
|
13
|
+
import type { Logger } from "../logging/types";
|
|
14
|
+
import { getFallbackTracer, type SerializedTraceContext, type Tracer } from "../observability";
|
|
15
|
+
import { createDistributedLock, type DistributedLock } from "../pipeline/distributed-lock";
|
|
16
|
+
import { RedisKeys } from "../pipeline/redis-keys";
|
|
17
|
+
|
|
18
|
+
// Queue-name convention: <prefix>-<lane>. The prefix is fixed in prod
|
|
19
|
+
// ("kumiko-jobs") — it must match between enqueuers and consumers, and an
|
|
20
|
+
// accidental drift would silently drop jobs. Tests override via
|
|
21
|
+
// `queueNamePrefix` for per-run isolation (stale jobs from a prior run
|
|
22
|
+
// don't leak into a new test because the queue name includes a timestamp).
|
|
23
|
+
const DEFAULT_QUEUE_NAME_PREFIX = "kumiko-jobs";
|
|
24
|
+
function queueNameFor(prefix: string, lane: JobRunIn): string {
|
|
25
|
+
return `${prefix}-${lane}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type JobLogEntry = {
|
|
29
|
+
level: "info" | "warn" | "error";
|
|
30
|
+
message: string;
|
|
31
|
+
timestamp: Temporal.Instant;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function createJobLogger(logs: JobLogEntry[]): Logger {
|
|
35
|
+
function push(level: "info" | "warn" | "error", msg: string, data?: Record<string, unknown>) {
|
|
36
|
+
const message = data ? `${msg} ${JSON.stringify(data)}` : msg;
|
|
37
|
+
logs.push({ level, message, timestamp: Temporal.Now.instant() });
|
|
38
|
+
}
|
|
39
|
+
const logger: Logger = {
|
|
40
|
+
info(msg, data) {
|
|
41
|
+
push("info", msg, data);
|
|
42
|
+
},
|
|
43
|
+
warn(msg, data) {
|
|
44
|
+
push("warn", msg, data);
|
|
45
|
+
},
|
|
46
|
+
error(msg, data) {
|
|
47
|
+
push("error", msg, data);
|
|
48
|
+
},
|
|
49
|
+
debug() {},
|
|
50
|
+
child() {
|
|
51
|
+
return logger;
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
return logger;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type JobMeta = {
|
|
58
|
+
triggeredById?: string | undefined;
|
|
59
|
+
payload?: string | undefined;
|
|
60
|
+
// BullMQ numbers retries from 1 upward; the logger threads this into
|
|
61
|
+
// the run-started event so audit queries can distinguish "fresh run" vs.
|
|
62
|
+
// "nth retry" without joining back to BullMQ-internals.
|
|
63
|
+
attempt?: number | undefined;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export type JobRunner = {
|
|
67
|
+
start(): Promise<void>;
|
|
68
|
+
stop(): Promise<void>;
|
|
69
|
+
dispatch(jobName: string, payload?: Record<string, unknown>, meta?: JobMeta): Promise<string>;
|
|
70
|
+
handleEvent(
|
|
71
|
+
eventName: string,
|
|
72
|
+
payload: Record<string, unknown>,
|
|
73
|
+
user?: SessionUser,
|
|
74
|
+
): Promise<void>;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export type JobRunnerOptions = {
|
|
78
|
+
registry: Registry;
|
|
79
|
+
context: AppContext;
|
|
80
|
+
redisUrl: string;
|
|
81
|
+
// Which lane this runner CONSUMES — i.e. starts a BullMQ worker for and
|
|
82
|
+
// schedules cron/boot jobs on. Undefined = enqueuer-only: the runner
|
|
83
|
+
// still holds queue-clients for BOTH lanes so dispatch()/handleEvent()
|
|
84
|
+
// can enqueue jobs destined for either lane, but no BullMQ worker is
|
|
85
|
+
// started and no cron schedules fire. API processes that don't
|
|
86
|
+
// runLocalJobs leave this unset; worker processes set "worker"; api-
|
|
87
|
+
// processes with runLocalJobs set "api".
|
|
88
|
+
consumerLane?: JobRunIn | undefined;
|
|
89
|
+
// Override the queue-name prefix. Prod uses the default ("kumiko-jobs").
|
|
90
|
+
// Tests set a unique prefix (e.g. `"test-${Date.now()}"`) for isolation —
|
|
91
|
+
// two parallel test-runners never see each other's jobs.
|
|
92
|
+
queueNamePrefix?: string | undefined;
|
|
93
|
+
getActiveTenantIds?: () => Promise<number[]>;
|
|
94
|
+
onJobStart?: (jobName: string, jobId: string, meta: JobMeta) => void;
|
|
95
|
+
onJobComplete?: (jobName: string, jobId: string, duration: number, logs: JobLogEntry[]) => void;
|
|
96
|
+
onJobFailed?: (jobName: string, jobId: string, error: string, logs: JobLogEntry[]) => void;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// Serialized trace context lives under this key in the BullMQ job data.
|
|
100
|
+
// Leading underscore matches the existing internal-meta convention
|
|
101
|
+
// (_triggeredById, _tenantId, _payload).
|
|
102
|
+
const TRACE_CONTEXT_KEY = "_traceContext";
|
|
103
|
+
|
|
104
|
+
function readTraceContext(data: Record<string, unknown>): SerializedTraceContext | undefined {
|
|
105
|
+
const raw = data[TRACE_CONTEXT_KEY];
|
|
106
|
+
if (!raw || typeof raw !== "object") return undefined;
|
|
107
|
+
const ctx = raw as Partial<SerializedTraceContext>;
|
|
108
|
+
if (!ctx.traceId || !ctx.spanId) return undefined;
|
|
109
|
+
return { traceId: ctx.traceId, spanId: ctx.spanId };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function captureTraceContext(tracer: Tracer): SerializedTraceContext | undefined {
|
|
113
|
+
const span = tracer.getActiveSpan();
|
|
114
|
+
if (!span?.traceId) return undefined;
|
|
115
|
+
return { traceId: span.traceId, spanId: span.spanId };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function parseRedisOpts(url: string): { host: string; port: number; db?: number | undefined } {
|
|
119
|
+
const parsed = new URL(url);
|
|
120
|
+
const result: { host: string; port: number; db?: number | undefined } = {
|
|
121
|
+
host: parsed.hostname,
|
|
122
|
+
port: Number(parsed.port) || 6379,
|
|
123
|
+
};
|
|
124
|
+
if (parsed.pathname.length > 1) {
|
|
125
|
+
result.db = Number(parsed.pathname.slice(1));
|
|
126
|
+
}
|
|
127
|
+
return result;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
131
|
+
const { registry, context, redisUrl, consumerLane } = options;
|
|
132
|
+
const queueNamePrefix = options.queueNamePrefix ?? DEFAULT_QUEUE_NAME_PREFIX;
|
|
133
|
+
const redisOpts = parseRedisOpts(redisUrl);
|
|
134
|
+
// Use the context's tracer when present (observability-provider injected at
|
|
135
|
+
// boot); otherwise noop so dispatch/handleJob stay zero-cost without config.
|
|
136
|
+
const tracer: Tracer = context.tracer ?? getFallbackTracer();
|
|
137
|
+
|
|
138
|
+
const allJobs = registry.getAllJobs();
|
|
139
|
+
|
|
140
|
+
// Resolve the lane for a job — "worker" is the default because that's the
|
|
141
|
+
// sensible prod lane (heavy async off the request path). Jobs that opted
|
|
142
|
+
// into "api" must have been validated at registry boot already.
|
|
143
|
+
function laneForJob(def: { readonly runIn?: JobRunIn | undefined }): JobRunIn {
|
|
144
|
+
return def.runIn ?? "worker";
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Sequential coordination: BullMQ OSS has no `group`, so we serialise
|
|
148
|
+
// same-name jobs ourselves with a per-name Redis lock. Only built when at
|
|
149
|
+
// least one job actually requested it — keeps the no-sequential boot path
|
|
150
|
+
// free of the extra Redis client. Scoped under the consumer lane so two
|
|
151
|
+
// runners on different lanes cannot collide on the same lock-key for
|
|
152
|
+
// unrelated jobs.
|
|
153
|
+
const hasSequential = [...allJobs.values()].some((def) => def.concurrency === "sequential");
|
|
154
|
+
let lockRedis: Redis | null = null;
|
|
155
|
+
let sequentialLock: DistributedLock | null = null;
|
|
156
|
+
if (hasSequential) {
|
|
157
|
+
lockRedis = new Redis(redisOpts);
|
|
158
|
+
const lockScope = consumerLane ?? "enqueue";
|
|
159
|
+
sequentialLock = createDistributedLock(lockRedis, `${RedisKeys.lock}seq:${lockScope}:`);
|
|
160
|
+
}
|
|
161
|
+
// Default lock-TTL for sequential jobs that didn't declare a timeout.
|
|
162
|
+
// 5 minutes matches BullMQ's default stalledInterval — long enough for
|
|
163
|
+
// any reasonable handler, short enough that a crashed worker recovers
|
|
164
|
+
// without manual intervention.
|
|
165
|
+
const SEQUENTIAL_DEFAULT_TTL_SEC = 305;
|
|
166
|
+
// How long to wait before re-trying a busy sequential lock. Short enough
|
|
167
|
+
// to feel responsive, long enough that we don't hammer Redis.
|
|
168
|
+
const SEQUENTIAL_RETRY_DELAY_MS = 200;
|
|
169
|
+
|
|
170
|
+
// Two queue-clients — one per lane. Every runner holds both, regardless of
|
|
171
|
+
// its own consumerLane, so dispatch()/handleEvent() always write to the
|
|
172
|
+
// queue matching the target job's runIn. Client-creation is cheap (shared
|
|
173
|
+
// ioredis connection via bullmq), so this doesn't scale with number of
|
|
174
|
+
// processes.
|
|
175
|
+
const queues: Readonly<Record<JobRunIn, Queue>> = {
|
|
176
|
+
api: new Queue(queueNameFor(queueNamePrefix, "api"), { connection: redisOpts }),
|
|
177
|
+
worker: new Queue(queueNameFor(queueNamePrefix, "worker"), { connection: redisOpts }),
|
|
178
|
+
};
|
|
179
|
+
let worker: Worker | null = null;
|
|
180
|
+
|
|
181
|
+
// Counts active + waiting jobs with this name for this tenant across
|
|
182
|
+
// BOTH lane queues. Jobs with the same name should only live in one
|
|
183
|
+
// lane (jobDef.runIn is static), but walking both is cheap and avoids
|
|
184
|
+
// a subtle bug if someone ever reassigns a job to a different lane
|
|
185
|
+
// between deploys while old queue contents are still draining.
|
|
186
|
+
async function isOverPerTenantLimit(
|
|
187
|
+
jobName: string,
|
|
188
|
+
tenantId: string,
|
|
189
|
+
max: number,
|
|
190
|
+
): Promise<boolean> {
|
|
191
|
+
const results = await Promise.all([
|
|
192
|
+
queues.api.getActive(),
|
|
193
|
+
queues.api.getWaiting(),
|
|
194
|
+
queues.worker.getActive(),
|
|
195
|
+
queues.worker.getWaiting(),
|
|
196
|
+
]);
|
|
197
|
+
let count = 0;
|
|
198
|
+
for (const list of results) {
|
|
199
|
+
for (const j of list) {
|
|
200
|
+
if (j.name !== jobName) continue;
|
|
201
|
+
const t = (j.data as { _tenantId?: string } | undefined)?._tenantId;
|
|
202
|
+
if (t === tenantId) {
|
|
203
|
+
count += 1;
|
|
204
|
+
if (count >= max) return true;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function handleJob(bullJob: Job): Promise<void> {
|
|
212
|
+
const rawName = bullJob.name;
|
|
213
|
+
|
|
214
|
+
// Handle perTenant dispatch jobs — fan out to one job per tenant. The
|
|
215
|
+
// fan-out re-enqueues into the lane the actual job is assigned to;
|
|
216
|
+
// the _perTenant wrapper itself always lives in the consumer-lane
|
|
217
|
+
// (it's picked up by this runner's own worker).
|
|
218
|
+
if (rawName.startsWith("_perTenant:")) {
|
|
219
|
+
const actualName = rawName.slice("_perTenant:".length);
|
|
220
|
+
if (!options.getActiveTenantIds) {
|
|
221
|
+
throw new Error(`perTenant job "${actualName}" requires getActiveTenantIds option`);
|
|
222
|
+
}
|
|
223
|
+
const actualDef = allJobs.get(actualName);
|
|
224
|
+
if (!actualDef) {
|
|
225
|
+
throw new Error(`Unknown job: ${actualName}`);
|
|
226
|
+
}
|
|
227
|
+
const tenantIds = await options.getActiveTenantIds();
|
|
228
|
+
const targetQueue = queues[laneForJob(actualDef)];
|
|
229
|
+
for (const tenantId of tenantIds) {
|
|
230
|
+
await targetQueue.add(actualName, { ...bullJob.data, _tenantId: tenantId });
|
|
231
|
+
}
|
|
232
|
+
// skip: fan-out dispatcher job, per-tenant children enqueued
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const jobName = rawName;
|
|
237
|
+
const jobDef = allJobs.get(jobName);
|
|
238
|
+
if (!jobDef) {
|
|
239
|
+
throw new Error(`Unknown job: ${jobName}`);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Sequential gate: try to claim the per-name lock. If another worker
|
|
243
|
+
// (or this worker on a different bullJob) holds it, re-enqueue with a
|
|
244
|
+
// small delay and exit *successfully* — using throw would burn the
|
|
245
|
+
// job's retry budget and pollute failure metrics, but a re-enqueue
|
|
246
|
+
// looks like an ordinary handoff to BullMQ.
|
|
247
|
+
let sequentialToken: string | null = null;
|
|
248
|
+
if (jobDef.concurrency === "sequential" && sequentialLock) {
|
|
249
|
+
const ttlSec = jobDef.timeout
|
|
250
|
+
? Math.ceil(jobDef.timeout / 1000) + 5
|
|
251
|
+
: SEQUENTIAL_DEFAULT_TTL_SEC;
|
|
252
|
+
sequentialToken = await sequentialLock.acquire(jobName, { ttlSeconds: ttlSec });
|
|
253
|
+
if (!sequentialToken) {
|
|
254
|
+
// Re-enqueue onto the job's own lane-queue. In practice that's the
|
|
255
|
+
// same queue the worker just picked from (since only the consuming
|
|
256
|
+
// lane runs handleJob at all), but route explicitly — no implicit
|
|
257
|
+
// coupling to "whichever queue the caller happened to be on".
|
|
258
|
+
await queues[laneForJob(jobDef)].add(jobName, bullJob.data, {
|
|
259
|
+
delay: SEQUENTIAL_RETRY_DELAY_MS,
|
|
260
|
+
});
|
|
261
|
+
// skip: lock taken, work re-enqueued with delay, current invocation done
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const jobId = bullJob.id ?? "unknown";
|
|
267
|
+
const startTime = Date.now();
|
|
268
|
+
const logs: JobLogEntry[] = [];
|
|
269
|
+
|
|
270
|
+
// Extract meta from job data. `attempt` is BullMQ's own counter
|
|
271
|
+
// (1-based on the first run, incremented on each retry) — threading
|
|
272
|
+
// it through lets the logger tag the run-started event with the
|
|
273
|
+
// retry number, so audit queries distinguish fresh from retry runs
|
|
274
|
+
// without peeking at BullMQ internals.
|
|
275
|
+
const rawData = bullJob.data as DbRow;
|
|
276
|
+
const meta: JobMeta = {
|
|
277
|
+
triggeredById: rawData["_triggeredById"] as string | undefined,
|
|
278
|
+
payload: rawData["_payload"] as string | undefined,
|
|
279
|
+
attempt: bullJob.attemptsMade + 1,
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
// Build handler payload (without internal meta fields)
|
|
283
|
+
const payload: Record<string, unknown> = {};
|
|
284
|
+
for (const [k, v] of Object.entries(rawData)) {
|
|
285
|
+
if (!k.startsWith("_")) payload[k] = v;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Determine tenantId and triggeredBy from meta
|
|
289
|
+
const tenantId =
|
|
290
|
+
(rawData["_tenantId"] as string | undefined) ??
|
|
291
|
+
(payload["tenantId"] as string | undefined) ??
|
|
292
|
+
SYSTEM_TENANT_ID;
|
|
293
|
+
const triggeredById = (rawData["_triggeredById"] as string | undefined) ?? null;
|
|
294
|
+
|
|
295
|
+
// _triggerName aus rawData übernehmen falls gesetzt — handleEvent
|
|
296
|
+
// packt das beim Multi-Trigger-Dispatch rein (siehe unten). Über
|
|
297
|
+
// jobContext.triggerName freigegeben damit der Handler nicht selbst
|
|
298
|
+
// im rohen Payload kramen muss.
|
|
299
|
+
const triggerName = rawData["_triggerName"] as string | undefined;
|
|
300
|
+
const jobContext: AppContext = {
|
|
301
|
+
...context,
|
|
302
|
+
systemUser: createSystemUser(tenantId),
|
|
303
|
+
triggeredBy: triggeredById !== null ? { id: triggeredById, tenantId } : null,
|
|
304
|
+
log: createJobLogger(logs),
|
|
305
|
+
...(triggerName !== undefined && { triggerName }),
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
await options.onJobStart?.(jobName, jobId, meta);
|
|
309
|
+
|
|
310
|
+
// Cross-process trace continuation: if the enqueuing code captured a
|
|
311
|
+
// parent span, start the job.execute span as its child. Works for event
|
|
312
|
+
// and manual triggers; cron jobs start a fresh root span.
|
|
313
|
+
const parentContext = readTraceContext(rawData);
|
|
314
|
+
|
|
315
|
+
// Correlation propagation: the scheduling request's correlationId was
|
|
316
|
+
// packed into _correlationId at dispatch time. Re-enter requestContext.run
|
|
317
|
+
// so event writes during this job stamp the same correlation as the
|
|
318
|
+
// request that scheduled it. Cron/boot jobs (no scheduler) start fresh
|
|
319
|
+
// — correlationId = new requestId, no parent causation.
|
|
320
|
+
const inheritedCorrelationId = (rawData["_correlationId"] as string | undefined) ?? undefined;
|
|
321
|
+
const jobRequestId = requestContext.generateId();
|
|
322
|
+
const jobCorrelationId = inheritedCorrelationId ?? jobRequestId;
|
|
323
|
+
|
|
324
|
+
const runInSpan = async (): Promise<void> => {
|
|
325
|
+
try {
|
|
326
|
+
await requestContext.run({ requestId: jobRequestId, correlationId: jobCorrelationId }, () =>
|
|
327
|
+
jobDef.handler(payload, jobContext),
|
|
328
|
+
);
|
|
329
|
+
const duration = Date.now() - startTime;
|
|
330
|
+
await options.onJobComplete?.(jobName, jobId, duration, logs);
|
|
331
|
+
} catch (err) {
|
|
332
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
333
|
+
logs.push({ level: "error", message: errorMsg, timestamp: Temporal.Now.instant() });
|
|
334
|
+
await options.onJobFailed?.(jobName, jobId, errorMsg, logs);
|
|
335
|
+
throw err;
|
|
336
|
+
}
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
// Unified span creation: withSpan handles start/end + status/exception
|
|
340
|
+
// recording identically for both parent-context and no-parent paths.
|
|
341
|
+
// When parentContext is set, the new parent-aware StartSpanOptions
|
|
342
|
+
// plumbs it through to startSpan — no manual try/finally needed.
|
|
343
|
+
try {
|
|
344
|
+
await tracer.withSpan(
|
|
345
|
+
"job.execute",
|
|
346
|
+
{
|
|
347
|
+
attributes: {
|
|
348
|
+
"job.name": jobName,
|
|
349
|
+
"job.id": jobId,
|
|
350
|
+
"job.attempt": bullJob.attemptsMade + 1,
|
|
351
|
+
"kumiko.tenant_id": tenantId,
|
|
352
|
+
// Lane-routing attributes (Welle 2.6). `run_in` is the job's
|
|
353
|
+
// declared lane (explicit or default-"worker"); `consumer_lane`
|
|
354
|
+
// is which runner actually executed it. They diverge in
|
|
355
|
+
// all-in-one (both lanes live in one process) but must match
|
|
356
|
+
// in split deploys — a mismatch in prod logs signals a
|
|
357
|
+
// misrouted job that slipped past the boot-validator.
|
|
358
|
+
"kumiko.job.run_in": laneForJob(jobDef),
|
|
359
|
+
// Omit attribute entirely when no consumer (enqueuer-only runner) —
|
|
360
|
+
// SpanAttributeValue doesn't accept undefined.
|
|
361
|
+
...(consumerLane !== undefined ? { "kumiko.job.consumer_lane": consumerLane } : {}),
|
|
362
|
+
},
|
|
363
|
+
...(parentContext ? { parent: parentContext } : {}),
|
|
364
|
+
},
|
|
365
|
+
runInSpan,
|
|
366
|
+
);
|
|
367
|
+
} finally {
|
|
368
|
+
// Release the sequential lock value-matched (Lua compare-and-delete
|
|
369
|
+
// inside DistributedLock). A TTL-expired lock that's been claimed by
|
|
370
|
+
// a different owner stays put — releasing it would break sequencing
|
|
371
|
+
// for the new owner.
|
|
372
|
+
if (sequentialToken && sequentialLock) {
|
|
373
|
+
await sequentialLock.release(jobName, sequentialToken);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
return {
|
|
379
|
+
async start(): Promise<void> {
|
|
380
|
+
// skip: enqueuer-only runner — no BullMQ worker, no cron schedules,
|
|
381
|
+
// no boot jobs. The API-process (runLocalJobs=false) lands here; it
|
|
382
|
+
// still holds the queue-clients so dispatch()/handleEvent() can
|
|
383
|
+
// target the worker-lane queue, but nothing local consumes.
|
|
384
|
+
if (!consumerLane) {
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const consumerQueue = queues[consumerLane];
|
|
389
|
+
worker = new Worker(queueNameFor(queueNamePrefix, consumerLane), handleJob, {
|
|
390
|
+
connection: redisOpts,
|
|
391
|
+
concurrency: 5,
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
// Only schedule cron + boot for jobs that belong to this lane. Jobs
|
|
395
|
+
// assigned to the other lane get their cron/boot wiring from the
|
|
396
|
+
// runner running on that lane. Running both here would double-fire.
|
|
397
|
+
for (const [name, jobDef] of allJobs) {
|
|
398
|
+
if (laneForJob(jobDef) !== consumerLane) continue;
|
|
399
|
+
if ("cron" in jobDef.trigger) {
|
|
400
|
+
await consumerQueue.upsertJobScheduler(
|
|
401
|
+
`scheduler-${name.replace(/\./g, "-")}`,
|
|
402
|
+
{ pattern: jobDef.trigger.cron },
|
|
403
|
+
{
|
|
404
|
+
name: jobDef.perTenant ? `_perTenant:${name}` : name,
|
|
405
|
+
data: {},
|
|
406
|
+
},
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
for (const [name, jobDef] of allJobs) {
|
|
412
|
+
if (laneForJob(jobDef) !== consumerLane) continue;
|
|
413
|
+
if (jobDef.runOnBoot) {
|
|
414
|
+
const bootName = jobDef.perTenant ? `_perTenant:${name}` : name;
|
|
415
|
+
await consumerQueue.add(bootName, {}, { jobId: `boot-${name.replace(/\./g, "-")}` });
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
},
|
|
419
|
+
|
|
420
|
+
async stop(): Promise<void> {
|
|
421
|
+
if (worker) {
|
|
422
|
+
await worker.close();
|
|
423
|
+
worker = null;
|
|
424
|
+
}
|
|
425
|
+
await Promise.all([queues.api.close(), queues.worker.close()]);
|
|
426
|
+
if (lockRedis) {
|
|
427
|
+
// quit() drains in-flight commands; disconnect() would cancel them
|
|
428
|
+
// and risk a half-released lock.
|
|
429
|
+
await lockRedis.quit();
|
|
430
|
+
lockRedis = null;
|
|
431
|
+
}
|
|
432
|
+
},
|
|
433
|
+
|
|
434
|
+
async dispatch(
|
|
435
|
+
jobName: string,
|
|
436
|
+
payload?: Record<string, unknown>,
|
|
437
|
+
meta?: JobMeta,
|
|
438
|
+
): Promise<string> {
|
|
439
|
+
const jobDef = allJobs.get(jobName);
|
|
440
|
+
if (!jobDef) {
|
|
441
|
+
throw new Error(`Unknown job: ${jobName}`);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Route to the job's declared lane, not the runner's consumer lane —
|
|
445
|
+
// an api-runner is allowed to enqueue a worker-lane job and vice
|
|
446
|
+
// versa (that's the whole point of both queues being held).
|
|
447
|
+
const targetQueue = queues[laneForJob(jobDef)];
|
|
448
|
+
|
|
449
|
+
// perTenant: dispatch the fan-out wrapper instead
|
|
450
|
+
if (jobDef.perTenant) {
|
|
451
|
+
const job = await targetQueue.add(`_perTenant:${jobName}`, payload ?? {});
|
|
452
|
+
return job.id ?? "unknown";
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// maxPerTenant guard: cap concurrent + waiting jobs of the same name
|
|
456
|
+
// for the same tenant. Orthogonal to the concurrency mode below — runs
|
|
457
|
+
// first because if we're over the limit nothing else matters.
|
|
458
|
+
// Requires a `_tenantId` in the payload to know which bucket to count
|
|
459
|
+
// against; without it the guard is inactive (system jobs, ambient
|
|
460
|
+
// dispatch). Fan-out children of perTenant jobs land here on their
|
|
461
|
+
// recursive queue.add and DO carry _tenantId.
|
|
462
|
+
if (jobDef.maxPerTenant !== undefined) {
|
|
463
|
+
const tenantId = (payload as { _tenantId?: string } | undefined)?._tenantId;
|
|
464
|
+
if (
|
|
465
|
+
tenantId !== undefined &&
|
|
466
|
+
(await isOverPerTenantLimit(jobName, tenantId, jobDef.maxPerTenant))
|
|
467
|
+
) {
|
|
468
|
+
return "skipped:max-per-tenant";
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const concurrency = jobDef.concurrency ?? "parallel";
|
|
473
|
+
const bullOpts: Record<string, unknown> = {};
|
|
474
|
+
|
|
475
|
+
switch (concurrency) {
|
|
476
|
+
case "skip": {
|
|
477
|
+
const active = await targetQueue.getActive();
|
|
478
|
+
const waiting = await targetQueue.getWaiting();
|
|
479
|
+
const isRunning = [...active, ...waiting].some((j) => j.name === jobName);
|
|
480
|
+
if (isRunning) {
|
|
481
|
+
return "skipped";
|
|
482
|
+
}
|
|
483
|
+
break;
|
|
484
|
+
}
|
|
485
|
+
case "replace": {
|
|
486
|
+
const waiting = await targetQueue.getWaiting();
|
|
487
|
+
for (const j of waiting) {
|
|
488
|
+
if (j.name === jobName && j.id) {
|
|
489
|
+
await j.remove();
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
break;
|
|
493
|
+
}
|
|
494
|
+
// case "sequential" is rejected at boot — see createJobRunner. Once
|
|
495
|
+
// the OSS-compatible implementation lands (per-name Redis lock),
|
|
496
|
+
// re-add the dispatch branch here.
|
|
497
|
+
case "debounce": {
|
|
498
|
+
const debounceMs = jobDef.debounceMs ?? 5000;
|
|
499
|
+
bullOpts["debounce"] = { id: jobName, ttl: debounceMs };
|
|
500
|
+
break;
|
|
501
|
+
}
|
|
502
|
+
default:
|
|
503
|
+
break;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
if (jobDef.retries !== undefined) bullOpts["attempts"] = jobDef.retries + 1;
|
|
507
|
+
if (jobDef.backoff) bullOpts["backoff"] = { type: jobDef.backoff };
|
|
508
|
+
if (jobDef.timeout) bullOpts["timeout"] = jobDef.timeout;
|
|
509
|
+
|
|
510
|
+
// Pack meta into job data with _ prefix
|
|
511
|
+
const data: Record<string, unknown> = { ...payload };
|
|
512
|
+
if (meta?.triggeredById !== undefined) data["_triggeredById"] = meta.triggeredById;
|
|
513
|
+
if (meta?.payload !== undefined) data["_payload"] = meta.payload;
|
|
514
|
+
// Carry the enqueuing span context into the worker so job.execute shows
|
|
515
|
+
// as a child of the caller.
|
|
516
|
+
const traceCtx = captureTraceContext(tracer);
|
|
517
|
+
if (traceCtx) data[TRACE_CONTEXT_KEY] = traceCtx;
|
|
518
|
+
// Propagate correlation from the scheduling request into the job
|
|
519
|
+
// execution context. The worker re-enters requestContext.run with
|
|
520
|
+
// this value so ctx.appendEvent / executor writes during the job
|
|
521
|
+
// stamp the same correlation as the HTTP request that scheduled it.
|
|
522
|
+
const reqCtx = requestContext.get();
|
|
523
|
+
if (reqCtx?.correlationId) data["_correlationId"] = reqCtx.correlationId;
|
|
524
|
+
|
|
525
|
+
const job = await targetQueue.add(jobName, data, bullOpts);
|
|
526
|
+
return job.id ?? "unknown";
|
|
527
|
+
},
|
|
528
|
+
|
|
529
|
+
async handleEvent(
|
|
530
|
+
eventName: string,
|
|
531
|
+
payload: Record<string, unknown>,
|
|
532
|
+
user?: SessionUser,
|
|
533
|
+
): Promise<void> {
|
|
534
|
+
const traceCtx = captureTraceContext(tracer);
|
|
535
|
+
// Same correlation propagation as dispatch(): events triggered from
|
|
536
|
+
// within a request (or an MSP-apply running under requestContext.run)
|
|
537
|
+
// get their correlationId into job data so the job execution keeps
|
|
538
|
+
// the same causal chain.
|
|
539
|
+
const reqCtx = requestContext.get();
|
|
540
|
+
for (const [name, jobDef] of allJobs) {
|
|
541
|
+
if (!("on" in jobDef.trigger)) continue;
|
|
542
|
+
// skip: andere Trigger-Formen (cron, manual) reagieren nicht auf
|
|
543
|
+
// Events. Nur "on"-Trigger werden hier matched.
|
|
544
|
+
const triggerOn = jobDef.trigger.on;
|
|
545
|
+
const matches = Array.isArray(triggerOn)
|
|
546
|
+
? triggerOn.includes(eventName)
|
|
547
|
+
: triggerOn === eventName;
|
|
548
|
+
if (!matches) continue;
|
|
549
|
+
const data: Record<string, unknown> = { ...payload };
|
|
550
|
+
if (user) {
|
|
551
|
+
data["_tenantId"] = user.tenantId;
|
|
552
|
+
data["_triggeredById"] = user.id;
|
|
553
|
+
}
|
|
554
|
+
// Multi-Trigger: payload bekommt _triggerName damit der Handler
|
|
555
|
+
// weiß, welcher der N Trigger gefeuert hat. Bei Single-Trigger
|
|
556
|
+
// setzen wir es auch — kostet nichts und vereinfacht Handler-Code
|
|
557
|
+
// (kein "ist es Multi?"-Branch nötig).
|
|
558
|
+
data["_triggerName"] = eventName;
|
|
559
|
+
if (traceCtx) data[TRACE_CONTEXT_KEY] = traceCtx;
|
|
560
|
+
if (reqCtx?.correlationId) data["_correlationId"] = reqCtx.correlationId;
|
|
561
|
+
// Same maxPerTenant guard as dispatch — events that fan into many
|
|
562
|
+
// jobs must respect the per-tenant cap or the limit is one-sided.
|
|
563
|
+
if (jobDef.maxPerTenant !== undefined && user?.tenantId !== undefined) {
|
|
564
|
+
if (await isOverPerTenantLimit(name, String(user.tenantId), jobDef.maxPerTenant)) {
|
|
565
|
+
continue;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
// Route to the job's declared lane, not a fixed queue — that's
|
|
569
|
+
// the whole reason both queues are held.
|
|
570
|
+
await queues[laneForJob(jobDef)].add(name, data);
|
|
571
|
+
}
|
|
572
|
+
},
|
|
573
|
+
};
|
|
574
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Test helper: builds a minimal Lifecycle-shaped stub with selectively
|
|
2
|
+
// overridable methods. Lets unit tests focus on the one method under test
|
|
3
|
+
// without repeating an 8-field boilerplate that breaks silently when the
|
|
4
|
+
// Lifecycle interface grows.
|
|
5
|
+
|
|
6
|
+
import type { Lifecycle } from "../lifecycle";
|
|
7
|
+
|
|
8
|
+
export function createTestLifecycle(overrides: Partial<Lifecycle> = {}): Lifecycle {
|
|
9
|
+
const defaults: Lifecycle = {
|
|
10
|
+
state: () => "ready",
|
|
11
|
+
uptimeSec: () => 0,
|
|
12
|
+
markReady: () => {},
|
|
13
|
+
onStateChange: () => () => {},
|
|
14
|
+
registerShutdownHook: () => {},
|
|
15
|
+
hookNames: () => [],
|
|
16
|
+
drain: async () => {},
|
|
17
|
+
};
|
|
18
|
+
return { ...defaults, ...overrides };
|
|
19
|
+
}
|