@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,1016 @@
|
|
|
1
|
+
import { and, asc, eq, gt, sql } from "drizzle-orm";
|
|
2
|
+
import { requestContext } from "../api/request-context";
|
|
3
|
+
import type { DbConnection, DbTx, PgClient } from "../db/connection";
|
|
4
|
+
import type { AppContext } from "../engine/types";
|
|
5
|
+
import {
|
|
6
|
+
EVENTS_PUBSUB_CHANNEL,
|
|
7
|
+
eventsTable,
|
|
8
|
+
getEventsHighWaterMark,
|
|
9
|
+
type StoredEvent,
|
|
10
|
+
} from "../event-store";
|
|
11
|
+
import {
|
|
12
|
+
emitDispatcherError,
|
|
13
|
+
emitEventConsumerLag,
|
|
14
|
+
emitEventConsumerPassOutcome,
|
|
15
|
+
emitEventDispatcherListenConnected,
|
|
16
|
+
getFallbackMeter,
|
|
17
|
+
getFallbackTracer,
|
|
18
|
+
type Meter,
|
|
19
|
+
type Tracer,
|
|
20
|
+
} from "../observability";
|
|
21
|
+
import {
|
|
22
|
+
ConsumerStatuses,
|
|
23
|
+
eventConsumerStateTable,
|
|
24
|
+
SHARED_INSTANCE_SENTINEL,
|
|
25
|
+
} from "./event-consumer-state";
|
|
26
|
+
|
|
27
|
+
// Async event-dispatcher — the "AsyncDaemon"-pendant for Kumiko.
|
|
28
|
+
//
|
|
29
|
+
// Consumers (SSE broadcast, search-index, cross-feature subscribers, and —
|
|
30
|
+
// later — async projections) read the events-table via a persistent cursor
|
|
31
|
+
// held in kumiko_event_consumers. One row per consumer, one independent
|
|
32
|
+
// cursor. A stalled Meili consumer doesn't block SSE; a dead subscription
|
|
33
|
+
// doesn't pause the others.
|
|
34
|
+
//
|
|
35
|
+
// Run loop, per consumer, per pass:
|
|
36
|
+
// 1. BEGIN
|
|
37
|
+
// 2. SELECT state row FOR UPDATE SKIP LOCKED
|
|
38
|
+
// — multi-instance-safe: if another poller holds the lock, this pass
|
|
39
|
+
// skips this consumer and tries the next. No duplicate delivery.
|
|
40
|
+
// 3. SELECT events WHERE id > lastProcessedEventId ORDER BY id ASC LIMIT batchSize
|
|
41
|
+
// 4. For each event: call the consumer's handler
|
|
42
|
+
// - handler throws → increment attempts, mark status="dead" at
|
|
43
|
+
// maxAttempts, surface lastError, STOP this consumer's pass
|
|
44
|
+
// (later events aren't consumed out-of-order)
|
|
45
|
+
// - handler succeeds → advance cursor, reset attempts
|
|
46
|
+
// 5. COMMIT — cursor update + dead-letter flag land atomic
|
|
47
|
+
//
|
|
48
|
+
// Order guarantee: per-consumer, events are applied in events.id order. We
|
|
49
|
+
// don't skip past a failing event — ops has to fix it or mark the consumer
|
|
50
|
+
// disabled. This matches Marten's subscription semantics (and EventStoreDB's
|
|
51
|
+
// persistent subscriptions): strictly-ordered + halt-on-poison.
|
|
52
|
+
//
|
|
53
|
+
// Delivery semantics: **at-least-once**. If a handler runs but the cursor
|
|
54
|
+
// update fails (crash mid-pass), the same event is delivered again next pass.
|
|
55
|
+
// Handlers MUST be idempotent.
|
|
56
|
+
|
|
57
|
+
export type EventConsumerHandler = (event: StoredEvent, ctx: AppContext) => Promise<void>;
|
|
58
|
+
|
|
59
|
+
// Per-consumer error policy. When skipApplyErrors is true and handler throws,
|
|
60
|
+
// the dispatcher logs the error, advances the cursor past the offending event,
|
|
61
|
+
// and keeps delivering — instead of the default retry + dead-letter flow.
|
|
62
|
+
// Wire by copying MultiStreamProjectionDefinition.errorMode.continuous into
|
|
63
|
+
// the EventConsumer (see api/server.ts MSP wiring).
|
|
64
|
+
export type EventConsumerErrorPolicy = {
|
|
65
|
+
readonly skipApplyErrors?: boolean;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export type EventConsumer = {
|
|
69
|
+
readonly name: string;
|
|
70
|
+
readonly handler: EventConsumerHandler;
|
|
71
|
+
readonly errorPolicy?: EventConsumerErrorPolicy;
|
|
72
|
+
// Owning feature — when present, the dispatcher skips this consumer's
|
|
73
|
+
// pass while the feature is globally disabled. Events remain in the store
|
|
74
|
+
// and the consumer resumes from the same cursor when the feature is
|
|
75
|
+
// re-enabled (no data loss, no replay). System consumers (SSE, search,
|
|
76
|
+
// framework-level plumbing) omit this and always run.
|
|
77
|
+
readonly featureName?: string;
|
|
78
|
+
// Delivery semantics across multi-instance deploys:
|
|
79
|
+
// "shared" (default) — one cursor across all instances. SKIP LOCKED
|
|
80
|
+
// serialises; each event delivered exactly once globally.
|
|
81
|
+
// "per-instance" — one cursor per (name, dispatcher.instanceId) shard.
|
|
82
|
+
// Every process delivers every event independently. For
|
|
83
|
+
// push-to-local-subscribers (SSE broker, in-memory cache
|
|
84
|
+
// invalidators). Handler MUST be side-effect-free with
|
|
85
|
+
// respect to shared storage (no DB writes), otherwise
|
|
86
|
+
// each instance duplicates the effect.
|
|
87
|
+
readonly delivery?: "shared" | "per-instance";
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// Result of a dispatcher pass (runOnce / doPass). Shared across the public
|
|
91
|
+
// interface and the internal helpers so all three sites agree on the shape
|
|
92
|
+
// — adding a counter in one place wouldn't have compiled on the others
|
|
93
|
+
// when the type was inlined in each signature.
|
|
94
|
+
export type DispatcherPassResult = {
|
|
95
|
+
readonly processed: number;
|
|
96
|
+
readonly failed: number;
|
|
97
|
+
readonly byConsumer: Record<string, { processed: number; failed: number }>;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export type EventDispatcher = {
|
|
101
|
+
start(): Promise<void>;
|
|
102
|
+
stop(): Promise<void>;
|
|
103
|
+
// Force one pass now (tests drain deterministically via this).
|
|
104
|
+
// Throws if start() was never called — pre-registration of consumer
|
|
105
|
+
// state rows is a precondition, not a side-effect of the pass itself.
|
|
106
|
+
runOnce(): Promise<DispatcherPassResult>;
|
|
107
|
+
// Idempotent re-pre-registration of consumer state rows. Exists as a
|
|
108
|
+
// test-teardown surface: after `TRUNCATE kumiko_event_consumers` the
|
|
109
|
+
// rows are gone, and strict acquire() would skip every consumer as
|
|
110
|
+
// "not_registered". Tests call ensureRegistered() to repopulate without
|
|
111
|
+
// a full stop/start cycle. Production never needs this — start() runs
|
|
112
|
+
// it once on boot and the rows survive dispatcher lifetime.
|
|
113
|
+
ensureRegistered(): Promise<void>;
|
|
114
|
+
// Read-only view of the consumers this dispatcher is wired with. Exists
|
|
115
|
+
// for lane-filter assertions (Welle 2.6.b split-deploy tests) and for
|
|
116
|
+
// the boot-validator (Welle 2.6.c coverage check: every registered MSP
|
|
117
|
+
// must appear in at least one process's dispatcher). No runtime semantics
|
|
118
|
+
// — the list doesn't change after construction.
|
|
119
|
+
readonly consumers: readonly EventConsumer[];
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
export type EventDispatcherOptions = {
|
|
123
|
+
readonly db: DbConnection;
|
|
124
|
+
readonly consumers: readonly EventConsumer[];
|
|
125
|
+
readonly context: AppContext;
|
|
126
|
+
readonly batchSize?: number;
|
|
127
|
+
readonly pollIntervalMs?: number;
|
|
128
|
+
readonly maxAttempts?: number;
|
|
129
|
+
readonly tracer?: Tracer;
|
|
130
|
+
readonly meter?: Meter;
|
|
131
|
+
// Identifies THIS dispatcher process in the consumer-state table. Used as
|
|
132
|
+
// the `instance_id` value for every per-instance consumer's cursor row.
|
|
133
|
+
// Shared-delivery consumers ignore this and always use
|
|
134
|
+
// SHARED_INSTANCE_SENTINEL. Default undefined — dispatchers without any
|
|
135
|
+
// per-instance consumers don't need it. Required when at least one
|
|
136
|
+
// consumer has delivery="per-instance"; createEventDispatcher throws on
|
|
137
|
+
// boot if the invariant is violated, avoiding a later runtime surprise.
|
|
138
|
+
readonly instanceId?: string;
|
|
139
|
+
// Optional raw postgres.js client for LISTEN/NOTIFY-based wake-up
|
|
140
|
+
// (Sprint E.4). When present, `.start()` subscribes to EVENTS_PUBSUB_CHANNEL
|
|
141
|
+
// and fires runOnce on each NOTIFY — delivery latency becomes TCP-round-
|
|
142
|
+
// trip instead of pollIntervalMs. The polling timer remains active as a
|
|
143
|
+
// safety net (missed NOTIFYs from crashes, subscription drops).
|
|
144
|
+
readonly pgClient?: PgClient;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const DEFAULT_BATCH_SIZE = 200;
|
|
148
|
+
const DEFAULT_POLL_MS = 100;
|
|
149
|
+
const DEFAULT_MAX_ATTEMPTS = 10;
|
|
150
|
+
|
|
151
|
+
// --- processConsumer helpers ---
|
|
152
|
+
// Free functions (not closures) so they're independently readable and the
|
|
153
|
+
// dispatcher's main pass logic stays under ~50 LOC. Every helper takes an
|
|
154
|
+
// explicit `tx` — none of them use the outer dispatcher's closure state.
|
|
155
|
+
|
|
156
|
+
type ConsumerStateRow = typeof eventConsumerStateTable.$inferSelect;
|
|
157
|
+
|
|
158
|
+
type AcquireOutcome =
|
|
159
|
+
| { readonly state: ConsumerStateRow; readonly skip: null }
|
|
160
|
+
| {
|
|
161
|
+
readonly state: null;
|
|
162
|
+
readonly skip: "locked_by_other_instance" | "disabled" | "dead" | "not_registered";
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
// Lock the consumer's state row with SKIP LOCKED. Strict: no in-tx bootstrap.
|
|
166
|
+
// The row must exist — start() pre-registers every consumer up front so
|
|
167
|
+
// prune (event-retention) sees their cursors as soon as the process is up,
|
|
168
|
+
// closing the race where a lazy-bootstrapped consumer's cursor is absent
|
|
169
|
+
// during prune and its events are silently deleted.
|
|
170
|
+
//
|
|
171
|
+
// skip="not_registered" signals a row-missing-despite-start condition.
|
|
172
|
+
// Production shouldn't hit this — it means either start() wasn't called
|
|
173
|
+
// (runOnce() guards against that) or the state row was deleted externally
|
|
174
|
+
// (a test TRUNCATE without subsequent ensureRegistered(), or an operator
|
|
175
|
+
// intervention). Skipping quietly preserves the dispatcher's other
|
|
176
|
+
// consumers and surfaces the issue via the metrics pass-outcome.
|
|
177
|
+
async function acquireConsumerState(
|
|
178
|
+
tx: DbTx,
|
|
179
|
+
name: string,
|
|
180
|
+
instanceId: string,
|
|
181
|
+
): Promise<AcquireOutcome> {
|
|
182
|
+
const [state] = (await tx
|
|
183
|
+
.select()
|
|
184
|
+
.from(eventConsumerStateTable)
|
|
185
|
+
.where(
|
|
186
|
+
and(
|
|
187
|
+
eq(eventConsumerStateTable.name, name),
|
|
188
|
+
eq(eventConsumerStateTable.instanceId, instanceId),
|
|
189
|
+
),
|
|
190
|
+
)
|
|
191
|
+
.for("update", { skipLocked: true })) as [ConsumerStateRow | undefined];
|
|
192
|
+
|
|
193
|
+
if (!state) {
|
|
194
|
+
// Either the row never existed (no pre-reg, no ensureRegistered) or
|
|
195
|
+
// another instance currently holds the lock with SKIP LOCKED filtering
|
|
196
|
+
// us out. We can't distinguish here in a single query, so return
|
|
197
|
+
// "not_registered" — ops sees a skip-reason instead of silent delivery
|
|
198
|
+
// loss. Under normal operation (start() called, no external tampering)
|
|
199
|
+
// this path is never taken.
|
|
200
|
+
return { state: null, skip: "not_registered" };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (state.status === ConsumerStatuses.disabled) return { state: null, skip: "disabled" };
|
|
204
|
+
if (state.status === ConsumerStatuses.dead) return { state: null, skip: "dead" };
|
|
205
|
+
return { state, skip: null };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Shared pre-registration: one row per (consumer, shard), cursor = 0,
|
|
209
|
+
// status = idle. Shared-delivery consumers use SHARED_INSTANCE_SENTINEL;
|
|
210
|
+
// per-instance consumers use the dispatcher's instanceId. Idempotent
|
|
211
|
+
// under restart and concurrent start-calls via ON CONFLICT DO NOTHING
|
|
212
|
+
// on the composite PK — never clobbers an existing cursor.
|
|
213
|
+
async function preRegisterConsumers(
|
|
214
|
+
db: DbConnection,
|
|
215
|
+
consumers: readonly EventConsumer[],
|
|
216
|
+
dispatcherInstanceId: string | undefined,
|
|
217
|
+
): Promise<void> {
|
|
218
|
+
for (const consumer of consumers) {
|
|
219
|
+
const instanceId = consumerInstanceId(consumer, dispatcherInstanceId);
|
|
220
|
+
await db
|
|
221
|
+
.insert(eventConsumerStateTable)
|
|
222
|
+
.values({ name: consumer.name, instanceId, status: "idle" })
|
|
223
|
+
.onConflictDoNothing({
|
|
224
|
+
target: [eventConsumerStateTable.name, eventConsumerStateTable.instanceId],
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Resolve the instance_id column value for one consumer on this dispatcher.
|
|
230
|
+
// Shared stays at the sentinel; per-instance rides the dispatcher's id.
|
|
231
|
+
// Throws when a per-instance consumer is registered without an instanceId
|
|
232
|
+
// — missing at boot is the sharp-edge to catch, not at first delivery.
|
|
233
|
+
function consumerInstanceId(
|
|
234
|
+
consumer: EventConsumer,
|
|
235
|
+
dispatcherInstanceId: string | undefined,
|
|
236
|
+
): string {
|
|
237
|
+
if (consumer.delivery !== "per-instance") return SHARED_INSTANCE_SENTINEL;
|
|
238
|
+
if (!dispatcherInstanceId) {
|
|
239
|
+
throw new Error(
|
|
240
|
+
`EventConsumer "${consumer.name}" has delivery="per-instance" but the dispatcher was created without an instanceId — ` +
|
|
241
|
+
`pass EventDispatcherOptions.instanceId (typically from ServerOptions.instanceId / KUMIKO_INSTANCE_ID).`,
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
return dispatcherInstanceId;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Mark the consumer row as "processing" for ops visibility. The SKIP LOCKED
|
|
248
|
+
// lock already guarantees single-writer semantics; this is purely
|
|
249
|
+
// informational (and resets on commit to idle/dead via persistConsumerOutcome).
|
|
250
|
+
async function markProcessing(tx: DbTx, name: string, instanceId: string): Promise<void> {
|
|
251
|
+
await tx
|
|
252
|
+
.update(eventConsumerStateTable)
|
|
253
|
+
.set({ status: "processing", updatedAt: sql`now()` })
|
|
254
|
+
.where(
|
|
255
|
+
and(
|
|
256
|
+
eq(eventConsumerStateTable.name, name),
|
|
257
|
+
eq(eventConsumerStateTable.instanceId, instanceId),
|
|
258
|
+
),
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function fetchPendingEvents(
|
|
263
|
+
tx: DbTx,
|
|
264
|
+
cursor: bigint,
|
|
265
|
+
batchSize: number,
|
|
266
|
+
): Promise<ReadonlyArray<typeof eventsTable.$inferSelect>> {
|
|
267
|
+
return (await tx
|
|
268
|
+
.select()
|
|
269
|
+
.from(eventsTable)
|
|
270
|
+
.where(gt(eventsTable.id, cursor))
|
|
271
|
+
.orderBy(asc(eventsTable.id))
|
|
272
|
+
.limit(batchSize)) as ReadonlyArray<typeof eventsTable.$inferSelect>;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
type DeliveryOutcome = {
|
|
276
|
+
readonly cursor: bigint;
|
|
277
|
+
readonly attempts: number;
|
|
278
|
+
readonly lastError: string | null;
|
|
279
|
+
readonly deadLettered: boolean;
|
|
280
|
+
readonly processed: number;
|
|
281
|
+
readonly failed: number;
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
function rowToStoredEvent(row: typeof eventsTable.$inferSelect): StoredEvent {
|
|
285
|
+
return {
|
|
286
|
+
id: String(row.id),
|
|
287
|
+
aggregateId: row.aggregateId,
|
|
288
|
+
aggregateType: row.aggregateType,
|
|
289
|
+
tenantId: row.tenantId,
|
|
290
|
+
version: row.version,
|
|
291
|
+
type: row.type,
|
|
292
|
+
eventVersion: row.eventVersion,
|
|
293
|
+
payload: row.payload,
|
|
294
|
+
metadata: row.metadata,
|
|
295
|
+
createdAt: row.createdAt,
|
|
296
|
+
createdBy: row.createdBy,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Deliver events to the consumer's handler in events.id order. Halt-on-
|
|
301
|
+
// poison: a throw breaks the loop, the cursor stays at the last successful
|
|
302
|
+
// event, and attempts climb. At maxAttempts the caller persists status=
|
|
303
|
+
// "dead" and the consumer is parked until ops intervenes (see
|
|
304
|
+
// restartConsumer / skipPoisonEvent).
|
|
305
|
+
async function deliverEvents(
|
|
306
|
+
consumer: EventConsumer,
|
|
307
|
+
events: ReadonlyArray<typeof eventsTable.$inferSelect>,
|
|
308
|
+
context: AppContext,
|
|
309
|
+
maxAttempts: number,
|
|
310
|
+
state: ConsumerStateRow,
|
|
311
|
+
): Promise<DeliveryOutcome> {
|
|
312
|
+
let cursor = state.lastProcessedEventId;
|
|
313
|
+
let attempts = state.attempts;
|
|
314
|
+
let lastError: string | null = state.lastError ?? null;
|
|
315
|
+
let deadLettered = false;
|
|
316
|
+
let processed = 0;
|
|
317
|
+
let failed = 0;
|
|
318
|
+
|
|
319
|
+
for (const row of events) {
|
|
320
|
+
try {
|
|
321
|
+
// Propagate causation: if the handler calls ctx.appendEvent, the new
|
|
322
|
+
// event should record THIS event as its cause. correlationId is
|
|
323
|
+
// inherited unchanged — it survives the hop across streams by design.
|
|
324
|
+
// requestId falls back to a fresh id because the dispatcher runs
|
|
325
|
+
// outside any HTTP request (background poll), and a stable log-
|
|
326
|
+
// correlation handle is still useful for debugging.
|
|
327
|
+
const stored = rowToStoredEvent(row);
|
|
328
|
+
const correlationId = stored.metadata.correlationId ?? requestContext.generateId();
|
|
329
|
+
const causationId = String(stored.id);
|
|
330
|
+
const requestId = requestContext.generateId();
|
|
331
|
+
await requestContext.run({ requestId, correlationId, causationId }, async () => {
|
|
332
|
+
await consumer.handler(stored, context);
|
|
333
|
+
});
|
|
334
|
+
cursor = row.id;
|
|
335
|
+
attempts = 0;
|
|
336
|
+
lastError = null;
|
|
337
|
+
processed += 1;
|
|
338
|
+
} catch (e) {
|
|
339
|
+
const errMessage = e instanceof Error ? e.message : String(e);
|
|
340
|
+
if (consumer.errorPolicy?.skipApplyErrors) {
|
|
341
|
+
// Best-effort mode: record the error on the skip counter so ops
|
|
342
|
+
// can alert on a spike of skipped events, advance the cursor past
|
|
343
|
+
// the bad event, keep going. The consumer stays "idle", not "dead".
|
|
344
|
+
// Also emit a warn-level log line — the metric tells ops THAT events
|
|
345
|
+
// are being dropped, the log tells them WHICH events. Without this
|
|
346
|
+
// a poisoned-then-skipped event is invisible to forensic search.
|
|
347
|
+
const errorClass = e instanceof Error ? e.constructor.name : "UnknownError";
|
|
348
|
+
emitDispatcherError(context.meter ?? getFallbackMeter(), {
|
|
349
|
+
handler: consumer.name,
|
|
350
|
+
errorClass,
|
|
351
|
+
});
|
|
352
|
+
context.log?.warn(
|
|
353
|
+
`event-dispatcher: ${consumer.name} skipped event ${row.id} (${errorClass}): ${errMessage}`,
|
|
354
|
+
);
|
|
355
|
+
cursor = row.id;
|
|
356
|
+
attempts = 0;
|
|
357
|
+
lastError = null;
|
|
358
|
+
failed += 1;
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
attempts += 1;
|
|
362
|
+
lastError = errMessage;
|
|
363
|
+
failed += 1;
|
|
364
|
+
if (attempts >= maxAttempts) deadLettered = true;
|
|
365
|
+
break;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return { cursor, attempts, lastError, deadLettered, processed, failed };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
async function persistConsumerOutcome(
|
|
373
|
+
tx: DbTx,
|
|
374
|
+
name: string,
|
|
375
|
+
instanceId: string,
|
|
376
|
+
outcome: DeliveryOutcome,
|
|
377
|
+
): Promise<void> {
|
|
378
|
+
await tx
|
|
379
|
+
.update(eventConsumerStateTable)
|
|
380
|
+
.set({
|
|
381
|
+
lastProcessedEventId: outcome.cursor,
|
|
382
|
+
attempts: outcome.attempts,
|
|
383
|
+
status: outcome.deadLettered ? "dead" : "idle",
|
|
384
|
+
lastError: outcome.lastError,
|
|
385
|
+
updatedAt: sql`now()`,
|
|
386
|
+
})
|
|
387
|
+
.where(
|
|
388
|
+
and(
|
|
389
|
+
eq(eventConsumerStateTable.name, name),
|
|
390
|
+
eq(eventConsumerStateTable.instanceId, instanceId),
|
|
391
|
+
),
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Emit the lag gauge inside the consumer pass's tx so ops sees a snapshot
|
|
396
|
+
// consistent with the cursor we just advanced to. `MAX(id)` on the events
|
|
397
|
+
// table is an O(1) reverse-index scan — cheap even under load.
|
|
398
|
+
async function emitLagFromTx(
|
|
399
|
+
tx: DbTx,
|
|
400
|
+
consumerName: string,
|
|
401
|
+
instanceId: string,
|
|
402
|
+
cursor: bigint,
|
|
403
|
+
meter: Meter,
|
|
404
|
+
): Promise<void> {
|
|
405
|
+
const result = await tx.execute(
|
|
406
|
+
sql`SELECT COALESCE(MAX(id), 0)::bigint AS head FROM kumiko_events`,
|
|
407
|
+
);
|
|
408
|
+
// @cast-boundary db-row — raw drizzle.execute() COALESCE-aggregate row
|
|
409
|
+
const rows = Array.isArray(result) ? (result as Array<{ head?: bigint | string | null }>) : [];
|
|
410
|
+
const raw = rows[0]?.head;
|
|
411
|
+
const head = typeof raw === "bigint" ? raw : BigInt(raw ?? 0);
|
|
412
|
+
const lag = head > cursor ? Number(head - cursor) : 0;
|
|
413
|
+
emitEventConsumerLag(meter, { consumer: consumerName, instanceId }, lag);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
export function createEventDispatcher(options: EventDispatcherOptions): EventDispatcher {
|
|
417
|
+
const {
|
|
418
|
+
db,
|
|
419
|
+
consumers,
|
|
420
|
+
context,
|
|
421
|
+
batchSize = DEFAULT_BATCH_SIZE,
|
|
422
|
+
pollIntervalMs = DEFAULT_POLL_MS,
|
|
423
|
+
maxAttempts = DEFAULT_MAX_ATTEMPTS,
|
|
424
|
+
} = options;
|
|
425
|
+
|
|
426
|
+
// Fail-fast on misconfigured per-instance wiring. Catching this at
|
|
427
|
+
// construction surfaces the problem in boot logs instead of first
|
|
428
|
+
// delivery attempt — where it would land as a confusing preRegister
|
|
429
|
+
// throw much later in the startup sequence.
|
|
430
|
+
for (const consumer of consumers) {
|
|
431
|
+
if (consumer.delivery === "per-instance" && !options.instanceId) {
|
|
432
|
+
throw new Error(
|
|
433
|
+
`EventConsumer "${consumer.name}" has delivery="per-instance" but EventDispatcherOptions.instanceId is missing. ` +
|
|
434
|
+
`Pass ServerOptions.instanceId (defaults to KUMIKO_INSTANCE_ID or a boot-time UUID) when any consumer uses per-instance delivery.`,
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
if (options.instanceId === SHARED_INSTANCE_SENTINEL) {
|
|
439
|
+
throw new Error(
|
|
440
|
+
`EventDispatcherOptions.instanceId cannot equal the reserved sentinel "${SHARED_INSTANCE_SENTINEL}". ` +
|
|
441
|
+
`Pick any other stable string (typically KUMIKO_INSTANCE_ID from the deploy env).`,
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
const tracer: Tracer = options.tracer ?? getFallbackTracer();
|
|
445
|
+
const meter: Meter = options.meter ?? getFallbackMeter();
|
|
446
|
+
|
|
447
|
+
let running = false;
|
|
448
|
+
// Separate from `running` on purpose: pre-registration of consumer state
|
|
449
|
+
// rows is a one-time boot action, while running/timer/LISTEN is a
|
|
450
|
+
// lifecycle toggle. stop() flips running back to false but leaves
|
|
451
|
+
// preRegistered true — a subsequent runOnce() is still safe because the
|
|
452
|
+
// state rows are in place. Production code never stops-then-runs-once;
|
|
453
|
+
// tests do (drain on-demand without a timer loop).
|
|
454
|
+
let preRegistered = false;
|
|
455
|
+
let timer: ReturnType<typeof setInterval> | null = null;
|
|
456
|
+
// LISTEN subscription handle. Set when .start() successfully subscribed
|
|
457
|
+
// to EVENTS_PUBSUB_CHANNEL; cleared by .stop(). The timer remains active
|
|
458
|
+
// even with LISTEN attached — it's a cheap safety net against missed
|
|
459
|
+
// NOTIFYs (subscription drop, crash mid-commit).
|
|
460
|
+
let pgUnlisten: (() => Promise<void>) | null = null;
|
|
461
|
+
|
|
462
|
+
// Serialises concurrent runOnce() calls from both wake-up sources (timer
|
|
463
|
+
// + any future explicit nudge). Mirrors outbox-poller's passInFlight
|
|
464
|
+
// pattern so behaviour under races stays predictable.
|
|
465
|
+
let passInFlight: Promise<DispatcherPassResult> | null = null;
|
|
466
|
+
|
|
467
|
+
async function runOnce(): Promise<DispatcherPassResult> {
|
|
468
|
+
if (!preRegistered) {
|
|
469
|
+
throw new Error(
|
|
470
|
+
"EventDispatcher.runOnce() called before start() — consumer state rows are not registered. Call start() first (production) or ensureRegistered() (tests after truncating kumiko_event_consumers).",
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
if (passInFlight) return passInFlight;
|
|
474
|
+
passInFlight = doPass();
|
|
475
|
+
try {
|
|
476
|
+
return await passInFlight;
|
|
477
|
+
} finally {
|
|
478
|
+
passInFlight = null;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
async function doPass(): Promise<DispatcherPassResult> {
|
|
483
|
+
let totalProcessed = 0;
|
|
484
|
+
let totalFailed = 0;
|
|
485
|
+
const byConsumer: Record<string, { processed: number; failed: number }> = {};
|
|
486
|
+
|
|
487
|
+
// Feature-toggle snapshot taken once per pass (not per consumer): all
|
|
488
|
+
// consumers see the same disabled-set even if an operator flips a
|
|
489
|
+
// toggle mid-pass, so "this event batch" decisions stay consistent.
|
|
490
|
+
const effective = context.effectiveFeatures?.();
|
|
491
|
+
|
|
492
|
+
// Seriell pro consumer. Parallelisierung wäre möglich (je eigene TX), aber
|
|
493
|
+
// das einfache Modell reicht für v1 — jeder consumer hat geringe
|
|
494
|
+
// per-event-Arbeit (network call at worst). Bei hunderten Events pro
|
|
495
|
+
// Batch lohnt sich Parallelisierung — Optimierung für später.
|
|
496
|
+
for (const consumer of consumers) {
|
|
497
|
+
// Feature-gate: consumers tagged with a featureName get paused while
|
|
498
|
+
// that feature is globally disabled. Cursor stays put — events accumulate
|
|
499
|
+
// and are re-delivered in order when the feature is re-enabled.
|
|
500
|
+
if (effective && consumer.featureName && !effective.has(consumer.featureName)) {
|
|
501
|
+
byConsumer[consumer.name] = { processed: 0, failed: 0 };
|
|
502
|
+
continue;
|
|
503
|
+
}
|
|
504
|
+
const perConsumer = await processConsumer(consumer);
|
|
505
|
+
byConsumer[consumer.name] = perConsumer;
|
|
506
|
+
totalProcessed += perConsumer.processed;
|
|
507
|
+
totalFailed += perConsumer.failed;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
return { processed: totalProcessed, failed: totalFailed, byConsumer };
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
async function processConsumer(
|
|
514
|
+
consumer: EventConsumer,
|
|
515
|
+
): Promise<{ processed: number; failed: number }> {
|
|
516
|
+
let processed = 0;
|
|
517
|
+
let failed = 0;
|
|
518
|
+
|
|
519
|
+
const instanceId = consumerInstanceId(consumer, options.instanceId);
|
|
520
|
+
|
|
521
|
+
const span = tracer.startSpan("events.consumer.pass", {
|
|
522
|
+
attributes: {
|
|
523
|
+
"consumer.name": consumer.name,
|
|
524
|
+
"consumer.instance_id": instanceId,
|
|
525
|
+
},
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
try {
|
|
529
|
+
await db.transaction(async (tx) => {
|
|
530
|
+
const acquired = await acquireConsumerState(tx, consumer.name, instanceId);
|
|
531
|
+
// skip: another instance holds the lock, or the consumer is
|
|
532
|
+
// disabled/dead. Nothing to deliver this pass.
|
|
533
|
+
if (acquired.skip !== null) {
|
|
534
|
+
span.setAttribute("consumer.skip_reason", acquired.skip);
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
await markProcessing(tx, consumer.name, instanceId);
|
|
538
|
+
|
|
539
|
+
const events = await fetchPendingEvents(tx, acquired.state.lastProcessedEventId, batchSize);
|
|
540
|
+
const outcome = await deliverEvents(consumer, events, context, maxAttempts, acquired.state);
|
|
541
|
+
processed = outcome.processed;
|
|
542
|
+
failed = outcome.failed;
|
|
543
|
+
|
|
544
|
+
await persistConsumerOutcome(tx, consumer.name, instanceId, outcome);
|
|
545
|
+
await emitLagFromTx(tx, consumer.name, instanceId, outcome.cursor, meter);
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
emitEventConsumerPassOutcome(
|
|
549
|
+
meter,
|
|
550
|
+
{ consumer: consumer.name, instanceId },
|
|
551
|
+
processed,
|
|
552
|
+
failed,
|
|
553
|
+
);
|
|
554
|
+
span.setAttribute("consumer.processed", processed);
|
|
555
|
+
span.setAttribute("consumer.failed", failed);
|
|
556
|
+
span.setStatus(failed === 0 ? "ok" : "error");
|
|
557
|
+
} catch (e) {
|
|
558
|
+
// Unexpected: a handler error is caught inside deliverEvents and
|
|
559
|
+
// surfaces via `failed`, so anything landing here is infrastructure
|
|
560
|
+
// (db connection lost, serialization, standard-metrics not registered
|
|
561
|
+
// on this meter). Don't let one consumer's outage stall the others,
|
|
562
|
+
// but do log — a silent rollback here looks like "at-most-once" to
|
|
563
|
+
// callers and at-least-once-with-duplicate-delivery on the next pass;
|
|
564
|
+
// neither is what we want, so ops needs to see it.
|
|
565
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
566
|
+
context.log?.error(`[event-dispatcher] ${consumer.name} pass failed: ${msg}`);
|
|
567
|
+
span.setStatus("error", msg);
|
|
568
|
+
} finally {
|
|
569
|
+
span.end();
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
return { processed, failed };
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
return {
|
|
576
|
+
consumers,
|
|
577
|
+
async start() {
|
|
578
|
+
// skip: already running, idempotent
|
|
579
|
+
if (running) return;
|
|
580
|
+
running = true;
|
|
581
|
+
|
|
582
|
+
// Pre-register consumer state rows. Without this, a consumer first
|
|
583
|
+
// bootstraps lazily on its first runOnce — and if prune runs between
|
|
584
|
+
// "process came up" and "first pass landed", prune wouldn't see the
|
|
585
|
+
// consumer in the state table, would delete events past its (absent)
|
|
586
|
+
// cursor, and the consumer's first pass would silently skip them.
|
|
587
|
+
//
|
|
588
|
+
// Pre-registering turns every consumer into a row-with-cursor-0 the
|
|
589
|
+
// moment the dispatcher starts — so the retention guard
|
|
590
|
+
// (pruneEvents → ConsumerLagError) correctly refuses to prune past
|
|
591
|
+
// any consumer that exists, including freshly-deployed ones.
|
|
592
|
+
await preRegisterConsumers(db, consumers, options.instanceId);
|
|
593
|
+
preRegistered = true;
|
|
594
|
+
|
|
595
|
+
timer = setInterval(() => {
|
|
596
|
+
void runOnce().catch(() => {
|
|
597
|
+
// skip: per-consumer errors already recorded in the state row
|
|
598
|
+
});
|
|
599
|
+
}, pollIntervalMs);
|
|
600
|
+
|
|
601
|
+
// NOTIFY-based wake-up: subscribe on the same channel that
|
|
602
|
+
// event-store.append fires on commit. Fires runOnce directly, no
|
|
603
|
+
// polling round-trip. The timer stays on as a belt-and-braces
|
|
604
|
+
// fallback (dropped subscriptions, missed commits under load).
|
|
605
|
+
//
|
|
606
|
+
// Observability: the gauge kumiko_event_dispatcher_listen_connected
|
|
607
|
+
// flips to 1 on initial subscribe AND on every postgres.js silent
|
|
608
|
+
// reconnect (via the onlisten callback). A drop to 0 while running
|
|
609
|
+
// means delivery latency regressed from TCP-round-trip to
|
|
610
|
+
// pollIntervalMs — ops-visible.
|
|
611
|
+
emitEventDispatcherListenConnected(meter, false);
|
|
612
|
+
if (options.pgClient) {
|
|
613
|
+
try {
|
|
614
|
+
const sub = await options.pgClient.listen(
|
|
615
|
+
EVENTS_PUBSUB_CHANNEL,
|
|
616
|
+
() => {
|
|
617
|
+
void runOnce().catch(() => {
|
|
618
|
+
// skip: per-consumer errors already recorded in the state row
|
|
619
|
+
});
|
|
620
|
+
},
|
|
621
|
+
() => {
|
|
622
|
+
// Fires on initial connect AND on each reconnect. postgres.js
|
|
623
|
+
// reconnects transparently if the TCP connection drops, so the
|
|
624
|
+
// only way to see the recovery window is to flip the gauge
|
|
625
|
+
// every time this callback lands.
|
|
626
|
+
emitEventDispatcherListenConnected(meter, true);
|
|
627
|
+
},
|
|
628
|
+
);
|
|
629
|
+
pgUnlisten = sub.unlisten;
|
|
630
|
+
} catch (e) {
|
|
631
|
+
emitEventDispatcherListenConnected(meter, false);
|
|
632
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
633
|
+
context.log?.error(`[event-dispatcher] pg LISTEN failed: ${msg}`);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
},
|
|
637
|
+
|
|
638
|
+
async stop() {
|
|
639
|
+
// skip: already stopped, idempotent
|
|
640
|
+
if (!running) return;
|
|
641
|
+
running = false;
|
|
642
|
+
|
|
643
|
+
if (timer) {
|
|
644
|
+
clearInterval(timer);
|
|
645
|
+
timer = null;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
if (pgUnlisten) {
|
|
649
|
+
await pgUnlisten().catch(() => {
|
|
650
|
+
// skip: unlisten failure only matters during shutdown — the
|
|
651
|
+
// subscription is being torn down anyway.
|
|
652
|
+
});
|
|
653
|
+
pgUnlisten = null;
|
|
654
|
+
emitEventDispatcherListenConnected(meter, false);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// Drain any in-flight pass so shutdown observes consistent state.
|
|
658
|
+
if (passInFlight) {
|
|
659
|
+
await passInFlight.catch(() => {
|
|
660
|
+
// skip: errors already recorded per-consumer inside the pass
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
// preRegistered stays true — the rows survive stop(). runOnce()
|
|
664
|
+
// after a stop() still works (tests stop the timer and then drain
|
|
665
|
+
// deterministically).
|
|
666
|
+
},
|
|
667
|
+
|
|
668
|
+
async ensureRegistered() {
|
|
669
|
+
await preRegisterConsumers(db, consumers, options.instanceId);
|
|
670
|
+
preRegistered = true;
|
|
671
|
+
},
|
|
672
|
+
|
|
673
|
+
runOnce,
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// --- Ops recovery surface ---
|
|
678
|
+
//
|
|
679
|
+
// These are intentionally verb-distinct; each maps to a CLI sub-command.
|
|
680
|
+
// They all target a single consumer row by name. Every call returns the
|
|
681
|
+
// state after the write so the CLI can echo what actually changed.
|
|
682
|
+
//
|
|
683
|
+
// Semantics:
|
|
684
|
+
// restartConsumer status="dead" → "idle", attempts=0, lastError=null.
|
|
685
|
+
// Cursor unchanged → next pass retries the SAME event
|
|
686
|
+
// that poisoned the consumer. For transient failures.
|
|
687
|
+
// disableConsumer status=* → "disabled". Dispatcher skips this consumer
|
|
688
|
+
// until enableConsumer() flips it back.
|
|
689
|
+
// enableConsumer status="disabled" → "idle". No-op on any other state.
|
|
690
|
+
// skipPoisonEvent cursor advances past the first event after the
|
|
691
|
+
// current cursor (the one that's failing). attempts=0,
|
|
692
|
+
// lastError=null, status="idle". For events that will
|
|
693
|
+
// never succeed (broken payload, removed feature code).
|
|
694
|
+
|
|
695
|
+
function normalizeConsumerState(
|
|
696
|
+
row: typeof eventConsumerStateTable.$inferSelect,
|
|
697
|
+
): ConsumerRecoveryState {
|
|
698
|
+
return {
|
|
699
|
+
name: row.name,
|
|
700
|
+
instanceId: row.instanceId,
|
|
701
|
+
status: row.status,
|
|
702
|
+
lastProcessedEventId: row.lastProcessedEventId,
|
|
703
|
+
attempts: row.attempts,
|
|
704
|
+
lastError: row.lastError,
|
|
705
|
+
updatedAt: row.updatedAt,
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
export type ConsumerRecoveryState = {
|
|
710
|
+
readonly name: string;
|
|
711
|
+
readonly instanceId: string;
|
|
712
|
+
readonly status: string;
|
|
713
|
+
readonly lastProcessedEventId: bigint;
|
|
714
|
+
readonly attempts: number;
|
|
715
|
+
readonly lastError: string | null;
|
|
716
|
+
readonly updatedAt: Temporal.Instant;
|
|
717
|
+
};
|
|
718
|
+
|
|
719
|
+
// Ops calls default to the SHARED_INSTANCE_SENTINEL row — that's the only
|
|
720
|
+
// row shared-delivery consumers have, so legacy CLI invocations without
|
|
721
|
+
// --instance-id keep working. Per-instance consumers require an explicit
|
|
722
|
+
// instanceId: picking one of N shards arbitrarily ("first row wins") or
|
|
723
|
+
// mutating all shards simultaneously ("bounce every instance") are both
|
|
724
|
+
// worse than a loud missing-arg error on the CLI.
|
|
725
|
+
async function requireConsumerRow(
|
|
726
|
+
db: DbConnection,
|
|
727
|
+
name: string,
|
|
728
|
+
instanceId: string,
|
|
729
|
+
): Promise<typeof eventConsumerStateTable.$inferSelect> {
|
|
730
|
+
const [row] = await db
|
|
731
|
+
.select()
|
|
732
|
+
.from(eventConsumerStateTable)
|
|
733
|
+
.where(
|
|
734
|
+
and(
|
|
735
|
+
eq(eventConsumerStateTable.name, name),
|
|
736
|
+
eq(eventConsumerStateTable.instanceId, instanceId),
|
|
737
|
+
),
|
|
738
|
+
);
|
|
739
|
+
if (!row) {
|
|
740
|
+
throw new Error(
|
|
741
|
+
`Consumer "${name}" (instance_id="${instanceId}") has no state row — it hasn't run yet, the name is misspelled, or the instance is misspelled. ` +
|
|
742
|
+
`For per-instance consumers pass the instance_id explicitly; shared consumers use the default.`,
|
|
743
|
+
);
|
|
744
|
+
}
|
|
745
|
+
return row;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
export async function restartConsumer(
|
|
749
|
+
db: DbConnection,
|
|
750
|
+
name: string,
|
|
751
|
+
instanceId: string = SHARED_INSTANCE_SENTINEL,
|
|
752
|
+
): Promise<ConsumerRecoveryState> {
|
|
753
|
+
const before = await requireConsumerRow(db, name, instanceId);
|
|
754
|
+
if (before.status !== "dead") {
|
|
755
|
+
throw new Error(
|
|
756
|
+
`Consumer "${name}" (instance_id="${instanceId}") is not dead (status="${before.status}"). Restart only applies to dead consumers; use "enable" for a disabled one.`,
|
|
757
|
+
);
|
|
758
|
+
}
|
|
759
|
+
const [updated] = await db
|
|
760
|
+
.update(eventConsumerStateTable)
|
|
761
|
+
.set({ status: "idle", attempts: 0, lastError: null, updatedAt: sql`now()` })
|
|
762
|
+
.where(
|
|
763
|
+
and(
|
|
764
|
+
eq(eventConsumerStateTable.name, name),
|
|
765
|
+
eq(eventConsumerStateTable.instanceId, instanceId),
|
|
766
|
+
),
|
|
767
|
+
)
|
|
768
|
+
.returning();
|
|
769
|
+
if (!updated) {
|
|
770
|
+
throw new Error(
|
|
771
|
+
`Consumer "${name}" (instance_id="${instanceId}") vanished between read and write — retry.`,
|
|
772
|
+
);
|
|
773
|
+
}
|
|
774
|
+
return normalizeConsumerState(updated);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
export async function disableConsumer(
|
|
778
|
+
db: DbConnection,
|
|
779
|
+
name: string,
|
|
780
|
+
instanceId: string = SHARED_INSTANCE_SENTINEL,
|
|
781
|
+
): Promise<ConsumerRecoveryState> {
|
|
782
|
+
await requireConsumerRow(db, name, instanceId);
|
|
783
|
+
const [updated] = await db
|
|
784
|
+
.update(eventConsumerStateTable)
|
|
785
|
+
.set({ status: "disabled", updatedAt: sql`now()` })
|
|
786
|
+
.where(
|
|
787
|
+
and(
|
|
788
|
+
eq(eventConsumerStateTable.name, name),
|
|
789
|
+
eq(eventConsumerStateTable.instanceId, instanceId),
|
|
790
|
+
),
|
|
791
|
+
)
|
|
792
|
+
.returning();
|
|
793
|
+
if (!updated) {
|
|
794
|
+
throw new Error(
|
|
795
|
+
`Consumer "${name}" (instance_id="${instanceId}") vanished between read and write — retry.`,
|
|
796
|
+
);
|
|
797
|
+
}
|
|
798
|
+
return normalizeConsumerState(updated);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
export async function enableConsumer(
|
|
802
|
+
db: DbConnection,
|
|
803
|
+
name: string,
|
|
804
|
+
instanceId: string = SHARED_INSTANCE_SENTINEL,
|
|
805
|
+
): Promise<ConsumerRecoveryState> {
|
|
806
|
+
const before = await requireConsumerRow(db, name, instanceId);
|
|
807
|
+
if (before.status !== "disabled") {
|
|
808
|
+
throw new Error(
|
|
809
|
+
`Consumer "${name}" (instance_id="${instanceId}") is not disabled (status="${before.status}"). Enable only flips disabled → idle; use "restart" for a dead consumer.`,
|
|
810
|
+
);
|
|
811
|
+
}
|
|
812
|
+
const [updated] = await db
|
|
813
|
+
.update(eventConsumerStateTable)
|
|
814
|
+
.set({ status: "idle", attempts: 0, lastError: null, updatedAt: sql`now()` })
|
|
815
|
+
.where(
|
|
816
|
+
and(
|
|
817
|
+
eq(eventConsumerStateTable.name, name),
|
|
818
|
+
eq(eventConsumerStateTable.instanceId, instanceId),
|
|
819
|
+
),
|
|
820
|
+
)
|
|
821
|
+
.returning();
|
|
822
|
+
if (!updated) {
|
|
823
|
+
throw new Error(
|
|
824
|
+
`Consumer "${name}" (instance_id="${instanceId}") vanished between read and write — retry.`,
|
|
825
|
+
);
|
|
826
|
+
}
|
|
827
|
+
return normalizeConsumerState(updated);
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// skipPoisonEvent advances the cursor past the first event after the
|
|
831
|
+
// current cursor. Single TX so concurrent dispatcher passes can't double-
|
|
832
|
+
// advance. If no event exists past the cursor, there is nothing to skip —
|
|
833
|
+
// treat as idempotent no-op (cursor already at head).
|
|
834
|
+
export async function skipPoisonEvent(
|
|
835
|
+
db: DbConnection,
|
|
836
|
+
name: string,
|
|
837
|
+
instanceId: string = SHARED_INSTANCE_SENTINEL,
|
|
838
|
+
): Promise<ConsumerRecoveryState & { readonly skippedEventId: bigint | null }> {
|
|
839
|
+
const before = await requireConsumerRow(db, name, instanceId);
|
|
840
|
+
return db.transaction(async (tx) => {
|
|
841
|
+
const [poison] = (await tx
|
|
842
|
+
.select({ id: eventsTable.id })
|
|
843
|
+
.from(eventsTable)
|
|
844
|
+
.where(gt(eventsTable.id, before.lastProcessedEventId))
|
|
845
|
+
.orderBy(asc(eventsTable.id))
|
|
846
|
+
.limit(1)) as ReadonlyArray<{ id: bigint }>;
|
|
847
|
+
if (!poison) {
|
|
848
|
+
const [unchanged] = await tx
|
|
849
|
+
.select()
|
|
850
|
+
.from(eventConsumerStateTable)
|
|
851
|
+
.where(
|
|
852
|
+
and(
|
|
853
|
+
eq(eventConsumerStateTable.name, name),
|
|
854
|
+
eq(eventConsumerStateTable.instanceId, instanceId),
|
|
855
|
+
),
|
|
856
|
+
);
|
|
857
|
+
if (!unchanged)
|
|
858
|
+
throw new Error(`Consumer "${name}" (instance_id="${instanceId}") vanished — retry.`);
|
|
859
|
+
return { ...normalizeConsumerState(unchanged), skippedEventId: null };
|
|
860
|
+
}
|
|
861
|
+
const [updated] = await tx
|
|
862
|
+
.update(eventConsumerStateTable)
|
|
863
|
+
.set({
|
|
864
|
+
lastProcessedEventId: poison.id,
|
|
865
|
+
status: "idle",
|
|
866
|
+
attempts: 0,
|
|
867
|
+
lastError: null,
|
|
868
|
+
updatedAt: sql`now()`,
|
|
869
|
+
})
|
|
870
|
+
.where(
|
|
871
|
+
and(
|
|
872
|
+
eq(eventConsumerStateTable.name, name),
|
|
873
|
+
eq(eventConsumerStateTable.instanceId, instanceId),
|
|
874
|
+
),
|
|
875
|
+
)
|
|
876
|
+
.returning();
|
|
877
|
+
if (!updated)
|
|
878
|
+
throw new Error(
|
|
879
|
+
`Consumer "${name}" (instance_id="${instanceId}") vanished mid-skip — retry.`,
|
|
880
|
+
);
|
|
881
|
+
return { ...normalizeConsumerState(updated), skippedEventId: poison.id };
|
|
882
|
+
});
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
// Read-only status for one consumer shard — CLI surface.
|
|
886
|
+
export async function getConsumerState(
|
|
887
|
+
db: DbConnection,
|
|
888
|
+
name: string,
|
|
889
|
+
instanceId: string = SHARED_INSTANCE_SENTINEL,
|
|
890
|
+
): Promise<{
|
|
891
|
+
readonly name: string;
|
|
892
|
+
readonly instanceId: string;
|
|
893
|
+
readonly status: string;
|
|
894
|
+
readonly lastProcessedEventId: bigint;
|
|
895
|
+
readonly attempts: number;
|
|
896
|
+
readonly lastError: string | null;
|
|
897
|
+
readonly updatedAt: Temporal.Instant;
|
|
898
|
+
} | null> {
|
|
899
|
+
const [row] = await db
|
|
900
|
+
.select()
|
|
901
|
+
.from(eventConsumerStateTable)
|
|
902
|
+
.where(
|
|
903
|
+
and(
|
|
904
|
+
eq(eventConsumerStateTable.name, name),
|
|
905
|
+
eq(eventConsumerStateTable.instanceId, instanceId),
|
|
906
|
+
),
|
|
907
|
+
);
|
|
908
|
+
if (!row) return null;
|
|
909
|
+
return {
|
|
910
|
+
name: row.name,
|
|
911
|
+
instanceId: row.instanceId,
|
|
912
|
+
status: row.status,
|
|
913
|
+
lastProcessedEventId: row.lastProcessedEventId,
|
|
914
|
+
attempts: row.attempts,
|
|
915
|
+
lastError: row.lastError,
|
|
916
|
+
updatedAt: row.updatedAt,
|
|
917
|
+
};
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
// List every consumer the registry knows about, joined with all shard rows
|
|
921
|
+
// from the state table. One entry per (name, instance_id) shard. Consumers
|
|
922
|
+
// that have never run appear with status="never-run" and instance_id =
|
|
923
|
+
// SHARED_INSTANCE_SENTINEL — a placeholder, because without a running
|
|
924
|
+
// dispatcher we can't know the instance-ids of per-instance consumers yet.
|
|
925
|
+
// Mirrors listProjectionsWithState — the registry (not the DB) is the
|
|
926
|
+
// source-of-truth for which consumer-names exist; the DB is the source-
|
|
927
|
+
// of-truth for which instance-shards have been seen.
|
|
928
|
+
export async function listConsumersWithState(
|
|
929
|
+
db: DbConnection,
|
|
930
|
+
registeredNames: readonly string[],
|
|
931
|
+
): Promise<
|
|
932
|
+
ReadonlyArray<{
|
|
933
|
+
readonly name: string;
|
|
934
|
+
readonly instanceId: string;
|
|
935
|
+
readonly status: string;
|
|
936
|
+
readonly lastProcessedEventId: bigint;
|
|
937
|
+
readonly attempts: number;
|
|
938
|
+
readonly lastError: string | null;
|
|
939
|
+
}>
|
|
940
|
+
> {
|
|
941
|
+
const stateRows = await db.select().from(eventConsumerStateTable);
|
|
942
|
+
const registered = new Set(registeredNames);
|
|
943
|
+
|
|
944
|
+
// Materialize one output row per (name, instance_id). Registered names
|
|
945
|
+
// without any shard (never-run) get a placeholder row so ops can still
|
|
946
|
+
// see the name exists.
|
|
947
|
+
const out: Array<{
|
|
948
|
+
name: string;
|
|
949
|
+
instanceId: string;
|
|
950
|
+
status: string;
|
|
951
|
+
lastProcessedEventId: bigint;
|
|
952
|
+
attempts: number;
|
|
953
|
+
lastError: string | null;
|
|
954
|
+
}> = [];
|
|
955
|
+
|
|
956
|
+
const seenNames = new Set<string>();
|
|
957
|
+
for (const r of stateRows) {
|
|
958
|
+
if (!registered.has(r.name)) continue; // stale row from an older deploy
|
|
959
|
+
seenNames.add(r.name);
|
|
960
|
+
out.push({
|
|
961
|
+
name: r.name,
|
|
962
|
+
instanceId: r.instanceId,
|
|
963
|
+
status: r.status,
|
|
964
|
+
lastProcessedEventId: r.lastProcessedEventId,
|
|
965
|
+
attempts: r.attempts,
|
|
966
|
+
lastError: r.lastError,
|
|
967
|
+
});
|
|
968
|
+
}
|
|
969
|
+
for (const name of registeredNames) {
|
|
970
|
+
if (seenNames.has(name)) continue;
|
|
971
|
+
out.push({
|
|
972
|
+
name,
|
|
973
|
+
instanceId: SHARED_INSTANCE_SENTINEL,
|
|
974
|
+
status: "never-run",
|
|
975
|
+
lastProcessedEventId: 0n,
|
|
976
|
+
attempts: 0,
|
|
977
|
+
lastError: null,
|
|
978
|
+
});
|
|
979
|
+
}
|
|
980
|
+
return out;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
export type ConsumerProgress = {
|
|
984
|
+
readonly name: string;
|
|
985
|
+
readonly instanceId: string;
|
|
986
|
+
readonly status: string;
|
|
987
|
+
readonly lastProcessedEventId: bigint;
|
|
988
|
+
readonly attempts: number;
|
|
989
|
+
readonly lastError: string | null;
|
|
990
|
+
// Global MAX(events.id) at query time.
|
|
991
|
+
readonly highWaterMark: bigint;
|
|
992
|
+
// HWM - cursor. 0n when caught-up. Disabled consumers often show high
|
|
993
|
+
// lag intentionally (ops parks them before pruning).
|
|
994
|
+
readonly lag: bigint;
|
|
995
|
+
};
|
|
996
|
+
|
|
997
|
+
// Like listConsumersWithState, but also returns HWM + lag per consumer.
|
|
998
|
+
// Async consumers (MSPs) lag behind inline projections because they run
|
|
999
|
+
// post-commit — lag is the primary signal for backpressure, dead consumers,
|
|
1000
|
+
// or dispatcher stalls. Programmatic callers can map the result to a
|
|
1001
|
+
// `kumiko_consumer_lag{name}` Prometheus gauge.
|
|
1002
|
+
export async function getAllConsumerProgress(
|
|
1003
|
+
db: DbConnection,
|
|
1004
|
+
registeredNames: readonly string[],
|
|
1005
|
+
): Promise<readonly ConsumerProgress[]> {
|
|
1006
|
+
const [consumers, highWaterMark] = await Promise.all([
|
|
1007
|
+
listConsumersWithState(db, registeredNames),
|
|
1008
|
+
getEventsHighWaterMark(db),
|
|
1009
|
+
]);
|
|
1010
|
+
|
|
1011
|
+
return consumers.map((c) => ({
|
|
1012
|
+
...c,
|
|
1013
|
+
highWaterMark,
|
|
1014
|
+
lag: highWaterMark - c.lastProcessedEventId,
|
|
1015
|
+
}));
|
|
1016
|
+
}
|