From 8fcc371dd3f0c8e6278ce18eed9d68798d9702b4 Mon Sep 17 00:00:00 2001 From: Striped Zebra Date: Mon, 11 May 2026 18:29:36 +0200 Subject: [PATCH] docs(event-broker): gear design + SDK Signed-off-by: Striped Zebra --- Cargo.lock | 31 + Cargo.toml | 5 +- docs/GEARS.md | 13 + .../docs/ADR/0001-offset-semantics.md | 131 + .../docs/ADR/0002-partition-selection.md | 265 ++ .../docs/ADR/0003-event-schema.md | 281 ++ .../ADR/0004-idempotent-producer-protocol.md | 365 +++ .../ADR/0005-subscription-filter-typing.md | 388 +++ .../docs/ADR/0006-offset-authority.md | 142 + gears/system/event-broker/docs/DESIGN.md | 2588 +++++++++++++++++ gears/system/event-broker/docs/PRD.md | 722 +++++ gears/system/event-broker/docs/USE_CASES.md | 293 ++ .../features/0001-idempotent-producers.md | 469 +++ .../0002-consumer-subscription-lifecycle.md | 411 +++ .../0003-topic-segment-introspection.md | 197 ++ .../features/0004-consumption-transport.md | 287 ++ gears/system/event-broker/docs/migration.sql | 146 + gears/system/event-broker/docs/openapi.yaml | 778 +++++ .../schemas/consumer_group.v1.schema.json | 60 + .../docs/schemas/event.v1.schema.json | 134 + .../docs/schemas/event_type.v1.schema.json | 31 + .../docs/schemas/subscription.v1.schema.json | 128 + .../docs/schemas/topic.v1.schema.json | 38 + .../event-broker/event-broker-sdk/Cargo.toml | 53 + .../event-broker/event-broker-sdk/README.md | 348 +++ .../event-broker/event-broker-sdk/src/api.rs | 584 ++++ .../event-broker-sdk/src/api_tests.rs | 93 + .../src/consumer/batch_tests.rs | 121 + .../event-broker-sdk/src/consumer/builder.rs | 903 ++++++ .../src/consumer/builder_tests.rs | 644 ++++ .../event-broker-sdk/src/consumer/commit.rs | 67 + .../src/consumer/commit_tests.rs | 230 ++ .../src/consumer/dispatcher.rs | 2094 +++++++++++++ .../src/consumer/dispatcher_tests.rs | 2022 +++++++++++++ .../event-broker-sdk/src/consumer/mod.rs | 60 + .../src/consumer/offset_manager.rs | 343 +++ .../src/consumer/offset_manager_tests.rs | 210 ++ .../event-broker-sdk/src/consumer/progress.rs | 35 + .../src/consumer/progress_tests.rs | 112 + .../event-broker-sdk/src/consumer/runtime.rs | 473 +++ .../event-broker-sdk/src/consumer/types.rs | 829 ++++++ .../src/consumer/types_tests.rs | 305 ++ .../event-broker-sdk/src/dlq/envelope.rs | 99 + .../src/dlq/envelope_tests.rs | 96 + .../event-broker-sdk/src/dlq/mod.rs | 18 + .../event-broker-sdk/src/dlq/outbox.rs | 83 + .../event-broker-sdk/src/dlq/outbox_tests.rs | 162 ++ .../event-broker-sdk/src/dlq/record.rs | 102 + .../event-broker-sdk/src/dlq/record_tests.rs | 72 + .../event-broker-sdk/src/error.rs | 729 +++++ .../event-broker/event-broker-sdk/src/ids.rs | 83 + .../event-broker/event-broker-sdk/src/lib.rs | 69 + .../event-broker-sdk/src/mock/backend.rs | 131 + .../event-broker-sdk/src/mock/control.rs | 272 ++ .../src/mock/control_tests.rs | 29 + .../event-broker-sdk/src/mock/core.rs | 246 ++ .../event-broker-sdk/src/mock/ingest.rs | 299 ++ .../event-broker-sdk/src/mock/mod.rs | 23 + .../event-broker-sdk/src/mock/partitioning.rs | 5 + .../src/mock/partitioning_tests.rs | 19 + .../event-broker-sdk/src/mock/rebalance.rs | 106 + .../event-broker-sdk/src/mock/stream.rs | 349 +++ .../event-broker-sdk/src/mock/stream_tests.rs | 72 + .../event-broker-sdk/src/mock/stubs.rs | 18 + .../src/mock/tests/consumer/flows.rs | 408 +++ .../src/mock/tests/consumer/groups.rs | 194 ++ .../src/mock/tests/consumer/mod.rs | 7 + .../src/mock/tests/consumer/positions.rs | 590 ++++ .../src/mock/tests/consumer/stream.rs | 489 ++++ .../src/mock/tests/consumer/subscriptions.rs | 398 +++ .../src/mock/tests/coverage_guard.rs | 100 + .../event-broker-sdk/src/mock/tests/errors.rs | 239 ++ .../event-broker-sdk/src/mock/tests/flows.rs | 120 + .../src/mock/tests/helpers.rs | 124 + .../event-broker-sdk/src/mock/tests/mod.rs | 9 + .../src/mock/tests/producer/batch.rs | 135 + .../src/mock/tests/producer/flows.rs | 480 +++ .../src/mock/tests/producer/mod.rs | 5 + .../src/mock/tests/producer/single.rs | 266 ++ .../event-broker-sdk/src/mock/tests/topics.rs | 137 + .../event-broker-sdk/src/mock/transport.rs | 898 ++++++ .../event-broker-sdk/src/models.rs | 204 ++ .../event-broker-sdk/src/producer/db.rs | 510 ++++ .../event-broker-sdk/src/producer/direct.rs | 496 ++++ .../src/producer/direct_tests.rs | 39 + .../src/producer/event_factory.rs | 61 + .../src/producer/migrations.rs | 77 + .../event-broker-sdk/src/producer/mod.rs | 36 + .../event-broker-sdk/src/producer/outbox.rs | 517 ++++ .../src/producer/partitioning.rs | 32 + .../src/producer/partitioning_tests.rs | 83 + .../src/producer/registration.rs | 233 ++ .../src/producer/schema_cache.rs | 242 ++ .../event-broker-sdk/src/producer/types.rs | 231 ++ .../event-broker/event-broker-sdk/src/sdk.rs | 10 + .../event-broker-sdk/src/typed_event.rs | 81 + .../event-broker-sdk/tests/consumer.rs | 18 + .../tests/consumer/batch_handler.rs | 83 + .../tests/consumer/builder.rs | 57 + .../event-broker-sdk/tests/consumer/common.rs | 132 + .../tests/consumer/custom_offset_store.rs | 106 + .../event-broker-sdk/tests/consumer/db_tx.rs | 469 +++ .../tests/consumer/dlq/handler_owned.rs | 139 + .../tests/consumer/dlq/mod.rs | 4 + .../tests/consumer/dlq/outbox_backed.rs | 373 +++ .../tests/consumer/in_memory.rs | 65 + .../tests/consumer/offset_manager.rs | 87 + .../tests/consumer/remote_calls.rs | 79 + .../tests/consumer/routed_handlers.rs | 85 + .../tests/consumer/single_handler.rs | 110 + .../tests/consumer/slow_consumer.rs | 325 +++ .../event-broker-sdk/tests/producer.rs | 8 + .../tests/producer/builder.rs | 105 + .../event-broker-sdk/tests/producer/direct.rs | 233 ++ .../event-broker-sdk/tests/producer/outbox.rs | 870 ++++++ .../event-broker-sdk/tests/sdk.rs | 5 + .../event-broker-sdk/tests/sdk/defaults.rs | 15 + .../event-broker-sdk/tests/sdk/errors.rs | 940 ++++++ .../event-broker-sdk/tests/sdk/typed_event.rs | 61 + .../producer/db_rejects_direct_dedup.rs | 5 + .../producer/db_rejects_direct_dedup.stderr | 24 + .../producer/direct_rejects_db_dedup.rs | 6 + .../producer/direct_rejects_db_dedup.stderr | 13 + .../producer/missing_direct_broker.rs | 11 + .../producer/missing_direct_broker.stderr | 16 + .../event-broker-sdk/tests/usage.rs | 869 ++++++ gears/system/event-broker/scenarios/INDEX.md | 318 ++ .../1.01-negative-missing-bearer-token.md | 34 + .../1.02-negative-invalid-bearer-token.md | 32 + ...egative-insufficient-permission-produce.md | 47 + ...egative-insufficient-permission-consume.md | 51 + ...5-negative-cross-tenant-anonymous-group.md | 54 + .../flows/1.01-flow-two-consumer-rebalance.md | 191 ++ .../1.02-flow-positions-not-set-recovery.md | 90 + .../1.03-flow-path-a-consumer-with-db.md | 136 + ...1.04-flow-leave-triggers-gain-terminate.md | 119 + .../1.01-positive-create-anonymous-group.md | 42 + .../groups/1.02-positive-get-group-by-id.md | 33 + .../groups/1.03-positive-list-groups.md | 31 + .../1.04-positive-delete-empty-group.md | 26 + ...gative-delete-group-with-active-members.md | 45 + .../1.06-negative-invalid-client-agent.md | 47 + .../groups/1.07-negative-get-unknown-group.md | 32 + .../groups/1.08-positive-named-group-join.md | 61 + .../positions/1.01-positive-seek-earliest.md | 44 + .../positions/1.02-positive-seek-latest.md | 43 + .../1.03-positive-seek-exact-offset.md | 43 + .../1.04-positive-mixed-sentinels.md | 45 + .../1.05-negative-out-of-range-offset.md | 54 + .../1.06-negative-offset-above-hwm.md | 51 + .../1.07-negative-seek-while-streaming.md | 54 + ...1.09-negative-seek-unassigned-partition.md | 52 + .../1.10-positive-seek-any-value-in-range.md | 40 + .../1.11-positive-seek-at-timestamp.md | 50 + ...tive-seek-at-timestamp-before-retention.md | 41 + ...3-positive-seek-at-timestamp-beyond-hwm.md | 41 + .../1.01-positive-stream-multipart-frames.md | 51 + .../1.02-positive-stream-heartbeat-cadence.md | 39 + ...tive-stream-topology-frame-on-rebalance.md | 48 + .../1.04-negative-stream-positions-not-set.md | 49 + ...05-negative-stream-unknown-subscription.md | 33 + ...negative-stream-terminated-subscription.md | 40 + ...7-guardrail-stream-accept-json-rejected.md | 36 + ...1.08-guardrail-sse-from-stream-endpoint.md | 36 + .../stream/1.09-positive-sse-event-stream.md | 36 + ...e-stream-rejects-timeout-collect-params.md | 38 + .../1.11-negative-streaming-in-progress.md | 37 + .../1.13-positive-delete-while-streaming.md | 27 + .../1.14-positive-control-progress-frame.md | 32 + .../1.01-positive-cold-join-fresh-group.md | 63 + ....02-positive-join-multi-topic-interests.md | 49 + .../1.03-positive-join-with-typed-filter.md | 51 + ...tive-parallelism-multiple-subscriptions.md | 45 + .../1.05-positive-leave-subscription.md | 26 + .../1.06-negative-join-unauthorized-topic.md | 45 + .../1.07-negative-join-too-many-interests.md | 49 + ....08-negative-leave-unknown-subscription.md | 32 + .../1.09-positive-list-subscriptions.md | 42 + .../1.10-positive-read-subscription.md | 51 + ...positive-second-join-triggers-rebalance.md | 55 + ...-positive-third-join-triggers-rebalance.md | 53 + .../1.13-negative-join-group-at-capacity.md | 47 + .../1.01-positive-problem-details-envelope.md | 53 + .../1.02-negative-401-unauthenticated.md | 33 + .../errors/1.03-negative-403-unauthorized.md | 43 + .../errors/1.04-negative-404-not-found.md | 37 + .../errors/1.05-negative-409-conflict.md | 51 + .../1.06-negative-412-sequence-violation.md | 56 + .../errors/1.07-negative-429-rate-limited.md | 49 + .../errors/1.08-negative-500-internal.md | 40 + .../1.01-flow-publish-subscribe-consume.md | 271 ++ .../batch/1.01-positive-publish-batch.md | 52 + .../1.02-negative-mixed-partition-batch.md | 59 + .../batch/1.03-negative-batch-too-large.md | 44 + ...-negative-batch-late-validation-failure.md | 78 + ...1.01-positive-register-chained-producer.md | 35 + ...02-positive-register-monotonic-producer.md | 33 + .../1.03-positive-chained-mode-sequence.md | 40 + .../1.04-positive-idempotency-key-dedup.md | 41 + ....05-negative-chained-sequence-violation.md | 61 + .../flows/1.06-positive-cursor-recovery.md | 42 + .../flows/1.07-positive-chain-reset.md | 42 + .../flows/1.08-negative-unknown-producer.md | 55 + ...9-flow-chained-producer-desync-recovery.md | 121 + .../1.01-positive-publish-single-async.md | 42 + ...02-positive-publish-sync-wait-persisted.md | 39 + ...1.03-negative-schema-validation-failure.md | 61 + .../single/1.04-negative-rate-limited.md | 58 + ...05-negative-readonly-partition-rejected.md | 63 + .../topics/1.01-positive-list-topics.md | 31 + .../1.02-positive-list-topic-segments.md | 32 + .../1.03-negative-segments-unknown-topic.md | 32 + .../topics/1.04-positive-list-event-types.md | 45 + libs/toolkit-stable-hash/Cargo.toml | 19 + libs/toolkit-stable-hash/README.md | 24 + libs/toolkit-stable-hash/src/lib.rs | 12 + libs/toolkit-stable-hash/src/murmur3.rs | 69 + libs/toolkit-stable-hash/src/murmur3_tests.rs | 19 + 218 files changed, 38213 insertions(+), 1 deletion(-) create mode 100644 gears/system/event-broker/docs/ADR/0001-offset-semantics.md create mode 100644 gears/system/event-broker/docs/ADR/0002-partition-selection.md create mode 100644 gears/system/event-broker/docs/ADR/0003-event-schema.md create mode 100644 gears/system/event-broker/docs/ADR/0004-idempotent-producer-protocol.md create mode 100644 gears/system/event-broker/docs/ADR/0005-subscription-filter-typing.md create mode 100644 gears/system/event-broker/docs/ADR/0006-offset-authority.md create mode 100644 gears/system/event-broker/docs/DESIGN.md create mode 100644 gears/system/event-broker/docs/PRD.md create mode 100644 gears/system/event-broker/docs/USE_CASES.md create mode 100644 gears/system/event-broker/docs/features/0001-idempotent-producers.md create mode 100644 gears/system/event-broker/docs/features/0002-consumer-subscription-lifecycle.md create mode 100644 gears/system/event-broker/docs/features/0003-topic-segment-introspection.md create mode 100644 gears/system/event-broker/docs/features/0004-consumption-transport.md create mode 100644 gears/system/event-broker/docs/migration.sql create mode 100644 gears/system/event-broker/docs/openapi.yaml create mode 100644 gears/system/event-broker/docs/schemas/consumer_group.v1.schema.json create mode 100644 gears/system/event-broker/docs/schemas/event.v1.schema.json create mode 100644 gears/system/event-broker/docs/schemas/event_type.v1.schema.json create mode 100644 gears/system/event-broker/docs/schemas/subscription.v1.schema.json create mode 100644 gears/system/event-broker/docs/schemas/topic.v1.schema.json create mode 100644 gears/system/event-broker/event-broker-sdk/Cargo.toml create mode 100644 gears/system/event-broker/event-broker-sdk/README.md create mode 100644 gears/system/event-broker/event-broker-sdk/src/api.rs create mode 100644 gears/system/event-broker/event-broker-sdk/src/api_tests.rs create mode 100644 gears/system/event-broker/event-broker-sdk/src/consumer/batch_tests.rs create mode 100644 gears/system/event-broker/event-broker-sdk/src/consumer/builder.rs create mode 100644 gears/system/event-broker/event-broker-sdk/src/consumer/builder_tests.rs create mode 100644 gears/system/event-broker/event-broker-sdk/src/consumer/commit.rs create mode 100644 gears/system/event-broker/event-broker-sdk/src/consumer/commit_tests.rs create mode 100644 gears/system/event-broker/event-broker-sdk/src/consumer/dispatcher.rs create mode 100644 gears/system/event-broker/event-broker-sdk/src/consumer/dispatcher_tests.rs create mode 100644 gears/system/event-broker/event-broker-sdk/src/consumer/mod.rs create mode 100644 gears/system/event-broker/event-broker-sdk/src/consumer/offset_manager.rs create mode 100644 gears/system/event-broker/event-broker-sdk/src/consumer/offset_manager_tests.rs create mode 100644 gears/system/event-broker/event-broker-sdk/src/consumer/progress.rs create mode 100644 gears/system/event-broker/event-broker-sdk/src/consumer/progress_tests.rs create mode 100644 gears/system/event-broker/event-broker-sdk/src/consumer/runtime.rs create mode 100644 gears/system/event-broker/event-broker-sdk/src/consumer/types.rs create mode 100644 gears/system/event-broker/event-broker-sdk/src/consumer/types_tests.rs create mode 100644 gears/system/event-broker/event-broker-sdk/src/dlq/envelope.rs create mode 100644 gears/system/event-broker/event-broker-sdk/src/dlq/envelope_tests.rs create mode 100644 gears/system/event-broker/event-broker-sdk/src/dlq/mod.rs create mode 100644 gears/system/event-broker/event-broker-sdk/src/dlq/outbox.rs create mode 100644 gears/system/event-broker/event-broker-sdk/src/dlq/outbox_tests.rs create mode 100644 gears/system/event-broker/event-broker-sdk/src/dlq/record.rs create mode 100644 gears/system/event-broker/event-broker-sdk/src/dlq/record_tests.rs create mode 100644 gears/system/event-broker/event-broker-sdk/src/error.rs create mode 100644 gears/system/event-broker/event-broker-sdk/src/ids.rs create mode 100644 gears/system/event-broker/event-broker-sdk/src/lib.rs create mode 100644 gears/system/event-broker/event-broker-sdk/src/mock/backend.rs create mode 100644 gears/system/event-broker/event-broker-sdk/src/mock/control.rs create mode 100644 gears/system/event-broker/event-broker-sdk/src/mock/control_tests.rs create mode 100644 gears/system/event-broker/event-broker-sdk/src/mock/core.rs create mode 100644 gears/system/event-broker/event-broker-sdk/src/mock/ingest.rs create mode 100644 gears/system/event-broker/event-broker-sdk/src/mock/mod.rs create mode 100644 gears/system/event-broker/event-broker-sdk/src/mock/partitioning.rs create mode 100644 gears/system/event-broker/event-broker-sdk/src/mock/partitioning_tests.rs create mode 100644 gears/system/event-broker/event-broker-sdk/src/mock/rebalance.rs create mode 100644 gears/system/event-broker/event-broker-sdk/src/mock/stream.rs create mode 100644 gears/system/event-broker/event-broker-sdk/src/mock/stream_tests.rs create mode 100644 gears/system/event-broker/event-broker-sdk/src/mock/stubs.rs create mode 100644 gears/system/event-broker/event-broker-sdk/src/mock/tests/consumer/flows.rs create mode 100644 gears/system/event-broker/event-broker-sdk/src/mock/tests/consumer/groups.rs create mode 100644 gears/system/event-broker/event-broker-sdk/src/mock/tests/consumer/mod.rs create mode 100644 gears/system/event-broker/event-broker-sdk/src/mock/tests/consumer/positions.rs create mode 100644 gears/system/event-broker/event-broker-sdk/src/mock/tests/consumer/stream.rs create mode 100644 gears/system/event-broker/event-broker-sdk/src/mock/tests/consumer/subscriptions.rs create mode 100644 gears/system/event-broker/event-broker-sdk/src/mock/tests/coverage_guard.rs create mode 100644 gears/system/event-broker/event-broker-sdk/src/mock/tests/errors.rs create mode 100644 gears/system/event-broker/event-broker-sdk/src/mock/tests/flows.rs create mode 100644 gears/system/event-broker/event-broker-sdk/src/mock/tests/helpers.rs create mode 100644 gears/system/event-broker/event-broker-sdk/src/mock/tests/mod.rs create mode 100644 gears/system/event-broker/event-broker-sdk/src/mock/tests/producer/batch.rs create mode 100644 gears/system/event-broker/event-broker-sdk/src/mock/tests/producer/flows.rs create mode 100644 gears/system/event-broker/event-broker-sdk/src/mock/tests/producer/mod.rs create mode 100644 gears/system/event-broker/event-broker-sdk/src/mock/tests/producer/single.rs create mode 100644 gears/system/event-broker/event-broker-sdk/src/mock/tests/topics.rs create mode 100644 gears/system/event-broker/event-broker-sdk/src/mock/transport.rs create mode 100644 gears/system/event-broker/event-broker-sdk/src/models.rs create mode 100644 gears/system/event-broker/event-broker-sdk/src/producer/db.rs create mode 100644 gears/system/event-broker/event-broker-sdk/src/producer/direct.rs create mode 100644 gears/system/event-broker/event-broker-sdk/src/producer/direct_tests.rs create mode 100644 gears/system/event-broker/event-broker-sdk/src/producer/event_factory.rs create mode 100644 gears/system/event-broker/event-broker-sdk/src/producer/migrations.rs create mode 100644 gears/system/event-broker/event-broker-sdk/src/producer/mod.rs create mode 100644 gears/system/event-broker/event-broker-sdk/src/producer/outbox.rs create mode 100644 gears/system/event-broker/event-broker-sdk/src/producer/partitioning.rs create mode 100644 gears/system/event-broker/event-broker-sdk/src/producer/partitioning_tests.rs create mode 100644 gears/system/event-broker/event-broker-sdk/src/producer/registration.rs create mode 100644 gears/system/event-broker/event-broker-sdk/src/producer/schema_cache.rs create mode 100644 gears/system/event-broker/event-broker-sdk/src/producer/types.rs create mode 100644 gears/system/event-broker/event-broker-sdk/src/sdk.rs create mode 100644 gears/system/event-broker/event-broker-sdk/src/typed_event.rs create mode 100644 gears/system/event-broker/event-broker-sdk/tests/consumer.rs create mode 100644 gears/system/event-broker/event-broker-sdk/tests/consumer/batch_handler.rs create mode 100644 gears/system/event-broker/event-broker-sdk/tests/consumer/builder.rs create mode 100644 gears/system/event-broker/event-broker-sdk/tests/consumer/common.rs create mode 100644 gears/system/event-broker/event-broker-sdk/tests/consumer/custom_offset_store.rs create mode 100644 gears/system/event-broker/event-broker-sdk/tests/consumer/db_tx.rs create mode 100644 gears/system/event-broker/event-broker-sdk/tests/consumer/dlq/handler_owned.rs create mode 100644 gears/system/event-broker/event-broker-sdk/tests/consumer/dlq/mod.rs create mode 100644 gears/system/event-broker/event-broker-sdk/tests/consumer/dlq/outbox_backed.rs create mode 100644 gears/system/event-broker/event-broker-sdk/tests/consumer/in_memory.rs create mode 100644 gears/system/event-broker/event-broker-sdk/tests/consumer/offset_manager.rs create mode 100644 gears/system/event-broker/event-broker-sdk/tests/consumer/remote_calls.rs create mode 100644 gears/system/event-broker/event-broker-sdk/tests/consumer/routed_handlers.rs create mode 100644 gears/system/event-broker/event-broker-sdk/tests/consumer/single_handler.rs create mode 100644 gears/system/event-broker/event-broker-sdk/tests/consumer/slow_consumer.rs create mode 100644 gears/system/event-broker/event-broker-sdk/tests/producer.rs create mode 100644 gears/system/event-broker/event-broker-sdk/tests/producer/builder.rs create mode 100644 gears/system/event-broker/event-broker-sdk/tests/producer/direct.rs create mode 100644 gears/system/event-broker/event-broker-sdk/tests/producer/outbox.rs create mode 100644 gears/system/event-broker/event-broker-sdk/tests/sdk.rs create mode 100644 gears/system/event-broker/event-broker-sdk/tests/sdk/defaults.rs create mode 100644 gears/system/event-broker/event-broker-sdk/tests/sdk/errors.rs create mode 100644 gears/system/event-broker/event-broker-sdk/tests/sdk/typed_event.rs create mode 100644 gears/system/event-broker/event-broker-sdk/tests/trybuild/producer/db_rejects_direct_dedup.rs create mode 100644 gears/system/event-broker/event-broker-sdk/tests/trybuild/producer/db_rejects_direct_dedup.stderr create mode 100644 gears/system/event-broker/event-broker-sdk/tests/trybuild/producer/direct_rejects_db_dedup.rs create mode 100644 gears/system/event-broker/event-broker-sdk/tests/trybuild/producer/direct_rejects_db_dedup.stderr create mode 100644 gears/system/event-broker/event-broker-sdk/tests/trybuild/producer/missing_direct_broker.rs create mode 100644 gears/system/event-broker/event-broker-sdk/tests/trybuild/producer/missing_direct_broker.stderr create mode 100644 gears/system/event-broker/event-broker-sdk/tests/usage.rs create mode 100644 gears/system/event-broker/scenarios/INDEX.md create mode 100644 gears/system/event-broker/scenarios/auth/1.01-negative-missing-bearer-token.md create mode 100644 gears/system/event-broker/scenarios/auth/1.02-negative-invalid-bearer-token.md create mode 100644 gears/system/event-broker/scenarios/auth/1.03-negative-insufficient-permission-produce.md create mode 100644 gears/system/event-broker/scenarios/auth/1.04-negative-insufficient-permission-consume.md create mode 100644 gears/system/event-broker/scenarios/auth/1.05-negative-cross-tenant-anonymous-group.md create mode 100644 gears/system/event-broker/scenarios/consumer/flows/1.01-flow-two-consumer-rebalance.md create mode 100644 gears/system/event-broker/scenarios/consumer/flows/1.02-flow-positions-not-set-recovery.md create mode 100644 gears/system/event-broker/scenarios/consumer/flows/1.03-flow-path-a-consumer-with-db.md create mode 100644 gears/system/event-broker/scenarios/consumer/flows/1.04-flow-leave-triggers-gain-terminate.md create mode 100644 gears/system/event-broker/scenarios/consumer/groups/1.01-positive-create-anonymous-group.md create mode 100644 gears/system/event-broker/scenarios/consumer/groups/1.02-positive-get-group-by-id.md create mode 100644 gears/system/event-broker/scenarios/consumer/groups/1.03-positive-list-groups.md create mode 100644 gears/system/event-broker/scenarios/consumer/groups/1.04-positive-delete-empty-group.md create mode 100644 gears/system/event-broker/scenarios/consumer/groups/1.05-negative-delete-group-with-active-members.md create mode 100644 gears/system/event-broker/scenarios/consumer/groups/1.06-negative-invalid-client-agent.md create mode 100644 gears/system/event-broker/scenarios/consumer/groups/1.07-negative-get-unknown-group.md create mode 100644 gears/system/event-broker/scenarios/consumer/groups/1.08-positive-named-group-join.md create mode 100644 gears/system/event-broker/scenarios/consumer/positions/1.01-positive-seek-earliest.md create mode 100644 gears/system/event-broker/scenarios/consumer/positions/1.02-positive-seek-latest.md create mode 100644 gears/system/event-broker/scenarios/consumer/positions/1.03-positive-seek-exact-offset.md create mode 100644 gears/system/event-broker/scenarios/consumer/positions/1.04-positive-mixed-sentinels.md create mode 100644 gears/system/event-broker/scenarios/consumer/positions/1.05-negative-out-of-range-offset.md create mode 100644 gears/system/event-broker/scenarios/consumer/positions/1.06-negative-offset-above-hwm.md create mode 100644 gears/system/event-broker/scenarios/consumer/positions/1.07-negative-seek-while-streaming.md create mode 100644 gears/system/event-broker/scenarios/consumer/positions/1.09-negative-seek-unassigned-partition.md create mode 100644 gears/system/event-broker/scenarios/consumer/positions/1.10-positive-seek-any-value-in-range.md create mode 100644 gears/system/event-broker/scenarios/consumer/positions/1.11-positive-seek-at-timestamp.md create mode 100644 gears/system/event-broker/scenarios/consumer/positions/1.12-positive-seek-at-timestamp-before-retention.md create mode 100644 gears/system/event-broker/scenarios/consumer/positions/1.13-positive-seek-at-timestamp-beyond-hwm.md create mode 100644 gears/system/event-broker/scenarios/consumer/stream/1.01-positive-stream-multipart-frames.md create mode 100644 gears/system/event-broker/scenarios/consumer/stream/1.02-positive-stream-heartbeat-cadence.md create mode 100644 gears/system/event-broker/scenarios/consumer/stream/1.03-positive-stream-topology-frame-on-rebalance.md create mode 100644 gears/system/event-broker/scenarios/consumer/stream/1.04-negative-stream-positions-not-set.md create mode 100644 gears/system/event-broker/scenarios/consumer/stream/1.05-negative-stream-unknown-subscription.md create mode 100644 gears/system/event-broker/scenarios/consumer/stream/1.06-negative-stream-terminated-subscription.md create mode 100644 gears/system/event-broker/scenarios/consumer/stream/1.07-guardrail-stream-accept-json-rejected.md create mode 100644 gears/system/event-broker/scenarios/consumer/stream/1.08-guardrail-sse-from-stream-endpoint.md create mode 100644 gears/system/event-broker/scenarios/consumer/stream/1.09-positive-sse-event-stream.md create mode 100644 gears/system/event-broker/scenarios/consumer/stream/1.10-negative-stream-rejects-timeout-collect-params.md create mode 100644 gears/system/event-broker/scenarios/consumer/stream/1.11-negative-streaming-in-progress.md create mode 100644 gears/system/event-broker/scenarios/consumer/stream/1.13-positive-delete-while-streaming.md create mode 100644 gears/system/event-broker/scenarios/consumer/stream/1.14-positive-control-progress-frame.md create mode 100644 gears/system/event-broker/scenarios/consumer/subscriptions/1.01-positive-cold-join-fresh-group.md create mode 100644 gears/system/event-broker/scenarios/consumer/subscriptions/1.02-positive-join-multi-topic-interests.md create mode 100644 gears/system/event-broker/scenarios/consumer/subscriptions/1.03-positive-join-with-typed-filter.md create mode 100644 gears/system/event-broker/scenarios/consumer/subscriptions/1.04-positive-parallelism-multiple-subscriptions.md create mode 100644 gears/system/event-broker/scenarios/consumer/subscriptions/1.05-positive-leave-subscription.md create mode 100644 gears/system/event-broker/scenarios/consumer/subscriptions/1.06-negative-join-unauthorized-topic.md create mode 100644 gears/system/event-broker/scenarios/consumer/subscriptions/1.07-negative-join-too-many-interests.md create mode 100644 gears/system/event-broker/scenarios/consumer/subscriptions/1.08-negative-leave-unknown-subscription.md create mode 100644 gears/system/event-broker/scenarios/consumer/subscriptions/1.09-positive-list-subscriptions.md create mode 100644 gears/system/event-broker/scenarios/consumer/subscriptions/1.10-positive-read-subscription.md create mode 100644 gears/system/event-broker/scenarios/consumer/subscriptions/1.11-positive-second-join-triggers-rebalance.md create mode 100644 gears/system/event-broker/scenarios/consumer/subscriptions/1.12-positive-third-join-triggers-rebalance.md create mode 100644 gears/system/event-broker/scenarios/consumer/subscriptions/1.13-negative-join-group-at-capacity.md create mode 100644 gears/system/event-broker/scenarios/errors/1.01-positive-problem-details-envelope.md create mode 100644 gears/system/event-broker/scenarios/errors/1.02-negative-401-unauthenticated.md create mode 100644 gears/system/event-broker/scenarios/errors/1.03-negative-403-unauthorized.md create mode 100644 gears/system/event-broker/scenarios/errors/1.04-negative-404-not-found.md create mode 100644 gears/system/event-broker/scenarios/errors/1.05-negative-409-conflict.md create mode 100644 gears/system/event-broker/scenarios/errors/1.06-negative-412-sequence-violation.md create mode 100644 gears/system/event-broker/scenarios/errors/1.07-negative-429-rate-limited.md create mode 100644 gears/system/event-broker/scenarios/errors/1.08-negative-500-internal.md create mode 100644 gears/system/event-broker/scenarios/flows/1.01-flow-publish-subscribe-consume.md create mode 100644 gears/system/event-broker/scenarios/producer/batch/1.01-positive-publish-batch.md create mode 100644 gears/system/event-broker/scenarios/producer/batch/1.02-negative-mixed-partition-batch.md create mode 100644 gears/system/event-broker/scenarios/producer/batch/1.03-negative-batch-too-large.md create mode 100644 gears/system/event-broker/scenarios/producer/batch/1.04-negative-batch-late-validation-failure.md create mode 100644 gears/system/event-broker/scenarios/producer/flows/1.01-positive-register-chained-producer.md create mode 100644 gears/system/event-broker/scenarios/producer/flows/1.02-positive-register-monotonic-producer.md create mode 100644 gears/system/event-broker/scenarios/producer/flows/1.03-positive-chained-mode-sequence.md create mode 100644 gears/system/event-broker/scenarios/producer/flows/1.04-positive-idempotency-key-dedup.md create mode 100644 gears/system/event-broker/scenarios/producer/flows/1.05-negative-chained-sequence-violation.md create mode 100644 gears/system/event-broker/scenarios/producer/flows/1.06-positive-cursor-recovery.md create mode 100644 gears/system/event-broker/scenarios/producer/flows/1.07-positive-chain-reset.md create mode 100644 gears/system/event-broker/scenarios/producer/flows/1.08-negative-unknown-producer.md create mode 100644 gears/system/event-broker/scenarios/producer/flows/1.09-flow-chained-producer-desync-recovery.md create mode 100644 gears/system/event-broker/scenarios/producer/single/1.01-positive-publish-single-async.md create mode 100644 gears/system/event-broker/scenarios/producer/single/1.02-positive-publish-sync-wait-persisted.md create mode 100644 gears/system/event-broker/scenarios/producer/single/1.03-negative-schema-validation-failure.md create mode 100644 gears/system/event-broker/scenarios/producer/single/1.04-negative-rate-limited.md create mode 100644 gears/system/event-broker/scenarios/producer/single/1.05-negative-readonly-partition-rejected.md create mode 100644 gears/system/event-broker/scenarios/topics/1.01-positive-list-topics.md create mode 100644 gears/system/event-broker/scenarios/topics/1.02-positive-list-topic-segments.md create mode 100644 gears/system/event-broker/scenarios/topics/1.03-negative-segments-unknown-topic.md create mode 100644 gears/system/event-broker/scenarios/topics/1.04-positive-list-event-types.md create mode 100644 libs/toolkit-stable-hash/Cargo.toml create mode 100644 libs/toolkit-stable-hash/README.md create mode 100644 libs/toolkit-stable-hash/src/lib.rs create mode 100644 libs/toolkit-stable-hash/src/murmur3.rs create mode 100644 libs/toolkit-stable-hash/src/murmur3_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 9774503ad..686ccb285 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1715,6 +1715,33 @@ dependencies = [ "zeroize", ] +[[package]] +name = "cf-gears-event-broker-sdk" +version = "0.1.0" +dependencies = [ + "async-stream", + "async-trait", + "cf-gears-toolkit-canonical-errors", + "cf-gears-toolkit-db", + "cf-gears-toolkit-gts", + "cf-gears-toolkit-security", + "cf-gears-toolkit-stable-hash", + "chrono", + "futures-core", + "futures-util", + "gts-id", + "jsonschema", + "sea-orm", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", + "trybuild", + "uuid", +] + [[package]] name = "cf-gears-example-server" version = "0.6.1" @@ -2861,6 +2888,10 @@ dependencies = [ "uuid", ] +[[package]] +name = "cf-gears-toolkit-stable-hash" +version = "0.1.0" + [[package]] name = "cf-gears-toolkit-transport-grpc" version = "0.6.6" diff --git a/Cargo.toml b/Cargo.toml index d1295ad45..faef34893 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,6 +43,7 @@ members = [ "libs/toolkit-security", "libs/toolkit-odata", "libs/toolkit-node-info", + "libs/toolkit-stable-hash", "libs/toolkit-transport-grpc", "libs/toolkit-utils", "libs/rustls-corecrypto-provider", @@ -107,6 +108,7 @@ members = [ "gears/bss/ledger/ledger", "gears/bss/ledger/ledger-sdk", "gears/bss/libs/coord", + "gears/system/event-broker/event-broker-sdk", ] exclude = ["tools/fuzz"] resolver = "3" @@ -255,6 +257,7 @@ toolkit-odata = { package = "cf-gears-toolkit-odata", version = "0.8.2", path = toolkit-odata-macros = { package = "cf-gears-toolkit-odata-macros", version = "0.6.7", path = "libs/toolkit-odata-macros" } toolkit-sdk = { package = "cf-gears-toolkit-sdk", version = "0.7.0", path = "libs/toolkit-sdk" } toolkit-security = { package = "cf-gears-toolkit-security", version = "0.7.2", path = "libs/toolkit-security" } +toolkit-stable-hash = { package = "cf-gears-toolkit-stable-hash", version = "0.1.0", path = "libs/toolkit-stable-hash" } toolkit-transport-grpc = { package = "cf-gears-toolkit-transport-grpc", version = "0.6.6", path = "libs/toolkit-transport-grpc" } toolkit-utils = { package = "cf-gears-toolkit-utils", version = "0.6.3", path = "libs/toolkit-utils" } rustls-corecrypto-provider = { package = "cf-gears-rustls-corecrypto-provider", version = "0.1.1", path = "libs/rustls-corecrypto-provider" } @@ -591,4 +594,4 @@ gts = "0.11.0" gts-macros = "0.11.0" [workspace.metadata.cargo-shear] -ignored-paths = ["**/tests/ui/**"] +ignored-paths = ["**/tests/ui/**", "**/tests/trybuild/**"] diff --git a/docs/GEARS.md b/docs/GEARS.md index ac5448e0f..51e7a06c9 100644 --- a/docs/GEARS.md +++ b/docs/GEARS.md @@ -699,6 +699,19 @@ Introduces an abstraction layer behind the real Outbound API Gateway. The main g - [API](../gears/system/oagw/oagw/README.md) - [SDK](../gears/system/oagw/oagw-sdk/README.md) +### Event Broker + +Multi-consumer, partitioned, append-only event streaming for Cyber Ware modules. +Typed events, at-least-once delivery, idempotent producers (chained/monotonic/stateless), +pluggable storage backends, consumer-group cursor tracking. + +**Status**: SDK landed (`cyberware-event-broker-sdk`) — impl crate TODO. + +#### More details +- [PRD](../gears/system/event-broker/docs/PRD.md) +- [Design](../gears/system/event-broker/docs/DESIGN.md) (R4 signed off) +- [SDK](../gears/system/event-broker/event-broker-sdk/README.md) + ## Core Platform Services Core Platform Services are authoritative, enterprise-level services that may exist outside of Gears and act as systems of record for critical governance domains such as accounts, identity, access policies, licensing, credentials, and outbound egress control. These components typically belong to an organization’s broader platform or SaaS ecosystem and may already be deployed, certified, and governed independently of Gears. diff --git a/gears/system/event-broker/docs/ADR/0001-offset-semantics.md b/gears/system/event-broker/docs/ADR/0001-offset-semantics.md new file mode 100644 index 000000000..90b42111c --- /dev/null +++ b/gears/system/event-broker/docs/ADR/0001-offset-semantics.md @@ -0,0 +1,131 @@ +# ADR-0001: Offset Semantics — Sequences Start at 1 + + + +- [Status](#status) +- [Context and Problem Statement](#context-and-problem-statement) +- [Decision Drivers](#decision-drivers) +- [Considered Options](#considered-options) +- [Decision Outcome](#decision-outcome) + - [Consequences](#consequences) + - [Confirmation](#confirmation) +- [Pros and Cons of the Options](#pros-and-cons-of-the-options) + - [Sequences Start at 1](#sequences-start-at-1) + - [Sequences Start at 0](#sequences-start-at-0) + - [Leave Unspecified](#leave-unspecified) +- [More Information](#more-information) + + + +**ID**: `cpt-cf-evbk-adr-offset-semantics` + +## Status + +Accepted + +## Context and Problem Statement + +The event broker exposes consumer-visible **sequences** (also called offsets) to events through the storage backend boundary. The design describes public sequences as monotonically increasing within a `(topic, partition)` but did not originally specify the floor — whether they start at 0 or 1. This gap has consequences for every component that reasons about cursors, valid SEEK ranges, or offset types. + +The **cursor** model used throughout the broker is **last-processed-offset**: a consumer stores the sequence of the last event it successfully processed, and the broker delivers the next event from `cursor + 1`. For a consumer that has never processed any event, a special "nothing processed yet" cursor value is needed. That value is `cursor = RF - 1`, where RF is the retention floor (the sequence of the oldest available event). + +The decision needs to keep three things aligned: + +- Broker-visible event sequences must have one unambiguous floor. +- Cursor arithmetic must not require negative sentinel values. +- Backend adapters must expose the same broker-logical sequence space even when their native offset model differs. + +## Decision Drivers + +- Keep consumer-visible offsets non-negative across wire, database, and SDK surfaces. +- Preserve the last-processed cursor model where the broker delivers from `cursor + 1`. +- Avoid backend-specific offset floors leaking into broker APIs. +- Make the fresh-topic and retention-floor cases mechanically obvious. + +## Considered Options + +Two choices were considered: + +| Sequences start at | RF on fresh topic | "nothing yet" cursor | cursor space | Minimum type | +|---|---|---|---|---| +| 0 | 0 | `0 - 1 = -1` | `{-1, 0, 1, ...}` | signed (i64) | +| 1 | 1 | `1 - 1 = 0` | `{0, 1, 2, ...}` | unsigned (u64) | + +Starting at 0 requires a signed integer type everywhere offsets appear (wire, DB, SDK) and co-opts -1 as a sentinel with no semantic business meaning. Starting at 1 eliminates negative values entirely. + +## Decision Outcome + +**Consumer-visible broker sequences MUST start from 1 on a fresh `(topic, partition)`. Sequence 0 is never exposed as an event sequence.** + +This is a hard conformance requirement for every backend implementation (built-in and third-party), not a convention or default. A backend MAY use a different native position model internally, but it MUST translate that native position into the broker-logical sequence space before any value reaches a public broker surface. + +For Kafka-backed storage, native Kafka offset `N` maps to broker sequence `N + 1`; a broker cursor `N` resumes from native Kafka offset `N`. + +### Consequences + +**Cursor space is non-negative.** + +With sequences starting at 1, the retention floor RF ≥ 1 always. Therefore: + +``` +cursor ∈ {0, 1, 2, ...} + +cursor = 0 → "nothing processed yet; broker emits from RF" +cursor = N → "last processed event had sequence N; broker emits from N + 1" +``` + +**Valid SEEK range is always non-negative.** + +``` +valid range: [RF - 1, HWM] + = [≥ 0, HWM] (since RF ≥ 1) +``` + +No negative value is reachable on the wire, in the database, or in SDK types. + +**Cursor type may be u64.** + +Implementations MAY represent cursors as `u64` (unsigned 64-bit integer). Implementations that already use `i64` for cursors MUST enforce a `≥ 0` invariant. A future SDK design change may tighten this to `u64` across all layers. + +**Backend conformance contract.** + +Every storage backend that implements the `StorageBackend` trait MUST satisfy: + +- The first event visible through the broker on a fresh `(topic, partition)` has `sequence = 1`. +- Sequence 0 is never exposed as an event sequence in stream frames, query results, SEEK responses, topology frames, control frames, or SDK offset stores. +- On idempotent retry, previously persisted broker-logical sequences are returned as-is; no public sequence is assigned or re-assigned to 0. +- If the backend's native position space does not already satisfy this contract, the backend adapter owns the native-to-logical mapping at its boundary. + +A backend that exposes sequence 0 violates this ADR and breaks the cursor non-negativity guarantee for all consumers of that partition. + +### Confirmation + +Confirm backend implementations and SDK offset stores by checking that no public broker surface exposes sequence `0` as an event sequence and that fresh-topic `"earliest"` resolves to cursor `0` before emitting sequence `1`. + +## Pros and Cons of the Options + +### Sequences Start at 1 + +Pros: + +- Cursor space is non-negative. +- Fresh-topic `"nothing processed yet"` is represented as cursor `0`. +- Backend adapters have one explicit public mapping contract. + +Cons: + +- Backends with native zero-based positions must map native offsets at the boundary. + +### Sequences Start at 0 + +**Start at 0**: requires signed integer types; cursor = -1 as a "nothing yet" sentinel has no positive semantic meaning and creates edge cases in arithmetic (e.g., `cursor + 1 = 0` looks like "first sequence" but is actually "start of stream"). Rejected. + +### Leave Unspecified + +**Leave unspecified**: implicit assumptions about the floor diverge across backend implementations and SDK components. The gap was discovered during scenario review when `"earliest"` on a fresh topic was described as resolving to cursor = -1. Leaving it unspecified delays the conflict until runtime. Rejected. + +## More Information + +- DESIGN.md §3.1 "Offset Semantics" — normative vocabulary section derived from this decision +- ADR-0002 (partition-selection) — references `(topic, partition)` sequences without specifying their floor; this ADR is its prerequisite +- ADR-0006 (offset-authority) — establishes that consumers own their offset tracking; this ADR establishes what those offsets ARE diff --git a/gears/system/event-broker/docs/ADR/0002-partition-selection.md b/gears/system/event-broker/docs/ADR/0002-partition-selection.md new file mode 100644 index 000000000..5f762d08a --- /dev/null +++ b/gears/system/event-broker/docs/ADR/0002-partition-selection.md @@ -0,0 +1,265 @@ +--- +status: proposed +date: 2026-05-12 +decision-makers: Event Broker Team +revision-history: + - 2026-05-06 — initial draft (key-hash with explicit producer override + subject fallback) + - 2026-05-12 — revised (drop explicit `partition` override; broker re-hashes and is authoritative; `partition_key` is the producer-facing input selector) + - 2026-06-07 — default partition key changed from `subject` to `tenant` (PR #1978 review): a tenant's events are totally ordered by default; per-subject grouping is opt-in via explicit `partition_key` +--- + +# Partition Selection — Broker-Authoritative `partition_key` / `tenant_id` Input + + + +- [Context and Problem Statement](#context-and-problem-statement) +- [Decision Drivers](#decision-drivers) +- [Considered Options](#considered-options) +- [Decision Outcome](#decision-outcome) + - [Default Algorithm](#default-algorithm) + - [Partition-Key Source](#partition-key-source) + - [Hash Location](#hash-location) + - [Broker-Side Validation](#broker-side-validation) + - [Encoding](#encoding) + - [Consequences](#consequences) + - [Confirmation](#confirmation) +- [Pros and Cons of the Options](#pros-and-cons-of-the-options) + - [Broker-Authoritative `partition_key` / `tenant_id` Input (chosen)](#broker-authoritative-partition_key--tenant_id-input-chosen) + - [Earlier Draft — Add Explicit `partition` Producer Override (rejected on revision)](#earlier-draft--add-explicit-partition-producer-override-rejected-on-revision) + - [Always Derive Partition From `event.subject`](#always-derive-partition-from-eventsubject) + - [Round-Robin With No Key Affinity](#round-robin-with-no-key-affinity) + - [Custom Pluggable Partitioner Trait in SDK (MVP)](#custom-pluggable-partitioner-trait-in-sdk-mvp) +- [More Information](#more-information) +- [Traceability](#traceability) + + + +**ID**: `cpt-cf-evbk-adr-partition-selection` + +## Context and Problem Statement + +A topic in the Gears event broker is divided into a fixed number of partitions declared at topic registration time (`Topic.partitions`, see DESIGN §3.1). Every `Event` is bound to a partition; sequence assignment, ordering, idempotent-producer state, and consumer cursors are all scoped to `(topic, partition)`. The broker therefore needs a contract for **how partition assignment is computed** before the event is enqueued in the producer outbox and ultimately landed in `backend.persist`. + +The existing design imposes hard constraints on this decision: + +- `Topic.partitions` is **fixed at topic creation** and cannot be grown or shrunk on a live topic. Re-partitioning would break per-key ordering for every key already published; the migration path is "create new topic, dual-write, cut consumers over." Partition selection MUST therefore behave deterministically across the topic's full lifetime. +- The broker assigns the consumer-visible `event.sequence` per `(topic, partition)`. Producers never set `sequence`; they only carry chain state for ingest-side dedup in `meta.previous` and `meta.sequence` (see [ADR-0003 Event Schema](0002-event-schema.md)). +- `Event` carries `subject` (a per-event entity identifier) and `subject_type`. A dedicated `partition_key` field exists as an optional producer-supplied grouping input. +- Idempotent-producer state is keyed by `(producer_id, topic, partition)` (`evbk_producer_state`). The same producer publishing "the same logical event" on a retry MUST land on the same partition, otherwise the chain check fires against a state row that does not contain the previous attempt and the duplicate is admitted. + +The **initial 2026-05-06 draft** of this ADR allowed three paths to a partition value: `partition_key` (hashed), `subject` fallback (hashed), and an explicit `event.partition` producer override. The override path turned out to be a silent-ordering-bug surface — a producer refactor that switches a code-path from "set `partition_key`" to "set `partition` directly" quietly breaks per-key ordering on a live topic, and the SDK has no way to tell whether the producer *meant* to bypass the hash. Re-iteration during pre-implementation review removed the override path. This revision records the corrected contract. + +## Decision Drivers + +* Per-key order: events sharing a stable partition key MUST land on the same partition for the lifetime of the topic so consumers observe them in publish order +* Even distribution under unbiased keys: the chosen partition distribution SHOULD be approximately uniform across `[0, topic.partitions)` +* Idempotent retry determinism: a retry of the same logical publish MUST resolve to the same `partition`, otherwise idempotent-producer dedup degrades to "best effort" +* First-party SDK / broker parity: when an SDK computes a local partition hint for outbox routing, it must use the same input field as the broker and produce a value the broker can validate +* Operational simplicity: the broker stays a thin validator; the partition is computed deterministically from inputs already on the event +* No silent freedom: a producer should not be able to bypass the hash with an explicit partition number — this turned out to be a refactor-induced bug surface in the initial draft +* Schema extensibility: support legitimate use cases where the partition key differs from the event subject (per-tenant audit, system events with no business-domain subject, deliberate fan-out) + +## Considered Options + +* Broker-authoritative partition selection from `partition_key` by default, falling back to `tenant_id`; SDK-local partitioning is only a hint for first-party outbox routing (chosen — this revision) +* Earlier draft: key-hash with explicit `event.partition` producer override (rejected on revision) +* Always derive partition from `event.subject` (no `partition_key` field at all) +* Round-robin with no key affinity +* Custom pluggable partitioner trait exposed in the SDK at MVP + +## Decision Outcome + +Adopt **broker-authoritative partition selection from `partition_key` when present, otherwise `tenant_id`**. `partition_key` is the producer-facing way to choose a grouping input other than the default `tenant_id`. There is no explicit `partition` field on publish input. The broker computes the final topic partition; any producer SDK partition computation is an internal/local hint for outbox routing, not a native Kafka producer compatibility contract. + +Defaulting to `tenant` gives **per-tenant total ordering** out of the box — every event a tenant emits to a topic lands on one partition and is observed in publish order (the property the audit pipeline needs). Producers that want finer-grained grouping (per-subject, per-region, fan-out) set `partition_key` explicitly. + +### Default Algorithm + +The partition is computed deterministically from a single input: + +```text +partition_input = partition_key if partition_key is present + tenant_id otherwise +partition = local_derivation(ascii_bytes(partition_input)) % partition_count +``` + +- Current first-party SDK/broker implementation: **MurmurHash3 (32-bit, x86 variant)** with a fixed seed of `0x00000000`, masked with `& 0x7FFFFFFF` before modulo. This pins first-party SDK hints to broker validation but is not a native Kafka producer compatibility promise. +- The mask `& 0x7FFFFFFF` strips the sign bit so the modulo operates on a non-negative `u31` value and avoids the negative-modulo edge case in languages with signed `%`. +- The bytes hashed MUST be the **ASCII byte representation** of the input. Per the platform convention recorded in [ADR-0003 Event Schema § Event Field Encoding](0002-event-schema.md#event-field-encoding-ascii-only), all event string fields (`partition_key`, `subject`, etc.) are ASCII; UTF-8 is permitted only inside `data`. This eliminates UTF-8-vs-ASCII determinism concerns in the hash path. +- Producers MUST NOT provide a top-level topic `partition`. First-party SDKs that send an internal `meta.partition_hint` MUST compute it with the broker-supported local derivation for that broker version. + +### Partition-Key Source + +The `Event` schema carries an **optional, body-level** field: + +- **`partition_key: Option`** — producer-supplied, opaque (ASCII, ≤ 1024 bytes), used only for partition selection. Not validated against any GTS schema. Preserved on the read projection so consumers can see which grouping key the producer chose. + +Resolution rules: + +1. If `event.partition_key` is `Some(s)`, hash `s`. +2. Else, hash `event.tenant_id` (which is always present per the event schema contract). + +Since `tenant_id` is required on every event, there is no third case — no missing-input failure mode, no silent fallback. The default path is always defined. + +`tenant_id`, `subject`, and `partition_key` are conceptually different: + +- `tenant_id` identifies the tenant the event belongs to — the default co-location key, giving per-tenant ordering. +- `subject` identifies the entity the event is *about*. +- `partition_key` is the explicit grouping input controlling *co-location* when tenant-wide grouping is not the desired behavior. + +The tenant default fits the common platform case (audit, notifications, per-tenant streams) where a tenant's events should be totally ordered. Producers needing per-subject ordering set `partition_key = subject`; system events with no natural tenant grouping or deliberate fan-out for non-causal high-volume events set `partition_key` explicitly (e.g., `partition_key = uuid_v4()`). + +### Hash Location + +Partition selection happens in **both** the producer SDK and the broker: + +- **Producer SDK** computes the partition locally before calling `outbox.enqueue()`, so the `toolkit-db` outbox can route the row to the correct per-`(topic, partition)` outbox shard and preserve order. +- **Broker** re-computes the partition on ingest from `partition_key` / `tenant_id` in the received event. The broker's value is authoritative; if persisted at all, the SDK-computed value is treated as a hint only. + +This is a deliberate change from the initial draft (which had the SDK as the single computer of `partition`). The trade-offs: + +- Adds one Murmur3-32 hash to the ingest path (~ns-scale; negligible against the DB write that follows). +- Eliminates the producer-supplied partition surface (no explicit `partition` field that producers can stamp). +- Adds defense-in-depth against SDK bugs: if the SDK stamps an internal `meta.partition_hint` for outbox routing, the broker validates equality and returns `400 PartitionHashMismatch` on drift. + +The SDK may fetch `topic.partitions` once at startup (or on first publish to a topic) via `GET /v1/topics/{id}` when it needs a local topic-partition hint. The count is immutable per `Topic.partitions` design, so that cache does not go stale. This does not require producer-local outbox partitions, broker topic partitions, and ingest-service shards to have the same count; each partition domain derives its own local partition from the agreed input field and its own partition count. + +Example: + +```text +partition_input = partition_key if present else tenant_id + +producer local/outbox partition = local_derivation(partition_input) % producer_outbox_partitions +broker topic partition = broker_derivation(partition_input) % topic.partitions +ingest service shard = ingest_derivation(partition_input or topic/partition) % ingest_shard_count +``` + +These counts can legitimately differ, such as 16 producer outbox partitions, 64 broker topic partitions, and 8 ingest shards. + +### Broker-Side Validation + +On `POST /v1/events` and `POST /v1/events:batch`, the ingest service MUST: + +- Compute `partition` from `partition_key` (if present) or `tenant_id` using the broker-supported derivation for this broker version. +- Use the computed value as the authoritative partition for sequence assignment, `evbk_producer_state` lookup, and storage routing. +- Reject any publish carrying a top-level `partition` field with `400 BadRequest` (RFC 9457 problem type `gts.cf.core.errors.err.v1~cf.core.partition.forbidden.v1`). The wire contract DOES NOT accept a producer-supplied partition. +- If the publish carries `meta.partition_hint` (an internal SDK-stamped optimization), validate it equals the broker-computed value; reject mismatches with `400 PartitionHashMismatch` (RFC 9457 problem type `gts.cf.core.errors.err.v1~cf.core.partition.hash.mismatch.v1`). + +### Encoding + +All inputs to the hash are ASCII per [ADR-0003 § Event Field Encoding](0002-event-schema.md#event-field-encoding-ascii-only). The broker rejects publishes with non-ASCII bytes in `partition_key` or `tenant_id` with `400 InvalidEventFieldEncoding` before partition computation is attempted. `partition_key` is additionally length-capped at 1024 bytes (`400 EventFieldTooLong` on overflow). + +### Consequences + +- Good, because per-`tenant` ordering holds by default, with zero producer configuration — a tenant's events on a topic are totally ordered, which is the common platform need (audit, notifications, per-tenant streams). +- Good, because producers needing a different grouping (per-subject, per-region, fan-out) opt in by setting `partition_key` explicitly — no SDK fork, no custom partitioner. +- Good, because idempotent retries are deterministic: the same `(producer_id, partition_key | tenant_id)` resolves to the same `partition` and therefore to the same `evbk_producer_state` row, so chain dedup works as designed. +- Good, because future first-party SDKs can validate their local hints against broker fixtures without making native Kafka producer compatibility part of the publish contract. +- Good, because the broker is the authority on partition assignment, removing the silent-ordering-bug class that a producer-set `partition` override created in the initial draft. +- Good, because the broker stays a thin validator: the partition computation is a single line of code (hash + mask + mod), not a policy engine. +- Bad / accepted limitation, because **re-partitioning is not supported**. Once a topic is created with N partitions, the only way to change is the dual-write migration path. Deliberate match to Kafka semantics; consumers depend on stable key-to-partition mapping. +- Bad / accepted limitation, because **no per-topic partitioner choice in MVP**. Every topic uses the same Murmur3 algorithm. +- Bad / accepted limitation, because **hash collisions are accepted**. Two distinct partition keys can map to the same partition; intrinsic to modulo-hash partitioning. +- Bad / accepted limitation, because **a large tenant hot-spots its partition** under the tenant default — all of one tenant's events route to a single partition, so a high-volume tenant gets no intra-tenant parallelism and can become a noisy neighbour. Accepted in exchange for per-tenant ordering; the escape hatch is an explicit `partition_key` (e.g., `partition_key = subject`) for topics where a tenant's volume outweighs its ordering need. +- Bad / accepted limitation, because **adversarial keys can hot-spot a partition**. Murmur3 is not cryptographic, so producers must use authenticated, normalized identifiers with producer-controlled canonical representations rather than raw attacker-controlled free-form values. Canonical user-derived identifiers remain supported. The broker's threat model treats producers as authenticated trusted modules; opening ingest to untrusted producers requires a separately versioned keyed partition algorithm and migration design. +- Bad / accepted cost, because **the broker now spends one Murmur3-32 hash per ingest** that the SDK already computed. Murmur3-32 over ≤ 1024 ASCII bytes is sub-microsecond; negligible against the DB write that follows. Accepted in exchange for removing the producer-supplied partition surface. +- Bad / accepted limitation, because **producers that use `partition_key` inconsistently** (sometimes set, sometimes rely on the `tenant_id` default) can split ordering intent across different grouping levels. Mitigation: producer-author guidance — set `partition_key` consistently per logical entity when tenant-wide ordering is not the desired grouping. Documented in `docs/features/0001-idempotent-producers.md`. + +### Confirmation + +The decision is verified by: + +- **SDK unit tests** pinning the current first-party local derivation: known input → known partition for representative `partition_key` / `tenant_id` strings (ASCII printable, ASCII control bytes, empty `partition_key` falling back to `tenant_id`), with `topic.partitions` values 1, 2, 16, 64. The tests SHALL fail any future SDK change that drifts from the broker-supported derivation for that version. +- **Broker-side test** of the same fixture vector: the broker's re-hash matches the SDK's per-vector value bit-for-bit. +- **First-party SDK fixture vector** maintained in the SDK contract documentation: a list of `(input, partitions, expected_partition)` tuples (using `input = partition_key or tenant_id`) that any SDK sending `meta.partition_hint` MUST reproduce for the broker version it targets. Native Kafka producers writing directly to backend topics are outside this contract. +- **Broker rejection tests**: + - Publish with top-level `partition` field → `400 BadRequest` (`...partition.forbidden.v1`). + - Publish with `meta.partition_hint` that disagrees with broker's re-hash → `400 PartitionHashMismatch` (`...partition.hash.mismatch.v1`). + - Publish with non-ASCII bytes in `partition_key` or `tenant_id` → `400 InvalidEventFieldEncoding`. + - Publish with `partition_key` > 1024 bytes → `400 EventFieldTooLong`. +- **Idempotent-retry test**: a producer publishes with chained mode, retries the publish without the original network response, and the test asserts both attempts resolve to the same partition (so they hit the same `evbk_producer_state` row) and the second is rejected per the chain protocol (`412 SequenceViolation` for chain mismatch, `200 OK` for duplicate). + +## Pros and Cons of the Options + +### Broker-Authoritative `partition_key` / `tenant_id` Input (chosen) + +* Good, because it cleanly separates the partitioning concern (`partition_key`) from the semantic concern (`subject`), allowing legitimate divergence +* Good, because the producer-facing contract is field-level (`partition_key` else `tenant_id`) and does not depend on native Kafka producer behavior +* Good, because the broker is authoritative — no producer-set `partition` to drift on refactor +* Good, because the SDK still owns hashing for outbox routing, so per-`(topic, partition)` outbox order is preserved synchronously +* Good, because adding `partition_key` to the schema is a low-cost extension point that producers opt into when needed +* Bad, because Murmur3 is not cryptographic — adversarial keys can collide on one partition (accepted; producer threat model is "trusted modules") +* Bad, because the broker now hashes once per ingest (accepted; sub-microsecond cost) + +### Earlier Draft — Add Explicit `partition` Producer Override (rejected on revision) + +**Description**: The initial 2026-05-06 draft kept all three paths: `partition_key` hashed, `subject` fallback hashed, and explicit `event.partition` producer override. The override was justified for "deterministic test fixtures, replaying events from another system that already picked partitions, and operator-driven traffic shaping experiments." + +* Good, because the escape hatch covered niche use cases without bloating the default path +* Good, because producers replaying historical data could preserve the original partition numbers +* Bad / decisive against, because **a producer refactor that switches a code-path from "set `partition_key`" to "set `partition` directly" quietly breaks per-key ordering on a live topic**, and the broker has no way to tell whether the producer *meant* to bypass the hash. The producer override is a refactor-induced bug surface that's invisible in CI / staging and only manifests as a partition-ordering anomaly in production. +* Bad, because the original niche use cases re-decompose cleanly: + - **Test fixtures**: use literal `partition_key` values; the hash is deterministic. Same partition guaranteed by Murmur3. + - **Cross-system replay**: preserve the *source system's partition key*, not its partition number. Source N's partition layout is irrelevant once events land in our broker. + - **Operator-driven traffic shaping**: this is an operator-side concern, handled by operational tooling (replay job that re-emits events with synthesized `partition_key` values), not by a producer-facing API. +* Bad, because every niche use case the override served can be served by `partition_key` alone, but the silent-ordering-bug class only exists with the override path +* Captured as the rejected alternative; the revision moves all override semantics into "Rejected Alternatives" for the historical record + +### Always Derive Partition From `event.subject` + +**Description**: Drop `partition_key` from the schema; `partition = murmur3(subject) % N` always. + +* Good, because the schema is one field smaller +* Bad, because `subject` and partition key are not always the same — audit aggregation per tenant, system events with no domain subject, deliberate fan-out for non-causal events all need a different key +* Bad, because the alternative for producers needing a different grouping becomes "synthesize a different `subject`" — overloading the subject field, which is supposed to identify the entity the event is about +* Bad, because adding `partition_key` later is a one-way schema migration; not adding it now is a one-way lock-in + +### Round-Robin With No Key Affinity + +**Description**: SDK assigns `partition = next_counter % N` per call, ignoring keys. + +* Good, because partition utilization is even by construction +* Bad, because it violates the design's central per-topic-ordering guarantee; two events about the same subject end up on different partitions +* Bad, because idempotent producer retry becomes non-deterministic +* Bad, because the only legitimate niche (high-volume non-causal events that want even spread) is already covered by `partition_key = uuid_v4()` per event in the chosen design + +### Custom Pluggable Partitioner Trait in SDK (MVP) + +**Description**: Expose a `Partitioner` trait in `cf-gears-event-broker-sdk`; the default impl is Murmur3-mod-N; users can register their own. + +* Good, because it is maximally extensible +* Bad, because a pluggable partitioner that disagrees across producer instances on the same topic silently breaks per-key ordering — one Pod hashes with FNV, the other with Murmur3, and a fraction of keys land on different partitions +* Bad, because it expands the public SDK surface before any concrete second use case has been identified (YAGNI) +* Bad, because the broker is now authoritative on partition assignment — a custom SDK partitioner that disagrees with the broker's Murmur3 simply gets `400 PartitionHashMismatch` on every publish +* Captured as a post-MVP extension in [More Information](#more-information) if and when a real second use case appears + +## More Information + +- **Sticky-batch partitioning post-MVP**: Kafka 2.4+ offers a "sticky batch" partitioner that keeps consecutive keyless events on the same partition for batching efficiency, then rotates. Likely worth offering as an opt-in once the SDK gains true batch-publish performance work; deferred. +- **Pluggable Partitioner trait**: if a real second use case appears (e.g., a producer wanting weighted partition selection for hot-tenant isolation), the SDK could expose a `Partitioner` trait — but the broker would still be authoritative and reject mismatches, so any pluggable scheme would need an explicit broker-side contract. Decision deferred until a concrete request lands. +- **Hash function evolution**: Murmur3 has known weaknesses against adversarial inputs. The threat model treats producers as trusted, but if the broker ever opens to untrusted producers (e.g., a public ingest endpoint), it requires a separately versioned keyed partition algorithm and a migration plan that preserves existing topic assignments. Out of scope for MVP. +- **`meta.partition_hint`**: an internal SDK-stamped optimization the broker may accept to short-circuit re-hashing once cross-validated; not part of the public producer API. The SDK MAY omit it; the broker MUST handle its absence gracefully. + +External references: + +- MurmurHash3 reference (Austin Appleby): +- CloudEvents `subject` attribute (semantic for "what the event is about"; reinforces why `subject` and partition-key may differ): +- RFC 2119 — keyword definitions used above (MUST, SHOULD, MAY): +- RFC 9457 — Problem Details, used for error response shapes: + +## Traceability + +- **PRD**: [PRD.md](../PRD.md) + - `cpt-cf-evbk-fr-publish-single` — single-event publish; partition is broker-derived + - `cpt-cf-evbk-fr-publish-batch` — batch publish requires same `(topic, partition)` for all events (broker derives partition; producer guarantees a batch shares one `partition_key` or one `tenant_id`-default bucket) + - `cpt-cf-evbk-fr-producer-modes` — chained / monotonic dedup uses chain check on `evbk_producer_state(producer_id, topic, partition)`; partition determinism on retry is the dedup invariant +- **DESIGN**: [DESIGN.md](../DESIGN.md) + - §1.1 Architectural Vision — per-topic ordering centrality + - §2.1 Design Principles — Per-topic ordering, Immutable log + - §3.1 Domain Model — `Topic.partitions`, "Partition Count" subsection + - §3.2 Producer Modes — references [ADR-0004](0003-idempotent-producer-protocol.md) + - §3.6 Two Sequences — producer chain in `meta` / server-assigned `sequence` (per [ADR-0003](0002-event-schema.md)) + - `evbk_producer_state` — keyed by `(producer_id, topic, partition)` +- **Related ADRs**: + - [`0002-event-schema`](0002-event-schema.md) — canonical event shape; `partition_key` placement (body, optional); `partition` is `readOnly` (server-stamped on read) + - [`0003-idempotent-producer-protocol`](0003-idempotent-producer-protocol.md) — chain dedup is keyed by `(producer_id, topic, partition)`; partition determinism is the chain-correctness invariant diff --git a/gears/system/event-broker/docs/ADR/0003-event-schema.md b/gears/system/event-broker/docs/ADR/0003-event-schema.md new file mode 100644 index 000000000..afb842e42 --- /dev/null +++ b/gears/system/event-broker/docs/ADR/0003-event-schema.md @@ -0,0 +1,281 @@ +--- +status: proposed +date: 2026-05-12 +decision-makers: Event Broker Team +--- + +# Event Schema — Single Resource With Read/Write Field Markers And Optional `meta` Block + + + +- [Context and Problem Statement](#context-and-problem-statement) +- [Decision Drivers](#decision-drivers) +- [Considered Options](#considered-options) +- [Decision Outcome](#decision-outcome) + - [One Schema With Read/Write Field Markers](#one-schema-with-readwrite-field-markers) + - [Optional Versioned `meta` Block](#optional-versioned-meta-block) + - [Field-Level Changes](#field-level-changes) + - [Terminology Cleanup: `offset` → `sequence`](#terminology-cleanup-offset--sequence) + - [Event Field Encoding: ASCII Only](#event-field-encoding-ascii-only) + - [Optional CloudEvents Converter, Not Wire Conformance](#optional-cloudevents-converter-not-wire-conformance) + - [Codegen Note](#codegen-note) + - [Consequences](#consequences) + - [Confirmation](#confirmation) +- [Pros and Cons of the Options](#pros-and-cons-of-the-options) + - [Single Schema With Markers (chosen)](#single-schema-with-markers-chosen) + - [Keep Today's Single Schema Unchanged](#keep-todays-single-schema-unchanged) + - [Two Schemas (publish + read)](#two-schemas-publish--read) + - [Full CloudEvents Conformance](#full-cloudevents-conformance) +- [More Information](#more-information) +- [Traceability](#traceability) + + + +**ID**: `cpt-cf-evbk-adr-event-schema` + +## Context and Problem Statement + +The event broker's `schemas/event.v1.schema.json` today mixes four categories of fields onto one event: + +| Category | Fields | +|---|---| +| Event-semantic | `id`, `type`, `topic`, `source`, `subject`, `subject_type`, `occurred_at`, `trace_parent`, `data` | +| Partition-routing | `partition_key`, `partition` | +| Producer-protocol | `producer_id`, `previous`, `sequence` | +| Server-stamped | `offset`, `offset_time`, `tenant_id`, `created_at` | + +Three problems with this state, surfaced during pre-implementation review: + +1. **SDK authors cannot tell from the schema** which fields are publish input vs. read output. `readOnly: true` is convention-enforced; code generated from the schema gives a single struct with all fields together, and runtime validation cannot catch a producer that sets a server-stamped field. +2. **Producer-protocol fields leak transport mechanics to consumers.** `producer_id`, `previous`, and the producer-side `sequence` describe *how the event was published*, not *what the event is*. Surfacing them to consumers exposes producer-side state that should be opaque. +3. **The `offset` field and the `max_sequence` / `sent` / `received` / `acked` positions on the cursor model refer to the same value space but use different names.** Wire and cursor speak two dialects for one concept. + +The broker is still in design — unshipped, no production data, no live producers or consumers. This ADR locks in the canonical event schema before implementation begins. + +Reference specs surveyed for what categories of fields belong on an event at all (NOT for target wire-format conformance): + +- CloudEvents v1.0 core attributes (`id`, `source`, `specversion`, `type`) and optional (`subject`, `time`, `datacontenttype`, `dataschema`, `data`); `distributedtracing` extension. +- AsyncAPI message metadata conventions. +- Kafka record headers + key/value/timestamp split. +- NATS message metadata (subject, reply-to, headers). + +The decision is about *what fields exist on the broker's event* and *how the schema expresses per-direction (publish vs read) semantics*. Broker-native field names stay broker-native; CloudEvents wire conformance is explicitly rejected. + +## Decision Drivers + +* SDK authors MUST be able to tell from the schema alone which fields are inputs and which are outputs +* Consumers MUST NOT see producer-side transport mechanics (`producer_id`, `previous`, producer-side `sequence`) +* Single source of truth for the consumer-visible ordering key (wire and cursor agree on naming) +* The producer protocol MUST be able to evolve independently of the event schema +* Batch publish MUST support per-event chain values (each event in a contiguous chain has its own `previous` / `sequence`) +* No silent leak: ingest-internal data (e.g., the `created_at` accept timestamp) is observability data and does not need to live on the event record +* Broker stays as a thin validator: validation logic that can live in JSON Schema does + +## Considered Options + +* Single schema with field-level `readOnly` / `writeOnly` markers + optional versioned `meta` block + broker-native field names + optional external CloudEvents converter (chosen) +* Keep today's single schema unchanged +* Two schemas (publish + read), separate files +* Full CloudEvents conformance (broker adopts CloudEvents naming and field set) + +## Decision Outcome + +Adopt **a single canonical event JSON Schema (`schemas/event.v1.schema.json`) with field-level `readOnly` / `writeOnly` markers for per-direction semantics, an optional versioned `meta` block for publish-time transport mechanics, broker-native field names, and an *optional, external* CloudEvents converter for cross-broker interop if and when needed.** + +### One Schema With Read/Write Field Markers + +One JSON Schema replaces the publish-input / read-projection pair: + +- **`event.v1.schema.json`** is the single source of truth for the event resource shape. +- Field-level markers encode per-direction semantics: + - `meta`: `"writeOnly": true` — accepted on publish, stripped on read. + - `partition`, `sequence`, `sequence_time`: `"readOnly": true` — server-stamped on read, rejected with `400 BadRequest` if supplied on publish. + - All other fields round-trip without markers. +- The top-level `required` array is the **union** of publish-required and read-required fields. Per-direction enforcement is the broker's responsibility at the wire, NOT the schema's responsibility at validation. Strict-validator producers must filter `readOnly` fields before submission. + +The detailed field-by-field reference (descriptions, types, validators, examples) lives in [`DESIGN.md §3.1`](../DESIGN.md). This ADR records only the schema-shape decision and the per-direction-semantics mechanism. + +### Optional Versioned `meta` Block + +A top-level publish-input field `meta` (marked `writeOnly`) carries producer-protocol fields in a versioned, optional block: + +```jsonc +"meta": { + "version": 1, // required when meta is present + "producer_id": "", // chained / monotonic + "previous": , // chained only + "sequence": // chained / monotonic +} +``` + +- **Optional**: omit `meta` entirely for stateless publish. The simplest case (no broker dedup, no chain machinery) is the simplest wire. +- **Versioned**: `meta.version` lets the producer protocol evolve independently of the event schema. The broker accepts `meta.version <= current_supported`; rejects newer with `400 UnknownMetaVersion`. New producer-protocol fields land under a bumped `meta.version` without an event schema change. +- **Per-event in batches**: each event in a publish batch carries its own `meta`. Contiguous-chain batches "just work" — no per-event header gymnastics; HTTP headers cannot carry per-event values across a request. +- **Transport-agnostic**: same shape over REST, gRPC, message-queue replay, or file import. No header/body split per transport. +- **Stripped on read**: the public read API does NOT echo `meta` to consumers. Storage MAY retain `meta` for audit; the read projection layer strips it. The `writeOnly` marker makes this contract explicit in the schema. + +`meta` namespacing eliminates the body↔header duplication that an HTTP-header design (`Producer-Id` header vs. `event.producer_id` body field) would create — there is only one canonical location for each field. + +### Field-Level Changes + +Concrete edits to the event shape vs. today's `event.v1.schema.json`: + +| Field | Today | After this ADR | Rationale | +|---|---|---|---| +| `id`, `type`, `topic`, `source`, `subject`, `occurred_at`, `data` | body | body (unchanged) | Event-semantic, consumer-visible, names stay broker-native | +| `subject_type` | body | body (kept) | Carries entity-kind not derivable from `type` (e.g., generic `rule_applied` may apply to many subject kinds; body-less events have no `data` to introspect) | +| `trace_parent` | body | body (kept) | Distributed-tracing context is event-correlated, not publish-correlated; useful to consumers for post-hoc trace correlation | +| `partition_key` | body, optional | body, optional | Content-derived (producer-chosen grouping key); semantically close to `subject`; visible to consumers on read | +| `tenant_id` | body, `readOnly: true` | body, **producer-supplied** | A system service legitimately publishes events on behalf of arbitrary tenants (billing aggregator, audit emitter); authz delegated to platform resolver | +| `producer_id` | body | **moved to `meta`** | Producer-protocol mechanic; should not appear on the consumer-visible body | +| `previous` | body | **moved to `meta`** | Producer-protocol mechanic; per-event in batches | +| `sequence` (producer-side) | body | **moved to `meta`** | Producer-protocol mechanic; renamed under `meta` (the body-level `sequence` after this ADR is the server-assigned consumer-visible field, see "Terminology Cleanup") | +| `partition` | body, producer-set | body, `readOnly` | Broker derives from `partition_key` or `tenant_id` per [ADR-0002](0002-partition-selection.md); rejected on publish; surfaced on read | +| `offset` | body, `readOnly: true` | **renamed `sequence`**, `readOnly` | Wire / cursor terminology alignment | +| `offset_time` | body, `readOnly: true` | **renamed `sequence_time`**, `readOnly` | Same | +| `created_at` | body, `readOnly: true` | **dropped entirely** | Redundant between `occurred_at` (producer-stamped) and `sequence_time` (server-stamped); the ingest-accept moment is observability data, not event-record data | +| `meta` | (did not exist) | body, `writeOnly` | Producer-protocol block; stripped on read | + +### Terminology Cleanup: `offset` → `sequence` + +The server-assigned, consumer-visible ordering key is renamed: + +- `event.offset` → `event.sequence` +- `event.offset_time` → `event.sequence_time` + +The cursor model already uses `max_sequence`, `received`, `sent`, `acked` for the same value space. Renaming the field aligns wire and cursor. The DESIGN.md "Three Sequences" section (§3.6) collapses to **two**: + +1. **Producer chain** — `meta.previous`, `meta.sequence` (publish-time only, `writeOnly`, stripped on read). +2. **Server-assigned `sequence`** — consumer-visible ordering key per `(topic, partition)`, `readOnly`. + +The outbox sequence inside `toolkit-db` is internal plumbing and not part of the broker's wire surface. + +No collision between `meta.sequence` and body-level `sequence`: the `meta.` qualifier disambiguates. Within `meta`, `previous` and `sequence` are the producer-chain pair (the canonical naming idiom; renaming to `producer_sequence` would break that idiom unnecessarily). + +### Event Field Encoding: ASCII Only + +All event string fields (`id`, `type`, `topic`, `source`, `subject`, `subject_type`, `partition_key`, `trace_parent`, and all `meta.*` string fields) MUST be ASCII on the publish wire. UTF-8 is permitted only inside the `data` payload. The broker rejects publishes containing non-ASCII bytes in any event field with `400 InvalidEventFieldEncoding`. Per-field byte caps apply (e.g., `partition_key` ≤ 1024 bytes; `400 EventFieldTooLong` on overflow). + +This is a platform-wide convention, not a broker-specific choice. It keeps first-party partition-hint derivation deterministic without normalization concerns, and it keeps event-field parsing in any language cheap. + +### Optional CloudEvents Converter, Not Wire Conformance + +If cross-broker interop ever needs CloudEvents 1.0 wire format, AsyncAPI message bindings, or another standard, the broker event stays as-is and an **external converter** does the field-name translation at the boundary. Two viable shapes for that converter, neither built in this ADR: + +- SDK-side adapter that maps broker-native fields to/from CloudEvents (`occurred_at` ↔ `time`, `trace_parent` ↔ `traceparent`, etc.) at publish/consume boundaries. +- Broker-side projection endpoint `GET /v1/events:cloudevents` returning the CloudEvents 1.0 wire shape — separate codepath, separate schema, the canonical storage stays broker-native. + +This deliberately avoids paying CloudEvents-conformance cost in the broker's core path against a hypothetical future requirement. If/when such a requirement lands, it ships as an additive feature. + +### Codegen Note + +JSON Schema codegen tools vary in how they handle `readOnly` / `writeOnly`: + +- Tools that **honor** the markers produce two struct variants (or one struct with markers translated to language-level read/write controls). +- Tools that **ignore** the markers produce a single struct with all fields present, including server-stamped ones. + +For the codegen-ignores-markers case, producers using such SDKs MUST filter `readOnly` fields (`partition`, `sequence`, `sequence_time`) before submitting publish requests. The broker enforces the rule at the wire: any publish carrying a `readOnly` field is rejected with `400 BadRequest` naming the offending field. SDK contract docs make this requirement explicit. The opposite direction is symmetric: the broker never emits `writeOnly` `meta` on read, so consumers cannot accidentally observe producer-protocol state. + +### Consequences + +- Good, because SDK authors get a single schema with explicit per-direction markers; codegen tools that honor `readOnly` / `writeOnly` produce correct types; tools that don't are handled by SDK-side filters + broker-side wire enforcement. +- Good, because consumers never see `producer_id`, `previous`, or producer-side `sequence` — producer-side state is opaque to them by construction (the `meta` `writeOnly` marker + broker read-projection logic enforce this together). +- Good, because the producer protocol can evolve without bumping the event schema; new `meta.version`s ship behind feature flags on producer SDKs. +- Good, because batch publish ergonomics are uniform — each event carries its own `meta`; HTTP headers do not block batch chain support. +- Good, because wire and cursor agree on `sequence` terminology; the "Three Sequences" mental model collapses to two. +- Good, because the broker stays a thin validator: `required` + property markers in JSON Schema cover most of the shape; only runtime checks (mode lookup, partition re-hash, `readOnly`-on-publish rejection) need broker code. +- Good, because one schema file replaces two — no `$ref` duplication burden. +- Bad / accepted, because strict JSON-Schema validators that don't honor `readOnly` will demand `partition` on publish unless the SDK filters first. Mitigation: prose descriptions are explicit; broker `400 BadRequest` is unambiguous; SDK contract spells out the filter requirement. +- Bad / accepted, because `meta.sequence` (producer-side) and body-level `sequence` (server-assigned) share a word. Mitigation: the `meta.` qualifier disambiguates; documentation always shows the full path; `writeOnly` vs. `readOnly` markers reinforce the directional distinction. +- Bad / accepted, because the change requires editing DESIGN.md significantly (§3.1 field tables, §3.2 producer modes, §3.6 sequences) and the existing JSON Schema file. The change is unshipped, so this cost is one-time. +- Bad / accepted, because the ASCII-only rule rejects legitimate UTF-8 subject identifiers in some legacy systems. Mitigation: producers normalize to ASCII (e.g., URL-encoded forms, UUIDs, ULIDs) at the broker boundary; the platform convention applies broadly, not just to the broker. +- Neutral, because the optional CloudEvents converter is deferred. The cost of building it is paid only if and when real interop demand emerges. + +### Confirmation + +The decision is verified by: + +- **Schema codegen run**: generating Rust types from the single schema and confirming the producer-facing API on a `readOnly`-honoring tool excludes `partition` / `sequence` / `sequence_time` from publish constructors; the consumer-facing API excludes `meta`. +- **Round-trip test**: a producer publishes an event with `meta`; storage retains `meta`; the read response strips `meta` and surfaces server-assigned `partition`, `sequence`, `sequence_time`. Assertion: the consumer-visible body contains no producer-protocol fields. +- **Mode-shape rejection tests**: publishes carrying `partition`, `sequence`, `sequence_time` as top-level body fields are rejected with `400 BadRequest`. Publishes carrying `producer_id`, `previous`, producer-side `sequence` *outside* `meta` (i.e., as top-level body fields) are rejected with `400 BadRequest`. +- **Encoding tests**: publishes with non-ASCII bytes in any event field are rejected with `400 InvalidEventFieldEncoding`; publishes with UTF-8 inside `data` and ASCII elsewhere are accepted. +- **`meta.version` compatibility test**: a publish with `meta.version > current_supported` is rejected with `400 UnknownMetaVersion`; a publish with `meta.version <= current_supported` is accepted (assuming other validation passes). + +## Pros and Cons of the Options + +### Single Schema With Markers (chosen) + +* Good, because broker-native naming stays stable regardless of which external wire format is adopted downstream +* Good, because per-direction semantics are expressed inside the schema (markers + union `required`) plus enforced at the wire (broker rejects offending fields) +* Good, because one source of truth — no two-file maintenance burden +* Good, because batch ergonomics are uniform via per-event `meta` +* Good, because the producer protocol can evolve under `meta.version` without bumping the event schema +* Good, because optional converter pays no cost in the core path; only invoked at interop boundaries if and when needed +* Bad, because strict validators that don't honor `readOnly` mis-trip on publish (mitigated via SDK-side filter + broker enforcement) +* Bad, because `meta.sequence` vs. body `sequence` share a word + +### Keep Today's Single Schema Unchanged + +**Description**: Leave all 17 fields on a single schema with `readOnly` annotations; producer-protocol stays on body; `offset` keeps its name. + +* Good, because zero migration cost (the broker is unshipped, so this argument is weak) +* Bad, because SDK authors cannot tell input from output without inspecting `readOnly` annotations that codegen may strip +* Bad, because consumers see `producer_id`, `previous`, producer-side `sequence` — leak of transport mechanics +* Bad, because wire and cursor disagree on terminology (`offset` vs. `max_sequence`/`sent`/`received`/`acked`) +* Bad, because batch publish + HTTP-header alternatives are forced into convention-based handling; per-event values fundamentally don't fit in headers +* Bad, because the producer-protocol surface cannot evolve without bumping the event schema + +### Two Schemas (publish + read) + +**Description**: Maintain `event.v1.schema.json` for publish input and a separate read-side schema file. SDK authors get type-safe schemas; codegen produces two structs. + +* Good, because per-direction semantics are type-system-enforceable via separate files +* Bad, because two files must be kept in sync — duplicate field declarations (mitigated via `$ref`, but still maintenance) +* Bad, because the read schema was never `$ref`'d from `openapi.yaml`; it served as documentation-as-a-file, not as a wire contract. The intended codegen-separation benefit was never realized. +* Bad, because the publish/read split is essentially a per-direction `required` puzzle that JSON Schema `readOnly` / `writeOnly` markers already solve in one file + +### Full CloudEvents Conformance + +**Description**: Drop broker-native field names entirely. Rename `occurred_at`→`time`, `trace_parent`→`traceparent`, etc. Broker becomes a CloudEvents 1.0 transport; events on the wire are valid CloudEvents. + +* Good, because external interop is "free" — CloudEvents consumers can read events without translation +* Good, because the field set is normalized against a public standard +* Bad, because broker-specific concerns (topic identity, partition, idempotent-producer chain) need to be expressed as CloudEvents extensions — adds attribute-name boilerplate (`io.cyberfabric.broker.topic`) for every internal field +* Bad, because the per-direction surface still needs the marker treatment; CloudEvents conformance does not solve the input/output problem +* Bad, because CloudEvents-conformance cost is paid on every event on every code path, against a hypothetical future requirement; YAGNI +* Bad, because future CloudEvents-spec changes become broker-version dependencies + +## More Information + +- **Converter realization (post-MVP)**: when / if real interop demand emerges, the converter ships as either an SDK-side adapter (in `cf-gears-event-broker-sdk`) mapping broker-native ↔ CloudEvents at publish/consume boundaries, or a broker-side projection endpoint (`GET /v1/events:cloudevents`) returning CloudEvents 1.0 wire shape. Both are additive and require no change to the canonical storage shape. +- **AsyncAPI documentation export**: similarly post-MVP; an AsyncAPI 2.x document describing the broker's event schema + topic conventions can be generated from the JSON Schema file, with no schema-shape impact. +- **Per-type `data_schema`**: the per-event-type payload schema (the contents of the `data` field) is described by the event type's `data_schema` in the types registry. See [`DESIGN.md §3.1`](../DESIGN.md) for the concept relationship and the Validation Pipeline walkthrough. +- **Field-name stability commitment**: changes to the broker-native field names after this ADR is accepted require a major event schema version (`event.v2.schema.json`). Field additions and `meta.*` changes do not. + +External references: + +- CloudEvents v1.0 — core attributes + extension model (cited as reference for *what fields belong on an event*, NOT for wire-format conformance): +- CloudEvents `distributedtracing` extension: +- AsyncAPI 2.x message metadata: +- Apache Kafka — record header conventions: +- NATS message metadata (subject / reply-to / headers): +- W3C Trace Context (`traceparent` / `tracestate` header format used as the basis for the broker's `trace_parent` field validation): +- RFC 2119 / RFC 8174 — keyword definitions (MUST, SHOULD, MAY) +- RFC 9457 — Problem Details for HTTP APIs (used for error response shapes) + +## Traceability + +- **PRD**: [PRD.md](../PRD.md) + - `cpt-cf-evbk-fr-publish-single` — single-event publish accepts the event shape defined here + - `cpt-cf-evbk-fr-publish-batch` — batch publish accepts the event with per-event `meta` + - `cpt-cf-evbk-fr-producer-modes` — producer modes consume `meta.{producer_id, previous, sequence}` per [ADR-0004](0003-idempotent-producer-protocol.md) +- **DESIGN**: [DESIGN.md](../DESIGN.md) + - §3.1 Domain Model — `Event` field table is updated against this ADR + - §3.2 Producer Modes — references [ADR-0004](0003-idempotent-producer-protocol.md) + - §3.1 Validation Pipeline — end-to-end discovery + validation walkthrough + - §3.6 Three Sequences → "Two Sequences" (producer chain in `meta` + server-assigned `sequence`) +- **Related ADRs**: + - [`0002-partition-selection`](0002-partition-selection.md) — partition derivation contract + - [`0004-idempotent-producer-protocol`](0004-idempotent-producer-protocol.md) — producer modes / `meta`-block-shape enforcement / registration TTL / reset endpoint +- **Schemas**: + - [`schemas/event.v1.schema.json`](../schemas/event.v1.schema.json) — single canonical event schema (publish + read; per-direction semantics via `readOnly` / `writeOnly` markers) diff --git a/gears/system/event-broker/docs/ADR/0004-idempotent-producer-protocol.md b/gears/system/event-broker/docs/ADR/0004-idempotent-producer-protocol.md new file mode 100644 index 000000000..82e2f3e65 --- /dev/null +++ b/gears/system/event-broker/docs/ADR/0004-idempotent-producer-protocol.md @@ -0,0 +1,365 @@ +--- +status: proposed +date: 2026-05-12 +decision-makers: Event Broker Team +--- + +# Idempotent Producer Protocol — Mode Declared At Registration, Enforced Per Request + + + +- [Context and Problem Statement](#context-and-problem-statement) +- [Decision Drivers](#decision-drivers) +- [Considered Options](#considered-options) +- [Decision Outcome](#decision-outcome) + - [Three Modes, Wire Shapes](#three-modes-wire-shapes) + - [Registration: `POST /v1/producers`](#registration-post-v1producers) + - [Mode-Shape Enforcement at Publish Time](#mode-shape-enforcement-at-publish-time) + - [Mode Immutability](#mode-immutability) + - [Bootstrap Chain Value](#bootstrap-chain-value) + - [Chain Reset — Two Levers](#chain-reset--two-levers) + - [Producer Registration TTL](#producer-registration-ttl) + - [Stateless Safety Floor](#stateless-safety-floor) + - [Producer Concurrency](#producer-concurrency) + - [Producer Identity Principal Binding](#producer-identity-principal-binding) + - [Atomicity: Outbox Enqueue + State Update](#atomicity-outbox-enqueue--state-update) + - [Hard-Error Catalog](#hard-error-catalog) + - [Consequences](#consequences) + - [Confirmation](#confirmation) +- [Pros and Cons of the Options](#pros-and-cons-of-the-options) + - [Mode Declared At Registration (chosen — Option B)](#mode-declared-at-registration-chosen--option-b) + - [Inferred Per Request From `meta` Fields](#inferred-per-request-from-meta-fields) + - [Per-Request `meta.mode` Discriminator](#per-request-metamode-discriminator) +- [More Information](#more-information) +- [Traceability](#traceability) + + + +**ID**: `cpt-cf-evbk-adr-idempotent-producer-protocol` + +## Context and Problem Statement + +The event broker offers three producer modes for ingest-side idempotent publishing: + +- **chained**: per-event `previous` + `sequence`; broker enforces chain continuity; gap detection via the link. +- **monotonic**: per-event `sequence`; broker enforces strict advancement of `last_sequence`; gaps detected by the producer via cursor reconciliation. +- **stateless**: no broker-side dedup; consumer carries the idempotency burden. + +The initial design (DESIGN.md §3.2 Producer Modes) **inferred mode per request** from which fields the producer happened to set: + +| Mode | Producer sets | +|---|---| +| chained | `producer_id`, `previous`, `sequence` | +| monotonic | `producer_id`, `sequence` (no `previous`) | +| stateless | none | + +This mirrors the partition-override problem in [ADR-0002](0002-partition-selection.md): a producer refactor that drops `meta.previous` silently downgrades the producer from chained-mode dedup to monotonic-mode dedup with **no error and no observable signal** until a duplicate slips through. Same silent-switch hazard, different field. + +This ADR locks in **mode declared at registration**, with broker-side per-request enforcement, and resolves the satellite questions: bootstrap chain value, chain reset, producer-registration TTL, stateless safety floor, producer concurrency, principal binding, and the publish-time atomicity contract. + +The broker is unshipped — no production data, no live producers — so this ADR lands as a single coherent surface before implementation begins. + +## Decision Drivers + +* Explicit contract: producer mode must be a declared property, not an emergent property of which fields a producer happened to set +* Hard errors at the wire boundary: mode-shape violations reject the publish loudly, not silently weaken dedup +* Symmetry with partition selection: same principle ([ADR-0002 revised](0002-partition-selection.md)) — broker is authoritative, producer freedom is constrained to the choice the producer intended to make +* Principal binding: a `producer_id` is owned by the principal that created it; cross-principal use is rejected +* Per-event chain values in batches: chained-mode contiguous batches carry per-event `previous` and `sequence` +* Future-mode extensibility: new modes (e.g., post-MVP recent-`event.id` LRU stateless variant) ship as new registration values without changing the event schema +* Operator-driven reset must exist but must be auditable +* Idle producers should age out automatically so producer-state storage doesn't grow without bound + +## Considered Options + +* **Option A** — Inferred per request from `meta` fields (status quo of the initial design) +* **Option B** — Mode declared at `POST /v1/producers`, enforced per request (chosen) +* **Option C** — Per-request `meta.mode` discriminator (`meta.mode: "chained" | "monotonic" | "stateless"`) + +## Decision Outcome + +Adopt **Option B — mode declared at producer registration, enforced per request**. A producer registers once with `POST /v1/producers { "mode": "chained" | "monotonic" }`, the broker stores `mode` on the producer row, and every subsequent publish referencing that `producer_id` is validated against the stored mode. Mode-shape mismatches reject with `400`. Stateless mode does **not** register: no row, no `meta.producer_id` on publish, no broker-side dedup. + +### Three Modes, Wire Shapes + +The `meta` block (per [ADR-0003 Event Schema](0002-event-schema.md)) is the carrier for all producer-protocol fields. Mode determines its shape: + +```jsonc +// Chained — registered with POST /v1/producers { "mode": "chained" } +"meta": { "version": 1, "producer_id": "", "previous": 7, "sequence": 8 } + +// Monotonic — registered with POST /v1/producers { "mode": "monotonic" } +"meta": { "version": 1, "producer_id": "", "sequence": 8 } + +// Stateless — no registration, meta omitted entirely +// (no producer_id, no chain state, no broker dedup) +``` + +Within `meta`, `previous` and `sequence` are the producer-chain pair (no naming collision with the body-level `sequence` — the `meta.` qualifier disambiguates per [ADR-0003 § Terminology Cleanup](0002-event-schema.md#terminology-cleanup-offset--sequence)). + +#### When to Choose Each Mode + +For the purposes of this ADR, a **gap** is any missing value in a producer's sequence stream — intentional (a business-txn rollback, an operator deletion, a deliberate skip; re-sequencing is often not an option) or unintentional (lost in transit, out-of-order arrival, producer view diverging from broker view). All three modes accept intentional gaps. The modes differ in whether the broker can detect unintended ones. + +- **Stateless** — use when the consumer's processing is naturally idempotent (UPSERT, set-state-to-X, idempotent task triggers), or the producer is ephemeral (lambdas, short-lived workers), or events are non-causal. Broker holds no state, performs no dedup. +- **Monotonic** — use when the publish path is reliable / synchronous and the producer trusts its own counter. Broker accepts any `meta.sequence > last_sequence`; cannot distinguish intentional gaps from unintentional ones. Recovery on error: `GET /v1/producers/{producer_id}/cursors` → reconcile → resume. Cheaper than chained. +- **Chained** — use when the publish path is async / unreliable / windowed AND the producer needs the broker to detect unintended gaps. Producer fires a window, later polls `GET /v1/producers/{producer_id}/cursors`; re-publishes from the stall point if cursor didn't advance. Out-of-order arrival or transit loss surfaces as `412 SequenceViolation` carrying the broker's `last_sequence`. + +Rule of thumb: + +- "Consumer is idempotent; no broker dedup needed" → **stateless** +- "Synchronous publish, trust my counter; broker doesn't need to detect gaps for me" → **monotonic** +- "Async / unreliable hops; broker must detect unintended gaps and tell me where I stalled" → **chained** + +### Registration: `POST /v1/producers` + +- **Request body**: `{ "mode": "chained" | "monotonic", "client_agent": "" }`. Both fields are required. The body MUST NOT contain `producer_id` or `id` — broker-minted. +- **Response**: `201 Created`, body `{ "id": "", "mode": "", "client_agent": "" }`, header `Location: /v1/producers/`. +- **Authn**: requires an authenticated principal. The producer row records the principal as `owner_principal`. +- **Modes other than `chained` / `monotonic`** (including `stateless`) → `400 InvalidMode`. Stateless does not register. +- **`client_agent`** is an informational diagnostic hint — purely observational. The broker persists it on the producer row, returns it on the cursor and reset endpoints, and surfaces it in operational logs and metric labels. It does **not** participate in deduplication, authorization, ownership, or any other load-bearing decision; collisions across producers are allowed. Validation: ASCII, 1–256 bytes, conforms to RFC 9110 User-Agent grammar (`product *( RWS ( product / comment ) )`). Failure surfaces as the canonical RFC 9457 `400` problem type; no broker-specific error code. Immutable after create — no mutation endpoint exists. The HTTP `User-Agent` request header continues to be captured in access logs on every request independently of `client_agent` and is NOT a fallback for a missing `client_agent`. + +The caller persists the returned `id` (as `producer_id`) and distributes it to its producer fleet (DB, ConfigMap, env var, secret store — caller's choice of coordination mechanism). + +### Mode-Shape Enforcement at Publish Time + +On every publish, after authn but before any storage write: + +1. If `meta` is absent → stateless publish path. Broker does not consult the producer registry. Accept at face value (subject to event-schema validation). +2. If `meta` is present: + - If `meta.producer_id` is absent but other producer-protocol fields are present (`meta.previous` or `meta.sequence`) → reject `400 MetaWithoutProducerId`. + - If `meta.producer_id` is absent and no other producer-protocol fields → stateless publish path (treat as if `meta` were absent). + - If `meta.producer_id` is present: + - Look up the producer row. + - If not found → reject `400 UnknownProducer`. + - If owner principal does not match the calling principal → reject `403 ProducerPrincipalMismatch`. + - Validate shape against the stored mode: + +| Stored mode | Required in `meta` | Forbidden in `meta` | Error on mismatch | +|---|---|---|---| +| `chained` | `producer_id`, `previous`, `sequence` | — | `400 ChainModeFieldsMissing` | +| `monotonic` | `producer_id`, `sequence` | `previous` | `400 MonotonicModeFieldsViolation` | + +3. If `meta.version` exceeds the broker's supported version → reject `400 UnknownMetaVersion` (per [ADR-0003 § Optional Versioned `meta` Block](0002-event-schema.md#optional-versioned-meta-block)). + +After validation passes, mode-specific business rules apply: + +- **Chained**: accept iff `meta.previous == evbk_producer_state.last_sequence` AND `meta.sequence > last_sequence`. Chain mismatch → `412 SequenceViolation` carrying broker's `last_sequence`. Duplicate (`meta.sequence <= last_sequence`) → `200 OK` returning the original event_id. +- **Monotonic**: accept iff `meta.sequence > evbk_producer_state.last_sequence`. Duplicate (`meta.sequence <= last_sequence`) → `200 OK`. Gaps between sequences are accepted. + +### Mode Immutability + +`mode` is immutable for the lifetime of a `producer_id`. The broker exposes no operation that changes the mode of an existing producer row. To switch modes, a producer: + +1. Registers a fresh `producer_id` via `POST /v1/producers` with the new mode. +2. Distributes the new `producer_id` across its fleet. +3. Retires the old `producer_id` (its state ages out via the Reaper, see [Producer Registration TTL](#producer-registration-ttl)). + +Why immutable: reusing `evbk_producer_state` rows across modes is unsafe. The chained invariant (`previous` == `last_sequence`) does not hold against the monotonic gap-accepting rule, and switching mid-stream would cause both modes to misbehave on in-flight events. + +### Bootstrap Chain Value + +The first chained-mode publish for a `(producer_id, topic, partition)` triple — i.e., the publish that creates the `evbk_producer_state` row — establishes the chain. The contract: + +- The broker treats a missing `evbk_producer_state` row as `last_sequence = 0` (the row's logical default). +- The first chained event MUST set `meta.previous = 0` and `meta.sequence >= 1`. The broker accepts the publish and inserts the state row with `last_sequence = meta.sequence`. +- Any `meta.previous` value other than `0` on the first publish rejects with `412 SequenceViolation` (broker reports its known `last_sequence = 0`). +- The same bootstrap rule applies after a `:reset` (see below) or after the Reaper purges a stale `evbk_producer_state` row. + +Monotonic mode has no `previous` — the first publish simply requires `meta.sequence > 0` (since `last_sequence` defaults to `0`). + +### Chain Reset — Two Levers + +Two distinct paths exist for chain reset, serving different scenarios: + +#### Operator-driven reset: `POST /v1/producers/{producer_id}:reset` + +- **Request body** (optional): `{ "topic": "...", "partition": N }` to scope the reset to a single `(topic, partition)`. Body absent → reset all `evbk_producer_state` rows for the `producer_id`. +- **Authz**: owning principal only. Cross-principal → `403 ProducerPrincipalMismatch`. +- **Audit**: every reset emits an audit record (operator-driven destructive operation). +- **Effect**: deletes `evbk_producer_state` rows; next publish bootstraps fresh (see [Bootstrap Chain Value](#bootstrap-chain-value)). +- **`producer_id` is preserved**. The fleet does not need to redistribute a new id. + +Use case: the producer's fleet is alive and well, but the chain state on the broker side is wrong (or needs to be cleared for testing / debugging) and the producer can resume from sequence 1. + +#### Natural reset: Producer Registration TTL + +A producer's registration row carries `last_seen_at`, updated on every accepted chained / monotonic publish. The Reaper purges `evbk_producer` rows whose `last_seen_at` is older than the platform's producer-registration TTL (see [Producer Registration TTL](#producer-registration-ttl)). After purge, the `producer_id` is gone — the next publish referencing it gets `400 UnknownProducer`, the producer re-registers, distributes the new id, and continues. + +Use case: long-quiet producers (monthly batch job that hasn't run in 6 months) shouldn't keep their identity forever. The TTL forces a natural re-registration cycle. + +### Producer Registration TTL + +- Default TTL: platform-wide setting (initial proposed value `P30D` — 30 days). Configurable per-deployment. +- TTL is **per producer registration row**, not per `evbk_producer_state` row. The state rows have their own retention governed by topic-level `retention` (capped at `P14D`, per ADR-revised — see DESIGN.md). +- A producer's `evbk_producer.last_seen_at` is updated atomically with every accepted chained / monotonic publish. +- Reaper sweep cadence: bounded (default `PT5M`); exact cadence is implementation detail, not spec. +- Purge cascade: when an `evbk_producer` row is deleted, any orphaned `evbk_producer_state` rows for the same `producer_id` are also deleted in the same sweep. +- Post-purge publish: `400 UnknownProducer`. Producer must re-register and obtain a new `producer_id`. + +### Stateless Safety Floor + +In MVP, stateless mode performs **no broker-side dedup**. A publish-retry under stateless yields two persisted events; the consumer absorbs duplicates via idempotent processing. + +Documented loudly in the producer-author guidance. Stateless = "consumer carries idempotency." + +**Post-MVP extension** (not in this ADR): an opt-in recent-`event.id` LRU at registration time, e.g.: + +```jsonc +// Hypothetical future shape — NOT shipped at MVP +POST /v1/producers +{ "mode": "stateless", "dedup_window": "PT5M" } +``` + +Captured here so the registration body's future evolution is anticipated. If/when this lands, it adds a new mode value or a new registration field; the event schema is unaffected. + +### Producer Concurrency + +The broker supports **single-writer per `producer_id`**. HA producer topologies use external leader election (Raft, etcd, Consul, DB row lock — caller's choice). The broker does NOT provide: + +- Producer epochs / leases / fencing +- Cluster-wide active/standby coordination + +Concurrent writers sharing a `producer_id` will see chain failures (`412 SequenceViolation` for chained mode) or monotonic regressions (the lower-`sequence` writer gets `200 OK` duplicate, then catches up against the higher-sequence writer's state — natural divergence detector). This is documented as **misuse, not a bug**. + +This keeps the broker simple: sequence-ordering itself is the only guard. Adding epoch fields to `meta` was considered and rejected — see [More Information](#more-information). + +### Producer Identity Principal Binding + +The `owner_principal` is recorded on the producer row at `POST /v1/producers` time. Every endpoint that touches the producer's state (`POST /v1/events`, `GET /v1/producers/{id}/cursors`, `POST /v1/producers/{id}:reset`) validates the calling principal against `owner_principal`: + +- Match → proceed. +- Mismatch → `403 ProducerPrincipalMismatch`. + +`producer_id` is not a secret — it is distributed across the producer's fleet via the caller's coordination mechanism. The `403` is explicit ("you don't own this") rather than `404` (info-protective); concealing existence offers no real security benefit because the id is intentionally widely distributed. + +Cursor-endpoint authz: ownership IS the authz check. No separate permission constant beyond the principal-ownership rule. + +### Atomicity: Outbox Enqueue + State Update + +For accepted chained / monotonic publishes, the broker performs the ingest-outbox enqueue and the `evbk_producer_state` update **in one transaction**: + +```sql +BEGIN; + INSERT INTO outbox(...) VALUES (...); -- enqueue for the dispatcher + UPDATE evbk_producer_state + SET last_sequence = $meta_sequence, last_seen_at = now() + WHERE producer_id = $pid AND topic = $topic AND partition = $partition; + UPDATE evbk_producer + SET last_seen_at = now() + WHERE producer_id = $pid; +COMMIT; +``` + +Outcomes by failure point: + +1. **Producer business txn commit / outbox enqueue split** — handled by `toolkit-db`'s transactional outbox at the producer side; not a broker concern. +2. **Outbox → ingest network failure mid-publish** — producer SDK retries; broker dedups via chain check (chained) or `sequence` check (monotonic) and returns `200 OK` with original event_id. +3. **Ingest crash between enqueue and state update** — single transaction; commits all-or-nothing; producer sees publish failure and retries. +4. **Producer restart with in-flight outbox rows** — producer SDK resumes from its outbox; broker dedups via mode-specific check. + +This atomicity is the central invariant of the "exactly-once via idempotent producer" claim. A publish that returns `200 OK` / `202 Accepted` guarantees BOTH the outbox row is persisted AND the chain state has advanced (or, in stateless, the event has been accepted for storage without chain state). + +### Hard-Error Catalog + +| HTTP | Code | When | Recovery | +|---|---|---|---| +| 400 | `BadRequest` | Top-level forbidden field (producer-protocol, backend-assigned, or explicit `partition`) | Fix request shape | +| 400 | `InvalidMode` | Registration with mode other than `chained` / `monotonic` | Use a valid mode (stateless = no registration) | +| 400 | `ChainModeFieldsMissing` | Chained-mode publish missing `meta.previous` or `meta.sequence` | Fix request shape | +| 400 | `MonotonicModeFieldsViolation` | Monotonic-mode publish with forbidden `meta.previous` or missing `meta.sequence` | Fix request shape | +| 400 | `MetaWithoutProducerId` | `meta` carries `previous` / `sequence` but no `producer_id` | Either omit `meta` entirely (stateless) or include `meta.producer_id` | +| 400 | `UnknownProducer` | `meta.producer_id` not found in registry (or aged out by TTL) | Re-register and distribute new id | +| 400 | `UnknownMetaVersion` | `meta.version` exceeds broker's supported version | SDK rolls back to a supported version | +| 400 | `InvalidEventFieldEncoding` | Non-ASCII bytes in event field | Sanitize input | +| 400 | `EventFieldTooLong` | Event string field exceeds length cap | Sanitize input | +| 400 | `RetentionExceedsMaxSpan` | Topic created/updated with `retention > P14D` | Lower the value | +| 403 | `ProducerPrincipalMismatch` | Cross-principal publish / cursor read / reset | Use the owning principal | +| 403 | `TenantIdNotAuthorized` | Platform authz resolver denied the `tenant_id` | Acquire grant (platform-side) | +| 412 | `SequenceViolation` | Chained mode: `meta.previous != last_sequence` | `GET /v1/producers/{id}/cursors` → reconcile → resume | + +### Consequences + +- Good, because mode-switch bugs become hard errors at the wire boundary instead of silent dedup degradation +- Good, because the wire `meta` block has three crisp shapes, one per mode, each enforced by JSON Schema `oneOf` + broker-side mode lookup +- Good, because the `:reset` lever + producer-TTL lever cover both operator-driven and natural reset scenarios without forcing one paradigm +- Good, because future modes (post-MVP stateless-with-LRU, or future "monotonic-with-gap-rejection") slot in as new registration mode values without changing the event schema +- Good, because principal binding is enforced uniformly across publish, cursor read, and reset +- Bad / accepted, because registration is now a required step for chained / monotonic producers (already true today; one extra HTTP call at first-publish) +- Bad / accepted, because mode immutability forces re-registration to switch modes (deliberate friction; mode-switching is rare and dangerous) +- Bad / accepted, because the no-fencing concurrency model means concurrent writers on the same `producer_id` cause data anomalies that surface as `412` / monotonic regressions, not as a clean error. Mitigation: producer-author guidance ("single-writer per `producer_id`"). +- Neutral, because `POST /v1/producers/{id}:reset` is destructive and authz-fenced; operators must explicitly choose to call it, and every call is audited. + +### Confirmation + +The decision is verified by: + +- **Wire-shape rejection tests** for each hard-error code listed above, with sample valid + invalid payloads. +- **Mode-shape enforcement test matrix**: chained / monotonic / stateless × valid / missing-field / forbidden-field / wrong-mode-for-registered-id. Every cell produces the documented outcome. +- **Atomicity test**: simulate ingest crash between outbox enqueue and state update; verify no half-state visible after recovery. +- **Reset audit test**: every successful `:reset` call produces an audit record with operator principal + timestamp + scope. +- **TTL reap test**: idle producer's row is reaped after the TTL window; next publish gets `400 UnknownProducer`. +- **Principal binding test**: cross-principal calls to publish / cursor read / reset all return `403 ProducerPrincipalMismatch`. +- **Concurrent writer test**: two writers sharing a `producer_id` produce `412 SequenceViolation` (chained) or monotonic regression (monotonic) without broker error — documented behavior. + +## Pros and Cons of the Options + +### Mode Declared At Registration (chosen — Option B) + +* Good, because mode is an explicit declared property, not an emergent one +* Good, because mode-shape violations are hard `400`s, not silent dedup degradation +* Good, because future modes plug in as new registration values without event-schema churn +* Good, because principal binding has a natural home (the producer row) +* Bad, because chained / monotonic publish requires registration (one-time setup); stateless does not +* Bad, because switching modes requires re-registration (deliberate friction) + +### Inferred Per Request From `meta` Fields + +**Description**: The status-quo of the initial design. Mode is per-request, inferred from which fields the producer happens to set. No registration required for any mode. + +* Good, because no registration round-trip for chained / monotonic +* Good, because the wire is self-describing per event +* Bad / decisive against, because **a producer refactor that drops `meta.previous` silently downgrades chained → monotonic** with no error and no signal; same silent-switch hazard as the partition override removed in [ADR-0002 (revised)](0002-partition-selection.md) +* Bad, because principal binding has no natural home; ad-hoc per-publish lookup of "who owns this `producer_id`?" against an implicit registry +* Bad, because future modes require adding new per-request fields or discriminators + +### Per-Request `meta.mode` Discriminator + +**Description**: `meta.mode: "chained" | "monotonic" | "stateless"` on every event; broker validates required fields per declared mode. + +* Good, because mode is explicit per request — no hidden inference +* Bad, because per-event mode-switching is not a real use case; producers stay in one mode for their lifetime +* Bad, because it adds yet another inconsistency vector: a producer that sets the right fields but the wrong `meta.mode` (or vice versa) gets a confusing error +* Bad, because it duplicates information that the registration model captures once +* Bad, because principal binding is still unsolved (need a separate registry mechanism anyway) +* Captured for completeness; the registration model dominates on every axis + +## More Information + +- **Producer epochs / leases** (rejected): adding a `meta.producer_epoch` field plus broker-side epoch tracking would enable clean active/standby failover (standby bumps epoch on takeover; broker rejects stragglers). Rejected for MVP — adds wire-field surface and broker complexity for an HA scenario that the platform already handles via external leader election. Captured as a potential future extension if real concurrent-writer pain emerges. +- **Stateless LRU** (post-MVP): an opt-in recent-`event.id` cache at registration time (`{ mode: "stateless", dedup_window: "PT5M" }`) is captured as a future addition. Ships as a new mode value or a new registration field; no event-schema change. +- **Mode evolution**: new modes after MVP (e.g., `monotonic-strict` that rejects gaps) are added as new registration values. The mode-shape table grows; existing producers continue to work unchanged. +- **Reset audit log destination**: the `:reset` audit record's storage location is a platform concern (per existing audit infrastructure), not specified here. +- **`evbk_producer` row shape**: minimum fields are `producer_id` (PK, broker-minted UUID), `owner_principal`, `mode`, `client_agent`, `created_at`, `last_seen_at`. `client_agent` is required, persisted, and immutable. Implementation may add observability columns; not constrained by this ADR. + +External references: + +- Apache Kafka — idempotent producer protocol (producer-id + sequence + epoch model): +- RFC 2119 / RFC 8174 — keyword definitions (MUST, SHOULD, MAY) +- RFC 9457 — Problem Details for HTTP APIs +- W3C Trace Context (`traceparent`) — referenced by [ADR-0003 § Field-Level Changes](0002-event-schema.md#field-level-changes) + +## Traceability + +- **PRD**: [PRD.md](../PRD.md) + - `cpt-cf-evbk-fr-producer-modes` — three producer modes; chained / monotonic dedup; principal binding + - `cpt-cf-evbk-fr-publish-single` — single-event publish carries `meta` per this ADR's wire shapes + - `cpt-cf-evbk-fr-publish-batch` — batch publish carries per-event `meta`; chained-mode batches are contiguous-chain (see [ADR-0003](0002-event-schema.md)) +- **DESIGN**: [DESIGN.md](../DESIGN.md) + - §3.2 Producer Modes — shrunk to summary + link to `docs/features/0001-idempotent-producers.md` + - §3.6 Two Sequences — producer chain in `meta` (per this ADR); server-assigned `sequence` (per [ADR-0003](0002-event-schema.md)) + - §3.7 Database schemas — `evbk_producer` row shape (this ADR); `evbk_producer_state` row shape (existing); both governed by [ADR-0003](0002-event-schema.md) field-level changes +- **Related ADRs**: + - [`0002-partition-selection`](0002-partition-selection.md) — partition derivation contract; chain dedup invariant ((producer_id, topic, partition) determinism on retry) + - [`0002-event-schema`](0002-event-schema.md) — canonical event shape; `meta` block placement (`writeOnly`); `tenant_id` flips to producer-supplied; `subject_type` stays; ASCII encoding rule +- **Feature doc**: [`docs/features/0001-idempotent-producers.md`](../features/0001-idempotent-producers.md) — CDSL flows, mode-choice producer-author guidance, acceptance criteria, test plan diff --git a/gears/system/event-broker/docs/ADR/0005-subscription-filter-typing.md b/gears/system/event-broker/docs/ADR/0005-subscription-filter-typing.md new file mode 100644 index 000000000..70b4b069f --- /dev/null +++ b/gears/system/event-broker/docs/ADR/0005-subscription-filter-typing.md @@ -0,0 +1,388 @@ +--- +status: proposed +date: 2026-05-14 +decision-makers: Event Broker Team +--- + +# Subscription Filter Typing — Topic-Anchored Interests With GTS-Typed Filter Engines + + + +- [Context and Problem Statement](#context-and-problem-statement) +- [Decision Drivers](#decision-drivers) +- [Considered Options](#considered-options) +- [Decision Outcome](#decision-outcome) + - [Interest Body Shape](#interest-body-shape) + - [Type-Pattern Syntax (per GTS spec)](#type-pattern-syntax-per-gts-spec) + - [Pattern Resolution and Version Selection](#pattern-resolution-and-version-selection) + - [Filter-Engine Plugin Pattern](#filter-engine-plugin-pattern) + - [CEL Engine Filter Context (v1)](#cel-engine-filter-context-v1) + - [Filter Limits (compile-time constants in v1)](#filter-limits-compile-time-constants-in-v1) + - [JOIN Validation Order](#join-validation-order) + - [Per-Event Delivery Evaluation](#per-event-delivery-evaluation) + - [Authorization](#authorization) + - [Acknowledged GTS Features](#acknowledged-gts-features) + - [Consequences](#consequences) + - [Confirmation](#confirmation) +- [Pros and Cons of the Options](#pros-and-cons-of-the-options) + - [Topic-Anchored Interests With Typed Filters (chosen)](#topic-anchored-interests-with-typed-filters-chosen) + - [Keep Parallel Topics and Filters Arrays (status quo)](#keep-parallel-topics-and-filters-arrays-status-quo) + - [Event-Type-Centric Only (drop `topic` from interest)](#event-type-centric-only-drop-topic-from-interest) +- [More Information](#more-information) +- [Traceability](#traceability) + + + +**ID**: `cpt-cf-evbk-adr-subscription-filter-typing` + +## Context and Problem Statement + +The current consumer subscription API (`POST /v1/subscriptions`, per `schemas/subscription.v1.schema.json`) carries `topics: [string]` and `filters: [string]` as **two parallel arrays**. Three problems with that surface: + +1. **Parallel arrays with no declared mapping.** The JSON Schema does not specify whether `filters[i]` applies to `topics[i]`, to all of `topics`, or to none. Prose in `features/0002-consumer-subscription-lifecycle.md` is ambiguous; the rolling-deploy scenario (v1 with `(topics=T1, filters=F1)`, v2 with `(T2, F2)`) becomes guess-work as soon as a member subscribes to more than one topic. +2. **Untyped filter expressions.** `filters[i]` is a bare string, implicitly CEL, with no declared expression language, no engine-extensibility story, no validation contract. Adding a second engine (starlark, rego, jsonpath) would break the wire. +3. **No event-type filtering on the wire.** Today the broker delivers every event on a subscribed topic and relies on the filter expression to select. For consumers that want a narrow type-set (e.g., "orders.placed.v1.x"), this forces them to encode the type check inside the CEL string, repeated for every interest. The broker can't skip non-matching types before invoking the engine. + +The broker is unshipped. Re-iterating the consumer subscription surface now - before any caller has integrated - is the right time to make the wire shape explicit and stable. + +## Decision Drivers + +* **Unambiguous wire shape**: which filter applies to which topic must be self-evident from the JSON, not inferred. +* **Engine extensibility**: support CEL today, starlark / rego / vendor-custom tomorrow, without breaking the wire when a second engine ships. +* **Topic-anchored model** (Kafka-style): topics are the partition / rebalance / authz unit; explicit on the wire. +* **Two-level type filtering**: events the consumer doesn't pattern-match on `types[]` should be skipped before invoking the engine (cheap), with the engine reserved for richer predicates over `event.data` etc. +* **GTS spec compliance**: type patterns follow `GlobalTypeSystem/gts-spec` §10 wildcard rules exactly — no broker-specific extensions. +* **Rolling-deploy unambiguity**: different members of the same consumer group can declare different interest sets without positional-correspondence games. +* **Symmetric plugin pattern**: filter engines plug in via the same GTS-typed `types_registry` + `ClientHub` resolution as storage backends and OAGW auth/guard/transform plugins. + +## Considered Options + +* Topic-anchored `interests[]` with typed filter engines and GTS-spec-compliant patterns (chosen). +* Keep parallel `topics[]` + `filters[]` arrays; add a per-filter `type` field as a back-compat extension (rejected — preserves the mapping ambiguity). +* Event-type-centric only: drop `topic` from the interest, derive it from `types_registry` via the resolved event type → parent topic (rejected — silent cross-topic-wildcard hazard; topic-level authz becomes per-type-only; topology resolution couples to the registry on every JOIN). + +## Decision Outcome + +Adopt **topic-anchored `interests[]` with GTS-typed filter engines**. The JOIN body becomes: + +```jsonc +{ + "consumer_group": "gts.cf.core.events.consumer_group.v1~", + "client_agent": "", + "session_timeout": "PT30S", + "interests": [ + { + "topic": "gts.cf.core.events.topic.v1~yourorg.orders.v1", + "tenant_id": "", + "types": ["gts.cf.core.events.event.v1~yourorg.orders.placed.v1"], + "expression_type": "gts.cf.core.events.filter.v1~cf.core.expression.cel.v1", + "expression": "event.data.amount > 100" + }, + { + // No filter expression — match every event of these types under this tenant. + "topic": "gts.cf.core.events.topic.v1~yourorg.refunds.v1", + "tenant_id": "", + "types": ["gts.cf.core.events.event.v1~yourorg.refunds.*"] + } + ] +} +``` + +### Interest Body Shape + +Three required fields + two paired-optional fields. No capability flags. + +| Field | Cardinality | Description | +|---|---|---| +| `topic` | required | Full GTS topic identifier. Partition / rebalance / authz unit (Kafka-style). | +| `tenant_id` | required | UUID. Tenant scope. Authz-validated by the platform tenant resolver. Different interests MAY declare different tenant_ids. | +| `types` | required, non-empty | Array of GTS event-type-instance patterns. Scoped to `topic`. Wildcards per GTS §10. | +| `expression_type` | optional (paired with `expression`) | Full GTS identifier extending `gts.cf.core.events.filter.v1~`. Always full GTS — no short-discriminator shortcut. | +| `expression` | optional (paired with `expression_type`) | Engine-specific source string. | + +**Paired-optional rule**: `expression_type` and `expression` are both present (engine-typed filter) or both absent (no filter beyond topic + tenant + types). Exactly one present → `400 BadRequest`. An interest without the paired filter fields delivers every event matching topic + tenant + types — no engine round-trip. + +**Multiple interests OR together**: an event matches a subscription if at least one interest matches. Consumers wanting AND-of-two-predicates encode both in the single `expression`. + +### Type-Pattern Syntax (per GTS spec) + +Per `GlobalTypeSystem/gts-spec` README §10 "Collecting Identifiers with Wildcards" (verified rules 1–5 + conformance tests): + +- The wildcard `*` MAY be used **at most once**. +- The wildcard MUST appear at the **end** of the pattern. +- The wildcard MUST start at a **segment boundary** (`.` or `~`). +- Per rule 4, the wildcard MAY follow the `v` that begins the version segment (`placed.v*` is valid; the `v` is the segment start, `*` is trailing). +- Mid-pattern wildcards, substring wildcards within a segment (`vendor*`), and multi-segment wildcards (`**`) are rejected. + +``` +type_pattern = exact_pattern | wildcard_pattern +exact_pattern = full GTS event-type instance identifier +wildcard_pattern = gts_prefix ("." | "~") "*" +gts_prefix = canonical GTS prefix ending at a segment boundary + (.vendor | .package | .namespace | .name | .v | .v. | ~) +``` + +Valid examples (under topic `~yourorg.orders.v1`): + +- `gts.cf.core.events.event.v1~yourorg.orders.placed.v1` — exact match. +- `gts.cf.core.events.event.v1~yourorg.orders.placed.v1` matches every `placed.v1.x` (minor-version-omitted matching; see below). +- `gts.cf.core.events.event.v1~yourorg.orders.placed.v*` — all versions of `placed` (rule 4). +- `gts.cf.core.events.event.v1~yourorg.orders.placed.*` — broader: all continuations of `placed.`. +- `gts.cf.core.events.event.v1~yourorg.orders.*` — all event types under `yourorg.orders` namespace. +- `gts.cf.core.events.event.v1~*` — all event types of this schema (broker's `parent_topic` filter restricts to `interest.topic`). + +Rejected examples → `400 BadTypePattern`: + +- `~yourorg.orders.*.v1` — mid-pattern wildcard. +- `~yourorg.**` — multi-segment wildcard. +- `~vendor*` — substring wildcard within a segment. +- `~yourorg.orders.*~*` — two wildcards. + +### Pattern Resolution and Version Selection + +At JOIN, the broker: + +1. Filters `types_registry` entries to those with `parent_topic == interest.topic`. +2. Applies each pattern in `interest.types[]` against that filtered set. +3. **Per-name latest** rule: among matches, select the highest version per `(vendor.package.namespace.name)` tuple. +4. **Minor-version-omitted matching** (GTS §10 rule 5, applied in v1): a pattern that pins only the major version (e.g., `placed.v1` without explicit minor) matches every registered minor of that major; per-name latest then picks the highest minor (e.g., `placed.v1.7` over `placed.v1.0`). A higher major (`placed.v2.0`) is **not** picked unless the pattern explicitly admits it. +5. **Type-belongs-to-topic** defense-in-depth: every resolved type's `parent_topic` MUST equal `interest.topic`; mismatch → `400 TypeNotInTopic`. +6. **Empty match**: a pattern resolving to zero registered types → `400 NoTypesMatched`. All-or-nothing — partial JOINs over the subset that did match are NOT permitted. +7. **Pinned at JOIN**: newly-registered types matching the pattern after JOIN do NOT auto-subscribe. Consumer re-JOINs to refresh. + +### Filter-Engine Plugin Pattern + +Mirrors storage backends and OAGW plugins: + +- **Base type**: `gts.cf.core.events.filter.v1~` (registered via `types_registry`). +- **Built-in v1 engine**: `gts.cf.core.events.filter.v1~cf.core.expression.cel.v1`. +- **Plugin trait** (in `cf-gears-event-broker-sdk`): + +```rust +#[async_trait] +pub trait FilterEngine: Send + Sync { + /// Compile an expression. Called at JOIN per interest. Stateless beyond the returned CompiledFilter. + fn compile( + &self, + expression: &str, + max_length_bytes: usize, + ) -> Result; + + /// Evaluate a compiled filter against an event context. Per (event, interest) at delivery. + /// MUST complete within EVAL_TIMEOUT_MICROS. + fn eval( + &self, + compiled: &CompiledFilter, + ctx: &FilterContext, + ) -> Result; +} + +pub struct CompiledFilter { + inner: Box, // engine-specific compiled form; opaque to broker + compiled_size_bytes: usize, // engine-reported, for accounting +} + +pub enum FilterError { + CompileFailed { diagnostic: String }, + ExpressionTooLong, + EvalTimeout, + EvalRuntime { diagnostic: String }, +} +``` + +- **Resolution at JOIN**: broker reads `interest.expression_type`, resolves the engine via `ClientHub`, calls `engine.compile(interest.expression, MAX_EXPRESSION_LENGTH_BYTES)`, stores the compiled handle in the subscription's cache entry alongside the resolved type set and tenant_id. +- **Per-event delivery**: broker iterates `(prerequisite_check, compiled_handle)` pairs; on first match, short-circuit. + +### CEL Engine Filter Context (v1) + +The CEL engine binds one variable `event` whose fields are the read-side event (the same `event.v1.schema.json` resource consumers receive — `meta` is stripped on read via its `writeOnly` marker; `partition`/`sequence`/`sequence_time` are server-stamped via `readOnly`): + +| CEL identifier | Type | Source | +|---|---|---| +| `event.id` | string (UUID) | publish input | +| `event.type` | string (GTS) | publish input | +| `event.topic` | string (GTS) | publish input | +| `event.tenant_id` | string (UUID) | publish input (authz-validated) | +| `event.source` | string | publish input | +| `event.subject` | string | publish input | +| `event.subject_type` | string (GTS) | publish input | +| `event.occurred_at` | timestamp | publish input | +| `event.partition_key` | string (optional) | publish input | +| `event.partition` | int | broker-derived | +| `event.sequence` | int (i64) | backend-assigned | +| `event.sequence_time` | timestamp | backend-assigned | +| `event.trace_parent` | string (optional) | publish input | +| `event.data` | map / dyn | publish input (per the event type's `data_schema`) | + +`meta` is **never** in scope (producer-protocol; stripped on read projection per ADR-0003). `event.data` access uses CEL's standard `dyn` typing; missing-field access surfaces as `FilterError::EvalRuntime` and is treated as a non-match for the offending interest. + +### Filter Limits (compile-time constants in v1) + +All values are Rust `const`s. Runtime configurability is deferred — once workloads characterize the typical bounds, v2 lifts them to deployment config. + +- `MAX_INTERESTS_PER_SUBSCRIPTION: usize = 64` +- `MAX_TYPES_PER_INTEREST: usize = 32` +- `MAX_EXPRESSION_LENGTH_BYTES: usize = 4096` +- `MAX_COMPILED_FILTER_BYTES: usize = 65_536` (engine-reported) +- `EVAL_TIMEOUT_MICROS: u64 = 10_000` (10ms per `(event, interest)` evaluation) + +Breaches → `400 TooManyInterests` / `400 TooManyTypes` / `400 ExpressionTooLong` / `400 CompiledFilterTooLarge` at JOIN. Eval-timeout at delivery drops the offending event from this consumer's batch + warn log + `evbk_filter_eval_timeout_total{consumer_group}` metric (does NOT fail the poll). + +### JOIN Validation Order + +```text +POST /v1/subscriptions { consumer_group, client_agent, session_timeout, interests[] } + ↓ +1. Authn: SecurityContext present? else 401. +2. Per-tenant rate cap on JOIN (`cpt-cf-evbk-nfr-tenant-rate-caps`; default 60/min/tenant) else 429. +3. consumer_group exists in evbk_consumer_group? else 404 ConsumerGroupNotFound. +4. interests[] length cap check → 400 TooManyInterests. +5. For each interest in interests[]: + 5a. topic exists in evbk_topic? else 404 TopicNotFound. + 5b. tenant_id authz via platform tenant resolver → 403 TenantIdNotAuthorized. + 5c. topic-level consume authz → 403 TopicNotAuthorized. + 5d. types[] length cap → 400 TooManyTypes. + 5e. expression length cap (only if expression supplied) → 400 ExpressionTooLong. + 5f. For each pattern in types[]: + - Pattern syntax valid per GTS spec → else 400 BadTypePattern. + - Resolve via types_registry filtered to parent_topic == interest.topic: + - 0 matches → 400 NoTypesMatched (whole JOIN fails). + - Otherwise expand to concrete type GTS instances; apply per-name-latest + minor-version-omitted. + - Defense-in-depth: every resolved type's parent_topic == interest.topic → else 400 TypeNotInTopic. + - Per-resolved-type consume authz → 403 EventTypeNotAuthorized on any miss. + 5g. Paired-optional check: exactly-one of (expression_type, expression) → 400 BadRequest. + If both absent, skip 5h–5i; this interest has no compiled filter. + 5h. If both present: resolve expression_type via ClientHub → 400 UnknownFilterEngine on miss. + 5i. If both present: engine.compile(expression, MAX_EXPRESSION_LENGTH) → 400 InvalidFilterExpression + on err; 400 CompiledFilterTooLarge on size budget exceed. +6. Collect the topic set from interest.topic across all interests (no derivation; topics are explicit). +7. Run rebalance with this member's (topic, partition) interest set. +8. Persist subscription cache entry: { compiled_filters_or_None[], resolved_type_sets[], topic_set, assignments }. +9. Return 201 Created with { id, assigned, topology_version, expires_at, interests[] (echoed) }. +``` + +All validation BEFORE persistence. Failed JOIN leaves no broker state. + +### Per-Event Delivery Evaluation + +```text +delivery service has event E for (topic, partition) assigned to subscription S + ↓ +For each interest I in S.compiled_interests: + if E.topic != I.topic → skip this interest, continue + if E.tenant_id != I.tenant_id → skip this interest, continue + if E.type not in I.resolved_type_set → skip this interest, continue + if I.compiled is None: // no paired-optional fields supplied at JOIN + → INCLUDE E in this subscription's batch; short-circuit (other interests not evaluated) + else: + match I.engine.eval(I.compiled, FilterContext::from(E)) { + Ok(true) → INCLUDE E; short-circuit + Ok(false) → continue to next interest + Err(EvalTimeout) → log warn; bump metric; treat as Ok(false); continue + Err(EvalRuntime { .. }) → log warn; bump metric; treat as Ok(false); continue + } +If no interest matched → drop E from this subscription's batch. +``` + +Short-circuit on first match. Filter-eval failures (timeout, missing field) DO NOT propagate as broker errors — events are silently dropped from the offending consumer's batch and counted via metric. + +### Authorization + +Two layers, both all-or-nothing: + +- **Topic-level** (common grant shape): platform authz resolver checks `consume` against `interest.topic`. Denial → `403 TopicNotAuthorized`. +- **Per-resolved-type** (fine-grained, optional in deployments that grant per-type): resolver additionally checks each resolved event type. Denial → `403 EventTypeNotAuthorized` with the offending type in the response body. +- **Per-interest tenant** (platform resolver): `interest.tenant_id` authorized for the calling principal. Denial → `403 TenantIdNotAuthorized`. + +### Acknowledged GTS Features + +- **Applied in v1**: minor-version-omitted matching (§10 rule 5). A pattern pinning only the major version matches every registered minor of that major; per-name-latest rule picks the highest minor. +- **Not applied in v1**: implicit derived-type coverage (§3.6 — bare `~` type identifier auto-matched to derived-type instances). Broker requires explicit patterns or full identifiers; future versions MAY opt in as an additive change. + +### Consequences + +- Good, because the wire shape eliminates the parallel-array ambiguity by construction. +- Good, because topic is explicit (Kafka-style) — partition assignment, rebalance, and authz operate on the same unit the consumer declared. +- Good, because filter engines are extensible via the same GTS-typed plugin registry used for storage backends + OAGW plugins. +- Good, because event-type filtering happens before engine eval — the broker skips non-matching events cheaply. +- Good, because paired-optional `expression_type` + `expression` lets the common case (no filter beyond topic+tenant+types) be the simplest wire. +- Good, because GTS-spec-compliant patterns mean every other system that handles GTS identifiers (types_registry, authz resolver, observability) understands the broker's patterns natively. +- Good, because per-name-latest + minor-version-omitted matching gives consumers a stable major-pinned subscription that gracefully picks up minor-version updates. +- Bad / accepted, because two-level addressing (topic + type-patterns) is more surface than topic-only. Mitigation: documented; the `types: ["gts.cf.core.events.event.v1~*"]` idiom is the "all types in this topic" pattern. +- Bad / accepted, because version-resolution pinned at JOIN means new versions don't auto-flow to active subscriptions. Mitigation: future `dynamic_types_refresh` flag; out of v1 scope. +- Bad / accepted, because the per-event eval timeout silently drops events from the offending consumer's batch rather than failing the poll. Mitigation: alert-able metric. +- Bad / accepted, because all-or-nothing JOIN validation (one denied type or unauthorized tenant fails the whole JOIN). Mitigation: clearer authz model; no surprise partial subscriptions. + +### Confirmation + +- **GTS spec compliance test**: every example pattern in ADRs, DESIGN, scenarios, schemas, and fixtures is verified against the GTS spec's wildcard rules (single trailing `*`, segment-boundary, no `**`, no substring-within-segment). +- **JOIN wire-shape test**: schema codegen produces a strongly-typed `Interest` struct with `topic`, `tenant_id`, `types` required and `expression_type` + `expression` as a paired Option group. +- **JOIN validation test matrix**: each step in the JOIN validation order has a test asserting the documented error code on a triggering input. +- **Per-event delivery test**: short-circuit on first match; no-filter interest matches by prerequisites alone; eval timeout drops event silently + metric. +- **Rolling-deploy test**: v1 + v2 members with different interests in the same group; partition handoff preserves the per-member filter on subsequent events; cursor preserves position. + +## Pros and Cons of the Options + +### Topic-Anchored Interests With Typed Filters (chosen) + +* Good, because the interest is a single self-contained selection unit — no parallel-array ambiguity. +* Good, because topic is explicit on the wire (Kafka-style) — partition assignment, rebalance, and authz all key on the same identifier. +* Good, because filter engines are pluggable via the platform's standard GTS-typed registry pattern. +* Good, because per-name-latest + minor-version-omitted matching gives stable major-pinned subscriptions. +* Good, because paired-optional `expression_type` + `expression` keeps the no-filter common case simple. +* Bad, because the wire is slightly larger than the parallel-array shape (5 fields vs. 2 arrays). +* Bad, because two-level addressing (topic + types) is more surface than single-level routing — but it's the right surface for both broker-side filtering optimization AND consumer-side declarativeness. + +### Keep Parallel Topics and Filters Arrays (status quo) + +**Description**: Retain the current shape. Optionally add a per-filter `type` field as a back-compat extension. + +* Good, because zero migration cost (the broker is unshipped, so this argument is weak). +* Bad / decisive against, because the parallel-array mapping ambiguity is unresolvable without inventing a positional or naming correspondence rule. +* Bad, because adding a typed-filter field via back-compat extension preserves the underlying topology problem (topics are still topic-flat; types still implicit). +* Bad, because rolling-deploy scenarios become guess-work as soon as a member subscribes to more than one topic. + +### Event-Type-Centric Only (drop `topic` from interest) + +**Description**: Each interest declares `types[]` only; broker derives `parent_topic` from `types_registry` and computes the topic set from there. + +* Good, because the wire is one field smaller per interest. +* Bad, because wildcard `~yourorg.orders.*` can accidentally span topics if types under that namespace happen to belong to multiple topics — silent cross-topic subscription. +* Bad, because the "all events on this topic" use case becomes fragile (no clean way to say "everything in this topic" without enumerating types). +* Bad, because broker has to consult `types_registry` for topic derivation on every JOIN — extra coupling. +* Bad, because topic-level authz (the common grant shape) becomes a derived check via per-resolved-type — operators who currently grant per-topic see the model leak. +* Captured as the design-time alternative; rejected on the cross-topic-wildcard risk alone. + +## More Information + +- **Dynamic type-pattern refresh** (post-v1): a `dynamic_types_refresh: true` flag on interests would cause the broker to re-resolve patterns on each topology change and silently extend the resolved type set. Deferred — pinned-at-JOIN is simpler and meets the typical use case. +- **Second built-in filter engine** (post-v1): starlark, rego, or jsonpath. The plugin pattern is the unblock; choosing the second engine is a separate decision. +- **Implicit derived-type coverage** (GTS §3.6, post-v1): treat a bare `~` type identifier as auto-matching its derived-type instances. Adds an additive matching path; out of v1 scope to keep the resolution rule simple. +- **Filter-engine cardinality**: today's `filter.v1~` base type allows N concurrent engines in a deployment. Limits are implementation-defined. + +External references: + +- `GlobalTypeSystem/gts-spec` README §10 "Collecting Identifiers with Wildcards", §3.5 wildcard intent, §3.6 implicit derived-type coverage, conformance tests at `tests/test_op4_id_match_pattern.py`. The wildcard rules in this ADR are taken directly from §10 rules 1–5 (verified, see Pattern Resolution above). +- RFC 9457 — Problem Details, used for error response shapes. +- Apache Kafka — topic-anchored subscription model, referenced as the prior art for "topic is the partition / rebalance / authz unit." + +## Traceability + +- **PRD**: [PRD.md](../PRD.md) + - `cpt-cf-evbk-fr-subscription-join` — JOIN body shape (this ADR pins the interests[] structure). + - `cpt-cf-evbk-fr-filter-expression` — new FR for typed filter engines + plugin pattern. + - `cpt-cf-evbk-nfr-tenant-rate-caps` — extended to cover `POST /v1/subscriptions` per-tenant rate cap. +- **DESIGN**: [DESIGN.md](../DESIGN.md) + - §3.1 Subscription Schema — updated to reference the new interests[] body shape. + - §3.2 SubscriptionResolutionCache — adds filter-engine handle caching. + - §3.3 JOIN endpoint — references this ADR for the validation flow. + - §3.5 Long-Poll Consumption Flow — per-event filter evaluation step added. + - §4.3 Metrics — `evbk_filter_eval_timeout_total`, `evbk_filter_eval_error_total` added. +- **Scenario and test coverage**: JOIN shape, interest body, pattern syntax, version resolution, topic / tenant / type authz, filter-engine resolution, expression compilation, per-event delivery, limits, and rolling deploy are covered by repository scenarios and tests. +- **Related ADRs**: + - [`0002-partition-selection`](0002-partition-selection.md) — partition derivation; the subscription rebalance still operates on `(topic, partition)`. + - [`0002-event-schema`](0002-event-schema.md) — read-side event fields exposed to the CEL filter context (the `event.v1.schema.json` resource with `readOnly` fields populated and `writeOnly` `meta` stripped). + - [`0003-idempotent-producer-protocol`](0003-idempotent-producer-protocol.md) — `client_agent` field convention on resource-creating endpoints; JOIN reuses the consumer-group's `client_agent` rather than re-declaring per subscription. +- **Feature doc**: [`docs/features/0002-consumer-subscription-lifecycle.md`](../features/0002-consumer-subscription-lifecycle.md) — CDSL flows, ACs, test plans. +- **Schemas**: + - [`schemas/subscription.v1.schema.json`](../schemas/subscription.v1.schema.json) — subscription resource (updated for interests[]). diff --git a/gears/system/event-broker/docs/ADR/0006-offset-authority.md b/gears/system/event-broker/docs/ADR/0006-offset-authority.md new file mode 100644 index 000000000..851bebe38 --- /dev/null +++ b/gears/system/event-broker/docs/ADR/0006-offset-authority.md @@ -0,0 +1,142 @@ +# ADR-0006: Offset Authority — Client-Side Tracking, No Broker-Side Durability + + + +- [Status](#status) +- [Context and Problem Statement](#context-and-problem-statement) +- [Decision Drivers](#decision-drivers) +- [Considered Options](#considered-options) +- [Decision Outcome](#decision-outcome) + - [Consequences](#consequences) + - [Confirmation](#confirmation) +- [Pros and Cons of the Options](#pros-and-cons-of-the-options) + - [Client-Side Offset Tracking With SEEK-Owned Runtime Cursors](#client-side-offset-tracking-with-seek-owned-runtime-cursors) + - [Broker-Side Durable ACK Endpoint](#broker-side-durable-ack-endpoint) + - [Separate Timestamp-To-Offset Lookup Endpoint](#separate-timestamp-to-offset-lookup-endpoint) +- [More Information](#more-information) + + + +**ID**: `cpt-cf-evbk-adr-offset-authority` + +## Status + +Accepted + +## Context and Problem Statement + +Early design drafts included a `POST /v1/subscriptions/{id}/ack` endpoint for consumers to commit processed offsets back to the broker. The intent was to support consumers that have no persistent store of their own. + +In practice, every meaningful consumer type already has a natural persistence medium: + +| Consumer type | Natural offset store | +|---|---| +| Long-running service | Application database | +| Browser | IndexedDB / localStorage | +| Script / CLI | Local filesystem | +| Date-anchored reader | Timestamp → offset lookup (see below) | +| In-process module | toolkit-db transactional outbox (producer side); own DB (consumer side) | + +There is no consumer archetype that genuinely lacks a durable store. A broker-managed ACK endpoint does not eliminate the durability problem — it shifts responsibility to the broker and adds a write-path operation (ACK round-trip per processed batch) with no net reduction in system complexity. + +## Decision Drivers + +- Keep consumer progress ownership with the consumer, where the natural durable store already exists. +- Avoid adding broker write-path load for every processed batch. +- Preserve SEEK as the single broker operation that establishes an active subscription cursor. +- Support date-anchored readers without introducing a second offset-resolution endpoint. + +## Considered Options + +- Client-side offset tracking with SEEK-owned runtime cursors and timestamp sentinel support. +- Broker-side durable ACK endpoint for committed consumer offsets. +- Separate timestamp-to-offset lookup endpoint plus SEEK by integer offset. + +## Decision Outcome + +**Drop the ACK endpoint.** The broker does not durably store consumer progress. + +**Extend the SEEK endpoint with a timestamp sentinel.** Date-anchored readers need a way to start from a point in time without resolving offsets separately. The existing `POST /v1/subscriptions/{id}:seek` sentinel vocabulary is extended with `"at:"`: + +``` +POST /v1/subscriptions/{id}:seek +{ + "partition_positions": { + ":": "at:2026-06-14T10:00:00Z" + } +} +``` + +The broker resolves the timestamp to the offset of the first event whose `occurred_at ≥ timestamp` and sets the cursor in one step. The response returns the resolved integer offset per partition, which the consumer may persist for future re-seeks. No separate endpoint is introduced. + +### Consequences + +**Removed:** +- `POST /v1/subscriptions/{id}/ack` endpoint (and all associated broker-side cursor durability logic) +- `cursor.acked` position (renamed to `cursor.offset` — the ephemeral session cursor set by SEEK) +- `PartitionNotAssigned` error type (was only raised by ACK) +- `acknowledge()` method from `DeliveryService` + +**Added:** +- `"at:"` sentinel on `POST /v1/subscriptions/{id}:seek` — resolves to the offset of the first event whose `occurred_at ≥ timestamp`. Boundary behaviour: + - `timestamp` before retention floor → retention floor offset (first available event) + - `timestamp` beyond current HWM → HWM (consumer streams only future events, equivalent to `"latest"`) + - Response returns the resolved integer offset per partition; consumer may persist it for future re-seeks + - Malformed timestamp → `400 InvalidTimestamp` + +**Unchanged:** +- `cursor` inside the broker is still tracked during an active subscription session (ephemeral, cache-only), seeded by `POST /v1/subscriptions/{id}:seek` (SEEK). The SEEK position is the broker's reference for "emit from here" during the stream. It is NOT persisted across sessions — on reconnect, the consumer re-SEEKs from its own store. +- The `"earliest"` and `"latest"` sentinels on the SEEK endpoint are unchanged. + +**Consumer contract:** +Every consumer is responsible for persisting the last offset it has successfully processed, using whatever storage is natural for its deployment context. The broker makes this easy: `offset` appears on every delivered event; persisting it is a single field write. + +**Trade-off acknowledged:** +Consumers that are stateless by design (one-shot scripts, fire-and-forget readers) can use `"latest"` SEEK and simply accept reprocessing on restart — the at-least-once guarantee is preserved by the consumer's own idempotency, not by the broker's cursor. + +### Confirmation + +Confirm the design and implementation by checking that consumer progress durability lives outside the broker, that active subscription cursors remain cache/runtime state, and that `POST /v1/subscriptions/{id}:seek` is the only broker operation that sets the stream cursor. + +## Pros and Cons of the Options + +### Client-Side Offset Tracking With SEEK-Owned Runtime Cursors + +Pros: + +- Matches the natural durable store of real consumers. +- Keeps broker ingest and delivery free of per-batch ACK writes. +- Keeps reconnect behavior explicit: consumers re-SEEK from their own stored position. + +Cons: + +- SDKs must provide good offset-manager helpers so application authors do not reimplement this poorly. + +### Broker-Side Durable ACK Endpoint + +Pros: + +- Gives simple consumers an apparent central offset store. + +Cons: + +- Adds broker write-path load and durability requirements. +- Does not remove the need for consumer idempotency. +- Blurs ownership of processing success between the broker and application. + +### Separate Timestamp-To-Offset Lookup Endpoint + +Pros: + +- Makes timestamp resolution independently callable. + +Cons: + +- Adds an endpoint for behavior that SEEK can perform atomically. +- Forces clients to handle a lookup-then-SEEK race. + +## More Information + +- ADR-0001 (offset-semantics) — defines the broker-logical sequence space used by cursors. +- `features/0002-consumer-subscription-lifecycle.md` — describes JOIN, SEEK, stream, and re-JOIN flows. +- DESIGN.md §3.1 "Offset Semantics" — summarizes the consumer-visible cursor model. diff --git a/gears/system/event-broker/docs/DESIGN.md b/gears/system/event-broker/docs/DESIGN.md new file mode 100644 index 000000000..14aa23ac7 --- /dev/null +++ b/gears/system/event-broker/docs/DESIGN.md @@ -0,0 +1,2588 @@ +# Technical Design — Event Broker + + + +- [1. Architecture Overview](#1-architecture-overview) + - [1.1 Architectural Vision](#11-architectural-vision) + - [1.2 Architecture Drivers](#12-architecture-drivers) + - [1.3 Architecture Layers](#13-architecture-layers) + - [1.4 High-Level Architecture Diagram](#14-high-level-architecture-diagram) +- [2. Principles & Constraints](#2-principles--constraints) + - [2.1 Design Principles](#21-design-principles) + - [2.2 Constraints](#22-constraints) +- [3. Technical Architecture](#3-technical-architecture) + - [3.1 Domain Model](#31-domain-model) + - [3.2 Component Model](#32-component-model) + - [3.3 API Contracts](#33-api-contracts) + - [3.4 Internal Dependencies](#34-internal-dependencies) + - [3.5 External Dependencies](#35-external-dependencies) + - [3.6 Interactions & Sequences](#36-interactions--sequences) + - [3.7 Database schemas & tables](#37-database-schemas--tables) + - [3.8 Deployment Topology](#38-deployment-topology) +- [4. Additional Context](#4-additional-context) + - [4.1 Deployment Modes](#41-deployment-modes) + - [4.2 Caching Strategy](#42-caching-strategy) + - [4.3 Metrics and Observability](#43-metrics-and-observability) + - [4.4 Audit Logging](#44-audit-logging) + - [4.5 Security Considerations](#45-security-considerations) + - [4.6 Out of Scope](#46-out-of-scope) + - [4.7 Open Questions](#47-open-questions) + - [4.8 Future Developments](#48-future-developments) +- [5. Traceability](#5-traceability) + - [5.1 PRD ↔ DESIGN Traceability](#51-prd--design-traceability) + - [5.2 ADRs](#52-adrs) + - [5.3 Standards & External References](#53-standards--external-references) + - [5.4 Companion Documents](#54-companion-documents) + + + +## 1. Architecture Overview + +### 1.1 Architectural Vision + +The Event Broker is a composite, tenant-scoped event streaming module for Gears. It provides an append-only, GTS-typed event log with monotonic per-`(topic, partition)` sequences, enabling decoupled inter-module communication and reliable replay. + +**This design is an MVP.** The consumption surface is intentionally narrow — a single subscription-based long-poll endpoint — and several non-essential capabilities are explicitly deferred to post-MVP. Every endpoint, table, and trait is shaped so that planned extensions (`GET /v1/events:sse`, anonymous offset-based reads, gRPC consumption, sticky-Kafka rebalancing, cluster ClusterCapabilities providers, etc.) are **additive non-breaking changes** when delivered. See §4.6 Out of Scope and §4.8 Future Developments for the full deferred list. + +The architecture follows a **multi-service decomposition** with four internal services: + +- **Ingest Service**: Producer-facing. Accepts events, validates against GTS schemas, assigns sequences, persists, and broadcasts to delivery. In cluster mode, ingest is **sharded by topic** — each ingest instance owns a subset of topics for write affinity and sequence contention elimination. +- **Delivery Service**: Consumer-facing. Manages subscriptions, serves polling requests, tracks consumer offsets, handles acknowledgment. In cluster mode, delivery is **sharded by consumer group** — the dispatcher uses sticky sessions to route all instances of a consumer group to the same delivery shard. +- **Dispatcher Service**: HTTP gateway that routes requests between ingest and delivery services. In cluster mode, the dispatcher performs **topic-based routing** for producers (directing writes to the ingest shard owning the topic) and **consumer-group sticky routing** for consumers (directing polls to the delivery shard owning the consumer group). Optional — not needed in standalone mode. +- **Storage Backend**: Pluggable persistence layer following the ModKit plugin pattern. Backend implementations (in-memory, database, file-based, remote archive) register themselves via GTS instance discovery, provide a JSON Schema for their configuration, and are resolved at runtime by the event broker module — `types_registry` for discovery, `ClientHub` scoped clients for resolution. + +All four services are implemented as **domain traits within a single `event_broker` crate**, using DDD-Light layering. In standalone mode they run in-process, communicating directly via trait calls. In cluster mode, a **ClusterCapabilities** platform abstraction provides the coordination primitives (pub/sub, leader election, distributed locks, service discovery) needed for ingest and delivery to scale independently. + +The dispatcher is a **stateless HTTP router** — it performs topic-based and consumer-group-based routing using consistent hashing, but does **not** relay notifications. Event notifications flow directly from ingest to delivery shards via `ClusterCapabilities.publish()`, bypassing the dispatcher entirely. This means multiple dispatcher instances can run behind a load balancer without coordination. + +Storage backends are separate crates (potentially separate modules) that implement the `StorageBackend` trait and register themselves with the event broker at init time. This allows third-party or deployment-specific backends without modifying the core event broker code. + +The **producer client library** (`cf-gears-event-broker-sdk`) is built on top of `toolkit-db`'s transactional outbox. Producers enqueue events within their business transaction via the outbox; the outbox pipeline (sequencer → processor) then delivers them to the ingest service. This gives producers **transactional guarantees** — events are only published if the business transaction commits — and **exactly-once semantics** via the idempotent producer protocol. The same outbox machinery also powers the ingest service itself: ingested events flow through a per-topic outbox partition before being persisted to the storage backend and broadcast to delivery. + +### 1.2 Architecture Drivers + +**ID**: `cpt-cf-evbk-design-drivers` + +| Driver | Source | Influence | +|---|---|---| +| Decoupled inter-module communication | Platform need | Append-only event log with per-topic ordering | +| GTS-typed events | `cpt-cf-evbk-fr-event-type-registration` | Events validated against JSON Schema registered per event type | +| Reliable replay | FR: replay | Monotonic per-topic sequences, offset-based consumption | +| Long-poll consumption | `cpt-cf-evbk-fr-streaming-delivery` | Event-driven notification with configurable timeout | +| Consumer group support | `cpt-cf-evbk-fr-subscription-join` | Subscription resources with filters and session management | +| Batch production | `cpt-cf-evbk-fr-publish-batch` | Atomic per-topic batch writes up to 100 events | +| Idempotent publishing | `cpt-cf-evbk-fr-producer-modes` | Deduplication via composite key with configurable retention | +| Multi-tenant isolation | Gears platform | All data tenant-scoped via SecureConn | +| Flexible deployment topology | NFR: deployment modes | Standalone (all-in-one) and cluster (ingest + delivery + dispatcher) | +| Gears integration | Gears platform | Single-executable deployment, trait-based DI, secure ORM | + +**Architecture Decision Records**: + +- future ADR (service-decomposition) — Multi-service composite (ingest, delivery, dispatcher, storage backends) +- future ADR (storage-backend-plugin) — Storage backends as ModKit plugins with GTS discovery and self-describing config schemas +- `cpt-cf-evbk-design-sequence-assignment` — Three-sequence model: producer chain (`previous`, `sequence`) for ingest-side dedup (chained / monotonic / stateless modes); outbox sequence (toolkit-db) for in-pipeline order preservation; offset (backend) for consumer-visible ordering +- future ADR (idempotent-producers) — Idempotent producers via Producer-Id + per-`(producer_id, topic, partition)` sequence tracking. No epoch fencing — sequence ordering itself acts as the fence. +- future ADR (consumer-lifecycle) — Subscription-based consumers with session timeout and offset tracking +- future ADR (outbox-ingest) — Outbox-based producer library and ingest pipeline built on `toolkit-db` outbox +- future ADR (cluster-capabilities) — Platform-level cluster coordination abstraction (pub/sub, leader election, locks, discovery) with pluggable providers +- future ADR (long-polling) — Notification-based long-poll via ClusterCapabilities pub/sub +- future ADR (topic-sharding) — Topic-sharded ingest and consumer-group-sharded delivery in cluster mode +- future ADR (dispatcher) — Stateless HTTP router with topic-based and consumer-group sticky-session routing +- `cpt-cf-evbk-adr-offset-semantics` — Consumer-visible broker sequences start at 1 and backend adapters translate native offsets into the broker-logical sequence space. See [`ADR/0001-offset-semantics.md`](ADR/0001-offset-semantics.md). +- `cpt-cf-evbk-adr-partition-selection` — Broker-authoritative partition selection from `partition_key` when present, otherwise `tenant_id`; producers do not provide a top-level partition. See [`ADR/0002-partition-selection.md`](ADR/0002-partition-selection.md). +- `cpt-cf-evbk-adr-event-schema` — Single canonical event schema with read/write field markers, `meta` write-only producer protocol fields, and consumer-visible `sequence` naming. See [`ADR/0003-event-schema.md`](ADR/0003-event-schema.md). +- `cpt-cf-evbk-adr-idempotent-producer-protocol` — Producer mode is declared at registration and enforced per request, with broker-side chain state for idempotent publishing. See [`ADR/0004-idempotent-producer-protocol.md`](ADR/0004-idempotent-producer-protocol.md). +- `cpt-cf-evbk-adr-subscription-filter-typing` — JOIN uses topic-anchored `interests[]` with typed filter engines and topic-scoped type-pattern resolution. See [`ADR/0005-subscription-filter-typing.md`](ADR/0005-subscription-filter-typing.md). +- `cpt-cf-evbk-adr-offset-authority` — Consumer progress durability belongs to the consumer; the broker owns only runtime cursors seeded by SEEK. See [`ADR/0006-offset-authority.md`](ADR/0006-offset-authority.md). + +The consumption transport surface (`/events:stream` for `multipart/mixed`, `/events:sse` for `text/event-stream`) is documented as a feature. See [`features/0004-consumption-transport.md`](features/0004-consumption-transport.md). + +### 1.3 Architecture Layers + +**ID**: `cpt-cf-evbk-design-layers` + +| Layer | Responsibility | Key Components | +|---|---|---| +| **Transport** (`api/rest/`) | HTTP handling, request parsing, response serialization | Axum handlers, DTOs, extractors, OperationBuilder route registration | +| **Domain** (`domain/`) | Business logic, service traits, repository contracts | `IngestService`, `DeliveryService`, `SpecificationManager`, domain models, repository traits | +| **Infrastructure** (`infra/`) | Persistence, cluster coordination, GTS provisioning | Storage backends, ClusterCapabilities integration, type provisioning | +| **SDK** (`event-broker-sdk/`) | Public API for inter-module communication | `EventBrokerApi` trait (unified), `IngestApi` / `DeliveryApi` (split), SDK models, error types | + +**ID**: `cpt-cf-evbk-tech-dependencies` + +| Technology | Purpose | +|---|---| +| Rust / Axum | HTTP transport, async runtime | +| SeaORM + `toolkit-db` | Database persistence (PostgreSQL, MySQL, SQLite) | +| `ClusterCapabilities` (Gears platform) | Cluster coordination: pub/sub, leader election, distributed locks, service discovery. Pluggable providers (K8s, Redis, NATS, DB-only, standalone/in-process) | +| `toolkit-security` | Bearer token authentication & authorization | +| `types_registry` | GTS schema/instance registration | + +### 1.4 High-Level Architecture Diagram + +**ID**: `cpt-cf-evbk-design-overview` + +#### Standalone Mode + +```mermaid +graph TB + Producer[Producer] -->|POST /v1/events| IngestAPI[Ingest API Handler] + Consumer[Consumer] -->|GET /v1/events:stream| DeliveryAPI[Delivery API Handler] + + IngestAPI --> IS[IngestService] + DeliveryAPI --> DS[DeliveryService] + + IS -->|validate| SM[SpecificationManager] + IS -->|persist + sequence| SB[(Storage Backend)] + IS -->|cluster.publish| CC[ClusterCapabilities: Standalone] + CC -->|in-process notify| DS + + DS -->|poll + filter| SB + DS -->|offset tracking| SB + SM -->|types| TR[types_registry] + + SDK[EventBrokerApi SDK] -->|in-process| IS + SDK -->|in-process| DS +``` + +#### Cluster Mode + +```mermaid +graph TB + Producer[Producer] -->|POST /v1/events| LB[Load Balancer] + Consumer[Consumer] -->|GET /v1/events:stream| LB + + LB --> DISP1[Dispatcher #1] + LB --> DISP2[Dispatcher #2] + + DISP1 -->|hash topic| IS[Ingest Shard A] + DISP2 -->|sticky group| DS[Delivery Shard B] + + IS -->|persist + sequence| SB[(Shared Database)] + IS -->|cluster.publish| CC[ClusterCapabilities Provider] + CC -->|pub/sub| DS + + DS -->|poll + filter| SB + DS -->|offset tracking| SB + + DISP1 -->|cluster.discover_shards| CC + DISP2 -->|cluster.discover_shards| CC +``` + +## 2. Principles & Constraints + +### 2.1 Design Principles + +**ID**: `cpt-cf-evbk-principle-immutable-log` + +**Immutable log**: Events are append-only. Once written, events are never modified or reordered. Retention may remove older segments. + +**ID**: `cpt-cf-evbk-principle-per-topic-ordering` + +**Per-topic ordering**: Sequence numbers are monotonically increasing within a topic. No ordering guarantees across topics. + +**ID**: `cpt-cf-evbk-principle-tenant-scope` + +**Tenant scoping**: All database reads/writes use secure ORM with tenant scoping. Topics, events, subscriptions, and cursors are strictly isolated per tenant. + +**ID**: `cpt-cf-evbk-principle-no-auto-retry` + +**No automatic retries**: The broker does not retry failed writes or consumption. Retry logic is the client's responsibility. + +**ID**: `cpt-cf-evbk-principle-rfc9457` + +**RFC 9457 Problem Details**: All broker errors use `application/problem+json` format with GTS type identifiers. + +**ID**: `cpt-cf-evbk-principle-typed-events` + +**Typed events**: Events are identified by GTS type identifiers and validated against JSON Schema associated with the event type at publish time. + +**ID**: `cpt-cf-evbk-principle-abi-registration` + +**ABI-only registration**: Topics and event types are registered through the local Rust SDK (in-process) and specification configuration. The REST API does not expose topic/event-type creation — these are infrastructure concerns managed by module code, not end users. + +**ID**: `cpt-cf-evbk-principle-topology-transparent` + +**Topology-transparent**: Business logic is identical regardless of deployment mode. The `ClusterCapabilities` abstraction hides whether services communicate in-process (standalone) or via external coordination (cluster). Service traits call `cluster.publish()` / `cluster.subscribe()` without knowing the underlying transport. + +**ID**: `cpt-cf-evbk-principle-tenant-on-event` + +**Tenant is on the event, not on the group.** Every event carries a `tenant_id`. Every event read is tenant-scoped via API-level filtering (`SecureConn` applies `WHERE tenant_id = $ctx.tenant_id`). But tenant is **NOT** part of routing, group identity, or the cursor key — group identity is the `consumer_group` GTS identifier (globally unique by namespacing for named instances, by UUID for anonymous), and multiple tenants picking the same anonymous (UUID-backed) identifier or both being authorized to use the same named instance are intentionally in the same group, cooperatively consuming with per-tenant filtering applied at read time. + +Inter-service traffic uses a well-known `ROOT_TENANT_ID` constant for the event's `tenant_id` field, so platform-internal events are tenant-tagged like any others. `ROOT_TENANT_ID` is exposed by the platform's `tenant-resolver` SDK. + +This separation has consequences: see R52 (cross-tenant cooperative consumption can leave a tenant with empty assigned partitions) and R57 (filter-saturation requires offset adviser to avoid re-scanning). Both are documented as known design properties with mitigations. + +### 2.2 Constraints + +**ID**: `cpt-cf-evbk-constraint-modkit-deploy` + +Single-executable deployment via Gears framework. Standalone mode is the default. + +**ID**: `cpt-cf-evbk-constraint-multi-sql` + +Multi-SQL backend portability: PostgreSQL, MySQL, SQLite. `toolkit-db` requirement; avoid backend-specific features. Sequence generation must work correctly across all backends. + +**ID**: `cpt-cf-evbk-constraint-event-size` + +Per-event hard size limit: **64 KiB total** — combined headers + payload (`data` field plus `trace_parent` and any other event fields). Enforced at the ingest path before validation. Larger events MUST be split or referenced (a referenced-payload mechanism is post-MVP). + +**ID**: `cpt-cf-evbk-constraint-batch-limit` + +Batch size hard limit: 100 events per request, 1 MiB total payload. Prevent resource exhaustion on the ingest path. Per-event size limit (64 KiB) applies independently — a max batch fits ~16 max-sized events. + +**ID**: `cpt-cf-evbk-constraint-poll-timeout` + +Long-poll max timeout: 30 seconds. Prevents connection hoarding and balances responsiveness with resource usage. + +## 3. Technical Architecture + +### 3.1 Domain Model + +**ID**: `cpt-cf-evbk-design-domain-model` + +```mermaid +classDiagram + class Topic { + +String id (GTS Identifier) + +String description + +i32 partitions + +StreamingConfig streaming + +Duration retention + +Timestamp created_at + } + class EventType { + +String id (GTS Identifier) + +String description + +String topic_id + +List~String~ allowed_subject_types + +JsonSchema data_schema + +Timestamp created_at + } + class Event { + +UUID id + +String type (GTS Identifier) + +String topic (GTS Identifier) + +String partition_key + +UUID tenant_id + +String source + +String subject + +String subject_type (GTS Identifier) + +Timestamp occurred_at + +String trace_parent + +JsonValue data + +Meta meta (publish-input only) + +i32 partition (read-projection only) + +i64 sequence (read-projection only) + +Timestamp sequence_time (read-projection only) + } + class Meta { + <> + +i32 version + +UUID producer_id + +i64 previous + +i64 sequence + } + class Subscription { + <> + +UUID id + +UUID tenant_id + +String consumer_group + +List~String~ topics (GTS Identifiers) + +List~Assignment~ assigned + +Duration session_timeout + +Timestamp last_seen_at + +Timestamp expires_at + } + class Assignment { + +String topic (GTS Identifier) + +i32 partition + +i64 offset + +i64 last_examined + } + class GroupState { + <> + +String consumer_group + +String topic (GTS Identifier) + +Map~UUID,SubscriptionFilters~ per_member_filters + +Map~UUID,Subscription~ active_members + +i64 topology_version + +String owning_delivery_shard_id + } + class Cursor { + <> + +String topic (GTS Identifier) + +String consumer_group + +i32 partition + +i64 offset + } + + Topic "1" --> "*" EventType : defines types + Topic "1" --> "*" Event : contains + EventType "1" --> "*" Event : types + Topic "1" --> "*" GroupState : subscribed by + GroupState "1" --> "*" Subscription : has members + GroupState "1" --> "*" Cursor : one per assigned partition +``` + +**Key Domain Entities**: + +- **Topic** (`gts.cf.core.events.topic.v1~`): A named logical event stream identified by a GTS topic identifier. A topic is divided into a fixed number of **partitions** declared at topic creation. All sequencing, offsets, and ordering semantics are scoped to a single `(topic, partition)`. Persistence properties are determined by the chosen storage backend (e.g., the memory backend is non-persistent; the postgres / kafka backends are durable). Registered via ABI/specification, not REST. +- **Event Type** (`gts.cf.core.events.event_type.v1~`): Defines the schema and constraints for a category of events within a topic. Includes allowed subject types and a JSON Schema for payload validation. Registered via ABI/specification, not REST. +- **Event** (`gts.cf.core.events.event.v1~`): An immutable record in a `(topic, partition)` log. The `partition` is **broker-derived** at ingest (MurmurHash3-32 over the optional `partition_key` body field, falling back to `tenant_id` — so a tenant's events are totally ordered by default; see [`ADR/0002-partition-selection.md`](ADR/0002-partition-selection.md)); producers do not set `partition` on publish input. The `sequence` (formerly `offset`) is the **broker-logical, consumer-visible ordering key** returned by the storage backend boundary, monotonic within `(topic, partition)` and starting from 1 on a fresh partition (see [§ Offset Semantics](#offset-semantics) and [ADR-0001](ADR/0001-offset-semantics.md)). Backends may use native positions internally (Kafka offset / DB row sequence / file segment offset / etc.) but MUST translate them before exposing `sequence`. `sequence_time` records when the backend assigned the public sequence. The producer chain `meta.producer_id` / `meta.previous` / `meta.sequence` lives inside the publish-input `meta` block (chained / monotonic modes; absent in stateless), is used for ingest-side dedup, and is stripped from the consumer read projection. See §3.2 "Producer Modes" and §3.6 "Two Sequences". +- **Subscription** (`gts.cf.core.events.subscription.v1~`): An **ephemeral** resource representing a single consumer instance. Lives in the cache (ClusterCapabilities-backed), not in the broker's database. Has a UUID identity, a TTL (`session_timeout`), and a list of assigned `(topic, partition)` pairs across one or more topics. When session expires, the subscription is removed from its group automatically. Subscriptions never outlive the consumer process. +- **ConsumerGroup** (`gts.cf.core.events.consumer_group.v1~`): A GTS-typed consumer group identifier. **Persistent broker resource** stored in `evbk_consumer_group` (one row per registered group). Two creation paths, one table — but each path is exclusive to one of the two GTS-instance shapes: + - **Anonymous** (UUID-backed): `gts.cf.core.events.consumer_group.v1~{uuid}`. Created **only** via `POST /v1/consumer-groups`. The UUID is **broker-minted** at create time; the caller's request carries no `id` and the broker returns the composed GTS identifier in both the response body and the `Location` header. Caller's `tenant_id` and principal become the row's owner. The caller then distributes the returned identifier to its consumer fleet via its own coordination mechanism (DB, ConfigMap, env var). Use case: a single tenant's private consumer pool — disposable, not shared. + - **Named** (well-known): `gts.cf.core.events.consumer_group.v1~vendor.audit-processor.v1`. Provisioned **only** via `types_registry` — the registering module's principal owns the row. The broker reads `types_registry` at startup and upserts each named consumer-group instance into `evbk_consumer_group` (idempotent). Use case: shared platform groups intended for cross-tenant or cross-module consumption with explicit `:consume` grants on the concrete GTS instance. + + `POST /v1/consumer-groups` always produces an anonymous identifier — there is no way for the caller to request a named shape via this endpoint. Named groups MUST be registered via `types_registry`. Each shape has one provisioning path; no overlap. + + **JOIN authorization** (`POST /v1/subscriptions`): + - The row MUST exist in `evbk_consumer_group` → `404 ConsumerGroupNotFound` otherwise. + - **Anonymous groups**: caller's `tenant_id` must equal the row's `owner_tenant_id` (`403 ConsumerGroupNotOwned` otherwise) — cross-tenant sharing of anonymous groups is post-MVP via a `shared_with` mechanism (§4.8). + - **Named groups**: caller MUST hold an explicit `consume` permission on the concrete GTS instance via the `authz-resolver` PEP (e.g., `gts.cf.core.events.consumer_group.v1~vendor.audit-processor.v1:consume`). No tenant-equality short-circuit — named groups are first-class platform resources gated by explicit grants. This is the path for legitimate cross-tenant or cross-module shared consumption: the platform / operator grants `consume` to specific principals, and they can JOIN. + + This structurally eliminates the cross-tenant accidental-collision bug R52 flagged: anonymous groups are tenant-bound at creation; named groups require explicit permission. The "first JOIN claims it" race condition is gone. +- **GroupState**: The runtime state of a consumer group. **Ephemeral**, lives in the cache, keyed by the consumer_group GTS identifier (the string alone — no topic, no tenant). Holds active subscriptions and their per-member topic lists and filters, partition assignments per `(topic, partition)`, topology version, and the owning delivery instance endpoint. **Each member can have its own topic subset and filter set** — there is no canonical topic list or canonical filter at the group level (see "Per-Member Subscriptions" below). The group's TTL is the max `last_seen_at + session_timeout` across active members; an empty group is reaped. +- **Cursor**: Group-scoped offset tracking, held in cache. Keyed by `(consumer_group, topic, partition)`. Stores `offset` (session cursor set by SEEK; broker emits from offset+1) and `last_examined` (offset adviser, see R57). Survives subscription churn, delivery instance failover, and group emptiness. **`topic` is in the key for partition disambiguation only** (a partition number is meaningless without saying "of which topic") — `topic` is NOT part of group identity. + +**Sequence scope** is `(topic, partition)` — sequence numbers are globally unique within `(topic, partition)`, regardless of tenant. Topic GTS identifiers are globally unique by construction (vendor-namespaced). + +**Group identity** is the `consumer_group` GTS identifier — a full GTS string, globally unique by construction (vendor namespacing for named instances; UUID uniqueness for anonymous instances). Multiple tenants picking the same anonymous UUID-backed identifier (intentionally shared via out-of-band coordination) end up in the same group, cooperatively consuming with API-level tenant filtering applied at read time. Cross-tenant collision via accidentally-matching named instances is prevented by GTS namespace ownership (vendor.foo.v1 is registered and owned). + +**Per-member subscriptions** (Kafka shared-subscription model with per-member parameters): A consumer group can include members with **different topic lists** and **different filters**. There is no canonical topic list and no canonical filter at the group level. Each JOIN's `topics` and `filters` apply to that member alone: +- The group's effective topic set is the **union** of all active members' topic lists +- A `(topic, partition)` pair can only be assigned to a member that subscribes to that topic +- Each member's filter is applied to events delivered to it +- Cursor is shared per `(consumer_group, topic, partition)` regardless of which member processed events at any given moment +- During a rolling deploy where v1 (filters F1) and v2 (filters F2) coexist, partitions migrate organically; the **single-consumer-per-partition invariant** ensures no partition is ever processed by two filters simultaneously, but at the moment of partition handover the cursor reflects the previous owner's processing position. This is documented as accepted rollout behavior (see R60). +- Operators wanting strict atomic semantics (no overlap of filter generations) use a hard-stop deploy (drain v1, deploy v2 — orchestrated via k8s or similar) + +**Why subscription/group state is ephemeral**: subscription columns (`last_seen_at`, `expires_at`, `id`, `session_timeout`, plus per-member `topics` and `filters`) are session-lifetime data; nothing about a subscription needs to outlive the consumer process. Keeping this state in the DB would create write churn on every JOIN/POLL/expire for no persistence value. Cache-based state makes JOIN/POLL/ACK primarily in-memory operations and keeps the cursor (the only persistent piece) correctly group-scoped. + +#### Topic Schema + +A named, partitioned log of events identified by a GTS topic identifier. See [`schemas/topic.v1.schema.json`](schemas/topic.v1.schema.json). + +**Base type**: `gts.cf.core.events.topic.v1~` + +Key fields: +- `id` (String, GTS Identifier): Unique topic identifier (e.g., `gts.cf.core.events.topic.v1~vendor.users.v1`) +- `description` (String, optional): Human-readable description +- `partitions` (i32, **required**): Number of partitions. Fixed at topic creation. See "Partition Count" below. +- `streaming` (Object): Backend configuration. Opaque to the event broker; validated against the chosen backend type's `config_schema` at topic registration. The well-known sub-field `backend_selector` (with `type` + `metadata` filters) is used by the broker to resolve the backend instance via `cluster.discover_shards()`; everything else inside `streaming` is passed through to the backend (retention, compaction, replication, segment size, etc. — the backend's concern). +- `retention` (Object): Duration-based retention policy +- `retention` (String, ISO 8601 Duration): TTL for the producer-state idempotency-deduplication window (e.g., `PT24H`). Capped at `P14D`; values above the cap are rejected with `400 RetentionExceedsMaxSpan`. +- `created_at` (String, ISO 8601): Creation timestamp + +**Partition Count** (`partitions`): Required field, no default. The number of partitions a topic is divided into. Each partition is an independent ordered log; events with the same partition see total order, events across partitions have no ordering guarantee. + +Reasons partition count is required (no default): + +1. **It's a one-way decision.** Re-partitioning is essentially impossible in any log-based system without breaking per-key ordering for every key already published. Kafka, Kinesis, Pulsar all have this constraint. A default invites the operator to skip the choice and pay later. +2. **Hidden defaults for one-way decisions are an anti-pattern.** Every other topic knob (retention, idempotency window, session timeout) can be tweaked freely. Partition count cannot. Defaults are appropriate for things that are easy to change; they are wrong for things that are hard to undo. +3. **Kafka community consensus.** Kafka's broker-level `num.partitions=1` default is widely considered a footgun — operational guidance is universally "always specify explicitly." We learn from their pain. + +Operators must declare partition count at topic registration. There is no v1 mechanism to grow or shrink partitions on a live topic; the migration path is "create new topic, dual-write, cut consumers over." + +**Producer chain vs. consumer sequence**: there are two distinct numbering spaces. The producer chain (`meta.previous`, `meta.sequence`) is producer-assigned per `(producer_id, topic, partition)` for ingest dedup and is publish-input-only — stripped on the read projection. The consumer-visible `sequence` (formerly `offset`) is backend-assigned per `(topic, partition)` and appears only on the read projection. See §3.2 "Producer Modes" and §3.6 "Two Sequences". + +#### Event Type Schema + +A registration record describing one kind of event: its parent topic, allowed subject types, and per-type `data_schema` (a JSON Schema that validates `event.data` at ingest). See [`schemas/event_type.v1.schema.json`](schemas/event_type.v1.schema.json). + +**Base type**: `gts.cf.core.events.event_type.v1~` + +Key fields: +- `id` (String, GTS Identifier): Unique event type identifier +- `topic_id` (String, GTS Identifier): Parent topic +- `description` (String): Human-readable description +- `allowed_subject_types` (Array of GTS Patterns): Subject types this event can reference. Each entry is a valid GTS pattern — a concrete instance (e.g., `gts.cf.core.acm.user.v1~`), a wildcard suffix (e.g., `gts.cf.core.acm.*`), or a bare base type — letting a single entry cover a family. Enforced as a schema constraint at publish time, complementary to the `subject_type:produce` authorization check (see §3.3 — the two are independent dimensions: authz is *capability*, schema is *correctness*). +- `data_schema` (JSON Schema): Validation schema for event payload +- `created_at` (String, ISO 8601): Creation timestamp + +#### Event Schema + +**Base type**: `gts.cf.core.events.event.v1~` + +A single canonical JSON Schema (`schemas/event.v1.schema.json`) describes the event resource for both publish input (`POST /v1/events`, `POST /v1/events:batch`) and read responses (poll / query). Per-direction semantics are encoded via JSON Schema field-level markers: `writeOnly` fields are accepted on publish and stripped on read; `readOnly` fields are server-stamped on read and rejected if supplied on publish. See [`ADR/0003-event-schema.md`](ADR/0003-event-schema.md). + +Round-trip fields (present in both directions): + +- `id` (UUID): Client-provided unique event identifier. +- `type` (String, GTS Identifier, ASCII): Event type reference. +- `topic` (String, GTS Identifier, ASCII): Target topic. +- `tenant_id` (UUID): Tenant the event belongs to. **Producer-supplied**; ingest validates the producer's principal is authorized to publish to this tenant via the platform's authz resolver. First-party producers default it from the authenticated security context; explicit user-derived overrides must be canonical and authorized rather than copied from unchecked request input. +- `source` (String, ASCII): Origin of the event (e.g., service name). +- `subject` (String, ASCII): Subject entity identifier. Required. Used for event correlation, filtering, and consumer semantics. Producers that need subject-level ordering set `partition_key` to the subject value. +- `subject_type` (String, GTS Identifier, ASCII): Subject-type reference — the type of entity the event is about. Required and NOT derivable from `type` (event types may be generic across multiple subject kinds; body-less events have no `data` to introspect). +- `partition_key` (String, optional, ASCII, ≤ 1024 bytes): Optional producer-supplied routing key. When present, partition = `(murmur3_32(ascii_bytes(partition_key)) & 0x7FFFFFFF) % topic.partitions`; when absent, the same masked hash is applied to `tenant_id` (per-tenant ordering by default). Use a stable identifier whose canonical representation is controlled by the producer. User-derived identifiers are valid after authentication and normalization, but raw attacker-controlled free-form values are unsupported because Murmur3 permits deliberate partition hot-spotting. There is no explicit producer-set partition. See [`ADR/0002-partition-selection.md`](ADR/0002-partition-selection.md). +- `occurred_at` (Timestamp, ISO 8601): When the event occurred (producer-stamped). +- `trace_parent` (String, optional): W3C Trace Context parent. Validated at ingest against the W3C tracecontext format (`^[0-9a-f]{2}-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$`); malformed values are rejected with `400 InvalidTraceParent`. Carried on read for consumer-side trace correlation. +- `data` (Object, optional): Event payload, validated against the event type's `data_schema`. May be absent for body-less events. **The only field where UTF-8 is permitted**; all other event string fields are ASCII per platform convention. + +`writeOnly` fields (publish-only; stripped on read): + +- `meta` (Object, optional): Publish-time transport-metadata block carrying producer-protocol fields. Versioned (`meta.version`), per-event. Carries `producer_id`, `previous`, `sequence` per the registered producer mode. Omit entirely for stateless publish. See [`ADR/0003-event-schema.md`](ADR/0003-event-schema.md) and [`ADR/0004-idempotent-producer-protocol.md`](ADR/0004-idempotent-producer-protocol.md). + +`readOnly` fields (server-stamped; rejected with `400 BadRequest` if supplied on publish): + +- `partition` (i32, readOnly): Broker-derived topic partition for the event. Producers MUST NOT supply it on publish; publish bodies that contain `partition` are rejected as read-only field violations. The broker stamps it on read-side events for consumers, where it identifies the `(topic, partition)` ordering, cursor, and assignment scope. +- `sequence` (i64): Server-assigned monotonic ordering key per `(topic, partition)`. **Consumer-visible ordering key** — same value space as `cursor.max_sequence` / `received` / `sent` / `offset`. Distinct from `meta.sequence` (publish-only producer-side chain field). +- `sequence_time` (Timestamp): When the server assigned `sequence`. + +The `required` array is the union of publish-required and read-required fields; producers using strict validators that don't honor `readOnly` MUST filter `partition`/`sequence`/`sequence_time` before submission. The broker enforces per-direction semantics at the wire regardless of the producer's validator behavior: `partition` is never producer authority and is only stamped on read-side events. + +The producer chain (`meta.producer_id`, `meta.previous`, `meta.sequence`) is publish-only and never surfaces to consumers. See §3.6 "Two Sequences". + +#### Subscription Schema (Ephemeral, In-Cache) + +An ephemeral session a consumer instance opens against a consumer group; carries its `interests[]`, `assigned` partitions, and session liveness state. Lives in cache only, not in the broker's database. See [`schemas/subscription.v1.schema.json`](schemas/subscription.v1.schema.json) and [`ADR/0005-subscription-filter-typing.md`](ADR/0005-subscription-filter-typing.md). + +**Base type**: `gts.cf.core.events.subscription.v1~` + +The schema is the wire shape returned by `POST /v1/subscriptions` and the cache record's data. The consumer declares one or more topic-anchored typed-filter `interests[]`, each with optional paired engine-typed filter expression: + +- `id` (UUID): Server-generated subscription identifier (created at JOIN, expires when session does). +- `consumer_group` (String, GTS Identifier): The consumer group this subscription belongs to. Required. +- `client_agent` (String): RFC 9110 User-Agent grammar; ASCII; 1–256 bytes. Informational hint, logs-only. +- `interests` (Array of `Interest`, ≥1, ≤64): Topic-anchored typed-filter selections. Each `Interest` carries: + - `topic` (String, GTS identifier) — required; the partition/rebalance/authz unit. + - `tenant_id` (UUID) — required; tenant scope, authz-validated at JOIN. + - `max_depth` (Integer ≥ 0 | null, default 0) — descendant traversal depth relative to `tenant_id`. `0` = current tenant only; `1` = direct children; `null` = unlimited, bounded by `barrier_mode`. Aligned with `tenant_resolver_sdk::GetDescendantsOptions::max_depth`. + - `barrier_mode` (String enum `"respect"` | `"ignore"`, default `"respect"`) — how to handle `self_managed = true` tenant boundaries during hierarchy traversal. `"respect"` stops at barriers (default); `"ignore"` traverses through them (platform services only). Mirrors `tenant_resolver_sdk::BarrierMode`. + - `types` (Array of String, ≥1, ≤32) — GTS event-type-instance patterns; wildcards per GTS spec §10. + - `filter` (Object optional) — consolidated per-interest filter with required `engine` (GTS identifier of filter engine) and `expression` (engine-specific source string, ≤4096 bytes). Absent means no filter. Replaces the former flat paired-optional `expression_type` + `expression` fields. + Per ADR-0005. Different members of the same group MAY declare different `interests[]` sets (rolling-deploy preserved by construction). +- `assigned` (Array of `{topic, partition}` pairs): Subset of the group's `(topic, partition)` pairs assigned to this subscription by the rebalance algorithm. Computed at JOIN and updated on every topology change. Topic-centric; matches the existing seek mechanics. The topic set is the union of `interest.topic` values across all interests (broker doesn't derive topics — they're explicit on the wire). +- `session_timeout` (String, ISO 8601 Duration): TTL refreshed on every poll/seek (default: `PT30S`). +- `last_seen_at` (String, ISO 8601): Last poll/seek activity timestamp. +- `expires_at` (String, ISO 8601): Current expiry timestamp (= `last_seen_at + session_timeout`). +- (cache-internal, not on the wire) `compiled_interests` — for each interest, the resolved concrete-type-set and the optional compiled `FilterEngine` handle. Lifetime = subscription. Evicted with the subscription on `session_timeout`. + +#### GroupState Schema (Ephemeral, In-Cache) + +The cache record describing a running consumer group. The group identity (its GTS) is in the cache key, not in the schema fields. + +Cache key: `evbk.group.{consumer_group_gts}` — the full GTS identifier. + +Fields: +- `consumer_group` (String, GTS Identifier): Identity (the GTS string — globally unique by namespacing or UUID) +- `active_members` (Map): Currently active subscriptions in the group, each with its own `topics` list and filter set +- `assignments` (Map<`(topic, partition)`, subscription_id>): Which subscription owns each `(topic, partition)`. Each member is only assigned partitions for topics it subscribes to. +- `topology_version` (i64): Monotonically increased on every membership/assignment change +- `owning_endpoint` (String): Endpoint of the delivery instance that owns this group's state (claimed via `cluster.distributed_lock` on first JOIN; published to the routing cache `evbk.group.endpoint:{consumer_group_gts}`) +- `created_at`, `updated_at` (Timestamps) + +**No canonical topic list, no canonical filter at the group level.** Each member's `topics` and `filters` apply to that member alone. The group's effective topic set is the union across active members; partition assignment respects per-member topic subscriptions. See "Per-Member Subscriptions" in §3.1 entity descriptions. + +TTL: `max(member.expires_at)` across active members. An empty group is reaped immediately. Cursor state lives in the runtime cache for active subscription sessions (see "Cursor Schema" below). + +#### Cursor Schema (Ephemeral, In-Cache, Group-Scoped) + +Cursors live in the **ClusterCapabilities-backed runtime cache**. Keyed by `(consumer_group, topic, partition)`. Stores `offset` (session cursor set by SEEK; broker emits from offset+1) and `last_examined` (highest offset the broker has scanned for this group on that `(topic, partition)`, used by the offset-adviser feature — see R57). Cache providers may preserve this runtime state across shard transitions. Consumers that need durable progress track offsets in their own store and initialize each new subscription session from that store. + +`topic` is in the cursor key for **partition disambiguation only** — partition `0` of `topic_A` and partition `0` of `topic_B` are distinct cursors. `topic` is NOT part of group identity (which is `consumer_group` alone). + +Per-subscription progress (the `sent` position concept — "delivered but not yet confirmed") lives in cache as part of subscription state — purely advisory. Topic-level positions (`received`, `max_offset`) are derivable from `backend.segments` / `backend.query` (the storage backend's own `MAX(offset)`) and don't need cache entries. + +#### Offset Semantics + +**ID**: `cpt-cf-evbk-design-offset-semantics` + +Authoritative reference for offset vocabulary across the entire design. See [ADR-0001](ADR/0001-offset-semantics.md) for the full rationale. + +Two boundary values govern the readable window of a `(topic, partition)`: + +- **Retention floor (RF)** — sequence number of the oldest event still available on the partition. Events with sequence < RF have been purged by the backend's retention policy. RF ≥ 1 always (see below). +- **High-water mark (HWM)** — sequence number of the next event to be admitted (one past the last persisted sequence). + +**Sequence floor**: storage backends MUST assign sequences starting from 1. Sequence 0 is never assigned. This is a hard backend conformance contract (see [ADR-0001](ADR/0001-offset-semantics.md)). As a result, RF ≥ 1 always on any partition that has ever had an event written to it. + +**Cursor semantics** (last-processed-offset model): the cursor stored per `(consumer_group, topic, partition)` represents the sequence of the last event successfully processed by the group. The broker delivers the next event from `cursor + 1`. + +| Cursor value | Meaning | Broker emits from | +|---|---|---| +| `0` | Nothing processed yet — start of stream | `1` (= RF on a fresh topic where RF = 1) | +| `N` (≥ 1) | Last processed event had sequence `N` | `N + 1` | + +**Valid cursor range**: `[RF − 1, HWM]` + +Since RF ≥ 1, it follows that RF − 1 ≥ 0. The minimum valid cursor is therefore always `0` — no negative values are reachable on the wire, in the database, or in SDK types. The cursor type is `u64`-compatible (implementations may also use `i64` with a guaranteed ≥ 0 invariant). + +**SEEK sentinel resolution** (`POST /v1/subscriptions/{id}:seek`): + +| Sentinel | Resolves to | Broker emits from | +|---|---|---| +| `"earliest"` | `RF − 1` (= `0` on a fresh topic; ≥ 0 always) | RF (oldest retained event) | +| `"latest"` | HWM | Next event admitted after this SEEK | +| `"at:"` | Offset of first event with `occurred_at ≥ timestamp`, minus 1 | That event (or the next admitted if timestamp is beyond HWM) | +| Integer `N` | `N` (validated: must be in `[RF − 1, HWM]`) | `N + 1` | + +A SEEK value outside `[RF − 1, HWM]` is rejected with `400 InvalidInitialPosition`. + +#### GTS Base-Type Quick Reference + +The broker owns the following GTS base types. Concrete types extend each base with vendor-specific extensions (registered via `types_registry`): + +| Base type | Concept | Source | +|---|---|---| +| `gts.cf.core.events.topic.v1~` | Topic resource | [`schemas/topic.v1.schema.json`](schemas/topic.v1.schema.json) | +| `gts.cf.core.events.event.v1~` | Event (single schema, `readOnly` / `writeOnly` markers per direction) | [`schemas/event.v1.schema.json`](schemas/event.v1.schema.json), [`ADR/0003-event-schema.md`](ADR/0003-event-schema.md) | +| `gts.cf.core.events.event_type.v1~` | Event type registration record | [`schemas/event_type.v1.schema.json`](schemas/event_type.v1.schema.json) | +| `gts.cf.core.events.consumer_group.v1~` | Consumer group resource | [`schemas/consumer_group.v1.schema.json`](schemas/consumer_group.v1.schema.json) | +| `gts.cf.core.events.subscription.v1~` | Subscription resource (ephemeral, in-cache) | [`schemas/subscription.v1.schema.json`](schemas/subscription.v1.schema.json), [`ADR/0005-subscription-filter-typing.md`](ADR/0005-subscription-filter-typing.md) | +| `gts.cf.core.events.backend.v1~` | Storage backend plugin contract | DESIGN.md §3.2 (Storage Backend Plugin System) | +| `gts.cf.core.events.filter.v1~` | Filter engine plugin contract | [`ADR/0005-subscription-filter-typing.md`](ADR/0005-subscription-filter-typing.md) | + +`gts.cf.core.events.subject_type.v1~` appears in the broker design as an *authz-scope* resource type only (in `:` permission grammar). The broker does not own a schema file for subject types — subject types are owned by the modules that emit events about them. + +#### Validation Pipeline + +End-to-end flow when a producer publishes an event: + +1. **Authentication / authorization** at the API edge. `toolkit-security` populates `SecurityContext`; `authz-resolver` enforces `event_type:produce` and `tenant_id`-related scopes via the platform tenant resolver. Rejected publishes return `403`. +2. **Schema-level validation** against `event.v1.schema.json` (per [`ADR/0003-event-schema.md`](ADR/0003-event-schema.md)): structural conformance, ASCII encoding of event-field strings, per-field length caps, `trace_parent` format. Violations return `400 InvalidEventFieldEncoding`, `400 EventFieldTooLong`, `400 InvalidTraceParent`, etc. +3. **Read-only field rejection.** If the publish body contains any `readOnly` field (`partition`, `sequence`, `sequence_time`), the broker rejects with `400 BadRequest` naming the offending field. +4. **Event-type lookup.** The broker reads `event.type` (a GTS event-type identifier) and resolves it via `types_registry.get(event.type)`. Unknown event types are rejected with `404 EventTypeNotFound`. +5. **Subject-type membership.** The event's `subject_type` MUST match one of the patterns in `event_type.allowed_subject_types`. Violations return `422 SubjectTypeNotAllowed`. +6. **Per-type `data_schema` validation.** The broker fetches `event_type.data_schema` (a JSON Schema embedded in the event-type registration record) and validates `event.data` against it. Validation failure returns `422 PayloadValidationFailed` with the JSON Schema error path. Events declared as body-less (no `data` allowed by the `data_schema`) reject publishes carrying `data`. +7. **Partition derivation.** Per [`ADR/0002-partition-selection.md`](ADR/0002-partition-selection.md): `partition = murmur3_32(ascii_bytes(partition_key ?? tenant_id)) % topic.partitions`. The broker stamps `partition` server-side. +8. **Producer-mode shape check.** If `meta` is present, the broker validates `meta.version`, `meta.producer_id`, and the chained / monotonic / stateless mode-shape per [`ADR/0004-idempotent-producer-protocol.md`](ADR/0004-idempotent-producer-protocol.md). +9. **Ingest enqueue and outbox handoff.** The event passes to the toolkit-db outbox; backend persist assigns `sequence` and `sequence_time` later. See §3.6 "Event Publish Flow." + +Read-side delivery applies a smaller surface: the broker strips `writeOnly` `meta` from query / poll responses; `readOnly` `partition`/`sequence`/`sequence_time` fields are populated from storage. No re-validation against `data_schema` on read — the data was validated at ingest. + +### 3.2 Component Model + +**ID**: `cpt-cf-evbk-component-model` + +#### Module Structure + +```text +modules/system/event-broker/ +├── event-broker-sdk/ # Public API: traits, models, errors, producer client +│ └── src/ +│ ├── lib.rs # Re-exports +│ ├── api.rs # EventBrokerApi (unified), IngestApi, DeliveryApi traits +│ ├── models.rs # SDK types (Topic, EventType, Event, Subscription, Cursor) +│ ├── producer.rs # Outbox-based producer client (wraps toolkit-db outbox) +│ └── error.rs # EventBrokerError +│ +└── event-broker/ # Single module crate with internal service isolation + └── src/ + ├── lib.rs # Public exports + ├── module.rs # ModKit module wiring & deployment mode selection + ├── config.rs # EventBrokerConfig + ├── api/rest/ # Transport layer + │ ├── handlers/ + │ │ ├── ingest.rs # POST /v1/events, POST /v1/events:batch + │ │ ├── producers.rs # POST /v1/producers, GET cursors, POST :reset + │ │ ├── delivery.rs # GET /v1/events:stream, GET /v1/events:sse + │ │ ├── topics.rs # GET /v1/topics, GET /v1/topics/segments + │ │ ├── event_types.rs # GET /v1/event-types + │ │ ├── consumer_groups.rs # CRUD for consumer groups + │ │ └── subscriptions.rs # JOIN, list, read, leave, seek + │ ├── routes/ # OperationBuilder route registration + │ ├── dto.rs # REST DTOs (serde + utoipa) + │ ├── error.rs # Error response mapping + │ └── extractors.rs # Custom Axum extractors + ├── domain/ # Business logic (no infra dependencies) + │ ├── ingest.rs # IngestService trait + impl + │ ├── delivery.rs # DeliveryService trait + impl + │ ├── specification.rs # SpecificationManager: topic/type registration & cache + │ ├── model.rs # Domain entities (Topic, EventType, Event, Subscription, Cursor) + │ ├── repo.rs # Repository traits (EventRepo, TopicRepo, SubscriptionRepo, CursorRepo) + │ ├── cluster.rs # ClusterCapabilities usage (pub/sub, leader election) + │ ├── idempotency.rs # Idempotency key computation and checking + │ └── error.rs # DomainError + ├── infra/ # Infrastructure implementations + │ ├── storage/ # Storage backend plugin resolution + │ │ ├── registry.rs # StorageBackendRegistry (GTS discovery + resolution) + │ │ └── builtin/ # Built-in backend (in-memory for dev/test) + │ │ └── memory.rs # InMemoryStorageBackend + │ ├── cluster/ # ClusterCapabilities integration + │ │ └── notifications.rs # Event notification via cluster.publish/subscribe + │ ├── workers/ # Background workers + │ │ ├── cleaner.rs # Fully-consumed event cleanup + │ │ ├── retention.rs # Retention policy enforcement + │ │ └── reaper.rs # Expired subscription + idempotency cleanup + │ ├── dispatcher/ # Optional HTTP gateway + │ │ ├── proxy.rs # Proxy handler (routes to ingest service) + │ │ └── router.rs # Router handler (routes to delivery service) + │ └── type_provisioning.rs # GTS type registration + └── test_support/ # Test utilities +``` + +**Crate Naming**: Directory names hyphenated (`event-broker-sdk`), package names `cf-gears-` prefixed (`cf-gears-event-broker-sdk`), library names underscored (`event_broker_sdk`). + +#### Internal Services + +**ID**: `cpt-cf-evbk-component-services` + +##### IngestService (`domain/ingest.rs`) + +Producer-facing. Owns the write path. Built on the `toolkit-db` transactional outbox. + +Responsibilities: +- Accept single and batch event submissions +- Validate events against topic specification and event type JSON Schema (via SpecificationManager) +- Derive the broker topic `partition` from the publish body: hash `partition_key` if present, else `tenant_id`. Reject any top-level `partition` field on publish input with `400 BadRequest`. Treat any SDK/internal partition hint as non-authoritative validation metadata and reject mismatches with `400 PartitionHashMismatch`. See [`ADR/0002-partition-selection.md`](ADR/0002-partition-selection.md) +- Enqueue validated events into the per-topic outbox partition (atomic with validation) +- Enforce idempotent producer guarantees — track per-producer sequence state, detect and reject out-of-order or duplicate submissions +- Assign broker-managed monotonic sequences within the topic (via outbox processor) +- Persist events via storage backend (via outbox processor) +- Notify delivery shards via `cluster.publish("evbk.topic.{T}.partition.{P}")` (via outbox processor) + +Key methods: +- `publish_event(ctx, event_dto) → Result` +- `publish_batch(ctx, events) → Result` + +##### DeliveryService (`domain/delivery.rs`) + +Consumer-facing. Owns the read path and subscription lifecycle. + +Responsibilities: +- Manage subscription lifecycle in cache (JOIN creates, DELETE removes, TTL expires) +- Manage GroupState in cache (claim ownership on first JOIN, release on last LEAVE) +- Serve long-poll requests with server-side cursor tracking +- Apply each member's filters (per-member, declared at JOIN; no canonical group filter) +- **Apply defense-in-depth authz filter**: every event returned by `backend.query` is filtered through the subscription's cached `AccessScope` constraints (tenant boundaries, owner predicates, etc.) before being sent to the consumer — regardless of what the backend returned. A misbehaving or malicious backend cannot leak cross-(tenant, scope) data through this layer. (Resolves R16.) +- **Tolerate sparse offsets**: backends may delete events anytime per their retention. `query` returns events in `offset` order but consecutive offsets are not guaranteed; cursor advances by the highest offset seen, not by `+1`. (Resolves R09.) +- Refresh subscription TTL on each poll + +Key methods: +- `join(ctx, dto) → Result` — creates subscription in cache, claims/joins group +- `leave(ctx, subscription_id) → Result<()>` — removes from group, triggers rebalance +- `poll(ctx, subscription_id, timeout) → Result` — long-poll with topology-version-aware response +- `seek(ctx, subscription_id, partition_positions: HashMap) → Result<()>` — sets the cursor position for each assigned `(topic, partition)` before or during streaming + +##### Subscription Resolution Cache + +**ID**: `cpt-cf-evbk-component-subscription-resolution` + +The dispatcher routes requests with `subscription_id` only — no `consumer_group` or `topic` in the URL. Routing is two cache lookups: first to find the group, then to find the instance owning it. + +``` +Cache 1 — Subscription Resolution: + Key: evbk.subscription:{subscription_id} + Value: { consumer_group, ...meta (topology_version, etc.) } + TTL: session_timeout (refreshed on every poll/seek) + +Cache 2 — Group Endpoint: + Key: evbk.group.endpoint:{consumer_group} + Value: delivery_instance_endpoint (e.g., "http://delivery-7:8080") + TTL: refreshed by the owning instance via heartbeat +``` + +Dispatcher request handling for any request other than JOIN: + +``` +GET /v1/events:stream?subscription_id={S} (or :seek, /subscriptions/{S}, DELETE) + ↓ +1. cache.get("evbk.subscription:{S}") → { consumer_group } + (cache miss → 404 SubscriptionNotFound; consumer SDK re-JOINs) +2. cache.get("evbk.group.endpoint:{consumer_group}") → endpoint + (cache miss or stale → trigger new-group placement; see "New-Group Placement" below) +3. forward request to endpoint +4. on forward failure: dispatcher's circuit breaker opens for endpoint, cache entry treated stale, + request triggers re-placement +``` + +`POST /v1/subscriptions` (JOIN) is the only endpoint that doesn't start with subscription_id resolution — the consumer provides `consumer_group` and `interests[]` directly in the body (per [ADR-0005](ADR/0005-subscription-filter-typing.md)). The JOIN response creates both cache entries (subscription resolution and, if first member, the group endpoint mapping), plus the compiled-filter handles for each interest's optional `filter: {engine, expression}` object. + +The exact value shape of the subscription resolution cache is an implementation detail (may include `topology_version` for early stale-detection, owning_endpoint, etc.). The dispatcher only needs `consumer_group` to forward. + +##### GroupState Cache + +**ID**: `cpt-cf-evbk-component-group-state` + +Holds active members (each with their own topic list and filters), partition assignments, topology version, owning delivery instance endpoint. Manipulated only by the owning delivery instance, under `cluster.distributed_lock` for mutating operations: + +``` +Cache key: evbk.group.{consumer_group} +Value: GroupState (see §3.1 schema) +TTL: max(member.expires_at) across active_members; auto-reaped when empty +Lock: evbk.group.{consumer_group}.rebalance +``` + +The cache is replicated/shared via ClusterCapabilities (concrete provider varies — Redis, K8s ConfigMap+watch, Postgres LISTEN+UNLISTEN, NATS KV, in-memory for standalone). Required primitives: + +| Primitive | Used for | +|---|---| +| `cache.get(key)` | Resolution cache lookup, group state read | +| `cache.put(key, value, ttl)` | Refresh subscription TTL on poll, update group state | +| `cache.put_if_absent(key, value, ttl)` | CAS create-on-first-JOIN | +| `cache.delete(key)` | Explicit DELETE / LEAVE | +| `cache.watch(key)` | Topology change notifications (alternative to `cluster.publish`) | +| `cluster.distributed_lock(key, ttl)` | Group rebalance serialization | +| `cluster.publish/subscribe` | Per-`(topic, partition)` event notifications, per-group topology change notifications | + +##### SpecificationManager (`domain/specification.rs`) + +Shared by both ingest and delivery. Owns topic/event-type metadata. + +Responsibilities: +- Load and cache topic and event type specifications +- Validate specification state transitions (topics and types are append-only — no deletions of active specs) +- Provide indexed lookups by GTS identifier, UUID, or internal ID +- Hash-based change detection for cache invalidation +- Compile and cache JSON Schemas for event type validation + +Key methods: +- `register_topic(spec) → Result` +- `register_event_type(spec) → Result` +- `get_topic(gts_id) → Option` +- `get_event_type(gts_id) → Option` +- `validate_event_data(event_type, data) → Result<()>` + +##### ClusterCapabilities (Platform Dependency) + +**ID**: `cpt-cf-evbk-component-cluster-capabilities` + +The event broker consumes the platform-level cluster system module (see `modules/system/cluster/docs/DESIGN.md`). The module exposes four independent primitives, each as a versioned facade struct: + +- **`ClusterCacheV1`** — KV with TTL, version-based CAS, and watch notifications. Methods: `get`, `put`, `delete`, `contains`, `put_if_absent`, `compare_and_swap`, `watch`, `watch_prefix`. +- **`LeaderElectionV1`** — leader election with TTL and renewal config. +- **`DistributedLockV1`** — TTL-bounded distributed locks with explicit async release. +- **`ServiceDiscoveryV1`** — register / discover / watch service instances. + +There is **no separate pub/sub primitive**. The cluster module deliberately keeps reliable messaging out of scope (per its DESIGN.md §"Reliable messaging belongs in the event broker"). The broker realizes its own pub/sub semantics on top of `ClusterCacheV1::put` + `ClusterCacheV1::watch` — a publisher does `cache.put(notif_key, marker, ttl=short)` to trigger watchers; subscribers do `cache.watch(notif_key)` and act on the `Changed` event (the watch event carries only the key, no payload — consumers consult their local data structures or `cache.get` if they need state). + +**How the event broker uses each primitive**: + +| Use Case | Primitive | Concrete API | When | +|---|---|---|---| +| Event notifications (per `(topic, partition)`) | `ClusterCacheV1` | `cache.put("evbk.notif:{T}:{P}", marker, ttl=short)` → watchers wake | Ingest, after each successful persist (via outbox processor) | +| Long-poll wake-up | `ClusterCacheV1` | `cache.watch("evbk.notif:{T}:{P}")` | Delivery, when long-poll has no events to return immediately | +| Topology change notifications (per consumer group) | `ClusterCacheV1` | `cache.put("evbk.topology:{group}", version, ttl=long)` → watchers wake on `Changed` | When group rebalance happens or ownership migrates | +| Subscription / GroupState / Cursor cache | `ClusterCacheV1` | `cache.get / put / put_if_absent / compare_and_swap` | Across delivery shard's request handling | +| Group rebalance serialization | `DistributedLockV1` | `lock.try_lock("evbk.group.{group}.rebalance", ttl)` | Per-group rebalance critical section | +| Worker singleton (Reaper) | `LeaderElectionV1` | `election.elect("evbk.worker.reaper")` | Ensures one Reaper runner across the cluster | +| Shard discovery | `ServiceDiscoveryV1` | `sd.discover(role: ingest \| delivery)` | Dispatcher, to build its routing table; delivery shard, to consult instance liveness on circuit-breaker open | +| Shard registration | `ServiceDiscoveryV1` | `sd.register(info)` / `sd.deregister()` | Ingest and Delivery instances at startup / graceful shutdown | +| Topic rebalancing across ingest shards | `DistributedLockV1` | `lock.try_lock("evbk.rebalance:{T}", ttl)` | When topic ownership transfers between ingest shards (drain outbox before handoff) | + +Throughout the design, prose phrasings like *"ingest publishes a notification"* / *"delivery subscribes"* are shorthand for the `cache.put` + `cache.watch` realization above. The high-level abstraction-level pseudo-code (`cluster.publish(...)` / `cluster.subscribe(...)`) preserves readability; implementation maps it to the cache primitive. + +The event broker does not care which provider backs the cluster module (`standalone`, Redis, NATS, K8s, Postgres-via-`db-only`, etc.) — the platform layer handles that. In standalone mode, the standalone provider's in-process implementation makes all of the above effectively no-op or local-mutex, with zero external dependencies. + +#### Storage Backend Plugin System + +**ID**: `cpt-cf-evbk-component-storage-backend-plugin` + +Storage backends follow the **ModKit plugin pattern**: each backend is a GTS type extending a common base type, registered in `types_registry` at startup, and resolved at runtime through `ClientHub` scoped clients. Per-instance configuration is validated against the backend's own JSON Schema, declared as part of its GTS type definition. + +##### Plugin Trait + +The event broker SDK defines the storage backend contract following the `Backend` contract type semantics from [PR #1536](https://github.com/cyberfabric/cyberfabric-core/pull/1536) — remote-capable, independent failure domain, errors as RFC 9457 Problem Details, `SecurityContext` as first argument on every method. + +The trait is deliberately minimal — **four async data methods**. Per-call metadata (config schema, capability flags) lives in the GTS type registration, not on the trait. The backend knows nothing about cluster coordination, notifications, idempotency, or subscriptions. It is pure storage. + +```rust +/// Storage backend contract. Implemented by built-in or third-party backend crates, +/// resolved by the event broker module via GTS type discovery + ClientHub. +/// Follows the Backend contract type — remote-capable, independent failure domain. +#[async_trait] +pub trait StorageBackend: Send + Sync { + /// Persist events. The backend ASSIGNS the broker-logical consumer-visible + /// `offset`/`sequence` at its boundary. It may use a native write position + /// internally (Kafka offset, DB row sequence, file segment offset, etc.), + /// but native positions are translated before consumers observe them. + /// Consumers learn offsets via `query` after persist commits. + /// + /// Events arrive with `previous` and `sequence` populated by the ingest + /// service (the producer's chain, or null in stateless mode). Per-(topic, + /// partition) ordering is the OUTBOX's responsibility, not the backend's: + /// by the time persist is called, events for a (topic, partition) arrive in + /// the order the outbox sequencer ordered them. The backend MUST preserve + /// this order when assigning broker-logical offsets. + /// + /// **Outbox-retry idempotency**: the outbox processor may retry the same + /// persist call after a network timeout. The backend MUST be idempotent on + /// retry. It tracks its own `last_sequence` per `(topic, partition)` (where + /// `sequence` is the producer-set chain field on the event). On retry, if + /// every incoming event's `sequence` is `<=` the stored `last_sequence`, + /// the call is a duplicate and the backend returns `Ok(())` without + /// writing. Mismatched chain (e.g., the first new event's `previous` does + /// not match stored `last_sequence`) surfaces as `SequenceViolation`. + /// + /// Producer-level dedup (across producer client retries) is the + /// IngestService's responsibility via `evbk_producer_state` and is already + /// resolved before `persist` is called. + async fn persist( + &self, ctx: &SecurityContext, topic: &Topic, + partition: i32, + events: &[Event], + ) -> Result<(), StorageError>; + + /// Read events where broker-logical sequence > offset, ordered by sequence, + /// up to limit. `offset` and returned `event.sequence` values are in the + /// broker-logical consumer-visible sequence space. The backend translates + /// them to/from native positions internally when needed. + async fn query( + &self, ctx: &SecurityContext, topic: &Topic, + offset: i64, limit: i32, + ) -> Result, StorageError>; + + /// Delete events with sequence ≤ threshold. Idempotent. + async fn truncate( + &self, ctx: &SecurityContext, topic: &Topic, up_to: i64, + ) -> Result; + + /// Topic storage metadata (segments, offsets, sizes). + async fn segments( + &self, ctx: &SecurityContext, topic: &Topic, + ) -> Result, StorageError>; +} + +/// Storage errors round-trip across the boundary as RFC 9457 Problem Details. +/// `error_domain = "cf.event_broker.storage"`. Variants generate stable +/// UPPER_SNAKE_CASE `error_code` values for machine-readable error handling. +#[derive(Debug, Clone, ContractError)] +#[contract_error(domain = "cf.event_broker.storage")] +pub enum StorageError { + #[error(status = 404, problem_type = "topic-not-found")] + TopicNotFound { topic: String }, + + #[error(status = 400, problem_type = "sequence-conflict")] + SequenceConflict { topic: String, sequence: i64 }, + + #[error(status = 400, problem_type = "sequence-violation")] + SequenceViolation { + topic: String, + partition: i32, + expected_prev: i64, // backend's stored last_sequence + received_prev: i64, // what the incoming event claimed + detail: String, + }, + + #[error(status = 503, problem_type = "backend-unavailable")] + BackendUnavailable { detail: String, retry_after_seconds: Option }, + + #[error(status = 500, problem_type = "internal")] + Internal { description: String }, + // ... extended as backends declare specific failure modes +} +``` + +##### Backend Metadata (Registration-Time, Not Per-Call) + +Backend metadata is needed by the event broker but is **not a per-request method** — it would not survive a remote boundary as a synchronous reference-returning call. It is queried once at registration and cached by the contract proxy: + +| Metadata | Purpose | Source | +|---|---|---| +| `config_schema` (JSON Schema) | Validates a topic's `streaming` config block at topic registration | GTS type registration `properties.config_schema` | + +The GTS instance for a backend type carries this metadata in its `properties` block, registered when the backend plugin crate loads. The event broker reads it once at type discovery time. + +```text +GTS instance properties for a storage backend type: +{ + "config_schema": { /* JSON Schema for streaming config */ }, + "vendor": "x.core.event_broker", + ... +} +``` + +**Design rationale** — what the backend does NOT own: + +| Concern | Owner | Why not backend? | +|---|---|---| +| Sequence assignment | IngestService | Kafka can do it; file/S3/DB cannot without distributed coordination. Keep simple backends simple. | +| Event notifications | ClusterCapabilities | Broadcasting requires P2P knowledge (who to notify). Backends like file/S3 have no pub/sub capability. | +| Idempotency tracking | IngestService | Producer state is a cross-cutting concern independent of storage medium. | +| Subscription/cursor management | DeliveryService | Consumer state has its own lifecycle (expiry, acknowledgment) unrelated to event storage. | + +##### Backend Type vs. Backend Instance + +Two separate concerns are involved in resolving "which backend stores this topic": + +| Concept | Identity | Registered Via | Example | +|---|---|---|---| +| **Backend Type** | GTS type extending the base storage-backend type | `types_registry` (compile-time per backend plugin crate) | `gts.cf.core.events.backend.v1~cf.core.backend.postgres.v1` — the *kind* of storage | +| **Backend Instance** | Service entry with metadata | `ClusterCapabilities.register_shard()` at runtime | A running postgres deployment in `us-east-1` — the *specific* deployment | + +A single backend type (say `postgres`) may have many running instances across regions, environments, capacity tiers, etc. + +##### Backend Type Registration (GTS Type Extension) + +Storage backends are GTS types that **extend a common base type**. The event broker registers the base type at `init()`: + +```text +Base type (registered by event broker): + gts.cf.core.events.backend.v1~ + +Concrete types (each registered by its plugin crate): + gts.cf.core.events.backend.v1~cf.core.backend.memory.v1 + gts.cf.core.events.backend.v1~cf.core.backend.postgres.v1 + gts.cf.core.events.backend.v1~vendor.events.backend.kafka.v1 + gts.cf.core.events.backend.v1~vendor.events.backend.s3.v1 +``` + +The base type defines the contract; concrete types extend it with backend-specific schemas (e.g., `gts.cf.core.events.backend.v1~cf.core.backend.postgres.v1`). + +Each backend plugin crate, when loaded: +1. Registers its concrete GTS type extending `backend.v1~` in `types_registry` +2. Registers its `dyn StorageBackend` trait implementation in `ClientHub` scoped by the GTS type id +3. Provides its `config_schema()` for validating instance configuration + +##### Backend Instance Registration (ClusterCapabilities) + +Backend **instances** are runtime deployments registered through `ClusterCapabilities.register_shard()` with metadata. Operators register them at platform startup or via deployment configuration: + +```yaml +# Platform-level backend instance registration +backends: + - type: "gts.cf.core.events.backend.v1~cf.core.backend.postgres.v1" + metadata: + region: "us-east-1" + tier: "primary" + capacity: "high" + environment: "production" + config: + connection_url: "postgresql://..." + pool_size: 20 + + - type: "gts.cf.core.events.backend.v1~cf.core.backend.postgres.v1" + metadata: + region: "eu-west-1" + tier: "primary" + capacity: "medium" + environment: "production" + config: + connection_url: "postgresql://..." + pool_size: 10 + + - type: "gts.cf.core.events.backend.v1~vendor.events.backend.kafka.v1" + metadata: + region: "us-east-1" + environment: "production" + config: + brokers: ["kafka-1:9092", "kafka-2:9092"] +``` + +The `config` block is validated against the backend type's `config_schema()`. The `metadata` block is free-form key/value used for selection. + +##### Topic → Backend Selection (Selector-Only) + +Topic specifications **never reference backend instances by id** — selection is always via a metadata selector. This decouples topic specs from operator-specific deployment naming and lets ops change the underlying instances (failover, migration, scale-up) without touching topic configurations. + +```yaml +topics: + - id: "gts.cf.core.events.topic.v1~vendor.users.v1" + streaming: + backend_selector: + type: "gts.cf.core.events.backend.v1~cf.core.backend.postgres.v1" + metadata: + region: "us-east-1" + tier: "primary" + retention: "P30D" + + - id: "gts.cf.core.events.topic.v1~vendor.audit.v1" + streaming: + backend_selector: + type: "gts.cf.core.events.backend.v1~cf.core.backend.postgres.v1" + metadata: + region: "eu-west-1" + environment: "production" + retention: "P90D" +``` + +At topic registration: +1. The event broker queries `cluster.discover_shards()` for instances matching the selector's `type` filtered by `metadata` predicates +2. **Exactly one instance must match** — zero matches rejects the topic spec; multiple matches reject the topic spec (selector ambiguity error). Operators must make selectors specific enough to identify exactly one instance. +3. The resolved instance becomes the topic's bound backend; the binding is persisted so reads/writes stay consistent even if matching instances change later +4. If the bound instance becomes unavailable (deregistered), writes fail with `BackendUnavailable` until the instance is restored or the topic is re-bound through ops tooling + +##### Built-in Backend Types + +| Type GTS | Description | +|---|---| +| `gts.cf.core.events.backend.v1~cf.core.backend.memory.v1` | In-memory. Dev/test only. No persistence across restarts. | +| `gts.cf.core.events.backend.v1~cf.core.backend.postgres.v1` | SeaORM-based. PostgreSQL primary, also supports MySQL/SQLite. Production default. | + +Additional backend types (Kafka, S3, file-based, remote archive) are separate plugin crates — operators install them as needed without modifying the event broker core. + +#### Background Workers + +**ID**: `cpt-cf-evbk-component-workers` + +Workers run as background tasks managed by the module lifecycle. Each worker acquires a distributed lock (PostgreSQL advisory lock in cluster mode, no-op in standalone) before processing to prevent duplicate work across nodes. + +| Worker | Responsibility | Trigger | +|---|---|---| +| **Reaper** | Cleans up expired subscriptions (`expires_at < now()`), stale `evbk_producer_state` records (no activity within the topic's `retention`), and stale `evbk_producer` rows (no activity within the platform-wide producer-registration TTL). Cascade-purges state rows when a registration row is reaped. | Periodic (default: 60s) | + +**Event-level retention is the backend's responsibility.** The event broker has no `Cleaner` or `RetentionWorker`. Retention, compaction, and event deletion are entirely owned by the storage backend (configured via the backend's own `streaming` config block at topic registration). Backends MAY delete events at any time — including mid-stream gaps — and consumers / the delivery service treat the offset stream as a sparse log: `query` returns events ordered by `offset` but consecutive offsets are not guaranteed. The ingest outbox auto-vacuums its own messages once `backend.persist` acknowledges them; that's the only event-related cleanup the broker performs, and it's part of standard `toolkit-db` outbox behavior. + +#### Request Routing + +| Path Pattern | Service | Purpose | +|---|---|---| +| `POST /v1/events` | Ingest | Publish single event | +| `POST /v1/events:batch` | Ingest | Publish batch of events | +| `POST /v1/producers` | Ingest | Register a producer (mint `producer_id`) | +| `GET /v1/producers/{id}/cursors` | Ingest | Read per-`(topic,partition)` last_sequence for desync recovery | +| `POST /v1/producers/{id}:reset` | Ingest | Operator-driven chain reset (preserves `producer_id`) | +| `POST /v1/consumer-groups` | Delivery | Create anonymous consumer group (broker-minted id) | +| `GET /v1/consumer-groups` | Delivery | List consumer groups | +| `GET /v1/consumer-groups/{id}` | Delivery | Read a consumer group | +| `DELETE /v1/consumer-groups/{id}` | Delivery | Delete a consumer group (only if no active members) | +| `POST /v1/subscriptions` | Delivery | JOIN — create subscription against a consumer group | +| `GET /v1/subscriptions` | Delivery | List active subscriptions (OData: `$filter`, `limit`, `cursor`) | +| `GET /v1/subscriptions/{id}` | Delivery | Read a single subscription | +| `DELETE /v1/subscriptions/{id}` | Delivery | LEAVE — terminate a subscription | +| `GET /v1/events:stream` | Delivery | Multipart event stream (long-lived, one event per part) | +| `GET /v1/events:sse` | Delivery | SSE event stream (opt-in, browser-native) | +| `POST /v1/subscriptions/{id}:seek` | Delivery | SEEK — set per-partition starting cursor; accepts integer offsets and sentinels including `"at:"` | +| `GET /v1/topics` | Shared | List topics (OData: `$filter`, `limit`, `cursor`) | +| `GET /v1/topics/segments` | Shared | Get topic segment manifest for a `(topic, partition)` | +| `GET /v1/event-types` | Shared | List event types (OData: `$filter`, `limit`, `cursor`; read-only) | + +#### Long-Poll Mechanism + +**ID**: `cpt-cf-evbk-component-streaming-delivery` + +> **Transport surface**: `/events:stream` is the default v1 consumption transport — long-lived `multipart/mixed` over `Transfer-Encoding: chunked`, emitting one event per multipart part with heartbeats at a 5 s default cadence to keep idle connections alive. `/events:sse` is an opt-in additive endpoint (`text/event-stream`) for browser-direct consumers. Both share the frame schema (`event` / `heartbeat` / `topology` / `control`) — see [`features/0004-consumption-transport.md`](features/0004-consumption-transport.md). The previous `/events:poll` endpoint is retired; long-poll semantics are covered by reading from `/events:stream` and disconnecting voluntarily. + +Streaming delivery uses `ClusterCapabilities` pub/sub for event notifications, plus a per-partition in-memory event cache to bound notification fan-out: + +1. Consumer sends `GET /v1/events:stream?subscription_id={uuid}` and holds the response open. +2. DeliveryService reads from the **per-partition in-memory cache** (an append-only linked list of recent batches; one cache instance per `(topic, partition)` owned by this delivery shard). The stream holds an iterator into the list at the consumer's `cursor.offset` position. +3. If the iterator has unread events → apply per-member filters and emit one multipart part per matching event, immediately. +4. If the iterator is at the tail → wait on the cache's condvar; emit a `heartbeat` frame every 5 s of idle. +5. On `cluster.subscribe("evbk.topic.{T}.partition.{P}")` notification (ingest published new events) → the cache is updated, condvar is signaled, all waiting iterators wake, advance, filter, and emit their respective events. +6. On subscription termination or consumer disconnect → close the response gracefully. + +**Why the per-partition cache** (resolves R39): without it, every event publish triggers N independent `query + filter + respond` cycles for N consumer groups subscribed to the topic. The append-only cache + iterator pattern means each new event batch is queried/loaded **once** by the partition's owner, and N iterators advance over the same memory — no per-group backend re-query, no fan-out amplification at the storage layer. + +The notification path bypasses the dispatcher entirely — ingest shards publish directly to the cluster pub/sub channel, delivery shards subscribe. In standalone mode (standalone `ClusterCapabilities` provider), this is an in-process Tokio channel. In cluster mode, it's whatever the provider implements (Redis Pub/Sub, NATS, K8s events, DB polling, etc.). + +> **Note**: this section will be revisited and tightened in a follow-up pass once outstanding feedback is closed (cache eviction policy, backfill paths for consumers behind the cache window, iterator-vs-cursor reconciliation, condvar / notification ordering details). Tracked in §4.7. + +#### Producer Modes (Chained / Monotonic / Stateless) + +**ID**: `cpt-cf-evbk-component-idempotent-producers` + +The broker supports three producer modes — **chained**, **monotonic**, **stateless** — for ingest-side idempotent publishing. **Mode is declared once at producer registration** (`POST /v1/producers { "mode": "chained" | "monotonic" }`) and enforced per request. Stateless publish does not register; the producer omits the `meta` block entirely and the broker performs no dedup. Producer-protocol fields (`producer_id`, `previous`, `sequence`) live inside the publish-time `meta` block on the event (marked `writeOnly` per [ADR-0003](ADR/0003-event-schema.md)) and are stripped on the consumer-visible read response. + +Per-event chain state lives in `evbk_producer_state` keyed by `(producer_id, topic, partition)`. Producer registration lives in `evbk_producer` with a `last_seen_at` timestamp. Both rows are reaped by the Reaper worker — state rows per the topic-level `retention` (capped at `P14D`); registration rows per the platform-wide producer-registration TTL (default `P30D`). When a producer registration is reaped, its state rows are cascade-deleted; next publish referencing the aged-out `producer_id` returns `400 UnknownProducer`. + +Operator-driven chain reset is available via `POST /v1/producers/{id}:reset` (preserves `producer_id`; principal-bound; audited). + +Batch publishes are single-`(topic, partition)`, contiguous-chain for chained mode, and all-or-nothing (a single mode-shape or chain violation rejects the entire batch). + +For the full normative surface — wire shapes, registration ergonomics, mode-shape enforcement, error catalog, atomicity contract, principal binding, desync recovery flows, reset semantics, producer-registration TTL, acceptance criteria, and test plan — see [`docs/features/0001-idempotent-producers.md`](features/0001-idempotent-producers.md), [`ADR/0004-idempotent-producer-protocol.md`](ADR/0004-idempotent-producer-protocol.md), and [`ADR/0003-event-schema.md`](ADR/0003-event-schema.md). + +#### Subscription Lifecycle + +**ID**: `cpt-cf-evbk-component-subscription-lifecycle` + +Subscriptions are session-lifetime resources with session-timeout-based expiry: + +1. `POST /v1/subscriptions` creates a subscription with `interests[]` (topic-anchored typed-filter selections per [ADR-0005](ADR/0005-subscription-filter-typing.md)) and `session_timeout`. JOIN validation is all-or-nothing — see ADR-0005 § JOIN Validation Order. +2. `expires_at = now() + session_timeout` set on creation. +3. Each poll with `subscription_id` refreshes: `last_seen_at = now()`, `expires_at = now() + session_timeout`. +4. Subscriptions not polled within `session_timeout` become eligible for cleanup by the Reaper worker (which also evicts the compiled-filter handles). + +Consumer progress is tracked by the consumer via SEEK (`POST /v1/subscriptions/{id}:seek`), not by a broker-side ACK. See [ADR-0006](ADR/0006-offset-authority.md). + +`interests[]` and the compiled filter handles are immutable after creation. To update interests, create a new subscription and retire the old one. This avoids race conditions between filter updates and in-flight poll requests. + +#### Offset Tracking (Cursor) + +**ID**: `cpt-cf-evbk-component-cursor` + +Each subscription tracks three positions within the topic. The broker does not durably store consumer-confirmed progress — see [ADR-0006](ADR/0006-offset-authority.md). + +```text + cursor sent received max_sequence + │ │ │ │ + ▼ ▼ ▼ ▼ +Events: ─────[1]──[2]──[3]──[4]──[5]──[6]──[7]──[8]──[9]──→ + ▲ ▲ ▲ + │ │ │ + SEEK sets delivered to ingested + start point consumer into topic +``` + +| Position | Meaning | Updated By | +|---|---|---| +| `max_sequence` | Highest sequence assigned in the topic | IngestService (on publish) | +| `received` | Highest sequence persisted and broadcast | IngestService (after persistence + broadcast) | +| `sent` | Highest sequence delivered to this consumer in the current session | DeliveryService (on poll response) | +| `cursor` | Starting offset set via SEEK; broker emits from `cursor + 1` | Consumer via `POST /v1/subscriptions/{id}:seek` | + +`cursor` is ephemeral — cache-backed, valid for the lifetime of the subscription session. On reconnect the consumer re-SEEKs from its own persistent store. The gap between `cursor` and `sent` is events delivered but not yet processed client-side. The gap between `sent` and `received` is available for the next delivery cycle. `cursor ∈ [0, HWM]` — always non-negative since sequences start at 1 (see [§ Offset Semantics](#offset-semantics)). + +#### Outbox-Based Producer and Ingest Pipeline + +**ID**: `cpt-cf-evbk-component-outbox-ingest` + +The `toolkit-db` transactional outbox is the foundation for both the **producer client library** (SDK-side) and the **ingest service** (server-side). This reuses proven infrastructure — the outbox's four-stage pipeline (enqueue → sequencer → processor → vacuum), per-partition ordering, and transactional guarantees — rather than building a parallel mechanism. + +##### Producer Client (SDK) + +Modules that produce events use the SDK's `EventProducer`, which wraps the outbox: + +```rust +// Inside a business transaction: +let producer = hub.get::()?.producer(); +producer.enqueue(&txn, "gts.cf.core.events.topic.v1~vendor.users.v1", Event { + id: Uuid::new_v4(), + event_type: "gts.cf.core.events.event_type.v1~vendor.users.user_created.v1", + subject: user_id.to_string(), + subject_type: "gts.vendor.users.user.v1~", + data: serde_json::to_value(&payload)?, + ..Default::default() +}).await?; +// Event is only published if the business transaction commits. +``` + +Under the hood, `EventProducer.enqueue()` calls `outbox.enqueue()` with the topic as the outbox queue name and a hash-based partition key. The outbox handler then delivers the event to the ingest service (in-process call in standalone mode, HTTP/gRPC in cluster mode). + +This gives producers: +- **Transactional safety** — events are only visible if the business transaction commits +- **At-least-once delivery** — the outbox retries until the ingest acknowledges +- **Exactly-once via idempotent producer** — the ingest deduplicates using PID + producer sequence from the outbox message metadata + +##### Ingest Pipeline + +The ingest service uses the toolkit-db outbox internally to **preserve per-(topic, partition) order** between event arrival and backend persist. The outbox does NOT assign the broker sequence — the backend does that, on persist. The outbox's role is order preservation only. See §3.6 "Two Sequences" for the full story. + +```text +HTTP POST /v1/events + → IngestService.publish_event() + → Validate (SpecificationManager) + → Chain check (using meta.previous + meta.sequence; not the consumer-visible sequence) + → outbox.enqueue(&txn, topic, partition, event) + // atomic with producer_state.last_sequence update + // outbox sequencer assigns its OWN per-partition sequence (internal, + // never exposed) for the queue ordering guarantee + → Default response: 202 Accepted { event_id, accepted_at } (broker sequence + not yet assigned; see "Sync vs Async Persist Modes" in §3.6) + → Sync mode (opt-in): wait for outbox processor → backend.persist completion; + respond 201 Created { event_id, partition, ... } + +Asynchronously, in the outbox processor: + → backend.persist(events without broker sequence) + → Backend ASSIGNS broker-logical sequence at its boundary + (native positions stay internal; Kafka offset N → sequence N + 1) + → Returns events with broker-logical sequence populated + → Notify delivery via cluster.publish("evbk.topic.{T}.partition.{P}") + → Ack outbox message +``` + +The outbox queues within the ingest map to topics — each topic is an outbox queue with configurable partition count. This gives the ingest service: +- **Per-(topic, partition) order preservation** — the outbox sequencer guarantees order from enqueue to backend.persist +- **Crash recovery** — unprocessed outbox messages are retried on restart; backend's idempotency on `event.id` handles retries safely +- **Backpressure** — the outbox's configurable concurrency and pacing controls ingest throughput +- **Decoupled persist latency** — slow backends (Kafka with `acks=all`, S3, etc.) don't block producer's ack; the producer gets 202 in milliseconds (single outbox enqueue), and backend persist completes asynchronously + +**The outbox's internal per-partition sequence number** assigned by its sequencer stage is NOT the broker sequence — it's an internal queue ordering number, used only to guarantee in-order processor delivery, never exposed in the API or stored by the backend. + +**The vacuum stage** (last of the outbox's four stages) is implemented entirely by `toolkit-db`'s outbox library — not by the broker. Vacuum's cluster ownership, failure recovery, leader election, and any tuning live in `toolkit-db`. The broker contributes nothing beyond using the outbox. + +##### Two outboxes — intentional layering + +The producer SDK has its own outbox, and the ingest service has its own outbox. **Both exist in standalone mode**, and both live in the same DB process. This is intentional, not redundant. They solve different problems: + +| Outbox | Problem solved | Boundary it guarantees | +|---|---|---| +| Producer outbox (toolkit-db, in producer module's DB schema) | Transactional write integrity — if the producer's business transaction commits, the event WILL reach the broker, even if the broker is unreachable right now | At-least-once delivery from producer to broker, atomic with business state | +| Ingest outbox (toolkit-db, in event-broker module's DB schema) | In-pipeline order preservation + decoupling backend latency from HTTP latency | At-least-once persistence to backend, per-`(topic, partition)` order | + +**No table-name collision in standalone**: every Gears module owns its own DB schema (platform invariant), so `producer_outbox` in the producer module's schema and `ingest_outbox` in the event-broker's schema are distinct tables in the same database. The producer SDK never names the ingest's outbox table — it talks to its own module's outbox and lets `IngestService` (in-process trait call in standalone, HTTP in cluster) handle the rest. + +##### Ack Semantics + +Each outbox layer has an independent retry / durability boundary: + +- **Producer outbox** considers an event delivered when the ingest API returns `202 Accepted` — i.e., the event is durably enqueued in the ingest outbox AND `evbk_producer_state.last_sequence` has advanced atomically. The producer outbox does NOT wait for `backend.persist`; that's the ingest outbox's job. +- **Ingest outbox** considers an event delivered when `backend.persist` returns `Ok(())` — backend has stored the event and assigned the offset. +- **Sync mode (`Sync-Wait: true`)** extends the HTTP response timing: the ingest holds the request open until `backend.persist` returns, then responds `201 Created`. The producer outbox still acks on the same response — sync mode just delays it. The two-layer ack model is unchanged. + +This layering means a slow backend never blocks the producer outbox; a misconfigured ingest never silently drops producer commits. + +#### Dispatcher Routing + +**ID**: `cpt-cf-evbk-component-dispatcher-routing` + +In cluster mode, the dispatcher routes producer requests (ingest path) and consumer requests (delivery path) using two independent strategies. Neither relies on exclusive `(topic, partition)` ownership for correctness — the toolkit-db outbox handles ingest serialization at row granularity (see R03), and delivery uses cache-based instance lookup (R04 / R58). + +##### Ingest Routing (Two Modes) + +Ingest routing picks which ingest instance handles a producer's `POST /v1/events` for a given `topic`. Two modes, configurable per deployment: + +**Mode H — Hetero (default for MVP)**: every ingest instance serves every topic. Dispatcher load-balances HTTP requests across all healthy ingest instances (round-robin, least-loaded — implementation choice). Any instance can publish to any topic; the toolkit-db outbox handles per-`(topic, partition)` serialization across all of them via claim-based locking. + +**Mode S — Sharded**: ingest instances are specialized — each is configured to serve a subset of topics matching declared patterns. Dispatcher uses `cluster.discover_shards(role: ingest)` + per-instance topic-pattern metadata to pick a matching ingest. Specialized shards isolate workload (high-volume topics, tenant grouping, hardware affinity); a default shard provides a fallback. + +Both modes are correct. The choice is operational — workload isolation vs single-pool simplicity. + +##### Pattern Syntax (`serves_topic_patterns`) + +Each ingest instance declares the topics it serves via static config: + +```yaml +ingest: + serves_topic_patterns: ["gts.cf.vendor.audit.*", "gts.cf.vendor.security.*"] +``` + +Or for the default catch-all: + +```yaml +ingest: + serves_topic_patterns: ["*"] +``` + +Pattern syntax — minimal and unambiguous: + +| Pattern shape | Meaning | Example | +|---|---|---| +| `*` (alone) | Match any topic | catch-all default | +| `.*` | Match any topic GTS string starting with `.` | `gts.cf.vendor.audit.*` matches `gts.cf.vendor.audit.topic.v1~users` | +| Exact string | Match only this exact topic GTS | `gts.cf.vendor.users.v1` | + +Rules: +- The wildcard `*` is allowed **only as a complete suffix segment**: it must follow a literal `.` (or be the entire pattern as a catch-all). +- No mid-string wildcards (`gts.cf.*.audit.*` is invalid). +- No prefix wildcards (`*.users.v1` is invalid). +- Pattern is matched against the full topic GTS string. +- Multiple patterns per instance: matched if any pattern matches. + +##### Dispatcher Matching Algorithm (Sharded Mode) + +For an incoming publish to topic `T`: + +``` +1. Discover candidates: + instances = cluster.discover_shards(role: ingest) + .filter(i => i.is_healthy()) + .filter(i => any pattern in i.serves_topic_patterns matches T) + +2. Tie-break by specificity (most-specific wins): + Compute specificity for each matched pattern: + - Exact match (no `*`): specificity = pattern.length + ∞ + - Wildcard pattern .*: specificity = prefix.length + 1 + - Catch-all `*`: specificity = 0 + Group instances by their best-matching pattern's specificity. + Keep only instances at the highest specificity tier. + +3. Load-balance within the tier: + Among the highest-specificity matches, pick one (round-robin / + least-loaded — implementation choice). + +4. No match: + instances.is_empty() → return 503 NoIngestForTopic. +``` + +In hetero mode, all instances declare `serves_topic_patterns: ["*"]`, so step 2 always converges to the same tier and step 3 is the load-balance. + +##### Why no exclusive ownership + +Earlier drafts of this design framed ingest as "topic-sharded with a single owner per `(topic, partition)`" — that framing is wrong (see R03). The outbox's claim-based locking serializes per-`(topic, partition)` at row granularity inside the toolkit-db outbox, regardless of how many ingest instances exist or how the dispatcher routes. The dispatcher's routing is a **load-distribution choice**, not a correctness invariant: + +- In hetero mode, the dispatcher could send the same `(topic, partition)` to two different ingest instances at the same time — the outbox queue serializes them. +- In sharded mode, only specialized instances see traffic for matching topics — but if two instances share a pattern (e.g., a 2-instance specialized shard), the outbox still serializes correctly. + +The benefit of sharded mode is **resource isolation** (a misbehaving topic doesn't starve other topics' workers; tenant or workload grouping by shard) — not correctness or sequence safety. + +Hot-topic concerns (R30) are addressable in either mode: in hetero, multiple workers can drain a hot partition's outbox queue concurrently subject to the per-partition serialization claim; in sharded mode, dedicate a shard to the hot topic. + +##### Delivery Routing (Cache-Based, Not Hash-Based) + +A `consumer_group` is owned by exactly one delivery instance at a time. The mapping is held in the cluster cache (ClusterCapabilities KV) and looked up on every request — there is **no consistent-hash routing**. + +``` +Cache (ClusterCapabilities, TTL'd): + evbk.group.endpoint:{consumer_group} → delivery_instance_endpoint + + Refreshed by the owning instance via heartbeat (write-with-TTL). +``` + +Dispatcher per-request flow: +1. Resolve `subscription_id → consumer_group` (subscription resolution cache) +2. Look up `evbk.group.endpoint:{consumer_group}` → owning endpoint +3. Forward request to that endpoint +4. On forward failure: dispatcher's circuit breaker trips for that endpoint; cache entry is treated as stale; request triggers re-placement + +This ensures: +- **Cursor consistency** — a single instance manages each group's in-cache state, avoiding split-brain offset tracking (cursor itself is in the DB, but the in-memory `assignments` and `last_examined` write-back path is single-writer) +- **Efficient state caching** — the owning instance can hold subscription filters and recent events for its groups +- **Ordered delivery within a group** — no cross-node coordination needed + +##### New-Group Placement + +When a JOIN arrives for a `consumer_group` with no cache entry (= new group): + +1. **Look for an instance already serving this group's topic(s)** — query a topic-instance metadata source (cache `evbk.topic.serving:{topic}` or `cluster.discover_shards()` results with topic metadata). Among matches, pick one **under cap** — `max_groups_per_delivery_instance` (default `1000`, configurable per deployment). +2. **Fallback: cluster-level "who can take it?"** — broadcast via KV watch / queue / leader election (mechanism deferred to ClusterCapabilities provider). First instance under cap claims ownership atomically (CAS create on `evbk.group.endpoint:{consumer_group}`). +3. **All instances at cap → 503 with `Retry-After`**. Operator's signal to scale out delivery shards. +4. The chosen instance writes the cache entry, runs the JOIN, and starts owning the group. + +**Why cap on groups, not on long-polls**: a group is the durable unit of state held by a delivery instance (cursor sticky, GroupState in cache, partition assignments). Number of long-poll connections varies with consumer-instance count and polling cadence; capping that directly is fragile. Capping groups gives operators a predictable scale-out trigger and bounds total long-poll count transitively (1000 groups × ~3 members average = ~3000 active long-polls per shard, well within tokio's comfort zone). + +##### Failover + +If a delivery instance B becomes unavailable, the broker uses **service-discovery as the source of truth for liveness** — never any single dispatcher's local view. This prevents one dispatcher's connectivity blip from causing cluster-wide ownership thrashing. + +**Cache invalidation under partial connectivity**: when a dispatcher A's circuit-breaker opens against delivery instance B, A MUST consult `ServiceDiscoveryV1::discover` to check B's liveness **before** invalidating the shared `evbk.group.endpoint:{...}` cache entry: + +1. **A circuit-breaks on B** — local outbound failures (HTTP timeout, connection refused) trip A's per-endpoint circuit-breaker. +2. **A consults SD**: `sd.discover(role: delivery, instance: B)`. +3. **If B is alive in SD** → only A's local circuit-breaker engages. The shared `evbk.group.endpoint:{...}` cache is untouched. Other dispatchers continue routing to B normally. A retries with backoff; if A's network recovers, normal routing resumes. If A's local breaker stays open past a configured budget, A surfaces a 503 to incoming requests for groups it can't reach (operator's signal that A is partitioned). +4. **If B is deregistered** (heartbeat lapsed, SD evicted B) — the dispatcher invalidates the shared cache entry; subsequent JOINs trigger new-group placement. Convergence on B's actual death is bounded by SD's heartbeat-loss detection (typically 10–30s). +5. **If SD itself is unreachable** from A — A assumes worst-case (B alive); local-only circuit-breaker; no shared-cache invalidation. Pessimizes the rare "B is actually dead AND SD is unreachable from A" case but avoids the much worse "A causes split-brain when B is fine." + +**Why the SD check is structural** (not optional): without it, A's local connectivity loss would invalidate the shared cache → other dispatchers re-place the group on a different shard C via CAS → C claims ownership while B is still alive with active long-polls → split-brain (two delivery instances owning the same group). The SD check is the bridge between A's local view and consensus-truth liveness. + +**After cache invalidation, group recovery**: +- Affected groups go through new-group placement on next request. +- Subscription state is gone (was in B's pod memory or in cache TTL'd against B's lease) — consumer SDKs see `410 Gone` (graceful) or `404 SubscriptionNotFound` (ungraceful) and re-JOIN cleanly. +- Group cursor: cache entry survives if the cache provider is persistent (Redis-with-disk, etcd) and not bound to B's lease. New owner reads `evbk.cursor:{group}:{topic}:{partition}` and resumes from `offset`. If the cache also lost the cursor, recovery falls through to `earliest_available_offset` per `backend.query` (consumers re-process per at-least-once). + +##### Dispatcher Routing Table + +```text +┌──────────────────────────────────────────────────────────────────────┐ +│ Dispatcher │ +├──────────────────────────────────┬───────────────────────────────────┤ +│ Ingest Routes │ Delivery Routes │ +│ │ │ +│ POST /v1/events │ GET /v1/events:stream │ +│ POST /v1/events:batch │ GET /v1/events:sse │ +│ POST /v1/producers │ POST /v1/consumer-groups │ +│ GET /v1/producers/{id}/ │ GET /v1/consumer-groups │ +│ cursors │ GET /v1/consumer-groups/{id} │ +│ POST /v1/producers/{id}:reset │ DELETE /v1/consumer-groups/{id} │ +│ │ POST /v1/subscriptions │ +│ │ GET /v1/subscriptions │ +│ │ GET /v1/subscriptions/{id} │ +│ │ DELETE /v1/subscriptions/{id} │ +│ │ POST .../{id}:seek │ +│ │ │ +│ Route by: topic │ Route by: consumer_group │ +│ │ │ +│ Mode H (hetero, default): │ Strategy: cache lookup │ +│ all instances match `*`, │ evbk.group.endpoint:{group} │ +│ load-balance among healthy │ → delivery instance endpoint │ +│ │ │ +│ Mode S (sharded): │ On miss / failure: │ +│ match topic against each │ trigger new-group placement │ +│ instance's serves_topic_ │ (see "New-Group Placement") │ +│ patterns; most-specific wins;│ │ +│ load-balance within tier │ │ +│ │ │ +│ On no match → 503 │ │ +│ NoIngestForTopic │ │ +├──────────────────────────────────┴───────────────────────────────────┤ +│ Shared Routes (any instance, no special routing) │ +│ GET /v1/topics, GET /v1/topics/segments, GET /v1/event-types │ +└──────────────────────────────────────────────────────────────────────┘ +``` + +The dispatcher's job for delivery routes is **cache-lookup-and-forward** — no routing computation, just a string-keyed cache read. For ingest routes in hetero mode, it's load-balance among all healthy instances; in sharded mode, it's pattern-match + most-specific-wins + load-balance within the matching tier. In standalone mode, the dispatcher is not needed — all traffic goes to the single process. + +### 3.3 API Contracts + +**ID**: `cpt-cf-evbk-interface-api` + +> **Authoritative wire contract**: the full request/response shapes for every endpoint +> below live in [`openapi.yaml`](openapi.yaml) and the per-resource JSON Schemas under +> [`schemas/`](schemas/). This section keeps endpoint **summaries** and behavioral +> contracts; concrete payloads are not duplicated here. Where prose below contains +> illustrative JSON examples (kept for readability), the authoritative shape is the +> referenced openapi path. + +#### Event Response Shape (Common) + +Every event in any response (single publish, batch result, list, poll, etc.) carries the same fields: + +> _Illustrative example removed; authoritative shape: [openapi.yaml](openapi.yaml)._ + +`partition` is broker-derived (not producer-set); `meta.producer_id` / `meta.previous` / `meta.sequence` are producer chain fields inside the publish-input `meta` block (chained / monotonic modes; `meta` absent in stateless); `sequence` and `sequence_time` are backend-assigned consumer-visible fields (populated by the time consumers see the event via `query`). For producer responses: async mode (`202 Accepted`, default) returns `event_id` and `partition` only; sync mode (`201 Created`) confirms backend persistence but **does not** include `sequence` (assignment is async). See §3.6 "Two Sequences". + +#### Event Production (Ingest) + +**POST /v1/events** — Publish a single event. + +Request body: + +| Field | Type | Required | Description | +|---|---|---|---| +| `id` | UUID | Yes | Client-provided unique event identifier | +| `type` | String (GTS) | Yes | Event type identifier | +| `topic` | String (GTS) | Yes | Target topic identifier | +| `partition` | i32 | Yes | Broker-derived topic partition. Producers do not set it directly; the broker derives it from `partition_key` when present, otherwise `tenant_id`. Must be in `[0, topic.partitions)`. | +| `previous` | i64 | Conditional | Chained mode only. Predecessor's `sequence` for this `(producer_id, topic, partition)`. | +| `sequence` | i64 | Conditional | Required in chained and monotonic modes. Monotonic per `(producer_id, topic, partition)` from the producer's perspective. Omitted in stateless mode. | +| `occurred_at` | String (ISO 8601) | Yes | When the event occurred | +| `source` | String | Yes | Origin service/component | +| `subject` | String | Yes | Subject entity identifier | +| `subject_type` | String (GTS) | Yes | Subject type identifier | +| `tenant_id` | UUID | Yes | Tenant identifier | +| `trace_parent` | String | No | W3C Trace Context parent | +| `data` | Object | Yes | Event payload | + +Request headers: +- `Producer-Id` (optional): UUID issued by `POST /v1/producers`, bound to the calling principal. When present, the broker uses **chained mode** if the event has `previous`, **monotonic mode** if only `sequence` is set, or rejects the request if neither is set. When absent (stateless mode), no broker-side dedup in MVP. + +**Why Producer-Id is a header, not a body field:** +- It identifies the **producer** (who's making this request), not the event itself +- All events in a batch share the same Producer-Id — header sends it once +- Conceptually parallels `Authorization`, `Idempotency-Key`, `Trace-Parent` — all session/transport-level identifiers + +**Producer-Id is a server-issued UUID.** Producers register via `POST /v1/producers` to obtain a UUID bound to their principal. The broker rejects subsequent `POST /v1/events` whose `Producer-Id` doesn't match a producer registered to the call's principal (`403 Forbidden`). Stateless producers omit the header entirely. + +``` +Producer-Id: 550e8400-e29b-41d4-a716-446655440000 +``` + +When the producer's local chain state is no longer trustworthy (DB restore, corruption, etc.), the recommended path is `GET /v1/producers/{producer_id}/cursors` to read the broker's view and reconcile. If the producer cannot reconcile, registering a fresh `producer_id` (another `POST /v1/producers`) starts a new chain — old rows in `evbk_producer_state` are GC'd by the Reaper after the topic's `retention`. For operator-driven chain reset (preserving the `producer_id`), call `POST /v1/producers/{producer_id}:reset` — see [`docs/features/0001-idempotent-producers.md`](features/0001-idempotent-producers.md) §4.6. + +**Response shape depends on persist mode** (default async; sync opt-in via the `Sync-Wait` header, the `?wait=persisted` query parameter, per-topic config, or per-deployment config — whichever the deployment exposes): + +- **Async (default)**: `202 Accepted` once the event is durably enqueued in the ingest outbox (atomic with `evbk_producer_state` chain update). Backend persist happens asynchronously; `offset` is not in the response. + ```json + HTTP 202 Accepted + { + "id": "", + "topic": "...", + "partition": 4, + "accepted_at": "2025-12-03T12:01:59.829Z" + } + ``` +- **Sync** (opt-in): `201 Created` after `backend.persist` returns. Confirms the event is in backend storage (not just the outbox). **Does not** include `offset` — offset assignment is async from the producer's perspective. May be unsupported on backends with hard async semantics → `400 SyncNotSupported`. + ```json + HTTP 201 Created + { + "id": "", + "topic": "...", + "partition": 4, + "created_at": "...", + /* full event body, minus offset / offset_time */ + } + ``` +- `200 OK` — if dedup matches an existing already-persisted event (returns the original event; same shape regardless of mode). + +Errors: +- `400 InvalidPartition` — Partition out of range `[0, topic.partitions)` +- `400 SyncNotSupported` — Sync persist requested but backend doesn't support it +- `400` — Invalid GTS type format, missing required fields, or chained/monotonic mode invariant violated (e.g., `previous` set without `sequence`) +- `403` — Caller's principal doesn't match the registered owner of `Producer-Id` +- `404` — Topic, event type, or producer not found +- `400 SequenceViolation` — chained mode: `meta.previous` doesn't match the broker's `last_sequence` for this `(producer_id, topic, partition)`. Response carries broker's known `last_sequence`. Recover via `GET /v1/producers/{producer_id}/cursors`. +- `422` — Payload validation failed (data doesn't match event type schema) +- `503 BackendUnavailable` — (sync mode only) backend is currently unavailable; retry later + +**POST /v1/events:batch** — Publish multiple events to a single `(topic, partition)`. Per-batch write is atomic. + +Request body: `{ "events": [...] }` + +Limits and rules: +- Maximum 100 events per batch +- Maximum 1MB total payload +- **All events MUST share the same `topic` AND the same `partition`** — mixed-partition batches are rejected with `400 MixedPartitionBatch`. Reasons: dispatcher routes by `(topic, partition)`; per-partition write is the natural atomic unit; producer SDK batching naturally groups by `(topic, partition)`. +- All events from the same producer must form a contiguous chain (each event's `previous` matches the prior event's `sequence`) in chained mode +- All-or-nothing: if any event fails dedup validation, the entire batch is rejected with `400 SequenceViolation` (chained mode) or `400` (chain shape violation) + +**Response shape depends on persist mode** (same async/sync rules as `POST /v1/events`): + +- **Async (default)**: `202 Accepted` once all events are durably enqueued in the outbox. + ```json + HTTP 202 Accepted + { + "data": { + "results": [ + { "index": 0, "status": 202, "id": "", "accepted_at": "..." }, + { "index": 1, "status": 422, "error": { ... } } + ] + }, + "meta": { + "total": 2, "succeeded": 1, "failed": 1, + "topic": "gts.cf.core.events.topic.v1~vendor.users.v1", + "partition": 4 + } + } + ``` +- **Sync** (opt-in): `207 Multi-Status` after `backend.persist` returns. Each successful entry has `status: 201` with the full event including broker `sequence`. Same `400 SyncNotSupported` rule applies if the backend doesn't support sync mode. + +Errors: +- `400 MixedPartitionBatch` — Batch contains events from different `(topic, partition)` combinations +- `400 InvalidPartition` — Partition out of range +- `400 BatchTooLarge` — Batch exceeds size limits (event count or total bytes) +- `400 SyncNotSupported` — Sync mode requested but backend doesn't support it +- `400 SequenceViolation` — chained mode: a chain link in the batch doesn't match the broker's `last_sequence`. Atomic batch rejection. +- `503 BackendUnavailable` — (sync mode only) backend is currently unavailable + +A producer publishing to multiple partitions sends multiple batches (one per partition). + +#### Producer Lifecycle (Ingest) + +The full lifecycle: + +``` +# ── Stateless (no registration) ───────────────────────────────────── +POST /v1/events # PUBLISH (no dedup) + Header: (none) # no Producer-Id header + Body: + topic: # REQUIRED + type: # REQUIRED + tenant_id: # REQUIRED + data: { ... } # REQUIRED; validated against type's JSON Schema + # meta block absent — broker skips dedup entirely + Returns 202: Accepted (async persist) | 201: Persisted (sync mode) + +# ── Chained / Monotonic (idempotent) ──────────────────────────────── +POST /v1/producers # REGISTER — mint producer id + Body: + mode: chained | monotonic # REQUIRED + Returns 201: + id: # bound to calling principal + +POST /v1/events # PUBLISH (with dedup) + Header: Producer-Id: # REQUIRED; must match registered owner + Body: + topic: + type: + tenant_id: + data: { ... } + meta: + producer_id: # same as header + sequence: # monotonic chain counter + previous: # chained only: last accepted sequence + Returns 202 | 201 | 200 (duplicate — echoes original event) + Returns 400: SequenceViolation # chain broken; see RECOVERY below + +GET /v1/producers/{id}/cursors # RECOVERY — read broker's last_sequence + # Use when local chain state is lost or diverged + # (DB restore, restart, 400 SequenceViolation). + Returns 200: + [{topic, partition, last_sequence}] # reconcile local counter against broker's view + +POST /v1/producers/{id}:reset # RESET (operator) — clear chain state + # Preserves producer_id; audited. + Body: + topic: # optional; scope to one (topic, partition) + partition: # optional; omit both to clear all state rows + Returns 200 +``` + +**POST /v1/producers** — Register a producer. Returns a server-issued `id` (UUID) bound to the calling principal. + +Request body: `{}` (no required fields in MVP; future versions may accept mode enforcement, name, metadata). + +Response (`201 Created`): +> _Illustrative example removed; authoritative shape: [openapi.yaml](openapi.yaml)._ + +Subsequent `POST /v1/events` requests with this `Producer-Id` header are accepted only when the call's principal matches the registered owner (`403 Forbidden` otherwise). Stateless producers don't call this endpoint and don't send the `Producer-Id` header. + +**GET /v1/producers/{producer_id}/cursors** — Read the broker's known `last_sequence` per `(topic, partition)` for a registered producer. Used for desync recovery (DB restore, restart without persistent state, suspected divergence). + +Response (`200 OK`): +> _Illustrative example removed; authoritative shape: [openapi.yaml](openapi.yaml)._ + +Per-partition entries; future fields (e.g., `last_seen_at`, `mode`) are additive — extending without breaking. + +Errors: +- `403 Forbidden` — Caller's principal doesn't own this producer +- `404 ProducerNotFound` — Producer not registered + +#### Event Consumption (Delivery) + +The MVP exposes a **single consumption endpoint** — subscription-based long-poll. All consumers go through `POST /v1/subscriptions` (JOIN) → `GET /v1/events:stream` cycle. Anonymous / stateless reads are intentionally out of scope for v1; see §4.6 Out of Scope and §4.8 Future Developments for planned extensions (`GET /v1/events:sse`, anonymous offset-based read, etc.). + +**GET /v1/events:stream** — Long-poll for new events. Subscription-based; broker tracks all per-`(topic, partition)` cursor state internally. A single subscription can be assigned partitions across multiple topics (the topics this member declared at JOIN, intersected with the group's effective topic set); the response groups events by `(topic, partition)`. + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `subscription_id` | UUID | Yes | Subscription to poll (created via `POST /v1/subscriptions`) | +| `limit` | i32 | No | Max events per response across all assigned `(topic, partition)` pairs (default: 100, max: 100) | +| `timeout` | i32 | No | Wait timeout in seconds (default: 30, max: 30) | + +The poll body is empty. The broker uses each `(consumer_group, topic, partition)`'s `cursor.offset` to determine which events to return. Consumers advance cursors via `POST /v1/subscriptions/{id}:seek`. + +Behavior: +1. Events with `sequence > cursor.offset` available on any assigned `(topic, partition)` → return `200 OK` immediately +2. No new events on assigned pairs → wait up to `timeout` seconds +3. Topology change during wait → wake early, return current state with new `assignments` and `topology_version` +4. Timeout expires with no events → return `200 OK` with empty `batches: []` +5. Subscription has no assigned partitions (group fully claimed elsewhere) → return immediately with `retry_after_seconds` + +Response: `200 OK` + +> _Illustrative example removed; authoritative shape: [openapi.yaml](openapi.yaml)._ + +Each entry in `batches` carries its own `topic` + `partition` and the events from that pair. `assignments` lists every `(topic, partition)` currently owned by this subscription with its `offset` (= `cursor.offset` — the session cursor set by SEEK; where the next consumer joining the group would resume) and `last_examined` (the broker's highest scanned sequence for this group/partition regardless of filter match — see R57). When this member's filter rejects all events scanned, `last_examined` advances while `offset` stays put. When events do match, both advance together. (Filter is per-member: this advancement reflects the current owner's filter.) + +Errors: +- `404 SubscriptionNotFound` — Subscription expired or deleted + +**Pre-stream SEEK and start-position resolution** (per ADR-0006). Consumer progress is owned by the consumer. See [ADR-0006](ADR/0006-offset-authority.md). + +The SDK's `OffsetManager` trait owns the "where do I start?" decision. Its `position(ctx, group, topic, partition)` method returns a [`ResolvedPosition`]: + +- `Exact(i64)` — the last offset the consumer has already processed, read from its own persistent store. The broker emits from `offset + 1`. +- `Earliest` — server-resolved sentinel. The broker sets the cursor to `retention_floor - 1` at admission, so subsequent emission begins at `retention_floor`. +- `Latest` — server-resolved sentinel. The broker sets the cursor to the current high-water mark at admission, so the consumer receives only events admitted *after* the SEEK lands. +- `AtTime(DateTime)` — server-resolved sentinel. The broker resolves to the offset of the first event whose `occurred_at ≥ timestamp` and sets the cursor accordingly. Wire form: `"at:"`. + +Each built-in `OffsetManager` (`LocalDbOffsetManager`, `InMemoryOffsetManager`) requires a `Fallback` (`Earliest` | `Latest`) argument in its constructor — the policy applied when the backing store has no cursor for a `(group, topic, partition)`. Optional per-partition seed overrides for replay/backfill via `.with_overrides([...])`. + +**Dispatcher flow:** + +1. JOIN: `POST /v1/subscriptions` → assignments + `subscription_id`. +2. For each assigned `(topic, partition)`: call `offset_manager.position(...)` to get a `ResolvedPosition`. +3. SEEK: `POST /v1/subscriptions/{id}:seek` with the resolved per-partition values (integer or sentinel string). +4. Stream: `GET /v1/events:stream?subscription_id=...`. + +**Sentinel resolution semantics on the broker** (SEEK endpoint): + +- Request body's `partition_positions` map accepts `int64 | "earliest" | "latest" | "at:"` per partition. Integers are last-processed offsets; the broker validates against `[retention_floor - 1, high_water_mark]` and rejects out-of-range with `400 InvalidInitialPosition`. +- `"earliest"` → cursor set to `retention_floor - 1` (emit from retention floor onwards). +- `"latest"` → cursor set to current high-water mark (emit only future events). +- `"at:"` → cursor set to the broker-logical sequence immediately before the first event whose `occurred_at ≥ timestamp`. Timestamp before retention floor → `retention_floor - 1`; timestamp beyond HWM → HWM (consumer will wait for future events). Malformed timestamp → `400 InvalidTimestamp`. +- Response body returns the resolved integer offsets per partition for audit. + +**`:stream` defensive backstop**: `GET /v1/events:stream` rejects with `400 PositionsNotSet { unseeded: [...], recovery_hint }` when any assigned partition has no committed cursor in the subscription's group. A well-behaved SDK seeds positions before opening the stream via `POST /v1/subscriptions/{id}:seek` and never observes this on the happy path; the SDK recovers from it by re-resolving via `position()` and re-SEEKing (sharing the `SubscriptionRecoveryExhausted` budget). + +**Rebalance handling** (see [`features/0004-consumption-transport.md`](features/0004-consumption-transport.md)): a partition **loss** arrives as a non-terminal `topology` frame and the consumer keeps streaming its remaining partitions; a partition **gain** (or lose-all) terminates the subscription — the broker emits a `control` frame with `code: "terminal"` (complete final positions) as the last frame, closes gracefully, and the consumer re-JOINs (new `subscription_id`), SEEKs, and reopens. There is no mid-stream re-SEEK of a gained partition. + +**SEEK is pre-stream-only**: `POST /v1/subscriptions/{id}:seek` is valid only before a stream is open; it accepts any value in `[RF − 1, HWM]` (including backward, for replay — there is no forward-only rule). While `:stream` is open the session cursor auto-advances with delivery; a SEEK against an open stream is rejected with `400 StreamingInProgress`. + +#### Topic Introspection + +**GET /v1/topics** — List available topics. + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `topic` | String (GTS) | No | Filter by topic identifier (wildcards allowed) | +| `limit` | i32 | No | Records per page (default: 100, max: 100) | + +Response: `200 OK` + +> _Illustrative example removed; authoritative shape: [openapi.yaml](openapi.yaml)._ + +The `partitions` field exposes the broker topic partition count. Consumers use it to know the full partition set when subscribing with explicit assignment; first-party SDKs may use it for local broker-partition hints or batching, while the broker remains authoritative for final topic partition assignment. + +**GET /v1/topics/segments** — Get storage segments for a `(topic, partition)`. + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `topic` | String (GTS) | Yes | Topic identifier | +| `partition` | i32 | Yes | Partition within the topic. Segments are tracked per `(topic, partition)`. | +| `$orderby` | String | No | Sort order (default: `start_sequence asc`) | +| `limit` | i32 | No | Records per page (default: 100, max: 100) | + +Response: `200 OK` + +> _Illustrative example removed; authoritative shape: [openapi.yaml](openapi.yaml)._ + +Errors: +- `400 InvalidPartition` — Partition out of range +- `404` — Topic not found + +#### Consumer Group Lifecycle (Delivery) + +**ID**: `cpt-cf-evbk-interface-consumer-group-protocol` + +Consumer groups are persistent broker resources (`evbk_consumer_group` table). Each group's GTS shape determines its provisioning path: **anonymous** (UUID-backed) groups via `POST /v1/consumer-groups`; **named** (vendor-namespaced) groups via `types_registry` registration. JOIN authorization differs by shape: anonymous → owner-tenant equality; named → explicit `:consume` permission via PEP. + +**POST /v1/consumer-groups** — Register an **anonymous** consumer group. The broker mints the group identifier server-side; the request body MUST NOT carry an `id`. + +Request body: +> _Illustrative example removed; authoritative shape: [openapi.yaml](openapi.yaml)._ + +Response (`201 Created`, `Location: /v1/consumer-groups/`): +> _Illustrative example removed; authoritative shape: [openapi.yaml](openapi.yaml)._ + +The broker generates a fresh UUID per request, composes the `gts.cf.core.events.consumer_group.v1~` identifier, and returns it in both the response body and the `Location` header. `tenant_id` and `owner_principal_id` are pulled from `SecurityContext` at create time and are non-overridable. `kind` is always `anonymous` for this endpoint. The caller distributes the returned `id` to its consumer fleet via its own coordination mechanism (DB, ConfigMap, env var) — the coordination point is post-create, not pre-create. + +Errors: +- `403 Forbidden` — caller lacks `consumer_group:define` permission via PEP + +(The `400 InvalidConsumerGroupId`, `400 NamedGroupRequiresRegistry`, and `400 ConsumerGroupAlreadyExists` error classes that previously applied to the create path are no longer reachable here — there is no caller-supplied `id` to validate or collide on. `400 NamedGroupRequiresRegistry` and `400 ConsumerGroupAlreadyExists` remain in the broker's error catalog for JOIN-side and named-via-`types_registry` cases respectively.) + +**GET /v1/consumer-groups/{id}** — Read a registered consumer group. Returns the full record. + +Errors: `403 Forbidden` (no permission), `404 ConsumerGroupNotFound`. + +**GET /v1/consumer-groups** — List consumer groups visible to the caller (filtered by `AccessScope` from the PEP — typically the caller's tenant's anonymous groups plus any named groups the caller has `:consume` permission on). + +**DELETE /v1/consumer-groups/{id}** — Remove a consumer group from the registry. Allowed only when there are no active members (cache state empty for this group). + +Errors: `403 Forbidden`, `404 ConsumerGroupNotFound`, `400 ConsumerGroupHasActiveMembers`. + +**Note on named groups**: there is no REST endpoint to register a named consumer group. Named groups MUST be declared in `types_registry` (compile-time per registering module). At startup, the broker reads all instances of `gts.cf.core.events.consumer_group.v1~` from `types_registry` and upserts each into `evbk_consumer_group` (idempotent; ownership stays with the registering module's principal). This keeps named groups as platform-level resources, not arbitrary client-created identifiers. + +#### Subscription Lifecycle Endpoints (Delivery) + +**ID**: `cpt-cf-evbk-interface-subscription-protocol` + +The subscription endpoints follow the **Confluent REST Proxy v2 / Strimzi Kafka Bridge** convention — the de-facto Kafka REST contract used by Confluent REST Proxy, Strimzi Kafka Bridge (CNCF, Apache 2.0), Karapace, and Redpanda Pandaproxy. This makes the API instantly familiar to anyone who has consumed Kafka via HTTP. + +The full lifecycle: + +``` +POST /v1/subscriptions # JOIN + Body: + consumer_group: # REQUIRED; named or anonymous UUID-backed + client_agent: # REQUIRED; RFC 9110; 1–256 bytes + interests: # REQUIRED; ≥1, ≤64 entries + - topic: # REQUIRED; partition/rebalance/authz unit + tenant_id: # REQUIRED; tenant scope, authz-validated at JOIN + max_depth: 0|1|N|null # optional; 0=current only (default), null=unlimited + barrier_mode: respect|ignore # optional; default respect + types: [, ...] # REQUIRED; ≥1 GTS event-type-instance patterns + filter: # optional + engine: # full GTS identifier of the filter engine + expression: # engine-specific expression ≤4096 bytes + session_timeout: # optional; default PT30S + Returns 201: + id: + topology_version: + expires_at: + assigned: [{topic, partition}, ...] # topic+partition only; no offsets in assignment + +POST /v1/subscriptions/{id}:seek # SEEK (pre-stream, required before first stream open) + # Consumer seeds starting position for each assigned + # (topic, partition) — from its own DB, or a sentinel. + Body: + partition_positions: + ":": # int64 from consumer's own DB + | "earliest" # broker resolves to retention_floor-1 + | "latest" # broker resolves to current HWM + | "at:" # broker resolves to first event at or after timestamp + Returns 200: + partition_positions: + ":": # sentinels resolved to integers + # If omitted, broker returns 400 PositionsNotSet on the first stream open. + # SEEK is pre-stream-only; a SEEK while :stream is open returns 400 StreamingInProgress. + +GET /v1/events:stream?subscription_id={uuid} # STREAM — multipart/mixed streaming delivery + Returns 200: long-lived multipart/mixed stream; each part carries one frame: + kind: event → one delivered event + kind: heartbeat → idle marker (5 s default cadence) + kind: topology → full assignment snapshot (open + non-terminal change) + kind: control → cursor carrier (code: progress | terminal) + Returns 410: SubscriptionTerminated — re-JOIN required + Returns 404: SubscriptionNotFound — subscription_id unknown or expired + Returns 400: PositionsNotSet — SEEK was not called for one or more assigned partitions + +DELETE /v1/subscriptions/{id} # LEAVE — terminate subscription + Returns 204 + +GET /v1/subscriptions # LIST — OData-filterable page of subscriptions + Returns 200: + items: [{ id, consumer_group, assigned: [{topic,partition}], + topology_version, expires_at }] + page_info: { next_cursor, prev_cursor, limit } + +GET /v1/subscriptions/{id} # READ — single subscription by id + Returns 200: + id: + consumer_group: + assigned: [{topic, partition}, ...] + topology_version: + expires_at: +``` + +**Consumer group identifier**: `consumer_group` is **required** and must be a GTS identifier conforming to `gts.cf.core.events.consumer_group.v1~`. Two patterns: +- **Named**: `gts.cf.core.events.consumer_group.v1~vendor.audit-processor.v1` — registered in `types_registry` with an owning principal. Authorization on JOIN is enforced via the GTS instance permission system. Used by CF modules and operator-managed services. +- **Anonymous**: `gts.cf.core.events.consumer_group.v1~{uuid}` — UUID-backed, ad-hoc. The first JOIN claims the identifier; the consumer is responsible for sharing the UUID across its instances via its own coordination mechanism (DB, ConfigMap, env var, etc.). Suitable for public-API callers with no pre-registered name. + +The broker validates GTS format on every JOIN. Named identifiers must already be registered in `types_registry`; anonymous (UUID-backed) identifiers are accepted as-is and tracked in the cache/cursor on first use. + +**Multi-topic, per-member subscriptions**: A `consumer_group` may consume from multiple topics, and **each member can have its own topic list and filter set**. The group's effective topic set is the union of all active members' topic lists. Partition assignment respects per-member subscriptions: a `(topic, partition)` is assigned only to a member that subscribes to that topic. There is no canonical topic list, no canonical filter at the group level, and no `400 GroupTopicMismatch` / `400 GroupFilterMismatch` errors on JOIN — JOINs always succeed regardless of topic/filter differences across members. See R60 for the rollout transition semantics. + +**State location**: The broker holds **all** consumer state (assignment, cursor positions, in-flight). Consumers never send offsets in `GET /v1/events:stream`. The poll body is empty; the server uses internal cursor state to determine which events to return. + +**Topology change handling**: When a topology change happens during an active long-poll, the poll wakes early and returns the new `assignments` and `topology_version` along with any events from the consumer's still-assigned partitions. Consumer SDKs detect the change by comparing `topology_version` between responses. + +**Subscription termination on ownership change**: when the delivery shard owning a consumer group releases ownership (graceful shutdown, scale-down), the in-pod subscription state — `subscription_id`, in-flight partition assignments, active long-poll tasks — is lost. The new owner runs a fresh rebalance and has no record of the old `subscription_id`. Consumers must re-JOIN to recover. The group cursor lives in the ClusterCapabilities-backed cache; if the cache survives the shard transition (cluster mode with a persistent provider — Redis-with-disk, etcd) the new subscription resumes from the correct offset. If the cache also dies (standalone, or non-persistent cluster cache), the new subscription resumes from `earliest_available_offset` per `backend.query` and consumers re-process events per at-least-once. + +**Delivery shard graceful shutdown** (e.g., k8s SIGTERM): +1. Respond to each in-flight long-poll with `410 Gone`, body `{ "detail": "Subscription terminated; re-JOIN to recover" }`. +2. Release group ownership in the cache (`evbk.group.endpoint:{group_G}` cleared). +3. Deregister from service discovery (`cluster.deregister_shard(...)`). +4. Process exits. + +Ungraceful kill (SIGKILL / crash) skips steps 1–3: consumer connections drop, dispatcher's circuit breaker on the dead endpoint invalidates the cache, the consumer SDK's next poll routes through dispatcher → fresh resolution → new owner shard, which doesn't recognize the old `subscription_id` and returns `404`. From the SDK's perspective the recovery path is identical to the 410 case. + +**SDK behavior on `410 Gone` or `404 SubscriptionNotFound` during poll** (normative): the SDK MUST call `POST /v1/subscriptions` again with the original `consumer_group`, `topics`, and filter set to obtain a fresh `subscription_id` and `assignments`. Polling resumes from the new subscription; the group cursor preserves position. + +**Streaming connection — proxy interaction**: corporate proxies and load balancers (NLB / ALB) typically apply idle-connection cuts at ~60 s. The broker counteracts this by emitting `heartbeat` frames on idle subscriptions at a 5 s default cadence (configurable per deployment) — strictly under the common 60 s threshold. Consumers (notably the in-tree `cf-gears-event-broker-sdk`) additionally implement the drop-on-Nth-heartbeat self-healing pattern (default K=10 ≈ 50 s of silence → reconnect + re-JOIN) to recover from silently-degraded connections (mid-path NAT churn, ALB rebalance, etc.) — see [`features/0004-consumption-transport.md`](features/0004-consumption-transport.md). The broker does not detect or compensate for proxy behavior beyond emitting heartbeats; the rest is a consumer-side responsibility. + +`topology_version` continues to cover within-session partition rebalances (member JOIN/LEAVE while the subscription is alive). Ownership transitions are NOT signaled via `topology_version` — they go through the 410/404 → re-JOIN path above. + +**Assignments shape**: Each entry: `{ "topic": , "partition": , "offset": , "last_examined": }`. `offset` is `cursor.offset` for `(consumer_group, topic, partition)` — the session cursor set by SEEK; events with `sequence > offset` will be returned on the next poll. `last_examined` is the highest sequence the broker has scanned for this group/`(topic, partition)` regardless of filter match (see R57 for the offset-adviser rationale). + +**Explicit SEEK only**: Durable cursor advancement is the consumer's explicit responsibility — there is no broker-side auto-commit. The consumer SEEKs (pre-stream-only) from its own store; the ephemeral session cursor auto-advances with delivery while streaming. The at-least-once delivery guarantee holds as long as consumers persist progress only after successfully processing events. + +#### Error Response Format + +**ID**: `cpt-cf-evbk-interface-error-codes` + +All errors follow RFC 9457 Problem Details (`application/problem+json`) using the canonical `toolkit-canonical-errors` categories. The `type` field is always a canonical GTS category URI of the form `gts://gts.cf.core.errors.err.v1~cf.core.err..v1~`. `status` is the value returned by `CanonicalError::status_code()` for that category; Event Broker does not apply local HTTP status overrides. Domain identity (which specific resource was not found, which field failed, etc.) is expressed via `context.resource_type` / `context.resource_name` or structured violation arrays — not via a domain-specific `type` URI. + +| Domain error | HTTP | Canonical category | `type` URI suffix | Retriable | `context` | +|---|---|---|---|---|---| +| InvalidType, InvalidOffset, InvalidSequence, InvalidPartition | 400 | `InvalidArgument` | `invalid_argument` | No | `field_violations` or `constraint` | +| MixedPartitionBatch, BatchTooLarge, InvalidConsumerGroupId, NamedGroupRequiresRegistry, SyncNotSupported | 400 | `InvalidArgument` | `invalid_argument` | No | `constraint` | +| ValidationError | 400 | `InvalidArgument` | `invalid_argument` | No | `field_violations` — one per validator diagnostic | +| TopicNotFound | 404 | `NotFound` | `not_found` | No | `resource_type: "gts.cf.core.events.topic.v1~"`, `resource_name: ` | +| EventTypeNotFound | 404 | `NotFound` | `not_found` | No | `resource_type: "gts.cf.core.events.event_type.v1~"`, `resource_name: ` | +| SubscriptionNotFound | 404 | `NotFound` | `not_found` | No | `resource_type: "gts.cf.core.events.subscription.v1~"`, `resource_name: ` | +| ConsumerGroupNotFound | 404 | `NotFound` | `not_found` | No | `resource_type: "gts.cf.core.events.consumer_group.v1~"`, `resource_name: ` | +| ConsumerGroupNotOwned | 403 | `PermissionDenied` | `permission_denied` | No | `reason: ` | +| SequenceConflict | 400 | `FailedPrecondition` | `failed_precondition` | Yes | `violations: [{type: "sequence_conflict", subject: "(producer)", description: }]` | +| ConsumerGroupAlreadyExists | 400 | `FailedPrecondition` | `failed_precondition` | No | `violations: [{type: "already_exists", subject: , description: }]` | +| ConsumerGroupHasActiveMembers | 400 | `FailedPrecondition` | `failed_precondition` | No | `violations: [{type: "has_active_members", subject: , description: }]` | +| PositionsNotSet | 400 | `FailedPrecondition` | `failed_precondition` | No | `violations`: one `{type: "cursor_missing", subject: ":", description: "no committed cursor"}` per unseeded partition | +| SequenceViolation | 400 | `FailedPrecondition` | `failed_precondition` | No | `violations: [{type: "sequence_mismatch", subject: "(producer)", description: "expected_previous="}]` | +| RateLimitExceeded | 429 | `ResourceExhausted` | `resource_exhausted` | Yes | `violations: [{subject: "publish-quota", description: , retry_after_seconds: }]` | +| BackendUnavailable, NoIngestForTopic | 503 | `ServiceUnavailable` | `service_unavailable` | Yes | `{}` or `retry_after_seconds: ` | +| Internal broker invariant failure | 500 | `Internal` | `internal` | No | `{}` — no server-side diagnostic on wire | + +**Standard Fields** (RFC 9457 + platform extension): +- `type`: canonical GTS category URI — `gts://gts.cf.core.errors.err.v1~cf.core.err..v1~` +- `title`: category title (e.g. `"Not Found"`, `"Invalid Argument"`, `"Failed Precondition"`) +- `status`: HTTP status code +- `detail`: human-readable explanation specific to this occurrence +- `instance`: URI reference identifying the specific occurrence (boundary/middleware-injected) +- `trace_id`: distributed tracing correlation (boundary/middleware-injected) +- `context`: category-specific structured payload (always present; may be `{}`) + +#### Authentication & Authorization + +**Authentication** (Inbound, Client → Event Broker): Bearer token via `toolkit-security`. + +**Authorization**: Per-(resource, action) checks via the platform's `authz-resolver` framework — `PolicyEnforcer::access_scope_with(&ctx, &RESOURCE_TYPE, "action", resource_id, &req).await?`. The PEP returns either `EnforcerError::Denied` or an `AccessScope` containing constraint predicates (`Eq`, `In`, `InGroup`, `InGroupSubtree`) that the broker AND-merges and applies as read-side filters on subsequent queries. + +**Permission grammar**: `:` where `` is a valid GTS pattern — a bare base (e.g., `gts.cf.core.events.topic.v1~`, matches all instances), a concrete instance (e.g., `gts.cf.core.events.topic.v1~vendor.events.tenants.v1`), or a wildcard suffix (e.g., `gts.cf.core.events.topic.v1~vendor.audit.*`). Patterns let a single grant cover a family. + +**Resource types and actions**: + +| Resource (GTS type) | `produce` | `consume` | `define` | +|---|---|---|---| +| `gts.cf.core.events.topic.v1~` | producers | consumers | ABI / operator registration | +| `gts.cf.core.events.event_type.v1~` | producers | consumers | ABI / operator registration | +| `gts.cf.core.events.subject_type.v1~` | producers | consumers | — (subject types owned by other modules) | +| `gts.cf.core.events.consumer_group.v1~` | — | consumers (named groups; explicit grant required) | group creators (POST `/v1/consumer-groups`) and `types_registry` upserts | + +Wildcards in the resource pattern are supported on every cell above. + +**Subject_type is enforced on two independent dimensions**: +1. **Authorization** (the `subject_type:produce` / `subject_type:consume` permission above) — *capability*: can the principal write or read events whose subject is of this type? +2. **Schema** (`event_type.allowed_subject_types`) — *correctness*: is this subject type semantically valid for events of this event type? + +Both must pass on publish; both must pass at subscribe. They cover different concerns and cannot substitute for each other. + +**Permission examples**: +- `gts.cf.core.events.topic.v1~:produce` — produce to any topic (e.g., a system service) +- `gts.cf.core.events.topic.v1~vendor.audit.*:produce` — produce to any audit topic of `vendor` +- `gts.cf.core.events.topic.v1~vendor.events.tenants.v1:consume` — consume only that one topic +- `gts.cf.core.events.event_type.v1~vendor.audit.*:consume` — consume any audit event type +- `gts.cf.core.events.subject_type.v1~cf.core.acm.*:produce` — produce events about ACM resources only + +**Publish path** — `POST /v1/events`, `POST /v1/events:batch`: +- Three PEP checks per request, all must allow: + - `(topic_resource, "produce", event.topic)` + - `(event_type_resource, "produce", event.type)` + - `(subject_type_resource, "produce", event.subject_type)` +- Plus schema validation: `event.subject_type` matches one of `event_type.allowed_subject_types` (which may contain wildcards). +- All four must pass. AccessScope constraints AND-merged. `event.tenant_id` recorded from `ctx`. + +**Subscribe path** — `POST /v1/subscriptions` (JOIN): +- Look up `evbk_consumer_group` row for `subscription.consumer_group` → `404 ConsumerGroupNotFound` if missing. +- Group-level authorization (depends on `kind`): + - **Anonymous** (`kind = 'anonymous'`): caller's `tenant_id` MUST equal row's `owner_tenant_id` → `403 ConsumerGroupNotOwned` otherwise. No PEP check on the consumer_group resource (private to the owning tenant). + - **Named** (`kind = 'named'`): PEP check `(consumer_group_resource, "consume", subscription.consumer_group)` — explicit grant required; tenant-equality is NOT a substitute. This is the cross-tenant / cross-module sharing path. +- Plus the standard topic / event_type / subject_type PEP checks, all must allow: + - `(topic_resource, "consume", subscription.topic)` + - `(event_type_resource, "consume", event_type_pattern)` — for each entry in `subscription.types` + - `(subject_type_resource, "consume", subject_type_pattern)` — for each entry in `subscription.subject_types` (if present) +- Combined `AccessScope` from the group + topic + event_type + subject_type checks is cached with the subscription. Every subsequent poll applies these constraints to the read query. + +**Stream / seek / leave** — no re-check. Authorization is done at JOIN; the cached AccessScope governs all subsequent reads. `subscription_id` is the bearer of authority for follow-up operations. There is no separate `manage` permission — subscription create / list / read / seek / leave are gated by the same `consume` permission. + +**Define path** — ABI / operator registration time: +- `(topic_resource, "define", topic.id)` for each topic being registered +- `(event_type_resource, "define", event_type.id)` for each event type being registered +- Subject types are not defined by the event broker. + +**Audit logging of denials**: out of MVP scope (see R34 — deferred). + +### 3.4 Internal Dependencies + +**ID**: `cpt-cf-evbk-design-internal-dependencies` + +| Dependency | Purpose | +|---|---| +| `types_registry` | GTS schema and instance registration for topics, event types, subject types, and named consumer groups; named-consumer-group provisioning at startup. | +| `tenant-resolver` | Tenant scoping and hierarchy resolution; provides `ROOT_TENANT_ID` for inter-service traffic. | +| `authz-resolver` | `PolicyEnforcer` PEP for all per-(resource, action) authorization decisions. | +| `api_ingress` | REST API hosting at the platform's HTTP entry point. | +| `toolkit-db` | Database persistence (SeaORM, multi-backend) and the transactional outbox library used by both producer SDK and ingest pipeline. | +| `toolkit-security` | Bearer token authentication; populates `SecurityContext`. | +| `cluster` system module | `ClusterCacheV1` (KV with TTL, CAS, watch), `LeaderElectionV1`, `DistributedLockV1`, `ServiceDiscoveryV1`. | + +### 3.5 External Dependencies + +**ID**: `cpt-cf-evbk-design-external-dependencies` + +No external dependencies are required for **standalone mode** with the built-in `database` storage backend and the standalone cluster provider — every coordination primitive is in-process. + +**Cluster mode** requires a cluster system module provider backed by external infrastructure: Redis (with disk persistence for cursor durability), NATS, etcd, K8s coordination APIs, or PostgreSQL (via `db-only` provider). Choice is operator-driven and orthogonal to the broker's design. + +**Third-party storage backend plugins** may introduce their own external dependencies (Kafka client library, S3 SDK, etc.) — those are the plugin's contract with its environment, not the broker's. + +### 3.6 Interactions & Sequences + +**ID**: `cpt-cf-evbk-seq-publish-flow` + +#### Throughput Model + +**ID**: `cpt-cf-evbk-design-throughput-model` + +This derives the broker-relative throughput targets in PRD `cpt-cf-evbk-nfr-throughput-efficiency`. The pipeline is `producer → ingest outbox → sequencer → processor → backend.persist`; the broker owns everything up to `persist`, the backend owns `persist` and sets the ceiling `TPS_backend` (transactions/sec per `(topic, partition)`). + +Express throughput as a function of that ceiling: + +``` + T_single ≈ η · TPS_backend η = per-event efficiency (broker overhead, < 1) + T_batch ≈ B · TPS_backend B = events coalesced per backend transaction +``` + +- **η (per-event)** — each single publish costs one ingest-outbox row write plus a producer-state dedup lookup before the backend transaction; the residual after that overhead is `η`. Target `η ≥ 0.4`. Against a 5,000-TPS backend: `0.4 × 5,000 ≈ 2,000 events/sec`. +- **B (batch)** — the async processor coalesces many events into one backend transaction. Target `B ≥ 20`. Against a 5,000-TPS backend: `20 × 5,000 = 100,000 events/sec` — and `100,000 / batch-100 = 1,000 tx/sec`, well under 5,000, so the binding constraint in batch mode is efficient multi-row writes, not raw TPS. + +**Accept vs. sustained.** The async path (see Publish Flow below) returns once the event is durable in the ingest outbox — the **accept** rate, bounded only by a local row write and therefore high and burst-absorbing. The **sustained** rate is bounded by `backend.persist`; the η/B targets are sustained-rate targets. Accept exceeding sustained is expected (burst absorption) and is bounded by outbox lag, not by refusing the accept — a lag ceiling, not a throughput refusal. + +#### Event Publish Flow (Async Default — 202 Accepted) + +```mermaid +sequenceDiagram + participant P as Producer + participant API as Ingest Handler + participant IS as IngestService + participant SM as SpecificationManager + participant OB as Ingest Outbox (toolkit-db) + participant OP as Outbox Processor + participant SB as Storage Backend + participant CC as ClusterCapabilities + + P->>API: POST /v1/events (+ Producer-Id header) + API->>API: Extract SecurityContext + API->>IS: publish_event(ctx, event_dto) + IS->>SM: validate(topic, event_type, data) + SM-->>IS: OK (schema valid) + IS->>IS: Chain check (meta.previous vs evbk_producer_state.last_sequence) + alt Duplicate (meta.sequence <= last_sequence) + IS-->>API: 200 OK (original event) + else Chain mismatch (chained mode: meta.previous != last_sequence) + IS-->>API: 400 SequenceViolation + else Valid (chain advances) + IS->>OB: BEGIN; outbox.enqueue(topic, partition, event); UPDATE evbk_producer_state SET last_sequence = meta.sequence; UPDATE evbk_producer SET last_seen_at = now(); COMMIT + Note over IS,OB: Atomic: enqueue + chain advance
Outbox sequencer assigns its internal queue order
(NOT the consumer-visible offset) + IS-->>API: 202 Accepted { event_id, accepted_at } + end + API-->>P: Response (202 — sequence not yet known) + + Note over OP,CC: --- async, decoupled from producer's request --- + OP->>OB: dequeue next batch (in queue order) + OP->>SB: persist(topic, partition, events) + SB->>SB: Backend assigns broker-logical sequence
(native positions stay internal; Kafka offset N maps to N + 1) + SB-->>OP: events with broker-logical sequence populated + OP->>CC: cluster.publish("evbk.topic.{T}.partition.{P}", notification) + OP->>OB: ack outbox messages +``` + +**Sync mode** (opt-in): the IngestService blocks on the outbox processor's completion of `backend.persist` and returns `201 Created` with the broker sequence in the response. Slower; useful when producer needs the sequence synchronously. + +**ID**: `cpt-cf-evbk-seq-poll-flow` + +#### Long-Poll Consumption Flow + +```mermaid +sequenceDiagram + participant C as Consumer + participant API as Delivery Handler + participant DS as DeliveryService + participant SB as Storage Backend + participant CC as ClusterCapabilities + + C->>API: GET /v1/events:stream?subscription_id={uuid} + API->>DS: poll_events(ctx, topic/sub, sequence, limit, timeout) + DS->>SB: query(topic, offset=N, limit) + alt Events available + SB-->>DS: Events + DS->>DS: Apply subscription filters + DS->>DS: Update cursor.sent + DS-->>API: 200 OK (items) + else No events + DS->>CC: cluster.subscribe("evbk.topic.{T}.partition.{P}") + CC-->>DS: Wait (up to T seconds) + alt Notified (ingest published) + DS->>SB: Re-query(topic, offset=N, limit) + SB-->>DS: Events + DS->>DS: Apply subscription filters + DS->>DS: Update cursor.sent + DS-->>API: 200 OK (items) + else Timeout + DS-->>API: 200 OK (items: []) + end + end + API-->>C: Response +``` + +#### SEEK (Cursor Advance) Flow + +```text +Consumer → POST /v1/subscriptions/{id}:seek { "partition_positions": { "T1:4": 12345, "T2:0": 6789 } } + → DeliveryService.seek(ctx, subscription_id, { ("T1", 4): 12345, ("T2", 0): 6789 }) + → resolve subscription_id → consumer_group via cache + → for each ((topic, partition), offset): + cache.update("evbk.cursor:{G}:{topic}:{partition}", + |cur| { offset: max(cur.offset, offset), updated_at: now() }) + (and verify (topic, partition) is in this subscription's assignment, else 400) + → Backend may later delete events at its discretion per its own retention policy (broker has no Cleaner) + → 200 OK + → 400 PartitionNotAssigned (with current assignments in body) if any partition is not in this subscription's assignment +``` + +### 3.7 Database schemas & tables + +**ID**: `cpt-cf-evbk-db-schema` + +The tables below belong to the **built-in `database` storage backend plugin**. Other backend plugins manage their own persistence. The subscription, cursor, idempotency, and producer cursor tables are shared infrastructure (used by ingest/delivery services regardless of storage backend). + +#### Core Tables + +| Table | Purpose | Key Constraints | +|---|---|---| +| `evbk_topic` | Topic definitions | PK: `id` (GTS string), platform-scoped (globally unique by GTS) | +| `evbk_event_type` | Event type definitions | PK: `id` (GTS string), FK: `topic_id`, platform-scoped (globally unique by GTS) | +| `evbk_consumer_group` | Consumer group registry (named + anonymous, both creation paths) | PK: `id` (GTS string), bound to creating tenant + principal | +| `evbk_event` | Event log | PK: `id` (UUID), UNIQUE: `(topic, partition, sequence)` | +| `evbk_segment` | Topic storage segments | PK: `(topic, partition, segment_id)` | +| `evbk_producer` | Producer registration (mode + principal binding) | PK: `producer_id` | +| `evbk_producer_state` | Idempotent producer chain state (last sequence per `(producer_id, topic, partition)`) | PK: `(producer_id, topic, partition)` | + +**Subscription and group state are NOT in the database.** They live in the cache (ClusterCapabilities-backed). See §3.1 Subscription Schema, §3.1 GroupState Schema, and §3.2 Subscription Resolution Cache / GroupState Cache. Subscription columns are session-lifetime state — storing them in the DB would mean write churn on every JOIN/POLL/expire and would couple cursor durability to subscription lifetime via cascade deletes. Cache-based state avoids both. + +#### Event Table Schema + +**This is the schema for the built-in DB storage backend specifically.** Other backends (Kafka, S3, file) have their own native storage layouts; this DDL is internal to the DB backend's `persist` / `query` implementation. + +Columns: `id` (UUID, PK), `tenant_id`, `topic`, `partition`, `type`, `producer_id` (nullable; chained/monotonic only), `previous` (nullable; chained only), `sequence` (nullable; chained/monotonic only), `offset` (backend-assigned, monotonic per `(topic, partition)`, consumer-visible), `offset_time`, `source`, `subject`, `subject_type`, `occurred_at`, `created_at`, `trace_parent` (nullable), `data` (JSONB). Index on `(topic, partition, offset)`. Full DDL: see [`migration.sql`](migration.sql). + +**No `UNIQUE (topic, partition, offset)` constraint** — the backend's own offset assignment (combined with single-writer-via-outbox order preservation) guarantees uniqueness without requiring a global cross-segment UNIQUE index. Segmented storage (Postgres native partitioning, time-based shards, etc.) is therefore practical without operational pain. The PRIMARY KEY on `id` (UUID, client-provided) catches accidental duplicate event submissions; the unique offset per `(topic, partition)` is enforced by single-writer discipline, not a cross-partition unique index. + +Note: `tenant_id` is present for authorization filtering (queries are tenant-scoped via SecureConn) but is NOT part of any uniqueness or sequencing key — topic GTS identifiers are globally unique by namespace. + +#### Cursor — Cache-Only, Group-Scoped + +Cursors are NOT a SQL table. They live entirely in the **ClusterCapabilities-backed cache**, keyed by `(consumer_group, topic, partition)`: + +```text +Cache key: evbk.cursor:{consumer_group}:{topic}:{partition} +Cache value: { offset: i64, last_examined: i64, updated_at: timestamp } +TTL: no TTL while group is alive; cache provider's eviction policy applies +``` + +Each group has one cache entry per `(topic, partition)` it consumes. **Tenant is NOT in the cursor key** — multiple tenants in the same group share one cursor per `(topic, partition)`. **Topic is in the key for partition disambiguation only** (a partition number means nothing without saying "of which topic") — `topic` is NOT part of group identity. + +**Why cache-only**: cursor state is broker runtime state. In standalone, the cursor lives in the in-process cache and dies with the process. In cluster mode, a persistent cache provider may preserve runtime cursor state across shard failover. Durable consumer progress belongs in the consumer's own store; consumers initialize each new subscription session from that store. + +**`offset`**: The session cursor set by SEEK (pre-stream-only); the broker emits from offset+1. While streaming it auto-advances with delivery. The next consumer to join the group resumes from `offset + 1` if the cache entry is still alive. + +**`last_examined`** (offset adviser, see R57): The highest offset the broker has scanned for this group on this partition, regardless of whether events matched the subscription's filters. Lets consumers skip ahead when filters reject most of the topic content. Always satisfies `last_examined ≥ offset`. + +The four conceptual positions (`received`, `max_offset`, `sent`, `offset`) are split by where they belong: +- `offset` (session cursor set by SEEK) → in cache (this entry) +- `last_examined` (broker's scan position regardless of filter) → in cache (this entry) +- `sent` (per-subscription "delivered but not yet confirmed") → in cache, per-subscription advisory +- `received` / `max_offset` (per-topic-partition highest persisted) → derivable from `backend.query` / `backend.segments` (storage backend's own `MAX(offset)`) + +**No `evbk_subscription` table** — subscription state lives in the cache (see §3.1 Subscription Schema, §3.2 Subscription Resolution Cache). Subscriptions are ephemeral and don't survive their consumer; the cursor is the only durable consumer-side state. + +**No `evbk_subscription_partition` table** — partition assignments are derived in-memory by the rebalance algorithm and held in `GroupState.active_members[sub_id].assigned_partitions` in the cache. + +#### Consumer Group Table Schema + +Columns: `id` (full GTS identifier — named `gts.cf.core.events.consumer_group.v1~vendor.foo.v1` or anonymous `~{uuid}`, PK), `tenant_id` (owner from SecurityContext), `owner_principal` (creator from SecurityContext), `kind` (`named` | `anonymous`, derived from id-instance shape), `description` (nullable), `created_at`. Index on `tenant_id`. Full DDL: see [`migration.sql`](migration.sql). + +The registry row is identity-and-ownership state — outlives every subscription, every delivery shard restart, every cache transition. Belongs in the broker's persistent DB alongside `evbk_topic` and `evbk_event_type`, NOT in the cache (which would re-introduce the silent-collision bug after a cache wipe — a second tenant's first JOIN post-eviction would re-claim the GTS). + +**Two creation paths, one table — each path exclusive to one shape**: +- **`POST /v1/consumer-groups`** — anonymous-only; `id` is **broker-minted** (`gts.cf.core.events.consumer_group.v1~`), the request body MUST NOT carry `id`. `tenant_id` and `owner_principal` come from `SecurityContext` at create time; non-overridable. JOIN authz is owner-tenant equality. +- **`types_registry` upsert** — named-only. At startup, the broker reads `types_registry` for instances of `gts.cf.core.events.consumer_group.v1~` and upserts each into `evbk_consumer_group` with `kind='named'` and `owner_principal` set to the registering module's principal. Idempotent. JOIN authz is explicit `:consume` grant on the concrete GTS instance via the PEP — no tenant-equality short-circuit. + +#### Producer Registration Table Schema + +Columns: `producer_id` (PK, UUID, broker-minted), `owner_principal`, `mode` (`chained` | `monotonic` — immutable; stateless does not register), `client_agent` (RFC 9110 User-Agent grammar, ASCII, 1–256 bytes, informational), `created_at`, `last_seen_at`. The Reaper purges rows whose `last_seen_at` is older than the platform-wide producer-registration TTL (default `P30D`); cascade-deletes the producer's `evbk_producer_state` rows on purge. Full DDL: see [`migration.sql`](migration.sql). Normative behavior: [`ADR/0004-idempotent-producer-protocol.md`](ADR/0004-idempotent-producer-protocol.md), [`features/0001-idempotent-producers.md`](features/0001-idempotent-producers.md). + +#### Producer State Table Schema + +Columns: `producer_id` (FK → `evbk_producer`, `ON DELETE CASCADE`), `topic`, `partition` — composite PK on `(producer_id, topic, partition)`; `last_sequence` (default 0), `last_seen_at`. Index on `last_seen_at` (used by the Reaper to find stale rows). Reaped per the topic's `retention` (capped at `P14D`). Full DDL: see [`migration.sql`](migration.sql). + +Producer chain state is keyed by `(producer_id, topic, partition)` — global, not narrowed by tenant. `last_sequence` tracks the highest producer-set `meta.sequence` accepted from this producer for this `(topic, partition)`. The Reaper worker cleans up records where `last_seen_at` is older than the topic's `retention` (capped at `P14D`). Separately, the Reaper cascade-deletes state rows when the parent `evbk_producer` registration row is aged out by the platform-wide producer-registration TTL. + +**Chain check at ingest** (chained mode): on incoming event with `meta.{previous, sequence}`, the broker verifies `meta.previous == state.last_sequence` AND `meta.sequence > state.last_sequence`. Match → accept and update `last_sequence = meta.sequence`. Previous mismatch → `400 SequenceViolation` (producer's view of the chain disagrees with the broker's; producer should call `GET /v1/producers/{producer_id}/cursors` to recover). `meta.sequence <= state.last_sequence` → duplicate, return original event with `200 OK`. + +**Monotonicity check at ingest** (monotonic mode, `meta.previous` MUST be absent): just `meta.sequence > state.last_sequence`. Gaps allowed in MVP; future enforcement settable at registration. + +Concurrent updates to a single `(producer_id, topic, partition)` row act as the fencing mechanism — exactly one writer advances `last_sequence` at a time. Producers that race lose to the unique-index serialization and see `400 SequenceViolation`; recovery via the desync mechanism. + +#### Two Sequences (Single Source of Truth Each) + +**ID**: `cpt-cf-evbk-design-sequence-assignment` + +The wire surface has **two consumer-visible sequence concepts** and one **internal** one (the ingest outbox sequencer), each owned by exactly one component. After [ADR-0003 Event Schema](ADR/0003-event-schema.md), the producer chain lives in `meta` (publish-only via `writeOnly`, stripped on read) and the server-assigned ordering key is named `sequence` (renamed from `offset`) — matching the cursor model's `max_sequence` / `received` / `sent` / `offset` terminology. + +| Sequence | Owner (single source of truth) | Scope | Purpose | Visible to | +|---|---|---|---|---| +| **Producer chain** (`meta.previous`, `meta.sequence`) | Producer client | per `(producer_id, topic, partition)` | Ingest-side dedup (chained / monotonic modes) | Producer ↔ broker only; lives on publish input; stripped on the consumer read projection | +| **Backend `sequence`** (`event.sequence`, `readOnly`) | **Storage backend** | per `(topic, partition)` | Consumer-visible ordering key. What `cursor.offset` tracks; seek positions reference. Renamed from `offset` per [ADR-0003 § Terminology Cleanup](ADR/0003-event-schema.md#terminology-cleanup-offset--sequence) | Consumers; the cursor model uses the same value space | +| **Outbox sequence** (internal) | toolkit-db ingest-side outbox | per outbox partition | **Order preservation** in the ingest pipeline (events for a `(topic, partition)` are delivered to backend in enqueue order) | Internal to broker plumbing; never exposed on the wire | + +**Critically**: the outbox does NOT assign the consumer-visible `sequence`. It assigns its own internal queue ordering number, which is invisible. The consumer-visible `sequence` is the broker-logical value exposed by the storage backend boundary. A backend may use native positions internally (Kafka offset, DB row sequence, file segment offset, etc.), but those values do not leak into public stream, query, SEEK, topology, or control surfaces unless they already satisfy the broker-logical contract. + +##### Where each sequence is assigned + +**Producer chain (`meta.previous`, `meta.sequence`)** — assigned by the producer client at publish time (chained / monotonic modes; `meta` absent in stateless). Carried inside the publish-input `meta` block. Validated by the IngestService's chain check against `evbk_producer_state.last_sequence`. Storage MAY retain `meta` for audit; the consumer-visible read projection strips it. + +**Outbox sequence** — assigned by `toolkit-db`'s outbox sequencer when an event is enqueued in the ingest-side outbox. Guarantees events for the same outbox partition are delivered to `backend.persist` in enqueue order. Stays internal to the outbox; not exposed in the API; not stored by the backend. + +**Backend `sequence`** — assigned by the backend boundary during `persist`: +- **DB backend**: may use a native DB sequence (e.g., Postgres `BIGINT GENERATED ALWAYS AS IDENTITY` per `(topic, partition)` partition, or per-row sequence within segment) as long as the public value satisfies ADR-0001. +- **Kafka backend**: Kafka's own offset is native and atomic, but backend-internal. The public wire-level `sequence` is broker-logical: `logical_sequence = kafka_offset + 1`; reads translate back with `kafka_offset = logical_sequence - 1`. +- **File / S3 backend**: may use a backend counter (segment-relative offset + segment base, or a backend-managed durable counter) as long as the public value satisfies ADR-0001. +- **In-memory (test)**: `AtomicI64` or equivalent broker-logical counter. + +The backend never returns the sequence inline from `persist`. Consumers learn `sequence` via `query` after persist commits — assignment is async from the producer's perspective. + +##### What this means for the design + +- **No `UNIQUE (topic, partition, sequence)` constraint** is needed at the broker DB level — the backend owns assignment, and the outbox guarantees ordered delivery to the backend (so the backend never sees out-of-order writes that would cause sequence collisions). +- **No `SELECT MAX + INSERT`** at the broker level — that's an internal DB-backend implementation choice, hidden inside its `persist` method. Other backends use their native mechanism. +- **Producers never see the `sequence` synchronously.** The `POST /v1/events` response is `202 Accepted` (no `sequence`) once the event is durably enqueued in the outbox. Sync mode (`201 Created`) confirms backend persistence but also doesn't include `sequence` (assignment is async). Producers needing the value learn it via consumer-side query or admin lookup. +- **Consumers see only `sequence`.** Consumer seek parameters are broker-logical last-processed cursors. The backend's `query` translates seek positions natively as needed (Kafka cursor `N` starts reading at native offset `N`; DB can query `WHERE "sequence" > $sequence` when DB sequence already matches broker-logical sequence). + +##### Sync vs Async Persist Modes + +The default is async (HTTP 202). Producers that need synchronous "wait until persisted" can opt in: + +| Mode | Trigger | Response | Trade-off | +|---|---|---|---| +| **Async (default)** | Default behavior | `202 Accepted { event_id, accepted_at }` | Fast (single outbox enqueue); event durable in the outbox, persistence to backend follows asynchronously | +| **Sync** | Request flag (`Sync-Wait: true` header or `?wait=persisted` query param), or per-topic / per-deployment config | `201 Created { event_id, ... }` after `backend.persist` returns | Slower (blocks on backend); confirms the event is in backend storage, not just the outbox. **Does not** include `offset` — offset assignment is async. Producers that need the offset learn it via consumer-side query. | + +The flag is honored when the backend supports synchronous persist; for backends with hard async semantics (e.g., S3 with eventual consistency), sync mode may be unsupported and rejected with `400 SyncNotSupported`. Per-deployment configuration can force one mode globally. + +#### Key Invariants + +- All reads/writes tenant-scoped through secure ORM (no raw SQL) +- Per-(topic, partition) ordering is enforced by the **outbox sequencer** at enqueue time, preserved by single-writer outbox processing into `backend.persist` +- Per-(topic, partition) sequence monotonicity is enforced by the **backend boundary** using the backend's native mechanism where appropriate (Kafka offsets, DB sequences, etc.) and exposing only broker-logical values. The broker doesn't need a global UNIQUE index across segments. +- Event immutability: no UPDATE operations on event data. Event deletion (retention, compaction, mid-stream gaps) is entirely the storage backend's responsibility — the broker has no Cleaner / Retention workers. +- Subscription expiry is independent of event lifecycle +- Cursor positions satisfy: `0 <= offset <= backend.MAX(logical_sequence) for that (topic, partition)`. `sent` is per-subscription advisory state in cache (not on the cursor); `received`/`max_sequence` are derivable via `backend.segments()` or `backend.query()` in broker-logical space. + +### 3.8 Deployment Topology + +**ID**: `cpt-cf-evbk-topology-deployment` + +The Event Broker supports two deployment topologies, both expressed as variants of the same module wiring: + +- **Standalone** — one process; all internal services (ingest, delivery, dispatcher) co-resident; in-process cluster provider; storage backend either in-memory (dev/test) or postgres (production self-contained). +- **Cluster** — separate ingest, delivery, and dispatcher processes scaled independently; cluster system module provider backed by Redis (with disk persistence for cursor durability), NATS, etcd, or PostgreSQL (`db-only` provider); storage backend is operator-chosen per topic via the `streaming.backend_selector`. + +Within cluster mode, ingest routing has two further variants: + +- **Hetero ingest** (default) — every ingest instance serves every topic; load-balanced across the ingest set; hot topics distributed naturally across the fleet. +- **Sharded ingest** — specific topic patterns pinned to specific ingest instances via `serves_topic_patterns`; opt-in for noisy-neighbor isolation, hardware affinity, or regulatory tenancy. A default `*` fallback shard is recommended for unmatched topics. + +Detailed deployment-mode tables (services active per mode, cluster provider per mode, routing semantics per mode) are documented in §4.1 Deployment Modes. + +**Consumer-side use cases, walkthroughs, and the v1 rebalance algorithm** are documented in a companion file [USE_CASES.md](USE_CASES.md) — non-canonical content kept separate from this DESIGN to preserve canonical structure. + +## 4. Additional Context + +### 4.1 Deployment Modes + +| Mode | Services Active | ClusterCapabilities Provider | Routing | Use Case | +|---|---|---|---|---| +| `standalone` | All (ingest + delivery + workers) in one process | `standalone` (in-process channels, local locks) | None — direct calls | Single-node, dev, small deployments | +| `cluster_ingest` | IngestService + workers | Any cluster provider (Redis, NATS, K8s, DB-only) | Hetero (default) or sharded by topic patterns | Horizontally scaled ingest | +| `cluster_delivery` | DeliveryService + workers | Any cluster provider | Cache-based: `consumer_group → instance` | Horizontally scaled delivery | +| `cluster_dispatcher` | Dispatcher only (stateless) | Reads `cluster.discover_shards()` | Routing table via discovery | HTTP gateway (N instances behind LB) | + +In standalone mode, the module wires all services into a single process. The `standalone` ClusterCapabilities provider uses Tokio channels for pub/sub and local mutexes for locks — zero external dependencies. The outbox runs in-process. + +In cluster mode: +- **Dispatcher instances** (stateless, behind load balancer) route HTTP requests via cache lookup + per-mode logic (see §3.2 Dispatcher Routing): + - Ingest routes use **hetero mode** (default; load-balance any healthy ingest) or **sharded mode** (pattern-match `serves_topic_patterns` → most-specific-wins → load-balance within tier; `503 NoIngestForTopic` on no match). + - Delivery routes use cache lookup `evbk.group.endpoint:{consumer_group}` → delivery instance endpoint, then forward. + - The dispatcher resolves `subscription_id` → `consumer_group` via the subscription resolution cache (§3.2 Subscription Resolution Cache). JOIN endpoints (`POST /v1/subscriptions`) carry `consumer_group` and `topics` in the body so the first lookup is skipped. + - Dispatchers discover ingest/delivery instances via `cluster.discover_shards()`. All dispatcher instances see the same shard membership. +- **Event notifications** flow directly from ingest to delivery via `cluster.publish()` / `cluster.subscribe()` — they do NOT pass through the dispatcher. This means multiple dispatcher instances require no shared state for notifications. +- **Worker leader election** uses `cluster.leader_election()` — only one node runs the cleaner for a given `(topic, partition)`, etc. +- All instances share the same database. The ClusterCapabilities provider is the only external dependency beyond the database. + +#### Deployment Variants + +```text +Variant A — Standalone (single process) +───────────────────────────────────────── + ┌─────────────────────────────────┐ + │ event_broker module │ + │ ┌─────────┐ ┌──────────────┐ │ + │ │ Ingest │ │ Delivery │ │ + │ └────┬────┘ └──────┬───────┘ │ + │ │ outbox │ │ + │ ┌────▼──────────────▼───────┐ │ + │ │ Backend (DB / memory) │ │ + │ └───────────────────────────┘ │ + └─────────────────────────────────┘ + ClusterCapabilities = standalone (in-process) + Use: dev, single-node, small deployments + + +Variant B — Hetero cluster (every ingest serves every topic) +───────────────────────────────────────────────────────────── + HTTP Load Balancer + │ + ┌─────────────┼─────────────┐ + ▼ ▼ ▼ + Dispatcher Dispatcher Dispatcher (stateless, N instances) + │ │ │ + │ ingest: load-balance any healthy ingest matching `*` + │ delivery: cache.get("evbk.group.endpoint:{group}") → forward + ▼ ▼ ▼ + ┌──────────┐ ┌──────────┐ ┌──────────┐ + │ Ingest │ │ Ingest │ │ Ingest │ serves_topic_patterns: ["*"] + └────┬─────┘ └────┬─────┘ └────┬─────┘ + └─────────────┼─────────────┘ + │ + ┌──────▼──────┐ + │ Backend │ + └─────────────┘ + ClusterCapabilities = K8s | Redis | NATS | DB-only + Use: simple horizontal scaling; no workload isolation + + +Variant C — Sharded cluster (specialized + default fallback) +──────────────────────────────────────────────────────────── + HTTP Load Balancer + │ + ┌─────────────┴─────────────┐ + ▼ ▼ + Dispatcher Dispatcher (stateless) + │ │ + │ ingest: discover ingests + match topic against + │ each instance's serves_topic_patterns + │ → most-specific wins, load-balance within tier + │ → 503 NoIngestForTopic if no match + │ + ▼ ▼ + ┌────────────────────────────────────────────┐ + │ Ingest shard "hot-topics" │ + │ ┌──────────┐ ┌──────────┐ │ + │ │ Instance │ │ Instance │ │ + │ │ patterns:│ │ patterns:│ │ + │ │ gts.cf. │ │ gts.cf. │ │ + │ │ audit.* │ │ audit.* │ │ + │ └──────────┘ └──────────┘ │ + └────────────────────────────────────────────┘ + ┌────────────────────────────────────────────┐ + │ Ingest shard "default" │ + │ ┌──────────┐ ┌──────────┐ │ + │ │ Instance │ │ Instance │ │ + │ │ patterns:│ │ patterns:│ │ + │ │ ["*"] │ │ ["*"] │ │ + │ └──────────┘ └──────────┘ │ + └────────────────────────────────────────────┘ + │ │ + ▼ ▼ + ┌─────────────────────┐ + │ Backend │ + └─────────────────────┘ + + Routing examples: + publish(gts.cf.audit.users.v1) → "hot-topics" shard (specialized + match `gts.cf.audit.*` beats `*`) + publish(gts.cf.users.v1) → "default" shard (only `*` matches) + publish(gts.cf.audit.security.v1) → "hot-topics" shard + "hot-topics" shard fully down → 503 NoIngestForTopic for topics + matched only by `gts.cf.audit.*` + (operator must restore the shard + OR add `*` fallback to "hot-topics" + OR rely on the default shard's `*` + if it's configured to also match + `gts.cf.audit.*` — operator's + choice at config time) + + +Variant D — Mixed (sharded ingest + multiple delivery instances) +───────────────────────────────────────────────────────────────── + Same as Variant C for ingest, plus multiple delivery instances + managing different consumer_groups via cache-based routing + (the delivery routing model is independent of ingest sharding). + + Ingest sharding is for producer workload isolation. + Delivery instance count is for consumer group throughput. + Both scale independently. +``` + +**Default for MVP**: Variant B (hetero cluster). Sharded mode (Variant C) is available for deployments that need explicit workload isolation. Variant A (standalone) for dev / single-node. + +The deployer selects the ClusterCapabilities provider based on their infrastructure: + +| Infrastructure | Recommended Provider | +|---|---| +| Single Postgres, no extras | `db-only` (advisory locks, polling watermark, registration table) | +| Kubernetes | `k8s` (Lease API, DNS discovery, K8s events) | +| Redis in stack | `redis` (RedLock, Pub/Sub, Redis sets) | +| NATS in stack | `nats` (JetStream, NATS pub/sub, NATS service) | + +Module configuration selects the mode: + +```yaml +modules: + event_broker: + mode: standalone # standalone | cluster_ingest | cluster_delivery | cluster_dispatcher + + # Default storage backend (resolved via GTS plugin discovery) + default_storage_backend: database # GTS short alias or full instance ID + + # ClusterCapabilities provider is configured at the platform level (Gears platform), + # not per-module. The event broker just consumes it. + # See: modkit.cluster.provider in platform config. + + batch: + max_size: 100 + max_payload_bytes: 1048576 # 1MB + + polling: + default_timeout_secs: 30 + max_timeout_secs: 30 + + subscription: + default_session_timeout: PT30S + min_session_timeout: PT1S + + workers: + cleaner_interval_secs: 60 + retention_interval_secs: 300 + reaper_interval_secs: 60 +``` + +### 4.2 Caching Strategy + +**Topic/Event Type metadata** (SpecificationManager): Cached in-memory with indexed lookups (by GTS ID, by UUID). Loaded at module init, updated on specification changes. Hash-based change detection avoids unnecessary cache invalidation. + +**Event data**: Not cached. Events are read from the storage backend on every consumption request. The `(topic, partition, sequence)` index provides efficient range scans. + +**Subscription/Cursor state**: Not cached. Lookups are per-poll (once per request) and the subscription table is small. + +**JSON Schema compilation**: Compiled schemas cached in SpecificationManager alongside event type metadata. Avoids re-compilation on every publish. + +### 4.3 Metrics and Observability + +| Metric | Type | Labels | Description | +|---|---|---|---| +| `evbk_events_published_total` | Counter | `status` (success/error) | Events published | +| `evbk_events_published_batch_size` | Histogram | — | Batch sizes | +| `evbk_events_consumed_total` | Counter | — | Events returned to consumers | +| `evbk_publish_duration_seconds` | Histogram | — | Publish latency (ingest) | +| `evbk_poll_duration_seconds` | Histogram | — | Poll request duration including wait | +| `evbk_poll_wait_seconds` | Histogram | — | Time spent waiting in long-poll | +| `evbk_subscriptions_active` | Gauge | — | Active (non-expired) subscriptions, sourced from cache snapshot | +| `evbk_producer_dedup_total` | Counter | — | Idempotent producer duplicate rejections | +| `evbk_producer_out_of_order_total` | Counter | — | Idempotent producer out-of-order rejections | +| `evbk_cursor_lag` | Gauge | `tenant_id`, `consumer_group`, `topic`, `partition` | Gap between topic's `MAX(offset)` and `cursor.offset` for the group | +| `evbk_long_poll_connections` | Gauge | `delivery_instance` | Active long-poll connections per delivery instance | +| `evbk_groups_owned` | Gauge | `delivery_instance` | Number of consumer groups owned by this delivery instance (capped by `max_groups_per_delivery_instance`) | +| `evbk_backend_health` | Gauge | `backend_instance` | 1 = healthy, 0 = unavailable; updated from `backend.persist` / `query` outcomes | +| `evbk_filter_eval_timeout_total` | Counter | `consumer_group` | Per-event filter evaluation exceeded `EVAL_TIMEOUT_MICROS`. Event silently dropped from this consumer's batch; poll does NOT fail. Per ADR-0005. | +| `evbk_filter_eval_error_total` | Counter | `consumer_group`, `kind` (e.g., `missing_field`, `runtime`) | Per-event filter evaluation returned `FilterError::EvalRuntime`. Event silently dropped from this consumer's batch. | +| `evbk_worker_runs_total` | Counter | `worker` | Worker execution count | + +**Cardinality**: only operationally-load-bearing metrics carry tenant / consumer_group / topic / partition labels (e.g., `evbk_cursor_lag` — needed to identify lagging groups). Other metrics are unlabeled by these dimensions to bound time-series count. + +**`evbk_cursor_lag` and cache transitions**: the gauge is computed from the cursor cache entry (`offset`) and the storage backend's `MAX(offset)` per `(topic, partition)`. When a cursor cache entry is lost (cache provider restart, TTL eviction, standalone-shard failure), the metric for that `(group, topic, partition)` either drops out of the gauge or jumps to "lag = `MAX(offset)`" once the cursor re-initializes at `0` on the next poll. Operationally useful — a sudden lag spike or a series disappearing flags a cache-transition event. + +**Health / readiness**: `/health` and `/ready` endpoints are platform-wide infrastructure provided by the Gears platform for every Gears module — not broker-specific. The broker contributes its readiness signals (e.g., `evbk_backend_health` per bound backend, outbox processor liveness) via the platform's standard hooks. + +### 4.4 Audit Logging + +Structured JSON to stdout, ingested by centralized logging system. + +**Logged events**: +- Event accepted to outbox (INFO): topic, event type, event_id, tenant_id, partition, async/sync mode +- Event persisted by backend (INFO): topic, event type, event_id, broker sequence, tenant_id, partition (emitted by outbox processor after backend.persist returns) +- Batch published (INFO): topic, count, succeeded, failed, tenant_id +- Subscription created/expired (INFO): subscription_id, consumer_group, topic, tenant_id +- Offset acknowledged (INFO): subscription_id, offset, tenant_id +- Publish validation failure (WARN): topic, event type, error detail, tenant_id +- Idempotency deduplication (INFO): event_id, topic, tenant_id +- **Backend.persist failure** (WARN/ERROR): topic, partition, event_id(s), backend identifier, error detail, retry attempt count +- **Outbox dead-letter** (ERROR): event_id, topic, partition, terminal failure reason — operator must reconcile +- Worker run (DEBUG): worker name, duration, items processed + +**Backend persist failure recovery**: +- Transient failures (network, backend slow, retryable error): outbox retries with backoff per toolkit-db's retry policy. +- Permanent failures (validation, schema mismatch, backend rejected): event moves to outbox dead-letter after configured max attempts. +- Operator visibility: metric `evbk_outbox_dead_letter_total{topic, partition, reason}`; alarm threshold per topic. +- Reconciliation: dead-letter inspection via admin endpoint (post-MVP); manual replay or operator-driven discard. +- Producer impact: when async (default), the producer was already 202'd before the failure occurred — there's no retroactive failure signal to the producer. Operators must monitor and act on dead-letter accumulation. +- Sync mode: backend failures surface as `503 BackendUnavailable` (transient) or `4xx`/`5xx` from the backend (permanent) directly to the producer's HTTP response. + +**Not logged**: Event payloads (`data` field), CEL expressions, full filter criteria. + +### 4.5 Security Considerations + +**Tenant isolation**: Topics, event types, and subject types are platform-scoped (globally unique by GTS identifier). Authorization is per-(resource, action) via the `authz-resolver` framework — see §3.3. Per-event row-level filtering (using the publisher's `tenant_id` recorded at ingest) is applied via the `AccessScope` constraints returned by the PEP, not via topic ownership. A principal can only publish to or consume from topics it has explicit `publish` / `subscribe` permission on. + +**Input validation**: +- GTS identifiers validated on all API inputs +- Event payload validated against registered JSON Schema +- CEL expressions parsed and validated at subscription creation time (not at query time) +- Batch size and payload size enforced before processing + +**No cross-tenant event leakage**: Subscriptions can only target topics, event types, and subject types the subscribing principal has been granted `subscribe` permission on. Per-event `tenant_id` (the publisher's tenant, recorded at ingest) is filtered at read time using the `AccessScope` constraints from the PEP (typically `Eq(tenant_id = ctx.tenant)` or `In(tenant_id IN subtree)`), not via topic ownership. + +**Worker isolation**: Background workers (Reaper) operate within tenant scope. Event-level retention / cleanup is the storage backend's responsibility, governed by the backend's own configuration and security context. + +### 4.6 Out of Scope + +This is an **MVP design**. The consumption surface is intentionally narrow — one path (subscription-based long-poll) — with explicit hooks for non-breaking extensions later. The list below is the full set of things deliberately deferred from v1; each one is an additive future endpoint or capability, not a contract change. + +**Consumption surface extensions** (additive — new endpoints alongside existing ones): +- **`GET /v1/events:sse`**: Server-Sent Events streaming variant — same subscription mechanics as `:poll`, but the response is an `text/event-stream` instead of polled JSON. Enables push-based delivery without WebSocket complexity. +- **`GET /v1/events`**: Anonymous offset-based read for tooling, diagnostics, and bulk backfill. Single-partition, stateless. The MVP forces all reads through subscriptions; this endpoint relaxes that for tools that don't need rebalance/ack/state. Shape would be `?topic=T&partition=P&offset=N&limit=L&timeout=t`. +- **WebSocket bidirectional streaming** (`GET /v1/events:ws`): Full-duplex for use cases where the consumer needs to send acks back over the same channel. + +**Other deferred capabilities**: +- **Consumer group partitioning beyond round-robin**: Server-side sticky-Kafka-style assignment to minimize partition movement on rebalance. v1 uses simple round-robin. +- **Dead letter queues**: Failed processing retry/DLQ. The outbox module handles transactional delivery guarantees. +- **Schema evolution within an event type**: v1 treats event types as immutable — any change to an event type's `data_schema` requires a new event type with a new GTS identifier and explicit consumer migration. Per-version compatibility checks and inter-version casting are post-MVP (see §4.8). +- **Cross-tenant event sharing**: Hierarchical topic visibility. Events are strictly tenant-scoped. +- **Automatic compaction**: Segment cleanup is tracked but not automatically managed in v1. +- **ClusterCapabilities providers beyond standalone**: Redis, NATS, K8s, DB-only providers. Standalone provider is the v1 deliverable; cluster providers are a platform-level deliverable. +- **gRPC consumption API**: For high-throughput inter-service streaming. +- **Live partition resize**: Adding/removing partitions on existing topics. Partition count is fixed at topic creation in MVP. +- **Multi-tenant backend instance migration**: Operator-driven topic-to-backend rebinding without downtime. +- **Testing infrastructure design**: cluster-mode integration test harness, two-outbox pipeline test harness with deterministic delivery hooks, time-scaled test mode for timeout-dependent scenarios. Testing strategy is out of scope for this design and will be addressed in a separate event-broker performance / integration test design. +- **Broker admin / debug endpoints**: replay events from timestamp, inspect last N events on a partition, browse events without DB access. Early-MVP debugging relies on storage-backend-native tooling (Kafka CLI, Postgres queries, S3 browse, etc.). Broker-side admin endpoints (`GET /v1/admin/events`, etc.) are post-MVP. + +**MVP design principle**: Every endpoint, table, and trait in this design is shaped to leave room for these extensions without breaking changes. New consumption endpoints reuse existing subscription state; new providers plug into ClusterCapabilities; new backends register via the existing GTS plugin mechanism. + +### 4.7 Open Questions + +- **Tenant depth for topic visibility**: ~~Open question~~ **Resolved** — `SubscriptionInterest` now carries `max_depth: Option` (0 = current only, 1 = direct children, null = unlimited) and `barrier_mode: "respect" | "ignore"`, aligned with `tenant_resolver_sdk::GetDescendantsOptions`. The broker expands `(tenant_id, max_depth, barrier_mode)` into a concrete tenant set at fan-out via the Tenant Resolver. See `schemas/subscription.v1.schema.json`. +- **Partition selection algorithm**: ~~Open question~~ **Resolved** — ADR-0002 defines broker-authoritative topic partition assignment from `partition_key` when present, otherwise `tenant_id`. Producers do not provide a top-level topic partition; SDK-local partition work is limited to local routing or non-authoritative broker-partition hints. +- **Partition-sharded ingest**: v1 shards ingest by topic (whole topic owned by one ingest shard, all partitions co-located). Future: shard by `(topic, partition)` for hot-topic horizontal scaling. Trade-off: routing table grows from O(topics) to O(topics × partitions); rebalancing more frequent. Deferred — contracts already carry `partition_id` so this is a routing change, not a contract change. +- **Outbox extension for partition-level scaling**: should `toolkit-db`'s outbox grow native partitioning for producer or ingest scaling? This is a local outbox/ingest partition-domain question, not a requirement that producer outbox partition count equals broker topic partition count. Two shapes to consider: (a) multiple outbox table instances, (b) a partition-aware sequencer + processor pool inside a single outbox table. Current design works for moderate throughput; hot-topic deployments may need this. Decision deferred to post-MVP, gated by the outbox library's roadmap. +- **Long-poll protocol revisit**: the per-partition in-memory cache + condvar-driven iterator model (see §3.2 Long-Poll Mechanism) needs a focused pass once feedback is closed. Open sub-questions: cache eviction policy (size-based, time-based, both?); backfill path when a consumer's `cursor.offset` is older than the cache's earliest retained batch (fall through to `backend.query`?); iterator-vs-cursor reconciliation across partition reassignment; condvar / notification ordering and fairness across many waiters; interaction with `cluster.subscribe` provider semantics (lossy vs lossless pub/sub). +- **Auto-commit semantics**: Should the broker auto-commit consumer offsets on poll (advancing `cursor.offset` to `sent`), or always require explicit `POST /v1/subscriptions/{id}:seek`? Auto-commit is convenient but risks losing events on consumer crash mid-processing. +- **Default page size**: Should the default page size be 100 or 1000? Higher limits reduce round-trips for bulk replay; lower limits are safer for memory. +- **SSE delivery shape**: When SSE streaming is added (deferred to post-MVP per §4.6), should it be a separate endpoint (`GET /v1/events:sse`) or an upgrade on the existing poll endpoint via `Accept: text/event-stream`? Current lean: separate endpoint for clean OpenAPI definition and independent versioning. +- **Anonymous read shape**: When anonymous `GET /v1/events` is added (deferred to post-MVP per §4.6), the proposed shape is single-partition with required `topic`, `partition`, `offset` params. Confirm no fields are missing for tooling use cases (e.g., backwards-from-end reads, time-based seeks). +- **Backend capability negotiation**: Should backends declare capabilities (e.g., supports-segments, supports-retention, supports-sequence-alignment) so the event broker can validate topic configurations against backend capabilities at registration time? +- **Multi-backend per deployment**: Different topics can use different backend instances (multiple postgres clusters across regions, plus a kafka cluster, plus memory for dev). Operationally complex — needs clear visibility on which topic lives where. +- **Backend instance migration**: When a topic's bound backend instance is decommissioned, how is data migrated to a new instance? Out of scope for v1 (operator-managed cutover with downtime), but a long-term concern. +- **Selector evolution**: If an operator updates a topic's selector after binding (e.g., changes `region: us-east-1` → `region: eu-west-1`), should the broker reject the change, attempt re-binding (no data migration — new topic effectively), or require an explicit `migrate: true` flag? Current lean: reject — selector is bound at topic registration and immutable thereafter. + +### 4.8 Future Developments + +The MVP is shaped so that all of the following are **additive non-breaking changes** when delivered: + +**Consumption surface extensions** (new endpoints alongside `:poll`): +- **`GET /v1/events:sse`**: Server-Sent Events streaming variant of subscription poll +- **`GET /v1/events`**: Anonymous offset-based read for tooling, diagnostics, bulk backfill +- **`GET /v1/events:ws`**: WebSocket bidirectional streaming for full-duplex use cases + +**Platform / infrastructure**: +- **ClusterCapabilities providers**: Redis, NATS, K8s, DB-only — platform-level implementations enabling cluster mode +- **Dispatcher service**: Stateless HTTP gateway for cluster deployments (multiple instances behind LB) +- **gRPC consumption API**: High-throughput inter-service event streaming + +**Capability evolution**: +- **Sticky-Kafka rebalancing**: Replace v1 round-robin with sticky assignment to minimize partition movement +- **Partition-sharded ingest**: Shard ingest by `(topic, partition)` instead of by topic, for hot-topic horizontal scaling +- **Live partition resize**: Add (or under controlled circumstances, merge) partitions on existing topics +- **Retention and compaction**: Automatic segment cleanup based on configurable retention policies +- **Quotas and rate limiting**: per-tenant produce rate caps, per-(tenant, topic) produce caps, per-subscription poll rate caps, per-tenant active-subscription count caps, per-tenant storage quotas. Required for multi-tenant safety at scale; not in MVP. Mechanism candidates: token bucket per tenant in the ingest layer, ClusterCapabilities-backed for cluster mode. +- **Topic soft-delete with grace-period serving**: topics marked deleted (or actually deleted in storage) keep being served by the event broker for a configurable grace period — existing consumers continue to drain events; new subscriptions / produces are rejected. Avoids breaking in-flight consumers when an operator decommissions a topic. Not in MVP. +- **Backend-failure backpressure**: ingest may mark a topic as failed after persistent backend.persist errors and stop accepting new events for that topic, surfacing 503 to producers. Mechanism, thresholds, and recovery semantics are implementation-detail concerns deferred to the implementation phase. +- **DB-restore resync tooling**: a CLI to recover from a broker DB restore by realigning `evbk_producer_state.last_sequence` per `(producer_id, topic, partition)` to match the storage backend's actual state — **producer-cursor desync**. (Consumer-cursor desync is no longer a DB-level concern: consumer cursors live in the ClusterCapabilities cache, not in SQL — see §3.6 Cursor.) Until the tooling exists, manual operator intervention (`UPDATE evbk_producer_state SET last_sequence = …`; consumer-side seek or cache flush as needed) is the only recovery path. +- **SDK design and reference implementations**: Rust / TypeScript / Python / Go client trait shapes, error type mappings, retry / backoff defaults, broker discovery, reconnection semantics. The wire contract (REST endpoints, status codes, normative SDK behaviors on `410 Gone`, `400 SequenceViolation`, etc.) is fully specified in this design; per-language SDK realization is implementation-phase work shipping alongside the broker. +- **Cross-tenant event sharing**: Hierarchical topic visibility for multi-tenant deployments +- **Compatible schema evolution**: Forward/backward-compatible changes within a major event-type version (per GTS spec rules), plus broker- or SDK-side **inter-version casting** between minor versions (e.g., a v1.1 consumer reading a v1.2 event sees a v1.1-shaped projection). Allows long-lived event logs to evolve without forcing all consumers to migrate in lockstep. +- **Backend instance migration**: Operator-driven topic-to-backend rebinding without downtime + +## 5. Traceability + +This DESIGN traces back to the [PRD.md](PRD.md) functional and non-functional requirements, forward to the partition-selection ADR, and laterally to standards / external references. Architectural-review findings (a non-canonical audit trail) are preserved in [REVIEW.md](REVIEW.md). Consumer use cases, walked scenarios, and the v1 rebalance algorithm (also non-canonical for DESIGN) are documented in [USE_CASES.md](USE_CASES.md). + +### 5.1 PRD ↔ DESIGN Traceability + +| PRD requirement | Realized in DESIGN.md | +|---|---| +| `cpt-cf-evbk-fr-topic-registration` | §3.1 Topic Schema; §3.6 Database schemas (`evbk_topic`); §3.2 SpecificationManager | +| `cpt-cf-evbk-fr-event-type-registration` | §3.1 Event Type Schema; §3.6 Database schemas (`evbk_event_type`) | +| `cpt-cf-evbk-fr-topic-introspection` | §3.3 API Contracts (Topic Introspection endpoints) | +| `cpt-cf-evbk-fr-publish-single` | §3.3 Event Production (`POST /v1/events`); §3.5 Event Publish Flow | +| `cpt-cf-evbk-fr-publish-batch` | §3.3 Event Production (`POST /v1/events:batch`) | +| `cpt-cf-evbk-fr-producer-modes` | §3.2 Producer Modes (Chained / Monotonic / Stateless); §3.6 `evbk_producer_state` | +| `cpt-cf-evbk-fr-producer-registration` | §3.3 Producer Lifecycle (`POST /v1/producers`) | +| `cpt-cf-evbk-fr-producer-cursors` | §3.3 Producer Lifecycle (`GET /v1/producers/{id}/cursors`) | +| `cpt-cf-evbk-fr-subscription-join` | §3.3 Subscription Lifecycle Endpoints (JOIN); §3.2 GroupState Cache | +| `cpt-cf-evbk-fr-streaming-delivery` | §3.2 Long-Poll Mechanism; §3.5 Long-Poll Consumption Flow | +| `cpt-cf-evbk-fr-ack-seek` | §3.3 ACK / Seek endpoints; §3.5 Offset Acknowledgment Flow | +| `cpt-cf-evbk-fr-subscription-recovery` | §3.3 Subscription Termination on Ownership Change (410 Gone / 404 SubscriptionNotFound) | +| `cpt-cf-evbk-fr-anonymous-group-create` | §3.3 Consumer Group Lifecycle (`POST /v1/consumer-groups`); §3.6 `evbk_consumer_group` | +| `cpt-cf-evbk-fr-named-group-provisioning` | §3.1 ConsumerGroup entity; §3.2 `types_registry` upsert at startup | +| `cpt-cf-evbk-fr-group-join-authz` | §3.3 Authentication & Authorization (Subscribe path); §3.1 ConsumerGroup | +| `cpt-cf-evbk-fr-backend-trait` | §3.2 Storage Backend Plugin System (`StorageBackend` trait) | +| `cpt-cf-evbk-fr-builtin-backends` | §3.2 Built-in Backend Types | +| `cpt-cf-evbk-fr-third-party-backends` | §3.2 Backend Type Registration (GTS Type Extension) | +| `cpt-cf-evbk-fr-authorization` | §3.3 Authentication & Authorization (resource × action matrix) | +| `cpt-cf-evbk-fr-subject-type-dual-enforcement` | §3.3 Authentication & Authorization (Subject_type two dimensions); §3.1 Event Type Schema (`allowed_subject_types`) | +| `cpt-cf-evbk-fr-rfc9457-errors` | §3.3 Error Response Format | + +| PRD NFR | Realized in DESIGN.md | +|---|---| +| `cpt-cf-evbk-nfr-event-size` | §2.2 Constraints (`cpt-cf-evbk-constraint-event-size`) | +| `cpt-cf-evbk-nfr-long-poll-timeout` | §2.2 Constraints (`cpt-cf-evbk-constraint-poll-timeout`); §3.3 POLL endpoint (proxy timeout note) | +| `cpt-cf-evbk-nfr-group-cap` | §3.2 New-Group Placement (`max_groups_per_delivery_instance`) | +| `cpt-cf-evbk-nfr-failover-bound` | §3.2 Failover (SD heartbeat-loss bound); §3.2 ClusterCapabilities | + +### 5.2 ADRs + +- [ADR/0002-partition-selection.md](ADR/0002-partition-selection.md) — `cpt-cf-evbk-adr-partition-selection`. **Revised** to drop the explicit `partition` producer override; the broker is now authoritative for partition assignment, deriving from `partition_key` (when present) or `tenant_id` (default). Realizes the Event-schema partition derivation referenced in §3.1 Event Schema and §3.6 Two Sequences. Native Kafka producer partitioner compatibility is not a supported producer contract. +- [ADR/0003-event-schema.md](ADR/0003-event-schema.md) — `cpt-cf-evbk-adr-event-schema`. Defines the canonical event schema: single JSON Schema with field-level `readOnly` / `writeOnly` markers (no separate read-side file), optional versioned `meta` block for transport mechanics (marked `writeOnly`), `tenant_id` as producer-supplied, `subject_type` retained, `created_at` dropped, `offset`/`offset_time` renamed to `sequence`/`sequence_time` (`readOnly`), broker-native naming (no CloudEvents conformance), ASCII event-field encoding rule. Realized by `schemas/event.v1.schema.json`. +- [ADR/0004-idempotent-producer-protocol.md](ADR/0004-idempotent-producer-protocol.md) — `cpt-cf-evbk-adr-idempotent-producer-protocol`. Mode declared at registration (`POST /v1/producers { mode }`), enforced per request. Mode-shape hard errors at the wire boundary. Producer-registration TTL + operator-driven `POST :reset`. Single-writer concurrency. Realized by §3.2 Producer Modes (shrunk) and `docs/features/0001-idempotent-producers.md`. + +The remaining ADR identifiers referenced in §1.2 (future ADR (cluster-capabilities), future ADR (long-polling), future ADR (topic-sharding), future ADR (dispatcher), `cpt-cf-evbk-design-sequence-assignment`, future ADR (outbox-ingest)) are documented inline in this DESIGN.md and will be extracted into standalone canonical ADR files in follow-up design iterations. + +### 5.3 Standards & External References + +- **RFC 2119** — keyword definitions used throughout (MUST, SHOULD, MAY): +- **RFC 9457** — Problem Details for HTTP APIs; the broker's error response format: +- **W3C Trace Context** — `trace_parent` field validation regex: +- **CloudEvents 1.0** — `subject` attribute semantics that informed the producer-chain `partition_key` separation: +- **Confluent REST Proxy v2 / Strimzi Kafka Bridge** — wire-contract precedent for the consumer subscription protocol (JOIN / poll / ack / seek / leave): +- **MurmurHash3 reference (Austin Appleby)**: + +### 5.4 Companion Documents + +- [PRD.md](PRD.md) — product requirements, actors, scope, use cases, acceptance criteria. +- [USE_CASES.md](USE_CASES.md) — consumer state machine, producer / consumer profiles, walked scenarios, v1 rebalance algorithm. (Non-canonical extension to DESIGN.) +- [REVIEW.md](REVIEW.md) — architectural-review findings (RESOLVED / DEFERRED / DROPPED / PARTIALLY MITIGATED). Audit trail of decisions made during design evolution. (Non-canonical appendix to DESIGN.) +- [ADR/0002-partition-selection.md](ADR/0002-partition-selection.md) — partition-selection ADR. diff --git a/gears/system/event-broker/docs/PRD.md b/gears/system/event-broker/docs/PRD.md new file mode 100644 index 000000000..69fc3feb4 --- /dev/null +++ b/gears/system/event-broker/docs/PRD.md @@ -0,0 +1,722 @@ +# PRD — Event Broker + + + + +- [1. Overview](#1-overview) + - [1.1 Purpose](#11-purpose) + - [1.2 Background / Problem Statement](#12-background--problem-statement) + - [1.3 Goals (Business Outcomes)](#13-goals-business-outcomes) + - [1.4 Glossary](#14-glossary) + - [1.5 Domain Model](#15-domain-model) +- [2. Actors](#2-actors) + - [2.1 Human Actors](#21-human-actors) + - [2.2 System Actors](#22-system-actors) +- [3. Operational Concept & Environment](#3-operational-concept--environment) + - [3.1 Module-Specific Environment Constraints](#31-module-specific-environment-constraints) +- [4. Scope](#4-scope) + - [4.1 In Scope](#41-in-scope) + - [4.2 Out of Scope](#42-out-of-scope) +- [5. Functional Requirements](#5-functional-requirements) + - [5.1 Topic & Event-Type Definition](#51-topic--event-type-definition) + - [5.2 Producer Path (Ingest)](#52-producer-path-ingest) + - [5.3 Consumer Path (Delivery)](#53-consumer-path-delivery) + - [5.4 Consumer Group Registry](#54-consumer-group-registry) + - [5.5 Storage Backend Plugin System](#55-storage-backend-plugin-system) + - [5.6 Authorization](#56-authorization) + - [5.7 Error Codes](#57-error-codes) +- [6. Non-Functional Requirements](#6-non-functional-requirements) + - [6.1 Module-Specific NFRs](#61-module-specific-nfrs) + - [6.2 NFR Exclusions](#62-nfr-exclusions) +- [7. Public Library Interfaces](#7-public-library-interfaces) + - [7.1 Public API Surface](#71-public-api-surface) + - [7.2 External Integration Contracts](#72-external-integration-contracts) +- [8. Use Cases](#8-use-cases) +- [9. Acceptance Criteria](#9-acceptance-criteria) +- [10. Dependencies](#10-dependencies) +- [11. Assumptions](#11-assumptions) +- [12. Risks](#12-risks) +- [13. Open Questions](#13-open-questions) +- [14. Traceability](#14-traceability) + + + + +## 1. Overview + +### 1.1 Purpose + +The Event Broker is a tenant-scoped event streaming primitive for Gears modules. It enables decoupled inter-module communication, real-time data streaming to subscribers, and reliable replay of historical events. Producers publish typed events to topics; consumers subscribe via streaming and receive events filtered by type, subject, and dynamic filter expressions (pluggable filter engines; the specific expression language is not fixed at the requirements level). On top of this primitive, modules build higher-level patterns — **notification fan-out and audit streams** — without each re-implementing fan-out, ordering, or replay. + +The broker is a platform-native module — it ships in-process with the Gears framework, integrates with the existing transactional outbox (`toolkit-db`), and consumes platform coordination primitives (`cluster` system module). It does NOT replace external messaging systems for inter-region streaming, but it does provide a self-contained Kafka-style event log for intra-platform communication without requiring an external broker (Kafka, NATS, etc.). + +### 1.2 Background / Problem Statement + +Gears modules currently couple either through direct SDK calls (synchronous, no replay, point-to-point) or through `toolkit-db`'s transactional outbox (one-shot delivery, no fan-out, no consumer groups, no historical query). Neither pattern fits the use cases that need: + +- **Multi-consumer fan-out**: many subscribers need the same event (audit, billing, analytics, search index, notifications). +- **Replayable history**: a new subscriber needs to backfill from a known offset, not just receive future events. +- **Decoupled lifecycle**: producers and consumers deploy independently and may not be running at the same time. +- **At-least-once delivery with idempotent processing**: the standard event-streaming contract. + +Today a module needing these properties must either bring up an external broker (Kafka/NATS), depend on a peer module that exposes events directly (tight coupling), or build a per-module fan-out mechanism. The Event Broker closes this gap as a platform primitive. + +### 1.3 Goals (Business Outcomes) + +- Provide a **single, opinionated event-streaming primitive** for every Gears module — eliminate per-module fan-out implementations and external-broker dependencies for intra-platform use cases. +- Standardize on **GTS-typed events** so producer / consumer contracts are explicit, validated, and discoverable via `types_registry`. +- Preserve **at-least-once delivery with idempotent producers and consumers** — the standard contract platform modules can rely on. +- Support **multiple deployment topologies** (standalone single-process, cluster with multiple ingest/delivery shards) without changing module-level code — wire format and SDK contract are identical. +- Allow **third-party storage backends** (Kafka, S3, custom) to plug in without modifying broker core, so deployment-specific scale or compliance needs can be met. + +### 1.4 Glossary + +| Term | Definition | +|------|------------| +| Topic | A named, partitioned event stream identified by a GTS topic identifier (`gts.cf.core.events.topic.v1~vendor.foo.v1`). Topic is the unit of subscription and the scope of partition / offset semantics. Topics are platform-scoped (globally unique by GTS). See [DESIGN.md §3.1 Topic Schema](DESIGN.md). | +| Partition | An independent ordered log within a topic. Partitioning serves two requirements — per-key event ordering and horizontal scale — rather than being a requirement itself. Per-partition order is total; cross-partition order is unspecified. The producer-facing partition input is `partition_key` when present and `tenant_id` otherwise; the broker derives the final topic partition and producers do not set a top-level partition. Partition count is fixed at topic creation. See [ADR-0002](ADR/0002-partition-selection.md). | +| Event | An immutable record in a `(topic, partition)` log, GTS-typed via its `event_type`. Carries `subject` + `subject_type` to identify the entity it concerns; its broker topic partition follows the partition input contract. See [DESIGN.md §3.1 Event Schema](DESIGN.md) and [ADR-0003 Event Schema](ADR/0003-event-schema.md). | +| Event Type | A GTS-typed registration record describing one kind of event: its parent topic, allowed subject types, and the per-type `data_schema` (a JSON Schema that validates `event.data` at ingest). See [DESIGN.md §3.1 Event Type Schema](DESIGN.md). | +| Subject Type | The GTS-typed kind of entity an event concerns (e.g., user, account, invoice). Enforced on two dimensions: authorization (per-principal) and schema (`event_type.allowed_subject_types`). | +| Producer | A client that publishes events. Three modes: chained (producer-id + previous + sequence chain), monotonic (producer-id + sequence), stateless (no producer-id). | +| Consumer Group | A logical set of cooperating consumer instances sharing partition assignments and a single committed offset per partition. Persistent broker resource, GTS-typed, with two creation paths (anonymous via `POST /v1/consumer-groups`, named via `types_registry`). | +| Subscription | An ephemeral session a consumer instance opens against a consumer group via `POST /v1/subscriptions`. Holds the partition assignment, declared `interests[]`, compiled filter handles, and streaming session state. Identified by `subscription_id`; lives in cache; expires on `session_timeout`. See [DESIGN.md §3.1 Subscription Schema](DESIGN.md). | +| Interest | A topic-anchored, typed-filter selection declared at JOIN per [ADR-0005](ADR/0005-subscription-filter-typing.md). One subscription carries 1–64 interests. Each interest has required `topic`, `tenant_id`, `types[]` plus paired-optional `expression_type` + `expression`. Multiple interests OR together. | +| FilterEngine | Plugin trait (`compile()` + `eval()`) for evaluating filter expressions over events. GTS-typed; base type `gts.cf.core.events.filter.v1~`; v1 built-in CEL engine. Resolved at JOIN via `ClientHub`. | +| FilterContext | Per-engine declaration of which event fields are visible to filter expressions. CEL engine v1 exposes the read-side event (`event.v1.schema.json` with `readOnly` fields populated; `writeOnly` `meta` stripped). | +| Offset | Backend-assigned, monotonic-per-`(topic, partition)` ordering key. Consumer-visible; the only sequence consumers paginate by. | +| Storage Backend | Pluggable persistence layer for events. Discovered via GTS instance registration and resolved via ClientHub. Built-in implementations: memory, postgres. Backend owns retention, compaction, and offset assignment. | +| Cluster Module | The platform-level `modules/system/cluster` system module providing KV-with-TTL, leader election, distributed locks, and service discovery. Consumed by the broker for coordination. | +| GTS | Global Type System — the platform's schema and instance registration system used for type identification, validation, and pattern matching in authorization. | + +### 1.5 Domain Model + +Before the functional requirements, this section establishes the conceptual entities the broker deals in and how they relate. It is the requirements-level view; the schema-level model (fields, persistence, GTS base types) lives in [DESIGN.md §3.1 Domain Model](DESIGN.md). + +1. **Topic** — A named, platform-scoped event stream identified by a GTS topic identifier and the unit of subscription. A topic fixes its **partition count** at creation and defines the scope within which event ordering and offsets are meaningful. It carries a backend `streaming` configuration block — retention horizon, compaction/consolidation policy, segment sizing, and similar — that is **opaque to the broker and owned and enforced by the topic's bound storage backend**; the broker performs no retention or consolidation of its own. + +2. **Event Type** — A versioned contract, identified by a GTS identifier and bound to exactly one parent **topic**, that constrains the events published under it. It declares the set of **allowed subject types** and a **payload schema** (`data_schema`) against which each event's payload is validated at ingest. Event types are **immutable** in v1: changing the contract requires a new GTS identifier. + +3. **Event** — An **immutable**, append-only record of something that occurred, classified by its **event type**. Each event carries a client-supplied identifier, the **subject** and **subject type** identifying the entity it concerns, its owning **tenant**, an occurrence **timestamp**, and a typed **payload** whose shape is governed by the event type's schema. An event's **partition is derived by the broker** (by default from the tenant) and is never set by the producer. Once accepted, an event is never modified, re-ordered, or re-numbered. + +4. **Partition** — An independent, totally-ordered log within a topic. Ordering is **total within** a single partition and **unspecified across** partitions. The partition count is fixed at topic creation. Partitioning determines the granularity of ordering, of parallel consumption, and of offset tracking. + +5. **Subscription** — An **ephemeral** session opened by a single consumer instance against a consumer group. It holds the **partition assignment** granted to that instance and the consumer's declared **interests** — the topics, event types, and optional filter expression it wishes to receive. A subscription exists only for the lifetime of the consuming session and expires on timeout; it never outlives the consumer process. + +6. **Consumer Group** — A named, **persistent** grouping of cooperating consumer instances that together consume a set of topics. The group is the unit across which **partitions are distributed** (a partition is processed by at most one member at a time) and against which delivery progress is committed. Membership is dynamic; the group's committed position survives individual member churn. + +7. **Offset / Cursor** — The **offset** is a backend-assigned, monotonic position within a `(topic, partition)` log; it is the only ordering coordinate consumers observe. The **cursor** is the group-scoped ephemeral session position set by SEEK. The cursor advances **forward-only** during streaming and may be repositioned explicitly for replay via SEEK (`POST /v1/subscriptions/{id}:seek`). + +8. **Storage Backend** — The pluggable persistence layer bound to a topic. It durably stores events, **assigns their offsets**, and **owns retention, compaction/consolidation, and deletion** as directed by the topic's `streaming` configuration. Built-in backends are provided (in-memory for dev/test, PostgreSQL for production); third-party backends register via GTS without modifying broker core. + +**Relationships**: + +``` +Topic 1 ──* Event Type (a topic defines its event types) +Topic 1 ──* Partition (fixed count, set at creation) +Event Type 1 ──* Event (an event is an instance of one type) +Partition 1 ──* Event (each event lands in exactly one partition) +Topic * ──1 Storage Backend (a topic is bound to one backend; the backend owns retention/consolidation) +Consumer Group 1 ──* Subscription (members) +Consumer Group 1 ──* Cursor (one committed cursor per assigned partition) +Subscription * ──* Partition (a subscription is assigned partitions to consume) +``` + +**Invariants**: events are immutable and append-only; an event's payload shape is governed by its event type and therefore varies across types; ordering guarantees hold **per partition only**; a tenant's events share a partition by default; retention and consolidation are enforced by the **storage backend**, not the broker. + +## 2. Actors + +> **Note**: Stakeholder needs are managed at project/task level by steering committee. Document **actors** (users, systems) that interact with this module. + +### 2.1 Human Actors + +#### Platform Operator + +**ID**: `cpt-cf-evbk-actor-platform-operator` + +- **Role**: Configures broker deployment topology (standalone vs cluster), bound storage backend instances per topic, and operational policies (stream connection caps, group caps, retention via backend config). +- **Needs**: Operate broker as a managed platform service; observe per-topic / per-group lag; scale delivery shards horizontally; rebind topics to different backend instances on infrastructure changes. + +#### Module Developer (Producer Side) + +**ID**: `cpt-cf-evbk-actor-module-dev-producer` + +- **Role**: Authors a Gears module that publishes events. Registers topic + event-type instances in `types_registry` and/or uses the producer SDK to enqueue events transactionally with business writes. Registration (`define`) and publishing (`produce`) are independently grantable authorization actions (see §5.6) — the module that registers a type need not be the module that publishes it. +- **Needs**: Transactional publish (events durable iff business commit succeeds); chain-of-custody dedup so retries don't double-publish; explicit error semantics on chain breaks; minimal coupling to broker internals. + +#### Module Developer (Consumer Side) + +**ID**: `cpt-cf-evbk-actor-module-dev-consumer` + +- **Role**: Authors a Gears module that consumes events. Joins a consumer group, declares per-member topic / filter set, polls and acknowledges events; handles topology changes and re-JOIN semantics. +- **Needs**: At-least-once delivery; per-member filters (no canonical group filter to fight); a clear recovery contract distinguishing shard shutdown from subscription loss, with re-JOIN semantics; ability to seek for replay. + +### 2.2 System Actors + +#### `toolkit-db` (Outbox) + +**ID**: `cpt-cf-evbk-actor-modkit-db` + +- **Role**: Provides the transactional outbox the producer SDK and ingest service both build on. Owns the four-stage outbox pipeline (enqueue → sequencer → processor → vacuum). The broker contributes nothing to the outbox library; it just uses it. + +#### Cluster System Module + +**ID**: `cpt-cf-evbk-actor-cluster` + +- **Role**: Provides `ClusterCacheV1` (KV with TTL, CAS, watch), `LeaderElectionV1`, `DistributedLockV1`, and `ServiceDiscoveryV1`. The broker uses these primitives for subscription / group state caching, group-rebalance locks, Reaper singleton election, and shard discovery / liveness. + +#### `authz-resolver` + +**ID**: `cpt-cf-evbk-actor-authz-resolver` + +- **Role**: Provides the `PolicyEnforcer` PEP for per-(resource, action) authorization. The broker delegates all authorization decisions to this framework — no broker-local authz rules. AccessScope constraints from PEP are AND-merged into broker queries. + +#### `tenant-resolver` + +**ID**: `cpt-cf-evbk-actor-tenant-resolver` + +- **Role**: Resolves tenant identities and hierarchy. Provides `ROOT_TENANT_ID` for inter-service traffic. The broker reads tenant context from `SecurityContext` set by the platform's authentication layer. + +#### `types-registry` + +**ID**: `cpt-cf-evbk-actor-types-registry` + +- **Role**: Stores GTS schema and instance registrations. The broker uses it to validate topic / event-type / consumer-group GTS identifiers; to resolve the event-type `data_schema` against which event payloads are validated at ingest; reads named consumer-group instances at startup to upsert `evbk_consumer_group` rows; resolves storage backend type instances for ClientHub-based backend resolution. + +#### Storage Backend Plugin + +**ID**: `cpt-cf-evbk-actor-storage-backend` + +- **Role**: Persists events. Implements the `StorageBackend` async trait (`persist`, `query`, `truncate`, `segments`). Owns retention, compaction, offset assignment. Built-in plugins: memory, postgres. Third-party plugins (Kafka, S3) plug in via GTS type registration without modifying broker core. + +## 3. Operational Concept & Environment + +> **Note**: Project-wide runtime, OS, architecture, lifecycle policy, and integration patterns defined in root PRD. Document only module-specific deviations here. + +### 3.1 Module-Specific Environment Constraints + +The Event Broker follows standard Gears module conventions. Module-specific deployment notes: + +- **Two deployment topologies** are first-class: **Standalone** (single process; in-process `cluster` provider; one ingest + one delivery in-process) and **Cluster** (separate ingest / delivery / dispatcher processes, scaled independently, coordinating via the platform `cluster` module backed by Redis / etcd / NATS). +- **Per-module DB schema invariant**: in standalone mode, the producer module's outbox and the broker module's ingest outbox live in distinct DB schemas (one per Gears module), so no table-name collision arises. +- **Two streaming-delivery deployment patterns** for ingest in cluster mode: **hetero** (every ingest serves every topic; load-balance across ingest set; default) and **sharded** (specific topic patterns pinned to specific ingest instances; opt-in for noisy-neighbor isolation or hardware affinity). + +## 4. Scope + +### 4.1 In Scope + +- REST API for produce (single + batch), consume (stream, ack, seek, leave), producer lifecycle, consumer-group lifecycle, topic introspection. +- Three producer modes (chained / monotonic / stateless) with broker-side chain-of-custody dedup at ingest. +- Long-poll consumption with per-partition append-only in-memory cache and iterator-driven notification fan-out. +- GTS-typed topics, event types, subject types, consumer groups; full GTS pattern wildcards in authorization. +- Storage backend plugin contract with built-in memory and postgres implementations; vendor-extensible via GTS instance registration. +- Per-(resource, action) authorization via the `authz-resolver` framework (4 resource types × 3 actions: `produce`, `consume`, `define`). +- Persistent consumer-group registry (`evbk_consumer_group`) with two exclusive provisioning paths (anonymous via REST, named via `types_registry`). +- Per-event size limit (64 KiB combined headers + payload); per-batch limit (100 events / 1 MiB). +- Cluster-mode failover with service-discovery as source of truth for delivery-shard liveness. +- Standalone and cluster deployment topologies; hetero and sharded ingest routing modes. +- RFC 9457 Problem Details for all error responses with stable GTS type identifiers. + +### 4.2 Out of Scope + +- Consumer-facing dead-letter queues — the broker provides none. A consumer that needs one creates its own dedicated dead-letter **topic** (single event type, `object`-typed payload) and republishes events it cannot process. (Distinct from the ingest-side *outbox dead-letter* that records terminal `backend.persist` failures for operators — see DESIGN §3.7.) +- Webhooks / outbound HTTP push delivery — the added load, authorization, and retry semantics belong in a separate component adjacent to the broker, not in the broker itself. +- Event-type schema evolution (forward / backward compatibility, inter-version casting). v1 treats event types as immutable; schema changes require a new GTS event-type identifier. +- Quotas and rate limiting. Limits **must** exist before broad production exposure — scoped **per topic, per producer, and per consumer** (produce rate, poll rate, active-subscription and resource-creation caps, storage quotas) — but the full scheme needs dedicated design and is deferred to the post-MVP **Quotas & Rate Limiting** feature (see §12 Risks). Interim per-tenant safety-floor caps on resource-creation endpoints are tracked now (see §6.2, `cpt-cf-evbk-nfr-tenant-rate-caps`). +- Topic soft-delete with grace-period serving. +- Backend-failure backpressure (ingest auto-marking topics as failed and refusing produces). +- DB-restore resync tooling (CLI to realign producer / consumer state after broker DB restore). +- Finalized per-language SDK designs (concrete Rust/Go/etc. trait shapes, error enums, retry and reconnection policies). This PRD/DESIGN specifies the wire contract that any SDK must honour; each language SDK's internal design is produced during implementation, not here. +- Broker admin / debug endpoints (replay from timestamp, browse last N events on a partition). +- Testing infrastructure design (cluster-mode integration test harness, two-outbox pipeline test harness, time-scaled test mode) — separate design. +- Cooperative cross-tenant consumption on anonymous groups (the future `shared_with` mechanism). + +## 5. Functional Requirements + +> **Testing strategy**: All requirements verified via automated tests (unit, integration, e2e) targeting 90%+ code coverage unless otherwise specified. Document verification method only for non-test approaches (analysis, inspection, demonstration). + +### 5.1 Topic & Event-Type Definition + +#### Topic Registration + +- [ ] `p1` - **ID**: `cpt-cf-evbk-fr-topic-registration` + +The system MUST support topic registration through the Type Registry: topics are authored declaratively (e.g. in YAML) and owned by `types_registry` as GTS instances; the broker consumes the registered instances at startup (it does not parse declarative config itself). A registered topic MUST have a globally-unique GTS identifier (`gts.cf.core.events.topic.v1~....v1`), a fixed partition count (no default — operator must declare), and a `streaming` configuration block validated against the chosen storage backend's `config_schema`. + +- **Rationale**: Topics are platform-scoped resources; registration is identity-and-ownership state that must be unambiguous across deployments. +- **Actors**: `cpt-cf-evbk-actor-platform-operator`, `cpt-cf-evbk-actor-module-dev-producer` + +#### Event-Type Registration + +- [ ] `p1` - **ID**: `cpt-cf-evbk-fr-event-type-registration` + +The system MUST support event-type registration via `types_registry`. Each event type MUST have a GTS identifier, a parent topic, an `allowed_subject_types` list (GTS patterns; wildcards permitted), and a JSON Schema for the `data` payload. Event types are immutable in v1 — schema changes require a new GTS identifier. Where a publish or registration references a concrete type, the broker MUST require a fully-qualified GTS identifier (carrying the version) and reject an under-qualified reference; version-granularity and resolution semantics (major/minor) are owned by GTS / the Type Registry, not the broker. + +- **Rationale**: Event-type schemas are part of the contract producers and consumers depend on; immutability ensures consumers don't break under producer changes. +- **Actors**: `cpt-cf-evbk-actor-module-dev-producer`, `cpt-cf-evbk-actor-types-registry` + +#### Topic Introspection + +- [ ] `p1` - **ID**: `cpt-cf-evbk-fr-topic-introspection` + +The system MUST expose read-only REST endpoints for listing topics, listing event types, retrieving a single topic's metadata (including partition count and bound backend), and retrieving a single event type's schema. Read access is gated by the `topic:consume` PEP check. + +- **Rationale**: Producers and SDKs may need topic metadata for local broker-partition hints or batching, consumers need event-type schemas to validate, and tooling needs discovery. +- **Actors**: `cpt-cf-evbk-actor-module-dev-producer`, `cpt-cf-evbk-actor-module-dev-consumer` + +### 5.2 Producer Path (Ingest) + +#### Single-Event Publish + +- [ ] `p1` - **ID**: `cpt-cf-evbk-fr-publish-single` + +The system MUST accept single-event publish requests. The event MUST carry `id` (UUID, client-provided), `type`, `topic`, `subject`, `subject_type`, `tenant_id`, and `data`; the partition is broker-derived, not client-set. Publish MUST be **accepted asynchronously and durably** — the broker acknowledges once the event is durably recorded in the ingest outbox, with persistence to the storage backend completing asynchronously under an at-least-once guarantee. A synchronous mode MUST be available for callers needing read-after-write, acknowledging only after backend persistence completes. Wire endpoint, status codes, and headers are defined in DESIGN §3.3. + +- **Rationale**: Standard event-streaming publish primitive with explicit at-least-once semantics. +- **Actors**: `cpt-cf-evbk-actor-module-dev-producer` + +#### Batch Publish + +- [ ] `p1` - **ID**: `cpt-cf-evbk-fr-publish-batch` + +The system MUST accept batch publish via `POST /v1/events:batch`. All events in a batch MUST resolve to the same `(topic, partition)` — i.e. share the same topic and the same partitioning input (default: same `tenant`). Hard limits: 100 events per batch, 1 MiB total payload, 64 KiB per event. The batch is atomic — if any event fails dedup validation, the entire batch is rejected. + +- **Rationale**: High-throughput producers benefit from batching; same-(topic, partition) requirement keeps batching aligned with the dispatcher's routing model. +- **Actors**: `cpt-cf-evbk-actor-module-dev-producer` + +#### Producer Modes + +- [ ] `p1` - **ID**: `cpt-cf-evbk-fr-producer-modes` + +The system MUST recognize three producer modes inferred from the fields set on each event: **chained** (`producer_id` + `previous` + `sequence`), **monotonic** (`producer_id` + `sequence` only), **stateless** (none). Chained mode MUST verify `event.previous == state.last_sequence` AND `event.sequence > state.last_sequence` against `evbk_producer_state`; mismatched chains MUST surface as `412 SequenceViolation`. Monotonic mode MUST verify only `event.sequence > state.last_sequence`. Stateless mode MUST do no broker-side dedup. On `412 SequenceViolation` there are two cases. **(1) Transient async retry / duplicate** — resolved automatically with no human involvement: a duplicate is treated as success, and a benign reorder is re-driven from the broker's cursor. **(2) Genuine divergence** — e.g. the producer's local DB was restored from backup and its sequence regressed — which cannot be reconciled silently and MUST surface for **operator acknowledgement**; the operator reconciles against `GET /v1/producers/{producer_id}/cursors` (resume from `last_sequence + 1`) or rotates to a fresh `producer_id` to start a new chain. + +- **Rationale**: Different producer profiles need different dedup guarantees; chain-of-custody is the broker-side primitive that supports all three without an event.id index. +- **Actors**: `cpt-cf-evbk-actor-module-dev-producer` + +#### Producer Registration + +- [ ] `p1` - **ID**: `cpt-cf-evbk-fr-producer-registration` + +The system MUST expose `POST /v1/producers` returning a server-issued `id` (UUID) bound to the calling principal. Subsequent `POST /v1/events` requests with this `Producer-Id` header MUST be accepted only when the call's principal matches the registered owner; mismatch MUST surface as `403 Forbidden`. + +- **Rationale**: Prevents unauthenticated `Producer-Id` claiming and same-tenant ordering chaos. +- **Actors**: `cpt-cf-evbk-actor-module-dev-producer` + +#### Producer Cursor Recovery + +- [ ] `p1` - **ID**: `cpt-cf-evbk-fr-producer-cursors` + +The system MUST expose `GET /v1/producers/{producer_id}/cursors` returning the broker's known `last_sequence` per `(topic, partition)` for the registered producer. Producers MUST use this for desync recovery (DB restore, restart without persistent state, suspected divergence). + +- **Rationale**: Recovery from chain-state divergence requires producer to read broker's authoritative view. +- **Actors**: `cpt-cf-evbk-actor-module-dev-producer` + +### 5.3 Consumer Path (Delivery) + +#### Subscription Creation (JOIN) + +- [ ] `p1` - **ID**: `cpt-cf-evbk-fr-subscription-join` + +The system MUST accept `POST /v1/subscriptions` (JOIN) with body containing `consumer_group` (GTS identifier, required), `client_agent` (RFC 9110 User-Agent grammar, required, per ADR-0004), `interests[]` (≥1, ≤64 entries), and `session_timeout`. Each entry in `interests[]` is a topic-anchored typed-filter selection per [ADR/0005-subscription-filter-typing.md](ADR/0005-subscription-filter-typing.md), with three required fields (`topic`, `tenant_id`, `types`) and two paired-optional fields (`expression_type`, `expression`). The system MUST validate the consumer group exists; for each interest, validate topic existence + topic-level `consume` authz + per-tenant authz + type-pattern syntax + topic-scoped pattern resolution (per-name latest + minor-version-omitted) + per-type authz + (if paired-optional fields supplied) engine resolution + expression compilation. Failed JOIN MUST leave no broker state. On success, the broker computes the topic set as the union of `interest.topic` values, runs the rebalance algorithm, and returns `subscription_id` + `assigned[]` (topic, partition pairs) + `topology_version`. Subject to the per-tenant JOIN rate cap (`cpt-cf-evbk-nfr-tenant-rate-caps`). + +- **Rationale**: The JOIN is the contract entry point for consumers; authorization, group identity, partition assignment, and filter-engine compilation all converge here. The interests model gives unambiguous topic-anchored declarations and the paired-optional filter allows the common "match topic+tenant+types" case to omit expressions entirely. +- **Actors**: `cpt-cf-evbk-actor-module-dev-consumer` + +#### Typed Filter Expressions + +- [ ] `p1` - **ID**: `cpt-cf-evbk-fr-filter-expression` + +The system MUST support typed filter expressions as a paired-optional pair (`expression_type`, `expression`) on each interest, where `expression_type` is a full GTS identifier extending the base type `gts.cf.core.events.filter.v1~`. The v1 deliverable MUST include a built-in CEL engine (`gts.cf.core.events.filter.v1~cf.core.expression.cel.v1`). Additional engines (starlark, rego, vendor-custom) MUST be pluggable via the same GTS-typed registry pattern used for storage backends, without breaking the wire. The engine trait, filter context, and event-field visibility are defined in DESIGN / ADR-0005. + +- **Rationale**: Typed filters open the door to additional expression languages without breaking the wire; GTS-typed engines are consistent with how every other plugin in the platform is identified. +- **Actors**: `cpt-cf-evbk-actor-module-dev-consumer` + +#### Long-Poll Consumption + +- [ ] `p1` - **ID**: `cpt-cf-evbk-fr-streaming-delivery` + +The system MUST serve `GET /v1/events:stream?subscription_id={uuid}` as a long-lived `multipart/mixed` stream that emits one event per multipart part. The broker MUST emit `heartbeat` frames at a configurable cadence (default 5 s) on idle subscriptions. The response MUST include `topology` frames whenever assignment / topology version changes mid-stream. The stream MUST use a per-`(topic, partition)` in-memory append-only cache + iterator pattern to avoid per-group backend re-queries on event publish. + +- **Rationale**: Streaming `multipart/mixed` is the only consumption surface in MVP for server-to-server consumers; per-partition cache + iterators is the design center for fan-out efficiency. +- **Actors**: `cpt-cf-evbk-actor-module-dev-consumer` + +#### Seek (Cursor Advance) + +- [ ] `p1` - **ID**: `cpt-cf-evbk-fr-ack-seek` + +The system MUST support `POST /v1/subscriptions/{id}:seek` (advance cursor via SEEK, sets `cursor.offset` per `(topic, partition)`; forward-only during streaming via `MAX(current, requested)`; unrestricted before streaming opens). Cursor state MUST be group-scoped (shared across all subscriptions in the group), held in the cluster cache (no SQL `evbk_cursor` table). + +- **Rationale**: Replay and cursor advance require seek; cursor's lifetime should match the group's, not any specific subscription. +- **Actors**: `cpt-cf-evbk-actor-module-dev-consumer` + +#### Subscription Termination Recovery + +- [ ] `p1` - **ID**: `cpt-cf-evbk-fr-subscription-recovery` + +The system MUST distinguish graceful delivery-shard shutdown (in-flight streams drained) from ungraceful subscription loss, signalling each distinctly to the consumer. The SDK contract MUST require re-JOIN with the original `consumer_group` / `topics` / filters on either signal — the group cursor preserves position across re-JOIN when the cache survives. The specific status codes are defined in DESIGN §3.3. + +- **Rationale**: Shard ownership migration is a routine operational event; clean, normative recovery semantics keep consumer code simple. +- **Actors**: `cpt-cf-evbk-actor-module-dev-consumer`, `cpt-cf-evbk-actor-cluster` + +### 5.4 Consumer Group Registry + +#### Anonymous Group Creation + +- [ ] `p1` - **ID**: `cpt-cf-evbk-fr-anonymous-group-create` + +The system MUST accept `POST /v1/consumer-groups` with a UUID-backed GTS identifier (`gts.cf.core.events.consumer_group.v1~{uuid}`). The row's `tenant_id` and `owner_principal` MUST come from `SecurityContext` and be non-overridable. Named (vendor-namespaced) identifiers MUST be rejected with `400 NamedGroupRequiresRegistry`. + +- **Rationale**: Anonymous groups are caller-driven, tenant-private; this endpoint is the only path. Named groups have a separate, authority-controlled path. +- **Actors**: `cpt-cf-evbk-actor-module-dev-consumer` + +#### Named Group Provisioning + +- [ ] `p1` - **ID**: `cpt-cf-evbk-fr-named-group-provisioning` + +The system MUST read `types_registry` at startup for instances of `gts.cf.core.events.consumer_group.v1~` and upsert each into `evbk_consumer_group` with `kind='named'` and `owner_principal` set to the registering module's principal. The upsert MUST be idempotent — re-running startup MUST NOT change ownership of existing named groups. + +- **Rationale**: Named groups are platform-level resources; their lifecycle is tied to module registration, not REST. +- **Actors**: `cpt-cf-evbk-actor-platform-operator`, `cpt-cf-evbk-actor-types-registry` + +#### Group JOIN Authorization + +- [ ] `p1` - **ID**: `cpt-cf-evbk-fr-group-join-authz` + +The system MUST enforce two distinct authorization paths at JOIN: **anonymous groups** require caller's `tenant_id` to equal the row's `owner_tenant_id` (`403 ConsumerGroupNotOwned` otherwise); **named groups** require an explicit `:consume` PEP grant on the concrete GTS instance via `authz-resolver`. + +- **Rationale**: Anonymous groups are tenant-private by construction; named groups are the legitimate cross-tenant cooperative path gated by explicit grants. +- **Actors**: `cpt-cf-evbk-actor-authz-resolver` + +### 5.5 Storage Backend Plugin System + +#### Backend Trait Contract + +- [ ] `p1` - **ID**: `cpt-cf-evbk-fr-backend-trait` + +The system MUST define a `StorageBackend` async trait with four methods — `persist`, `query`, `truncate`, and `segments`. `persist` MUST NOT return offsets inline — the backend assigns offsets natively and consumers learn them via `query`. Method signatures, arguments, and error types are defined in DESIGN §3.2. + +- **Rationale**: Minimal trait surface — backend owns storage and offset assignment; broker owns dedup, ordering (via outbox), and notification. +- **Actors**: `cpt-cf-evbk-actor-storage-backend` + +#### Built-in Backends + +- [ ] `p1` - **ID**: `cpt-cf-evbk-fr-builtin-backends` + +The system MUST ship two built-in storage backend instances: `gts.cf.core.events.backend.v1~cf.core.backend.memory.v1` (in-memory, dev/test only, no persistence across restarts) and `gts.cf.core.events.backend.v1~cf.core.backend.postgres.v1` (SeaORM-based, multi-SQL: PostgreSQL primary, also MySQL / SQLite, production default). + +- **Rationale**: Memory backend supports development and testing; postgres backend is the production default for self-contained deployments. +- **Actors**: `cpt-cf-evbk-actor-platform-operator` + +#### Third-Party Backend Extension + +- [ ] `p2` - **ID**: `cpt-cf-evbk-fr-third-party-backends` + +The system MUST allow third-party storage backend plugins (e.g., Kafka, S3, custom) to register via GTS instance registration without modifying broker core. The plugin MUST implement the `StorageBackend` trait and its GTS type MUST extend `gts.cf.core.events.backend.v1~`. ClientHub MUST resolve the bound instance per-topic at runtime. + +- **Rationale**: Deployment-specific scale or compliance needs (Kafka for cross-region, S3 for cold archival) shouldn't require forking the broker. +- **Actors**: `cpt-cf-evbk-actor-platform-operator` + +### 5.6 Authorization + +#### Per-Resource Authorization + +- [ ] `p1` - **ID**: `cpt-cf-evbk-fr-authorization` + +The system MUST delegate all authorization decisions to the `authz-resolver` framework via `PolicyEnforcer::access_scope_with(...)`. The broker MUST define four resource types — `gts.cf.core.events.topic.v1~`, `gts.cf.core.events.event_type.v1~`, `gts.cf.core.events.subject_type.v1~`, `gts.cf.core.events.consumer_group.v1~` — each with three actions (`produce`, `consume`, `define`). Permission grammar MUST be `:` with full GTS wildcard support. + +- **Rationale**: Standardized authorization via the platform PEP; per-resource granularity prevents the coarse-permission failure mode where any service with `produce` could write to any topic. +- **Actors**: `cpt-cf-evbk-actor-authz-resolver` + +#### Subject-Type Dual Enforcement + +- [ ] `p1` - **ID**: `cpt-cf-evbk-fr-subject-type-dual-enforcement` + +The system MUST enforce subject-type validity on two independent dimensions: **authorization** (`subject_type:produce` / `:consume` PEP check at runtime) and **schema** (publish-time validation that `event.subject_type` is in the event-type's `allowed_subject_types`, which MAY contain GTS wildcards). + +- **Rationale**: The two checks cover different concerns — capability vs correctness — and cannot substitute for each other. +- **Actors**: `cpt-cf-evbk-actor-authz-resolver` + +### 5.7 Error Codes + +#### RFC 9457 Problem Details + +- [ ] `p1` - **ID**: `cpt-cf-evbk-fr-rfc9457-errors` + +All error responses MUST follow RFC 9457 Problem Details (`application/problem+json`) with stable GTS type identifiers under the `gts.cf.core.errors.err.v1~cf.core...v1` namespace. Each error MUST carry `type`, `title`, `status`, `detail`, `instance`, optionally `trace_id` and `retry_after_seconds`. + +- **Rationale**: Consistent, machine-readable error format aligned with the platform's error model. +- **Actors**: All + +## 6. Non-Functional Requirements + +> **Global baselines**: Project-wide NFRs (performance, security, reliability, scalability) defined in root PRD and [guidelines/](../guidelines/). Document only module-specific NFRs here: **exclusions** from defaults or **standalone** requirements. +> +> **Testing strategy**: NFRs verified via automated benchmarks, security scans, and monitoring unless otherwise specified. + +### 6.1 Module-Specific NFRs + +#### Per-Event Size Limit + +- [ ] `p1` - **ID**: `cpt-cf-evbk-nfr-event-size` + +Per-event hard size limit: 64 KiB combined headers + payload. Enforced at ingest before validation. Larger events MUST be split or referenced (referenced-payload mechanism is post-MVP). + +#### Long-Poll Timeout + +- [ ] `p1` - **ID**: `cpt-cf-evbk-nfr-long-poll-timeout` + +Long-poll maximum timeout: 30 seconds. Consumers behind aggressive corporate proxies SHOULD request a lower timeout (e.g., 20 seconds) to keep responses well within proxy idle limits. + +#### Tenant Resource-Creation Safety Caps + +- [ ] `p1` - **ID**: `cpt-cf-evbk-nfr-tenant-rate-caps` + +Before the full post-MVP quotas feature exists, the broker MUST enforce interim per-tenant safety-floor caps on resource-creation endpoints: `POST /v1/consumer-groups`, `POST /v1/producers`, and `POST /v1/subscriptions`. These caps are abuse controls for resource creation only; they do not replace the deferred full quota model for produce rate, poll rate, active-subscription caps, or storage quotas. + +#### Group Cap Per Delivery Instance + +- [ ] `p1` - **ID**: `cpt-cf-evbk-nfr-group-cap` + +Maximum consumer groups owned per delivery instance: 1000 (default, configurable per deployment). When all delivery instances are at cap, JOIN MUST return `503` with `Retry-After` (operator's signal to scale out). + +#### Cluster Mode Failover Bound + +- [ ] `p1` - **ID**: `cpt-cf-evbk-nfr-failover-bound` + +Convergence on a delivery instance's death (cluster-mode failover) MUST be bounded by the platform `cluster` module's service-discovery heartbeat-loss detection — typically 10–30 seconds. + +#### Throughput Efficiency + +- [ ] `p1` - **ID**: `cpt-cf-evbk-nfr-throughput-efficiency` + +Absolute throughput is set by the chosen storage backend's transaction ceiling, which the broker does not own. The broker-level requirement is therefore stated as **efficiency relative to that ceiling**, not as an absolute events/sec figure: + +- **Per-event sustained throughput** SHOULD preserve at least `η = 0.4` of the backend's per-`(topic, partition)` transaction rate (the residual is ingest-outbox and dedup overhead). +- **Batch publish** SHOULD coalesce at least `B = 20` events per backend transaction. + +These targets are measured under stated conditions: small payloads (≈1 KiB, not the 64 KiB ceiling), per single `(topic, partition)`, and at the **sustained** rate. Sustained throughput is distinct from the **accept** rate: the asynchronous publish path acknowledges at ingest-outbox-durability speed (higher, burst-absorbing), while sustained throughput is bounded by backend persistence; accept exceeding sustained grows outbox lag and MUST be bounded by lag, not by refusing the accept. + +*Illustrative*: against a storage backend rated at ≥5,000 transactions/sec, these factors yield roughly **2,000 events/sec** single-event and **100,000 events/sec** batched. Actual figures depend on backend and topology; the derivation is in DESIGN §3.6. + +### 6.2 NFR Exclusions + +- **Per-tenant produce rate ceilings, per-subscription poll rate ceilings, per-tenant storage quotas** — deferred to a post-MVP "Quotas and rate limiting" feature. (Per-tenant safety-floor caps on resource-creation endpoints — `POST /v1/consumer-groups`, `POST /v1/producers`, `POST /v1/subscriptions` — are tracked separately as `cpt-cf-evbk-nfr-tenant-rate-caps` and land via the BACKLOG.md §6.1 work.) +- **Absolute events/sec throughput targets** — superseded by `cpt-cf-evbk-nfr-throughput-efficiency`, which states throughput as backend-relative efficiency rather than an absolute figure (absolute numbers depend on the chosen backend and topology). +- **Specific p99 publish / poll latency targets** — publish-accept latency is bounded by ingest-outbox durability (a local write), but end-to-end persist/poll latency depends on the chosen backend and topology, so no absolute p99 is set at the broker level. +- **Dynamic type-pattern refresh in subscriptions** — newly-registered event types matching an active subscription's patterns don't auto-extend the resolved set; consumer re-JOINs. Future `dynamic_types_refresh` flag, post-MVP. Per ADR-0005 § More Information. +- **Second built-in filter engine** — CEL is the v1 default; starlark / rego / vendor-custom are plugins via the GTS-typed registry. Per ADR-0005. +- **Implicit derived-type coverage** (GTS spec §3.6 auto-matching) — v1 requires explicit patterns / full identifiers. Future opt-in. Per ADR-0005 § Acknowledged GTS Features. +- **Runtime-configurable filter limits** — `MAX_INTERESTS_PER_SUBSCRIPTION` / `MAX_TYPES_PER_INTEREST` / `MAX_EXPRESSION_LENGTH_BYTES` / `MAX_COMPILED_FILTER_BYTES` / `EVAL_TIMEOUT_MICROS` are compile-time constants in v1. Per ADR-0005. + +## 7. Public Library Interfaces + +### 7.1 Public API Surface + +The Event Broker exposes its capability via a versioned REST API. The full surface: + +``` +POST /v1/events # produce single event +POST /v1/events:batch # produce batch (atomic; same topic+partition) +GET /v1/events:stream?subscription_id=... # streaming consume + +POST /v1/producers # register producer (server-issued id) +GET /v1/producers/{producer_id}/cursors # read broker's last_sequence per (topic, partition) + +POST /v1/consumer-groups # register anonymous consumer group +GET /v1/consumer-groups/{id} # read consumer group registry record +GET /v1/consumer-groups # list groups visible to caller +DELETE /v1/consumer-groups/{id} # remove consumer group from registry + +POST /v1/subscriptions # JOIN (create subscription, claim group, get assignments) +POST /v1/subscriptions/{id}:seek # seek (set cursor.offset, forward-only during streaming) +DELETE /v1/subscriptions/{id} # leave (explicit teardown) +GET /v1/subscriptions # list active subscriptions for caller +GET /v1/subscriptions/{id} # read subscription state + +GET /v1/topics # list registered topics +GET /v1/topics/{id} # read topic metadata (partition count, bound backend) +GET /v1/event-types # list registered event types +GET /v1/event-types/{id} # read event-type schema and allowed subject types +``` + +### 7.2 External Integration Contracts + +**Producer SDK trait** — `cf-gears-event-broker-sdk` will export a `EventBrokerProducer` trait whose concrete implementation wraps the `toolkit-db` outbox so producers atomically commit business state and event-publish intent in one transaction. Trait shape, error types, retry policies, and reconnection semantics are implementation-phase deliverables. + +**Consumer SDK trait** — `cf-gears-event-broker-sdk` will export a `EventBrokerConsumer` trait covering JOIN / poll / seek / leave with normative behavior on `410 Gone` / `404 SubscriptionNotFound` / `412 SequenceViolation` / `409 PartitionNotAssigned` (re-JOIN, drop in-flight, etc.). Per-language SDK realization is implementation-phase work. + +**Storage Backend plugin contract** — third-party plugins implement `StorageBackend` and register their GTS type as an extension of `gts.cf.core.events.backend.v1~`. Resolution happens via ClientHub at runtime; no broker-core modification needed. + +## 8. Use Cases + +#### Audit Pipeline + +- [ ] `p1` - **ID**: `cpt-cf-evbk-usecase-audit-pipeline` + +**Actor**: `cpt-cf-evbk-actor-module-dev-consumer` + +**Preconditions**: +- A named consumer group `gts.cf.core.events.consumer_group.v1~vendor.audit.pipeline.v1` is registered in `types_registry`. +- The audit-pipeline principal holds `:consume` PEP grants on the relevant audit topics, event types, subject types, and the consumer group. + +**Main Flow**: +1. A platform module emits an audit event (e.g., `gts.cf.core.events.event_type.v1~vendor.audit.user_changed.v1`) on every privileged action. +2. The audit-pipeline module JOINs the named consumer group and streams. +3. The broker delivers events at-least-once; audit-pipeline processes idempotently into long-term storage. +4. Audit-pipeline seeks past processed events via SEEK (`POST /v1/subscriptions/{id}:seek`); broker advances `cursor.offset` for the group. + +**Ordering**: Because the default partition key is `tenant` (per [ADR-0002](ADR/0002-partition-selection.md)), all of a tenant's audit events land on one partition and are delivered in publish order — the pipeline observes a **per-tenant total order** of audit events without any dedicated single-partition topic. Cross-tenant order is unspecified, which is acceptable for audit (records are inspected within a tenant); a deployment needing one global ordered stream would use a single-partition audit topic. + +**Postconditions**: +- Each privileged action has a corresponding audit-storage record (at-least-once, idempotent), with per-tenant ordering preserved. + +**Alternative Flows**: +- **Cross-tenant audit**: multiple tenants' audit events flow through the same named group; per-event `tenant_id` is preserved for downstream filtering, and each tenant's events remain individually ordered (separate partitions). + +#### Real-Time Notification Fan-out + +- [ ] `p1` - **ID**: `cpt-cf-evbk-usecase-notification-fanout` + +**Actor**: `cpt-cf-evbk-actor-module-dev-consumer` + +**Preconditions**: +- A topic exists with a high-volume event stream (e.g., chat messages). +- Multiple downstream modules (notifications, search index, analytics) hold independent consumer groups. + +**Main Flow**: +1. The producer publishes events to the topic. +2. Each subscribed group's owning delivery shard receives notifications via the per-partition cache. +3. Iterators in each group's open stream wake, advance, apply per-member filters, and emit matching events as multipart parts. +4. Each consumer module processes independently and acks at its own pace. + +**Postconditions**: +- Every subscribed group sees every event matching its filter, at-least-once. +- Fan-out cost is O(active iterators) memory walks rather than O(groups) backend queries. + +**Alternative Flows**: +- **Group falls behind**: `evbk_cursor_lag` gauge surfaces the lagging group; operator alerts; group catches up via continued polling. + +#### Backfill on New Subscriber + +- [ ] `p1` - **ID**: `cpt-cf-evbk-usecase-backfill` + +**Actor**: `cpt-cf-evbk-actor-module-dev-consumer` + +**Preconditions**: +- A new analytics module is deploying with a fresh consumer group. +- The storage backend has a configurable `earliest_available_offset` per `(topic, partition)`. + +**Main Flow**: +1. The new module calls `POST /v1/consumer-groups` (anonymous group) or registers a named group via `types_registry`. +2. On JOIN, the broker assigns partitions and sets the cursor to `earliest_available_offset`. +3. The consumer pulls historical events via the streaming endpoint, processes idempotently, acks as it goes. +4. After catching up to the live tail, the consumer transitions to receiving live events from the per-partition cache. + +**Postconditions**: +- The new module has processed every available historical event and is now consuming live. + +**Alternative Flows**: +- **Backend retention horizon below acked target**: backend may have purged older events; consumer starts from the backend's current `earliest_available_offset` and the operator accepts the gap. + +#### Producer Restart with Persistent Chain State + +- [ ] `p2` - **ID**: `cpt-cf-evbk-usecase-producer-restart` + +**Actor**: `cpt-cf-evbk-actor-module-dev-producer` + +**Preconditions**: +- A producer module previously registered via `POST /v1/producers` and persisted `producer_id` + last-known `sequence` alongside its business data. + +**Main Flow**: +1. The producer module restarts and reads its persisted `producer_id` + `last_sequence` from local storage. +2. The producer resumes publishing with the next `sequence` after `last_sequence`. + +**Postconditions**: +- Publishing continues with no chain breaks; broker's `evbk_producer_state` advances normally. + +**Alternative Flows**: +- **Local DB restored from backup (sequence regressed)**: producer detects the divergence (e.g., business data has rows newer than `last_sequence` claims), calls `GET /v1/producers/{producer_id}/cursors` to read the broker's authoritative view; producer either reconciles its own state or registers a fresh `producer_id` via `POST /v1/producers` to start a new chain. + +## 9. Acceptance Criteria + +- All P1 functional requirements (5.1–5.7) implemented and covered by tests at the levels documented in DESIGN.md. +- All P1 NFRs (6.1) verifiable via integration tests or static configuration assertions. +- The DESIGN.md document is in canonical place (`modules/system/event-broker/docs/DESIGN.md`) and is the source of truth for technical decisions. +- The partition-selection ADR is in canonical place (`modules/system/event-broker/docs/ADR/0002-partition-selection.md`). +- Standalone deployment (single process, in-process cluster provider, in-memory backend) functions end-to-end against the producer + consumer SDKs. +- Cluster deployment (multi-process; persistent cluster provider; postgres backend) functions end-to-end with at least 2 ingest, 2 delivery, 1 dispatcher. +- All authorization decisions go through `authz-resolver`'s PEP — no broker-local authz rules. + +## 10. Dependencies + +| Dependency | Description | Criticality | +|------------|-------------|-------------| +| `toolkit-db` outbox | Transactional outbox foundation for both producer SDK and ingest pipeline (`cpt-cf-evbk-actor-modkit-db`). | p1 | +| `cluster` system module | KV-with-TTL, leader election, distributed locks, service discovery (`ClusterCacheV1`, `LeaderElectionV1`, `DistributedLockV1`, `ServiceDiscoveryV1`) — `cpt-cf-evbk-actor-cluster`. | p1 | +| `authz-resolver` | `PolicyEnforcer` PEP for all authorization decisions (`cpt-cf-evbk-actor-authz-resolver`). | p1 | +| `tenant-resolver` | Tenant identity, hierarchy resolution, `ROOT_TENANT_ID` (`cpt-cf-evbk-actor-tenant-resolver`). | p1 | +| `types-registry` | GTS schema and instance registration; named consumer-group provisioning at startup (`cpt-cf-evbk-actor-types-registry`). | p1 | +| `api_ingress` | REST API hosting at the platform's HTTP entry point. | p1 | +| `toolkit-security` | Bearer token authentication. | p1 | + +## 11. Assumptions + +- The platform's `cluster` system module is operational and provides KV-with-TTL, leader election, distributed locks, and service discovery primitives (verified during design). +- Consumers and producers are responsible for idempotent processing at their layer — the broker's at-least-once contract presumes this. +- `types_registry` is operational at broker startup so named consumer groups can be upserted on launch. +- Storage backend instances bound to topics remain operational; backend-failure backpressure is post-MVP. +- The platform's authentication layer populates `SecurityContext` reliably; the broker reads tenant + principal from it without further validation. + +## 12. Risks + +| Risk | Impact | Mitigation | +|------|--------|------------| +| Storage backend selection lock-in | A topic's bound backend cannot be changed without data migration tooling (post-MVP). Selecting an inappropriate backend at topic registration may require a full topic rebuild for migration. | Document backend characteristics and capacity ceilings clearly at registration time; restrict ad-hoc backend choices; prefer postgres unless an explicit scale or compliance constraint forces an alternative. Backend instance migration tooling is on the post-MVP roadmap. | +| Consumer-cursor durability under cache-only model | In standalone mode, broker runtime cursors die with the process; consumers may re-process from their chosen start position after restart unless they persist progress themselves. | Idempotent consumer processing is a normative SDK requirement (per the at-least-once contract). Consumers that need durable progress persist offsets in their own store; broker runtime cursor state is not the durable source of truth. | +| Hot-topic scaling in sharded ingest mode | A single hot topic pinned to a single ingest instance via `serves_topic_patterns` becomes a bottleneck and a noisy neighbor for any topics co-located with it. | Hetero ingest mode (default) load-balances all topics across all ingest instances. Sharded mode is opt-in for explicit isolation; operators choosing sharded mode are responsible for capacity planning per shard. Partition-sharded ingest (sub-topic level) is on the post-MVP roadmap. | +| Schema evolution gap | v1's "new GTS identifier per change" rule makes long-lived event logs operationally awkward without the post-MVP compatible-evolution feature. | Consumers use type-pattern wildcards (`gts.cf.core.events.event_type.v1~vendor.audit.*`) to subscribe to families of versions. Compatible schema evolution + inter-version casting is on the post-MVP roadmap. | +| Abuse / resource exhaustion without quotas | Without rate limits and storage quotas, a misbehaving or malicious tenant can flood ingest, exhaust backend storage, or starve consumers. The limits **must** be set before broad production exposure. | Interim per-tenant safety-floor caps on resource-creation endpoints (`cpt-cf-evbk-nfr-tenant-rate-caps`) bound the worst vector now. Full **Quotas & Rate Limiting** — scoped per topic / per producer / per consumer (produce rate, poll rate, active-subscription caps, storage quotas) — is the immediate post-MVP feature and needs dedicated design; tracked in BACKLOG.md. | + +## 13. Open Questions + +- **Outbox extension for partition-level scaling** (deferred): should `toolkit-db` outbox grow native partitioning so a topic with N partitions has N outbox sub-queues? Decision deferred to post-MVP, gated by the outbox library's roadmap. +- **Long-poll protocol revisit**: the per-partition cache + condvar-driven iterator model needs a focused pass after broader feedback closes — cache eviction policy, backfill semantics, fairness across waiters. +- **Tenant-scoped GTS namespaces** for consumer groups: not in MVP; future design may introduce per-tenant namespaces (e.g., `gts.cf.core.events.consumer_group.v1~tenant.{tenant_id}.foo.v1`). + +## 14. Traceability + +- DESIGN.md — technical realization of every requirement in §5; cross-references via section IDs (`cpt-cf-evbk-*`). +- ADR/0002-partition-selection.md — architectural decision behind partition selection and rationale. +- Future ADR extractions — follow-up design iterations for the remaining DESIGN.md §1.2 ADR-IDs (cluster-capabilities, streaming-delivery, topic-sharding, dispatcher, sequence-assignment, outbox-ingest). +- Future DECOMPOSITION.md — feature phasing and MVP boundaries; final design iteration in this sequence. diff --git a/gears/system/event-broker/docs/USE_CASES.md b/gears/system/event-broker/docs/USE_CASES.md new file mode 100644 index 000000000..6182c7109 --- /dev/null +++ b/gears/system/event-broker/docs/USE_CASES.md @@ -0,0 +1,293 @@ +# Event Broker — Consumer Use Cases & Flows + +This document captures consumer-side state machines, producer / consumer profiles, walked scenarios, and the v1 rebalance algorithm. It is a non-canonical extension to [DESIGN.md](DESIGN.md) — the canonical DESIGN template does not prescribe a section for this content; it is preserved as a separate file so DESIGN.md stays aligned with the canonical structure. + +**ID**: `cpt-cf-evbk-consumer-flows` + +The protocol is intentionally aligned with the **Confluent REST Proxy v2 / Strimzi Kafka Bridge** convention — the de-facto Kafka REST contract shared by Confluent REST Proxy, Strimzi Kafka Bridge (CNCF, Apache 2.0), Karapace (Apache 2.0), and Redpanda Pandaproxy. This document walks through the canonical consumer flows. + +## 1. Consumer State Machine + +``` + ┌──────────────────────┐ + │ No subscription │ ← initial / after explicit DELETE + └──────────┬───────────┘ + │ POST /v1/subscriptions { consumer_group, client_agent, interests: [{topic, tenant_id, types, ...}] } + │ ↳ broker triggers rebalance for the group + │ ↳ if other consumers are in the group, they receive a + │ topology_change wake-up on their next poll + ▼ + ┌──────────────────────────────────────────────┐ + │ Joined; assignments may be empty │ + │ - id, topology_version, │ + │ assignments: [{partition, offset}, ...] │ + └──────────┬───────────────────────────────────┘ + │ GET /v1/events:stream?subscription_id=... + ▼ + ┌──────────────────────────────────────────────┐ + │ Polling │ + │ Server holds: cursor.{offset, sent} per │ + │ (subscription_id, partition) │ + │ │ + │ Poll wakes on ANY of: │ + │ - event published to assigned partition │ + │ - topology change (rebalance happened) │ + │ - timeout │ + │ │ + │ Response always includes: │ + │ { items, topology_version, │ + │ assignments: [{partition, offset}, ...], │ + │ retry_after_seconds? } │ + └─┬──────────────────┬─────────────────────────┘ + │ │ + │ topology_version │ assignment unchanged + │ changed │ + ▼ ▼ + Re-align Continue polling + (assigned_ (process items, + partitions commit offsets, + shifted) poll again) +``` + +## 2. Producer Use Cases + +The producer flows we have explicitly accounted for: + +| # | Producer profile | Producer-Id strategy | Idempotency story | +|---|---|---|---| +| **P1** | Has DB + uses outbox | Persistent UUID stored alongside business data | Outbox tracks `last_sent_sequence` per `(topic, partition)`; transactionally atomic with business writes; exactly-once via outbox replay on retry | +| **P2** | Has DB, no outbox | Persistent UUID stored alongside business data | Application persists `last_sent_sequence` in own DB; idempotent producer protocol via Producer-Id header; reads broker's `last_sequence` on startup if local state is lost | +| **P3** | Stateless single instance | Ephemeral (fresh UUID per process startup) | New PID each restart; can never deduplicate across restarts; relies on consumer-side idempotent processing | +| **P4** | Stateless burst (lambda-like) | No `Producer-Id` header at all | Stateless mode — broker does no dedup; at-least-once delivery; consumer-side processing MUST be idempotent | +| **P5** | Multi-instance shared service | Each instance generates its OWN UUID at startup | No PID sharing — instances never collide; partition selection (key-hash) routes related events to consistent partitions | +| **P6** | Bulk backfill / replay | Either ephemeral PID or no PID (mass writes) | Application-managed dedup; out-of-order events tolerable for backfill; consumer must be idempotent | + +**Rule for P5**: Two producer instances MUST use distinct `producer_id`s — and since each instance calls `POST /v1/producers` independently and gets its own server-issued UUID bound to its principal, this is automatic. If two instances did somehow share a UUID (e.g., both restored from the same persisted blob without rotation), they'd race the chain — the unique constraint on `evbk_producer_state(producer_id, topic, partition)` serializes them, the loser sees `412 SequenceViolation`, and the recommended recovery is to register a fresh `producer_id` (no epoch needed). + +## 3. Consumer Use Cases + +| # | Consumer profile | Behavior | Recovery / scale | +|---|---|---|---| +| **C1** | Single, DB-backed, solo | Joins group with itself as sole member, gets all partitions, processes & commits in own DB transaction (broker's `cursor.offset` is a checkpoint, consumer's own DB is source of truth) | On restart: same Producer-Id, server recreates assignment if `session_timeout` expired or resumes if still active | +| **C2** | Single, stateless, solo | Joins group, polls, broker tracks only runtime cursor state | On crash: `session_timeout` expires; new instance joins → gets all partitions → re-initializes position from its chosen start point and may re-receive uncommitted events | +| **C3** | Group, multiple instances | Each instance creates its own subscription with same `consumer_group` | Broker rebalances on each JOIN/LEAVE/expiry; partitions distributed round-robin (sticky-Kafka deferred to v2) | +| **C4** | Group, planned upscale | New instance: `POST /v1/subscriptions` | Existing polls wake on topology change, return with new (smaller) `assignments`; new instance gets its share immediately | +| **C5** | Group, planned downscale | Departing instance: `DELETE /v1/subscriptions/{id}` | Remaining instances' polls wake; partitions reassigned to survivors via rebalance | +| **C6** | Group, crash downscale | No DELETE — `session_timeout` expires; Reaper detects and triggers rebalance | Same as C5 but delayed by `session_timeout` (default `PT30S`) | +| **C7** | Bulk historical replay | Join with a dedicated `consumer_group` (e.g., `replay-{job-id}`), then `POST /subscriptions/{id}:seek { "partition_positions": {"P":0} }` to seek to start, then poll | Standard subscription path; the dedicated group ensures replay traffic doesn't compete with primary consumers. Anonymous offset-based read is **out of scope for MVP** — see DESIGN.md §4.6/§4.8. | +| **C8** | Filtering-heavy | `types`, `subject_types`, `cel` filters declared at JOIN | Filters applied server-side before returning; reduces network traffic | +| **C8a** | Rolling deploy with filter or topic change | New version of consumer service deploys with different `types`/`cel` filter set, or expanded/reduced `topics` list | Per-member subscriptions: each member's JOIN succeeds regardless of differences from existing members. Partition assignments span the union of all members' topic lists; `(topic, partition)` is assigned only to a member that subscribes to that topic. During the transition, partitions migrate organically from v1 to v2 instances; the **single-consumer-per-partition invariant** holds at every moment. The cursor reflects whichever member processed events at the time of advancement; events at the migration boundary may be filtered differently than steady-state. Operator can hard-stop deploy (drain v1, deploy v2) for strict atomic semantics. | +| **C9** | Hot-standby (active/passive) | Multiple instances, same consumer_group, but only one instance polls at a time (the active one); standby is a placeholder subscription | Active fails → its `session_timeout` expires → standby's next poll returns the reassigned partitions | + +### Filter Behavior During Rollout + +The broker does **not** enforce canonical filters at the group level. Each member's JOIN declares its own filters (`types`, `subject_types`, `cel`) and topic list (`topics`). Different members in the same group can have different filters and different topic lists — this is intentional, to support gradual rolling deploys. + +**Invariants that ARE enforced**: +- Single-consumer-per-`(topic, partition)` at any moment: a partition is owned by exactly one active subscription +- A `(topic, partition)` is assigned only to a member whose `topics` list includes that `topic` +- Cursor `(consumer_group, topic, partition)` advances (via SEEK) based on the current owner's processing + +**Behavior on rolling deploy** (v1 with filters F1, v2 with filters F2 coexisting): +- Some `(topic, partition)` pairs owned by v1 instances → filtered with F1 +- Some pairs owned by v2 instances → filtered with F2 +- When a partition migrates from F1-owner to F2-owner via rebalance, the cursor stays where F1 left it; F2 starts reading from there +- Events that F1 rejected but F2 would accept may have been already scanned-past by F1 (tracked via `last_examined`); F2 will not see them +- This is **accepted behavior**: gradual rollouts trade strict filter consistency for zero-downtime evolution + +**For strict atomic semantics**: operators perform a hard-stop deploy — drain all v1 instances (k8s rollout, group session_timeout, or explicit DELETE), then start v2. During the brief gap, no consumer is processing; cursors are unchanged; v2 starts consuming from the exact position v1 left. + +**Why no canonical filter at the group level**: enforcing filter parity would require either rejecting v2's JOIN (forcing rename) or kicking out v1 instances on filter change (downtime). Per-member filtering supports both fast rolling deploys and operator-driven hard-stop semantics, with the trade-off documented and discoverable. + +## 4. Walked Scenarios + +**Scenario A — 1 partition, 2 instances (single-topic group):** + +``` +T0: Instance A: POST /v1/subscriptions {consumer_group: "gts.cf.core.events.consumer_group.v1~vendor.audit.v1", client_agent: "audit-svc/1.0", interests: [{topic: "T", tenant_id: ..., types: ["gts.cf.core.events.event.v1~*"]}]} (T has 1 partition) + Dispatcher: cache.put_if_absent("evbk.group.endpoint:{vendor.audit.v1}", thisInstance) + (claims ownership of group GTS ~vendor.audit.v1) + Broker: rebalance → A gets (T, 0) + Response 201: { id: A, topology_version: 1, + assignments: [ {topic:"T", partition:0, offset:0, last_examined:0} ] } + A: GET /v1/events:stream?subscription_id=A ← streams (T, 0) + +T1: Instance B: POST /v1/subscriptions {consumer_group: "gts.cf.core.events.consumer_group.v1~vendor.audit.v1", client_agent: "audit-svc/1.0", interests: [{topic: "T", tenant_id: ..., types: ["gts.cf.core.events.event.v1~*"]}]} + Dispatcher: cache.get("evbk.group.endpoint:{vendor.audit.v1}") → existing endpoint, forward there + Owning instance: rebalance → A keeps [(T,0)], B gets [] + Response 201: { id: B, topology_version: 2, + assignments: [], retry_after_seconds: 5 } + cluster.publish("evbk.group.{vendor.audit.v1}.topology", v=2) + +T2: A's stream wakes on topology v=2 + A re-reads its assignment: still [(T, 0)] + A returns 200: { topology_version: 2, + assignments: [{topic:"T", partition:0, offset:0, last_examined:0}], + batches: [] } + A SDK: assignment unchanged, resume polling + +T3: B is in retry-after loop (sleep 5s, or hold connection on topology channel) + +T4: A's session expires (crash or DELETE) + Reaper triggers rebalance → B gets (T, 0) + cluster.publish topology v=3 + +T5: B wakes, polls (B's offsets reflect A's last sought position — cursor is in cache, sticky) + B's response: { topology_version: 3, + assignments: [{topic:"T", partition:0, offset:, last_examined:}], + batches: [{topic:"T", partition:0, events:[ A's offset>]}] } + Events polled-but-not-sought-past by A are re-delivered to B (at-least-once) +``` + +**Scenario B — 8 partitions, 2 instances arriving back-to-back:** + +``` +T0: A: POST /v1/subscriptions {consumer_group: "gts.cf.core.events.consumer_group.v1~vendor.audit.v1", client_agent: "audit-svc/1.0", interests: [{topic: "T", tenant_id: ..., types: ["gts.cf.core.events.event.v1~*"]}]} (T has 8 partitions) + Dispatcher: claims ownership of G via cache.put_if_absent + Broker rebalance: A → [(T,0)..(T,7)] + Response 201: { id: A, topology_version: 1, + assignments: [ + {topic:"T", partition:0, offset:0, last_examined:0}, + ..., + {topic:"T", partition:7, offset:0, last_examined:0} + ] } + A: GET /v1/events:stream?subscription_id=A ← streams all 8 + +T1: B: POST /v1/subscriptions {consumer_group: "gts.cf.core.events.consumer_group.v1~vendor.audit.v1", client_agent: "audit-svc/1.0", interests: [{topic: "T", tenant_id: ..., types: ["gts.cf.core.events.event.v1~*"]}]} + Dispatcher: cache.get("evbk.group.endpoint:{vendor.audit.v1}") → owning instance, forward + Owning instance, rebalance under cluster.distributed_lock("evbk.group.{vendor.audit.v1}.rebalance"): + Validate B's topics list ["T"] is acceptable (per-member; broker doesn't enforce match) + B's filters apply to B alone (per-member; no canonical group filter) + Active subs (sorted by created_at): [A, B] + Round-robin split: A → [(T,0)..(T,3)], B → [(T,4)..(T,7)] + Update group_state in cache: + active_members[A].assigned = [(T,0)..(T,3)] + active_members[B].assigned = [(T,4)..(T,7)] + Initialize cursor cache entries for (G, T, 4..7) if missing + (no-op if entries already exist — cursors are group-keyed, sticky in cache) + cluster.publish topology v=2 + Response 201: { id: B, topology_version: 2, + assignments: [ {topic:"T", partition:4, offset:, ...}, + {topic:"T", partition:5, offset:, ...}, + {topic:"T", partition:6, offset:..., ...}, + {topic:"T", partition:7, offset:..., ...} ] } + +T2: A's stream wakes on topology v=2 + A re-reads its assignment: now [(T,0)..(T,3)] + A returns 200: { + topology_version: 2, + assignments: [ {topic:"T", partition:0, offset:, ...}, ..., + {topic:"T", partition:3, offset:, ...} ], + batches: [{topic:"T", partition:N, events:[...]} for any pending events on T,0..T,3] + } + A SDK: assignment shrunk → re-poll with awareness of new state + +T3: B polls + B: GET /v1/events:stream?subscription_id=B + Dispatcher: resolve B → consumer_group GTS; cache.get("evbk.group.endpoint:{vendor.audit.v1}") → owning instance, forward + Owning instance reads cursor[(G,T,4..7)].offset — sticky offsets preserved + B gets events from (T, 4..7) starting at those offsets + Includes anything A polled-but-didn't-seek-past (at-least-once redelivery) + +T4: B processes events, seeks past them via: + POST /v1/subscriptions/B:seek { "partition_positions": [{"topic":"T","partition":4,"value":130},{"topic":"T","partition":5,"value":78}] } +``` + +**Scenario C — Producer DB restored from backup (Producer-Id rotation):** + +``` +T0: Producer running with UUID PID_1 = "550e8400-e29b-41d4-a716-446655440000" + Producer's local DB: { producer_id: PID_1, last_sent_sequence[topic, 4] = 1000 } + Broker: last_sequence(PID_1, topic, 4) = 1000 + +T1: Producer DB restored from yesterday's backup + Producer's local DB: { producer_id: PID_1, last_sent_sequence[topic, 4] = 800 } ← stale + Broker still has: last_sequence(PID_1, topic, 4) = 1000 + +T2: Application detects stale state on startup + (e.g., business data has rows newer than last_sent_sequence claims, or a + sentinel row marks a known-good restore point that doesn't match) + +T3: Application generates new UUID PID_2 = "a1b2c3d4-..." + Persists alongside business data: + producer's local DB: { producer_id: PID_2, last_sent_sequence[topic, 4] = 0 } + +T4: Producer publishes with Producer-Id: PID_2 + Broker has no state for PID_2 → first publish creates state at sequence 1 + Producer continues normally with fresh PID + +T5: Reaper eventually deletes evbk_producer_state rows for PID_1 + after the topic's retention (no activity from PID_1 anymore) +``` + +This is the canonical reset flow — application detects stale state, rotates UUID, broker treats it as a brand-new producer. No explicit reset API needed. + +## 5. Rebalance Algorithm (v1) + +Round-robin per topic, sorted by `(created_at, id)` for determinism, **respecting per-member topic subscriptions**. Sticky-Kafka (minimize partition movement on rebalance) is deferred to v2 — contracts already carry `topology_version` so this is an internal change, not a contract change. + +**v1 thrash is acceptable.** Round-robin recomputes the assignment from scratch on every JOIN/LEAVE, which means nearly every partition can move on every membership change. For groups with many partitions and frequent membership churn, this is wasteful — but at-least-once delivery + idempotent consumers absorbs the cost: any work in-flight when a partition moves is simply re-delivered by the new owner. Sticky-Kafka assignment is the optimization that minimizes this and is captured in DESIGN.md §4.8 Future Developments. + +``` +rebalance(group G): # G is the GTS-typed consumer_group identifier + acquire cluster.distributed_lock("evbk.group.{G}.rebalance", ttl=10s) + try: + group_state = cache.get("evbk.group.{G}") + active_subs = group_state.active_members.values() + .filter(s => s.expires_at > now()) + .sorted_by(s => (s.created_at, s.id)) + + # Compute group's effective topic set: union of all members' topics + effective_topics = union(sub.topics for sub in active_subs) + + new_assignments = {} # (topic, partition) -> subscription_id + + # Round-robin per topic, considering only members that subscribe to it + for topic_T in effective_topics: + eligible = [sub for sub in active_subs if topic_T in sub.topics] + N = topic_T.partitions + S = len(eligible) + + if S == 0: continue # no member subscribes; partitions orphaned + + if S >= N: + for i in 0..N: new_assignments[(topic_T, i)] = eligible[i].id + else: + base, extra = N / S, N % S + cursor = 0 + for i in 0..S: + count = base + (1 if i < extra else 0) + for p in cursor..cursor+count: + new_assignments[(topic_T, p)] = eligible[i].id + cursor += count + + # Apply assignments to group_state.active_members + for sub_id in active_subs.map(s => s.id): + group_state.active_members[sub_id].assigned = [ + (topic, partition) for (topic, partition), assigned_id in new_assignments + if assigned_id == sub_id + ] + + # Initialize cursor cache entries for any (G, topic, partition) tuples newly assigned + # Existing offset values are preserved automatically (group-keyed, sticky in cache) + for (topic_T, partition) in new_assignments.keys(): + cache.put_if_absent("evbk.cursor:{G}:{topic_T}:{partition}", + { offset: 0, last_examined: 0, updated_at: now() }) + + # No "cursor.sent reset" needed — sent is in-memory per-subscription state in cache, + # naturally reset when partitions migrate to a new subscription + + group_state.topology_version += 1 + cache.put("evbk.group.{G}", group_state, ttl=max(member.expires_at)) + + cluster.publish("evbk.group.{G}.topology", + { version: group_state.topology_version, changed_at }) + finally: + release lock +``` + +**Triggers**: `POST /v1/subscriptions` (JOIN), `DELETE /v1/subscriptions/{id}` (LEAVE), Reaper detecting `expires_at < now()` (CRASH). + +**Concurrency**: Multiple JOINs in flight serialize on the lock; each runs a fresh rebalance with the latest membership. + +**At-least-once contract**: When partitions migrate from sub A to sub B, `cursor.offset` is preserved (sticky); `cursor.sent` is reset to `cursor.offset` so B re-receives any events A polled but didn't seek past. Consumers MUST be idempotent in their processing. diff --git a/gears/system/event-broker/docs/features/0001-idempotent-producers.md b/gears/system/event-broker/docs/features/0001-idempotent-producers.md new file mode 100644 index 000000000..103392fdf --- /dev/null +++ b/gears/system/event-broker/docs/features/0001-idempotent-producers.md @@ -0,0 +1,469 @@ + + + +# Feature: Idempotent Producers + +- [ ] `p1` - **ID**: `cpt-cf-evbk-featstatus-idempotent-producers-implemented` + + + +- [1. Feature Context](#1-feature-context) + - [1.1 Overview](#11-overview) + - [1.2 Purpose](#12-purpose) + - [1.3 Actors](#13-actors) + - [1.4 References](#14-references) +- [2. Choosing a Producer Mode](#2-choosing-a-producer-mode) + - [2.1 Stateless](#21-stateless) + - [2.2 Monotonic](#22-monotonic) + - [2.3 Chained](#23-chained) + - [2.4 Rule of Thumb](#24-rule-of-thumb) +- [3. Wire Shapes (`meta` block)](#3-wire-shapes-meta-block) +- [4. Actor Flows (CDSL)](#4-actor-flows-cdsl) + - [4.1 Producer Registration](#41-producer-registration) + - [4.2 Chained Mode Publish](#42-chained-mode-publish) + - [4.3 Monotonic Mode Publish](#43-monotonic-mode-publish) + - [4.4 Stateless Mode Publish](#44-stateless-mode-publish) + - [4.5 Desync Recovery (Restart / DB Restore)](#45-desync-recovery-restart--db-restore) + - [4.6 Chain Reset (Operator-Driven)](#46-chain-reset-operator-driven) + - [4.7 Producer Registration TTL (Natural Reset)](#47-producer-registration-ttl-natural-reset) +- [5. Processes / Business Logic (CDSL)](#5-processes--business-logic-cdsl) + - [5.1 Producer / Outbox Contract](#51-producer--outbox-contract) +- [6. States](#6-states) +- [7. Hard-Error Catalog](#7-hard-error-catalog) +- [8. Definitions of Done](#8-definitions-of-done) + - [Idempotent Producer Contract](#idempotent-producer-contract) +- [9. Acceptance Criteria](#9-acceptance-criteria) +- [10. Unit Test Plan](#10-unit-test-plan) +- [11. E2E Test Plan](#11-e2e-test-plan) +- [12. Traceability](#12-traceability) + + + +## 1. Feature Context + +### 1.1 Overview + +The event broker offers three producer modes — **chained**, **monotonic**, **stateless** — that trade off strictness of idempotent publishing against per-publish overhead. **Mode is declared once at producer registration** (`POST /v1/producers`) and enforced per request by the broker. Producer-protocol fields (`producer_id`, `previous`, `sequence`) live inside the publish-time `meta` block on the event (marked `writeOnly`, stripped on read); stateless publishes omit `meta` entirely. Chained and monotonic producers maintain ingest-side dedup state in `evbk_producer_state`, keyed by `(producer_id, topic, partition)`. + +### 1.2 Purpose + +Idempotent-producer support lets producers retry safely after transport failures, partial commits, or network timeouts without admitting duplicates into the log. Combined with the producer-side transactional outbox (`toolkit-db`), this gives effective exactly-once semantics for in-platform event flows. + +### 1.3 Actors + +- **Producer SDK** (`cf-gears-event-broker-sdk`): on first publish, calls `POST /v1/producers` to obtain a `producer_id` bound to its principal; populates `meta.producer_id` / `meta.previous` / `meta.sequence` per the registered mode; computes a broker-partition hint locally from `partition_key` or `tenant_id` for SDK/outbox routing while the broker remains authoritative for final topic partition assignment; retries on transient failures. +- **IngestService** (broker): validates `meta`-shape against the registered mode of `meta.producer_id`; performs chain check; updates `evbk_producer_state` and `evbk_producer.last_seen_at` atomically with the outbox enqueue. +- **Reaper worker** (broker): purges stale `evbk_producer_state` rows (per topic-level `retention`, capped at `P14D`) and stale `evbk_producer` rows (per producer-registration TTL, default `P30D`). +- **Operator**: invokes `POST /v1/producers/{id}:reset` for chain reset on a live `producer_id`; reads `GET /v1/producers/{id}/cursors` for diagnostics. + +### 1.4 References + +- [ADR-0002 Partition Selection (revised)](../ADR/0002-partition-selection.md) +- [ADR-0003 Event Schema](../ADR/0003-event-schema.md) +- [ADR-0004 Idempotent Producer Protocol](../ADR/0004-idempotent-producer-protocol.md) +- DESIGN.md §3.1 Domain Model, §3.2 Producer Modes, §3.6 Two Sequences +- `schemas/event.v1.schema.json` — single canonical event schema (publish + read; per-direction semantics via `readOnly` / `writeOnly` markers) +- `migration.sql` — `evbk_producer`, `evbk_producer_state` DDL +- PRD.md FR `cpt-cf-evbk-fr-producer-modes` + +## 2. Choosing a Producer Mode + +**Gaps in the producer's sequence are normal.** A producer may legitimately publish `1, 2, 3, 5` — sequence 4 was rolled back in a business transaction, deleted by an operator, or simply skipped for a domain reason; re-sequencing the producer's counter is often not an option. All three modes accept gaps. The modes differ in **whether the broker can detect *unintended* gaps** (events lost in transit, out-of-order arrival, producer's view diverging from the broker's) — and, if so, how the producer recovers. + +### 2.1 Stateless + +**Use when** one of: + +- The consumer's processing is naturally idempotent (UPSERT, set-state-to-X, idempotent task triggers — re-processing the same event is safe). +- The producer is ephemeral and cannot persist local state (lambdas, short-lived workers). +- Events are non-causal between publishes (independent emissions, no order required). + +**Properties:** + +- No registration required. `POST /v1/producers` is not called. No `meta.producer_id`. No `meta` block at all on the wire. +- Broker performs no dedup. A publish-retry under stateless results in two persisted events; the consumer absorbs duplicates. +- Cheapest publish path; no broker-side state. + +### 2.2 Monotonic + +**Use when** the publish path is **reliable / synchronous** and the producer trusts its own counter. The producer can persist `last_sequence` reliably across restarts (e.g., in a local DB, transactional with the business state). + +**Properties:** + +- One-time `POST /v1/producers { "mode": "monotonic" }` → broker mints `producer_id`. +- Per publish: `meta = { version, producer_id, sequence }`. The producer assigns `sequence` monotonically increasing per `(topic, partition)`. +- Broker check: `meta.sequence > evbk_producer_state.last_sequence` → accept and advance; duplicates (`meta.sequence <= last_sequence`) → `200 OK` with the original event_id. +- **Recovery on error**: `GET /v1/producers/{producer_id}/cursors` → reconcile local view → resume. +- Cheaper than chained: no `meta.previous` field; broker check is one comparison. + +### 2.3 Chained + +**Use when** the publish path is **async / unreliable / windowed** AND the producer needs the broker to detect unintended gaps. + +**Properties:** + +- One-time `POST /v1/producers { "mode": "chained" }` → broker mints `producer_id`. +- Per publish: `meta = { version, producer_id, previous, sequence }`. `meta.previous` is the broker's expected `last_sequence` at processing time (i.e., the prior accepted event's `sequence` on this `(producer_id, topic, partition)`) — **not** "sequence minus one." +- Broker check: `meta.previous == evbk_producer_state.last_sequence` AND `meta.sequence > last_sequence` → accept and advance; duplicates → `200 OK`; chain mismatch → `412 SequenceViolation` carrying the broker's known `last_sequence`. +- **Bootstrap**: the first chained-mode publish for a `(producer_id, topic, partition)` sets `meta.previous = 0` (since `last_sequence` defaults to 0 for absent rows). + +### 2.4 Rule of Thumb + +| Need | Mode | +|---|---| +| Consumer is idempotent; no broker dedup needed | **Stateless** | +| Synchronous publish, trust my counter; broker doesn't need to detect gaps for me | **Monotonic** | +| Async / unreliable hops; broker must detect unintended gaps and tell me where I stalled | **Chained** | + +## 3. Wire Shapes (`meta` block) + +The `meta` block lives on the event (`event.v1.schema.json`) and is marked `writeOnly` (publish-only). It is **optional**; absence = stateless. The broker strips `meta` from consumer-visible reads regardless of mode. + +```jsonc +// Chained +"meta": { "version": 1, "producer_id": "", "previous": 7, "sequence": 8 } + +// Monotonic +"meta": { "version": 1, "producer_id": "", "sequence": 8 } + +// Stateless +// (meta omitted entirely; no producer_id on the wire) +``` + +Bootstrap (first publish for a chained `(producer_id, topic, partition)`): + +```jsonc +"meta": { "version": 1, "producer_id": "", "previous": 0, "sequence": 1 } +``` + +## 4. Actor Flows (CDSL) + +### 4.1 Producer Registration + +```python +# Producer fleet startup, single coordinator role (DB lock / leader election) +resp = http.POST("/v1/producers", json={ + "mode": "chained", + "client_agent": "myservice/1.0.0 myeventlib/2.0.0", +}) +# 201 Created +# Location: /v1/producers/ +# body: { "id": "", "mode": "chained", "client_agent": "" } + +producer_id = resp.json["id"] +distribute_to_fleet(producer_id) # write to DB, ConfigMap, env var, secret store, etc. +``` + +Both `mode` and `client_agent` are required. `client_agent` is an informational diagnostic hint (RFC 9110 User-Agent grammar; ASCII; 1–256 bytes) — persisted on the producer row, surfaced in logs and metrics, immutable. Validation failure surfaces as the canonical RFC 9457 `400` problem type. The HTTP `User-Agent` request header is captured in access logs independently and is NOT a fallback. See §7 (Hard-Error Catalog). + +Re-registration: any subsequent `POST /v1/producers` from the same principal mints a fresh `producer_id` (broker does NOT reuse). Old `producer_id` ages out via the producer-registration TTL Reaper (see §4.7). + +### 4.2 Chained Mode Publish + +```python +# Producer side — publish a window of events through an unreliable / async path +def publish_chained_window(events, last_acked_sequence): + seq = last_acked_sequence + for e in events: + prev = seq + seq = prev + 1 + outbox.enqueue(Event( + id=uuid4(), type="...", topic=T, + tenant_id=current_tenant, source="...", + subject="...", subject_type="...", occurred_at=now(), + data=payload, + meta={ "version": 1, "producer_id": P, "previous": prev, "sequence": seq }, + )) + return seq # the last sequence the producer attempted + +# Ack-polling loop (runs independently of publish) +async def chained_ack_poll(producer_id): + while True: + cursors = await http.GET(f"/v1/producers/{producer_id}/cursors") + # cursors = [{topic, partition, last_sequence}, ...] + update_local_view(cursors) + await sleep(POLL_INTERVAL) + # If last_sequence has not advanced past the producer's window end, + # re-publish from the broker's last_sequence + 1. + +# Broker side — atomic transaction per event +row = state["evbk_producer_state"].get((meta.producer_id, topic, partition)) +last = row.last_sequence if row else 0 + +if meta.previous == last and meta.sequence > last: + BEGIN: + outbox.enqueue(event) + UPSERT evbk_producer_state(producer_id, topic, partition, + last_sequence=meta.sequence, + last_seen_at=now()) + UPDATE evbk_producer SET last_seen_at=now() WHERE producer_id=meta.producer_id + COMMIT + return 202_Accepted +elif meta.sequence <= last: + return 200_OK(original_event_for(event.id)) # idempotent retry +else: + return 412_SequenceViolation(known_last_sequence=last) +``` + +### 4.3 Monotonic Mode Publish + +```python +# Producer side — synchronous publish; producer trusts its own counter +def publish_monotonic(event_data, next_sequence): + resp = http.POST("/v1/events", json={ + "id": uuid4(), "type": "...", "topic": T, + "tenant_id": current_tenant, "source": "...", + "subject": "...", "subject_type": "...", "occurred_at": now(), + "data": event_data, + "meta": { "version": 1, "producer_id": P, "sequence": next_sequence }, + }) + if resp.status_code == 200: # duplicate + return resp.json["event_id"] + elif resp.status_code == 202: # new + persist_local_last_sequence(next_sequence) + return resp.json["event_id"] + else: # any error + cursors = http.GET(f"/v1/producers/{P}/cursors") + reconcile_local_state(cursors) + raise PublishError(...) # caller decides retry policy + +# Broker side +row = state["evbk_producer_state"].get((meta.producer_id, topic, partition)) +last = row.last_sequence if row else 0 + +if meta.sequence > last: + BEGIN: + outbox.enqueue(event) + UPSERT evbk_producer_state(..., last_sequence=meta.sequence, last_seen_at=now()) + UPDATE evbk_producer SET last_seen_at=now() WHERE producer_id=meta.producer_id + COMMIT + return 202_Accepted +else: + return 200_OK(original_event_for(event.id)) # duplicate +``` + +### 4.4 Stateless Mode Publish + +```python +# Producer side — no registration, no meta +http.POST("/v1/events", json={ + "id": uuid4(), "type": "...", "topic": T, + "tenant_id": current_tenant, "source": "...", + "subject": "...", "subject_type": "...", "occurred_at": now(), + "data": event_data, + # meta omitted entirely +}) + +# Broker side — no state lookup, no producer_id resolution +BEGIN: + outbox.enqueue(event) +COMMIT +return 202_Accepted + +# Two publish attempts that include the same event.id under stateless +# yield TWO persisted events (different event-broker assigned `sequence`s). +# Consumer must be idempotent on event.id or content semantics. +``` + +### 4.5 Desync Recovery (Restart / DB Restore) + +Scenario: producer's local `last_sequence` view is no longer trustworthy (process restarted without persistent state, local DB restored from older backup, suspected divergence). + +```python +async def desync_recover(producer_id): + cursors = await http.GET(f"/v1/producers/{producer_id}/cursors") + # 200 OK: [{topic, partition, last_sequence}, ...] + # 403 ProducerPrincipalMismatch — calling with a non-owning principal + # 404 ProducerNotFound — producer aged out by TTL or never existed + + for c in cursors: + local_state[(c.topic, c.partition)] = c.last_sequence + # Resume publishing from local_state values +``` + +**Edge case: producer-ahead-of-broker.** If the producer's locally-tracked `last_sequence` is HIGHER than the broker's known `last_sequence` (e.g., broker DB restored from older backup, or state rows reaped before the producer reconnected): + +- The producer SHOULD log an operator-facing warning. The local view is more advanced than the broker's; either the broker lost state or the producer has stale-but-untrusted local state. +- **Default**: register a fresh `producer_id` and start a new chain. The old `producer_id` ages out per TTL. +- **Alternative**: if the operator confirms the broker's view is authoritative (e.g., disaster-recovery scenario where the older backup is the intended state), rewind the producer's local cursor to the broker's view. Chain continuity is broken; reset semantics apply. + +### 4.6 Chain Reset (Operator-Driven) + +Scenario: producer's fleet is alive and well, but the chain state needs to be cleared (testing, debugging, manual recovery). The `producer_id` is preserved; the fleet does NOT redistribute a new id. + +```python +# Operator (or owner-principal automation) +resp = http.POST(f"/v1/producers/{producer_id}:reset", + json={"topic": T, "partition": k}) # body optional; absent = reset all +# 200 OK with audit-record reference +# 403 ProducerPrincipalMismatch — non-owning principal +# 404 ProducerNotFound + +# After reset, the next chained publish for the affected (producer_id, topic, partition) +# bootstraps from last_sequence=0: +# meta = { version: 1, producer_id: P, previous: 0, sequence: 1 } +``` + +Audit record fields: `producer_id`, `requested_scope` (full or `(topic, partition)`), `operator_principal`, `timestamp`, `outcome`. + +### 4.7 Producer Registration TTL (Natural Reset) + +Producer rows track `evbk_producer.last_seen_at`, updated atomically with every accepted chained / monotonic publish. The Reaper purges rows older than the platform-wide producer-registration TTL (default `P30D`). + +```python +# Reaper sweep (broker-internal) +async def reap_idle_producers(): + cutoff = now() - PRODUCER_TTL + for row in evbk_producer.find(last_seen_at__lt=cutoff): + BEGIN: + DELETE FROM evbk_producer_state WHERE producer_id = row.producer_id + DELETE FROM evbk_producer WHERE producer_id = row.producer_id + COMMIT + log("reaped idle producer", producer_id=row.producer_id, last_seen_at=row.last_seen_at) + +# Producer-side: next publish after age-out +resp = http.POST("/v1/events", json={..., "meta": {"producer_id": P, ...}}) +# 400 UnknownProducer +# Producer re-registers, distributes new id, retires old (which is already gone). +``` + +The state-row retention (topic-level `retention`, capped at `P14D`) and the producer-row TTL are separate dials. State rows age out at the topic's pace; registration rows age out at the platform's pace. A registration row reap cascades to delete any orphaned state rows for the same `producer_id`. + +## 5. Processes / Business Logic (CDSL) + +### 5.1 Producer / Outbox Contract + +The "exactly-once via idempotent producer" property is derived from a chain of guarantees: + +1. **Producer business transaction → toolkit-db outbox**: the producer's business code writes both its domain state and the outbox row in one transaction. If the business txn rolls back, the outbox row is never persisted. +2. **toolkit-db outbox → SDK publish call**: the outbox pipeline reads pending rows in order and calls the SDK `publish` function. Network failures cause the SDK to retry the same outbox row. +3. **SDK publish call → broker ingest**: the SDK serializes the event with `meta.{producer_id, previous, sequence}` and POSTs to `/v1/events`. On any non-2xx, the SDK does not advance its outbox cursor. +4. **Broker ingest dedup → atomic enqueue + state update**: the broker checks the chain; if accepted, the ingest outbox enqueue, the `evbk_producer_state.last_sequence` advance, and the `evbk_producer.last_seen_at` touch happen in **one transaction**. + +Failure modes and recovery: + +| Failure point | What happens | Recovery | +|---|---|---| +| 1. Producer business txn / outbox enqueue split | Single transaction; rolls back together; no outbox row, no event | Producer retries (business txn re-runs) | +| 2. Outbox → ingest network failure | SDK times out; toolkit-db outbox keeps the row pending; retry on next sweep | Broker dedups via chain check on the next attempt (200 OK if first attempt had succeeded) | +| 3. Ingest crash between enqueue and state update | Single transaction; rolls back; no outbox row visible, no state advance | SDK times out (or sees 5xx); retries; broker re-applies | +| 4. Producer restart with in-flight outbox rows | Outbox pipeline resumes from its cursor; sends pending events with original `(producer_id, previous, sequence)` | Broker dedups duplicates (200 OK with original event_id); chain continues from where it left off | + +The producer outbox considers an event "delivered" when the broker returns `202 Accepted` or `200 OK` (the latter for duplicates). The `202` does NOT wait for `backend.persist` — that's the broker-side ingest outbox's job. + +## 6. States + +`evbk_producer[producer_id]`: + +| State | Meaning | +|---|---| +| Active | `last_seen_at` within the platform's producer-registration TTL window; producer in normal operation | +| Idle | `last_seen_at` approaching but not past the TTL window | +| Reaped | Row deleted by the Reaper; `producer_id` returns `400 UnknownProducer` on subsequent publish | + +`evbk_producer_state[(producer_id, topic, partition)]`: + +| State | Meaning | +|---|---| +| Absent | No event has been accepted for this triple yet (or the row was reaped, or `:reset` was called). Treated as `last_sequence = 0`; next chained event MUST set `meta.previous = 0` | +| Present | Row exists with `last_sequence = N`, `last_seen_at = T`. Next chained event MUST have `meta.previous = N`; next monotonic event MUST have `meta.sequence > N` | +| Reapable | `last_seen_at` older than the topic's `retention` (capped at `P14D`). Next Reaper run deletes the row | + +## 7. Hard-Error Catalog + +| HTTP | Code | When | +|---|---|---| +| 400 | `BadRequest` | Top-level forbidden field on publish (`producer_id`, `previous`, `sequence`, `partition`, `offset`, `offset_time`, `created_at`) | +| 400 | `InvalidMode` | `POST /v1/producers` with mode other than `chained` / `monotonic` | +| 400 | `ChainModeFieldsMissing` | Chained-mode publish missing `meta.previous` or `meta.sequence` | +| 400 | `MonotonicModeFieldsViolation` | Monotonic-mode publish with forbidden `meta.previous` or missing `meta.sequence` | +| 400 | `MetaWithoutProducerId` | `meta` has `previous` / `sequence` but no `producer_id` | +| 400 | `UnknownProducer` | `meta.producer_id` not in registry (or aged out by TTL) | +| 400 | `UnknownMetaVersion` | `meta.version > current_supported` | +| 400 | `InvalidEventFieldEncoding` | Non-ASCII bytes in any event field | +| 400 | `EventFieldTooLong` | Event string field exceeds length cap | +| 400 | `RetentionExceedsMaxSpan` | Topic created/updated with `retention > P14D` | +| 400 | `PartitionHashMismatch` | Internal SDK/broker partition hint disagrees with the broker's authoritative derivation from `partition_key` or `tenant_id` | +| 403 | `ProducerPrincipalMismatch` | Cross-principal use of `producer_id` (publish / cursor read / reset) | +| 403 | `TenantIdNotAuthorized` | Platform authz resolver denied the supplied `tenant_id` | +| 404 | `ProducerNotFound` | `GET /v1/producers/{id}/cursors` or `POST :reset` for an unknown `producer_id` | +| 412 | `SequenceViolation` | Chained mode: `meta.previous != last_sequence`; response carries broker's known `last_sequence` | + +## 8. Definitions of Done + +### Idempotent Producer Contract + +- [ ] `p1` - **ID**: `cpt-cf-evbk-dod-idempotent-producers` + +- `evbk_producer` and `evbk_producer_state` tables created per `migration.sql`. +- `POST /v1/producers` mints `producer_id`, stores `mode` + `owner_principal` + `last_seen_at`. +- `POST /v1/events` and `POST /v1/events:batch` enforce mode-shape rules per the registered mode; reject mode mismatches with the documented `400` codes; reject chain mismatches with `412 SequenceViolation`. +- `GET /v1/producers/{producer_id}/cursors` returns per-`(topic, partition)` `last_sequence`; principal-bound. +- `POST /v1/producers/{producer_id}:reset` clears state rows (full or scoped); emits audit record; principal-bound. +- Reaper deletes stale `evbk_producer_state` rows (per topic-level `retention`) and stale `evbk_producer` rows (per platform-wide producer-registration TTL); cascade purges state rows for reaped producers. +- Ingest performs the outbox-enqueue + state-update + `evbk_producer.last_seen_at` touch in one transaction. +- Producer SDK declares mode at startup, hashes partition locally for outbox routing, sends `meta` per the registered mode. +- Metrics: `evbk_producer_sequence_violation_total`, `evbk_producer_duplicate_total`, `evbk_producer_state_rows`, `evbk_producer_rows`, `evbk_producer_state_reaper_deleted_total`, `evbk_producer_reaper_deleted_total`. + +## 9. Acceptance Criteria + +- **AC-1**: A chained producer publishes `(previous=0, sequence=1)` then `(previous=1, sequence=2)`; both land; `evbk_producer_state.last_sequence = 2`. +- **AC-2**: A chained producer re-publishes `(previous=1, sequence=2)` after a transport timeout; broker returns `200 OK` with the original event_id; no duplicate in the log. +- **AC-3**: A chained producer publishes `(previous=5, sequence=6)` when `last_sequence = 4`; broker returns `412 SequenceViolation` carrying `last_sequence=4`; no row mutation. +- **AC-4**: A monotonic producer publishes `sequence=10` then `sequence=20`; both land; `last_sequence = 20`. +- **AC-5**: A stateless producer publishes 1000 events with no `meta`; all 1000 are persisted distinctly; `evbk_producer_state` has no rows for the principal. +- **AC-6**: A producer publishes once; waits past the topic's `retention`; Reaper deletes the state row; the next chained publish with `meta.previous=0, meta.sequence=1` is accepted. +- **AC-7**: A producer remains idle past the producer-registration TTL; Reaper deletes `evbk_producer`; the next publish from the producer's fleet using the old `meta.producer_id` returns `400 UnknownProducer`. +- **AC-8**: Operator calls `POST /v1/producers/{id}:reset` (full scope); state rows deleted; audit record created; next chained publish with `meta.previous=0` accepted. +- **AC-9**: Operator calls `POST /v1/producers/{id}:reset { "topic": T, "partition": k }`; only the matching state row is deleted; other `(producer_id, topic, partition)` rows untouched. +- **AC-10**: Principal A registers a producer; principal B publishes with A's `meta.producer_id` → `403 ProducerPrincipalMismatch`. +- **AC-11**: Producer publishes with `meta.version` greater than broker's supported → `400 UnknownMetaVersion`. +- **AC-12**: Producer publishes with `meta` containing `sequence` but no `producer_id` → `400 MetaWithoutProducerId`. +- **AC-13**: Topic is created with `retention = P30D` → `400 RetentionExceedsMaxSpan`. + +## 10. Unit Test Plan + +- **Mode-shape matrix**: chained / monotonic / stateless × valid / missing-required / forbidden-present / wrong-mode-for-registered-id → each cell produces the documented outcome. +- **Chain check**: parameterize over (`evbk_producer_state` row state × incoming `meta.previous` × incoming `meta.sequence`) → expected (accept / duplicate / chain-broken, row delta). +- **Monotonic gap acceptance**: `last_sequence = 10`, incoming `sequence = 15` → accepted; rows `11–14` are never delivered. +- **Stateless skip**: event with no `meta` → broker does not touch `evbk_producer_state`. +- **Atomicity**: simulated ingest crash between outbox enqueue and `evbk_producer_state` update → no half-state after recovery. +- **Principal binding**: cross-principal publish / cursor read / reset → `403 ProducerPrincipalMismatch`. +- **TTL Reaper**: producer-row TTL elapses → row deleted on next sweep; cascade purge of orphaned state rows. +- **`meta.version` accept / reject**: `version <= supported` accepted; `version > supported` → `400 UnknownMetaVersion`. +- **Event field encoding**: non-ASCII bytes in any event field → `400 InvalidEventFieldEncoding`. +- **Reset audit**: every successful reset emits an audit record. + +## 11. E2E Test Plan + +- **E1 — Chained happy path**: producer fleet registers; publishes a chain of 100 events; consumer receives all in order; `last_sequence = 100`; producer-row `last_seen_at` advances. +- **E2 — Retry after timeout**: producer publishes event N; simulated transport timeout; producer retries; consumer sees event N exactly once; broker returns `200 OK` on the retry. +- **E3 — Chain break recovery**: producer publishes `(previous=0, sequence=1)`, then `(previous=99, sequence=100)` → `412 SequenceViolation` carrying `last_sequence=1`; producer calls `GET /cursors`, observes `last_sequence=1`, corrects to `(previous=1, sequence=2)`, retries → accepted. +- **E4 — Monotonic recovery**: monotonic producer's local DB is restored to a prior backup; producer calls `GET /cursors`, sees `last_sequence=N` higher than its local view; operator confirms broker is authoritative; producer rewinds local cursor to `N+1` and resumes. +- **E5 — Async windowed publish (chained)**: producer fires 1000 events into an async / lossy channel; ack-poll loop calls `GET /cursors` every 5s; on detecting stalled cursor, re-publishes from stall point; eventually all 1000 land; `last_sequence = 1000`. +- **E6 — Producer-state Reaper (topic retention)**: configure topic `retention = PT5S`; chained producer publishes one event; waits 10s; Reaper deletes state row; producer's next publish with `(previous=0, sequence=1)` is accepted. +- **E7 — Producer-registration Reaper (TTL)**: configure producer-registration TTL = `PT10S`; chained producer publishes one event; waits 15s; next publish with same `meta.producer_id` → `400 UnknownProducer`; producer re-registers, distributes new id, resumes. +- **E8 — Operator reset (full)**: operator calls `POST /v1/producers/{id}:reset`; verifies all state rows gone; producer's next chained publish with `meta.previous=0` is accepted; audit record present. +- **E9 — Operator reset (scoped)**: operator calls reset with `{topic, partition}`; only matching row deleted; chains on other partitions continue normally. +- **E10 — Cross-principal rejection**: principal B attempts publish / cursor read / reset on principal A's `producer_id` → `403 ProducerPrincipalMismatch` for all three. +- **E11 — Stateless duplicate admission**: stateless producer publishes the same `event.id` payload twice (no `meta`); both land; consumer absorbs duplicates by `event.id`. +- **E12 — Tenant authz**: producer publishes with `tenant_id` outside its principal's grant → `403 TenantIdNotAuthorized` (platform resolver decision); producer with grant → accepted. + +## 12. Traceability + +- **PRD**: [PRD.md](../PRD.md) + - `cpt-cf-evbk-fr-producer-modes` + - `cpt-cf-evbk-fr-publish-single` + - `cpt-cf-evbk-fr-publish-batch` +- **ADRs**: + - [`0002-partition-selection`](../ADR/0002-partition-selection.md) + - [`0002-event-schema`](../ADR/0003-event-schema.md) + - [`0003-idempotent-producer-protocol`](../ADR/0004-idempotent-producer-protocol.md) +- **DESIGN**: [DESIGN.md](../DESIGN.md) + - §3.1 Domain Model + - §3.2 Producer Modes (shrunk to summary + link to this feature doc) + - §3.6 Two Sequences + - §3.7 Database schemas — `evbk_producer`, `evbk_producer_state` rows +- **Schemas**: + - [`schemas/event.v1.schema.json`](../schemas/event.v1.schema.json) — publish input + - [`schemas/event.v1.schema.json`](../schemas/event.v1.schema.json) — single canonical event schema; read responses surface `readOnly` `partition`/`sequence`/`sequence_time` and strip `writeOnly` `meta` diff --git a/gears/system/event-broker/docs/features/0002-consumer-subscription-lifecycle.md b/gears/system/event-broker/docs/features/0002-consumer-subscription-lifecycle.md new file mode 100644 index 000000000..9e0176233 --- /dev/null +++ b/gears/system/event-broker/docs/features/0002-consumer-subscription-lifecycle.md @@ -0,0 +1,411 @@ + + +# Feature: Consumer Subscription Lifecycle + +- [ ] `p1` - **ID**: `cpt-cf-evbk-featstatus-consumer-subscription-lifecycle` + + + +- [1. Feature Context](#1-feature-context) + - [1.1 Overview](#11-overview) + - [1.2 Purpose](#12-purpose) + - [1.3 Actors](#13-actors) + - [1.4 References](#14-references) +- [2. Actor Flows (CDSL)](#2-actor-flows-cdsl) + - [2.1 Cold JOIN (new consumer, fresh group)](#21-cold-join-new-consumer-fresh-group) + - [2.2 SEEK (set cursor; pre-stream or forward advance)](#22-seek-set-cursor-pre-stream-or-forward-advance) + - [2.3 Re-JOIN after 410 Gone / 404 / shard failover](#23-re-join-after-410-gone--404--shard-failover) + - [2.4 Upscaling (add consumer instance)](#24-upscaling-add-consumer-instance) + - [2.5 Downscaling (remove consumer instance)](#25-downscaling-remove-consumer-instance) + - [2.6 Filter / topic-list mutation during rolling deploy](#26-filter--topic-list-mutation-during-rolling-deploy) + - [2.7 Session timeout (heartbeat-via-poll)](#27-session-timeout-heartbeat-via-poll) + - [2.8 Delivery shard ownership change](#28-delivery-shard-ownership-change) +- [3. Processes / Business Logic (CDSL)](#3-processes--business-logic-cdsl) +- [4. States (CDSL)](#4-states-cdsl) +- [5. Definitions of Done](#5-definitions-of-done) + - [Broker-Side Implementation](#broker-side-implementation) +- [6. Acceptance Criteria](#6-acceptance-criteria) +- [7. Unit Test Plan](#7-unit-test-plan) +- [8. E2E Test Plan](#8-e2e-test-plan) + + + +## 1. Feature Context + +### 1.1 Overview + +Consumers interact with the Event Broker through a JOIN → poll → seek → leave cycle bound to a consumer group. This feature catalogues the eight named flows that compose the full lifecycle, each described in CDSL with explicit broker-side state transitions and consumer-visible error / recovery paths. The flows cover both happy and unhappy paths and span both standalone (in-process cache) and cluster (persistent cache provider) deployment modes. + +### 1.2 Purpose + +Consumer subscription lifecycle defines the end-to-end contract a consumer SDK must implement to join a consumer group, poll events, advance the cursor via SEEK, handle rebalance / session-loss / shard-failover events, and leave cleanly. Without a uniform contract, consumer fleets drift across deployments — one absorbs `410 Gone` cleanly while another spins on it; one survives a rolling upscale with cursor continuity while another duplicates batches; one re-joins gracefully after a shard reassignment while another loses in-flight events. The feature pins down eight scenarios spanning the full lifecycle (cold JOIN, offset seek, re-JOIN after `410`/`404`/failover, upscale, downscale, filter mutation mid-rollout, session-timeout, shard ownership change), each with explicit state transitions, error surfaces, and recovery paths, so any conformant consumer behaves predictably on both standalone (in-process cache) and cluster (persistent provider) deployments. + +### 1.3 Actors + +- **Consumer SDK** (`cf-gears-event-broker-sdk`): JOINs, polls, seeks, leaves. +- **DispatcherService**: routes consumer-group requests to the owning delivery shard. +- **DeliveryService**: per-shard owner of `GroupState`; runs the rebalance algorithm; serves polls. +- **ClusterCapabilities** (cluster mode): provides distributed locks, leader election, persistent cache for cursors / GroupState. + +### 1.4 References + +- DESIGN.md §3.1 Subscription + GroupState + Cursor entities +- DESIGN.md §3.2 Subscription Lifecycle + Long-Poll Mechanism +- DESIGN.md §3.5 Long-Poll Consumption Flow +- DESIGN.md §3.6 (delivery shard ownership and SD double-check) +- USE_CASES.md §1 Consumer State Machine, §4 Walked Scenarios, §5 Rebalance Algorithm +- `openapi.yaml` — wire shapes for all endpoints referenced below +- `ADR/0002-consumption-transport.md` — the transport (long-poll vs SSE vs multipart) is open; flows below assume the current long-poll transport. + +## 2. Actor Flows (CDSL) + +Pseudo-code below is Python-ish — not runnable; intent over syntax. Each flow shows producer/consumer/broker steps at API granularity. + +### 2.1 Cold JOIN (new consumer, fresh group) + +```python +# Step 0 — anonymous case: caller creates the group; named case skips this +resp = http.post("/v1/consumer-groups", json={ + "client_agent": "myservice/1.0.0 myeventlib/2.0.0", +}) +assert resp.status == 201 +group_id = resp.json["id"] # e.g. gts.cf.core.events.consumer_group.v1~ +client_agent = resp.json["client_agent"] # echoed back; persisted on the group row +# named groups: group_id is provisioned via types_registry at broker startup; skip POST. +# client_agent is required (RFC 9110 User-Agent grammar; ASCII; 1–256 bytes); informational +# only — does not participate in dedup / authz / ownership decisions. Immutable after create. +# Validation failure surfaces as the canonical RFC 9457 400 problem type. + +# Step 1 — JOIN with topic-anchored typed-filter interests (per ADR-0005) +resp = http.post("/v1/subscriptions", json={ + "consumer_group": group_id, + "client_agent": "myservice/1.0.0 myeventlib/2.0.0", + "session_timeout": "PT30S", + "interests": [ + { + "topic": "gts.cf.core.events.topic.v1~yourorg.orders.v1", + "tenant_id": tenant_id, + "types": ["gts.cf.core.events.event.v1~yourorg.orders.*"], + # Optional paired filter — omit both for "no expression filter": + "expression_type": "gts.cf.core.events.filter.v1~cf.core.expression.cel.v1", + "expression": "event.data.amount > 100", + }, + ], +}) +sub_id = resp.json["id"] +assigned = resp.json["assigned"] # list of (topic, partition); topic-centric for seek +topology_version = resp.json["topology_version"] + +# Broker-side join logic +def join(req): + shard = dispatch_route(req.consumer_group) # via cluster lookup or claim + with cluster.distributed_lock(f"evbk.group.{req.consumer_group}"): + gs = GroupState.get_or_create(req.consumer_group) + gs.active_members[sub_id] = Member(req.topics, req.filters) + gs.topology_version += 1 + rebalance(gs) # round-robin v1, USE_CASES.md §5 + return Subscription(id=sub_id, assigned=gs.active_members[sub_id].assigned, + topology_version=gs.topology_version) + +# Step 2 — pre-stream SEEK (required; OffsetManager-driven) +# The SDK resolves a starting position per assigned (topic, partition) via +# OffsetManager.position(...). For a fresh group the manager returns the +# configured Fallback sentinel; for a re-running consumer with committed +# cursors it returns Exact(last_processed_offset). +positions = {} +for (topic, partition) in assigned: + pos = offset_manager.position(group_id, topic, partition) # ResolvedPosition + positions[f"{topic}:{partition}"] = pos.to_wire() # int | "earliest" | "latest" + +resp = http.post(f"/v1/subscriptions/{sub_id}:seek", json={ + "partition_positions": positions, +}) +assert resp.status == 200 # 400 InvalidInitialPosition if an integer is out of range + +# Step 3 — first stream +for frame in http.get(f"/v1/events:stream?subscription_id={sub_id}", stream=True): + handle(frame) +# Defensive: broker returns 409 PositionsNotSet if any assigned partition lacks +# a cursor. A well-behaved SDK never sees this on the happy path because Step 2 +# seeded every partition. SDK recovery: re-resolve via position() + re-SEEK. + +# Broker serves from cursor + 1 (where `cursor` is the session/runtime +# cursor for the subscription's assigned partition). Sentinels resolve at admission: +# "earliest" → cursor := retention_floor - 1 (emit from retention_floor) +# "latest" → cursor := current high-water mark (emit only future events) + +# Durability matrix: +# standalone → cursor dies with the process +# cluster + persistent → cursor survives shard restart (Redis-with-disk, etcd) +``` + +### 2.2 SEEK (set cursor; pre-stream or forward advance) + +The `POST /v1/subscriptions/{id}:seek` endpoint serves two roles for the same subscription: + +1. **Pre-stream seed** (§2.1 Step 2) — call it once after JOIN to declare the starting position for each assigned partition. Any value in the valid range `[retention_floor - 1, high_water_mark]` is permitted; sentinels `"earliest"` / `"latest"` are server-resolved at admission. +2. **Forward SEEK during streaming** — call it to advance the cursor past processed events. While `:stream` is open against the subscription, the broker enforces `MAX(stored, requested)` per partition; backward moves are rejected with `409 SeekBackwardNotAllowed`. + +```python +# Consumer side — pre-stream seed (per-partition; values are int OR "earliest"/"latest") +resp = http.post(f"/v1/subscriptions/{sub_id}:seek", json={ + "partition_positions": { + "orders:0": 42, # last-processed offset; broker emits from 43 + "orders:1": "earliest", # broker sets cursor := retention_floor - 1 + "orders:2": "latest", # broker sets cursor := current high-water mark + } +}) +assert resp.status == 200 + +# Consumer side — forward SEEK during streaming (integers only; forward-only enforced) +resp = http.post(f"/v1/subscriptions/{sub_id}:seek", json={ + "partition_positions": {"orders:0": 100} # 409 SeekBackwardNotAllowed if < 42 +}) + +# Broker side +def seek(sub_id, partition_positions, stream_is_open): + sub = state.subscriptions[sub_id] + for (key, value) in partition_positions.items(): + topic, part = key.split(":") + if (topic, int(part)) not in sub.assigned: + return 409_PartitionNotAssigned # atomic: nothing applies + + resolved = resolve(value, topic, int(part)) # int verbatim or sentinel→int + if not (retention_floor(topic, int(part)) - 1 <= resolved <= high_water_mark(topic, int(part))): + return 400_InvalidInitialPosition + + if stream_is_open and resolved < cursor.get(sub.group, topic, int(part)): + return 409_SeekBackwardNotAllowed # forward-only during streaming + + cursor.set((sub.group, topic, int(part)), resolved) + return 200_OK +``` + +Sentinel resolution (broker-side, at admission): +- `"earliest"` → cursor set to `retention_floor - 1` for `(group, topic, partition)`. Subsequent emission begins at `retention_floor`. +- `"latest"` → cursor set to the current high-water mark for `(group, topic, partition)`. Subsequent emission includes only events admitted after the SEEK lands. + +### 2.3 Re-JOIN after 410 Gone / 404 / shard failover + +```python +# Consumer SDK loop +while True: + try: + for frame in http.get(f"/v1/events:stream?subscription_id={sub_id}", stream=True): + handle(frame) + if frame.kind == "topology": + # Re-SEEK newly-assigned partitions (continuing partitions keep their cursor). + new_slots = [s for s in frame.assigned if s not in prev_assigned] + if new_slots: + positions = {f"{t}:{p}": offset_manager.position(group_id, t, p).to_wire() + for (t, p) in new_slots} + http.post(f"/v1/subscriptions/{sub_id}:seek", + json={"partition_positions": positions}) + prev_assigned = frame.assigned + except (HTTP_410_Gone, HTTP_404_SubscriptionNotFound): + drop_in_flight(sub_id) # any unfinished work for this sub + sub_id, assigned = re_join(group_id, interests) + # Fresh subscription_id → no SEEK history on broker → must re-seed + positions = {f"{t}:{p}": offset_manager.position(group_id, t, p).to_wire() + for (t, p) in assigned} + http.post(f"/v1/subscriptions/{sub_id}:seek", + json={"partition_positions": positions}) + continue + except TransportError: # ungraceful shard kill + sub_id, assigned = re_join(group_id, interests) + positions = {f"{t}:{p}": offset_manager.position(group_id, t, p).to_wire() + for (t, p) in assigned} + http.post(f"/v1/subscriptions/{sub_id}:seek", + json={"partition_positions": positions}) + continue + +# Cursor durability on re-JOIN: +# standalone → cursor lost; resume from earliest_available_offset +# cluster + persistent → cursor preserved; resume from cursor.offset + 1 +# Consumers MUST handle at-least-once: dedup by event.id on the consumer side. +``` + +### 2.4 Upscaling (add consumer instance) + +```python +# New member joins +sub_id_new = http.post("/v1/subscriptions", json={"consumer_group": G, "client_agent": CA, "interests": [...]}).json["id"] + +# Broker side +def join_new_member(G, member): + with cluster.distributed_lock(f"evbk.group.{G}"): + gs = GroupState[G] + gs.active_members[member.id] = member + gs.topology_version += 1 + rebalance(gs) # round-robin v1 + for existing_member in gs.active_members.values(): + if existing_member.id != member.id: + cluster.publish(f"evbk.poller_wake:{existing_member.id}") + # All in-flight pollers wake early; their next response carries the new + # topology_version + updated assigned set. + +# INVARIANT: no (topic, partition) is processed by two consumers simultaneously. +# The distributed_lock around GroupState mutation serializes the reassignment. +``` + +### 2.5 Downscaling (remove consumer instance) + +```python +# Graceful leave +http.delete(f"/v1/subscriptions/{sub_id}") # explicit LEAVE +# OR — silent death: consumer just stops polling + +# Broker side +def remove_member(G, member_id, reason): # reason in {"leave", "session_timeout"} + with cluster.distributed_lock(f"evbk.group.{G}"): + gs = GroupState[G] + del gs.active_members[member_id] + gs.topology_version += 1 + rebalance(gs) # redistribute departed member's partitions + for survivor in gs.active_members.values(): + cluster.publish(f"evbk.poller_wake:{survivor.id}") + # In-flight events held in the departed member's buffer are NOT shipped + # elsewhere — the new owner re-reads from cursor.offset + 1 (single source of truth). + +# Reaper fires the silent-death path: +async def reaper_loop(): + while True: + await sleep(5) + for sub in state.subscriptions.values(): + if sub.expires_at < now(): + remove_member(sub.group, sub.id, reason="session_timeout") +``` + +### 2.6 Filter / topic-list mutation during rolling deploy + +```python +# During the rolling deploy, both v1 (filters=F1, topics=T1) and v2 (F2, T2) +# members coexist in group G. Each member declares its own filters and topic +# list at JOIN; the rebalance assigns each partition to exactly one member. + +def deliver_to_member(member, raw_events): + matching = [e for e in raw_events if member.filters.match(e)] + return matching + +# Cursor handoff at partition reassignment: +# v1 member owns (T, p) with cursor.offset = X +# rebalance reassigns (T, p) to a v2 member +# v2 member resumes from cursor.offset + 1 = X + 1 +# ← cursor reflects the v1 member's processing position at the handoff +# (R60 in DESIGN.md: accepted rollout behavior) + +# When all v1 members leave, the group settles into the F2/T2 worldview. +``` + +### 2.7 Session timeout (heartbeat-via-poll) + +```python +# Each poll arrival refreshes the subscription's expires_at +def on_poll_arrival(sub_id): + sub = state.subscriptions[sub_id] + sub.expires_at = now() + sub.session_timeout # heartbeat-via-poll + +# Reaper (see §2.5) deletes subs past expires_at and triggers rebalance. +# Consumer's next poll after the reap returns 404 SubscriptionNotFound; +# consumer re-JOINs per §2.3. + +# IMPORTANT: session_timeout MUST exceed the chosen poll timeout to avoid +# reap-during-poll. Recommended: +# poll_timeout = 20 # seconds +# session_timeout = 60 # seconds +``` + +### 2.8 Delivery shard ownership change + +```python +# Graceful release (e.g., shard A scaling down) +async def release_ownership(G): + for poller in pollers_of_group(G): + respond_410_gone(poller) # consumer will re-JOIN + await cluster.distributed_lock.release(f"evbk.group.{G}") + # Next shard B to acquire the lock claims ownership and runs a fresh rebalance. + +# Ungraceful kill (shard A forcibly terminated) +# - In-flight pollers see TCP RST or read timeout +# - cluster.distributed_lock has a session TTL; the lock auto-releases on TTL expiry +# - Shard B acquires the lock; reconstructs GroupState from cache (cluster) or +# starts fresh (standalone or non-persistent cache) +# - Existing sub_ids in cache are honored if session_timeout hasn't elapsed; +# otherwise treated as gone (consumer re-JOINs) + +# Cursor durability across ownership change: +# standalone → no shard concept; broker restart = full cache wipe +# cluster + persistent → cursor.offset survives via Redis-with-disk / etcd +``` + +## 3. Processes / Business Logic (CDSL) + +The lock around `cluster.distributed_lock("evbk.group.")` is the serialization point for every GroupState mutation (JOIN, LEAVE, rebalance, ownership transfer). Stream reads and pre-stream SEEK are NOT lock-protected by the GroupState mutation lock — they touch runtime cursor cache keys directly and rely on the cache's own consistency guarantees. + +`topology_version` is monotonically increasing per group; every mutation increments it. Consumers compare across poll responses to detect rebalance. + +## 4. States (CDSL) + +| State | Trigger | Next | +|---|---|---| +| Joining | POST /v1/subscriptions accepted | Active | +| Active | rebalance assigned partitions | Streaming / Seeking | +| Streaming | GET /v1/events:stream in flight | Active (response closes) / Reaped (session_timeout or disconnect) | +| Reaped | session_timeout elapsed with no active stream | Gone | +| Gone | LEAVE or Reap | terminal — must re-JOIN to recover | + +## 5. Definitions of Done + +### Broker-Side Implementation + +- [ ] `p1` - **ID**: `cpt-cf-evbk-dod-consumer-subscription-lifecycle-broker` + +- DispatcherService routes by `consumer_group` GTS id to the owning shard. +- DeliveryService implements the v1 rebalance algorithm under `cluster.distributed_lock`. +- `GroupState` carries per-member filters / topic lists; rebalance respects them. +- `cursor.offset` is updated atomically via cache CAS (compare-and-swap with `>=` semantics — forward-only during streaming; the SEEK endpoint accepts any valid range pre-stream). +- `topology_version` is exposed on every poll response. +- Reaper removes subscriptions past `expires_at` and triggers rebalance. +- The eight flows above are implemented end-to-end and visible through `evbk_*` metrics. + +## 6. Acceptance Criteria + +- **AC-1 (cold JOIN)**: POST /v1/consumer-groups with `{ "client_agent": "" }` returns 201 + Location + broker-minted id + echoed `client_agent`; subsequent POST /v1/subscriptions referencing that id succeeds. POST without `client_agent` is rejected with the canonical RFC 9457 400 problem type. +- **AC-2 (seek)**: SEEK (`POST /v1/subscriptions/{id}:seek`) on assigned partitions updates `cursor.offset`; next poll returns events from the new offset; SEEK on unassigned partition returns 409. +- **AC-3 (re-JOIN)**: a poller receiving 410 Gone successfully re-JOINs with a fresh subscription id and resumes consumption. +- **AC-4 (upscale)**: adding member N+1 causes existing members to receive a new `topology_version` and updated `assigned` set within 5s. +- **AC-5 (downscale)**: graceful LEAVE redistributes partitions within 5s; silent death takes effect within `session_timeout`. +- **AC-6 (filter mutation)**: a partition handoff from v1 to v2 member preserves the cursor; no events are double-delivered to v1 after handoff. +- **AC-7 (session timeout)**: a consumer that stops polling for `session_timeout + 1s` finds its subscription reaped on next attempt; re-JOIN succeeds. +- **AC-8 (shard ownership change)**: graceful shutdown of owning shard delivers 410 to all in-flight pollers within 1s; ungraceful kill results in transport error within the cluster lock's session TTL. +- **AC-9 (typed filter happy path)**: JOIN with an interest carrying `expression_type: "gts.cf.core.events.filter.v1~cf.core.expression.cel.v1"` + `expression: "event.data.amount > 100"` succeeds; subsequent polls deliver only events matching the predicate; non-matching events are silently dropped. +- **AC-10 (no filter)**: JOIN with an interest omitting `expression_type` + `expression` succeeds; subsequent polls deliver every event matching topic + tenant + types (no engine invocation). +- **AC-11 (paired-optional violation)**: JOIN with an interest supplying `expression_type` without `expression` (or vice versa) is rejected with `400 BadRequest`. +- **AC-12 (invalid type pattern)**: JOIN with `types: ["gts.cf.core.events.event.v1~yourorg.*.placed.v1"]` (mid-pattern wildcard) is rejected with `400 BadTypePattern`. +- **AC-13 (zero-match type pattern)**: JOIN with a `types[]` pattern matching no registered types under the declared topic is rejected with `400 NoTypesMatched`. +- **AC-14 (type-belongs-to-topic)**: JOIN with a `types[]` pattern that would resolve to a type whose `parent_topic` differs from `interest.topic` is rejected with `400 TypeNotInTopic` (defense-in-depth). +- **AC-15 (rolling-deploy correctness)**: members M1 (interests pointing at `placed.v1`) and M2 (interests pointing at `placed.v2`) coexist in one group; rebalance produces correct per-member assignments; events of `placed.v1` ship only to M1; events of `placed.v2` ship only to M2. +- **AC-16 (filter eval timeout drops, does not fail poll)**: an interest whose `engine.eval` exceeds `EVAL_TIMEOUT_MICROS` produces a warn log + `evbk_filter_eval_timeout_total{consumer_group}` increment; the poll returns `200 OK` with the event dropped from this consumer's batch. + +## 7. Unit Test Plan + +- **rebalance algorithm**: parameterize over (member counts, topic subsets, partition counts); assert single-consumer-per-partition invariant + per-member topic respect. +- **cursor CAS forward-only**: SEEK with offset older than current `cursor.offset` is a no-op (forward-only during streaming). +- **topology_version monotonicity**: each mutation increments by exactly 1; concurrent mutations serialize through the lock. +- **filter evaluation order**: per-member filter applies AFTER partition assignment; events for non-assigned partitions are never evaluated. +- **interest prerequisites**: per-event delivery checks `event.topic == interest.topic`, then `event.tenant_id == interest.tenant_id`, then `event.type ∈ resolved_type_set` BEFORE invoking `engine.eval`. Non-matching prerequisite → skip interest without engine call. +- **paired-optional shape**: parameterize over (`expression_type`, `expression`) ∈ {(set, set), (set, absent), (absent, set), (absent, absent)} — first and last accepted; middle two rejected with `400 BadRequest`. +- **GTS pattern syntax**: parameterize over valid (exact, trailing `.*`, trailing `~*`, `v*` at trailing) and invalid (mid-pattern `*`, `**`, substring `vendor*`, two `*` occurrences) patterns; valid → accept; invalid → `400 BadTypePattern`. +- **Version resolution**: parameterize over (registered types, pattern) tuples and assert resolved set matches per-name-latest + minor-version-omitted rule from ADR-0005. +- **Filter context (CEL engine)**: assert `event.id` / `event.type` / ... visible; `event.meta` reference → `FilterError::CompileFailed`. + +## 8. E2E Test Plan + +- **S1 — full lifecycle**: POST /v1/consumer-groups → JOIN → SEEK → poll → SEEK (advance cursor) → poll → LEAVE; verify every state transition. +- **S2 — concurrent JOINs**: 100 members JOIN within 1s; verify all assigned at least one partition; verify no partition is double-assigned. +- **S3 — rolling deploy**: start with 5 v1 members; introduce 5 v2 members one at a time; remove v1 members; assert no event is lost, no event is double-processed. +- **S4 — shard kill**: ungracefully kill owning shard; another shard takes over within the cluster lock TTL; consumers re-JOIN and resume. +- **S5 — proxy idle behavior**: place a 25s-idle-timeout proxy between consumer and broker; verify consumer's 20s poll completes through the proxy. +- **S6 — standalone cursor loss**: in standalone mode, restart broker; subscription gone, cursor gone; new JOIN resumes from `earliest_available_offset`. +- **S7 — cluster cursor durability**: in cluster mode with persistent cache provider, restart owning shard; new shard takes over; cursor preserved; consumer resumes from `cursor.offset + 1` (i.e., SEEK sets `cursor.offset`, broker emits from `cursor.offset + 1`). +- **S8 — session timeout reap**: configure `session_timeout=PT3S`; consumer stops polling; after 5s, attempt poll; verify 404 SubscriptionNotFound; re-JOIN succeeds. diff --git a/gears/system/event-broker/docs/features/0003-topic-segment-introspection.md b/gears/system/event-broker/docs/features/0003-topic-segment-introspection.md new file mode 100644 index 000000000..b06cdf017 --- /dev/null +++ b/gears/system/event-broker/docs/features/0003-topic-segment-introspection.md @@ -0,0 +1,197 @@ + + +# Feature: Topic Segment Introspection + +- [ ] `p2` - **ID**: `cpt-cf-evbk-featstatus-topic-segment-introspection` + + + +- [1. Feature Context](#1-feature-context) + - [1.1 Overview](#11-overview) + - [1.2 Purpose](#12-purpose) + - [1.3 Actors](#13-actors) + - [1.4 References](#14-references) +- [2. Actor Flows (CDSL)](#2-actor-flows-cdsl) + - [2.1 Operator / Observability Tooling](#21-operator--observability-tooling) + - [2.2 Replay / Backfill Tooling](#22-replay--backfill-tooling) + - [2.3 Consumer Offset-Adviser (post-MVP, R57)](#23-consumer-offset-adviser-post-mvp-r57) + - [2.4 Producer Health-Check](#24-producer-health-check) +- [3. Processes / Business Logic (CDSL)](#3-processes--business-logic-cdsl) + - [Pagination Semantics](#pagination-semantics) + - [Backend Liveness](#backend-liveness) + - [Authorization](#authorization) +- [4. States (CDSL)](#4-states-cdsl) +- [5. Definitions of Done](#5-definitions-of-done) + - [Endpoint and Backend Integration](#endpoint-and-backend-integration) +- [6. Acceptance Criteria](#6-acceptance-criteria) +- [7. Unit Test Plan](#7-unit-test-plan) +- [8. E2E Test Plan](#8-e2e-test-plan) + + + +## 1. Feature Context + +### 1.1 Overview + +`GET /v1/topics/segments` exposes the storage backend's segment manifest for a `(topic, partition)` pair. The response carries the offset envelope (`start_sequence`, `end_sequence`), the time envelope (`start_time`, `end_time`), and an opaque `segments[]` array whose entries are backend-specific. The endpoint surfaces information that the broker itself does not track — segment boundaries, oldest / newest event timestamps, current high-water-mark — and lets external tooling answer questions the broker's own bookkeeping cannot. + +### 1.2 Purpose + +Topic segment introspection gives operators, observability tooling, and external replay/backfill systems read-access to a partition's persisted contents — segment boundaries, oldest/newest event timestamps, current high-water-mark — that the broker itself does not track. The feature decouples external operational tooling from the storage backend's internal layout (Kafka segments vs. DB row spans vs. S3 object boundaries) while preserving each backend's native semantics, so dashboards, capacity-planning jobs, replay schedulers, and producer health-checks can target one stable REST surface regardless of which storage backend is in use. + +Without it, operators have no way to answer routine retention / replay / lag questions short of querying each backend through its own native API — defeating the broker's pluggable-storage abstraction. + +### 1.3 Actors + +- **Operator / observability tooling**: dashboards, capacity planning. +- **Replay / backfill tooling**: external job that needs to know offset range before scheduling a re-process. +- **Consumer SDK (post-MVP, R57)**: offset-adviser for seek-to-earliest. +- **Producer health-check**: producer cross-checking its perceived state vs. the broker's persisted state. + +### 1.4 References + +- DESIGN.md §3.3 `GET /v1/topics/segments` endpoint summary +- DESIGN.md §3.2 storage-backend trait — `backend.segments(ctx, topic) -> Vec` +- DESIGN.md §3.7 reference to `received` / `max_offset` derivability +- DESIGN.md §4.6 Out of Scope (the future subscription-based backfill that supersedes external replay tooling) +- `openapi.yaml#/paths/~1v1~1topics~1segments` + +## 2. Actor Flows (CDSL) + +Pseudo-code below is Python-ish — not runnable; intent over syntax. + +### 2.1 Operator / Observability Tooling + +```python +# Scraped every 30s per (topic, partition) by Prometheus / Grafana +def scrape_lag_metrics(topic: str, partition: int) -> dict: + resp = http.get(f"/v1/topics/segments?topic={topic}&partition={partition}") + env = resp.json + cursor_offset = cursor.get((group, topic, partition)) # from broker-side cursor cache + return { + "lag": env["end_sequence"] - cursor_offset, + "segment_count": len(env["segments"]), + "oldest_event_ts": env["start_time"], + "newest_event_ts": env["end_time"], + } + +# AUTHZ: requires `topic:read` permission on T (same scope as GET /v1/topics). +``` + +### 2.2 Replay / Backfill Tooling + +```python +# Operator wants to re-process events for (T, p) between [t_start, t_end] +def plan_backfill(topic, partition, t_start, t_end): + env = http.get(f"/v1/topics/segments?topic={topic}&partition={partition}").json + # Linear interpolation across the envelope to convert timestamps -> offsets + off_start = interpolate_offset(env, t_start) + off_end = interpolate_offset(env, t_end) + schedule_reprocess_job(topic, partition, off_start, off_end) + +# In MVP there is no broker-side replay endpoint. This flow is informational +# only — the feature exists so the future subscription-based backfill (per +# DESIGN.md §4.6) has a clean place to read offset boundaries from. +``` + +### 2.3 Consumer Offset-Adviser (post-MVP, R57) + +```python +# Consumer wants to skip past events that all filters would reject +def maybe_skip_to_earliest(topic, partition, cursor_offset): + env = http.get(f"/v1/topics/segments?topic={topic}&partition={partition}").json + if cursor_offset < env["start_sequence"]: + # consumer is behind retention; SEEK forward to the earliest still-retained + http.post(f"/v1/subscriptions/{sub_id}:seek", json={ + "partition_positions": {partition: env["start_sequence"]} + }) + # else: keep normal polling + +# Pairs with the R57 offset-adviser feature (post-MVP). Until R57 lands, +# consumers can call this endpoint manually before a seek-to-earliest. +# AUTHZ: same `topic:read` permission as 2.1. +``` + +### 2.4 Producer Health-Check + +```python +# Producer cross-checks its chain state against the broker's persisted state +def check_publish_pipeline(producer_id, topic, partition): + cursors = http.get(f"/v1/producers/{producer_id}/cursors").json + segments = http.get(f"/v1/topics/segments?topic={topic}&partition={partition}").json + + last_seq = cursors[(topic, partition)]["last_sequence"] + end_seq = segments["end_sequence"] + + if last_seq < end_seq - LAG_THRESHOLD: + log.warn("publish pipeline lag", lag=end_seq - last_seq) + # events accepted by ingest but not yet visible on the consumer side + elif last_seq > end_seq: + log.error("publish desync — producer ahead of backend") + pause_publishes() + alert_operators() + # producer believes it published events the backend did not persist + # (ingest outbox backlog, partial failure, etc.) + +# AUTHZ: `topic:read` on T + the existing producer-cursor permission. +``` + +## 3. Processes / Business Logic (CDSL) + +### Pagination Semantics + +The endpoint paginates **segments**, not events. `$orderby` defaults to `start_sequence asc` (oldest segment first); `limit` caps the number of segment entries returned per page (default 100, max 100). The envelope fields (`start_sequence`, `end_sequence`, `start_time`, `end_time`) cover the FULL backend retention for the partition regardless of how many segments fit in the current page — they are computed against the backend's full manifest, not against the paginated subset. + +### Backend Liveness + +| Backend state | Endpoint response | +|---|---| +| Healthy | 200 OK with full manifest | +| Backend unreachable | 503 Service Unavailable; `Retry-After` header set | +| Backend partially scanned (e.g., S3 listing in progress) | 200 OK with envelope reflecting whatever is known; `segments[]` may be incomplete (clients should not rely on count); header `X-Manifest-Partial: true` flags the case | +| Backend reports no segments yet (empty partition) | 200 OK with `start_sequence: 0`, `end_sequence: 0`, empty `segments[]` | + +### Authorization + +The endpoint is gated by `topic:read` (same as `GET /v1/topics`). Rationale: segment metadata is not more sensitive than the topic existence itself; if a caller can list the topic, knowing its segment envelope leaks no further information. (Event payloads are protected by the consumer-side authz, not the segment metadata.) + +## 4. States (CDSL) + +The endpoint is stateless from the broker's perspective. It reflects the backend's manifest at call time and is eventually consistent — there is no broker-side caching of segment data. + +## 5. Definitions of Done + +### Endpoint and Backend Integration + +- [ ] `p2` - **ID**: `cpt-cf-evbk-dod-topic-segment-introspection-endpoint` + +- `GET /v1/topics/segments` is implemented per `openapi.yaml`. +- `backend.segments()` is wired through the storage-backend plugin trait. +- Authorization checks `topic:read` permission via the PEP. +- Pagination over the `segments[]` array works per the documented semantics. +- Backend-unavailable case returns 503 with `Retry-After`. +- Partial-scan case sets `X-Manifest-Partial: true`. +- DESIGN.md §3.3's `GET /v1/topics/segments` entry points at this feature file for use cases. + +## 6. Acceptance Criteria + +- **AC-1**: A topic with 0 published events returns `start_sequence: 0`, `end_sequence: 0`, empty `segments[]`. +- **AC-2**: A topic with N segments returns at most 100 segment entries per page; the envelope reflects the full N regardless of pagination. +- **AC-3**: A caller without `topic:read` on T receives 403 Forbidden. +- **AC-4**: With the backend simulated as unreachable, the endpoint returns 503 with `Retry-After`. +- **AC-5**: An operator dashboard built on this endpoint shows lag = `end_sequence - cursor.offset` for each consumer group on a partition. + +## 7. Unit Test Plan + +- **envelope computation**: given a manifest of N segments, assert envelope matches min/max across all segments regardless of paginated subset. +- **pagination boundary**: 250 segments + `limit=100` returns 100 entries on first page, 100 on second, 50 on third. +- **empty partition**: manifest with zero segments returns envelope `(0, 0)` and empty `segments[]`. +- **authz denial**: caller without permission gets 403 without any backend call. + +## 8. E2E Test Plan + +- **S1 — happy path**: publish 1000 events to (T, 0); call segments endpoint; assert end_sequence ≥ 1000 (some may still be pipeline-lagged). +- **S2 — operator dashboard**: scrape the endpoint every 30s over 5 minutes; assert end_sequence increases monotonically while events are being produced. +- **S3 — partial scan**: configure S3-style backend with simulated slow LIST; assert `X-Manifest-Partial: true` header on response while scan is in progress; assert envelope is non-decreasing across successive calls. +- **S4 — backend down**: bring backend offline; assert 503 + `Retry-After`; restore backend; assert next call returns 200. +- **S5 — authz boundary**: caller A with `topic:read` on T1 calls for T2 (no permission) — gets 403; T1 call succeeds. diff --git a/gears/system/event-broker/docs/features/0004-consumption-transport.md b/gears/system/event-broker/docs/features/0004-consumption-transport.md new file mode 100644 index 000000000..ff9ffa1a3 --- /dev/null +++ b/gears/system/event-broker/docs/features/0004-consumption-transport.md @@ -0,0 +1,287 @@ + + + + +# Feature: Consumption Transport + +- [ ] `p1` - **ID**: `cpt-cf-evbk-featstatus-consumption-transport` + + + +- [1. Feature Context](#1-feature-context) + - [1.1 Overview](#11-overview) + - [1.2 Purpose](#12-purpose) + - [1.3 Actors](#13-actors) + - [1.4 References](#14-references) +- [2. Actor Flows (CDSL)](#2-actor-flows-cdsl) + - [2.1 Multipart Streaming (`/events:stream`)](#21-multipart-streaming-eventsstream) + - [2.2 Server-Sent Events (`/events:sse`)](#22-server-sent-events-eventssse) +- [3. Processes / Business Logic (CDSL)](#3-processes--business-logic-cdsl) + - [Shared Frame Schema](#shared-frame-schema) + - [Application-Level Batching](#application-level-batching) + - [Heartbeat Cadence](#heartbeat-cadence) + - [Drop-on-Nth-Heartbeat Recovery](#drop-on-nth-heartbeat-recovery) + - [Topology-Change Handling (Rebalance)](#topology-change-handling-rebalance) + - [Design Rationale](#design-rationale) + - [Operator Enable / Disable](#operator-enable--disable) +- [4. States (CDSL)](#4-states-cdsl) +- [5. Definitions of Done](#5-definitions-of-done) + - [Shared Transport Layer](#shared-transport-layer) + - [Per-Transport Deliverables](#per-transport-deliverables) +- [6. Acceptance Criteria](#6-acceptance-criteria) +- [7. Unit Test Plan](#7-unit-test-plan) +- [8. E2E Test Plan](#8-e2e-test-plan) + + + +## 1. Feature Context + +### 1.1 Overview + +The broker exposes two consumption-transport endpoints for delivering events to subscriptions: + +| Transport | Endpoint | Wire format | Best for | +|---|---|---|---| +| **Multipart streaming** (default) | `GET /v1/events:stream` | `multipart/mixed; boundary=...` + `Transfer-Encoding: chunked` | Server-to-server SDKs (Rust / Go / Python / Node) — high-throughput, multi-topic / multi-partition consumers | +| **Server-Sent Events** (opt-in) | `GET /v1/events:sse` | `text/event-stream` | Browser-direct or `EventSource`-style consumers | + +Both endpoints carry the same frame schema (`event` / `heartbeat` / `control` / `topology`) and the same subscription-lifecycle error model (`404 SubscriptionNotFound`, `410 SubscriptionTerminated`). They differ only in how frames are framed on the wire (multipart parts vs SSE event records). + +Both endpoints are **delivery-only**. Cursor advance (SEEK) happens via the dedicated endpoint `POST /v1/subscriptions/{id}:seek`. + +### 1.2 Purpose + +Provide a single, long-lived streaming consumption transport that: +- delivers events with minimal latency (no request-response overhead, no fixed polling cycles); +- keeps idle connections alive across HTTP intermediaries via heartbeat frames; +- pushes batching responsibility to the consumer (application-level), giving it full control over commit / ack boundaries; +- offers a browser-friendly variant (SSE) without fragmenting the protocol design. + +### 1.3 Actors + +- **Consumer SDK / process**: opens the stream, reads frames, dispatches events to handlers, seeks (advances cursor) via the `:seek` endpoint. +- **Browser app (optional)**: opens an `EventSource` against `/events:sse`. +- **DeliveryService** (broker): emits `event` / `heartbeat` / `control` / `topology` frames; closes responses on subscription termination. + +### 1.4 References + +- [RFC 2046](https://www.rfc-editor.org/rfc/rfc2046) — `multipart/*` media types (`multipart/mixed` is the chosen subtype) +- [RFC 7230 §3.3.1](https://www.rfc-editor.org/rfc/rfc7230#section-3.3.1) — HTTP `Transfer-Encoding: chunked` +- [HTML Living Standard — Server-Sent Events](https://html.spec.whatwg.org/multipage/server-sent-events.html) +- [features/0002-consumer-subscription-lifecycle.md](0002-consumer-subscription-lifecycle.md) — JOIN / re-JOIN / LEAVE +- [DESIGN.md §3.3](../DESIGN.md) — API Contracts +- [openapi.yaml](../openapi.yaml) — authoritative REST surface + +## 2. Actor Flows (CDSL) + +### 2.1 Multipart Streaming (`/events:stream`) + +```python +# Consumer side: open a long-lived multipart connection, iterate frames as they arrive. +resp = http.get( + f"/v1/events:stream?subscription_id={sub_id}", + headers={"Accept": "multipart/mixed"}, # also accepted: */* (defaults to multipart/mixed) + stream=True, +) + +consecutive_heartbeats = 0 +for frame in parse_multipart(resp): # iterator over multipart parts as they arrive + # frame is JSON with top-level `kind` in {"event", "heartbeat", "control", "topology"} + if frame.kind == "event": + consecutive_heartbeats = 0 + process_event(frame.payload) # one event per part + elif frame.kind == "heartbeat": + consecutive_heartbeats += 1 + if consecutive_heartbeats >= K: # SDK default K = 10 ≈ 50 s of silence + break # voluntary disconnect; outer loop reopens + elif frame.kind == "control": + offsets.commit(frame.positions) # feed offset/last_examined into own store + if frame.code == "terminal": + break # last frame; outer loop re-JOINs (see below) + elif frame.kind == "topology": # loss / version bump — non-terminal + update_assignment(frame.assigned) # re-run offset-store / partition-lock routines + consecutive_heartbeats = 0 +# Stream closed. Recover by why it closed: +# saw a `terminal` control frame → re-JOIN (new subscription_id) → SEEK → reopen +# bare close (no terminating) → reopen same subscription_id: 200 → resume; 410 → re-JOIN +``` + +Notes: +- The connection is long-lived. The server emits frames continuously; the client reads as fast as it can. +- Each multipart part carries **exactly one** event (no server-side batching). +- Heartbeats arrive at the broker's configured cadence (default 5 s) on idle subscriptions; busy subscriptions suppress them. +- Cursor advance (SEEK) is out-of-band via `POST /v1/subscriptions/{id}:seek`. The stream connection plays no role in cursor management. +- **Pre-stream SEEK is required.** The SDK MUST call `POST /v1/subscriptions/{id}:seek` after JOIN (with positions resolved via `OffsetManager::position(...)`) before opening the stream. Opening `:stream` without seeded cursors returns `409 PositionsNotSet { unseeded: [(topic, partition), ...], recovery_hint }` — a defensive backstop the well-behaved SDK never observes on the happy path. See `features/0002-consumer-subscription-lifecycle.md` §2.1 for the full flow and `DESIGN.md` §3.3 for the start-position resolution semantics. + +### 2.2 Server-Sent Events (`/events:sse`) + +```javascript +// Browser side. +const es = new EventSource(`/v1/events:sse?subscription_id=${subId}`); + +es.addEventListener("event", (e) => process_event(JSON.parse(e.data))); +es.addEventListener("heartbeat", (e) => /* optional: drop-on-Nth-heartbeat */); +es.addEventListener("control", (e) => { + const a = JSON.parse(e.data); + offsets.commit(a.positions); // cursor carrier + if (a.code === "terminal") es.close(); // then re-JOIN and reopen +}); +es.addEventListener("topology", (e) => update_assignment(JSON.parse(e.data).assigned)); // loss/version — keep reading +es.onerror = () => { /* reopen same subscription_id; 410 → re-JOIN */ }; +``` + +The SSE endpoint serves `text/event-stream`. Same four frame kinds; the kind is carried in the SSE `event:` line and the JSON payload in the `data:` line. Reconnect resume uses `cursor.offset` (set via the `:seek` endpoint), not SSE's `Last-Event-ID`. + +## 3. Processes / Business Logic (CDSL) + +### Shared Frame Schema + +All transports carry the same four frame kinds. Each frame is a JSON object with a top-level `kind` field. On `/v1/events:stream` each frame is one multipart part with `Content-Type: application/json`. On `/v1/events:sse` each frame is one SSE event record with the kind in the `event:` line and the JSON in the `data:` line. + +``` +{ "kind": "event", "payload": { /* one EventEnvelope (id, type, topic, partition, offset, sequence, data, …) */ } } +{ "kind": "heartbeat", "at": "" } +{ "kind": "control", "code": "", /* "reason": "<…>" on terminal */ + "positions": [ { "topic": "...", "partition": , "offset": , "last_examined": }, ... ] } +{ "kind": "topology", "topology_version": , + "assigned": [ { "topic": "...", "partition": , "offset": , "last_examined": }, ... ] } +``` + +`offset` is the session cursor for the partition; `last_examined` is the highest offset the broker has scanned for this `(consumer_group, topic, partition)` regardless of whether the subscription filter matched — so a consumer computes true lag (including server-side-filtered events) without a separate call. Both are i64. + +**`topology`** is the full current assignment snapshot for the subscription (the complete set, not a delta). The broker emits it: +1. once at stream open, as the confirmed baseline — guaranteed to be the first frame; and +2. mid-stream, non-terminal, when a topology change leaves the subscription streamable: it lost one or more partitions (retaining ≥1), or only `topology_version` changed. + +**`control`** is the per-partition cursor carrier: the consumer feeds `positions` into its own offset store so that on reconnect it re-SEEKs from `last_examined`, skipping server-side-filtered events rather than re-scanning them. Two codes: +- `progress` — mid-stream, conditional, carrying only the **sparse** subset of partitions that need advising (chiefly the filter-saturated case where `last_examined` drifts ahead of delivered events). The broker eventually emits one for any partition whose `last_examined` drifts beyond the delivered offset by a bounded amount, so even a fully-filtered subscription learns its frontier. +- `terminal` — the terminal control frame: the **complete** final `positions` for the full assignment, emitted as the last frame, then the broker closes the stream gracefully. The `code` states the **fact** that the subscription is ending; the **reason** (`"rebalanced"`, `"lose_all"`, `"teardown"`) rides in an optional `reason` field, so consumer recovery switches on the fact, not the cause. + +The `batch` frame kind does not exist — one event per `event` frame; consumers batch at the application layer (below). + +### Application-Level Batching + +The broker emits one event per frame. Consumers that want to batch (commit N events in one DB transaction, send N events in one downstream HTTP call, etc.) batch at the application layer — typically anchored to a commit boundary that matters to the consumer (DB txn, ack horizon, time-bounded flush). This gives the consumer full control over batching semantics without coupling broker behavior to consumer commit shape. + +### Heartbeat Cadence + +- **Default**: 5 seconds. +- **Configurable** per deployment via broker configuration (operator concern). +- **Exposed** to the consumer in the JOIN response (`heartbeat_interval_ms`) so SDKs can scale their drop-on-Nth-heartbeat threshold proportionally. +- **Suppressed when busy**: an `event` frame resets the heartbeat-idle timer. Heartbeats only emit when the broker has had no events for the subscription within the cadence interval. + +The 5 s default comfortably undercuts common HTTP intermediary idle-cut thresholds (corp proxies ~60 s, AWS NLB 350 s default, ALB 60 s default). + +### Drop-on-Nth-Heartbeat Recovery + +Heartbeats prove the broker is alive but say nothing about whether the consumer's view of the subscription is fresh. If the connection has been silently degraded (mid-path NAT churn, ALB rebalance, etc.), the consumer can use a defensive recovery pattern: + +- After **K consecutive `heartbeat` frames** with no intervening `event` frame, the consumer voluntarily disconnects from the stream and re-JOINs the subscription. +- `cf-gears-event-broker-sdk` ships **K = 10** as the default (≈ 50 s of silence before reconnect). Tunable via `ConsumerBuilder::heartbeat_drop_threshold(K)`. +- The re-JOIN refreshes everything (new `subscription_id`, fresh assignment, fresh connection); group cursor is preserved on the broker side. + +The broker does not enforce or observe this pattern — it's purely a consumer self-healing convention. + +### Topology-Change Handling (Rebalance) + +A topology change is handled per subscription, by what happened to that subscription's assignment. With `gained = assigned_new \ assigned_old` and `lost = assigned_old \ assigned_new`: + +| Outcome for this subscription | Broker action | Consumer action | +|---|---|---| +| Lost a partition (retains ≥1) | non-terminal `topology` frame (full reduced assignment) | drop the lost partition, keep reading on the same connection | +| `topology_version` bump, set unchanged | non-terminal `topology` frame | re-run assignment-dependent routines (offset store, locks), keep reading | +| Gained a partition | `terminal` control frame (complete final positions) as last frame, then graceful close | commit positions → re-JOIN → new `subscription_id` + assignment → SEEK → open a fresh stream | +| Lost all partitions | `terminal` control frame + graceful close | same as gain (re-JOIN) | +| Gain + loss (reshuffle) | gain dominates → `terminal` control frame + close | re-JOIN | + +A subscription survives a loss (it simply streams fewer partitions) but a gain terminates it: a gained partition is unseeded and SEEK is pre-stream-only, so the stream must close to re-seed, and the subscription is recreated rather than mutated in place. The group cursor is group-scoped and survives the re-JOIN, so the new subscription resumes from the preserved position. Both transports behave identically — the `terminal` control frame and `topology` frame are carried as a multipart part (`/events:stream`) or an SSE event record (`/events:sse`). + +`410 SubscriptionTerminated` is returned to any request that reuses a terminated `subscription_id` — the safety net for a consumer that missed the `terminal` control frame (e.g. it crashed). A stream that closes without a `terminal` control frame is a transient drop: the consumer reopens the same `subscription_id` and resumes on `200`. + +**Handoff fence.** When a partition moves from consumer A to consumer B, the gain/loss asymmetry prevents both reading it at once: A (losing) stops on its `topology` frame immediately, while B (gaining) reaches the partition only after terminate → re-JOIN → SEEK → reopen — strictly later. No broker-held barrier is needed. + +**Livelock fencing.** A rebalance computes the assignment for the final membership set once and stamps `topology_version = N+1`; a forced re-JOIN settles into generation N+1 without recomputing the assignment or disrupting peers. A fixed stabilization window (default ~`PT1S`, advertised in the JOIN response) batches a burst of membership changes into one generation bump; consumers re-JOIN with backoff + jitter. + +**Admission.** A subscription always holds ≥1 partition. When a group already has as many members as partitions, a further JOIN that would receive zero partitions is refused with `429` + `Retry-After` and body `code: "GroupAtCapacity"` (distinct from the rate-limit `RateLimitExceeded`); the consumer retries the JOIN later. There are no zero-partition standbys, no standby streams, and no assignment-polling channel. + +### Design Rationale + +- **Terminate on gain, keep streaming on loss.** A gained partition is unseeded and SEEK is pre-stream-only, so the stream must close to re-seed; recreating the subscription (rather than mutating its assignment in place) keeps `410`-on-reuse semantics crisp. A loss needs none of this — the consumer just stops reading the dropped partition. Alternatives set aside: terminating on *any* change (forces a needless reconnect on a loss); surviving a gain via in-place re-seek (muddies `410` and mutates assignment in place). +- **Control frame is the cursor carrier; no held connection.** The `terminal` control frame delivers the complete final `positions` as the last frame, then the broker closes immediately — it does not hold the connection open for a drain window. A narrow/saturating filter needs this: its `last_examined` runs far ahead of delivered offsets, so it must re-SEEK from the true frontier to avoid re-scanning server-filtered events (resolves **R57**). A reopen `topology` baseline cannot substitute, because `PositionsNotSet` forces the re-SEEK before the new stream opens. Alternative set aside: a bare close with status-code-only recovery (loses the final frontier on a narrow filter). +- **Consumer owns durable progress.** The control frame feeds the consumer's own offset store; the broker never persists it (see [ADR-0006](../ADR/0006-offset-authority.md)). The session cursor is ephemeral and auto-advances with delivery. +- **No standbys.** Surplus consumers are refused at JOIN (`429 GroupAtCapacity`) rather than admitted with empty assignments, so the protocol needs no standby stream or assignment poll. + +### Operator Enable / Disable + +Each transport can be enabled / disabled per deployment: + +| Transport | Default | Notes | +|---|---|---| +| `/events:stream` | enabled | Required v1 baseline. Disabling it breaks all server-to-server consumers. | +| `/events:sse` | disabled in v1 | Opt-in via deployment configuration. Browser-direct consumers are not the primary v1 target. | + +## 4. States (CDSL) + +A single per-consumer state machine governs both transports: + +``` +Idle → connecting via GET /v1/events:stream (or :sse) +Streaming → emitting frames (events + heartbeats); the steady state +Closing → terminal control frame sent / DELETE / consumer disconnected; cleanup +Terminated → connection ended; consumer re-JOINs to enter Idle again +``` + +A **loss** or a bare `topology_version` change stays within `Streaming` — the broker emits a non-terminal `topology` frame and the stream continues. A **gain** or **lose-all** transitions `Streaming → Closing`: the broker emits the `terminal` control frame and closes, and the consumer re-JOINs (a fresh subscription) to re-enter `Idle`. + +## 5. Definitions of Done + +### Shared Transport Layer + +- [ ] `p1` - **ID**: `cpt-cf-evbk-dod-consumption-transport-shared` +- Broker emits all four frame kinds (`event`, `heartbeat`, `control`, `topology`) over both transports. +- `control` carries `positions`; `progress` (sparse, mid-stream) and `terminal` (complete, terminal) codes are both emitted. +- `topology` is the full-assignment snapshot, emitted at open and on a non-terminal change (loss / version bump). +- Rebalance: a loss → non-terminal `topology` frame (keep streaming); a gain / lose-all → `terminal` control frame + graceful close → consumer re-JOINs; `410` on reuse of the terminated id. +- Admission: a JOIN that would receive zero partitions returns `429` + `Retry-After` (`GroupAtCapacity`); no zero-partition standbys. +- Heartbeat cadence configurable, 5 s default, advertised in the JOIN response; the stabilization window is advertised likewise. +- Subscription lifecycle (404 / 410) is surfaced identically across transports. +- One event per multipart part (no server-side batching). + +### Per-Transport Deliverables + +- **`/v1/events:stream`**: + - `multipart/mixed` over chunked transfer encoding + - Long-lived response + - `Accept` header negotiation: `multipart/mixed` or `*/*` → served; anything else → `406 Not Acceptable` + - Heartbeats at 5 s cadence on idle +- **`/v1/events:sse`**: + - `text/event-stream` + - Same frame kinds via SSE `event:` lines + - Opt-in via deployment configuration + +## 6. Acceptance Criteria + +- AC-1: Consumer reads N events from `/v1/events:stream` and receives exactly N `event` frames (one per multipart part) in offset-monotonic order per `(topic, partition)`. +- AC-2: Idle subscription emits `heartbeat` frames at the configured cadence (default 5 s). +- AC-3: SDK consumer reconnects after K consecutive heartbeats (K = 10 default in `cf-gears-event-broker-sdk`). +- AC-4: Browser consumer opens `EventSource` against `/v1/events:sse` and receives the same four frame kinds via SSE events. +- AC-5: A partition **loss** (subscription retains ≥1) emits a non-terminal `topology` frame mid-stream without closing the connection; the consumer keeps streaming its remaining partitions. +- AC-5b: A partition **gain** (or lose-all) emits a `terminal` control frame with complete final `positions` as the last frame, then the broker closes gracefully; reuse of the terminated `subscription_id` returns `410 SubscriptionTerminated`. +- AC-5c: A JOIN that would receive zero partitions (group already full) returns `429` + `Retry-After` with body `code: "GroupAtCapacity"`. +- AC-6: `Accept: application/json` against `/v1/events:stream` returns `406 Not Acceptable`. +- AC-7: `GET /v1/events:poll` (legacy path) returns `404 Not Found`. + +## 7. Unit Test Plan + +- **frame-emitter**: parameterize over (transport × frame kind) and assert correct framing output (multipart boundaries / SSE `event:` lines). +- **heartbeat scheduler**: simulate idle / busy timelines, assert heartbeats emit at cadence on idle and are suppressed when events are flowing. +- **multipart parser** (consumer side): assert one event per part; reject responses where a part carries an event array. + +## 8. E2E Test Plan + +- **E2E-1**: Publish 100 events; consume via `/v1/events:stream`; assert all 100 arrive in monotonic order across partitions. +- **E2E-2**: Open `/v1/events:stream` against an empty topic for 30 s; assert ≥ 5 `heartbeat` frames arrive (5 s cadence). +- **E2E-3**: Open `/v1/events:stream`; cause a **loss** mid-stream (another consumer joins and takes a partition); assert a non-terminal `topology` frame arrives and the connection stays open. +- **E2E-3b**: Open `/v1/events:stream`; cause a **gain** mid-stream (a peer leaves, this consumer gains its partition); assert a `terminal` control frame with final `positions` arrives as the last frame, the connection closes, and re-JOIN yields the gained partition; reuse of the old `subscription_id` returns `410`. +- **E2E-4**: Open `/v1/events:stream` with `Accept: application/json`; assert `406 Not Acceptable`. +- **E2E-5**: SDK reconnect — block `event` flow for K × heartbeat_cadence; assert the SDK consumer voluntarily reconnects and re-JOINs. diff --git a/gears/system/event-broker/docs/migration.sql b/gears/system/event-broker/docs/migration.sql new file mode 100644 index 000000000..a5ed928c8 --- /dev/null +++ b/gears/system/event-broker/docs/migration.sql @@ -0,0 +1,146 @@ +-- Created: 2026-05-11 by Constructor Tech + +-- ── GTS type path domain ───────────────────────────────────────────────────── +-- GTS type identifier: single or chained, always ends with ~ (schema, not instance). +-- Format: gts.....v[.][~]*~ +-- Spec: https://github.com/GlobalTypeSystem/gts-spec +CREATE DOMAIN gts_type_path AS TEXT + CHECK ( + LENGTH(VALUE) <= 1024 + AND VALUE ~ '^gts\.[a-z_][a-z0-9_]*\.[a-z_][a-z0-9_]*\.[a-z_][a-z0-9_]*\.[a-z_][a-z0-9_]*\.v(0|[1-9][0-9]*)(\.(0|[1-9][0-9]*))?(?:~[a-z_][a-z0-9_]*\.[a-z_][a-z0-9_]*\.[a-z_][a-z0-9_]*\.[a-z_][a-z0-9_]*\.v(0|[1-9][0-9]*)(\.(0|[1-9][0-9]*))?)*~$' + ); + +-- ── Event Log (built-in DB storage backend) ────────────────────────────────── +-- This is the schema for the built-in DB storage backend specifically. Other backends +-- (Kafka, S3, file) have their own native storage layouts; this DDL is internal to +-- the DB backend's `persist` / `query` implementation. +-- +-- No `UNIQUE (topic, partition, offset)` constraint — backend offset assignment +-- combined with single-writer-via-outbox order preservation guarantees uniqueness +-- without a global cross-segment unique index. The PRIMARY KEY on `id` (UUID, +-- client-provided) catches accidental duplicate event submissions. +-- +-- Note: `tenant_id` is present for authorization filtering (queries are tenant-scoped +-- via SecureConn) but is NOT part of any uniqueness or sequencing key — topic GTS +-- identifiers are globally unique by namespace. +CREATE TABLE evbk_event ( + id UUID NOT NULL, + tenant_id UUID NOT NULL, -- publisher's tenant; authz scope, not sequencing scope + topic TEXT NOT NULL, + partition INTEGER NOT NULL, + type TEXT NOT NULL, + producer_id UUID, -- chained / monotonic modes only; null in stateless + previous BIGINT, -- chained mode only; null otherwise + sequence BIGINT, -- producer-set chain (chained / monotonic); null in stateless + "offset" BIGINT NOT NULL, -- backend-assigned, monotonic per (topic, partition); consumer-visible + offset_time TIMESTAMP NOT NULL, + source TEXT NOT NULL, + subject TEXT NOT NULL, + subject_type TEXT NOT NULL, + occurred_at TIMESTAMP NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + trace_parent TEXT, + data JSONB NOT NULL, + + PRIMARY KEY (id) +); + +CREATE INDEX idx_evbk_event_topic_partition_offset ON evbk_event (topic, partition, "offset"); + + +-- ── Consumer Group Registry ────────────────────────────────────────────────── +-- Persistent registry row — outlives every subscription, every delivery shard +-- restart, every cache transition. Lives in the broker's persistent DB alongside +-- `evbk_topic` and `evbk_event_type`, NOT in the cache (which would re-introduce +-- the silent-collision bug after a cache wipe). +-- +-- Two creation paths, one table — each path exclusive to one shape: +-- * `POST /v1/consumer_groups` — anonymous-only (`id` is broker-minted ~; +-- request body MUST NOT carry `id`); `tenant_id` and `owner_principal` come +-- from SecurityContext at create time. JOIN authz is owner-tenant equality. +-- * `types_registry` upsert — named-only. At startup the broker reads +-- `types_registry` for instances of `gts.cf.core.events.consumer_group.v1~` +-- and upserts each into this table with `kind='named'`. Idempotent. JOIN authz +-- is explicit `:consume` grant on the concrete GTS instance via the PEP. +CREATE TABLE evbk_consumer_group ( + id gts_type_path NOT NULL, -- full GTS identifier (named: gts.cf.core.events.consumer_group.v1~vendor.foo.v1; anonymous: ~{uuid}) + tenant_id UUID NOT NULL, -- owner tenant (from SecurityContext at create) + owner_principal TEXT NOT NULL, -- creator principal (from SecurityContext at create) + kind TEXT NOT NULL, -- 'named' | 'anonymous' (derived from id-instance shape, stored for fast filtering) + description TEXT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + + PRIMARY KEY (id) +); + +CREATE INDEX idx_evbk_consumer_group_tenant ON evbk_consumer_group (tenant_id); + + +-- ── Producer Registration ──────────────────────────────────────────────────── +-- Producer registration row: principal binding + declared mode + client_agent +-- diagnostic hint. `producer_id` is broker-minted at POST /v1/producers and +-- bound to `owner_principal`. `mode` is immutable for the lifetime of the row +-- (chained / monotonic only — stateless does not register). +-- `client_agent` is informational (RFC 9110 User-Agent grammar, ASCII, 1–256 +-- bytes), surfaced in logs and metric labels alongside producer_id; it does +-- NOT participate in dedup / authz / ownership decisions. +-- The Reaper purges rows whose `last_seen_at` is older than the platform-wide +-- producer-registration TTL (default P30D). Cascade-deletes the producer's +-- evbk_producer_state rows on purge. +CREATE TABLE evbk_producer ( + producer_id UUID PRIMARY KEY, + owner_principal TEXT NOT NULL, + mode TEXT NOT NULL CHECK (mode IN ('chained', 'monotonic')), + client_agent TEXT NOT NULL, + created_at TIMESTAMP NOT NULL, + last_seen_at TIMESTAMP NOT NULL +); + +-- ── Producer Chain State ───────────────────────────────────────────────────── +-- Producer chain state keyed by (producer_id, topic, partition) — global, not +-- narrowed by tenant. `last_sequence` tracks the highest producer-set +-- `meta.sequence` accepted from this producer for this (topic, partition). The +-- Reaper worker cleans up records where `last_seen_at` is older than the +-- topic's `retention` (capped at P14D). Cascade-deleted when the parent +-- evbk_producer registration row is aged out. +-- +-- Chain check at ingest (chained mode): on incoming event with +-- meta.{previous, sequence} the broker verifies +-- meta.previous == state.last_sequence AND +-- meta.sequence > state.last_sequence. +-- Match → accept and update last_sequence = meta.sequence. +-- Previous mismatch → 412 SequenceViolation (recover via +-- GET /v1/producers/{producer_id}/cursors). +-- Duplicate → return original event with 200 OK. +-- +-- Monotonicity check (monotonic mode, no `meta.previous`): just +-- meta.sequence > state.last_sequence. Gaps allowed in MVP; future +-- enforcement settable at registration. +-- +-- Concurrent updates to a single (producer_id, topic, partition) row act as the +-- fencing mechanism — exactly one writer advances `last_sequence` at a time. +CREATE TABLE evbk_producer_state ( + producer_id UUID NOT NULL REFERENCES evbk_producer(producer_id) ON DELETE CASCADE, + topic TEXT NOT NULL, + partition INTEGER NOT NULL, + last_sequence BIGINT NOT NULL DEFAULT 0, + last_seen_at TIMESTAMP NOT NULL, + + PRIMARY KEY (producer_id, topic, partition) +); + +CREATE INDEX idx_evbk_producer_state_last_seen ON evbk_producer_state (last_seen_at); + + +-- ── Pending DDL (not yet specified in DESIGN.md) ───────────────────────────── +-- The following tables are listed in DESIGN.md §3.7 "Core Tables" inventory but +-- their CREATE TABLE statements are not yet written into the design. They will +-- be added when their schemas are finalized and documented in DESIGN.md: +-- +-- * evbk_topic — Topic definitions. PK: id (GTS string). +-- * evbk_event_type — Event type definitions. PK: id (GTS string); FK to evbk_topic. +-- * evbk_segment — Topic storage segments. PK: (topic, partition, segment_id). +-- +-- Subscription, cursor, and group-state are intentionally NOT in this file — they +-- live in the ClusterCapabilities-backed cache (see DESIGN.md §3.2 Subscription +-- Resolution Cache / GroupState Cache). diff --git a/gears/system/event-broker/docs/openapi.yaml b/gears/system/event-broker/docs/openapi.yaml new file mode 100644 index 000000000..d34eb8f66 --- /dev/null +++ b/gears/system/event-broker/docs/openapi.yaml @@ -0,0 +1,778 @@ +# Created: 2026-05-11 by Constructor Tech + +openapi: 3.1.0 +info: + title: Event Broker API + version: 1.0.0 + description: | + Event Broker is a tenant-scoped event streaming primitive for Gears modules. + Producers publish typed events to topics via single or batch submissions; consumers + subscribe via `multipart/mixed` streaming (`/v1/events:stream`) or browser-native + Server-Sent Events (`/v1/events:sse`) — see `features/0004-consumption-transport.md` + — and receive events filtered by type, subject, and per-member filter expressions. + + This file is the authoritative machine-readable public contract for the Event Broker + v1 REST surface. PRD.md and DESIGN.md intentionally summarize behavior and refer + here for wire shapes, field semantics, and HTTP-level error mapping. + +servers: + - url: https://api.example.com + +tags: + - name: events + description: Producer-side event submission (single + batch) and consumer-side long-poll. + - name: producers + description: Producer registration and idempotent-chain lifecycle. + - name: topics + description: Topic introspection and storage-segment metadata. + - name: event-types + description: Event-type introspection (read-only; registration via SDK/types_registry). + - name: consumer-groups + description: Consumer group registry (anonymous create path + read/list/delete). + - name: subscriptions + description: Consumer subscription lifecycle — JOIN, poll, seek, leave. + +paths: + + /v1/events: + post: + tags: [events] + summary: Publish a single event + description: | + Enqueue one event into the per-topic ingest outbox. Default response is + `202 Accepted` once the event is durably enqueued; opt into synchronous + backend persistence via the `Sync-Wait: true` header or `?wait=persisted` + query parameter. Offset is assigned asynchronously by the storage backend + and is never returned inline. + requestBody: + required: true + content: + application/json: + schema: + $ref: "./schemas/event.v1.schema.json" + responses: + "202": + description: Accepted, durably enqueued in the outbox + "201": + description: Persisted to backend (sync mode) + "400": + description: Validation error (InvalidPartition, InvalidTraceParent, etc.) + "403": + description: Forbidden + "412": + description: SequenceViolation — producer's `previous`/`sequence` does not match the broker's stored `last_sequence`. Response body includes the broker's current `last_sequence` so the producer can resync. + + /v1/events:batch: + post: + tags: [events] + summary: Publish a batch of events (atomic per topic) + description: | + Atomic per-topic batch of up to 100 events. All-or-nothing per topic. + Mixing topics in one batch is rejected. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [events] + properties: + events: + type: array + items: + $ref: "./schemas/event.v1.schema.json" + minItems: 1 + maxItems: 100 + responses: + "202": + description: Accepted + "400": + description: Validation error + "403": + description: Forbidden + "412": + description: SequenceViolation — atomic batch rejection when any event in the batch fails dedup validation + "413": + description: BatchTooLarge + + /v1/events:stream: + get: + tags: [events] + summary: Multipart event consumption (default transport) + description: | + The single consumption endpoint for `multipart/mixed`-framed delivery. + Long-lived: the response stays open and emits frames as events arrive; + heartbeat frames keep intermediaries from cutting idle connections. + Wire format is `multipart/mixed; boundary=<...>` carried over + `Transfer-Encoding: chunked`. Each multipart part carries exactly one + frame whose JSON body has a top-level `kind` of `event`, `heartbeat`, + `topology`, or `control`. See `features/0004-consumption-transport.md`. + + Frame shapes: + - `{ "kind": "event", "payload": { /* one EventEnvelope */ } }` + - `{ "kind": "heartbeat", "at": "" }` + - `{ "kind": "topology", "topology_version": , "assigned": [{ "topic", "partition", "offset": , "last_examined": }] }` + — the full current assignment snapshot; emitted once at open (baseline, + the first frame) and again mid-stream, non-terminal, on a partition loss + (subscription retains ≥1) or a `topology_version`-only change. + - `{ "kind": "control", "code": "progress" | "terminal", "positions": [{ "topic", "partition", "offset": , "last_examined": }] }` + — the cursor carrier. `code: "progress"` is a sparse mid-stream cursor + update. `code: "terminal"` carries the COMPLETE final positions as the + LAST frame on a gain / lose-all, after which the broker closes the stream + gracefully; the consumer commits the positions and re-JOINs. A `terminal` + frame MAY include an optional `reason` (`rebalanced | lose_all | teardown`). + A partition loss is NOT control-carried — it is an in-band `topology` frame. + + Cursor seed/advance is via `POST /v1/subscriptions/{id}:seek` (pre-stream + only) — this stream endpoint is delivery-only. Browser consumers wanting + Server-Sent Events use `/v1/events:sse` instead. + parameters: + - name: subscription_id + in: query + required: true + schema: { type: string, format: uuid } + responses: + "200": + description: Long-lived multipart/mixed event stream (Transfer-Encoding chunked). + content: + multipart/mixed: + schema: + description: | + Stream of multipart parts. Each part has `Content-Type: application/json` + and a JSON body with top-level `kind` of `event | heartbeat | topology | control`. + type: string + "400": + description: Bad Request (missing or invalid `subscription_id`). + "404": + description: SubscriptionNotFound — consumer must re-JOIN with a new subscription. + "406": + description: | + Not Acceptable. The client's `Accept` header excludes `multipart/mixed`. + Response body lists the supported media types. + "409": + description: | + Two `code`s, distinguished in the body: + `PositionsNotSet` — one or more assigned `(topic, partition)` pairs have + no committed cursor for the subscription's group; the consumer SHALL + SEEK the listed partitions via `POST /v1/subscriptions/{id}:seek` and + retry (`{ unseeded: [{topic, partition}, ...], recovery_hint: "..." }`). + A well-behaved SDK seeds positions before opening the stream and should + not normally observe this. + `StreamingInProgress` — a stream is already open on this `subscription_id` + (a second `:stream`, or any non-DELETE call on a streaming subscription); + the existing stream is unaffected. + "410": + description: | + SubscriptionTerminated — the `subscription_id` has already terminated + (gain, lose-all, LEAVE/DELETE, or lease expiry). Returned on any reuse + of a terminated id; the consumer re-JOINs for a new subscription. + + /v1/events:sse: + get: + tags: [events] + summary: Server-Sent Events consumption (opt-in) + description: | + Browser-native `text/event-stream` consumption endpoint. Same frame + kinds as `/v1/events:stream` (`event | heartbeat | topology | control`), + but framed as SSE events with the `event:` line carrying the kind and + the JSON payload on a `data:` line. Reconnect resume uses `cursor.offset` + (not `Last-Event-ID`). Disabled by default in v1; enabled via deployment + configuration. See `features/0004-consumption-transport.md`. + parameters: + - name: subscription_id + in: query + required: true + schema: { type: string, format: uuid } + responses: + "200": + description: Long-lived text/event-stream + content: + text/event-stream: {} + "404": + description: SubscriptionNotFound (or transport disabled in deployment) + "410": + description: SubscriptionTerminated — consumer must re-JOIN + + /v1/producers: + post: + tags: [producers] + summary: Register a producer and obtain a broker-minted producer_id + description: | + Mints a `producer_id` (UUID) bound to the calling principal. The `mode` field + determines the dedup protocol: `chained` (chain-linked sequence) or `monotonic` + (monotonically increasing sequence). Stateless producers do not register. + Re-registration mints a fresh `producer_id`; the broker does NOT reuse prior ids. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [mode] + additionalProperties: false + properties: + mode: + type: string + enum: [chained, monotonic] + description: Dedup protocol mode for this producer. + responses: + "201": + description: Producer registered; id minted + headers: + Location: + description: URL of the producer resource (`/v1/producers/`) + schema: { type: string } + content: + application/json: + schema: + type: object + required: [id] + properties: + id: + type: string + format: uuid + "400": + description: InvalidMode — mode is not chained or monotonic + "403": + description: Forbidden + + /v1/producers/{id}/cursors: + parameters: + - name: id + in: path + required: true + schema: { type: string, format: uuid } + description: Producer id (broker-minted UUID). + get: + tags: [producers] + summary: Read per-(topic, partition) last_sequence for a registered producer + description: | + Returns the broker's known `last_sequence` per `(topic, partition)` for desync + recovery. Principal-bound — only the registering principal may call this. + Returns an empty array if the producer has not yet published any events. + responses: + "200": + description: Cursor array (may be empty) + content: + application/json: + schema: + type: array + items: + type: object + required: [topic, partition, last_sequence] + additionalProperties: false + properties: + topic: { type: string } + partition: { type: integer, minimum: 0 } + last_sequence: { type: integer, format: int64 } + "403": + description: Forbidden — calling principal does not own this producer_id + "404": + description: ProducerNotFound + + /v1/producers/{id}:reset: + parameters: + - name: id + in: path + required: true + schema: { type: string, format: uuid } + description: Producer id (broker-minted UUID). + post: + tags: [producers] + summary: Reset the ingest-side chain state for a producer + description: | + Operator-driven chain reset. Clears `evbk_producer_state` rows and emits an audit + record. When the request body is absent or empty, all rows for the producer are + cleared. When `topic` and `partition` are present, only the matching row is cleared. + Principal-bound — only the registering principal may call this. + requestBody: + required: false + content: + application/json: + schema: + type: object + additionalProperties: false + properties: + topic: { type: string } + partition: { type: integer, minimum: 0 } + responses: + "200": + description: Reset applied; audit record emitted + "403": + description: Forbidden — calling principal does not own this producer_id + "404": + description: ProducerNotFound + + /v1/topics: + get: + tags: [topics] + summary: List topics visible to the caller + description: | + Returns a paginated, filterable list of topics. To retrieve a single topic by its + GTS identifier use `$filter=id eq ''`. No `GET /v1/topics/{id}` path exists. + parameters: + - name: $filter + in: query + schema: { type: string } + description: OData v4 filter expression. Filterable fields — `id` (eq/ne/in). + - name: $orderby + in: query + schema: { type: string } + - name: limit + in: query + schema: { type: integer, minimum: 1, maximum: 200, default: 25 } + - name: cursor + in: query + schema: { type: string } + description: Opaque pagination token from a previous response. + responses: + "200": + description: Page of topics + content: + application/json: + schema: + type: object + required: [items, page_info] + properties: + items: + type: array + items: + $ref: "./schemas/topic.v1.schema.json" + page_info: + $ref: "#/components/schemas/PageInfo" + "400": + description: Invalid $filter expression or cursor mismatch + + /v1/topics/segments: + get: + tags: [topics] + summary: Get storage segments for a (topic, partition) + description: | + Returns the backend's segment manifest for a specific (topic, partition). + Backend-specific in shape; consumers treat `segments[]` entries as opaque + except for the documented envelope. See + `features/0003-topic-segment-introspection.md` for use cases and pagination + semantics. + parameters: + - name: topic + in: query + required: true + schema: { type: string } + - name: partition + in: query + required: true + schema: { type: integer, minimum: 0 } + - name: $orderby + in: query + schema: { type: string, default: "start_sequence asc" } + - name: limit + in: query + schema: { type: integer, minimum: 1, maximum: 100, default: 100 } + responses: + "200": + description: Segment manifest + content: + application/json: + schema: + type: object + required: [topic, partition, start_sequence, end_sequence, start_time, end_time, segments] + properties: + topic: { type: string } + partition: { type: integer } + start_sequence: { type: integer, format: int64 } + end_sequence: { type: integer, format: int64 } + start_time: { type: string, format: date-time } + end_time: { type: string, format: date-time } + segments: + type: array + items: { type: object, additionalProperties: true } + "400": + description: InvalidPartition + "404": + description: TopicNotFound + + /v1/event-types: + get: + tags: [event-types] + summary: List event types visible to the caller + description: | + Returns a paginated, filterable list of event types. Event types are registered + via the in-process SDK and types_registry — no creation path exists in the REST API. + To retrieve a single event type by GTS id use `$filter=id eq ''`. + parameters: + - name: $filter + in: query + schema: { type: string } + description: OData v4 filter expression. Filterable fields — `id` (eq/ne/in), `topic` (eq/ne). + - name: $orderby + in: query + schema: { type: string } + - name: limit + in: query + schema: { type: integer, minimum: 1, maximum: 200, default: 25 } + - name: cursor + in: query + schema: { type: string } + description: Opaque pagination token from a previous response. + responses: + "200": + description: Page of event types + content: + application/json: + schema: + type: object + required: [items, page_info] + properties: + items: + type: array + items: + $ref: "./schemas/event_type.v1.schema.json" + page_info: + $ref: "#/components/schemas/PageInfo" + "400": + description: Invalid $filter expression or cursor mismatch + + /v1/consumer-groups: + post: + tags: [consumer-groups] + summary: Register an anonymous consumer group (broker-minted id) + description: | + Mints `gts.cf.core.events.consumer_group.v1~` server-side. The request + body MUST NOT carry an `id`. The minted identifier is returned in the + response body and the `Location` header. Caller distributes it to its + consumer fleet via its own coordination mechanism (DB, ConfigMap, env var). + requestBody: + required: false + content: + application/json: + schema: + $ref: "./schemas/consumer_group.v1.schema.json#/definitions/CreateRequest" + responses: + "201": + description: Created + headers: + Location: + description: URL of the newly created consumer-group resource (`/v1/consumer-groups/`) + schema: { type: string } + content: + application/json: + schema: + $ref: "./schemas/consumer_group.v1.schema.json" + "403": + description: Forbidden (caller lacks `consumer_group:define` permission) + get: + tags: [consumer-groups] + summary: List consumer groups visible to the caller + parameters: + - name: $filter + in: query + schema: { type: string } + description: "OData v4 filter expression. Filterable fields — `id` (eq/in), `kind` (eq)." + - name: $orderby + in: query + schema: { type: string } + - name: limit + in: query + schema: { type: integer, minimum: 1, maximum: 200, default: 25 } + - name: cursor + in: query + schema: { type: string } + description: Opaque pagination token from a previous response. + responses: + "200": + description: Page of consumer groups + content: + application/json: + schema: + type: object + required: [items, page_info] + properties: + items: + type: array + items: + $ref: "./schemas/consumer_group.v1.schema.json" + page_info: + $ref: "#/components/schemas/PageInfo" + + /v1/consumer-groups/{id}: + parameters: + - name: id + in: path + required: true + schema: { type: string } + description: Full GTS consumer-group identifier (URL-encoded). + get: + tags: [consumer-groups] + summary: Read a registered consumer group + responses: + "200": + description: Consumer group record + content: + application/json: + schema: + $ref: "./schemas/consumer_group.v1.schema.json" + "403": + description: Forbidden + "404": + description: ConsumerGroupNotFound + delete: + tags: [consumer-groups] + summary: Remove a consumer group from the registry + description: Allowed only when there are no active members (cache state empty for this group). + responses: + "204": + description: Deleted + "403": + description: Forbidden + "404": + description: ConsumerGroupNotFound + "409": + description: ConsumerGroupHasActiveMembers + + /v1/subscriptions: + get: + tags: [subscriptions] + summary: List active subscriptions visible to the caller + parameters: + - name: $filter + in: query + schema: { type: string } + description: "OData v4 filter expression. Filterable fields: `id` (eq/ne/in), `consumer_group` (eq/ne), `expires_at` (gt/ge/lt/le)." + - name: $orderby + in: query + schema: { type: string } + - name: limit + in: query + schema: { type: integer, minimum: 1, maximum: 200, default: 25 } + - name: cursor + in: query + schema: { type: string } + description: Opaque pagination token from a previous response. + responses: + "200": + description: Page of subscriptions (may be empty) + content: + application/json: + schema: + type: object + required: [items, page_info] + properties: + items: + type: array + items: + $ref: "./schemas/subscription.v1.schema.json" + page_info: + $ref: "#/components/schemas/PageInfo" + "400": + description: Invalid $filter expression or cursor mismatch + post: + tags: [subscriptions] + summary: JOIN — register a subscription against a consumer group + description: | + Creates a subscription with one or more typed-filter `interests[]` entries. Each + interest is topic-anchored, declares event-type patterns scoped to that topic, and + optionally carries a paired engine-typed filter expression. See + `ADR/0005-subscription-filter-typing.md` and + `features/0002-consumer-subscription-lifecycle.md`. Subject to per-tenant rate cap + (default 60/min). + requestBody: + required: true + content: + application/json: + schema: + $ref: "./schemas/subscription.v1.schema.json" + responses: + "201": + description: Subscription created; assignment computed + content: + application/json: + schema: + $ref: "./schemas/subscription.v1.schema.json" + "400": + description: | + Validation error. Common codes: + `BadRequest` (missing required field, paired-optional violation: exactly one of + `expression_type` / `expression` present), + `BadTypePattern` (pattern violates GTS spec §10 wildcard rules — wildcard not at + segment boundary, mid-pattern wildcard, multiple wildcards, substring within segment), + `NoTypesMatched` (a `types[]` pattern resolved to zero registered types under the + declared topic), + `TypeNotInTopic` (defense-in-depth: resolved type's `parent_topic` differs from + `interest.topic`), + `TooManyInterests` (>64), `TooManyTypes` (>32 per interest), + `ExpressionTooLong` (>4096 bytes), `CompiledFilterTooLarge` (>64KiB), + `InvalidFilterExpression` (engine compile failed; engine diagnostic in body), + `UnknownFilterEngine` (`expression_type` GTS does not resolve to a registered + engine). + "403": + description: | + `TopicNotAuthorized` (calling principal lacks `consume` on `interest.topic`), + `EventTypeNotAuthorized` (principal lacks `consume` on a resolved event type; + the offending type is in the response body), + `TenantIdNotAuthorized` (platform tenant resolver denied `interest.tenant_id`). + "404": + description: | + `ConsumerGroupNotFound` (anonymous: must POST /v1/consumer-groups first; named: + must register via types_registry), + `TopicNotFound` (an `interest.topic` is not registered in `evbk_topic`). + "429": + description: | + Two `code`s, distinguished in the body, both with `Retry-After`: + `RateLimitExceeded` — per-tenant JOIN rate cap breached; + `GroupAtCapacity` — the group already has as many members as partitions, + so this JOIN would receive zero partitions (no standbys). The consumer + retries the JOIN later; a slot frees when a member leaves. + + /v1/subscriptions/{id}: + parameters: + - name: id + in: path + required: true + schema: { type: string, format: uuid } + get: + tags: [subscriptions] + summary: Read a single subscription by id + responses: + "200": + description: Subscription record + content: + application/json: + schema: + $ref: "./schemas/subscription.v1.schema.json" + "403": + description: Forbidden + "404": + description: SubscriptionNotFound + delete: + tags: [subscriptions] + summary: LEAVE — terminate a subscription + responses: + "204": + description: Subscription removed; rebalance triggered + "404": + description: SubscriptionNotFound + + /v1/subscriptions/{id}:seek: + parameters: + - name: id + in: path + required: true + schema: { type: string, format: uuid } + post: + tags: [subscriptions] + summary: SEEK — set the group cursor for assigned (topic, partition) pairs + description: | + SEEK is a **pre-stream-only** operation: the consumer SDK calls it once + after JOIN (before opening `:stream`) to declare the starting position for + each assigned partition. The SDK resolves the position via + `OffsetManager::position(...)`; the value is either an exact last-processed + offset (the broker emits from `offset + 1`) or a sentinel + (`"earliest"` / `"latest"` / `"at:"`) the broker resolves at + admission. Any value in the valid range `[retention_floor - 1, HWM]` is + accepted, including a backward move for replay — there is no forward-only + rule. + + While a stream is open on the subscription the session cursor auto-advances + with delivery; the consumer does NOT SEEK to record progress. A SEEK issued + while `:stream` is open is rejected with `409 StreamingInProgress`. + + Each integer is the **last offset the consumer has processed**; the broker + emits from `cursor + 1`. Per-partition behavior when a partition is no + longer assigned is documented in + `features/0002-consumer-subscription-lifecycle.md`. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [partition_positions] + properties: + partition_positions: + type: array + description: | + Per-partition seek positions. Each entry identifies the partition and + the desired starting position. Values are either an integer + (last-processed offset), or one of the string sentinels + "earliest", "latest", or "at:" (e.g., "at:2026-06-14T10:00:00Z"). + items: + type: object + required: [topic, partition, value] + additionalProperties: false + properties: + topic: { type: string } + partition: { type: integer, minimum: 0 } + value: + oneOf: + - type: integer + format: int64 + description: Last-processed offset. Valid range [retention_floor - 1, high_water_mark]. + - type: string + enum: ["earliest", "latest"] + description: Server-resolved sentinel. + - type: string + pattern: "^at:.+" + description: | + Timestamp sentinel. "at:" resolves to the offset of the + first event whose occurred_at >= timestamp. Boundary behaviour: + before retention floor -> retention floor offset; beyond HWM -> HWM. + responses: + "200": + description: | + Cursor seeded / updated. Response body returns the resolved integer + offsets per partition (sentinels expanded to integers). + content: + application/json: + schema: + type: object + properties: + partition_positions: + type: array + items: + type: object + required: [topic, partition, value] + additionalProperties: false + properties: + topic: { type: string } + partition: { type: integer, minimum: 0 } + value: { type: integer, format: int64 } + "400": + description: | + InvalidInitialPosition — offset outside valid range for one or more partitions. + InvalidTimestamp — "at:" value is not a valid ISO-8601 UTC timestamp. + RFC-9457 Problem Details body identifies offending partitions and reasons. + "404": + description: SubscriptionNotFound + "409": + description: | + `PartitionNotAssigned` — request references a partition not currently + assigned to this subscription. + `StreamingInProgress` — a SEEK was issued while a `:stream` is open on + this subscription; SEEK is pre-stream-only (the cursor auto-advances + with delivery). + +components: + schemas: + Topic: + $ref: "./schemas/topic.v1.schema.json" + EventType: + $ref: "./schemas/event_type.v1.schema.json" + Event: + $ref: "./schemas/event.v1.schema.json" + Subscription: + $ref: "./schemas/subscription.v1.schema.json" + ConsumerGroup: + $ref: "./schemas/consumer_group.v1.schema.json" + PageInfo: + type: object + required: [limit] + properties: + next_cursor: + type: ["string", "null"] + description: Opaque token for the next page. Null when no further pages exist. + prev_cursor: + type: ["string", "null"] + description: Opaque token for the previous page. Null on the first page. + limit: + type: integer + description: Page size used for this response. diff --git a/gears/system/event-broker/docs/schemas/consumer_group.v1.schema.json b/gears/system/event-broker/docs/schemas/consumer_group.v1.schema.json new file mode 100644 index 000000000..725668c32 --- /dev/null +++ b/gears/system/event-broker/docs/schemas/consumer_group.v1.schema.json @@ -0,0 +1,60 @@ +{ + "$id": "gts://gts.cf.core.events.consumer_group.v1~", + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Consumer group resource. Persistent broker resource (evbk_consumer_group). Two creation paths: anonymous (broker-minted UUID via POST /v1/consumer-groups) and named (vendor-namespaced via types_registry upsert).", + "type": "object", + "additionalProperties": false, + "required": ["id", "tenant_id", "owner_principal_id", "kind"], + "properties": { + "id": { + "description": "Full GTS consumer-group identifier. Broker-minted for anonymous groups; registered via types_registry for named groups. Always server-set — clients MUST NOT supply this field on creation.", + "type": "string", + "readOnly": true + }, + "tenant_id": { + "description": "Owner tenant (from SecurityContext at create time). Non-overridable.", + "type": "string", + "format": "uuid", + "readOnly": true + }, + "owner_principal_id": { + "description": "Creator principal (from SecurityContext at create time). For named groups, this is the registering module's principal. Non-overridable.", + "type": "string", + "readOnly": true + }, + "kind": { + "description": "Group kind — derived from id-instance shape, stored for fast filtering.", + "type": "string", + "enum": ["named", "anonymous"], + "readOnly": true + }, + "description": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true + } + }, + "definitions": { + "CreateRequest": { + "description": "Request body for POST /v1/consumer-groups. The broker mints the identifier server-side; this body MUST NOT carry an id.", + "type": "object", + "required": ["client_agent"], + "additionalProperties": false, + "properties": { + "client_agent": { + "description": "RFC 9110 User-Agent grammar; ASCII; 1-256 bytes. Diagnostic hint surfaced in operational logs. No broker-side semantic beyond logging.", + "type": "string", + "pattern": "^[\\x20-\\x7E]+$", + "minLength": 1, + "maxLength": 256 + }, + "description": { + "type": "string" + } + } + } + } +} diff --git a/gears/system/event-broker/docs/schemas/event.v1.schema.json b/gears/system/event-broker/docs/schemas/event.v1.schema.json new file mode 100644 index 000000000..2cdb264ea --- /dev/null +++ b/gears/system/event-broker/docs/schemas/event.v1.schema.json @@ -0,0 +1,134 @@ +{ + "$id": "gts://gts.cf.core.events.event.v1~", + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Event schema. Single canonical resource shape used by both publish input (POST /v1/events, POST /v1/events:batch) and read responses (poll / query). Per-direction semantics are encoded via JSON Schema field-level markers: `writeOnly` fields (meta) are accepted on publish and stripped on read; `readOnly` fields (partition, sequence, sequence_time) are server-stamped on read and rejected with 400 BadRequest if supplied on publish. The top-level `required` list is the union of publish-required and read-required fields; strict-validator producers must filter `readOnly` fields before submission. See ADR/0003-event-schema.md.", + "type": "object", + "required": [ + "id", + "type", + "topic", + "tenant_id", + "source", + "subject", + "subject_type", + "occurred_at", + "partition", + "sequence", + "sequence_time" + ], + "additionalProperties": false, + "properties": { + "id": { + "description": "Client-provided unique event identifier (UUID).", + "type": "string", + "format": "uuid" + }, + "type": { + "description": "Full GTS event-type identifier. ASCII only.", + "type": "string", + "pattern": "^[\\x20-\\x7E]+$", + "maxLength": 512 + }, + "topic": { + "description": "Full GTS topic identifier. ASCII only.", + "type": "string", + "pattern": "^[\\x20-\\x7E]+$", + "maxLength": 512 + }, + "tenant_id": { + "description": "Tenant the event belongs to. Producer-supplied. Ingest validates the producer's principal is authorized to publish to this tenant via the platform's authz resolver; unauthorized publishes are rejected with 403 TenantIdNotAuthorized.", + "type": "string", + "format": "uuid" + }, + "source": { + "description": "Origin of the event (e.g., service name). ASCII only.", + "type": "string", + "pattern": "^[\\x20-\\x7E]+$", + "maxLength": 256 + }, + "subject": { + "description": "Subject entity identifier for event correlation, filtering, and consumer semantics. Producers that need subject-level ordering set `partition_key` to the subject value. ASCII only.", + "type": "string", + "pattern": "^[\\x20-\\x7E]+$", + "maxLength": 1024, + "minLength": 1 + }, + "subject_type": { + "description": "Full GTS subject-type identifier — the type of the entity the event is about. Required and NOT derivable from `type`: event types may be generic (e.g., `rule_applied`) across multiple subject kinds, and events may have no `data` body to introspect. ASCII only.", + "type": "string", + "pattern": "^[\\x20-\\x7E]+$", + "maxLength": 512 + }, + "partition_key": { + "description": "Optional producer-supplied routing key for partition selection. When present: partition = murmur3_32(ascii_bytes(partition_key)) % topic.partitions. When absent: same hash applied to `tenant_id` (per-tenant ordering by default). There is no explicit producer-set partition override. See ADR/0002-partition-selection.md. ASCII only, max 1024 bytes.", + "type": "string", + "pattern": "^[\\x20-\\x7E]*$", + "maxLength": 1024 + }, + "occurred_at": { + "description": "When the event occurred (producer-stamped). ISO 8601 / RFC 3339 timestamp.", + "type": "string", + "format": "date-time" + }, + "trace_parent": { + "description": "W3C Trace Context parent. Carries the trace context the event was produced under; surfaced on read responses for consumer-side trace correlation. Validated against the W3C traceparent header format.", + "type": "string", + "pattern": "^[0-9a-f]{2}-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$" + }, + "data": { + "description": "Event payload, validated against the event type's data_schema at ingest. The only field where UTF-8 (or any non-ASCII bytes) is permitted; all other event fields are ASCII per platform convention. May be absent for body-less events (e.g., notification-only events whose semantics are fully carried by `type` + `subject`).", + "type": "object" + }, + "partition": { + "description": "Server-stamped partition for the event, surfaced on read. Computed at ingest via the partition contract (see ADR/0002-partition-selection.md). Producers MUST NOT supply this field on publish; doing so is rejected with 400 BadRequest.", + "type": "integer", + "format": "int32", + "minimum": 0, + "readOnly": true + }, + "sequence": { + "description": "Server-assigned monotonic ordering key per (topic, partition), surfaced on read. The only sequence consumers paginate by. Distinct from `meta.sequence` (publish-only producer-side chain field). Producers MUST NOT supply this field on publish; doing so is rejected with 400 BadRequest.", + "type": "integer", + "format": "int64", + "minimum": 0, + "readOnly": true + }, + "sequence_time": { + "description": "Server-stamped timestamp recording when `sequence` was assigned. Surfaced on read. Producers MUST NOT supply this field on publish; doing so is rejected with 400 BadRequest.", + "type": "string", + "format": "date-time", + "readOnly": true + }, + "meta": { + "description": "Optional publish-time transport-metadata block. Carries producer-protocol fields (producer_id, previous, sequence) for chained / monotonic modes. Omit entirely for stateless publish. Stripped on read responses; consumers MUST NOT rely on it. See ADR/0003-event-schema.md § Decision and ADR/0004-idempotent-producer-protocol.md.", + "type": "object", + "additionalProperties": false, + "required": ["version"], + "writeOnly": true, + "properties": { + "version": { + "description": "Meta-block schema version. The broker accepts version <= current_supported and rejects newer with 400 UnknownMetaVersion.", + "type": "integer", + "minimum": 1 + }, + "producer_id": { + "description": "Producer registered via POST /v1/producers; bound to the calling principal. Required for chained / monotonic mode publishes. Omit for stateless. ASCII (UUID format).", + "type": "string", + "format": "uuid" + }, + "previous": { + "description": "Predecessor's sequence for chain dedup. Required and only valid in chained mode (validated server-side against the registered mode of meta.producer_id). On the first chained-mode publish for a (producer_id, topic, partition), set to 0.", + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "sequence": { + "description": "Producer-assigned monotonic sequence per (producer_id, topic, partition). Required in chained and monotonic modes; omitted in stateless. The broker validates ordering rules per the registered mode. Distinct from the top-level `sequence` field (which appears only on read responses and is server-assigned).", + "type": "integer", + "format": "int64", + "minimum": 1 + } + } + } + } +} diff --git a/gears/system/event-broker/docs/schemas/event_type.v1.schema.json b/gears/system/event-broker/docs/schemas/event_type.v1.schema.json new file mode 100644 index 000000000..b68991367 --- /dev/null +++ b/gears/system/event-broker/docs/schemas/event_type.v1.schema.json @@ -0,0 +1,31 @@ +{ + "$id": "gts://gts.cf.core.events.event_type.v1~", + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Event type resource. Defines the payload contract for events published on a topic. Identified by a GTS event-type identifier and bound to a topic.", + "type": "object", + "additionalProperties": false, + "required": ["id", "topic", "data_schema"], + "properties": { + "id": { + "description": "Full GTS event-type identifier (e.g., gts.cf.core.events.event_type.v1~vendor.users.created.v1).", + "type": "string", + "readOnly": true + }, + "topic": { + "description": "Full GTS topic identifier this event type belongs to.", + "type": "string" + }, + "description": { + "type": "string" + }, + "data_schema": { + "description": "JSON Schema for the event payload. v1 treats event types as immutable: any change requires a new event type with a new GTS identifier.", + "type": "object" + }, + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true + } + } +} diff --git a/gears/system/event-broker/docs/schemas/subscription.v1.schema.json b/gears/system/event-broker/docs/schemas/subscription.v1.schema.json new file mode 100644 index 000000000..70d4ee502 --- /dev/null +++ b/gears/system/event-broker/docs/schemas/subscription.v1.schema.json @@ -0,0 +1,128 @@ +{ + "$id": "gts://gts.cf.core.events.subscription.v1~", + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Subscription resource. Ephemeral, in-cache representation of a single consumer instance bound to a consumer group. The consumer declares one or more `interests[]` — topic-anchored, typed-filter selections. Per ADR/0005-subscription-filter-typing.md.", + "type": "object", + "required": ["consumer_group", "client_agent", "interests"], + "additionalProperties": false, + "properties": { + "id": { + "description": "Broker-minted subscription identifier (UUID).", + "type": "string", + "format": "uuid", + "readOnly": true + }, + "consumer_group": { + "description": "Full GTS consumer-group identifier. Either named (vendor-namespaced) or anonymous (~{uuid}).", + "type": "string", + "pattern": "^[\\x20-\\x7E]+$" + }, + "client_agent": { + "description": "RFC 9110 User-Agent grammar; ASCII; 1-256 bytes. Informational diagnostic hint surfaced in operational logs (NEVER as a metric label). Required at create per ADR-0004.", + "type": "string", + "pattern": "^[\\x20-\\x7E]+$", + "minLength": 1, + "maxLength": 256 + }, + "interests": { + "description": "Array of topic-anchored typed-filter selections. >=1 entry; <=64 entries (compile-time const MAX_INTERESTS_PER_SUBSCRIPTION). Different members of the same consumer group MAY declare different `interests[]` sets (rolling-deploy preserved by construction). Per ADR/0005-subscription-filter-typing.md.", + "type": "array", + "minItems": 1, + "maxItems": 64, + "items": { "$ref": "#/definitions/Interest" } + }, + "session_timeout": { + "description": "ISO 8601 duration; consumer must poll (heartbeat) within this window or be reaped. Default and max set by broker configuration.", + "type": "string", + "format": "duration" + }, + "assigned": { + "description": "Subset of the group's (topic, partition) pairs assigned to this subscription by the rebalance algorithm. Computed at JOIN and updated on every topology change. Topic-centric — matches the existing ack / seek mechanics.", + "type": "array", + "readOnly": true, + "items": { + "type": "object", + "required": ["topic", "partition"], + "additionalProperties": false, + "properties": { + "topic": { "type": "string" }, + "partition": { "type": "integer", "minimum": 0 } + } + } + }, + "topology_version": { + "description": "Group topology version at the time this subscription's assignment was computed. Consumers detect topology changes by comparing this between poll responses.", + "type": "integer", + "format": "int64", + "readOnly": true + }, + "expires_at": { + "description": "Wall-clock instant when this subscription will be reaped if no further poll arrives.", + "type": "string", + "format": "date-time", + "readOnly": true + } + }, + "definitions": { + "Interest": { + "description": "A single topic-anchored typed-filter selection. Per ADR/0005-subscription-filter-typing.md.", + "type": "object", + "required": ["topic", "tenant_id", "types"], + "additionalProperties": false, + "properties": { + "topic": { + "description": "Full GTS topic identifier. Partition / rebalance / authz unit (Kafka-style). Required.", + "type": "string", + "pattern": "^[\\x20-\\x7E]+$" + }, + "tenant_id": { + "description": "Tenant scope. Authz-validated by the platform tenant resolver against the calling principal. Different interests MAY declare different tenant_ids (system services consuming across multiple tenants).", + "type": "string", + "format": "uuid" + }, + "max_depth": { + "description": "Descendant depth for tenant hierarchy traversal. 0 = current tenant only (default). 1 = direct children. null = unlimited descendants, bounded by barrier_mode.", + "type": ["integer", "null"], + "minimum": 0, + "default": 0 + }, + "barrier_mode": { + "description": "How to handle self-managed tenant boundaries during hierarchy traversal. respect (default) stops at self_managed=true tenants. ignore traverses through them.", + "type": "string", + "enum": ["respect", "ignore"], + "default": "respect" + }, + "types": { + "description": "GTS event-type-instance patterns. >=1 entry; <=32 entries (compile-time const MAX_TYPES_PER_INTEREST). Wildcards per GTS spec §10 (single trailing `*` at a segment boundary). Patterns scoped to the declared `topic` — types belonging to other topics are never matched. Per-name-latest + minor-version-omitted matching applied at JOIN.", + "type": "array", + "minItems": 1, + "maxItems": 32, + "items": { + "type": "string", + "pattern": "^[\\x20-\\x7E]+$", + "maxLength": 1024 + } + }, + "filter": { + "description": "Optional per-interest filter. When present, both engine and expression are required. The broker validates engine resolves to a registered filter engine and compiles expression at JOIN time.", + "type": "object", + "required": ["engine", "expression"], + "additionalProperties": false, + "properties": { + "engine": { + "description": "Full GTS identifier of the filter engine (e.g. gts.cf.core.events.filter.v1~cf.core.expression.cel.v1).", + "type": "string", + "pattern": "^[\\x20-\\x7E]+$" + }, + "expression": { + "description": "Engine-specific source string. 1–4096 bytes. For CEL, a CEL-syntax string.", + "type": "string", + "minLength": 1, + "maxLength": 4096 + } + } + } + } + } + } +} diff --git a/gears/system/event-broker/docs/schemas/topic.v1.schema.json b/gears/system/event-broker/docs/schemas/topic.v1.schema.json new file mode 100644 index 000000000..e397567ec --- /dev/null +++ b/gears/system/event-broker/docs/schemas/topic.v1.schema.json @@ -0,0 +1,38 @@ +{ + "$id": "gts://gts.cf.core.events.topic.v1~", + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Topic resource. A named logical event stream divided into a fixed number of partitions. Sequencing, offsets, and ordering are scoped to a single (topic, partition).", + "type": "object", + "additionalProperties": false, + "required": ["id", "partitions"], + "properties": { + "id": { + "description": "Full GTS topic identifier (e.g., gts.cf.core.events.topic.v1~vendor.users.v1).", + "type": "string", + "readOnly": true + }, + "description": { + "type": "string" + }, + "partitions": { + "description": "Number of partitions. Required, no default. Fixed at topic creation; cannot be grown or shrunk on a live topic.", + "type": "integer", + "minimum": 1 + }, + "retention": { + "description": "ISO 8601 duration (e.g., PT24H) controlling the TTL for evbk_producer_state rows for this topic. Capped at P14D (14 days); values exceeding the cap are rejected at topic creation/update.", + "type": "string", + "format": "duration" + }, + "streaming": { + "description": "Opaque backend configuration. Validated against the chosen backend type's config_schema at topic registration. The well-known sub-field 'backend_selector' (with 'type' + 'metadata' filters) is used by the broker to resolve the backend instance via cluster.discover_shards().", + "type": "object", + "additionalProperties": true + }, + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true + } + } +} diff --git a/gears/system/event-broker/event-broker-sdk/Cargo.toml b/gears/system/event-broker/event-broker-sdk/Cargo.toml new file mode 100644 index 000000000..fbf34765d --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/Cargo.toml @@ -0,0 +1,53 @@ +[package] +name = "cf-gears-event-broker-sdk" +description = "SDK for the Event Broker: high-level typed event publishing and consumption" +version = "0.1.0" +edition.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +rust-version.workspace = true +readme = "README.md" +keywords = ["cyberfabric", "cf-gears", "event-broker"] +categories = ["asynchronous"] + +[package.metadata.docs.rs] +all-features = true + +[lib] +name = "event_broker_sdk" +path = "src/lib.rs" + +[features] +default = [] +db = ["dep:sea-orm", "dep:toolkit-db", "toolkit-db/sqlite"] +outbox = ["db", "toolkit-db/preview-outbox"] +integration = [] +test-util = ["dep:async-stream"] + +[dependencies] +async-trait = { workspace = true } +thiserror = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +uuid = { workspace = true, features = ["v4", "v5", "v7", "serde"] } +chrono = { workspace = true, features = ["clock", "serde"] } +tracing = { workspace = true } +futures-core = { workspace = true } +futures-util = { workspace = true } +tokio = { workspace = true, features = ["rt", "sync", "time"] } +tokio-util = { workspace = true, features = ["rt"] } +async-stream = { workspace = true, optional = true } +toolkit-security = { workspace = true } +toolkit-canonical-errors = { workspace = true } +toolkit-stable-hash = { workspace = true } +jsonschema = { workspace = true } + +gts-id = { workspace = true } +toolkit-gts = { workspace = true } +toolkit-db = { workspace = true, optional = true } +sea-orm = { workspace = true, optional = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt", "sync"] } +trybuild = { workspace = true } diff --git a/gears/system/event-broker/event-broker-sdk/README.md b/gears/system/event-broker/event-broker-sdk/README.md new file mode 100644 index 000000000..4dc9dc7c5 --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/README.md @@ -0,0 +1,348 @@ +# cf-gears-event-broker-sdk + +High-level Rust SDK for the Cyberfabric Event Broker. + +Wire concerns (JSON serialisation, partition selection, producer-chain bookkeeping, +subscription lifecycle recovery, canonical error handling) are handled inside the +SDK. Callers work with their own typed event structs, a single `EventBroker` trait, +and structured `Producer` / `Consumer` builders. + +## Error model + +The SDK traits and wrappers use domain-readable local errors in-process: +`EventBroker` returns `EventBrokerError`, `EventBrokerBackend` returns +`StorageBackendError`, and producer/consumer wrappers return `EventBrokerError` +for local typed validation, retry, and dispatch. Those errors are canonical-error +compatible: before errors cross an API or transport boundary they convert to the +canonical categories and RFC 9457 `Problem` representation from +`toolkit-canonical-errors`. + +`Problem.instance` and `Problem.trace_id` are boundary-owned fields. SDK domain +errors do not treat them as business data; HTTP or RPC middleware attaches them +when converting a `CanonicalError` into the final problem response. + +## Design source + +Sourced from `modules/system/event-broker/docs/DESIGN.md` on the `event-broker-design` +branch. + +``` +DESIGN_PIN = bb8e169ee04eb40bdda18ff3a01da980c86fa546 +``` + +--- + +## Producer quick-start + +### DB-free direct producer + +```rust +use std::borrow::Cow; +use std::sync::Arc; + +use event_broker_sdk::{ + DirectDeduplication, EventBroker, Producer, ProducerIdentity, TypedEvent, +}; + +#[derive(Serialize, Deserialize)] +struct OrderCreated { order_id: Uuid, total_cents: i64 } + +impl TypedEvent for OrderCreated { + const TYPE_ID: &'static str = "gts.cf.core.events.event.v1~orders.created.v1"; + const TOPIC: &'static str = "gts.cf.core.events.topic.v1~orders.v1"; + const SUBJECT_TYPE: &'static str = "gts.cf.core.events.subject.v1~order.v1"; + const SOURCE: &'static str = "order-service"; + fn subject(&self) -> Cow<'_, str> { Cow::Owned(self.order_id.to_string()) } +} + +// Obtain from ClientHub. +let broker = hub.get::()?; + +let producer = Producer::builder() + .broker(Arc::clone(&broker)) + .security_context(ctx.clone()) + .identity(ProducerIdentity::new().source("order-service")) + .deduplication(DirectDeduplication::stateless()) + .topics(["gts.cf.core.events.topic.v1~orders.v1"]) + .event_type_patterns(["gts.cf.core.events.event.v1~orders.*"]) + .prepare_all() + .await?; + +producer.publish(OrderCreated { order_id, total_cents: 4299 }).await?; +producer.publish_persisted(OrderCreated { order_id, total_cents: 4299 }).await?; +``` + +### DB-aware managed outbox producer (`outbox` feature) + +```rust +use event_broker_sdk::{ + DbDeduplication, DbProducer, MissingProducerRegistration, ProducerIdentity, + ProducerMode, UnknownProducerRegistration, producer_registration_migrations, +}; +use toolkit_db::outbox::{Outbox, Partitions}; + +// Run explicitly during service migration, before constructing DbProducer. +toolkit_db::migration_runner::run_migrations_for_gear( + &db, + "event-broker-producer", + producer_registration_migrations(), +) +.await?; + +let producer = DbProducer::builder() + .broker(Arc::clone(&broker)) + .db(db.clone()) + .security_context(ctx.clone()) + .identity( + ProducerIdentity::new() + .source("order-service") + .client_agent("order-service/1.0"), + ) + .deduplication( + DbDeduplication::managed(ProducerMode::Chained) + .key("orders.created") + .on_missing(MissingProducerRegistration::RegisterNew) + .on_unknown(UnknownProducerRegistration::RegisterNew), + ) + .topics(["gts.cf.core.events.topic.v1~orders.v1"]) + .event_type_patterns(["gts.cf.core.events.event.v1~orders.*"]) + .prepare_all() + .await?; + +let event_outbox = producer.outbox_queue("event-broker-producer", Partitions::of(16))?; +let outbox_handle = event_outbox + .register(Outbox::builder(db.clone())) + .start() + .await?; +let producer_outbox = event_outbox.bind(&outbox_handle); + +let mut txn = db.begin().await?; +write_business_state(&txn).await?; +producer_outbox.enqueue(&txn, OrderCreated { ... }).await?; +txn.commit().await?; +``` + +`DbProducer` validates typed events before writing producer outbox rows. In lazy +validation mode, call `producer.prepare::().await?` or +`producer.prepare_all().await?` before opening the business transaction; enqueue +does not call Event Broker while the transaction is open. + +### Producer configuration matrix + +| Surface | Deduplication | Local DB | Producer id | Multi-instance notes | +|---|---|---|---| +| `Producer` | `DirectDeduplication::stateless()` | No | None | Safe for horizontally scaled producers when consumers are idempotent | +| `Producer` | `register_on_start(mode)` | No | Fresh broker-issued id per process | Deduplication identity resets on process restart | +| `Producer` | `reuse(mode, producer_id)` | No | Caller-supplied broker-issued id | Caller must provide durable id storage and single-writer coordination | +| `DbProducer` | `DbDeduplication::stateless()` | Yes | None | Uses DB only for producer surface/outbox integration, not producer id | +| `DbProducer` | `managed(mode).key(...)` | Yes | Loaded or registered broker-issued id | Preferred durable chained/monotonic outbox path | + +Producer ids are always broker-issued. The SDK does not mint producer ids and +does not persist a separate last-sent-sequence table. Direct non-stateless +producers rebuild in-memory sequence state from Event Broker cursors on start. +Outbox producers use toolkit-db `OutboxMessage.seq` as the durable local +sequence and Event Broker cursors as the authoritative accepted sequence. + +One `ProducerOutboxQueue` can carry all topics configured on a `DbProducer`. +The SDK maps `(topic, broker_partition)` to one producer outbox partition, so +topic partition counts and outbox queue partition counts can differ without +breaking ordering for a topic partition. + +The service owns toolkit-db outbox lifecycle: run toolkit-db outbox migrations, +register queues, start workers, tune leases, and stop the `OutboxHandle`. +`ProducerOutboxQueue` only binds the SDK producer queue and leased processor to +the service-provided builder. + +--- + +## Consumer quick-start + +Consumers use a **typestate builder** for commit mode: + +| Builder state | Outcome enum | Commit handle | Use when | +|---|---|---|---| +| `offset_manager(InMemoryOffsetManager)` | `HandlerOutcome` or `BatchHandlerOutcome` | None | Simple, in-process cursor | +| `offset_manager(custom CommitOffset)` | `HandlerOutcome` or `BatchHandlerOutcome` | None | Remote or custom cursor | +| `offset_manager(LocalDbOffsetManager)` | `HandlerOutcome` | `TxCommitHandle` | Atomic DB cursor | + +Dead-letter behavior is handler-owned policy. If a permanent failure should be parked, the +handler writes it through `event_broker_sdk::dlq` helpers or application code, then returns +`Success` only after the parking operation is durable. If parking fails, return retry or an +error so the source offset does not advance. + +### Single-event consumer + +```rust +use event_broker_sdk::{ + ConsumerBuilder, ConsumerError, ConsumerGroupRef, ConsumerProfile, EventTypeRef, + Fallback, HandlerOutcome, InMemoryOffsetManager, RawEvent, SingleEventHandler, + SubscriptionInterest, TopicRef, +}; + +struct BillingProjector; + +#[async_trait] +impl SingleEventHandler for BillingProjector { + async fn handle(&self, event: RawEvent, attempts: u16) + -> Result + { + // match on event.type_id and process + Ok(HandlerOutcome::Success) + } +} + +let handle = broker + .consumer_builder() + .group(ConsumerGroupRef::auto_anonymous("billing-projector")) + .subscription_interests([SubscriptionInterest::builder() + .topic(TopicRef::gts("gts.cf.core.events.topic.v1~orders.v1")) + .types([EventTypeRef::gts_pattern("gts.cf.core.events.event.v1~orders.*")]) + .build()?]) + .profile(ConsumerProfile::low_latency()) + .offset_manager(InMemoryOffsetManager::new(Fallback::Earliest)) + .handler(BillingProjector) + .start() + .await?; + +handle.stop().await?; +``` + +### Batch consumer + +```rust +use event_broker_sdk::{BatchHandlerOutcome, ConsumerBatching, ConsumerError, ConsumerHandler, EventBatch}; +use std::time::Duration; + +struct BatchProjector; + +#[async_trait] +impl ConsumerHandler for BatchProjector { + async fn handle_batch(&self, batch: &EventBatch<'_>, attempts: u16) + -> Result + { + let chunk = batch.next_chunk(batch.len()); + for event in chunk { + // process events from one topic partition + } + Ok(BatchHandlerOutcome::AdvanceThrough { + offset: chunk.last().expect("non-empty batch").offset, + }) + } +} + +let handle = broker + .consumer_builder() + .group(ConsumerGroupRef::auto_anonymous("billing-batch")) + .subscription_interests([orders_interest]) + .batching(ConsumerBatching { max_events: 128, max_wait: Duration::from_millis(250) }) + .offset_manager(InMemoryOffsetManager::new(Fallback::Earliest)) + .batch_handler(BatchProjector) + .start() + .await?; +``` + +### Routed handlers + +```rust +let handle = broker + .consumer_builder() + .group(ConsumerGroupRef::auto_anonymous("commerce-router")) + .subscription_interests([orders_interest]) + .offset_manager(InMemoryOffsetManager::new(Fallback::Earliest)) + .default_handler(DefaultProjector) + .route() + .topic(TopicRef::gts("gts.cf.core.events.topic.v1~orders.v1")) + .event_type(EventTypeRef::gts("gts.cf.core.events.event.v1~orders.created.v1")) + .handler(OrderCreatedProjector) + .start() + .await?; +``` + +### Consumer with DB cursor and outbox-backed DLQ (`outbox` feature) + +```rust +use std::sync::Arc; + +use event_broker_sdk::dlq::{ConsumerDlqOutbox, DeadLetterRecord}; +use event_broker_sdk::{ + ConsumerError, Fallback, HandlerOutcome, LocalDbOffsetManager, RawEvent, + TxCommitHandle, TxSingleEventHandler, +}; + +#[async_trait] +impl TxSingleEventHandler for MyHandler { + async fn handle( + &self, + event: RawEvent, + attempts: u16, + commit: TxCommitHandle, + ) + -> Result + { + if attempts > 5 { + let record = DeadLetterRecord::builder(&event, "too many retries") + .attempts(attempts) + .build(); + + self.db.transaction_ref(|tx| { + Box::pin(async move { + self.dlq.enqueue(tx, record).await?; + commit.commit_offset_in_tx(tx, event.offset).await?; + Ok(()) + }) + }).await?; + + return Ok(HandlerOutcome::Success); + } + + self.db.transaction_ref(|tx| { + Box::pin(async move { + self.project(tx, &event).await?; + commit.commit_offset_in_tx(tx, event.offset).await?; // offset + business state atomic + Ok(()) + }) + }).await?; + + Ok(HandlerOutcome::Success) + } +} + +// This starts the service-owned DLQ outbox queue. The SDK helper only enqueues +// a durable handoff record; your outbox processor owns final DLQ delivery. +let outbox_handle = toolkit_db::outbox::Outbox::builder(db.clone()) + .queue("consumer-dlq", toolkit_db::outbox::Partitions::of(4)) + .leased(MyDlqProcessor) + .start() + .await?; + +let dlq = ConsumerDlqOutbox::builder(Arc::clone(outbox_handle.outbox())) + .queue("consumer-dlq") + .partitions(4) + .build(); + +let handle = broker + .consumer_builder() + .group(...) + .subscription_interests([...]) + .offset_manager(LocalDbOffsetManager::new(db.clone(), Fallback::Earliest)) + .handler(MyHandler { db, dlq }) + .start() + .await?; +``` + +If the main business transaction already rolled back, open a new transaction for +the DLQ handoff and offset skip. If that DLQ transaction fails, return an error +from the handler so the source offset is not advanced. Services that need a +custom table, remote sink, or in-memory parking can still implement +`DeadLetterSink` directly. + +--- + +## Features + +| Feature | Enables | Extra deps | +|---|---|---| +| (default) | DB-free direct producer, consumer | — | +| `db` | `LocalDbOffsetManager`, `TxCommitHandle` | `toolkit-db` | +| `outbox` | DB-aware producer outbox helper | `db`, `toolkit-db/preview-outbox` | +| `integration` | Integration tests (gated) | — | diff --git a/gears/system/event-broker/event-broker-sdk/src/api.rs b/gears/system/event-broker/event-broker-sdk/src/api.rs new file mode 100644 index 000000000..dad21a3f0 --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/src/api.rs @@ -0,0 +1,584 @@ +use std::num::NonZeroU32; +use std::time::Duration; + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +use crate::error::{EventBrokerError, StorageBackendError}; +use crate::ids::{ConsumerGroupId, ProducerId, SubscriptionId}; +use crate::models::Event; +use crate::models::{ + ConsumerGroup, ConsumerGroupQuery, CreateConsumerGroupRequest, EventType, Page, + PartitionLeader, PartitionRange, ResetScope, Subscription, Topic, TopicSegment, +}; + +// --- Supporting types --------------------------------------------------------- + +/// Producer deduplication mode declared at broker registration. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ProducerMode { + /// No producer id and no broker-side idempotency metadata. + Stateless, + /// Idempotency by `producer_id + sequence`. + Monotonic, + /// Idempotency by `producer_id + previous + sequence`. + #[default] + Chained, +} + +/// Result of a single event publish. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IngestOutcome { + /// The event was admitted before persistence confirmation. + Accepted, + /// The event was durably persisted. + Persisted, + /// The event matched an idempotent duplicate. + Duplicate, +} + +/// Broker cursor for one `(topic, partition)` pair. +#[derive(Debug, Clone)] +pub struct ProducerCursor { + pub topic: String, + pub partition: u32, + pub last_sequence: i64, +} + +/// Where the consumer wants the broker to begin emitting for an assigned +/// `(topic, partition)`. The integer in [`ResolvedPosition::Exact`] is the +/// last offset the consumer has already processed. The broker computes +/// "emit from offset + 1" server-side. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ResolvedPosition { + /// Last offset the consumer has processed; broker emits from offset + 1. + Exact(i64), + /// Broker-resolved: emit from the partition's retention floor onwards. + Earliest, + /// Broker-resolved: emit only events admitted after this SEEK. + Latest, + /// Broker-resolved: seek to the first offset whose `occurred_at` is at or + /// after the given ISO-8601 timestamp. + AtTimestamp(String), +} + +/// One per-partition seed for the pre-stream SEEK call. +#[derive(Debug, Clone)] +pub struct SeekPosition { + pub topic: String, + pub partition: u32, + pub value: ResolvedPosition, +} + +/// Partition assignment returned from a JOIN. +/// The starting cursor is established separately via SEEK. +#[derive(Debug, Clone)] +pub struct AssignedPartition { + pub topic: String, + pub partition: u32, +} + +/// Response returned from a JOIN. +#[derive(Debug, Clone)] +pub struct SubscriptionAssignment { + pub subscription_id: SubscriptionId, + pub topology_version: i64, + pub expires_at: chrono::DateTime, + pub assigned: Vec, +} + +/// Request body for a JOIN. +#[derive(Debug, Clone)] +pub struct JoinRequest { + pub group: ConsumerGroupId, + /// RFC 9110 User-Agent grammar; ASCII 1-256 bytes. + pub client_agent: String, + /// Per-member interests (topic-anchored typed-filter selections per ADR-0005). + pub interests: Vec, + /// Session TTL, refreshed on each poll/seek. Default PT30S. + pub session_timeout: Option, +} + +/// An event received from the broker stream. +#[derive(Debug, Clone)] +pub struct WireEvent { + pub id: Uuid, + pub type_id: String, + pub topic: String, + pub tenant_id: Uuid, + pub subject: String, + pub subject_type: String, + pub partition_key: Option, + pub partition: u32, + pub sequence: i64, + pub offset: i64, + pub occurred_at: chrono::DateTime, + pub sequence_time: chrono::DateTime, + pub trace_parent: Option, + pub data: serde_json::Value, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct PartitionSlot { + pub topic_ix: u16, + pub partition: u32, +} + +/// A per-partition cursor position carried by stream topology/control frames. +#[derive(Debug, Clone)] +pub struct PartitionPosition { + pub slot: PartitionSlot, + /// Session cursor - last processed offset. + pub offset: i64, + /// Highest offset the broker has scanned for this group/partition. + pub last_examined: i64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ControlCode { + Progress, + Terminal, +} + +/// One frame on the consumption stream. +// `WireFrame::Event(WireEvent)` stays unboxed to preserve the public stream API shape. +#[allow(clippy::large_enum_variant)] +#[derive(Debug, Clone)] +pub enum WireFrame { + Event(WireEvent), + Heartbeat { + at: String, + }, + Topology { + topology_version: i64, + assigned: Vec, + }, + Control { + code: ControlCode, + positions: Vec, + reason: Option, + }, +} + +/// Boxed stream returned by [`EventBroker::stream`]. +pub type FrameStream = + std::pin::Pin> + Send>>; + +/// Whether to stop traversal at self-managed tenant boundaries. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BarrierMode { + #[default] + Respect, + Ignore, +} + +/// Tenant hierarchy traversal scope for a subscription interest. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum TenantTraversalDepth { + #[default] + CurrentTenant, + Descendants(NonZeroU32), + UnlimitedDescendants, +} + +impl TenantTraversalDepth { + pub fn direct_children() -> Self { + Self::Descendants(NonZeroU32::new(1).expect("1 is non-zero")) + } + + pub fn descendants(depth: NonZeroU32) -> Self { + Self::Descendants(depth) + } + + pub fn unlimited() -> Self { + Self::UnlimitedDescendants + } +} + +/// Paired filter engine + expression for a subscription interest. +#[derive(Debug, Clone)] +pub struct Filter { + pub(crate) engine: String, + pub(crate) expression: String, +} + +impl Filter { + pub fn new( + engine: impl Into, + expression: impl Into, + ) -> Result { + let engine = engine.into(); + let expression = expression.into(); + if engine.trim().is_empty() { + return Err(EventBrokerError::InvalidConsumerOptions { + detail: "subscription filter engine must not be empty".to_owned(), + instance: String::new(), + }); + } + if expression.is_empty() || expression.len() > 4096 { + return Err(EventBrokerError::InvalidConsumerOptions { + detail: format!( + "subscription filter expression must be 1..=4096 bytes (got {})", + expression.len() + ), + instance: String::new(), + }); + } + Ok(Self { engine, expression }) + } + + pub fn engine(&self) -> &str { + &self.engine + } + + pub fn expression(&self) -> &str { + &self.expression + } +} + +/// One interest entry for a subscription JOIN. +#[derive(Debug, Clone)] +pub struct SubscriptionInterest { + pub(crate) topic: String, + pub(crate) tenant_id: Uuid, + pub(crate) tenant_depth: TenantTraversalDepth, + pub(crate) barrier_mode: BarrierMode, + pub(crate) types: Vec, + pub(crate) filter: Option, +} + +#[derive(Debug, Default)] +pub struct SubscriptionInterestBuilder { + topic: Option, + tenant_id: Option, + tenant_depth: TenantTraversalDepth, + barrier_mode: BarrierMode, + types: Vec, + filter: Option, +} + +impl SubscriptionInterest { + pub fn builder() -> SubscriptionInterestBuilder { + SubscriptionInterestBuilder::default() + } + + pub fn topic(&self) -> &str { + &self.topic + } + + pub fn tenant_id(&self) -> Uuid { + self.tenant_id + } + + pub fn tenant_depth(&self) -> TenantTraversalDepth { + self.tenant_depth + } + + pub fn barrier_mode(&self) -> BarrierMode { + self.barrier_mode + } + + pub fn types(&self) -> &[String] { + &self.types + } + + pub fn filter(&self) -> Option<&Filter> { + self.filter.as_ref() + } +} + +impl SubscriptionInterestBuilder { + pub fn topic(mut self, topic: impl Into) -> Self { + self.topic = Some(topic.into()); + self + } + + pub fn tenant_id(mut self, tenant_id: Uuid) -> Self { + self.tenant_id = Some(tenant_id); + self + } + + pub fn tenant_depth(mut self, tenant_depth: TenantTraversalDepth) -> Self { + self.tenant_depth = tenant_depth; + self + } + + pub fn barrier_mode(mut self, barrier_mode: BarrierMode) -> Self { + self.barrier_mode = barrier_mode; + self + } + + pub fn types(mut self, types: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.types = types.into_iter().map(Into::into).collect(); + self + } + + pub fn filter(mut self, filter: Filter) -> Self { + self.filter = Some(filter); + self + } + + pub fn build(self) -> Result { + let topic = self + .topic + .ok_or_else(|| EventBrokerError::InvalidConsumerOptions { + detail: "subscription interest topic is required".to_owned(), + instance: String::new(), + })?; + let tenant_id = self + .tenant_id + .ok_or_else(|| EventBrokerError::InvalidConsumerOptions { + detail: "subscription interest tenant_id is required".to_owned(), + instance: String::new(), + })?; + if topic.trim().is_empty() { + return Err(EventBrokerError::InvalidConsumerOptions { + detail: "subscription interest topic must not be empty".to_owned(), + instance: String::new(), + }); + } + if self.types.is_empty() || self.types.len() > 32 { + return Err(EventBrokerError::InvalidConsumerOptions { + detail: format!( + "subscription interest event types must be 1..=32 entries (got {})", + self.types.len() + ), + instance: String::new(), + }); + } + if self.types.iter().any(|ty| ty.trim().is_empty()) { + return Err(EventBrokerError::InvalidConsumerOptions { + detail: "subscription interest event types must not contain empty entries" + .to_owned(), + instance: String::new(), + }); + } + + Ok(SubscriptionInterest { + topic, + tenant_id, + tenant_depth: self.tenant_depth, + barrier_mode: self.barrier_mode, + types: self.types, + filter: self.filter, + }) + } +} + +/// Opaque backend configuration envelope. +/// `gts_type_id` is a full GTS identifier registered with `types-registry-sdk`. +/// `config` is JSON validated against the GTS type's schema. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StorageBackendConfig { + pub gts_type_id: String, + pub config: serde_json::Value, +} + +/// Resolved position returned from a SEEK call, one entry per requested partition. +#[derive(Debug, Clone)] +pub struct SeekResult { + pub topic: String, + pub partition: u32, + pub offset: i64, +} + +// --- EventBroker - client-facing interface ------------------------------------ + +/// The Event Broker client interface - one method per broker operation. +/// +/// Resolved from `ClientHub`: +/// ```ignore +/// let broker = hub.get::()?; +/// ``` +/// +/// Implemented by: the in-process direct backend, the remote HTTP backend, +/// and the mock (`--features test-util`). This boundary is **transport-agnostic** - no +/// HTTP types leak through it, and method docs describe operations, not wire paths. +/// HTTP verbs/paths (and their renames) live solely in `openapi.yaml` and the HTTP +/// backend, the single source of truth. +#[async_trait] +pub trait EventBroker: Send + Sync { + // -- Producer -------------------------------------------------------------- + /// Register a producer; returns the broker-issued producer id. (On the HTTP + /// wire the response body field is `id`, not `producer_id`.) + async fn register_producer( + &self, + ctx: &SecurityContext, + mode: ProducerMode, + client_agent: &str, + ) -> Result; + + async fn publish( + &self, + ctx: &SecurityContext, + event: &Event, + ) -> Result; + + /// Persist-confirming publish: awaits the backend durable write and returns + /// `IngestOutcome::Persisted` on success (vs. `Accepted` for the async path). + /// A `Duplicate` is still reported as `Duplicate`. + /// + /// Default impl falls back to [`publish`] for backends that don't model + /// persist confirmation; the mock overrides it. + async fn publish_sync( + &self, + ctx: &SecurityContext, + event: &Event, + ) -> Result { + self.publish(ctx, event).await + } + + async fn publish_batch( + &self, + ctx: &SecurityContext, + events: &[Event], + ) -> Result, EventBrokerError>; + + async fn get_producer_cursors( + &self, + ctx: &SecurityContext, + producer_id: ProducerId, + ) -> Result, EventBrokerError>; + + async fn reset_producer_chain( + &self, + ctx: &SecurityContext, + producer_id: ProducerId, + scope: ResetScope<'_>, + ) -> Result<(), EventBrokerError>; + + // -- Consumer groups ------------------------------------------------------- + async fn create_consumer_group( + &self, + ctx: &SecurityContext, + req: CreateConsumerGroupRequest, + ) -> Result; + + async fn get_consumer_group( + &self, + ctx: &SecurityContext, + id: &ConsumerGroupId, + ) -> Result; + + async fn list_consumer_groups( + &self, + ctx: &SecurityContext, + query: ConsumerGroupQuery, + ) -> Result, EventBrokerError>; + + async fn delete_consumer_group( + &self, + ctx: &SecurityContext, + id: &ConsumerGroupId, + ) -> Result<(), EventBrokerError>; + + // -- Subscriptions --------------------------------------------------------- + async fn join( + &self, + ctx: &SecurityContext, + req: JoinRequest, + ) -> Result; + + async fn get_subscription( + &self, + ctx: &SecurityContext, + id: SubscriptionId, + ) -> Result; + + async fn list_subscriptions( + &self, + ctx: &SecurityContext, + ) -> Result, EventBrokerError>; + + async fn leave( + &self, + ctx: &SecurityContext, + id: SubscriptionId, + ) -> Result<(), EventBrokerError>; + + async fn stream( + &self, + ctx: &SecurityContext, + id: SubscriptionId, + ) -> Result; + + async fn seek( + &self, + ctx: &SecurityContext, + id: SubscriptionId, + positions: &[SeekPosition], + ) -> Result, EventBrokerError>; + + // -- Topic / event-type introspection ------------------------------------- + async fn list_topics(&self, ctx: &SecurityContext) -> Result, EventBrokerError>; + + async fn list_topic_segments( + &self, + ctx: &SecurityContext, + topic: &str, + partition: u32, + range: PartitionRange, + ) -> Result, EventBrokerError>; + + async fn list_event_types( + &self, + ctx: &SecurityContext, + ) -> Result, EventBrokerError>; + + async fn get_event_type( + &self, + ctx: &SecurityContext, + id: &str, + ) -> Result; +} + +// --- EventBrokerBackend - storage plugin seam -------------------------------- + +/// Plugin trait for swappable storage backends. +/// +/// Implemented by the built-in memory and postgres backends; third-party backends +/// register via GTS type extension without modifying broker core. +/// +/// **Note:** This trait exposes the public [`Event`](crate::models::Event) envelope. +/// Backend authors need the full shape to persist and assign offsets correctly. +#[async_trait] +pub trait EventBrokerBackend: Send + Sync { + async fn persist( + &self, + ctx: &SecurityContext, + topic: &str, + partition: u32, + events: &[Event], + ) -> Result<(), StorageBackendError>; + + async fn read( + &self, + ctx: &SecurityContext, + topic: &str, + partition: u32, + start_offset: i64, + max_count: usize, + ) -> Result, StorageBackendError>; + + async fn query( + &self, + ctx: &SecurityContext, + topic: &str, + partition: u32, + range: PartitionRange, + ) -> Result, StorageBackendError>; + + async fn list_partition_leaders( + &self, + ctx: &SecurityContext, + topic: &str, + ) -> Result, StorageBackendError>; +} diff --git a/gears/system/event-broker/event-broker-sdk/src/api_tests.rs b/gears/system/event-broker/event-broker-sdk/src/api_tests.rs new file mode 100644 index 000000000..0fe47bdb2 --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/src/api_tests.rs @@ -0,0 +1,93 @@ +use super::api::{BarrierMode, Filter, SubscriptionInterest, TenantTraversalDepth}; +use crate::error::EventBrokerError; +use toolkit_gts::{GTS_ID_PREFIX, gts_id}; + +#[test] +fn barrier_mode_default_is_respect() { + assert_eq!(BarrierMode::default(), BarrierMode::Respect); +} + +#[test] +fn barrier_mode_serialises_to_snake_case() { + assert_eq!( + serde_json::to_string(&BarrierMode::Respect).unwrap(), + "\"respect\"" + ); + assert_eq!( + serde_json::to_string(&BarrierMode::Ignore).unwrap(), + "\"ignore\"" + ); +} + +#[test] +fn subscription_interest_full_construction() { + let interest = SubscriptionInterest::builder() + .topic(gts_id!("cf.core.events.topic.v1~acme.orders.x.x.v1")) + .tenant_id(uuid::Uuid::nil()) + .tenant_depth(TenantTraversalDepth::direct_children()) + .barrier_mode(BarrierMode::Ignore) + .types([format!( + "{GTS_ID_PREFIX}cf.core.events.event_type.v1~acme.orders.*" + )]) + .filter( + Filter::new( + gts_id!("cf.core.events.filter.v1~cf.core.expression.cel.v1"), + "event.data.amount > 100", + ) + .unwrap(), + ) + .build() + .unwrap(); + + assert_eq!( + interest.topic(), + gts_id!("cf.core.events.topic.v1~acme.orders.x.x.v1") + ); + assert_eq!(interest.tenant_id(), uuid::Uuid::nil()); + assert_eq!(interest.types().len(), 1); + assert!(interest.filter().is_some()); + assert_eq!( + interest.tenant_depth(), + TenantTraversalDepth::direct_children() + ); + assert_eq!(interest.barrier_mode(), BarrierMode::Ignore); +} + +#[test] +fn subscription_interest_rejects_missing_types() { + let err = SubscriptionInterest::builder() + .topic(gts_id!("cf.core.events.topic.v1~acme.orders.x.x.v1")) + .tenant_id(uuid::Uuid::nil()) + .tenant_depth(TenantTraversalDepth::CurrentTenant) + .barrier_mode(BarrierMode::Respect) + .types(std::iter::empty::<&str>()) + .build() + .unwrap_err(); + + assert!(matches!( + err, + EventBrokerError::InvalidConsumerOptions { .. } + )); + assert!( + err.to_string().contains("event types must be 1..=32"), + "unexpected error: {err}" + ); +} + +#[test] +fn filter_rejects_empty_expression() { + let err = Filter::new( + gts_id!("cf.core.events.filter.v1~cf.core.expression.cel.v1"), + "", + ) + .unwrap_err(); + + assert!(matches!( + err, + EventBrokerError::InvalidConsumerOptions { .. } + )); + assert!( + err.to_string().contains("filter expression"), + "unexpected error: {err}" + ); +} diff --git a/gears/system/event-broker/event-broker-sdk/src/consumer/batch_tests.rs b/gears/system/event-broker/event-broker-sdk/src/consumer/batch_tests.rs new file mode 100644 index 000000000..b66ae7fa6 --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/src/consumer/batch_tests.rs @@ -0,0 +1,121 @@ +use chrono::Utc; +use std::sync::{Arc, Mutex}; +use toolkit_gts::gts_id; +use uuid::Uuid; + +use super::{ + BatchHandlerOutcome, ConsumerHandler, EventBatch, HandlerOutcome, RawEvent, SingleEventHandler, + SingleEventHandlerAdapter, +}; +use crate::error::ConsumerError; + +fn raw_event(offset: i64) -> RawEvent { + RawEvent { + id: Uuid::new_v4(), + type_id: gts_id!("cf.core.events.event.v1~example.orders.order_created.x.v1").to_owned(), + topic: gts_id!("cf.core.events.topic.v1~example.orders.x.x.v1").to_owned(), + tenant_id: Uuid::nil(), + subject: format!("order-{offset}"), + subject_type: "order".to_owned(), + partition_key: None, + partition: 7, + sequence: offset, + offset, + occurred_at: Utc::now(), + sequence_time: Utc::now(), + trace_parent: None, + data: serde_json::json!({ "offset": offset }), + } +} + +#[test] +fn event_batch_reports_empty_state() { + let events = Vec::new(); + let batch = EventBatch::new(&events); + + assert!(batch.is_empty()); + assert_eq!(batch.len(), 0); + assert!(batch.next_event().is_none()); + assert!(batch.next_chunk(10).is_empty()); + assert!(batch.iter().next().is_none()); +} + +#[test] +fn event_batch_reads_without_mutating_progress() { + let events = vec![raw_event(10), raw_event(11), raw_event(12)]; + let batch = EventBatch::new(&events); + + assert_eq!(batch.len(), 3); + assert_eq!(batch.next_event().map(|event| event.offset), Some(10)); + assert_eq!( + batch + .next_chunk(2) + .iter() + .map(|event| event.offset) + .collect::>(), + vec![10, 11] + ); + assert_eq!( + batch.iter().map(|event| event.offset).collect::>(), + vec![10, 11, 12] + ); + assert_eq!(batch.next_event().map(|event| event.offset), Some(10)); +} + +struct RecordingSingleHandler { + calls: Arc>>, + outcome: HandlerOutcome, +} + +#[async_trait::async_trait] +impl SingleEventHandler for RecordingSingleHandler { + async fn handle( + &self, + event: RawEvent, + _attempts: u16, + ) -> Result { + self.calls.lock().unwrap().push(event.offset); + Ok(self.outcome.clone()) + } +} + +#[tokio::test] +async fn single_handler_adapter_reports_one_event_processed_on_success() { + let calls = Arc::new(Mutex::new(Vec::new())); + let handler = Arc::new(RecordingSingleHandler { + calls: calls.clone(), + outcome: HandlerOutcome::Success, + }); + let adapter = SingleEventHandlerAdapter::new(handler); + let events = vec![raw_event(40)]; + let batch = EventBatch::new(&events); + + let outcome = adapter.handle_batch(&batch, 1).await.unwrap(); + + assert!(matches!( + outcome, + BatchHandlerOutcome::AdvanceThrough { offset: 40 } + )); + assert_eq!(batch.next_event().map(|event| event.offset), Some(40)); + assert_eq!(*calls.lock().unwrap(), vec![40]); +} + +#[tokio::test] +async fn single_handler_adapter_reports_retry_without_progress() { + let calls = Arc::new(Mutex::new(Vec::new())); + let handler = Arc::new(RecordingSingleHandler { + calls: calls.clone(), + outcome: HandlerOutcome::Retry { + reason: "not yet".to_owned(), + }, + }); + let adapter = SingleEventHandlerAdapter::new(handler); + let events = vec![raw_event(41)]; + let batch = EventBatch::new(&events); + + let outcome = adapter.handle_batch(&batch, 1).await.unwrap(); + + assert!(matches!(outcome, BatchHandlerOutcome::Retry { .. })); + assert_eq!(batch.next_event().map(|event| event.offset), Some(41)); + assert_eq!(*calls.lock().unwrap(), vec![41]); +} diff --git a/gears/system/event-broker/event-broker-sdk/src/consumer/builder.rs b/gears/system/event-broker/event-broker-sdk/src/consumer/builder.rs new file mode 100644 index 000000000..e358a57d2 --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/src/consumer/builder.rs @@ -0,0 +1,903 @@ +use std::collections::HashSet; +use std::sync::Arc; +use std::time::Duration; + +use toolkit_security::SecurityContext; +use uuid::Uuid; + +use crate::api::EventBroker; +use crate::api::{BarrierMode, Filter, TenantTraversalDepth}; +use crate::error::EventBrokerError; +use crate::sdk::EventBrokerSdk; + +use super::runtime::{Consumer, ConsumerHandle}; +use super::{ + CommitOffset, ConsumerBatching, ConsumerBuffering, ConsumerCommitMode, ConsumerGroupRef, + ConsumerHandler, ConsumerListenerSettings, ConsumerProfile, ConsumerRetry, + ConsumerRuntimeListener, ConsumerSettings, ConsumerSettingsOverrides, ConsumerSlowDetection, + EventTypeRef, SingleEventHandler, SubscriptionFilterRef, SubscriptionInterest, TopicRef, +}; +#[cfg(feature = "db")] +use super::{CommitOffsetInTx, LocalDbOffsetManager, TxConsumerHandler, TxSingleEventHandler}; + +// ---- Typestate markers (zero-sized) ---- + +pub struct BrokerOnly(pub M); + +#[cfg(feature = "db")] +pub struct WithTx(pub M); + +pub trait ConsumerOffsetManager: 'static { + type BuilderState; + + fn into_builder_state(self) -> Self::BuilderState; +} + +impl ConsumerOffsetManager for M +where + M: CommitOffset + 'static, +{ + type BuilderState = BrokerOnly; + + fn into_builder_state(self) -> Self::BuilderState { + BrokerOnly(self) + } +} + +#[cfg(feature = "db")] +impl ConsumerOffsetManager for LocalDbOffsetManager { + type BuilderState = WithTx; + + fn into_builder_state(self) -> Self::BuilderState { + WithTx(self) + } +} + +// ---- Builder ---- + +pub struct ConsumerBuilder { + pub(crate) group: Option, + pub(crate) topics: Vec, + pub(crate) subscription_interests: Vec, + pub(crate) tenant_id: Option, + pub(crate) tenant_depth: TenantTraversalDepth, + pub(crate) barrier_mode: BarrierMode, + pub(crate) event_type_patterns: Vec, + pub(crate) parallelism: u32, + pub(crate) client_agent: String, + pub(crate) session_timeout: Option, + pub(crate) filter: Option, + pub(crate) retry_base: Duration, + pub(crate) retry_max: Duration, + pub(crate) profile: ConsumerProfile, + pub(crate) settings_overrides: ConsumerSettingsOverrides, + pub(crate) commit_mode: ConsumerCommitMode, + /// Drop-on-Nth-heartbeat threshold: disconnect + re-JOIN after K consecutive heartbeats + /// with no intervening events. Default: 10 (≈ 50 s of silence at 5 s broker cadence). + pub(crate) heartbeat_drop_threshold: usize, + pub(crate) listeners: Vec>, + pub(crate) offset_manager: M, + /// Broker client resolved from ClientHub or supplied by tests. + pub(crate) broker: Option>, + /// Security context used by the SDK runtime for EventBroker calls. + pub(crate) security_context: SecurityContext, +} + +impl ConsumerBuilder<()> { + pub fn new(broker: Arc) -> Self { + Self { + group: None, + topics: Vec::new(), + subscription_interests: Vec::new(), + tenant_id: None, + tenant_depth: TenantTraversalDepth::CurrentTenant, + barrier_mode: BarrierMode::Respect, + event_type_patterns: Vec::new(), + parallelism: 1, + client_agent: EventBrokerSdk::default_client_agent().into(), + session_timeout: Some(Duration::from_secs(60)), + filter: None, + retry_base: Duration::from_secs(1), + retry_max: Duration::from_secs(60), + profile: ConsumerProfile::default_profile(), + settings_overrides: ConsumerSettingsOverrides::default(), + commit_mode: ConsumerCommitMode::default(), + heartbeat_drop_threshold: 10, + listeners: Vec::new(), + offset_manager: (), + broker: Some(broker), + security_context: SecurityContext::anonymous(), + } + } + + pub fn new_unbound() -> Self { + Self { + group: None, + topics: Vec::new(), + subscription_interests: Vec::new(), + tenant_id: None, + tenant_depth: TenantTraversalDepth::CurrentTenant, + barrier_mode: BarrierMode::Respect, + event_type_patterns: Vec::new(), + parallelism: 1, + client_agent: EventBrokerSdk::default_client_agent().into(), + session_timeout: Some(Duration::from_secs(60)), + filter: None, + retry_base: Duration::from_secs(1), + retry_max: Duration::from_secs(60), + profile: ConsumerProfile::default_profile(), + settings_overrides: ConsumerSettingsOverrides::default(), + commit_mode: ConsumerCommitMode::default(), + heartbeat_drop_threshold: 10, + listeners: Vec::new(), + offset_manager: (), + broker: None, + security_context: SecurityContext::anonymous(), + } + } +} + +// Common methods available in all commit-mode states. +impl ConsumerBuilder { + pub fn group(mut self, group: ConsumerGroupRef) -> Self { + self.group = Some(group); + self + } + pub fn topics(mut self, topics: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.topics.extend(topics.into_iter().map(Into::into)); + self + } + pub fn subscription_interests(mut self, interests: I) -> Self + where + I: IntoIterator, + { + self.subscription_interests.extend(interests); + self.topics = self + .subscription_interests + .iter() + .map(|interest| topic_ref_to_string(&interest.topic)) + .collect(); + self + } + pub fn tenant_id(mut self, id: Uuid) -> Self { + self.tenant_id = Some(id); + self + } + /// Tenant hierarchy traversal scope. Defaults to current tenant only. + pub fn tenant_depth(mut self, depth: TenantTraversalDepth) -> Self { + self.tenant_depth = depth; + self + } + /// Backward-compatible alias for tenant hierarchy traversal scope. + pub fn max_depth(mut self, depth: TenantTraversalDepth) -> Self { + self.tenant_depth = depth; + self + } + /// Whether to stop at self-managed tenant boundaries. Default: `BarrierMode::Respect`. + pub fn barrier_mode(mut self, mode: BarrierMode) -> Self { + self.barrier_mode = mode; + self + } + pub fn event_type_patterns(mut self, pats: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.event_type_patterns + .extend(pats.into_iter().map(Into::into)); + self + } + pub fn parallelism(mut self, n: u32) -> Self { + self.parallelism = n; + self + } + pub fn client_agent(mut self, ua: impl Into) -> Self { + self.client_agent = ua.into(); + self + } + /// Drop the streaming connection and re-JOIN after K consecutive + /// `heartbeat` frames with no intervening events. + /// Default: 10 (≈ 50 s of silence at the broker's 5 s default cadence). + pub fn heartbeat_drop_threshold(mut self, k: usize) -> Self { + self.heartbeat_drop_threshold = k; + self + } + pub fn session_timeout(mut self, d: Duration) -> Self { + self.session_timeout = Some(d); + self + } + pub fn filter(mut self, engine: impl Into, expr: impl Into) -> Self { + self.filter = Some(Filter { + engine: engine.into(), + expression: expr.into(), + }); + self + } + pub fn retry_base(mut self, d: Duration) -> Self { + self.retry_base = d; + self + } + pub fn retry_max(mut self, d: Duration) -> Self { + self.retry_max = d; + self + } + pub fn profile(mut self, profile: ConsumerProfile) -> Self { + self.profile = profile; + self + } + pub fn buffering(mut self, buffering: ConsumerBuffering) -> Self { + self.settings_overrides.buffering = Some(buffering); + self + } + pub fn batching(mut self, batching: ConsumerBatching) -> Self { + self.settings_overrides.batching = Some(batching); + self + } + pub fn slow_detection(mut self, slow_detection: ConsumerSlowDetection) -> Self { + self.settings_overrides.slow_detection = Some(slow_detection); + self + } + pub fn retry(mut self, retry: ConsumerRetry) -> Self { + self.settings_overrides.retry = Some(retry); + self.retry_base = retry.base_delay; + self.retry_max = retry.max_delay; + self + } + pub fn listener_settings(mut self, listener: ConsumerListenerSettings) -> Self { + self.settings_overrides.listener = Some(listener); + self + } + pub fn register_listener(mut self, listener: L) -> Self + where + L: ConsumerRuntimeListener + 'static, + { + self.listeners.push(Arc::new(listener)); + self + } + pub fn commit_mode(mut self, mode: ConsumerCommitMode) -> Self { + self.commit_mode = mode; + self + } + pub fn security_context(mut self, ctx: SecurityContext) -> Self { + self.security_context = ctx; + self + } +} + +impl ConsumerBuilder { + pub(crate) fn effective_settings(&self) -> Result { + let settings = ConsumerSettings::resolve(self.profile.clone(), self.settings_overrides); + settings.validate()?; + Ok(settings) + } +} + +// offset_manager transitions M and selects the commit mode. +impl ConsumerBuilder<()> { + pub fn offset_manager(self, m: M) -> ConsumerBuilder + where + M: ConsumerOffsetManager, + { + ConsumerBuilder { + group: self.group, + topics: self.topics, + subscription_interests: self.subscription_interests, + tenant_id: self.tenant_id, + tenant_depth: self.tenant_depth, + barrier_mode: self.barrier_mode, + event_type_patterns: self.event_type_patterns, + parallelism: self.parallelism, + client_agent: self.client_agent, + heartbeat_drop_threshold: self.heartbeat_drop_threshold, + session_timeout: self.session_timeout, + filter: self.filter, + retry_base: self.retry_base, + retry_max: self.retry_max, + profile: self.profile, + settings_overrides: self.settings_overrides, + commit_mode: self.commit_mode, + listeners: self.listeners, + offset_manager: m.into_builder_state(), + broker: self.broker, + security_context: self.security_context, + } + } +} + +// ---- Terminal handler states ---- + +pub struct ConsumerReady { + pub(crate) builder: ConsumerBuilder, + pub(crate) handler: H, +} + +pub struct ConsumerBatchReady { + pub(crate) builder: ConsumerBuilder, + pub(crate) handler: H, +} + +pub struct ConsumerRoutedReady { + pub(crate) builder: ConsumerBuilder, + pub(crate) default_handler: H, + pub(crate) has_default_handler: bool, + pub(crate) routes: Vec, + pub(crate) route_handlers: Vec>, +} + +#[cfg(feature = "db")] +pub struct TxConsumerRoutedReady { + pub(crate) builder: ConsumerBuilder>, + pub(crate) default_handler: Option>>, + pub(crate) routes: Vec, + pub(crate) route_handlers: Vec>>, +} + +pub struct NoDefaultHandler; + +pub struct RouteMissingTopic; +pub struct RouteHasTopic; + +pub struct ConsumerRouteBuilder { + ready: ConsumerRoutedReady, + topic: Option, + event_type: Option, + _topic_state: std::marker::PhantomData, +} + +#[cfg(feature = "db")] +pub struct TxConsumerRouteBuilder { + ready: TxConsumerRoutedReady, + topic: Option, + event_type: Option, + _topic_state: std::marker::PhantomData, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConsumerRoute { + pub topic: TopicRef, + pub event_type: Option, + pub handler_kind: ConsumerRouteHandlerKind, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConsumerRouteHandlerKind { + Single, + Batch, +} + +impl ConsumerBuilder> { + pub fn handler(self, handler: H) -> ConsumerReady, H> + where + H: SingleEventHandler + 'static, + { + ConsumerReady { + builder: self, + handler, + } + } + + pub fn batch_handler(self, handler: H) -> ConsumerBatchReady, H> + where + H: ConsumerHandler + 'static, + { + ConsumerBatchReady { + builder: self, + handler, + } + } + + pub fn default_handler(self, handler: H) -> ConsumerRoutedReady, H> + where + H: SingleEventHandler + 'static, + { + ConsumerRoutedReady { + builder: self, + default_handler: handler, + has_default_handler: true, + routes: Vec::new(), + route_handlers: Vec::new(), + } + } + + pub fn route(self) -> ConsumerRouteBuilder, NoDefaultHandler, RouteMissingTopic> { + ConsumerRouteBuilder { + ready: ConsumerRoutedReady { + builder: self, + default_handler: NoDefaultHandler, + has_default_handler: false, + routes: Vec::new(), + route_handlers: Vec::new(), + }, + topic: None, + event_type: None, + _topic_state: std::marker::PhantomData, + } + } +} + +#[cfg(feature = "db")] +impl ConsumerBuilder> { + pub fn handler(self, handler: H) -> ConsumerReady, H> + where + H: TxSingleEventHandler + 'static, + { + ConsumerReady { + builder: self, + handler, + } + } + + pub fn batch_handler(self, handler: H) -> ConsumerBatchReady, H> + where + H: TxConsumerHandler + 'static, + { + ConsumerBatchReady { + builder: self, + handler, + } + } + + pub fn default_handler(self, handler: H) -> TxConsumerRoutedReady + where + H: TxSingleEventHandler + 'static, + { + let handler = super::TxSingleEventHandlerAdapter::<_, M>::new(Arc::new(handler)); + TxConsumerRoutedReady { + builder: self, + default_handler: Some(Arc::new(handler)), + routes: Vec::new(), + route_handlers: Vec::new(), + } + } + + pub fn route(self) -> TxConsumerRouteBuilder { + TxConsumerRouteBuilder { + ready: TxConsumerRoutedReady { + builder: self, + default_handler: None, + routes: Vec::new(), + route_handlers: Vec::new(), + }, + topic: None, + event_type: None, + _topic_state: std::marker::PhantomData, + } + } +} + +impl ConsumerRoutedReady { + pub fn route(self) -> ConsumerRouteBuilder { + ConsumerRouteBuilder { + ready: self, + topic: None, + event_type: None, + _topic_state: std::marker::PhantomData, + } + } + + fn validate_routes(&self) -> Result<(), EventBrokerError> { + if self.builder.topics.is_empty() { + return Err(EventBrokerError::InvalidConsumerOptions { + detail: "routed consumer requires at least one configured topic".to_owned(), + instance: String::new(), + }); + } + + let mut seen = HashSet::new(); + for route in &self.routes { + if !self.route_topic_is_configured(&route.topic) { + return Err(EventBrokerError::InvalidConsumerOptions { + detail: format!( + "route topic {:?} is not part of the configured subscription topics", + route.topic + ), + instance: String::new(), + }); + } + + let key = (route.topic.clone(), route.event_type.clone()); + if !seen.insert(key) { + return Err(EventBrokerError::InvalidConsumerOptions { + detail: format!( + "duplicate consumer route for topic {:?} and event type {:?}", + route.topic, route.event_type + ), + instance: String::new(), + }); + } + } + + if !self.has_default_handler { + for configured in &self.builder.topics { + let has_topic_catch_all = self.routes.iter().any(|route| { + route.event_type.is_none() + && topic_ref_matches_configured(&route.topic, configured) + }); + if !has_topic_catch_all { + return Err(EventBrokerError::InvalidConsumerOptions { + detail: format!( + "routed consumer without a default handler requires a topic catch-all route for configured topic {configured}" + ), + instance: String::new(), + }); + } + } + } + + Ok(()) + } + + fn route_topic_is_configured(&self, route_topic: &TopicRef) -> bool { + self.builder + .topics + .iter() + .any(|configured| topic_ref_matches_configured(route_topic, configured)) + } +} + +#[cfg(feature = "db")] +impl TxConsumerRoutedReady +where + M: CommitOffsetInTx + 'static, +{ + pub fn route(self) -> TxConsumerRouteBuilder { + TxConsumerRouteBuilder { + ready: self, + topic: None, + event_type: None, + _topic_state: std::marker::PhantomData, + } + } + + fn validate_routes(&self) -> Result<(), EventBrokerError> { + if self.builder.topics.is_empty() { + return Err(EventBrokerError::InvalidConsumerOptions { + detail: "routed consumer requires at least one configured topic".to_owned(), + instance: String::new(), + }); + } + + let mut seen = HashSet::new(); + for route in &self.routes { + if !self.route_topic_is_configured(&route.topic) { + return Err(EventBrokerError::InvalidConsumerOptions { + detail: format!( + "route topic {:?} is not part of the configured subscription topics", + route.topic + ), + instance: String::new(), + }); + } + + let key = (route.topic.clone(), route.event_type.clone()); + if !seen.insert(key) { + return Err(EventBrokerError::InvalidConsumerOptions { + detail: format!( + "duplicate consumer route for topic {:?} and event type {:?}", + route.topic, route.event_type + ), + instance: String::new(), + }); + } + } + + if self.default_handler.is_none() { + for configured in &self.builder.topics { + let has_topic_catch_all = self.routes.iter().any(|route| { + route.event_type.is_none() + && topic_ref_matches_configured(&route.topic, configured) + }); + if !has_topic_catch_all { + return Err(EventBrokerError::InvalidConsumerOptions { + detail: format!( + "routed consumer without a default handler requires a topic catch-all route for configured topic {configured}" + ), + instance: String::new(), + }); + } + } + } + + Ok(()) + } + + fn route_topic_is_configured(&self, route_topic: &TopicRef) -> bool { + self.builder + .topics + .iter() + .any(|configured| topic_ref_matches_configured(route_topic, configured)) + } +} + +fn topic_ref_matches_configured(route_topic: &TopicRef, configured: &str) -> bool { + match route_topic { + TopicRef::Gts(gts) => gts == configured, + TopicRef::Id(id) => *id == crate::ids::TopicId::from_gts(configured), + } +} + +pub(crate) fn topic_ref_to_string(topic: &TopicRef) -> String { + match topic { + TopicRef::Gts(gts) => gts.clone(), + TopicRef::Id(id) => id.as_uuid().to_string(), + } +} + +pub(crate) fn event_type_ref_to_string(event_type: &EventTypeRef) -> String { + match event_type { + EventTypeRef::Gts(gts) | EventTypeRef::GtsPattern(gts) => gts.clone(), + EventTypeRef::Id(id) => id.as_uuid().to_string(), + } +} + +pub(crate) fn subscription_filter_ref_to_filter(filter: &SubscriptionFilterRef) -> Filter { + Filter { + engine: match &filter.engine { + super::FilterEngineRef::Gts(gts) => gts.clone(), + super::FilterEngineRef::Id(id) => id.to_string(), + }, + expression: filter.expression.clone(), + } +} + +impl ConsumerRouteBuilder { + pub fn topic(self, topic: impl Into) -> ConsumerRouteBuilder { + ConsumerRouteBuilder { + ready: self.ready, + topic: Some(topic.into()), + event_type: self.event_type, + _topic_state: std::marker::PhantomData, + } + } +} + +impl ConsumerRouteBuilder { + pub fn topic(mut self, topic: impl Into) -> Self { + self.topic = Some(topic.into()); + self + } + + pub fn event_type(mut self, event_type: impl Into) -> Self { + self.event_type = Some(event_type.into()); + self + } + + fn push_broker_route( + mut self, + handler_kind: ConsumerRouteHandlerKind, + handler: Arc, + ) -> ConsumerRoutedReady { + let topic = self + .topic + .expect("route topic is required before registering a handler"); + self.ready.routes.push(ConsumerRoute { + topic, + event_type: self.event_type, + handler_kind, + }); + self.ready.route_handlers.push(handler); + self.ready + } +} + +#[cfg(feature = "db")] +impl TxConsumerRouteBuilder +where + M: CommitOffsetInTx + 'static, +{ + pub fn topic(self, topic: impl Into) -> TxConsumerRouteBuilder { + TxConsumerRouteBuilder { + ready: self.ready, + topic: Some(topic.into()), + event_type: self.event_type, + _topic_state: std::marker::PhantomData, + } + } +} + +#[cfg(feature = "db")] +impl TxConsumerRouteBuilder +where + M: CommitOffsetInTx + 'static, +{ + pub fn topic(mut self, topic: impl Into) -> Self { + self.topic = Some(topic.into()); + self + } + + pub fn event_type(mut self, event_type: impl Into) -> Self { + self.event_type = Some(event_type.into()); + self + } + + fn push_tx_route( + mut self, + handler_kind: ConsumerRouteHandlerKind, + handler: Arc>, + ) -> TxConsumerRoutedReady { + let topic = self + .topic + .expect("route topic is required before registering a handler"); + self.ready.routes.push(ConsumerRoute { + topic, + event_type: self.event_type, + handler_kind, + }); + self.ready.route_handlers.push(handler); + self.ready + } +} + +impl ConsumerRouteBuilder, H, RouteHasTopic> +where + M: CommitOffset + 'static, +{ + pub fn handler(self, _handler: RH) -> ConsumerRoutedReady, H> + where + RH: SingleEventHandler + 'static, + { + let handler = super::SingleEventHandlerAdapter::new(Arc::new(_handler)); + self.push_broker_route(ConsumerRouteHandlerKind::Single, Arc::new(handler)) + } + + pub fn batch_handler(self, _handler: RH) -> ConsumerRoutedReady, H> + where + RH: ConsumerHandler + 'static, + { + self.push_broker_route(ConsumerRouteHandlerKind::Batch, Arc::new(_handler)) + } +} + +#[cfg(feature = "db")] +impl TxConsumerRouteBuilder +where + M: CommitOffsetInTx + 'static, +{ + pub fn handler(self, _handler: RH) -> TxConsumerRoutedReady + where + RH: TxSingleEventHandler + 'static, + { + let handler = super::TxSingleEventHandlerAdapter::new(Arc::new(_handler)); + self.push_tx_route(ConsumerRouteHandlerKind::Single, Arc::new(handler)) + } + + pub fn batch_handler(self, _handler: RH) -> TxConsumerRoutedReady + where + RH: TxConsumerHandler + 'static, + { + self.push_tx_route(ConsumerRouteHandlerKind::Batch, Arc::new(_handler)) + } +} + +// ---- Terminal build methods on ConsumerReady ---- + +// -- BrokerOnly (async-commit) -- + +impl ConsumerReady, H> +where + M: CommitOffset + 'static, + H: SingleEventHandler + 'static, +{ + pub async fn start(self) -> Result { + let consumer = Consumer::new_with_slots(self.builder, self.handler).await?; + Ok(ConsumerHandle::from_consumer(consumer)) + } + + pub async fn run_blocking( + self, + cancel: tokio_util::sync::CancellationToken, + ) -> Result<(), EventBrokerError> { + let handle = self.start().await?; + cancel.cancelled().await; + handle.stop().await + } +} + +impl ConsumerBatchReady, H> +where + M: CommitOffset + 'static, + H: ConsumerHandler + 'static, +{ + pub async fn start(self) -> Result { + let consumer = Consumer::new_with_batch_slots(self.builder, self.handler).await?; + Ok(ConsumerHandle::from_consumer(consumer)) + } + + pub async fn run_blocking( + self, + cancel: tokio_util::sync::CancellationToken, + ) -> Result<(), EventBrokerError> { + let handle = self.start().await?; + cancel.cancelled().await; + handle.stop().await + } +} + +#[cfg(feature = "db")] +impl ConsumerBatchReady, H> +where + M: CommitOffsetInTx + 'static, + H: TxConsumerHandler + 'static, +{ + pub async fn start(self) -> Result { + let consumer = Consumer::new_with_tx_batch_slots(self.builder, self.handler).await?; + Ok(ConsumerHandle::from_consumer(consumer)) + } +} + +impl ConsumerRoutedReady, H> +where + M: CommitOffset + 'static, + H: SingleEventHandler + 'static, +{ + pub async fn start(self) -> Result { + self.validate_routes()?; + let default_handler = super::SingleEventHandlerAdapter::new(Arc::new(self.default_handler)); + let consumer = Consumer::new_with_routed_slots( + self.builder, + Some(Arc::new(default_handler)), + self.routes, + self.route_handlers, + ) + .await?; + Ok(ConsumerHandle::from_consumer(consumer)) + } +} + +impl ConsumerRoutedReady, NoDefaultHandler> +where + M: CommitOffset + 'static, +{ + pub async fn start(self) -> Result { + self.validate_routes()?; + let consumer = + Consumer::new_with_routed_slots(self.builder, None, self.routes, self.route_handlers) + .await?; + Ok(ConsumerHandle::from_consumer(consumer)) + } +} + +// -- WithTx (db feature) -- + +#[cfg(feature = "db")] +impl TxConsumerRoutedReady +where + M: CommitOffsetInTx + 'static, +{ + pub async fn start(self) -> Result { + self.validate_routes()?; + let consumer = Consumer::new_with_tx_routed_slots( + self.builder, + self.default_handler, + self.routes, + self.route_handlers, + ) + .await?; + Ok(ConsumerHandle::from_consumer(consumer)) + } +} + +#[cfg(feature = "db")] +impl ConsumerReady, H> +where + M: CommitOffsetInTx + 'static, + H: TxSingleEventHandler + 'static, +{ + pub async fn start(self) -> Result { + let consumer = Consumer::new_with_tx_slots(self.builder, self.handler).await?; + Ok(ConsumerHandle::from_consumer(consumer)) + } + + pub async fn run_blocking( + self, + cancel: tokio_util::sync::CancellationToken, + ) -> Result<(), EventBrokerError> { + let handle = self.start().await?; + cancel.cancelled().await; + handle.stop().await + } +} diff --git a/gears/system/event-broker/event-broker-sdk/src/consumer/builder_tests.rs b/gears/system/event-broker/event-broker-sdk/src/consumer/builder_tests.rs new file mode 100644 index 000000000..b3614507c --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/src/consumer/builder_tests.rs @@ -0,0 +1,644 @@ +use crate::consumer::{ + BatchHandlerOutcome, ConsumerBuffering, ConsumerBuilder, ConsumerCommitMode, ConsumerGroupRef, + ConsumerHandle, ConsumerHandler, ConsumerListenerSettings, ConsumerProfile, EventBatch, + EventTypeRef, Fallback, HandlerOutcome, InMemoryOffsetManager, RawEvent, SingleEventHandler, + TopicRef, +}; +use crate::error::{ConsumerError, EventBrokerError}; +use std::time::Duration; +use toolkit_gts::{GTS_ID_PREFIX, gts_id}; + +struct NoopHandler; + +#[async_trait::async_trait] +impl SingleEventHandler for NoopHandler { + async fn handle( + &self, + _event: RawEvent, + _attempts: u16, + ) -> Result { + Ok(HandlerOutcome::Success) + } +} + +struct NoopBatchHandler; + +#[async_trait::async_trait] +impl ConsumerHandler for NoopBatchHandler { + async fn handle_batch( + &self, + _batch: &EventBatch<'_>, + _attempts: u16, + ) -> Result { + Ok(BatchHandlerOutcome::Success) + } +} + +#[tokio::test] +async fn consumer_ready_starts_without_context_argument() { + let ready = ConsumerBuilder::new_unbound() + .group(ConsumerGroupRef::auto_anonymous("builder-start")) + .topics([gts_id!("cf.core.events.topic.v1~example.orders.x.x.v1")]) + .offset_manager(InMemoryOffsetManager::new(Fallback::Earliest)) + .handler(NoopHandler); + + let err = match ready.start().await { + Ok(_) => panic!("unbound builder cannot open subscriptions"), + Err(err) => err, + }; + + assert!( + err.to_string().contains("broker not wired"), + "unexpected error: {err}" + ); +} + +#[test] +fn consumer_builder_accepts_batch_handler_terminal_method() { + let _ready = ConsumerBuilder::new_unbound() + .group(ConsumerGroupRef::auto_anonymous("builder-batch")) + .topics([gts_id!("cf.core.events.topic.v1~example.orders.x.x.v1")]) + .offset_manager(InMemoryOffsetManager::new(Fallback::Earliest)) + .batch_handler(NoopBatchHandler); +} + +#[test] +fn consumer_builder_accepts_default_and_routed_handlers() { + let _ready = ConsumerBuilder::new_unbound() + .group(ConsumerGroupRef::auto_anonymous("builder-routed")) + .topics([gts_id!("cf.core.events.topic.v1~example.orders.x.x.v1")]) + .offset_manager(InMemoryOffsetManager::new(Fallback::Earliest)) + .default_handler(NoopHandler) + .route() + .topic(TopicRef::gts(gts_id!( + "cf.core.events.topic.v1~example.orders.x.x.v1" + ))) + .event_type(EventTypeRef::gts(gts_id!( + "cf.core.events.event.v1~example.orders.order_created.x.v1" + ))) + .handler(NoopHandler) + .route() + .topic(TopicRef::gts(gts_id!( + "cf.core.events.topic.v1~example.orders.x.x.v1" + ))) + .event_type(EventTypeRef::gts_pattern(format!( + "{GTS_ID_PREFIX}cf.core.events.event.v1~example.orders.*" + ))) + .batch_handler(NoopBatchHandler); +} + +#[test] +fn consumer_builder_accepts_route_only_with_topic_catch_all() { + let _ready = ConsumerBuilder::new_unbound() + .group(ConsumerGroupRef::auto_anonymous("builder-route-only")) + .topics([gts_id!("cf.core.events.topic.v1~example.orders.x.x.v1")]) + .offset_manager(InMemoryOffsetManager::new(Fallback::Earliest)) + .route() + .topic(TopicRef::gts(gts_id!( + "cf.core.events.topic.v1~example.orders.x.x.v1" + ))) + .handler(NoopHandler); +} + +#[test] +fn consumer_builders_keep_independent_profiles_and_listener_settings() { + let low_latency = ConsumerBuilder::new_unbound() + .group(ConsumerGroupRef::auto_anonymous("builder-independent-low")) + .topics([gts_id!("cf.core.events.topic.v1~example.orders.x.x.v1")]) + .profile(ConsumerProfile::low_latency()) + .listener_settings(ConsumerListenerSettings { + timeout: Duration::from_millis(25), + channel_capacity: 8, + }) + .offset_manager(InMemoryOffsetManager::new(Fallback::Earliest)) + .handler(NoopHandler); + + let high_throughput = ConsumerBuilder::new_unbound() + .group(ConsumerGroupRef::auto_anonymous("builder-independent-high")) + .topics([gts_id!("cf.core.events.topic.v1~example.payments.x.x.v1")]) + .profile(ConsumerProfile::high_throughput()) + .buffering(ConsumerBuffering { + partition_capacity: 1024, + high_watermark: 900, + low_watermark: 512, + }) + .listener_settings(ConsumerListenerSettings { + timeout: Duration::from_millis(250), + channel_capacity: 64, + }) + .offset_manager(InMemoryOffsetManager::new(Fallback::Latest)) + .handler(NoopHandler); + + let low_settings = low_latency.builder.effective_settings().unwrap(); + let high_settings = high_throughput.builder.effective_settings().unwrap(); + + assert_ne!(low_latency.builder.topics, high_throughput.builder.topics); + assert_ne!(low_settings.batching, high_settings.batching); + assert_ne!(low_settings.listener, high_settings.listener); + assert_eq!(high_settings.buffering.partition_capacity, 1024); +} + +#[tokio::test] +async fn routed_consumer_rejects_route_outside_subscription_topics() { + let ready = ConsumerBuilder::new_unbound() + .group(ConsumerGroupRef::auto_anonymous( + "builder-routed-invalid-topic", + )) + .topics([gts_id!("cf.core.events.topic.v1~example.orders.x.x.v1")]) + .offset_manager(InMemoryOffsetManager::new(Fallback::Earliest)) + .default_handler(NoopHandler) + .route() + .topic(TopicRef::gts(gts_id!( + "cf.core.events.topic.v1~example.payments.x.x.v1" + ))) + .handler(NoopHandler); + + let err = match ready.start().await { + Ok(_) => panic!("route validation must fail"), + Err(err) => err, + }; + + assert!( + matches!(err, EventBrokerError::InvalidConsumerOptions { .. }), + "unexpected error: {err}" + ); + assert!( + err.to_string().contains("not part of the configured"), + "unexpected error: {err}" + ); +} + +#[tokio::test] +async fn routed_consumer_rejects_duplicate_routes() { + let ready = ConsumerBuilder::new_unbound() + .group(ConsumerGroupRef::auto_anonymous("builder-routed-duplicate")) + .topics([gts_id!("cf.core.events.topic.v1~example.orders.x.x.v1")]) + .offset_manager(InMemoryOffsetManager::new(Fallback::Earliest)) + .default_handler(NoopHandler) + .route() + .topic(TopicRef::gts(gts_id!( + "cf.core.events.topic.v1~example.orders.x.x.v1" + ))) + .event_type(EventTypeRef::gts(gts_id!( + "cf.core.events.event.v1~example.orders.order_created.x.v1" + ))) + .handler(NoopHandler) + .route() + .topic(TopicRef::gts(gts_id!( + "cf.core.events.topic.v1~example.orders.x.x.v1" + ))) + .event_type(EventTypeRef::gts(gts_id!( + "cf.core.events.event.v1~example.orders.order_created.x.v1" + ))) + .handler(NoopHandler); + + let err = match ready.start().await { + Ok(_) => panic!("duplicate route validation must fail"), + Err(err) => err, + }; + + assert!( + matches!(err, EventBrokerError::InvalidConsumerOptions { .. }), + "unexpected error: {err}" + ); + assert!( + err.to_string().contains("duplicate consumer route"), + "unexpected error: {err}" + ); +} + +#[tokio::test] +async fn routed_consumer_without_default_rejects_incomplete_routes() { + let ready = ConsumerBuilder::new_unbound() + .group(ConsumerGroupRef::auto_anonymous( + "builder-routed-missing-default", + )) + .topics([gts_id!("cf.core.events.topic.v1~example.orders.x.x.v1")]) + .offset_manager(InMemoryOffsetManager::new(Fallback::Earliest)) + .route() + .topic(TopicRef::gts(gts_id!( + "cf.core.events.topic.v1~example.orders.x.x.v1" + ))) + .event_type(EventTypeRef::gts(gts_id!( + "cf.core.events.event.v1~example.orders.order_created.x.v1" + ))) + .handler(NoopHandler); + + let err = match ready.start().await { + Ok(_) => panic!("route-only consumer without catch-all must fail"), + Err(err) => err, + }; + + assert!( + matches!(err, EventBrokerError::InvalidConsumerOptions { .. }), + "unexpected error: {err}" + ); + assert!( + err.to_string().contains("without a default handler"), + "unexpected error: {err}" + ); +} + +#[tokio::test] +async fn routed_consumer_rejects_missing_subscription_topics() { + let ready = ConsumerBuilder::new_unbound() + .group(ConsumerGroupRef::auto_anonymous("builder-routed-no-topics")) + .offset_manager(InMemoryOffsetManager::new(Fallback::Earliest)) + .route() + .topic(TopicRef::gts(gts_id!( + "cf.core.events.topic.v1~example.orders.x.x.v1" + ))) + .handler(NoopHandler); + + let err = match ready.start().await { + Ok(_) => panic!("routed consumer without topics must fail"), + Err(err) => err, + }; + + assert!( + matches!(err, EventBrokerError::InvalidConsumerOptions { .. }), + "unexpected error: {err}" + ); + assert!( + err.to_string() + .contains("requires at least one configured topic"), + "unexpected error: {err}" + ); +} + +#[tokio::test] +async fn consumer_handle_exposes_subscription_inspection_and_stop() { + let handle = ConsumerHandle::from_consumer(super::runtime::Consumer::new(2)); + + assert!(handle.subscription_ids().is_empty()); + handle.stop().await.expect("empty handle stop"); +} + +#[cfg(feature = "test-util")] +#[tokio::test] +async fn multiple_consumer_handles_run_with_independent_lifecycle() { + use crate::EventBroker; + use crate::mock::{MockBroker, MockBrokerHandle}; + + const ORDERS_TOPIC: &str = gts_id!("cf.core.events.topic.v1~cf.core.orders.topic.v1"); + const PAYMENTS_TOPIC: &str = gts_id!("cf.core.events.topic.v1~cf.core.payments.topic.v1"); + + let mock = MockBroker::new(); + let control = MockBrokerHandle::from_broker(&mock); + control.register_topic(ORDERS_TOPIC, 4).await; + control.register_topic(PAYMENTS_TOPIC, 4).await; + control + .set_heartbeat_interval(Duration::from_millis(10)) + .await; + + let broker: std::sync::Arc = std::sync::Arc::new(mock); + let orders = ConsumerBuilder::new(broker.clone()) + .group(ConsumerGroupRef::auto_anonymous("orders-handle")) + .topics([ORDERS_TOPIC]) + .profile(ConsumerProfile::low_latency()) + .parallelism(2) + .offset_manager(InMemoryOffsetManager::new(Fallback::Earliest)) + .handler(NoopHandler) + .start() + .await + .expect("orders consumer starts"); + let payments = ConsumerBuilder::new(broker) + .group(ConsumerGroupRef::auto_anonymous("payments-handle")) + .topics([PAYMENTS_TOPIC]) + .profile(ConsumerProfile::high_throughput()) + .parallelism(1) + .offset_manager(InMemoryOffsetManager::new(Fallback::Latest)) + .handler(NoopHandler) + .start() + .await + .expect("payments consumer starts"); + + wait_for_subscription_count(&orders, 2).await; + wait_for_subscription_count(&payments, 1).await; + + let order_subscriptions = orders.subscription_ids(); + let payment_subscriptions = payments.subscription_ids(); + assert_eq!(order_subscriptions.len(), 2); + assert_eq!(payment_subscriptions.len(), 1); + assert!( + order_subscriptions + .iter() + .all(|id| !payment_subscriptions.contains(id)) + ); + + orders.stop().await.expect("orders handle stops"); + payments.stop().await.expect("payments handle stops"); +} + +#[cfg(feature = "test-util")] +async fn wait_for_subscription_count(handle: &ConsumerHandle, expected: usize) { + for _ in 0..100 { + if handle.subscription_ids().len() == expected { + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + panic!( + "consumer handle exposed {} subscription ids, expected {expected}", + handle.subscription_ids().len() + ); +} + +#[cfg(feature = "db")] +mod tx_typestate { + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + + use async_trait::async_trait; + + use super::*; + use crate::consumer::{ + CommitOffsetInTx, ConsumerOffsetManager, OffsetManagerError, OffsetStore, ResolvedPosition, + TxCommitHandle, TxConsumerHandler, TxSingleEventHandler, WithTx, + }; + use crate::ids::{ConsumerGroupId, TopicId}; + + #[derive(Default)] + struct RecordingTxOffsetManager; + + #[async_trait] + impl OffsetStore for RecordingTxOffsetManager { + async fn load_position( + &self, + _group: &ConsumerGroupId, + _topic: &TopicId, + _partition: u32, + ) -> Result { + Ok(Fallback::Earliest.into()) + } + } + + #[async_trait] + impl CommitOffsetInTx for RecordingTxOffsetManager { + async fn commit_in_tx( + &self, + _txn: &TX, + _group: &ConsumerGroupId, + _topic: &TopicId, + _partition: u32, + _offset: i64, + ) -> Result<(), OffsetManagerError> + where + TX: toolkit_db::secure::DBRunner + Sync, + { + Ok(()) + } + } + + impl ConsumerOffsetManager for RecordingTxOffsetManager { + type BuilderState = WithTx; + + fn into_builder_state(self) -> Self::BuilderState { + WithTx(self) + } + } + + #[derive(Clone, Default)] + struct CountingTxOffsetManager { + commits: Arc, + } + + #[async_trait] + impl OffsetStore for CountingTxOffsetManager { + async fn load_position( + &self, + _group: &ConsumerGroupId, + _topic: &TopicId, + _partition: u32, + ) -> Result { + Ok(Fallback::Earliest.into()) + } + } + + #[async_trait] + impl CommitOffsetInTx for CountingTxOffsetManager { + async fn commit_in_tx( + &self, + _txn: &TX, + _group: &ConsumerGroupId, + _topic: &TopicId, + _partition: u32, + _offset: i64, + ) -> Result<(), OffsetManagerError> + where + TX: toolkit_db::secure::DBRunner + Sync, + { + self.commits.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + } + + impl ConsumerOffsetManager for CountingTxOffsetManager { + type BuilderState = WithTx; + + fn into_builder_state(self) -> Self::BuilderState { + WithTx(self) + } + } + + struct NoCommitTxHandler; + + #[async_trait] + impl TxSingleEventHandler for NoCommitTxHandler { + async fn handle( + &self, + _event: RawEvent, + _attempts: u16, + _commit: TxCommitHandle, + ) -> Result { + Ok(HandlerOutcome::Success) + } + } + + struct NoopTxHandler; + + #[async_trait] + impl TxSingleEventHandler for NoopTxHandler { + async fn handle( + &self, + _event: RawEvent, + _attempts: u16, + _commit: TxCommitHandle, + ) -> Result { + Ok(HandlerOutcome::Success) + } + } + + struct NoopTxBatchHandler; + + #[async_trait] + impl TxConsumerHandler for NoopTxBatchHandler { + async fn handle_batch( + &self, + _batch: &EventBatch<'_>, + _attempts: u16, + _commit: TxCommitHandle, + ) -> Result { + Ok(HandlerOutcome::Success) + } + } + + #[test] + fn consumer_builder_accepts_transactional_typestate_quadrants() { + let _single = ConsumerBuilder::new_unbound() + .group(ConsumerGroupRef::auto_anonymous("builder-tx-single")) + .topics([gts_id!("cf.core.events.topic.v1~example.orders.x.x.v1")]) + .offset_manager(RecordingTxOffsetManager) + .handler(NoopTxHandler); + + let _batch = ConsumerBuilder::new_unbound() + .group(ConsumerGroupRef::auto_anonymous("builder-tx-batch")) + .topics([gts_id!("cf.core.events.topic.v1~example.orders.x.x.v1")]) + .offset_manager(RecordingTxOffsetManager) + .batch_handler(NoopTxBatchHandler); + + let _routed = ConsumerBuilder::new_unbound() + .group(ConsumerGroupRef::auto_anonymous("builder-tx-routed")) + .topics([gts_id!("cf.core.events.topic.v1~example.orders.x.x.v1")]) + .offset_manager(RecordingTxOffsetManager) + .default_handler(NoopTxHandler) + .route() + .topic(TopicRef::gts(gts_id!( + "cf.core.events.topic.v1~example.orders.x.x.v1" + ))) + .event_type(EventTypeRef::gts(gts_id!( + "cf.core.events.event.v1~example.orders.order_created.x.v1" + ))) + .batch_handler(NoopTxBatchHandler); + } + + #[cfg(feature = "test-util")] + #[tokio::test] + async fn transactional_consumer_start_paths_return_lifecycle_handles() { + use crate::EventBroker; + use crate::mock::{MockBroker, MockBrokerHandle}; + + const TOPIC: &str = gts_id!("cf.core.events.topic.v1~cf.core.orders.topic.v1"); + + let mock = MockBroker::new(); + let control = MockBrokerHandle::from_broker(&mock); + control.register_topic(TOPIC, 3).await; + control + .set_heartbeat_interval(Duration::from_millis(10)) + .await; + let broker: std::sync::Arc = std::sync::Arc::new(mock); + + let single = ConsumerBuilder::new(broker.clone()) + .group(ConsumerGroupRef::auto_anonymous("tx-single-start")) + .topics([TOPIC]) + .parallelism(1) + .offset_manager(RecordingTxOffsetManager) + .handler(NoopTxHandler) + .start() + .await + .expect("transactional single consumer starts"); + let batch = ConsumerBuilder::new(broker.clone()) + .group(ConsumerGroupRef::auto_anonymous("tx-batch-start")) + .topics([TOPIC]) + .parallelism(1) + .offset_manager(RecordingTxOffsetManager) + .batch_handler(NoopTxBatchHandler) + .start() + .await + .expect("transactional batch consumer starts"); + let routed = ConsumerBuilder::new(broker.clone()) + .group(ConsumerGroupRef::auto_anonymous("tx-routed-start")) + .topics([TOPIC]) + .parallelism(1) + .offset_manager(RecordingTxOffsetManager) + .default_handler(NoopTxHandler) + .route() + .topic(TopicRef::gts(TOPIC)) + .batch_handler(NoopTxBatchHandler) + .start() + .await + .expect("transactional routed consumer starts"); + wait_for_subscription_count(&single, 1).await; + wait_for_subscription_count(&batch, 1).await; + wait_for_subscription_count(&routed, 1).await; + + single.stop().await.expect("single stops"); + batch.stop().await.expect("batch stops"); + routed.stop().await.expect("routed stops"); + } + + #[cfg(feature = "test-util")] + #[tokio::test] + async fn transactional_consumer_does_not_auto_commit_when_handler_omits_commit_in_tx() { + use crate::EventBroker; + use crate::mock::stubs::test_ctx_for_tenant; + use crate::mock::{MockBroker, MockBrokerHandle}; + use crate::models::Event; + use uuid::Uuid; + + const TOPIC: &str = gts_id!("cf.core.events.topic.v1~cf.core.orders.topic.v1"); + const EVENT_TYPE: &str = + gts_id!("cf.core.events.event_type.v1~example.mock.broker.event.v1"); + + let mock = MockBroker::new(); + let control = MockBrokerHandle::from_broker(&mock); + control.register_topic(TOPIC, 1).await; + control + .register_event_type( + TOPIC, + EVENT_TYPE, + serde_json::json!({ "type": "object" }), + &[], + ) + .await; + control + .set_heartbeat_interval(Duration::from_millis(10)) + .await; + let broker: std::sync::Arc = std::sync::Arc::new(mock); + let ctx = + test_ctx_for_tenant(Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap()); + let manager = CountingTxOffsetManager::default(); + let commits = manager.commits.clone(); + + let handle = ConsumerBuilder::new(broker.clone()) + .group(ConsumerGroupRef::auto_anonymous("tx-no-auto-commit")) + .topics([TOPIC]) + .commit_mode(ConsumerCommitMode::auto(Duration::from_millis(5))) + .offset_manager(manager) + .handler(NoCommitTxHandler) + .start() + .await + .expect("transactional consumer starts"); + + wait_for_subscription_count(&handle, 1).await; + broker + .publish( + &ctx, + &Event { + id: Uuid::new_v4(), + type_id: EVENT_TYPE.to_owned(), + topic: TOPIC.to_owned(), + tenant_id: ctx.subject_tenant_id(), + source: "consumer.builder.test".to_owned(), + subject: "subject-1".to_owned(), + subject_type: "test".to_owned(), + partition_key: None, + occurred_at: chrono::Utc::now(), + trace_parent: None, + data: Some(serde_json::json!({ "ok": true })), + partition: None, + sequence: None, + sequence_time: None, + offset: None, + offset_time: None, + meta: None, + }, + ) + .await + .expect("event published"); + tokio::time::sleep(Duration::from_millis(50)).await; + + handle.stop().await.expect("consumer stops"); + assert_eq!(commits.load(Ordering::SeqCst), 0); + } +} diff --git a/gears/system/event-broker/event-broker-sdk/src/consumer/commit.rs b/gears/system/event-broker/event-broker-sdk/src/consumer/commit.rs new file mode 100644 index 000000000..80dbf641b --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/src/consumer/commit.rs @@ -0,0 +1,67 @@ +#[cfg(feature = "db")] +use crate::error::ConsumerError; + +/// Commit handle for tx-capable consumers (`db` feature). +/// Offers `commit_offset_in_tx`, which writes a delivered offset into the +/// caller's transaction atomically with the handler's business writes. +/// +/// Generic over `OM: CommitOffsetInTx` so `commit_offset_in_tx` can call +/// `OM::commit_in_tx` without boxing. Handler impls use the concrete OM type +/// (`TxCommitHandle`). +#[cfg(feature = "db")] +pub struct TxCommitHandle { + pub(crate) partition: u32, + pub(crate) batch_offsets: Vec, + pub(crate) offset_manager: std::sync::Arc, + pub(crate) group: crate::ids::ConsumerGroupId, + pub(crate) topic: crate::ids::TopicId, + /// Offset successfully written inside the user transaction. + pub(crate) committed_offset: std::sync::Arc>>, +} + +#[cfg(feature = "db")] +pub(crate) struct TxCommitHandleParts { + pub(crate) partition: u32, + pub(crate) offsets: Vec, + pub(crate) offset_manager: std::sync::Arc, + pub(crate) group: crate::ids::ConsumerGroupId, + pub(crate) topic: crate::ids::TopicId, +} + +#[cfg(feature = "db")] +impl TxCommitHandle { + pub(crate) fn new(parts: TxCommitHandleParts) -> Self { + Self { + partition: parts.partition, + batch_offsets: parts.offsets, + offset_manager: parts.offset_manager, + group: parts.group, + topic: parts.topic, + committed_offset: std::sync::Arc::new(std::sync::Mutex::new(None)), + } + } + + pub async fn commit_offset_in_tx(&self, txn: &TX, offset: i64) -> Result<(), ConsumerError> + where + TX: toolkit_db::secure::DBRunner + Sync, + { + if !self.batch_offsets.contains(&offset) { + return Err(crate::error::EventBrokerError::InvalidConsumerOptions { + detail: format!( + "committed offset {offset} is not present in delivered batch offsets {:?}", + self.batch_offsets + ), + instance: String::new(), + }); + } + + self.offset_manager + .commit_in_tx(txn, &self.group, &self.topic, self.partition, offset) + .await + .map_err(crate::error::EventBrokerError::OffsetManager)?; + *self.committed_offset.lock().map_err(|_| { + crate::error::EventBrokerError::Internal("tx commit state mutex poisoned".into()) + })? = Some(offset); + Ok(()) + } +} diff --git a/gears/system/event-broker/event-broker-sdk/src/consumer/commit_tests.rs b/gears/system/event-broker/event-broker-sdk/src/consumer/commit_tests.rs new file mode 100644 index 000000000..e5552a036 --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/src/consumer/commit_tests.rs @@ -0,0 +1,230 @@ +#[cfg(feature = "db")] +mod tx { + use std::sync::{Arc, Mutex}; + + use async_trait::async_trait; + use uuid::Uuid; + + use super::super::commit::TxCommitHandleParts; + use crate::consumer::{ + CommitOffsetInTx, Fallback, OffsetManagerError, OffsetStore, ResolvedPosition, + TxCommitHandle, + }; + use crate::ids::{ConsumerGroupId, TopicId}; + + type RecordedCommit = (ConsumerGroupId, TopicId, u32, i64); + type RecordedCommits = Mutex>; + + #[derive(Default)] + struct RecordingTxOffsetManager { + calls: RecordedCommits, + } + + #[async_trait] + impl OffsetStore for RecordingTxOffsetManager { + async fn load_position( + &self, + _group: &ConsumerGroupId, + _topic: &TopicId, + _partition: u32, + ) -> Result { + Ok(Fallback::Earliest.into()) + } + } + + #[async_trait] + impl CommitOffsetInTx for RecordingTxOffsetManager { + async fn commit_in_tx( + &self, + _txn: &TX, + group: &ConsumerGroupId, + topic: &TopicId, + partition: u32, + offset: i64, + ) -> Result<(), OffsetManagerError> + where + TX: toolkit_db::secure::DBRunner + Sync, + { + self.calls + .lock() + .expect("recording mutex") + .push((*group, *topic, partition, offset)); + Ok(()) + } + } + + #[tokio::test] + async fn tx_commit_handle_persists_explicit_offset_and_records_commit_state() { + let db = toolkit_db::connect_db( + "sqlite::memory:", + toolkit_db::ConnectOpts { + max_conns: Some(1), + ..toolkit_db::ConnectOpts::default() + }, + ) + .await + .expect("sqlite db"); + + let manager = Arc::new(RecordingTxOffsetManager::default()); + let group = ConsumerGroupId::new(Uuid::new_v4()); + let topic = TopicId::new(Uuid::new_v4()); + let handle = TxCommitHandle::new(TxCommitHandleParts { + partition: 5, + offsets: vec![123], + offset_manager: manager.clone(), + group, + topic, + }); + let committed = handle.committed_offset.clone(); + + assert_eq!(*committed.lock().expect("commit state mutex"), None); + db.transaction_ref(move |tx| { + Box::pin(async move { + handle + .commit_offset_in_tx(tx, 123) + .await + .map_err(|err| toolkit_db::DbError::InvalidConfig(err.to_string()))?; + Ok(()) + }) + }) + .await + .expect("transaction"); + + assert_eq!(*committed.lock().expect("commit state mutex"), Some(123)); + assert_eq!( + manager.calls.lock().expect("recording mutex").as_slice(), + &[(group, topic, 5, 123)] + ); + } + + #[tokio::test] + async fn tx_commit_handle_commits_batch_success_offset_in_transaction() { + let db = toolkit_db::connect_db( + "sqlite::memory:", + toolkit_db::ConnectOpts { + max_conns: Some(1), + ..toolkit_db::ConnectOpts::default() + }, + ) + .await + .expect("sqlite db"); + + let manager = Arc::new(RecordingTxOffsetManager::default()); + let group = ConsumerGroupId::new(Uuid::new_v4()); + let topic = TopicId::new(Uuid::new_v4()); + let handle = TxCommitHandle::new(TxCommitHandleParts { + partition: 5, + offsets: vec![10, 11, 12], + offset_manager: manager.clone(), + group, + topic, + }); + let committed = handle.committed_offset.clone(); + + db.transaction_ref(move |tx| { + Box::pin(async move { + handle + .commit_offset_in_tx(tx, 12) + .await + .map_err(|err| toolkit_db::DbError::InvalidConfig(err.to_string()))?; + Ok(()) + }) + }) + .await + .expect("transaction"); + + assert_eq!( + manager.calls.lock().expect("recording mutex").as_slice(), + &[(group, topic, 5, 12)] + ); + assert_eq!(*committed.lock().expect("commit state mutex"), Some(12)); + } + + #[tokio::test] + async fn tx_commit_handle_commits_delivered_prefix_offset_in_transaction() { + let db = toolkit_db::connect_db( + "sqlite::memory:", + toolkit_db::ConnectOpts { + max_conns: Some(1), + ..toolkit_db::ConnectOpts::default() + }, + ) + .await + .expect("sqlite db"); + + let manager = Arc::new(RecordingTxOffsetManager::default()); + let group = ConsumerGroupId::new(Uuid::new_v4()); + let topic = TopicId::new(Uuid::new_v4()); + let handle = TxCommitHandle::new(TxCommitHandleParts { + partition: 5, + offsets: vec![10, 17, 19], + offset_manager: manager.clone(), + group, + topic, + }); + let committed = handle.committed_offset.clone(); + + db.transaction_ref(move |tx| { + Box::pin(async move { + handle + .commit_offset_in_tx(tx, 17) + .await + .map_err(|err| toolkit_db::DbError::InvalidConfig(err.to_string()))?; + Ok(()) + }) + }) + .await + .expect("transaction"); + + assert_eq!( + manager.calls.lock().expect("recording mutex").as_slice(), + &[(group, topic, 5, 17)] + ); + assert_eq!(*committed.lock().expect("commit state mutex"), Some(17)); + } + + #[tokio::test] + async fn tx_commit_handle_rejects_offset_outside_delivered_batch_before_write() { + let db = toolkit_db::connect_db( + "sqlite::memory:", + toolkit_db::ConnectOpts { + max_conns: Some(1), + ..toolkit_db::ConnectOpts::default() + }, + ) + .await + .expect("sqlite db"); + + let manager = Arc::new(RecordingTxOffsetManager::default()); + let group = ConsumerGroupId::new(Uuid::new_v4()); + let topic = TopicId::new(Uuid::new_v4()); + let handle = TxCommitHandle::new(TxCommitHandleParts { + partition: 5, + offsets: vec![10, 11, 12], + offset_manager: manager.clone(), + group, + topic, + }); + let committed = handle.committed_offset.clone(); + + let err = db + .transaction_ref(move |tx| { + Box::pin(async move { + handle + .commit_offset_in_tx(tx, 20) + .await + .map_err(|err| toolkit_db::DbError::InvalidConfig(err.to_string()))?; + Ok(()) + }) + }) + .await + .expect_err("outside offset must be rejected"); + + assert!( + err.to_string() + .contains("not present in delivered batch offsets") + ); + assert!(manager.calls.lock().expect("recording mutex").is_empty()); + assert_eq!(*committed.lock().expect("commit state mutex"), None); + } +} diff --git a/gears/system/event-broker/event-broker-sdk/src/consumer/dispatcher.rs b/gears/system/event-broker/event-broker-sdk/src/consumer/dispatcher.rs new file mode 100644 index 000000000..8514cf379 --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/src/consumer/dispatcher.rs @@ -0,0 +1,2094 @@ +use std::collections::{HashMap, VecDeque}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use tokio::sync::RwLock; +use toolkit_security::SecurityContext; +use tracing::{trace, warn}; + +use futures_util::StreamExt; + +#[cfg(feature = "db")] +use super::commit::TxCommitHandleParts; +use crate::api::{ + AssignedPartition, EventBroker, JoinRequest, ResolvedPosition, SubscriptionAssignment, +}; +use crate::api::{ + BarrierMode, ControlCode, Filter, PartitionPosition, PartitionSlot, SeekPosition, + SubscriptionInterest as BrokerSubscriptionInterest, TenantTraversalDepth, WireEvent, WireFrame, +}; +use crate::consumer::builder::{ + event_type_ref_to_string, subscription_filter_ref_to_filter, topic_ref_to_string, +}; +use crate::consumer::offset_manager::CommitOffset; +#[cfg(feature = "db")] +use crate::consumer::progress::processed_count_from_delivered_offset; +use crate::consumer::progress::processed_count_from_outcome; +use crate::consumer::types::{PartitionFrontier, SubscriptionInterest}; +use crate::consumer::{ + BatchHandlerOutcome, ConnectionDropReason, ConsumerCommitMode, ConsumerGroupRef, + ConsumerHandler, ConsumerRuntimeEvent, ConsumerRuntimeListener, EventBatch, + PartitionBufferState, PartitionBufferStateSnapshot, PartitionProgress, RawEvent, + SlowConsumerTrigger, +}; +#[cfg(feature = "db")] +use crate::consumer::{CommitOffsetInTx, TxCommitHandle, TxConsumerHandler}; +use crate::error::EventBrokerError; +use crate::ids::{ConsumerGroupId, SubscriptionId, TopicId}; + +/// Per-partition in-memory cursor for the contiguous processed frontier. +#[derive(Default)] +pub(crate) struct PartitionCursor { + frontier: Option, + committed: i64, // the last offset actually committed to CommitOffset +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub(crate) struct TopicPartitionKey { + topic: String, + topic_id: TopicId, + partition: u32, +} + +impl TopicPartitionKey { + pub(crate) fn new(topic: impl Into, topic_id: TopicId, partition: u32) -> Self { + Self { + topic: topic.into(), + topic_id, + partition, + } + } +} + +pub(crate) struct PartitionEventBuffer { + capacity: usize, + events: VecDeque, +} + +#[derive(Debug)] +pub(crate) struct EnqueuedPartitionBatch { + pub(crate) buffered_count: usize, +} + +impl PartitionEventBuffer { + fn new(capacity: usize) -> Self { + Self { + capacity, + events: VecDeque::with_capacity(capacity.min(1024)), + } + } + + fn push(&mut self, event: RawEvent) -> Result<(), EventBrokerError> { + if self.events.len() >= self.capacity { + return Err(EventBrokerError::InvalidConsumerOptions { + detail: format!( + "partition buffer capacity {} exceeded for topic '{}' partition {}", + self.capacity, event.topic, event.partition + ), + instance: String::new(), + }); + } + self.events.push_back(event); + Ok(()) + } + + fn len(&self) -> usize { + self.events.len() + } + + fn drain_batch(&mut self, max_events: usize) -> Vec { + let count = max_events.max(1).min(self.events.len()); + self.events.drain(..count).collect() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SlowConsumerReason { + BufferHighWatermark, + HandlerLatencyStrikes, +} + +fn slow_consumer_trigger(reason: SlowConsumerReason) -> SlowConsumerTrigger { + match reason { + SlowConsumerReason::BufferHighWatermark => SlowConsumerTrigger::BufferHighWatermark, + SlowConsumerReason::HandlerLatencyStrikes => SlowConsumerTrigger::HandlerLatencyStrikes, + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct SlowConsumerSignal { + pub reason: SlowConsumerReason, + pub buffered_count: usize, + pub consecutive_slow_handlers: u16, + pub latest_observed_offset: Option, + pub last_delivered_offset: Option, +} + +#[derive(Debug, Clone, Default)] +pub(crate) struct PartitionSlowState { + buffered_count: usize, + latest_observed_offset: Option, + last_delivered_offset: Option, + consecutive_slow_handlers: u16, + slow_detected: bool, +} + +type PartitionCursors = Arc>>; +type PartitionBuffers = Arc>>; +type PartitionSlowStates = Arc>>; + +#[derive(Clone, Copy)] +struct DispatchState<'a> { + cursors: &'a PartitionCursors, + buffers: &'a PartitionBuffers, + slow_states: &'a PartitionSlowStates, +} + +#[derive(Clone, Copy)] +struct ActiveSubscription<'a> { + group_id: &'a ConsumerGroupId, + sub_id: SubscriptionId, + affected: &'a [AssignedPartition], +} + +impl PartitionSlowState { + pub(crate) fn observe_enqueue( + &mut self, + buffered_count: usize, + latest_observed_offset: i64, + high_watermark: usize, + ) -> Option { + self.buffered_count = buffered_count; + self.latest_observed_offset = Some(latest_observed_offset); + if buffered_count >= high_watermark { + self.detect(SlowConsumerReason::BufferHighWatermark) + } else { + None + } + } + + pub(crate) fn observe_handler_completion( + &mut self, + elapsed: Duration, + handler_latency: Duration, + handler_strikes: u16, + last_delivered_offset: i64, + ) -> Option { + self.last_delivered_offset = Some(last_delivered_offset); + if elapsed >= handler_latency { + self.consecutive_slow_handlers = self.consecutive_slow_handlers.saturating_add(1); + } else { + self.consecutive_slow_handlers = 0; + } + + if self.consecutive_slow_handlers >= handler_strikes { + self.detect(SlowConsumerReason::HandlerLatencyStrikes) + } else { + None + } + } + + fn detect(&mut self, reason: SlowConsumerReason) -> Option { + if self.slow_detected { + return None; + } + self.slow_detected = true; + Some(SlowConsumerSignal { + reason, + buffered_count: self.buffered_count, + consecutive_slow_handlers: self.consecutive_slow_handlers, + latest_observed_offset: self.latest_observed_offset, + last_delivered_offset: self.last_delivered_offset, + }) + } +} + +pub(crate) async fn enqueue_partition_batch( + buffers: &PartitionBuffers, + key: TopicPartitionKey, + event: RawEvent, + capacity: usize, +) -> Result { + let mut guard = buffers.write().await; + let buffer = guard + .entry(key) + .or_insert_with(|| PartitionEventBuffer::new(capacity)); + buffer.push(event)?; + let buffered_count = buffer.len(); + Ok(EnqueuedPartitionBatch { buffered_count }) +} + +async fn drain_partition_batch( + buffers: &PartitionBuffers, + key: &TopicPartitionKey, + max_events: usize, +) -> Vec { + let mut guard = buffers.write().await; + guard + .get_mut(key) + .map(|buffer| buffer.drain_batch(max_events)) + .unwrap_or_default() +} + +async fn next_buffered_partition_batch( + buffers: &PartitionBuffers, + max_events: usize, +) -> Option<(TopicPartitionKey, Vec)> { + let mut guard = buffers.write().await; + let key = guard + .iter() + .find_map(|(key, buffer)| (!buffer.events.is_empty()).then(|| key.clone()))?; + let batch = guard + .get_mut(&key) + .map(|buffer| buffer.drain_batch(max_events)) + .unwrap_or_default(); + Some((key, batch)) +} + +pub(crate) async fn emit_runtime_event_to( + listeners: &[Arc], + timeout: Duration, + event: ConsumerRuntimeEvent, +) { + // Listener failures are diagnostic only. They must never decide ack, + // commit, reject, or stream-drop behavior. + for listener in listeners { + match tokio::time::timeout(timeout, listener.on_consumer_event(&event)).await { + Ok(Ok(())) => {} + Ok(Err(err)) => { + warn!(error = %err, "consumer runtime listener failed"); + } + Err(_) => { + warn!( + timeout_ms = timeout.as_millis(), + "consumer runtime listener timed out" + ); + } + } + } +} + +fn partition_progress(key: &TopicPartitionKey, offset: i64) -> PartitionProgress { + PartitionProgress { + topic_id: key.topic_id, + topic: key.topic.clone(), + partition: key.partition, + offset, + } +} + +impl PartitionCursor { + pub(crate) fn latest_offset(&self) -> i64 { + self.frontier + .as_ref() + .map(PartitionFrontier::committed) + .unwrap_or(self.committed) + } + + pub(crate) fn advance_through_delivered_prefix(&mut self, events: &[RawEvent]) -> i64 { + let Some(last) = events.last() else { + return self.latest_offset(); + }; + let frontier = last.offset; + self.frontier = Some(PartitionFrontier::new(frontier)); + frontier + } +} + +/// Runs one subscription slot: JOIN, poll, dispatch, retry, re-JOIN. +pub(crate) struct SlotDispatcher +where + H: ConsumerHandler + 'static, + OM: CommitOffset + 'static, +{ + pub slot_idx: u32, + pub broker: Arc, + pub offset_manager: Arc, + pub handler: Arc, + pub group_ref: ConsumerGroupRef, + pub topics: Vec, + pub subscription_interests: Vec, + pub tenant_id: Option, + pub tenant_depth: TenantTraversalDepth, + pub barrier_mode: BarrierMode, + pub event_type_patterns: Vec, + pub client_agent: String, + pub session_timeout: Option, + pub filter: Option, + pub heartbeat_drop_threshold: usize, + pub retry_base: Duration, + pub retry_max: Duration, + pub commit_mode: ConsumerCommitMode, + pub partition_buffer_capacity: usize, + pub buffer_high_watermark: usize, + pub buffer_low_watermark: usize, + pub batch_max_events: usize, + pub handler_latency: Duration, + pub handler_strikes: u16, + pub listeners: Vec>, + pub listener_timeout: Duration, + pub max_rejoin_attempts: u32, + pub subscription_id: Arc>>, +} + +#[cfg(feature = "db")] +/// Runs one transactional subscription slot. Unlike [`SlotDispatcher`], this path +/// never creates an auto-commit timer; offset durability is handler-driven through +/// `TxCommitHandle::commit_offset_in_tx`. +pub(crate) struct TxSlotDispatcher +where + H: TxConsumerHandler + 'static, + OM: CommitOffsetInTx + 'static, +{ + pub slot_idx: u32, + pub broker: Arc, + pub offset_manager: Arc, + pub handler: Arc, + pub group_ref: ConsumerGroupRef, + pub topics: Vec, + pub subscription_interests: Vec, + pub tenant_id: Option, + pub tenant_depth: TenantTraversalDepth, + pub barrier_mode: BarrierMode, + pub event_type_patterns: Vec, + pub client_agent: String, + pub session_timeout: Option, + pub filter: Option, + pub heartbeat_drop_threshold: usize, + pub retry_base: Duration, + pub retry_max: Duration, + pub partition_buffer_capacity: usize, + pub buffer_high_watermark: usize, + pub buffer_low_watermark: usize, + pub batch_max_events: usize, + pub handler_latency: Duration, + pub handler_strikes: u16, + pub listeners: Vec>, + pub listener_timeout: Duration, + pub max_rejoin_attempts: u32, + pub subscription_id: Arc>>, +} + +fn build_join_interests( + subscription_interests: &[SubscriptionInterest], + topics: &[String], + tenant_id: Option, + tenant_depth: TenantTraversalDepth, + barrier_mode: BarrierMode, + event_type_patterns: &[String], + filter: Option, +) -> Result, EventBrokerError> { + let tenant_id = tenant_id.unwrap_or_else(uuid::Uuid::nil); + if !subscription_interests.is_empty() { + return subscription_interests + .iter() + .map(|interest| { + let mut builder = BrokerSubscriptionInterest::builder() + .topic(topic_ref_to_string(&interest.topic)) + .tenant_id(tenant_id) + .tenant_depth(tenant_depth) + .barrier_mode(barrier_mode) + .types( + interest + .event_types + .iter() + .map(event_type_ref_to_string) + .collect::>(), + ); + if let Some(filter) = interest.filter.as_ref() { + builder = builder.filter(subscription_filter_ref_to_filter(filter)); + } + builder.build() + }) + .collect(); + } + + let event_type_patterns = if event_type_patterns.is_empty() { + vec!["*".to_owned()] + } else { + event_type_patterns.to_vec() + }; + topics + .iter() + .map(|topic| { + let mut builder = BrokerSubscriptionInterest::builder() + .topic(topic.clone()) + .tenant_id(tenant_id) + .tenant_depth(tenant_depth) + .barrier_mode(barrier_mode) + .types(event_type_patterns.clone()); + if let Some(filter) = filter.clone() { + builder = builder.filter(filter); + } + builder.build() + }) + .collect() +} + +#[cfg(feature = "db")] +impl TxSlotDispatcher +where + H: TxConsumerHandler + 'static, + OM: CommitOffsetInTx + 'static, +{ + pub async fn run( + &self, + ctx: Arc, + cancel: tokio_util::sync::CancellationToken, + ) -> Result<(), EventBrokerError> { + trace!( + slot_idx = self.slot_idx, + "transactional slot dispatcher starting" + ); + let group_id = self.ensure_group(&ctx).await?; + let mut assignment = self.join_subscription(&ctx, &group_id).await?; + { + let mut guard = self.subscription_id.lock().await; + *guard = Some(assignment.subscription_id); + } + + self.resolve_and_seek( + &ctx, + &group_id, + assignment.subscription_id, + &assignment.assigned, + ) + .await?; + + let cursors: PartitionCursors = Arc::new(RwLock::new(HashMap::new())); + let buffers: PartitionBuffers = Arc::new(RwLock::new(HashMap::new())); + let slow_states: PartitionSlowStates = Arc::new(RwLock::new(HashMap::new())); + let mut consecutive_failures = 0u32; + 'outer: loop { + if cancel.is_cancelled() { + break; + } + + let sub_id = assignment.subscription_id; + let mut stream = match self.broker.stream(&ctx, sub_id).await { + Ok(s) => s, + Err(EventBrokerError::PositionsNotSet { unseeded, .. }) => { + consecutive_failures += 1; + if consecutive_failures > self.max_rejoin_attempts { + return Err(EventBrokerError::SubscriptionRecoveryExhausted { + attempts: consecutive_failures, + detail: "stream open: PositionsNotSet recovery exhausted".into(), + instance: String::new(), + }); + } + let slots = self.slots_for_unseeded(&unseeded); + self.resolve_and_seek(&ctx, &group_id, sub_id, &slots) + .await?; + continue; + } + Err(e) => { + warn!( + slot_idx = self.slot_idx, + error = %e, + "transactional stream open failed; re-joining" + ); + assignment = self + .rejoin(&ctx, &group_id, sub_id, &mut consecutive_failures) + .await?; + self.resolve_and_seek( + &ctx, + &group_id, + assignment.subscription_id, + &assignment.assigned, + ) + .await?; + continue; + } + }; + + let mut consecutive_heartbeats: usize = 0; + consecutive_failures = 0; + let mut drain_before_rejoin = false; + + while let Some(frame_result) = stream.next().await { + if cancel.is_cancelled() { + break 'outer; + } + match frame_result { + Ok(WireFrame::Event(event)) => { + consecutive_heartbeats = 0; + if self + .dispatch_event( + event, + DispatchState { + cursors: &cursors, + buffers: &buffers, + slow_states: &slow_states, + }, + ActiveSubscription { + group_id: &group_id, + sub_id, + affected: &assignment.assigned, + }, + ) + .await + { + drain_before_rejoin = true; + break; + } + } + Ok(WireFrame::Heartbeat { .. }) => { + consecutive_heartbeats += 1; + if consecutive_heartbeats >= self.heartbeat_drop_threshold { + trace!( + slot_idx = self.slot_idx, + threshold = self.heartbeat_drop_threshold, + "transactional drop-on-Nth-heartbeat triggered" + ); + break; + } + } + Ok(WireFrame::Control { + code, + positions, + reason, + }) => { + self.observe_positions(&positions).await; + if code == ControlCode::Terminal { + trace!( + slot_idx = self.slot_idx, + ?reason, + "transactional terminal control frame; re-JOIN" + ); + break; + } + } + Ok(WireFrame::Topology { + topology_version: _, + assigned, + }) => { + self.observe_positions(&assigned).await; + } + Err(EventBrokerError::Transport(_)) => break, + Err(e) => { + warn!( + slot_idx = self.slot_idx, + error = %e, + "transactional stream frame error; ending stream" + ); + break; + } + } + } + + if cancel.is_cancelled() { + break; + } + if drain_before_rejoin { + self.drain_subscription_buffers( + DispatchState { + cursors: &cursors, + buffers: &buffers, + slow_states: &slow_states, + }, + ActiveSubscription { + group_id: &group_id, + sub_id, + affected: &assignment.assigned, + }, + ) + .await; + let _ = self.broker.leave(&ctx, sub_id).await; + } + self.emit_runtime_event(ConsumerRuntimeEvent::SubscriptionTerminated { + subscription_id: sub_id, + reason: ConnectionDropReason::StreamTerminated, + }) + .await; + assignment = self + .rejoin(&ctx, &group_id, sub_id, &mut consecutive_failures) + .await?; + self.resolve_and_seek( + &ctx, + &group_id, + assignment.subscription_id, + &assignment.assigned, + ) + .await?; + } + + let _ = self.broker.leave(&ctx, assignment.subscription_id).await; + Ok(()) + } + + async fn dispatch_event( + &self, + wire: WireEvent, + state: DispatchState<'_>, + active: ActiveSubscription<'_>, + ) -> bool { + let topic_id = TopicId::from_gts(&wire.topic); + let key = TopicPartitionKey::new(wire.topic.clone(), topic_id, wire.partition); + let raw = RawEvent { + id: wire.id, + type_id: wire.type_id.clone(), + topic: wire.topic.clone(), + tenant_id: wire.tenant_id, + subject: wire.subject, + subject_type: wire.subject_type, + partition_key: wire.partition_key, + partition: wire.partition, + sequence: wire.sequence, + offset: wire.offset, + occurred_at: wire.occurred_at, + sequence_time: wire.sequence_time, + trace_parent: wire.trace_parent, + data: wire.data, + }; + let enqueued = match enqueue_partition_batch( + state.buffers, + key.clone(), + raw, + self.partition_buffer_capacity, + ) + .await + { + Ok(events) => events, + Err(e) => { + warn!(error = %e, "transactional partition buffer rejected event"); + return false; + } + }; + if self + .observe_enqueue_slow_signal( + state, + key.clone(), + enqueued.buffered_count, + wire.offset, + active, + ) + .await + { + return true; + } + let batch_events = drain_partition_batch(state.buffers, &key, self.batch_max_events).await; + self.process_batch(state, key, batch_events, active).await + } + + async fn drain_subscription_buffers( + &self, + state: DispatchState<'_>, + active: ActiveSubscription<'_>, + ) { + trace!( + slot_idx = self.slot_idx, + low_watermark = self.buffer_low_watermark, + "draining transactional buffered events before re-JOIN" + ); + loop { + let Some((key, batch_events)) = + next_buffered_partition_batch(state.buffers, self.batch_max_events).await + else { + break; + }; + let _ = self.process_batch(state, key, batch_events, active).await; + } + } + + async fn process_batch( + &self, + state: DispatchState<'_>, + key: TopicPartitionKey, + batch_events: Vec, + active: ActiveSubscription<'_>, + ) -> bool { + if batch_events.is_empty() { + return false; + } + let batch_partition = batch_events[0].partition; + let batch_offset = batch_events + .last() + .map(|event| event.offset) + .unwrap_or_else(|| batch_events[0].offset); + let topic_id = key.topic_id; + + let mut attempts: u16 = 1; + let mut backoff = self.retry_base; + + loop { + self.emit_runtime_event(ConsumerRuntimeEvent::HandlerBatchStarted { + topic_id, + topic: key.topic.clone(), + partition: batch_partition, + len: batch_events.len(), + }) + .await; + let commit_handle = TxCommitHandle::new(TxCommitHandleParts { + partition: batch_partition, + offsets: batch_events.iter().map(|event| event.offset).collect(), + offset_manager: self.offset_manager.clone(), + group: *active.group_id, + topic: topic_id, + }); + let committed_offset = commit_handle.committed_offset.clone(); + let batch = EventBatch::new(&batch_events); + let started_at = Instant::now(); + match self + .handler + .handle_batch(&batch, attempts, commit_handle) + .await + { + Ok(crate::consumer::HandlerOutcome::Success) => { + let committed_offset_result = match committed_offset.lock() { + Ok(guard) => Ok(*guard), + Err(_) => Err("tx commit state mutex poisoned".to_owned()), + }; + let committed_offset = match committed_offset_result { + Ok(committed_offset) => committed_offset, + Err(error) => { + self.emit_runtime_event(ConsumerRuntimeEvent::HandlerFailed { + topic_id, + topic: key.topic.clone(), + partition: batch_partition, + error, + }) + .await; + return true; + } + }; + let Some(committed_offset) = committed_offset else { + self.emit_runtime_event(ConsumerRuntimeEvent::HandlerFailed { + topic_id, + topic: key.topic.clone(), + partition: batch_partition, + error: "transactional handler returned success without committing an offset".to_owned(), + }) + .await; + return true; + }; + let processed_count = match processed_count_from_delivered_offset( + committed_offset, + &batch_events, + ) { + Ok(count) => count, + Err(err) => { + self.emit_runtime_event(ConsumerRuntimeEvent::HandlerFailed { + topic_id, + topic: key.topic.clone(), + partition: batch_partition, + error: err.to_string(), + }) + .await; + return true; + } + }; + let outcome = if processed_count == batch_events.len() { + BatchHandlerOutcome::Success + } else { + BatchHandlerOutcome::AdvanceThrough { + offset: committed_offset, + } + }; + self.emit_runtime_event(ConsumerRuntimeEvent::HandlerBatchCompleted { + topic_id, + topic: key.topic.clone(), + partition: batch_partition, + outcome: outcome.clone(), + }) + .await; + let drop_stream = self + .observe_handler_slow_signal( + state, + key.clone(), + started_at.elapsed(), + batch_offset, + active, + ) + .await; + let frontier = { + let mut guard = state.cursors.write().await; + let c = guard.entry(key.clone()).or_default(); + let frontier = + c.advance_through_delivered_prefix(&batch_events[..processed_count]); + c.committed = frontier; + frontier + }; + self.emit_runtime_event(ConsumerRuntimeEvent::OffsetCommitted { + topic_id, + topic: key.topic.clone(), + partition: batch_partition, + offset: frontier, + }) + .await; + self.emit_runtime_event(ConsumerRuntimeEvent::ProgressAdvanced { + subscription_id: active.sub_id, + progress: vec![partition_progress(&key, frontier)], + }) + .await; + return drop_stream; + } + Ok(crate::consumer::HandlerOutcome::Retry { reason }) => { + self.emit_runtime_event(ConsumerRuntimeEvent::HandlerBatchCompleted { + topic_id, + topic: key.topic.clone(), + partition: batch_partition, + outcome: BatchHandlerOutcome::Retry { + reason: reason.clone(), + }, + }) + .await; + if self + .observe_handler_slow_signal( + state, + key.clone(), + started_at.elapsed(), + batch_offset, + active, + ) + .await + { + return true; + } + trace!( + reason, + attempts, "transactional handler returned Retry; backing off" + ); + self.emit_runtime_event(ConsumerRuntimeEvent::RetryScheduled { + topic_id, + topic: key.topic.clone(), + partition: batch_partition, + attempt: attempts, + delay: backoff, + }) + .await; + tokio::time::sleep(backoff).await; + backoff = (backoff * 2).min(self.retry_max); + attempts = attempts.saturating_add(1); + } + Err(e) => { + self.emit_runtime_event(ConsumerRuntimeEvent::HandlerFailed { + topic_id, + topic: key.topic.clone(), + partition: batch_partition, + error: e.to_string(), + }) + .await; + if self + .observe_handler_slow_signal( + state, + key.clone(), + started_at.elapsed(), + batch_offset, + active, + ) + .await + { + return true; + } + warn!(error = %e, "transactional handler returned Err; treating as Retry"); + self.emit_runtime_event(ConsumerRuntimeEvent::RetryScheduled { + topic_id, + topic: key.topic.clone(), + partition: batch_partition, + attempt: attempts, + delay: backoff, + }) + .await; + tokio::time::sleep(backoff).await; + backoff = (backoff * 2).min(self.retry_max); + attempts = attempts.saturating_add(1); + } + } + } + } + + async fn observe_enqueue_slow_signal( + &self, + state: DispatchState<'_>, + key: TopicPartitionKey, + buffered_count: usize, + latest_observed_offset: i64, + active: ActiveSubscription<'_>, + ) -> bool { + let mut guard = state.slow_states.write().await; + let signal = guard.entry(key.clone()).or_default().observe_enqueue( + buffered_count, + latest_observed_offset, + self.buffer_high_watermark, + ); + drop(guard); + if let Some(signal) = signal { + warn!( + slot_idx = self.slot_idx, + topic = %key.topic, + partition = key.partition, + reason = ?signal.reason, + buffered_count = signal.buffered_count, + latest_observed_offset = ?signal.latest_observed_offset, + "transactional slow consumer predicate detected" + ); + self.emit_slow_consumer_events( + *active.group_id, + active.sub_id, + key, + signal, + active.affected, + ) + .await; + return true; + } + false + } + + async fn observe_handler_slow_signal( + &self, + state: DispatchState<'_>, + key: TopicPartitionKey, + elapsed: Duration, + last_delivered_offset: i64, + active: ActiveSubscription<'_>, + ) -> bool { + let mut guard = state.slow_states.write().await; + let signal = guard + .entry(key.clone()) + .or_default() + .observe_handler_completion( + elapsed, + self.handler_latency, + self.handler_strikes, + last_delivered_offset, + ); + drop(guard); + if let Some(signal) = signal { + warn!( + slot_idx = self.slot_idx, + topic = %key.topic, + partition = key.partition, + reason = ?signal.reason, + consecutive_slow_handlers = signal.consecutive_slow_handlers, + last_delivered_offset = ?signal.last_delivered_offset, + "transactional slow consumer predicate detected" + ); + self.emit_slow_consumer_events( + *active.group_id, + active.sub_id, + key, + signal, + active.affected, + ) + .await; + return true; + } + false + } + + async fn emit_slow_consumer_events( + &self, + group_id: ConsumerGroupId, + sub_id: SubscriptionId, + key: TopicPartitionKey, + signal: SlowConsumerSignal, + affected: &[AssignedPartition], + ) { + let trigger = slow_consumer_trigger(signal.reason); + let state = PartitionBufferStateSnapshot { + group_id, + subscription_id: sub_id, + topic_id: key.topic_id, + topic: key.topic.clone(), + partition: key.partition, + state: PartitionBufferState::SlowDetected, + trigger: Some(trigger), + buffered_count: signal.buffered_count, + capacity: self.partition_buffer_capacity, + latest_observed_offset: signal.latest_observed_offset, + last_delivered_offset: signal.last_delivered_offset, + consecutive_slow_handlers: signal.consecutive_slow_handlers, + }; + self.emit_runtime_event(ConsumerRuntimeEvent::PartitionBufferStateChanged { state }) + .await; + self.emit_runtime_event(ConsumerRuntimeEvent::SubscriptionConnectionDropped { + subscription_id: sub_id, + reason: ConnectionDropReason::SlowConsumer { + topic_id: key.topic_id, + topic: key.topic, + partition: key.partition, + trigger, + }, + affected: affected.to_vec(), + }) + .await; + } + + async fn emit_runtime_event(&self, event: ConsumerRuntimeEvent) { + emit_runtime_event_to(&self.listeners, self.listener_timeout, event).await; + } + + async fn resolve_and_seek( + &self, + ctx: &SecurityContext, + group_id: &ConsumerGroupId, + subscription_id: SubscriptionId, + slots: &[AssignedPartition], + ) -> Result<(), EventBrokerError> { + if slots.is_empty() { + return Ok(()); + } + let mut positions: Vec = Vec::with_capacity(slots.len()); + for slot in slots { + let topic = slot.topic.clone(); + let topic_id = TopicId::from_gts(&topic); + let value = self + .offset_manager + .load_position(group_id, &topic_id, slot.partition) + .await?; + self.emit_runtime_event(ConsumerRuntimeEvent::OffsetLoaded { + topic_id, + topic: topic.clone(), + partition: slot.partition, + position: value.clone(), + }) + .await; + positions.push(SeekPosition { + topic, + partition: slot.partition, + value, + }); + } + self.broker + .seek(ctx, subscription_id, &positions) + .await + .map(|_| ()) + } + + async fn observe_positions(&self, positions: &[PartitionPosition]) { + for p in positions { + trace!( + topic_ix = p.slot.topic_ix, + partition = p.slot.partition, + offset = p.offset, + last_examined = p.last_examined, + "transactional position observed without out-of-tx commit" + ); + } + } + + fn slots_for_unseeded(&self, unseeded: &[(String, u32)]) -> Vec { + unseeded + .iter() + .map(|(topic, partition)| AssignedPartition { + topic: topic.clone(), + partition: *partition, + }) + .collect() + } + + async fn ensure_group( + &self, + ctx: &SecurityContext, + ) -> Result { + match &self.group_ref { + ConsumerGroupRef::Id(id) => Ok(*id), + ConsumerGroupRef::Gts(gts) => Err(EventBrokerError::InvalidConsumerOptions { + detail: format!( + "consumer group GTS reference '{gts}' must be resolved before startup" + ), + instance: String::new(), + }), + ConsumerGroupRef::AutoAnonymous { alias } => { + let group = self + .broker + .create_consumer_group( + ctx, + crate::models::CreateConsumerGroupRequest { + client_agent: alias.clone(), + description: None, + }, + ) + .await?; + Ok(group.id) + } + } + } + + async fn join_subscription( + &self, + ctx: &SecurityContext, + group_id: &ConsumerGroupId, + ) -> Result { + self.emit_runtime_event(ConsumerRuntimeEvent::SubscriptionJoining { + group_id: *group_id, + }) + .await; + let interests = build_join_interests( + &self.subscription_interests, + &self.topics, + self.tenant_id, + self.tenant_depth, + self.barrier_mode, + &self.event_type_patterns, + self.filter.clone(), + )?; + + let assignment = self + .broker + .join( + ctx, + JoinRequest { + group: *group_id, + client_agent: self.client_agent.clone(), + interests, + session_timeout: self.session_timeout, + }, + ) + .await?; + self.emit_runtime_event(ConsumerRuntimeEvent::SubscriptionStarted { + group_id: *group_id, + subscription_id: assignment.subscription_id, + assigned: assignment.assigned.clone(), + }) + .await; + self.emit_runtime_event(ConsumerRuntimeEvent::AssignmentChanged { + subscription_id: assignment.subscription_id, + assigned: assignment.assigned.clone(), + }) + .await; + Ok(assignment) + } + + async fn rejoin( + &self, + ctx: &SecurityContext, + group_id: &ConsumerGroupId, + previous_subscription_id: SubscriptionId, + consecutive_failures: &mut u32, + ) -> Result { + *consecutive_failures += 1; + if *consecutive_failures > self.max_rejoin_attempts { + return Err(EventBrokerError::SubscriptionRecoveryExhausted { + attempts: *consecutive_failures, + detail: "max transactional re-JOIN attempts exceeded".into(), + instance: String::new(), + }); + } + self.emit_runtime_event(ConsumerRuntimeEvent::SubscriptionRejoining { + group_id: *group_id, + previous_subscription_id, + }) + .await; + tokio::time::sleep(Duration::from_millis(250) * (*consecutive_failures)).await; + let assignment = self.join_subscription(ctx, group_id).await?; + { + let mut guard = self.subscription_id.lock().await; + *guard = Some(assignment.subscription_id); + } + Ok(assignment) + } +} + +impl SlotDispatcher +where + H: ConsumerHandler + 'static, + OM: CommitOffset + 'static, +{ + pub async fn run( + &self, + ctx: Arc, + cancel: tokio_util::sync::CancellationToken, + ) -> Result<(), EventBrokerError> { + trace!(slot_idx = self.slot_idx, "slot dispatcher starting"); + let group_id = self.ensure_group(&ctx).await?; + let mut assignment = self.join_subscription(&ctx, &group_id).await?; + { + let mut guard = self.subscription_id.lock().await; + *guard = Some(assignment.subscription_id); + } + + // Pre-stream SEEK: resolve a starting position per assigned partition + // via OffsetStore::load_position(...) and seed them on the broker before + // opening the stream. Without this, the broker returns `409 + // PositionsNotSet` on stream-open (defensive backstop). + self.resolve_and_seek( + &ctx, + &group_id, + assignment.subscription_id, + &assignment.assigned, + ) + .await?; + + let cursors: PartitionCursors = Arc::new(RwLock::new(HashMap::new())); + let buffers: PartitionBuffers = Arc::new(RwLock::new(HashMap::new())); + let slow_states: PartitionSlowStates = Arc::new(RwLock::new(HashMap::new())); + + // Auto-commit timer task. + let _commit_task = match self.commit_mode { + ConsumerCommitMode::Auto { interval } => { + let seek_cursors = cursors.clone(); + let seek_broker = self.broker.clone(); + let seek_om = self.offset_manager.clone(); + let seek_ctx = ctx.clone(); + let seek_group = group_id; + let seek_sub = assignment.subscription_id; + let seek_cancel = cancel.clone(); + let seek_listeners = self.listeners.clone(); + let seek_listener_timeout = self.listener_timeout; + Some(tokio::spawn(async move { + let mut interval = tokio::time::interval(interval); + loop { + tokio::select! { + _ = seek_cancel.cancelled() => break, + _ = interval.tick() => { + let pending: Vec<_> = { + let guard = seek_cursors.read().await; + guard + .iter() + .filter_map(|(key, cursor)| { + let latest_offset = cursor.latest_offset(); + (latest_offset > cursor.committed) + .then(|| (key.clone(), latest_offset)) + }) + .collect() + }; + + for (key, latest_offset) in pending { + if seek_om.commit( + &seek_group, + &key.topic_id, + key.partition, + latest_offset, + ).await.is_ok() { + { + let mut guard = seek_cursors.write().await; + if let Some(cursor) = guard.get_mut(&key) + && cursor.latest_offset() >= latest_offset + { + cursor.committed = latest_offset; + } + } + emit_runtime_event_to( + &seek_listeners, + seek_listener_timeout, + ConsumerRuntimeEvent::OffsetCommitted { + topic_id: key.topic_id, + topic: key.topic.clone(), + partition: key.partition, + offset: latest_offset, + }, + ).await; + emit_runtime_event_to( + &seek_listeners, + seek_listener_timeout, + ConsumerRuntimeEvent::ProgressAdvanced { + subscription_id: seek_sub, + progress: vec![partition_progress( + &key, + latest_offset, + )], + }, + ).await; + } + let _ = seek_broker.seek( + &seek_ctx, + seek_sub, + &[SeekPosition { + topic: key.topic.clone(), + partition: key.partition, + value: ResolvedPosition::Exact(latest_offset), + }], + ).await; + } + } + } + } + })) + } + ConsumerCommitMode::Manual => None, + }; + + let mut consecutive_failures = 0u32; + 'outer: loop { + if cancel.is_cancelled() { + break; + } + + let sub_id = assignment.subscription_id; + let mut stream = match self.broker.stream(&ctx, sub_id).await { + Ok(s) => s, + Err(EventBrokerError::SubscriptionRecoveryExhausted { .. }) => { + return Err(EventBrokerError::SubscriptionRecoveryExhausted { + attempts: consecutive_failures, + detail: "stream open: recovery exhausted".into(), + instance: String::new(), + }); + } + Err(EventBrokerError::PositionsNotSet { unseeded, .. }) => { + // Defensive recovery: re-resolve via position() and SEEK the + // unseeded partitions, then retry. Shares the + // SubscriptionRecoveryExhausted budget. + consecutive_failures += 1; + if consecutive_failures > self.max_rejoin_attempts { + return Err(EventBrokerError::SubscriptionRecoveryExhausted { + attempts: consecutive_failures, + detail: "stream open: PositionsNotSet recovery exhausted".into(), + instance: String::new(), + }); + } + let slots = self.slots_for_unseeded(&unseeded); + self.resolve_and_seek(&ctx, &group_id, sub_id, &slots) + .await?; + continue; + } + Err(e) => { + warn!( + slot_idx = self.slot_idx, + error = %e, + "stream open failed; re-joining" + ); + assignment = self + .rejoin(&ctx, &group_id, sub_id, &mut consecutive_failures) + .await?; + self.resolve_and_seek( + &ctx, + &group_id, + assignment.subscription_id, + &assignment.assigned, + ) + .await?; + continue; + } + }; + + let mut consecutive_heartbeats: usize = 0; + consecutive_failures = 0; + let mut drain_before_rejoin = false; + + while let Some(frame_result) = stream.next().await { + if cancel.is_cancelled() { + break 'outer; + } + match frame_result { + Ok(WireFrame::Event(event)) => { + consecutive_heartbeats = 0; + if self + .dispatch_event( + &cancel, + event, + DispatchState { + cursors: &cursors, + buffers: &buffers, + slow_states: &slow_states, + }, + ActiveSubscription { + group_id: &group_id, + sub_id, + affected: &assignment.assigned, + }, + ) + .await + { + drain_before_rejoin = true; + break; + } + } + Ok(WireFrame::Heartbeat { .. }) => { + consecutive_heartbeats += 1; + if consecutive_heartbeats >= self.heartbeat_drop_threshold { + trace!( + slot_idx = self.slot_idx, + threshold = self.heartbeat_drop_threshold, + "drop-on-Nth-heartbeat triggered; voluntary disconnect + re-JOIN" + ); + // Drop the stream by breaking out of the inner loop. + // Outer loop will rejoin. + break; + } + } + Ok(WireFrame::Control { + code, + positions, + reason, + }) => { + // Cursor carrier: feed positions into the offset store so a + // reconnect re-SEEKs from last_examined, skipping + // server-side-filtered events (R57). + self.commit_positions(&ctx, &group_id, sub_id, &positions) + .await; + if code == ControlCode::Terminal { + // Gain / lose-all: the subscription is terminating. Final + // positions are committed above; re-JOIN (outer loop). + trace!( + slot_idx = self.slot_idx, + ?reason, + "terminal control frame; re-JOIN" + ); + break; + } + // Progress: sparse mid-stream update; keep streaming. + } + Ok(WireFrame::Topology { + topology_version: _, + assigned, + }) => { + // Non-terminal: a partition loss or a `topology_version`-only + // change. Commit the snapshot's positions and update the + // assignment (lost partitions drop off); keep streaming. + // Gains never arrive as a topology frame - they terminate via + // a Terminal control frame and a re-JOIN. + self.commit_positions(&ctx, &group_id, sub_id, &assigned) + .await; + } + Err(EventBrokerError::Transport(_)) => { + break; // outer loop rejoins + } + Err(e) => { + warn!( + slot_idx = self.slot_idx, + error = %e, + "stream frame error; ending stream" + ); + break; + } + } + } + + // Stream ended (subscription gone, connection closed, or drop-on-Nth-heartbeat). + // Re-JOIN unless we're cancelled. + if cancel.is_cancelled() { + break; + } + if drain_before_rejoin { + self.drain_subscription_buffers( + &cancel, + DispatchState { + cursors: &cursors, + buffers: &buffers, + slow_states: &slow_states, + }, + ActiveSubscription { + group_id: &group_id, + sub_id, + affected: &assignment.assigned, + }, + ) + .await; + let _ = self.broker.leave(&ctx, sub_id).await; + } + self.emit_runtime_event(ConsumerRuntimeEvent::SubscriptionTerminated { + subscription_id: sub_id, + reason: ConnectionDropReason::StreamTerminated, + }) + .await; + assignment = self + .rejoin(&ctx, &group_id, sub_id, &mut consecutive_failures) + .await?; + // Fresh subscription_id → no SEEK history on the broker → must + // re-seed positions before opening the next stream. + self.resolve_and_seek( + &ctx, + &group_id, + assignment.subscription_id, + &assignment.assigned, + ) + .await?; + } + + // Graceful shutdown: leave subscription. + let _ = self.broker.leave(&ctx, assignment.subscription_id).await; + Ok(()) + } + + async fn dispatch_event( + &self, + cancel: &tokio_util::sync::CancellationToken, + wire: WireEvent, + state: DispatchState<'_>, + active: ActiveSubscription<'_>, + ) -> bool { + let topic_id = TopicId::from_gts(&wire.topic); + let key = TopicPartitionKey::new(wire.topic.clone(), topic_id, wire.partition); + + let raw = RawEvent { + id: wire.id, + type_id: wire.type_id.clone(), + topic: wire.topic.clone(), + tenant_id: wire.tenant_id, + subject: wire.subject, + subject_type: wire.subject_type, + partition_key: wire.partition_key, + partition: wire.partition, + sequence: wire.sequence, + offset: wire.offset, + occurred_at: wire.occurred_at, + sequence_time: wire.sequence_time, + trace_parent: wire.trace_parent, + data: wire.data, + }; + let enqueued = match enqueue_partition_batch( + state.buffers, + key.clone(), + raw, + self.partition_buffer_capacity, + ) + .await + { + Ok(events) => events, + Err(e) => { + warn!(error = %e, "partition buffer rejected event"); + return false; + } + }; + if self + .observe_enqueue_slow_signal( + state, + key.clone(), + enqueued.buffered_count, + wire.offset, + active, + ) + .await + { + return true; + } + let batch_events = drain_partition_batch(state.buffers, &key, self.batch_max_events).await; + self.process_batch(cancel, state, key, batch_events, active) + .await + } + + async fn drain_subscription_buffers( + &self, + cancel: &tokio_util::sync::CancellationToken, + state: DispatchState<'_>, + active: ActiveSubscription<'_>, + ) { + trace!( + slot_idx = self.slot_idx, + low_watermark = self.buffer_low_watermark, + "draining buffered events before re-JOIN" + ); + loop { + if cancel.is_cancelled() { + break; + } + let Some((key, batch_events)) = + next_buffered_partition_batch(state.buffers, self.batch_max_events).await + else { + break; + }; + let _ = self + .process_batch(cancel, state, key, batch_events, active) + .await; + } + } + + async fn process_batch( + &self, + cancel: &tokio_util::sync::CancellationToken, + state: DispatchState<'_>, + key: TopicPartitionKey, + batch_events: Vec, + active: ActiveSubscription<'_>, + ) -> bool { + if batch_events.is_empty() { + return false; + } + let batch_partition = batch_events[0].partition; + let batch_offset = batch_events + .last() + .map(|event| event.offset) + .unwrap_or_else(|| batch_events[0].offset); + let topic_id = key.topic_id; + + let mut attempts: u16 = 1; + let mut backoff = self.retry_base; + + loop { + if cancel.is_cancelled() { + return false; + } + self.emit_runtime_event(ConsumerRuntimeEvent::HandlerBatchStarted { + topic_id, + topic: key.topic.clone(), + partition: batch_partition, + len: batch_events.len(), + }) + .await; + let batch = EventBatch::new(&batch_events); + let started_at = Instant::now(); + match self.handler.handle_batch(&batch, attempts).await { + Ok( + outcome @ (BatchHandlerOutcome::Success + | BatchHandlerOutcome::AdvanceThrough { .. }), + ) => { + let processed_count = + match processed_count_from_outcome(&outcome, &batch_events) { + Ok(Some(count)) => count, + Ok(None) => unreachable!("retry outcomes are handled separately"), + Err(err) => { + self.emit_runtime_event(ConsumerRuntimeEvent::HandlerFailed { + topic_id, + topic: key.topic.clone(), + partition: batch_partition, + error: err.to_string(), + }) + .await; + return true; + } + }; + self.emit_runtime_event(ConsumerRuntimeEvent::HandlerBatchCompleted { + topic_id, + topic: key.topic.clone(), + partition: batch_partition, + outcome: outcome.clone(), + }) + .await; + let drop_stream = self + .observe_handler_slow_signal( + state, + key.clone(), + started_at.elapsed(), + batch_offset, + active, + ) + .await; + // Advance in-memory cursor. + let (frontier, already_committed) = { + let mut guard = state.cursors.write().await; + let c = guard.entry(key.clone()).or_default(); + ( + c.advance_through_delivered_prefix(&batch_events[..processed_count]), + false, + ) + }; + self.emit_runtime_event(ConsumerRuntimeEvent::ProgressAdvanced { + subscription_id: active.sub_id, + progress: vec![partition_progress(&key, frontier)], + }) + .await; + if already_committed { + self.emit_runtime_event(ConsumerRuntimeEvent::OffsetCommitted { + topic_id, + topic: key.topic.clone(), + partition: batch_partition, + offset: frontier, + }) + .await; + } + return drop_stream; + } + Ok(BatchHandlerOutcome::Retry { reason }) => { + self.emit_runtime_event(ConsumerRuntimeEvent::HandlerBatchCompleted { + topic_id, + topic: key.topic.clone(), + partition: batch_partition, + outcome: BatchHandlerOutcome::Retry { + reason: reason.clone(), + }, + }) + .await; + if self + .observe_handler_slow_signal( + state, + key.clone(), + started_at.elapsed(), + batch_offset, + active, + ) + .await + { + return true; + } + if cancel.is_cancelled() { + return false; + } + trace!(reason, attempts, "handler returned Retry; backing off"); + self.emit_runtime_event(ConsumerRuntimeEvent::RetryScheduled { + topic_id, + topic: key.topic.clone(), + partition: batch_partition, + attempt: attempts, + delay: backoff, + }) + .await; + tokio::select! { + _ = cancel.cancelled() => return false, + _ = tokio::time::sleep(backoff) => {} + } + backoff = (backoff * 2).min(self.retry_max); + attempts = attempts.saturating_add(1); + } + Err(e) => { + self.emit_runtime_event(ConsumerRuntimeEvent::HandlerFailed { + topic_id, + topic: key.topic.clone(), + partition: batch_partition, + error: e.to_string(), + }) + .await; + if self + .observe_handler_slow_signal( + state, + key.clone(), + started_at.elapsed(), + batch_offset, + active, + ) + .await + { + return true; + } + if cancel.is_cancelled() { + return false; + } + warn!(error = %e, "handler returned Err; treating as Retry"); + self.emit_runtime_event(ConsumerRuntimeEvent::RetryScheduled { + topic_id, + topic: key.topic.clone(), + partition: batch_partition, + attempt: attempts, + delay: backoff, + }) + .await; + tokio::select! { + _ = cancel.cancelled() => return false, + _ = tokio::time::sleep(backoff) => {} + } + backoff = (backoff * 2).min(self.retry_max); + attempts = attempts.saturating_add(1); + } + } + } + } + + async fn observe_enqueue_slow_signal( + &self, + state: DispatchState<'_>, + key: TopicPartitionKey, + buffered_count: usize, + latest_observed_offset: i64, + active: ActiveSubscription<'_>, + ) -> bool { + let mut guard = state.slow_states.write().await; + let signal = guard.entry(key.clone()).or_default().observe_enqueue( + buffered_count, + latest_observed_offset, + self.buffer_high_watermark, + ); + drop(guard); + if let Some(signal) = signal { + warn!( + slot_idx = self.slot_idx, + topic = %key.topic, + partition = key.partition, + reason = ?signal.reason, + buffered_count = signal.buffered_count, + latest_observed_offset = ?signal.latest_observed_offset, + "slow consumer predicate detected" + ); + self.emit_slow_consumer_events( + *active.group_id, + active.sub_id, + key, + signal, + active.affected, + ) + .await; + return true; + } + false + } + + async fn observe_handler_slow_signal( + &self, + state: DispatchState<'_>, + key: TopicPartitionKey, + elapsed: Duration, + last_delivered_offset: i64, + active: ActiveSubscription<'_>, + ) -> bool { + let mut guard = state.slow_states.write().await; + let signal = guard + .entry(key.clone()) + .or_default() + .observe_handler_completion( + elapsed, + self.handler_latency, + self.handler_strikes, + last_delivered_offset, + ); + drop(guard); + if let Some(signal) = signal { + warn!( + slot_idx = self.slot_idx, + topic = %key.topic, + partition = key.partition, + reason = ?signal.reason, + consecutive_slow_handlers = signal.consecutive_slow_handlers, + last_delivered_offset = ?signal.last_delivered_offset, + "slow consumer predicate detected" + ); + self.emit_slow_consumer_events( + *active.group_id, + active.sub_id, + key, + signal, + active.affected, + ) + .await; + return true; + } + false + } + + async fn emit_slow_consumer_events( + &self, + group_id: ConsumerGroupId, + sub_id: SubscriptionId, + key: TopicPartitionKey, + signal: SlowConsumerSignal, + affected: &[AssignedPartition], + ) { + let trigger = slow_consumer_trigger(signal.reason); + let state = PartitionBufferStateSnapshot { + group_id, + subscription_id: sub_id, + topic_id: key.topic_id, + topic: key.topic.clone(), + partition: key.partition, + state: PartitionBufferState::SlowDetected, + trigger: Some(trigger), + buffered_count: signal.buffered_count, + capacity: self.partition_buffer_capacity, + latest_observed_offset: signal.latest_observed_offset, + last_delivered_offset: signal.last_delivered_offset, + consecutive_slow_handlers: signal.consecutive_slow_handlers, + }; + self.emit_runtime_event(ConsumerRuntimeEvent::PartitionBufferStateChanged { state }) + .await; + self.emit_runtime_event(ConsumerRuntimeEvent::SubscriptionConnectionDropped { + subscription_id: sub_id, + reason: ConnectionDropReason::SlowConsumer { + topic_id: key.topic_id, + topic: key.topic, + partition: key.partition, + trigger, + }, + affected: affected.to_vec(), + }) + .await; + } + + async fn emit_runtime_event(&self, event: ConsumerRuntimeEvent) { + emit_runtime_event_to(&self.listeners, self.listener_timeout, event).await; + } + + /// Resolve a starting position for each slot via `CommitOffset::position` + /// and SEEK the broker. Used after JOIN (initial seed), after re-JOIN, on + /// Topology-frame for newly-assigned partitions, and on `409 + /// PositionsNotSet` recovery. + async fn resolve_and_seek( + &self, + ctx: &SecurityContext, + group_id: &ConsumerGroupId, + subscription_id: SubscriptionId, + slots: &[AssignedPartition], + ) -> Result<(), EventBrokerError> { + if slots.is_empty() { + return Ok(()); + } + let mut positions: Vec = Vec::with_capacity(slots.len()); + for slot in slots { + let topic = slot.topic.clone(); + let topic_id = TopicId::from_gts(&topic); + let value = self + .offset_manager + .load_position(group_id, &topic_id, slot.partition) + .await?; + self.emit_runtime_event(ConsumerRuntimeEvent::OffsetLoaded { + topic_id, + topic: topic.clone(), + partition: slot.partition, + position: value.clone(), + }) + .await; + positions.push(SeekPosition { + topic, + partition: slot.partition, + value, + }); + } + self.broker + .seek(ctx, subscription_id, &positions) + .await + .map(|_| ()) + } + + /// Feed control / topology-frame positions into the consumer's own offset + /// store. Persists `last_examined` (the scan frontier) so a later reconnect + /// re-SEEKs past server-side-filtered events (R57). Best-effort: a failure to + /// persist is logged, not fatal. + async fn commit_positions( + &self, + _ctx: &SecurityContext, + group_id: &ConsumerGroupId, + subscription_id: SubscriptionId, + positions: &[PartitionPosition], + ) { + for p in positions { + let topic = match self.topic_for_slot(&p.slot) { + Ok(t) => t, + Err(_) => continue, + }; + let topic_id = TopicId::from_gts(&topic); + if let Err(e) = self + .offset_manager + .commit(group_id, &topic_id, p.slot.partition, p.last_examined) + .await + { + warn!(error = %e, "failed to commit control-frame position to offset store"); + } else { + self.emit_runtime_event(ConsumerRuntimeEvent::OffsetCommitted { + topic_id, + topic: topic.clone(), + partition: p.slot.partition, + offset: p.last_examined, + }) + .await; + self.emit_runtime_event(ConsumerRuntimeEvent::ProgressAdvanced { + subscription_id, + progress: vec![PartitionProgress { + topic_id, + topic, + partition: p.slot.partition, + offset: p.last_examined, + }], + }) + .await; + } + } + } + + /// Look up a partition's topic name from the slot's `topic_ix`, indexed + /// into the dispatcher's declared topics. The broker assigns indices in + /// the order the SDK declared interests. + fn topic_for_slot(&self, slot: &PartitionSlot) -> Result { + self.topics + .get(slot.topic_ix as usize) + .cloned() + .ok_or_else(|| { + EventBrokerError::Internal(format!( + "topology returned topic_ix={} but only {} topics were declared", + slot.topic_ix, + self.topics.len() + )) + }) + } + + /// Translate `(topic, partition)` pairs reported by `409 PositionsNotSet` + /// into the public assignment shape used by `EventBroker::join`. + fn slots_for_unseeded(&self, unseeded: &[(String, u32)]) -> Vec { + unseeded + .iter() + .map(|(topic, partition)| AssignedPartition { + topic: topic.clone(), + partition: *partition, + }) + .collect() + } + + async fn ensure_group( + &self, + ctx: &SecurityContext, + ) -> Result { + match &self.group_ref { + ConsumerGroupRef::Id(id) => Ok(*id), + ConsumerGroupRef::Gts(gts) => Err(EventBrokerError::InvalidConsumerOptions { + detail: format!( + "consumer group GTS reference '{gts}' must be resolved before startup" + ), + instance: String::new(), + }), + ConsumerGroupRef::AutoAnonymous { alias } => { + let group = self + .broker + .create_consumer_group( + ctx, + crate::models::CreateConsumerGroupRequest { + client_agent: alias.clone(), + description: None, + }, + ) + .await?; + Ok(group.id) + } + } + } + + async fn join_subscription( + &self, + ctx: &SecurityContext, + group_id: &ConsumerGroupId, + ) -> Result { + self.emit_runtime_event(ConsumerRuntimeEvent::SubscriptionJoining { + group_id: *group_id, + }) + .await; + let interests = build_join_interests( + &self.subscription_interests, + &self.topics, + self.tenant_id, + self.tenant_depth, + self.barrier_mode, + &self.event_type_patterns, + self.filter.clone(), + )?; + + let assignment = self + .broker + .join( + ctx, + JoinRequest { + group: *group_id, + client_agent: self.client_agent.clone(), + interests, + session_timeout: self.session_timeout, + }, + ) + .await?; + self.emit_runtime_event(ConsumerRuntimeEvent::SubscriptionStarted { + group_id: *group_id, + subscription_id: assignment.subscription_id, + assigned: assignment.assigned.clone(), + }) + .await; + self.emit_runtime_event(ConsumerRuntimeEvent::AssignmentChanged { + subscription_id: assignment.subscription_id, + assigned: assignment.assigned.clone(), + }) + .await; + Ok(assignment) + } + + async fn rejoin( + &self, + ctx: &SecurityContext, + group_id: &ConsumerGroupId, + previous_subscription_id: SubscriptionId, + consecutive_failures: &mut u32, + ) -> Result { + *consecutive_failures += 1; + if *consecutive_failures > self.max_rejoin_attempts { + return Err(EventBrokerError::SubscriptionRecoveryExhausted { + attempts: *consecutive_failures, + detail: "max re-JOIN attempts exceeded".into(), + instance: String::new(), + }); + } + self.emit_runtime_event(ConsumerRuntimeEvent::SubscriptionRejoining { + group_id: *group_id, + previous_subscription_id, + }) + .await; + tokio::time::sleep(Duration::from_millis(250) * (*consecutive_failures)).await; + let assignment = self.join_subscription(ctx, group_id).await?; + { + let mut guard = self.subscription_id.lock().await; + *guard = Some(assignment.subscription_id); + } + Ok(assignment) + } +} diff --git a/gears/system/event-broker/event-broker-sdk/src/consumer/dispatcher_tests.rs b/gears/system/event-broker/event-broker-sdk/src/consumer/dispatcher_tests.rs new file mode 100644 index 000000000..10c0f415d --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/src/consumer/dispatcher_tests.rs @@ -0,0 +1,2022 @@ +use std::collections::{BTreeSet, HashMap}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; +use toolkit_gts::gts_id; + +use chrono::Utc; +use tokio::sync::RwLock; +use uuid::Uuid; + +use super::builder::{ConsumerRoute, ConsumerRouteHandlerKind}; +use super::dispatcher::{ + PartitionSlowState, SlowConsumerReason, TopicPartitionKey, enqueue_partition_batch, +}; +use super::runtime::RoutedBatchHandler; +use super::{ + BatchHandlerOutcome, CommitOffset, ConnectionDropReason, ConsumerBuffering, ConsumerBuilder, + ConsumerCommitMode, ConsumerGroupRef, ConsumerHandler, ConsumerListenerSettings, + ConsumerRuntimeEvent, ConsumerRuntimeListener, ConsumerSlowDetection, EventBatch, EventTypeRef, + Fallback, HandlerOutcome, InMemoryOffsetManager, OffsetManagerError, OffsetStore, + PartitionBufferState, RawEvent, ResolvedPosition, SingleEventHandlerAdapter, + SlowConsumerTrigger, TopicRef, +}; +use crate::error::ConsumerError; +use crate::ids::{ConsumerGroupId, TopicId}; + +type SharedOffsets = Arc>>; +type SharedNamedOffsets = Arc>>; +type PartitionCall = (String, u32, i64); +type SharedPartitionCalls = Arc>>; +type SharedAttempts = Arc>>; +type SharedTimeline = Arc>>; +type CommitRecord = (ConsumerGroupId, TopicId, u32, i64); +type SharedCommits = Arc>>; +type SharedScopes = Arc>>; +type SharedViolations = Arc>>; +type SharedRuntimeEvents = Arc>>; +type TestPartitionBuffers = + Arc>>; + +fn raw_event(topic: &str, type_id: &str, offset: i64) -> RawEvent { + raw_event_on(topic, type_id, 3, offset) +} + +fn raw_event_on(topic: &str, type_id: &str, partition: u32, offset: i64) -> RawEvent { + RawEvent { + id: Uuid::new_v4(), + type_id: type_id.to_owned(), + topic: topic.to_owned(), + tenant_id: Uuid::nil(), + subject: format!("event-{offset}"), + subject_type: "test".to_owned(), + partition_key: None, + partition, + sequence: offset, + offset, + occurred_at: Utc::now(), + sequence_time: Utc::now(), + trace_parent: None, + data: serde_json::json!({ "offset": offset }), + } +} + +fn partition_key_for_partition(target: u32, partitions: u32) -> String { + assert_eq!(partitions, 2, "only the two-partition fixture is supported"); + match target { + 0 => "partition-key-0-1", + 1 => "partition-key-1-0", + _ => panic!("two-partition fixture cannot target partition {target}"), + } + .to_owned() +} + +struct RecordingSingleHandler { + calls: SharedOffsets, + outcome: HandlerOutcome, +} + +#[async_trait::async_trait] +impl super::SingleEventHandler for RecordingSingleHandler { + async fn handle( + &self, + event: RawEvent, + _attempts: u16, + ) -> Result { + self.calls.lock().unwrap().push(event.offset); + Ok(self.outcome.clone()) + } +} + +struct RecordingBatchHandler { + name: &'static str, + calls: SharedNamedOffsets, + outcome: BatchHandlerOutcome, +} + +#[async_trait::async_trait] +impl ConsumerHandler for RecordingBatchHandler { + async fn handle_batch( + &self, + batch: &EventBatch<'_>, + _attempts: u16, + ) -> Result { + if let Some(event) = batch.next_event() { + self.calls.lock().unwrap().push((self.name, event.offset)); + } + Ok(self.outcome.clone()) + } +} + +struct AckAllBatchHandler { + calls: SharedPartitionCalls, +} + +#[async_trait::async_trait] +impl ConsumerHandler for AckAllBatchHandler { + async fn handle_batch( + &self, + batch: &EventBatch<'_>, + _attempts: u16, + ) -> Result { + let chunk = batch.next_chunk(batch.len()); + self.calls.lock().unwrap().extend( + chunk + .iter() + .map(|event| (event.topic.clone(), event.partition, event.offset)), + ); + Ok(chunk + .last() + .map(|event| BatchHandlerOutcome::AdvanceThrough { + offset: event.offset, + }) + .unwrap_or(BatchHandlerOutcome::Success)) + } +} + +struct SleepingBatchHandler { + calls: SharedPartitionCalls, + delay: Duration, +} + +#[async_trait::async_trait] +impl ConsumerHandler for SleepingBatchHandler { + async fn handle_batch( + &self, + batch: &EventBatch<'_>, + _attempts: u16, + ) -> Result { + tokio::time::sleep(self.delay).await; + let chunk = batch.next_chunk(batch.len()); + self.calls.lock().unwrap().extend( + chunk + .iter() + .map(|event| (event.topic.clone(), event.partition, event.offset)), + ); + Ok(chunk + .last() + .map(|event| BatchHandlerOutcome::AdvanceThrough { + offset: event.offset, + }) + .unwrap_or(BatchHandlerOutcome::Success)) + } +} + +struct FailingThenCommitBatchHandler { + failures_remaining: Arc>, + calls: SharedAttempts, +} + +#[async_trait::async_trait] +impl ConsumerHandler for FailingThenCommitBatchHandler { + async fn handle_batch( + &self, + _batch: &EventBatch<'_>, + attempts: u16, + ) -> Result { + { + let mut guard = self.failures_remaining.lock().unwrap(); + if *guard > 0 { + *guard -= 1; + return Err(ConsumerError::Internal( + "intentional representative handler failure".to_owned(), + )); + } + } + + self.calls.lock().unwrap().push(attempts); + Ok(BatchHandlerOutcome::Success) + } +} + +struct SequencedOffsetManager { + timeline: SharedTimeline, +} + +#[async_trait::async_trait] +impl OffsetStore for SequencedOffsetManager { + async fn load_position( + &self, + _group: &ConsumerGroupId, + _topic: &TopicId, + _partition: u32, + ) -> Result { + self.timeline.lock().unwrap().push("load"); + Ok(ResolvedPosition::Earliest) + } +} + +#[async_trait::async_trait] +impl CommitOffset for SequencedOffsetManager { + async fn commit( + &self, + _group: &ConsumerGroupId, + _topic: &TopicId, + _partition: u32, + _offset: i64, + ) -> Result<(), OffsetManagerError> { + Ok(()) + } +} + +#[derive(Clone, Default)] +struct RecordingCommitOffsetManager { + commits: SharedCommits, +} + +#[async_trait::async_trait] +impl OffsetStore for RecordingCommitOffsetManager { + async fn load_position( + &self, + _group: &ConsumerGroupId, + _topic: &TopicId, + _partition: u32, + ) -> Result { + Ok(ResolvedPosition::Earliest) + } +} + +#[async_trait::async_trait] +impl CommitOffset for RecordingCommitOffsetManager { + async fn commit( + &self, + group: &ConsumerGroupId, + topic: &TopicId, + partition: u32, + offset: i64, + ) -> Result<(), OffsetManagerError> { + self.commits + .lock() + .unwrap() + .push((*group, *topic, partition, offset)); + Ok(()) + } +} + +struct SequencedBatchHandler { + timeline: SharedTimeline, +} + +#[async_trait::async_trait] +impl ConsumerHandler for SequencedBatchHandler { + async fn handle_batch( + &self, + _batch: &EventBatch<'_>, + _attempts: u16, + ) -> Result { + self.timeline.lock().unwrap().push("handle"); + Ok(BatchHandlerOutcome::Success) + } +} + +#[derive(Default)] +struct BatchScopeRecorder { + scopes: SharedScopes, + violations: SharedViolations, +} + +#[derive(Clone, Default)] +struct RecordingRuntimeListener { + events: SharedRuntimeEvents, +} + +#[async_trait::async_trait] +impl ConsumerRuntimeListener for RecordingRuntimeListener { + async fn on_consumer_event(&self, event: &ConsumerRuntimeEvent) -> Result<(), ConsumerError> { + self.events.lock().unwrap().push(event.clone()); + Ok(()) + } +} + +struct FailingRuntimeListener; + +#[async_trait::async_trait] +impl ConsumerRuntimeListener for FailingRuntimeListener { + async fn on_consumer_event(&self, _event: &ConsumerRuntimeEvent) -> Result<(), ConsumerError> { + Err(ConsumerError::Internal( + "intentional listener failure".to_owned(), + )) + } +} + +struct SlowRuntimeListener { + delay: Duration, +} + +#[async_trait::async_trait] +impl ConsumerRuntimeListener for SlowRuntimeListener { + async fn on_consumer_event(&self, _event: &ConsumerRuntimeEvent) -> Result<(), ConsumerError> { + tokio::time::sleep(self.delay).await; + Ok(()) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +enum RuntimeEventKind { + SubscriptionJoining, + SubscriptionStarted, + SubscriptionRejoining, + SubscriptionTerminated, + SubscriptionConnectionDropped, + AssignmentChanged, + ProgressAdvanced, + PartitionBufferStateChanged, + HandlerBatchStarted, + HandlerBatchCompleted, + HandlerFailed, + OffsetLoaded, + OffsetCommitted, + RetryScheduled, +} + +fn runtime_event_kind(event: &ConsumerRuntimeEvent) -> RuntimeEventKind { + match event { + ConsumerRuntimeEvent::SubscriptionJoining { .. } => RuntimeEventKind::SubscriptionJoining, + ConsumerRuntimeEvent::SubscriptionStarted { .. } => RuntimeEventKind::SubscriptionStarted, + ConsumerRuntimeEvent::SubscriptionRejoining { .. } => { + RuntimeEventKind::SubscriptionRejoining + } + ConsumerRuntimeEvent::SubscriptionTerminated { .. } => { + RuntimeEventKind::SubscriptionTerminated + } + ConsumerRuntimeEvent::SubscriptionConnectionDropped { .. } => { + RuntimeEventKind::SubscriptionConnectionDropped + } + ConsumerRuntimeEvent::AssignmentChanged { .. } => RuntimeEventKind::AssignmentChanged, + ConsumerRuntimeEvent::ProgressAdvanced { .. } => RuntimeEventKind::ProgressAdvanced, + ConsumerRuntimeEvent::PartitionBufferStateChanged { .. } => { + RuntimeEventKind::PartitionBufferStateChanged + } + ConsumerRuntimeEvent::HandlerBatchStarted { .. } => RuntimeEventKind::HandlerBatchStarted, + ConsumerRuntimeEvent::HandlerBatchCompleted { .. } => { + RuntimeEventKind::HandlerBatchCompleted + } + ConsumerRuntimeEvent::HandlerFailed { .. } => RuntimeEventKind::HandlerFailed, + ConsumerRuntimeEvent::OffsetLoaded { .. } => RuntimeEventKind::OffsetLoaded, + ConsumerRuntimeEvent::OffsetCommitted { .. } => RuntimeEventKind::OffsetCommitted, + ConsumerRuntimeEvent::RetryScheduled { .. } => RuntimeEventKind::RetryScheduled, + } +} + +#[async_trait::async_trait] +impl ConsumerHandler for BatchScopeRecorder { + async fn handle_batch( + &self, + batch: &EventBatch<'_>, + _attempts: u16, + ) -> Result { + let chunk = batch.next_chunk(batch.len()); + if let Some(first) = chunk.first() { + if chunk + .iter() + .any(|event| event.topic != first.topic || event.partition != first.partition) + { + self.violations + .lock() + .unwrap() + .push("batch mixed topic IDs or partitions".to_owned()); + } + self.scopes + .lock() + .unwrap() + .push((first.topic.clone(), first.partition, chunk.len())); + } + Ok(chunk + .last() + .map(|event| BatchHandlerOutcome::AdvanceThrough { + offset: event.offset, + }) + .unwrap_or(BatchHandlerOutcome::Success)) + } +} + +#[test] +fn slow_state_detects_buffer_high_watermark_once() { + let mut state = PartitionSlowState::default(); + + assert!(state.observe_enqueue(3, 10, 4).is_none()); + let signal = state + .observe_enqueue(4, 11, 4) + .expect("high watermark triggers"); + + assert_eq!(signal.reason, SlowConsumerReason::BufferHighWatermark); + assert_eq!(signal.buffered_count, 4); + assert_eq!(signal.latest_observed_offset, Some(11)); + assert!(state.observe_enqueue(5, 12, 4).is_none()); +} + +#[test] +fn slow_state_detects_handler_latency_strikes_and_resets_on_fast_completion() { + let mut state = PartitionSlowState::default(); + let threshold = Duration::from_millis(50); + + assert!( + state + .observe_handler_completion(Duration::from_millis(75), threshold, 2, 20) + .is_none() + ); + assert!( + state + .observe_handler_completion(Duration::from_millis(10), threshold, 2, 21) + .is_none() + ); + + assert!( + state + .observe_handler_completion(Duration::from_millis(75), threshold, 2, 22) + .is_none() + ); + let signal = state + .observe_handler_completion(Duration::from_millis(80), threshold, 2, 23) + .expect("latency strikes trigger"); + + assert_eq!(signal.reason, SlowConsumerReason::HandlerLatencyStrikes); + assert_eq!(signal.consecutive_slow_handlers, 2); + assert_eq!(signal.last_delivered_offset, Some(23)); +} + +#[tokio::test] +async fn partition_buffer_capacity_is_isolated_per_topic_partition() { + let buffers: TestPartitionBuffers = Arc::new(RwLock::new(HashMap::new())); + let topic = gts_id!("cf.core.events.topic.v1~example.dispatcher.buffer.x.v1"); + let topic_id = TopicId::from_gts(topic); + let partition_zero = TopicPartitionKey::new(topic, topic_id, 0); + let partition_one = TopicPartitionKey::new(topic, topic_id, 1); + + let first_partition_zero = enqueue_partition_batch( + &buffers, + partition_zero.clone(), + raw_event_on(topic, "BufferEvent", 0, 10), + 1, + ) + .await + .expect("first event in partition 0 fits"); + assert_eq!(first_partition_zero.buffered_count, 1); + + let overflow_partition_zero = enqueue_partition_batch( + &buffers, + partition_zero, + raw_event_on(topic, "BufferEvent", 0, 11), + 1, + ) + .await + .expect_err("partition 0 capacity is exhausted"); + assert!( + overflow_partition_zero + .to_string() + .contains("partition buffer capacity 1 exceeded"), + "unexpected overflow error: {overflow_partition_zero}" + ); + + let first_partition_one = enqueue_partition_batch( + &buffers, + partition_one, + raw_event_on(topic, "BufferEvent", 1, 20), + 1, + ) + .await + .expect("partition 1 has independent capacity"); + assert_eq!(first_partition_one.buffered_count, 1); +} + +fn route(topic: &str, event_type: Option<&str>) -> ConsumerRoute { + ConsumerRoute { + topic: TopicRef::gts(topic), + event_type: event_type.map(EventTypeRef::gts), + handler_kind: ConsumerRouteHandlerKind::Batch, + } +} + +#[tokio::test] +async fn single_handler_adapter_dispatches_one_event_batch() { + let calls = Arc::new(Mutex::new(Vec::new())); + let handler = SingleEventHandlerAdapter::new(Arc::new(RecordingSingleHandler { + calls: calls.clone(), + outcome: HandlerOutcome::Success, + })); + let events = vec![ + raw_event_on("orders", "OrderCreated", 7, 20), + raw_event_on("orders", "OrderUpdated", 7, 21), + ]; + let batch = EventBatch::new(&events); + + let outcome = handler.handle_batch(&batch, 1).await.unwrap(); + + assert_eq!(*calls.lock().unwrap(), vec![20]); + assert!(matches!( + outcome, + BatchHandlerOutcome::AdvanceThrough { offset: 20 } + )); + assert_eq!(batch.next_event().map(|event| event.offset), Some(20)); +} + +#[tokio::test] +async fn native_batch_handler_dispatches_multiple_events_from_one_partition() { + let calls = Arc::new(Mutex::new(Vec::new())); + let handler = AckAllBatchHandler { + calls: calls.clone(), + }; + let events = vec![ + raw_event_on("orders", "OrderCreated", 5, 30), + raw_event_on("orders", "OrderUpdated", 5, 31), + raw_event_on("orders", "OrderClosed", 5, 32), + ]; + let batch = EventBatch::new(&events); + + let outcome = handler.handle_batch(&batch, 1).await.unwrap(); + + assert_eq!( + *calls.lock().unwrap(), + vec![ + ("orders".to_owned(), 5, 30), + ("orders".to_owned(), 5, 31), + ("orders".to_owned(), 5, 32), + ] + ); + assert!(matches!( + outcome, + BatchHandlerOutcome::AdvanceThrough { offset: 32 } + )); + assert_eq!(batch.next_event().map(|event| event.offset), Some(30)); +} + +#[tokio::test] +async fn routed_dispatch_prefers_exact_topic_and_event_type_route() { + let calls = Arc::new(Mutex::new(Vec::new())); + let routed = RoutedBatchHandler::new( + Some(Arc::new(RecordingBatchHandler { + name: "default", + calls: calls.clone(), + outcome: BatchHandlerOutcome::Success, + })), + vec![route("orders", Some("OrderCreated"))], + vec![Arc::new(RecordingBatchHandler { + name: "orders-created", + calls: calls.clone(), + outcome: BatchHandlerOutcome::Success, + })], + ) + .unwrap(); + let events = vec![raw_event("orders", "OrderCreated", 10)]; + let batch = EventBatch::new(&events); + + let outcome = routed.handle_batch(&batch, 1).await.unwrap(); + + assert_eq!(*calls.lock().unwrap(), vec![("orders-created", 10)]); + assert!(matches!(outcome, BatchHandlerOutcome::Success)); +} + +#[tokio::test] +async fn routed_dispatch_uses_topic_catch_all_before_default_handler() { + let calls = Arc::new(Mutex::new(Vec::new())); + let routed = RoutedBatchHandler::new( + Some(Arc::new(RecordingBatchHandler { + name: "default", + calls: calls.clone(), + outcome: BatchHandlerOutcome::Success, + })), + vec![route("orders", None)], + vec![Arc::new(RecordingBatchHandler { + name: "orders-any", + calls: calls.clone(), + outcome: BatchHandlerOutcome::Success, + })], + ) + .unwrap(); + let events = vec![raw_event("orders", "OrderCancelled", 11)]; + let batch = EventBatch::new(&events); + + let outcome = routed.handle_batch(&batch, 1).await.unwrap(); + + assert_eq!(*calls.lock().unwrap(), vec![("orders-any", 11)]); + assert!(matches!(outcome, BatchHandlerOutcome::Success)); +} + +#[tokio::test] +async fn routed_dispatch_falls_back_to_default_handler() { + let calls = Arc::new(Mutex::new(Vec::new())); + let routed = RoutedBatchHandler::new( + Some(Arc::new(RecordingBatchHandler { + name: "default", + calls: calls.clone(), + outcome: BatchHandlerOutcome::Success, + })), + vec![route("payments", None)], + vec![Arc::new(RecordingBatchHandler { + name: "payments-any", + calls: calls.clone(), + outcome: BatchHandlerOutcome::Success, + })], + ) + .unwrap(); + let events = vec![raw_event("orders", "OrderCancelled", 12)]; + let batch = EventBatch::new(&events); + + let outcome = routed.handle_batch(&batch, 1).await.unwrap(); + + assert_eq!(*calls.lock().unwrap(), vec![("default", 12)]); + assert!(matches!(outcome, BatchHandlerOutcome::Success)); +} + +#[tokio::test] +async fn routed_dispatch_fails_visibly_without_matching_route_or_default() { + let routed = RoutedBatchHandler::new( + None, + vec![route("payments", None)], + vec![Arc::new(RecordingBatchHandler { + name: "payments-any", + calls: Arc::new(Mutex::new(Vec::new())), + outcome: BatchHandlerOutcome::Success, + })], + ) + .unwrap(); + let events = vec![raw_event("orders", "OrderCancelled", 13)]; + let batch = EventBatch::new(&events); + + let err = routed.handle_batch(&batch, 1).await.unwrap_err(); + + assert!( + err.to_string().contains("no consumer route matched"), + "unexpected error: {err:?}" + ); + assert_eq!(batch.next_event().map(|event| event.offset), Some(13)); +} + +#[tokio::test] +async fn routed_dispatch_preserves_adjacent_event_order_across_routes() { + let calls = Arc::new(Mutex::new(Vec::new())); + let routed = RoutedBatchHandler::new( + None, + vec![ + route("orders", Some("OrderCreated")), + route("orders", Some("OrderCancelled")), + ], + vec![ + Arc::new(RecordingBatchHandler { + name: "created", + calls: calls.clone(), + outcome: BatchHandlerOutcome::Success, + }), + Arc::new(RecordingBatchHandler { + name: "cancelled", + calls: calls.clone(), + outcome: BatchHandlerOutcome::Success, + }), + ], + ) + .unwrap(); + let events = vec![ + raw_event_on("orders", "OrderCreated", 3, 40), + raw_event_on("orders", "OrderCancelled", 3, 41), + ]; + let batch = EventBatch::new(&events); + let second_batch = EventBatch::new(&events[1..]); + + routed.handle_batch(&batch, 1).await.unwrap(); + routed.handle_batch(&second_batch, 1).await.unwrap(); + + assert_eq!( + *calls.lock().unwrap(), + vec![("created", 40), ("cancelled", 41)] + ); + assert_eq!(batch.next_event().map(|event| event.offset), Some(40)); + assert_eq!( + second_batch.next_event().map(|event| event.offset), + Some(41) + ); +} + +#[tokio::test] +async fn routed_dispatch_retry_does_not_advance_past_earlier_unprocessed_event() { + let calls = Arc::new(Mutex::new(Vec::new())); + let routed = RoutedBatchHandler::new( + None, + vec![ + route("orders", Some("OrderCreated")), + route("orders", Some("OrderCancelled")), + ], + vec![ + Arc::new(RecordingBatchHandler { + name: "created", + calls: calls.clone(), + outcome: BatchHandlerOutcome::Retry { + reason: "try later".to_owned(), + }, + }), + Arc::new(RecordingBatchHandler { + name: "cancelled", + calls: calls.clone(), + outcome: BatchHandlerOutcome::Success, + }), + ], + ) + .unwrap(); + let events = vec![ + raw_event_on("orders", "OrderCreated", 3, 50), + raw_event_on("orders", "OrderCancelled", 3, 51), + ]; + let batch = EventBatch::new(&events); + + let outcome = routed.handle_batch(&batch, 1).await.unwrap(); + + assert!(matches!(outcome, BatchHandlerOutcome::Retry { .. })); + assert_eq!(*calls.lock().unwrap(), vec![("created", 50)]); + assert_eq!(batch.next_event().map(|event| event.offset), Some(50)); +} + +#[cfg(feature = "test-util")] +#[tokio::test] +async fn runtime_dispatch_never_mixes_topics_or_partitions_in_handler_batches() { + use crate::EventBroker; + use crate::mock::stubs::test_ctx_for_tenant; + use crate::mock::{MockBroker, MockBrokerHandle}; + use crate::models::Event; + use std::collections::BTreeSet; + + const ORDERS_TOPIC: &str = gts_id!("cf.core.events.topic.v1~example.mock.broker.orders.v1"); + const PAYMENTS_TOPIC: &str = gts_id!("cf.core.events.topic.v1~example.mock.broker.payments.v1"); + const ORDERS_EVENT: &str = gts_id!("cf.core.events.event_type.v1~example.mock.broker.order.v1"); + const PAYMENTS_EVENT: &str = + gts_id!("cf.core.events.event_type.v1~example.mock.broker.payment.v1"); + + let mock = MockBroker::new(); + let control = MockBrokerHandle::from_broker(&mock); + control.register_topic(ORDERS_TOPIC, 2).await; + control.register_topic(PAYMENTS_TOPIC, 2).await; + control + .register_event_type( + ORDERS_TOPIC, + ORDERS_EVENT, + serde_json::json!({ "type": "object" }), + &[], + ) + .await; + control + .register_event_type( + PAYMENTS_TOPIC, + PAYMENTS_EVENT, + serde_json::json!({ "type": "object" }), + &[], + ) + .await; + control + .set_heartbeat_interval(Duration::from_millis(10)) + .await; + + let broker: Arc = Arc::new(mock); + let ctx = test_ctx_for_tenant(Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap()); + let recorder = BatchScopeRecorder::default(); + let scopes = recorder.scopes.clone(); + let violations = recorder.violations.clone(); + + let handle = ConsumerBuilder::new(broker.clone()) + .group(ConsumerGroupRef::auto_anonymous("batch-scope")) + .topics([ORDERS_TOPIC, PAYMENTS_TOPIC]) + .offset_manager(InMemoryOffsetManager::new(Fallback::Earliest)) + .batch_handler(recorder) + .start() + .await + .expect("consumer starts"); + + for _ in 0..100 { + if handle.subscription_ids().len() == 1 { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + + for (topic, event_type, partition) in [ + (ORDERS_TOPIC, ORDERS_EVENT, 0), + (ORDERS_TOPIC, ORDERS_EVENT, 1), + (PAYMENTS_TOPIC, PAYMENTS_EVENT, 0), + (PAYMENTS_TOPIC, PAYMENTS_EVENT, 1), + ] { + broker + .publish( + &ctx, + &Event { + id: Uuid::new_v4(), + type_id: event_type.to_owned(), + topic: topic.to_owned(), + tenant_id: ctx.subject_tenant_id(), + source: "consumer.dispatcher.test".to_owned(), + subject: format!("{topic}-{partition}"), + subject_type: "test".to_owned(), + partition_key: Some(partition_key_for_partition(partition, 2)), + occurred_at: Utc::now(), + trace_parent: None, + data: Some(serde_json::json!({ "partition": partition })), + partition: None, + sequence: None, + sequence_time: None, + offset: None, + offset_time: None, + meta: None, + }, + ) + .await + .expect("event published"); + } + + for _ in 0..100 { + if scopes.lock().unwrap().len() >= 4 { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + handle.stop().await.expect("consumer stops"); + + assert_eq!(*violations.lock().unwrap(), Vec::::new()); + let recorded = scopes.lock().unwrap().clone(); + assert_eq!(recorded.len(), 4); + assert!(recorded.iter().all(|(_, _, len)| *len == 1)); + let topics = recorded + .iter() + .map(|(topic, _, _)| topic.as_str()) + .collect::>(); + assert_eq!(topics, BTreeSet::from([ORDERS_TOPIC, PAYMENTS_TOPIC])); +} + +#[cfg(feature = "test-util")] +#[tokio::test] +async fn parallelism_creates_independent_slots_with_shared_group_and_interests() { + use crate::EventBroker; + use crate::ids::ConsumerGroupId; + use crate::mock::{MockBroker, MockBrokerHandle}; + use std::collections::{BTreeSet, HashSet}; + + const TOPIC: &str = gts_id!("cf.core.events.topic.v1~example.mock.broker.parallel.v1"); + const GROUP: &str = + gts_id!("cf.core.events.consumer_group.v1~example.mock.consumer.parallel.v1"); + + let mock = MockBroker::new(); + let control = MockBrokerHandle::from_broker(&mock); + control.register_topic(TOPIC, 4).await; + control.register_named_group(GROUP).await; + control + .set_heartbeat_interval(Duration::from_millis(10)) + .await; + + let group = ConsumerGroupId::from_gts(GROUP); + let broker: Arc = Arc::new(mock); + let handle = ConsumerBuilder::new(broker) + .group(ConsumerGroupRef::id(group)) + .topics([TOPIC]) + .parallelism(2) + .offset_manager(InMemoryOffsetManager::new(Fallback::Earliest)) + .batch_handler(AckAllBatchHandler { + calls: Arc::new(Mutex::new(Vec::new())), + }) + .start() + .await + .expect("consumer starts"); + + let subscriptions = wait_for_parallel_assignments(&handle, &control, &group, 2, 4).await; + + let members = control.members(&group).await; + let member_set = members.iter().copied().collect::>(); + let subscription_set = subscriptions.iter().copied().collect::>(); + assert_eq!( + member_set, subscription_set, + "parallel slots should join the same consumer group" + ); + + let mut all_assigned = BTreeSet::new(); + for sub_id in &subscriptions { + let assignment = control.assignment(*sub_id).await; + assert_eq!( + assignment.len(), + 2, + "each of two slots should own half of the four partitions" + ); + for slot in assignment { + assert_eq!(slot.topic, TOPIC); + all_assigned.insert((slot.topic, slot.partition)); + } + } + assert_eq!( + all_assigned, + (0..4) + .map(|partition| (TOPIC.to_owned(), partition)) + .collect::>(), + "parallel slots should cover every partition exactly once" + ); + + handle.stop().await.expect("consumer stops"); +} + +#[cfg(feature = "test-util")] +async fn wait_for_parallel_assignments( + handle: &super::runtime::ConsumerHandle, + control: &crate::mock::MockBrokerHandle, + group: &ConsumerGroupId, + expected_slots: usize, + expected_total_partitions: usize, +) -> Vec { + for _ in 0..100 { + let subscriptions = handle.subscription_ids(); + let members = control.members(group).await; + if subscriptions.len() == expected_slots && members.len() == expected_slots { + let mut assigned_total = 0usize; + let mut all_non_empty = true; + for sub_id in &subscriptions { + let assignment = control.assignment(*sub_id).await; + assigned_total += assignment.len(); + all_non_empty &= !assignment.is_empty(); + } + if all_non_empty && assigned_total == expected_total_partitions { + return subscriptions; + } + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + panic!( + "parallel consumer did not expose {expected_slots} assigned subscription slots; ids={:?}, members={:?}", + handle.subscription_ids(), + control.members(group).await + ); +} + +#[cfg(feature = "test-util")] +#[tokio::test] +async fn slow_detection_emits_listener_events_and_drops_subscription_stream() { + use crate::EventBroker; + use crate::mock::stubs::test_ctx_for_tenant; + use crate::mock::{MockBroker, MockBrokerHandle}; + use crate::models::Event; + + const TOPIC: &str = gts_id!("cf.core.events.topic.v1~example.mock.broker.slow.v1"); + const EVENT_TYPE: &str = gts_id!("cf.core.events.event_type.v1~example.mock.broker.slow.v1"); + + let mock = MockBroker::new(); + let control = MockBrokerHandle::from_broker(&mock); + control.register_topic(TOPIC, 1).await; + control + .register_event_type( + TOPIC, + EVENT_TYPE, + serde_json::json!({ "type": "object" }), + &[], + ) + .await; + control + .set_heartbeat_interval(Duration::from_millis(10)) + .await; + + let broker: Arc = Arc::new(mock); + let ctx = test_ctx_for_tenant(Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap()); + let listener = RecordingRuntimeListener::default(); + let recorded = listener.events.clone(); + let calls = Arc::new(Mutex::new(Vec::new())); + + let handle = ConsumerBuilder::new(broker.clone()) + .group(ConsumerGroupRef::auto_anonymous("slow-drop")) + .topics([TOPIC]) + .buffering(ConsumerBuffering { + partition_capacity: 8, + high_watermark: 1, + low_watermark: 0, + }) + .register_listener(listener) + .offset_manager(InMemoryOffsetManager::new(Fallback::Earliest)) + .batch_handler(AckAllBatchHandler { + calls: calls.clone(), + }) + .start() + .await + .expect("consumer starts"); + + for _ in 0..100 { + if handle.subscription_ids().len() == 1 { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + + broker + .publish( + &ctx, + &Event { + id: Uuid::new_v4(), + type_id: EVENT_TYPE.to_owned(), + topic: TOPIC.to_owned(), + tenant_id: ctx.subject_tenant_id(), + source: "consumer.dispatcher.test".to_owned(), + subject: "slow-subject".to_owned(), + subject_type: "test".to_owned(), + partition_key: Some(partition_key_for_partition(0, 2)), + occurred_at: Utc::now(), + trace_parent: None, + data: Some(serde_json::json!({ "slow": true })), + partition: None, + sequence: None, + sequence_time: None, + offset: None, + offset_time: None, + meta: None, + }, + ) + .await + .expect("event published"); + + for _ in 0..100 { + let events = recorded.lock().unwrap().clone(); + let has_state = events.iter().any(|event| { + matches!( + event, + ConsumerRuntimeEvent::PartitionBufferStateChanged { state } + if state.state == PartitionBufferState::SlowDetected + ) + }); + let has_drop = events.iter().any(|event| { + matches!( + event, + ConsumerRuntimeEvent::SubscriptionConnectionDropped { + reason: ConnectionDropReason::SlowConsumer { .. }, + affected, + .. + } if !affected.is_empty() + ) + }); + if has_state && has_drop { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + + handle.stop().await.expect("consumer stops"); + + let events = recorded.lock().unwrap(); + assert!( + events.iter().any(|event| { + matches!( + event, + ConsumerRuntimeEvent::PartitionBufferStateChanged { state } + if state.state == PartitionBufferState::SlowDetected + && state.topic == TOPIC + && state.buffered_count == 1 + ) + }), + "missing slow buffer state event: {events:?}" + ); + assert!( + events.iter().any(|event| { + matches!( + event, + ConsumerRuntimeEvent::SubscriptionConnectionDropped { + reason: ConnectionDropReason::SlowConsumer { topic, .. }, + affected, + .. + } if topic == TOPIC && !affected.is_empty() + ) + }), + "missing slow connection drop event: {events:?}" + ); + assert!( + calls + .lock() + .unwrap() + .iter() + .any(|(topic, partition, _)| topic == TOPIC && *partition == 0), + "slow event should be drained through the handler before rejoin" + ); +} + +#[cfg(feature = "test-util")] +#[tokio::test] +async fn slow_drop_reports_other_assignments_owned_by_same_subscription_slot() { + use crate::EventBroker; + use crate::mock::stubs::test_ctx_for_tenant; + use crate::mock::{MockBroker, MockBrokerHandle}; + use crate::models::Event; + use std::collections::BTreeSet; + + const TOPIC: &str = gts_id!("cf.core.events.topic.v1~example.mock.broker.affected.v1"); + const EVENT_TYPE: &str = + gts_id!("cf.core.events.event_type.v1~example.mock.broker.affected.v1"); + + let mock = MockBroker::new(); + let control = MockBrokerHandle::from_broker(&mock); + control.register_topic(TOPIC, 2).await; + control + .register_event_type( + TOPIC, + EVENT_TYPE, + serde_json::json!({ "type": "object" }), + &[], + ) + .await; + control + .set_heartbeat_interval(Duration::from_millis(10)) + .await; + + let broker: Arc = Arc::new(mock); + let ctx = test_ctx_for_tenant(Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap()); + let listener = RecordingRuntimeListener::default(); + let recorded = listener.events.clone(); + + let handle = ConsumerBuilder::new(broker.clone()) + .group(ConsumerGroupRef::auto_anonymous("affected-assignments")) + .topics([TOPIC]) + .buffering(ConsumerBuffering { + partition_capacity: 8, + high_watermark: 1, + low_watermark: 0, + }) + .register_listener(listener) + .offset_manager(InMemoryOffsetManager::new(Fallback::Earliest)) + .batch_handler(AckAllBatchHandler { + calls: Arc::new(Mutex::new(Vec::new())), + }) + .start() + .await + .expect("consumer starts"); + + for _ in 0..100 { + if handle.subscription_ids().len() == 1 { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + + broker + .publish( + &ctx, + &Event { + id: Uuid::new_v4(), + type_id: EVENT_TYPE.to_owned(), + topic: TOPIC.to_owned(), + tenant_id: ctx.subject_tenant_id(), + source: "consumer.dispatcher.test".to_owned(), + subject: "affected-subject".to_owned(), + subject_type: "test".to_owned(), + partition_key: None, + occurred_at: Utc::now(), + trace_parent: None, + data: Some(serde_json::json!({ "slow": true })), + partition: None, + sequence: None, + sequence_time: None, + offset: None, + offset_time: None, + meta: None, + }, + ) + .await + .expect("event published"); + + let affected = wait_for_slow_drop_affected_assignments(&recorded).await; + handle.stop().await.expect("consumer stops"); + + assert_eq!( + affected, + BTreeSet::from([(TOPIC.to_owned(), 0), (TOPIC.to_owned(), 1)]), + "slow partition should report every assignment on the dropped subscription slot" + ); +} + +#[cfg(feature = "test-util")] +async fn wait_for_slow_drop_affected_assignments( + recorded: &SharedRuntimeEvents, +) -> BTreeSet<(String, u32)> { + for _ in 0..100 { + let events = recorded.lock().unwrap().clone(); + if let Some(affected) = events.iter().find_map(|event| { + if let ConsumerRuntimeEvent::SubscriptionConnectionDropped { + reason: ConnectionDropReason::SlowConsumer { .. }, + affected, + .. + } = event + { + Some( + affected + .iter() + .map(|slot| (slot.topic.clone(), slot.partition)) + .collect::>(), + ) + } else { + None + } + }) { + return affected; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + panic!( + "did not observe slow-drop affected assignments; events={:?}", + recorded.lock().unwrap() + ); +} + +#[cfg(feature = "test-util")] +#[tokio::test] +async fn runtime_listener_observes_representative_non_dlq_event_variants() { + use crate::EventBroker; + use crate::mock::stubs::test_ctx_for_tenant; + use crate::mock::{MockBroker, MockBrokerHandle}; + use crate::models::Event; + + const TOPIC: &str = gts_id!("cf.core.events.topic.v1~example.mock.broker.listener_all.v1"); + const EVENT_TYPE: &str = + gts_id!("cf.core.events.event_type.v1~example.mock.broker.listener_all.v1"); + + let mock = MockBroker::new(); + let control = MockBrokerHandle::from_broker(&mock); + control.register_topic(TOPIC, 1).await; + control + .register_event_type( + TOPIC, + EVENT_TYPE, + serde_json::json!({ "type": "object" }), + &[], + ) + .await; + control + .set_heartbeat_interval(Duration::from_millis(10)) + .await; + + let broker: Arc = Arc::new(mock); + let ctx = test_ctx_for_tenant(Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap()); + let listener = RecordingRuntimeListener::default(); + let recorded = listener.events.clone(); + let handler_calls = Arc::new(Mutex::new(Vec::new())); + + let handle = ConsumerBuilder::new(broker.clone()) + .group(ConsumerGroupRef::auto_anonymous("listener-all")) + .topics([TOPIC]) + .buffering(ConsumerBuffering { + partition_capacity: 8, + high_watermark: 1, + low_watermark: 0, + }) + .retry_base(Duration::from_millis(1)) + .retry_max(Duration::from_millis(1)) + .register_listener(listener) + .offset_manager(InMemoryOffsetManager::new(Fallback::Earliest)) + .batch_handler(FailingThenCommitBatchHandler { + failures_remaining: Arc::new(Mutex::new(1)), + calls: handler_calls.clone(), + }) + .start() + .await + .expect("consumer starts"); + + for _ in 0..100 { + if handle.subscription_ids().len() == 1 { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + + broker + .publish( + &ctx, + &Event { + id: Uuid::new_v4(), + type_id: EVENT_TYPE.to_owned(), + topic: TOPIC.to_owned(), + tenant_id: ctx.subject_tenant_id(), + source: "consumer.dispatcher.test".to_owned(), + subject: "listener-all-subject".to_owned(), + subject_type: "test".to_owned(), + partition_key: None, + occurred_at: Utc::now(), + trace_parent: None, + data: Some(serde_json::json!({ "representative": true })), + partition: None, + sequence: None, + sequence_time: None, + offset: None, + offset_time: None, + meta: None, + }, + ) + .await + .expect("event published"); + + let expected_before_stop = BTreeSet::from([ + RuntimeEventKind::SubscriptionJoining, + RuntimeEventKind::SubscriptionStarted, + RuntimeEventKind::SubscriptionRejoining, + RuntimeEventKind::SubscriptionConnectionDropped, + RuntimeEventKind::AssignmentChanged, + RuntimeEventKind::ProgressAdvanced, + RuntimeEventKind::PartitionBufferStateChanged, + RuntimeEventKind::HandlerBatchStarted, + RuntimeEventKind::HandlerBatchCompleted, + RuntimeEventKind::HandlerFailed, + RuntimeEventKind::OffsetLoaded, + RuntimeEventKind::OffsetCommitted, + RuntimeEventKind::RetryScheduled, + ]); + wait_for_runtime_event_kinds(&recorded, &expected_before_stop).await; + + handle.stop().await.expect("consumer stops"); + + let expected_after_stop = expected_before_stop + .into_iter() + .chain([RuntimeEventKind::SubscriptionTerminated]) + .collect::>(); + wait_for_runtime_event_kinds(&recorded, &expected_after_stop).await; + + assert_eq!(*handler_calls.lock().unwrap(), vec![2]); +} + +#[cfg(feature = "test-util")] +async fn wait_for_runtime_event_kinds( + recorded: &SharedRuntimeEvents, + expected: &BTreeSet, +) -> BTreeSet { + for _ in 0..100 { + let observed = recorded + .lock() + .unwrap() + .iter() + .map(runtime_event_kind) + .collect::>(); + if expected.is_subset(&observed) { + return observed; + } + drop(observed); + tokio::time::sleep(Duration::from_millis(10)).await; + } + panic!( + "missing representative runtime event kinds; expected={expected:?}, events={:?}", + recorded.lock().unwrap() + ); +} + +#[cfg(feature = "test-util")] +#[tokio::test] +async fn listener_failure_does_not_commit_drop_or_stop_consumer_events() { + use crate::EventBroker; + use crate::mock::stubs::test_ctx_for_tenant; + use crate::mock::{MockBroker, MockBrokerHandle}; + use crate::models::Event; + + const TOPIC: &str = gts_id!("cf.core.events.topic.v1~example.mock.broker.listener_failure.v1"); + const EVENT_TYPE: &str = + gts_id!("cf.core.events.event_type.v1~example.mock.broker.listener_failure.v1"); + + let mock = MockBroker::new(); + let control = MockBrokerHandle::from_broker(&mock); + control.register_topic(TOPIC, 1).await; + control + .register_event_type( + TOPIC, + EVENT_TYPE, + serde_json::json!({ "type": "object" }), + &[], + ) + .await; + control + .set_heartbeat_interval(Duration::from_millis(10)) + .await; + + let broker: Arc = Arc::new(mock); + let ctx = test_ctx_for_tenant(Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap()); + let recorder = RecordingRuntimeListener::default(); + let recorded_events = recorder.events.clone(); + let handler_calls = Arc::new(Mutex::new(Vec::new())); + let offset_manager = RecordingCommitOffsetManager::default(); + let commits = offset_manager.commits.clone(); + + let handle = ConsumerBuilder::new(broker.clone()) + .group(ConsumerGroupRef::auto_anonymous("listener-failure")) + .topics([TOPIC]) + .commit_mode(ConsumerCommitMode::manual()) + .register_listener(FailingRuntimeListener) + .register_listener(recorder) + .offset_manager(offset_manager) + .batch_handler(AckAllBatchHandler { + calls: handler_calls.clone(), + }) + .start() + .await + .expect("consumer starts despite listener that will fail"); + + for _ in 0..100 { + if handle.subscription_ids().len() == 1 { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + + for offset in 0..2 { + broker + .publish( + &ctx, + &Event { + id: Uuid::new_v4(), + type_id: EVENT_TYPE.to_owned(), + topic: TOPIC.to_owned(), + tenant_id: ctx.subject_tenant_id(), + source: "consumer.dispatcher.test".to_owned(), + subject: format!("listener-failure-{offset}"), + subject_type: "test".to_owned(), + partition_key: None, + occurred_at: Utc::now(), + trace_parent: None, + data: Some(serde_json::json!({ "offset": offset })), + partition: None, + sequence: None, + sequence_time: None, + offset: None, + offset_time: None, + meta: None, + }, + ) + .await + .expect("event published"); + } + + for _ in 0..100 { + if handler_calls.lock().unwrap().len() >= 2 { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + + let active_subscriptions = handle.subscription_ids(); + handle.stop().await.expect("consumer stops cleanly"); + + let calls = handler_calls.lock().unwrap().clone(); + assert_eq!( + calls.len(), + 2, + "listener failure must not drop consumer events before handler dispatch" + ); + let commits = commits.lock().unwrap().clone(); + assert!( + commits.iter().all(|(_, _, _, offset)| *offset < 0), + "listener failure must not durably advance delivered event offsets by itself: {commits:?}" + ); + assert!( + !active_subscriptions.is_empty(), + "listener failure must not stop the consumer by itself" + ); + let events = recorded_events.lock().unwrap(); + assert!( + !events.iter().any(|event| { + matches!( + event, + ConsumerRuntimeEvent::SubscriptionConnectionDropped { .. } + ) + }), + "listener failure must not drop the subscription stream: {events:?}" + ); + assert!( + events.iter().any(|event| { + matches!( + event, + ConsumerRuntimeEvent::HandlerBatchCompleted { + outcome: BatchHandlerOutcome::AdvanceThrough { .. }, + .. + } + ) + }), + "the recording listener should still observe handler success after another listener fails" + ); +} + +#[cfg(feature = "test-util")] +#[tokio::test] +async fn slow_listener_timeout_does_not_block_runtime_delivery_or_handler_processing() { + use crate::EventBroker; + use crate::mock::stubs::test_ctx_for_tenant; + use crate::mock::{MockBroker, MockBrokerHandle}; + use crate::models::Event; + + const TOPIC: &str = gts_id!("cf.core.events.topic.v1~example.mock.broker.listener_timeout.v1"); + const EVENT_TYPE: &str = + gts_id!("cf.core.events.event_type.v1~example.mock.broker.listener_timeout.v1"); + + let mock = MockBroker::new(); + let control = MockBrokerHandle::from_broker(&mock); + control.register_topic(TOPIC, 1).await; + control + .register_event_type( + TOPIC, + EVENT_TYPE, + serde_json::json!({ "type": "object" }), + &[], + ) + .await; + control + .set_heartbeat_interval(Duration::from_millis(10)) + .await; + + let broker: Arc = Arc::new(mock); + let ctx = test_ctx_for_tenant(Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap()); + let recorder = RecordingRuntimeListener::default(); + let recorded_events = recorder.events.clone(); + let handler_calls = Arc::new(Mutex::new(Vec::new())); + + let handle = ConsumerBuilder::new(broker.clone()) + .group(ConsumerGroupRef::auto_anonymous("listener-timeout")) + .topics([TOPIC]) + .listener_settings(ConsumerListenerSettings { + channel_capacity: 8, + timeout: Duration::from_millis(1), + }) + .register_listener(SlowRuntimeListener { + delay: Duration::from_secs(60), + }) + .register_listener(recorder) + .offset_manager(InMemoryOffsetManager::new(Fallback::Earliest)) + .batch_handler(AckAllBatchHandler { + calls: handler_calls.clone(), + }) + .start() + .await + .expect("consumer starts"); + + for _ in 0..100 { + if handle.subscription_ids().len() == 1 { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + + broker + .publish( + &ctx, + &Event { + id: Uuid::new_v4(), + type_id: EVENT_TYPE.to_owned(), + topic: TOPIC.to_owned(), + tenant_id: ctx.subject_tenant_id(), + source: "consumer.dispatcher.test".to_owned(), + subject: "listener-timeout-subject".to_owned(), + subject_type: "test".to_owned(), + partition_key: None, + occurred_at: Utc::now(), + trace_parent: None, + data: Some(serde_json::json!({ "timeout": true })), + partition: None, + sequence: None, + sequence_time: None, + offset: None, + offset_time: None, + meta: None, + }, + ) + .await + .expect("event published"); + + tokio::time::timeout(Duration::from_secs(2), async { + loop { + let handler_done = !handler_calls.lock().unwrap().is_empty(); + let listener_done = recorded_events.lock().unwrap().iter().any(|event| { + matches!( + event, + ConsumerRuntimeEvent::HandlerBatchCompleted { + outcome: BatchHandlerOutcome::AdvanceThrough { .. }, + .. + } + ) + }); + if handler_done && listener_done { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("slow listener timeout should let processing continue"); + + handle.stop().await.expect("consumer stops"); + + assert_eq!(handler_calls.lock().unwrap().len(), 1); + assert!( + recorded_events + .lock() + .unwrap() + .iter() + .any(|event| matches!(event, ConsumerRuntimeEvent::SubscriptionStarted { .. })), + "recording listener should receive events after the slow listener times out" + ); +} + +#[cfg(feature = "test-util")] +#[tokio::test] +async fn handler_latency_strikes_emit_listener_events_and_drop_subscription_stream() { + use crate::EventBroker; + use crate::mock::stubs::test_ctx_for_tenant; + use crate::mock::{MockBroker, MockBrokerHandle}; + use crate::models::Event; + + const TOPIC: &str = gts_id!("cf.core.events.topic.v1~example.mock.broker.latency.v1"); + const EVENT_TYPE: &str = gts_id!("cf.core.events.event_type.v1~example.mock.broker.latency.v1"); + + let mock = MockBroker::new(); + let control = MockBrokerHandle::from_broker(&mock); + control.register_topic(TOPIC, 1).await; + control + .register_event_type( + TOPIC, + EVENT_TYPE, + serde_json::json!({ "type": "object" }), + &[], + ) + .await; + control + .set_heartbeat_interval(Duration::from_millis(10)) + .await; + + let broker: Arc = Arc::new(mock); + let ctx = test_ctx_for_tenant(Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap()); + let listener = RecordingRuntimeListener::default(); + let recorded = listener.events.clone(); + let calls = Arc::new(Mutex::new(Vec::new())); + + let handle = ConsumerBuilder::new(broker.clone()) + .group(ConsumerGroupRef::auto_anonymous("latency-drop")) + .topics([TOPIC]) + .buffering(ConsumerBuffering { + partition_capacity: 8, + high_watermark: 8, + low_watermark: 0, + }) + .slow_detection(ConsumerSlowDetection { + handler_latency: Duration::from_millis(1), + handler_strikes: 1, + }) + .register_listener(listener) + .offset_manager(InMemoryOffsetManager::new(Fallback::Earliest)) + .batch_handler(SleepingBatchHandler { + calls: calls.clone(), + delay: Duration::from_millis(10), + }) + .start() + .await + .expect("consumer starts"); + + for _ in 0..100 { + if handle.subscription_ids().len() == 1 { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + + broker + .publish( + &ctx, + &Event { + id: Uuid::new_v4(), + type_id: EVENT_TYPE.to_owned(), + topic: TOPIC.to_owned(), + tenant_id: ctx.subject_tenant_id(), + source: "consumer.dispatcher.test".to_owned(), + subject: "latency-subject".to_owned(), + subject_type: "test".to_owned(), + partition_key: None, + occurred_at: Utc::now(), + trace_parent: None, + data: Some(serde_json::json!({ "slow": "handler" })), + partition: None, + sequence: None, + sequence_time: None, + offset: None, + offset_time: None, + meta: None, + }, + ) + .await + .expect("event published"); + + for _ in 0..100 { + let events = recorded.lock().unwrap().clone(); + let has_latency_drop = events.iter().any(|event| { + matches!( + event, + ConsumerRuntimeEvent::SubscriptionConnectionDropped { + reason: ConnectionDropReason::SlowConsumer { + trigger: SlowConsumerTrigger::HandlerLatencyStrikes, + .. + }, + affected, + .. + } if !affected.is_empty() + ) + }); + if has_latency_drop { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + + handle.stop().await.expect("consumer stops"); + + assert!( + calls + .lock() + .unwrap() + .iter() + .any(|(topic, partition, _)| topic == TOPIC && *partition == 0), + "slow handler should process the event before latency drop" + ); + let events = recorded.lock().unwrap(); + assert!( + events.iter().any(|event| { + matches!( + event, + ConsumerRuntimeEvent::PartitionBufferStateChanged { state } + if state.state == PartitionBufferState::SlowDetected + && state.topic == TOPIC + && state.trigger == Some(SlowConsumerTrigger::HandlerLatencyStrikes) + && state.consecutive_slow_handlers == 1 + ) + }), + "missing latency slow buffer state event: {events:?}" + ); + assert!( + events.iter().any(|event| { + matches!( + event, + ConsumerRuntimeEvent::SubscriptionConnectionDropped { + reason: ConnectionDropReason::SlowConsumer { + topic, + trigger: SlowConsumerTrigger::HandlerLatencyStrikes, + .. + }, + affected, + .. + } if topic == TOPIC && !affected.is_empty() + ) + }), + "missing latency connection drop event: {events:?}" + ); +} + +#[cfg(feature = "test-util")] +#[tokio::test] +async fn slow_drop_drains_buffer_before_rejoin_load_position() { + use crate::EventBroker; + use crate::mock::stubs::test_ctx_for_tenant; + use crate::mock::{MockBroker, MockBrokerHandle}; + use crate::models::Event; + + const TOPIC: &str = gts_id!("cf.core.events.topic.v1~example.mock.broker.drain_rejoin.v1"); + const EVENT_TYPE: &str = + gts_id!("cf.core.events.event_type.v1~example.mock.broker.drain_rejoin.v1"); + + let mock = MockBroker::new(); + let control = MockBrokerHandle::from_broker(&mock); + control.register_topic(TOPIC, 1).await; + control + .register_event_type( + TOPIC, + EVENT_TYPE, + serde_json::json!({ "type": "object" }), + &[], + ) + .await; + control + .set_heartbeat_interval(Duration::from_millis(10)) + .await; + + let broker: Arc = Arc::new(mock); + let ctx = test_ctx_for_tenant(Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap()); + let timeline = Arc::new(Mutex::new(Vec::new())); + + let handle = ConsumerBuilder::new(broker.clone()) + .group(ConsumerGroupRef::auto_anonymous("drain-rejoin")) + .topics([TOPIC]) + .buffering(ConsumerBuffering { + partition_capacity: 8, + high_watermark: 1, + low_watermark: 0, + }) + .commit_mode(ConsumerCommitMode::manual()) + .offset_manager(SequencedOffsetManager { + timeline: timeline.clone(), + }) + .batch_handler(SequencedBatchHandler { + timeline: timeline.clone(), + }) + .start() + .await + .expect("consumer starts"); + + for _ in 0..100 { + if handle.subscription_ids().len() == 1 { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + + broker + .publish( + &ctx, + &Event { + id: Uuid::new_v4(), + type_id: EVENT_TYPE.to_owned(), + topic: TOPIC.to_owned(), + tenant_id: ctx.subject_tenant_id(), + source: "consumer.dispatcher.test".to_owned(), + subject: "drain-rejoin-subject".to_owned(), + subject_type: "test".to_owned(), + partition_key: None, + occurred_at: Utc::now(), + trace_parent: None, + data: Some(serde_json::json!({ "slow": "high-watermark" })), + partition: None, + sequence: None, + sequence_time: None, + offset: None, + offset_time: None, + meta: None, + }, + ) + .await + .expect("event published"); + + let observed = wait_for_drain_rejoin_timeline(&timeline).await; + handle.stop().await.expect("consumer stops"); + + let first_load = observed + .iter() + .position(|entry| *entry == "load") + .expect("initial load recorded"); + let first_handle = observed + .iter() + .position(|entry| *entry == "handle") + .expect("buffer drain handler recorded"); + let second_load_after_handle = observed + .iter() + .enumerate() + .skip(first_handle + 1) + .find_map(|(idx, entry)| (*entry == "load").then_some(idx)) + .expect("rejoin load recorded after drain"); + + assert!( + first_load < first_handle && first_handle < second_load_after_handle, + "expected load -> handle -> load ordering, got {observed:?}" + ); +} + +#[cfg(feature = "test-util")] +async fn wait_for_drain_rejoin_timeline(timeline: &SharedTimeline) -> Vec<&'static str> { + for _ in 0..100 { + let observed = timeline.lock().unwrap().clone(); + let Some(first_handle) = observed.iter().position(|entry| *entry == "handle") else { + drop(observed); + tokio::time::sleep(Duration::from_millis(10)).await; + continue; + }; + if observed + .iter() + .skip(first_handle + 1) + .any(|entry| *entry == "load") + { + return observed; + } + drop(observed); + tokio::time::sleep(Duration::from_millis(10)).await; + } + panic!( + "did not observe drain-before-rejoin sequence; timeline={:?}", + timeline.lock().unwrap() + ); +} + +#[cfg(feature = "test-util")] +#[tokio::test] +async fn async_auto_commit_uses_resolved_group_topic_partition_and_frontier_offset() { + use crate::EventBroker; + use crate::mock::stubs::test_ctx_for_tenant; + use crate::mock::{MockBroker, MockBrokerHandle}; + use crate::models::Event; + + const TOPIC: &str = gts_id!("cf.core.events.topic.v1~example.mock.broker.audit.v1"); + const EVENT_TYPE: &str = gts_id!("cf.core.events.event_type.v1~example.mock.broker.event.v1"); + + #[derive(Clone, Default)] + struct RecordingOffsetManager { + commits: SharedCommits, + } + + #[async_trait::async_trait] + impl OffsetStore for RecordingOffsetManager { + async fn load_position( + &self, + _group: &ConsumerGroupId, + _topic: &TopicId, + _partition: u32, + ) -> Result { + Ok(ResolvedPosition::Earliest) + } + } + + #[async_trait::async_trait] + impl CommitOffset for RecordingOffsetManager { + async fn commit( + &self, + group: &ConsumerGroupId, + topic: &TopicId, + partition: u32, + offset: i64, + ) -> Result<(), OffsetManagerError> { + self.commits + .lock() + .expect("recording commits") + .push((*group, *topic, partition, offset)); + Ok(()) + } + } + + let mock = MockBroker::new(); + let control = MockBrokerHandle::from_broker(&mock); + control.register_topic(TOPIC, 1).await; + control + .register_event_type( + TOPIC, + EVENT_TYPE, + serde_json::json!({ "type": "object" }), + &[], + ) + .await; + control + .set_heartbeat_interval(Duration::from_millis(10)) + .await; + + let broker: Arc = Arc::new(mock); + let ctx = test_ctx_for_tenant(Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap()); + let offset_manager = RecordingOffsetManager::default(); + let commits = offset_manager.commits.clone(); + let calls = Arc::new(Mutex::new(Vec::new())); + + let handle = ConsumerBuilder::new(broker.clone()) + .group(ConsumerGroupRef::auto_anonymous("auto-commit-metadata")) + .topics([TOPIC]) + .commit_mode(ConsumerCommitMode::auto(Duration::from_millis(5))) + .offset_manager(offset_manager) + .batch_handler(RecordingBatchHandler { + name: "default", + calls, + outcome: BatchHandlerOutcome::Success, + }) + .start() + .await + .expect("consumer starts"); + + for _ in 0..100 { + if handle.subscription_ids().len() == 1 { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + + broker + .publish( + &ctx, + &Event { + id: Uuid::new_v4(), + type_id: EVENT_TYPE.to_owned(), + topic: TOPIC.to_owned(), + tenant_id: ctx.subject_tenant_id(), + source: "consumer.dispatcher.test".to_owned(), + subject: "subject-1".to_owned(), + subject_type: "test".to_owned(), + partition_key: None, + occurred_at: Utc::now(), + trace_parent: None, + data: Some(serde_json::json!({ "ok": true })), + partition: None, + sequence: None, + sequence_time: None, + offset: None, + offset_time: None, + meta: None, + }, + ) + .await + .expect("event published"); + + let commit = wait_for_first_non_negative_commit(&commits).await; + handle.stop().await.expect("consumer stops"); + + assert_ne!(commit.0, ConsumerGroupId::new(Uuid::nil())); + assert_eq!(commit.1, TopicId::from_gts(TOPIC)); + assert_eq!(commit.2, 0); + assert!(commit.3 >= 0); +} + +#[cfg(feature = "test-util")] +async fn wait_for_first_non_negative_commit( + commits: &SharedCommits, +) -> (ConsumerGroupId, TopicId, u32, i64) { + for _ in 0..100 { + if let Some(commit) = commits + .lock() + .expect("recording commits") + .iter() + .copied() + .find(|commit| commit.3 >= 0) + { + return commit; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + panic!("auto-commit did not persist a recorded event offset"); +} diff --git a/gears/system/event-broker/event-broker-sdk/src/consumer/mod.rs b/gears/system/event-broker/event-broker-sdk/src/consumer/mod.rs new file mode 100644 index 000000000..74c2eda01 --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/src/consumer/mod.rs @@ -0,0 +1,60 @@ +mod builder; +mod commit; +mod dispatcher; +mod offset_manager; +mod progress; +mod runtime; +mod types; + +#[cfg(test)] +mod batch_tests; +#[cfg(test)] +mod builder_tests; +#[cfg(test)] +mod commit_tests; +#[cfg(test)] +mod dispatcher_tests; +#[cfg(test)] +#[cfg(feature = "db")] +mod offset_manager_tests; +#[cfg(test)] +mod progress_tests; +#[cfg(test)] +mod types_tests; + +#[cfg(feature = "db")] +pub use commit::TxCommitHandle; + +#[cfg(feature = "db")] +pub use builder::WithTx; +pub use builder::{ + BrokerOnly, ConsumerBatchReady, ConsumerBuilder, ConsumerOffsetManager, ConsumerReady, + ConsumerRoute, ConsumerRouteBuilder, ConsumerRouteHandlerKind, ConsumerRoutedReady, + NoDefaultHandler, RouteHasTopic, RouteMissingTopic, +}; +#[cfg(feature = "db")] +pub use builder::{TxConsumerRouteBuilder, TxConsumerRoutedReady}; + +pub use offset_manager::{CommitOffset, Fallback, InMemoryOffsetManager, OffsetStore}; +#[cfg(feature = "db")] +pub use offset_manager::{ + CommitOffsetInTx, LOCAL_DB_OFFSET_STORE_MIGRATION_SQL, LocalDbOffsetManager, +}; +pub use runtime::{Consumer, ConsumerHandle}; + +pub use crate::api::{ + BarrierMode, ControlCode, FrameStream, PartitionPosition, PartitionSlot, ResolvedPosition, + SeekPosition, SubscriptionAssignment, TenantTraversalDepth, WireEvent, WireFrame, +}; +pub use crate::error::OffsetManagerError; +pub use types::{ + BatchHandlerOutcome, ConnectionDropReason, ConsumerBatching, ConsumerBuffering, + ConsumerCommitMode, ConsumerGroupRef, ConsumerHandler, ConsumerListenerSettings, + ConsumerProfile, ConsumerRetry, ConsumerRuntimeEvent, ConsumerRuntimeListener, + ConsumerSettings, ConsumerSettingsOverrides, ConsumerSlowDetection, EventBatch, EventTypeRef, + FilterEngineRef, HandlerOutcome, PartitionBufferState, PartitionBufferStateSnapshot, + PartitionProgress, RawEvent, SingleEventHandler, SingleEventHandlerAdapter, + SlowConsumerTrigger, SubscriptionFilterRef, SubscriptionInterest, TopicRef, +}; +#[cfg(feature = "db")] +pub use types::{TxConsumerHandler, TxSingleEventHandler, TxSingleEventHandlerAdapter}; diff --git a/gears/system/event-broker/event-broker-sdk/src/consumer/offset_manager.rs b/gears/system/event-broker/event-broker-sdk/src/consumer/offset_manager.rs new file mode 100644 index 000000000..2126a0be1 --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/src/consumer/offset_manager.rs @@ -0,0 +1,343 @@ +use std::collections::HashMap; +use std::sync::Mutex; + +use async_trait::async_trait; +#[cfg(feature = "db")] +use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, Set, sea_query::OnConflict}; +#[cfg(feature = "db")] +use toolkit_db::secure::{AccessScope, SecureEntityExt, SecureInsertExt}; + +use crate::api::ResolvedPosition; +use crate::error::OffsetManagerError; +use crate::ids::{ConsumerGroupId, TopicId}; + +type OffsetKey = (ConsumerGroupId, TopicId, u32); +type PartitionOffsetKey = (TopicId, u32); +type CommittedOffsets = HashMap; +type SeedOffsets = HashMap; + +#[cfg(feature = "db")] +mod offset_row { + use sea_orm::entity::prelude::*; + + #[derive(Clone, Debug, PartialEq, DeriveEntityModel)] + #[sea_orm(table_name = "evbk_consumer_offsets")] + pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub consumer_group_id: Uuid, + #[sea_orm(primary_key, auto_increment = false)] + pub topic_id: Uuid, + #[sea_orm(primary_key, auto_increment = false)] + pub partition: i32, + pub offset: i64, + pub updated_at: DateTimeUtc, + } + + #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] + pub enum Relation {} + + impl ActiveModelBehavior for ActiveModel {} + + impl toolkit_db::secure::ScopableEntity for Entity { + const IS_UNRESTRICTED: bool = true; + + fn tenant_col() -> Option { + None + } + + fn resource_col() -> Option { + None + } + + fn owner_col() -> Option { + None + } + + fn type_col() -> Option { + None + } + + fn resolve_property(_property: &str) -> Option { + None + } + } +} + +/// Policy applied when no committed cursor exists for an assigned partition. +/// Required argument to the constructors of all built-in offset stores. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Fallback { + Earliest, + Latest, +} + +impl From for ResolvedPosition { + fn from(f: Fallback) -> Self { + match f { + Fallback::Earliest => ResolvedPosition::Earliest, + Fallback::Latest => ResolvedPosition::Latest, + } + } +} + +#[cfg(feature = "db")] +pub const LOCAL_DB_OFFSET_STORE_MIGRATION_SQL: &str = r#" +CREATE TABLE IF NOT EXISTS evbk_consumer_offsets ( + consumer_group_id UUID NOT NULL, + topic_id UUID NOT NULL, + partition INTEGER NOT NULL, + offset BIGINT NOT NULL, + updated_at TIMESTAMP NOT NULL, + PRIMARY KEY (consumer_group_id, topic_id, partition) +); +"#; + +// ---- Traits ------------------------------------------------------------------ +// +// Offsets are a *client-side* concern: the consumer owns durable progress, and +// SEEK positions broker runtime cursor state for a subscription session. Reading +// "where do I start?" is universal ([`OffsetStore`]); persisting a processed +// offset is a separate capability whose *mechanism* is the variable axis - an +// eventual/batched commit ([`CommitOffset`]) or a transactional commit atomic +// with the caller's DB writes ([`CommitOffsetInTx`]). A store implements exactly +// one commit flavor; there is no manager that carries two. + +/// Resolves where each assigned `(group, topic, partition)` should start. +/// +/// `load_position(...)` is the single source of truth for "where should this +/// partition begin?": an exact last-processed offset (verbatim from the backing +/// store or a configured per-partition override) or a sentinel for the broker to +/// resolve. Read-only - persistence is [`CommitOffset`] / [`CommitOffsetInTx`]. +#[async_trait] +pub trait OffsetStore: Send + Sync { + async fn load_position( + &self, + group: &ConsumerGroupId, + topic: &TopicId, + partition: u32, + ) -> Result; +} + +/// Eventual / batched commit of a processed offset to the client store +/// (at-least-once). The dispatcher's auto-commit timer drives this. +#[async_trait] +pub trait CommitOffset: OffsetStore { + /// Persist `offset` as the last-processed offset for `(group, topic, partition)`. + async fn commit( + &self, + group: &ConsumerGroupId, + topic: &TopicId, + partition: u32, + offset: i64, + ) -> Result<(), OffsetManagerError>; +} + +/// Transactional commit: persist the offset atomically within the caller's DB +/// transaction (exactly-once with the handler's business writes). Implemented +/// only by stores that can join a caller-supplied transaction (e.g. +/// [`LocalDbOffsetManager`]). Requires the `db` feature. +#[cfg(feature = "db")] +#[async_trait] +pub trait CommitOffsetInTx: OffsetStore { + async fn commit_in_tx( + &self, + txn: &TX, + group: &ConsumerGroupId, + topic: &TopicId, + partition: u32, + offset: i64, + ) -> Result<(), OffsetManagerError> + where + TX: toolkit_db::secure::DBRunner + Sync; +} + +// ---- Built-in implementations ------------------------------------------------ + +/// DB-backed offset store. Implements [`OffsetStore`] + [`CommitOffsetInTx`] - +/// its purpose is the transactional commit (exactly-once with the handler's +/// writes). Cursor table: `evbk_consumer_offsets`. +/// Key: `(consumer_group_id UUID, topic_id UUID, partition INTEGER)`. +/// Requires the `db` feature. +#[cfg(feature = "db")] +pub struct LocalDbOffsetManager { + db: toolkit_db::Db, + fallback: Fallback, + overrides: SeedOffsets, +} + +#[cfg(feature = "db")] +impl LocalDbOffsetManager { + pub fn new(db: toolkit_db::Db, fallback: Fallback) -> Self { + Self { + db, + fallback, + overrides: HashMap::new(), + } + } + + /// Per-partition seed offsets, consulted only when the DB has no row. + pub fn with_overrides( + mut self, + overrides: impl IntoIterator, + ) -> Self { + self.overrides.extend(overrides); + self + } +} + +#[cfg(feature = "db")] +#[async_trait] +impl OffsetStore for LocalDbOffsetManager { + async fn load_position( + &self, + group: &ConsumerGroupId, + topic: &TopicId, + partition: u32, + ) -> Result { + let conn = self.db.conn().map_err(|err| { + OffsetManagerError::load_failed("open offset DB connection", err.to_string(), err) + })?; + + let row = offset_row::Entity::find() + .filter(offset_row::Column::ConsumerGroupId.eq(group.as_uuid())) + .filter(offset_row::Column::TopicId.eq(topic.as_uuid())) + .filter(offset_row::Column::Partition.eq(partition_to_i32(partition)?)) + .secure() + .scope_with(&AccessScope::allow_all()) + .one(&conn) + .await + .map_err(|err| { + OffsetManagerError::load_failed("load offset row", err.to_string(), err) + })?; + + if let Some(row) = row { + return Ok(ResolvedPosition::Exact(row.offset)); + } + + if let Some(&off) = self.overrides.get(&(*topic, partition)) { + return Ok(ResolvedPosition::Exact(off)); + } + Ok(self.fallback.into()) + } +} + +#[cfg(feature = "db")] +#[async_trait] +impl CommitOffsetInTx for LocalDbOffsetManager { + async fn commit_in_tx( + &self, + txn: &TX, + group: &ConsumerGroupId, + topic: &TopicId, + partition: u32, + offset: i64, + ) -> Result<(), OffsetManagerError> + where + TX: toolkit_db::secure::DBRunner + Sync, + { + let row = offset_row::ActiveModel { + consumer_group_id: Set(group.as_uuid()), + topic_id: Set(topic.as_uuid()), + partition: Set(partition_to_i32(partition)?), + offset: Set(offset), + updated_at: Set(chrono::Utc::now()), + }; + + offset_row::Entity::insert(row) + .secure() + .scope_unchecked(&AccessScope::allow_all()) + .map_err(|err| { + OffsetManagerError::persist_failed("scope offset upsert", err.to_string(), err) + })? + .on_conflict_raw( + OnConflict::columns([ + offset_row::Column::ConsumerGroupId, + offset_row::Column::TopicId, + offset_row::Column::Partition, + ]) + .update_columns([offset_row::Column::Offset, offset_row::Column::UpdatedAt]) + .to_owned(), + ) + .exec(txn) + .await + .map_err(|err| { + OffsetManagerError::persist_failed("upsert offset row", err.to_string(), err) + })?; + Ok(()) + } +} + +#[cfg(feature = "db")] +fn partition_to_i32(partition: u32) -> Result { + i32::try_from(partition).map_err(|_| { + OffsetManagerError::Internal(format!("partition {partition} exceeds i32 storage range")) + }) +} + +/// In-memory offset store for tests. Implements [`OffsetStore`] + [`CommitOffset`]; +/// `commit` persists to a map (so the test can assert round-trip). +pub struct InMemoryOffsetManager { + inner: Mutex, + fallback: Fallback, + overrides: SeedOffsets, +} + +impl InMemoryOffsetManager { + pub fn new(fallback: Fallback) -> Self { + Self { + inner: Mutex::new(HashMap::new()), + fallback, + overrides: HashMap::new(), + } + } + + pub fn with_overrides( + mut self, + overrides: impl IntoIterator, + ) -> Self { + self.overrides.extend(overrides); + self + } +} + +#[async_trait] +impl OffsetStore for InMemoryOffsetManager { + async fn load_position( + &self, + group: &ConsumerGroupId, + topic: &TopicId, + partition: u32, + ) -> Result { + let guard = self + .inner + .lock() + .map_err(|_| OffsetManagerError::Internal("mutex poisoned".into()))?; + if let Some(&stored) = guard.get(&(*group, *topic, partition)) { + return Ok(ResolvedPosition::Exact(stored)); + } + drop(guard); + if let Some(&off) = self.overrides.get(&(*topic, partition)) { + return Ok(ResolvedPosition::Exact(off)); + } + Ok(self.fallback.into()) + } +} + +#[async_trait] +impl CommitOffset for InMemoryOffsetManager { + async fn commit( + &self, + group: &ConsumerGroupId, + topic: &TopicId, + partition: u32, + offset: i64, + ) -> Result<(), OffsetManagerError> { + let mut guard = self + .inner + .lock() + .map_err(|_| OffsetManagerError::Internal("mutex poisoned".into()))?; + let entry = guard.entry((*group, *topic, partition)).or_insert(0); + *entry = (*entry).max(offset); + Ok(()) + } +} diff --git a/gears/system/event-broker/event-broker-sdk/src/consumer/offset_manager_tests.rs b/gears/system/event-broker/event-broker-sdk/src/consumer/offset_manager_tests.rs new file mode 100644 index 000000000..91c6dcaa0 --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/src/consumer/offset_manager_tests.rs @@ -0,0 +1,210 @@ +use std::error::Error; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; +use toolkit_gts::GTS_ID_PREFIX; + +use sea_orm::{ConnectionTrait, Database, Statement}; +use uuid::Uuid; + +use super::{ + CommitOffsetInTx, Fallback, LOCAL_DB_OFFSET_STORE_MIGRATION_SQL, LocalDbOffsetManager, + OffsetStore, ResolvedPosition, +}; +use crate::ids::{ConsumerGroupId, TopicId}; + +static DB_SEQ: AtomicU64 = AtomicU64::new(1); + +fn group(name: &str) -> ConsumerGroupId { + ConsumerGroupId::from_gts(&format!("{GTS_ID_PREFIX}cf.test.consumer.group.v1~{name}")) +} + +fn topic(name: &str) -> TopicId { + TopicId::from_gts(&format!("{GTS_ID_PREFIX}cf.test.events.topic.v1~{name}")) +} + +async fn db_with_offsets_table() -> (sea_orm::DatabaseConnection, toolkit_db::Db) { + let seq = DB_SEQ.fetch_add(1, Ordering::Relaxed); + let dsn = format!("sqlite:file:evbk_offsets_{seq}?mode=memory&cache=shared"); + let raw = Database::connect(&dsn).await.expect("raw sqlite connect"); + raw.execute(Statement::from_string( + raw.get_database_backend(), + LOCAL_DB_OFFSET_STORE_MIGRATION_SQL.to_owned(), + )) + .await + .expect("create offset table"); + + let db = toolkit_db::connect_db( + &dsn, + toolkit_db::ConnectOpts { + max_conns: Some(1), + ..toolkit_db::ConnectOpts::default() + }, + ) + .await + .expect("toolkit db connect"); + + (raw, db) +} + +#[tokio::test] +async fn local_db_load_position_returns_fallback_when_no_row_exists() { + let (_raw, db) = db_with_offsets_table().await; + let manager = LocalDbOffsetManager::new(db, Fallback::Latest); + + let pos = manager + .load_position(&group("billing"), &topic("orders"), 0) + .await + .expect("load position"); + + assert_eq!(pos, ResolvedPosition::Latest); +} + +#[tokio::test] +async fn local_db_load_position_uses_override_before_fallback() { + let (_raw, db) = db_with_offsets_table().await; + let orders = topic("orders"); + let manager = + LocalDbOffsetManager::new(db, Fallback::Earliest).with_overrides([((orders, 2), 41)]); + + let pos = manager + .load_position(&group("billing"), &orders, 2) + .await + .expect("load position"); + + assert_eq!(pos, ResolvedPosition::Exact(41)); +} + +#[tokio::test] +async fn local_db_commit_in_tx_upserts_and_load_reads_committed_row() { + let (_raw, db) = db_with_offsets_table().await; + let manager = Arc::new(LocalDbOffsetManager::new(db.clone(), Fallback::Earliest)); + let group = group("billing"); + let topic = topic("orders"); + let tx_manager = manager.clone(); + + db.transaction_ref(move |tx| { + Box::pin(async move { + tx_manager + .commit_in_tx(tx, &group, &topic, 3, 99) + .await + .map_err(|err| toolkit_db::DbError::InvalidConfig(err.to_string()))?; + Ok(()) + }) + }) + .await + .expect("commit transaction"); + + let pos = manager + .load_position(&group, &topic, 3) + .await + .expect("load committed position"); + + assert_eq!(pos, ResolvedPosition::Exact(99)); +} + +#[tokio::test] +async fn local_db_commit_in_tx_rollback_does_not_advance_position() { + let (_raw, db) = db_with_offsets_table().await; + let manager = Arc::new(LocalDbOffsetManager::new(db.clone(), Fallback::Earliest)); + let group = group("billing"); + let topic = topic("payments"); + let tx_manager = manager.clone(); + + let result: Result<(), toolkit_db::DbError> = db + .transaction_ref(move |tx| { + Box::pin(async move { + tx_manager + .commit_in_tx(tx, &group, &topic, 0, 12) + .await + .map_err(|err| toolkit_db::DbError::InvalidConfig(err.to_string()))?; + Err(toolkit_db::DbError::InvalidConfig( + "force rollback".to_owned(), + )) + }) + }) + .await; + + assert!(result.is_err()); + let pos = manager + .load_position(&group, &topic, 0) + .await + .expect("load position after rollback"); + + assert_eq!(pos, ResolvedPosition::Earliest); +} + +#[tokio::test] +async fn local_db_uuid_key_isolates_groups_topics_and_partitions() { + let (_raw, db) = db_with_offsets_table().await; + let manager = Arc::new(LocalDbOffsetManager::new(db.clone(), Fallback::Latest)); + let group_a = group("a"); + let group_b = group("b"); + let topic_a = topic("orders"); + let topic_b = topic("orders-archive"); + let tx_manager = manager.clone(); + + db.transaction_ref(move |tx| { + Box::pin(async move { + tx_manager + .commit_in_tx(tx, &group_a, &topic_a, 0, 10) + .await + .map_err(|err| toolkit_db::DbError::InvalidConfig(err.to_string()))?; + tx_manager + .commit_in_tx(tx, &group_a, &topic_a, 1, 11) + .await + .map_err(|err| toolkit_db::DbError::InvalidConfig(err.to_string()))?; + tx_manager + .commit_in_tx(tx, &group_b, &topic_a, 0, 20) + .await + .map_err(|err| toolkit_db::DbError::InvalidConfig(err.to_string()))?; + tx_manager + .commit_in_tx(tx, &group_a, &topic_b, 0, 30) + .await + .map_err(|err| toolkit_db::DbError::InvalidConfig(err.to_string()))?; + Ok(()) + }) + }) + .await + .expect("commit offsets"); + + assert_eq!( + manager.load_position(&group_a, &topic_a, 0).await.unwrap(), + ResolvedPosition::Exact(10) + ); + assert_eq!( + manager.load_position(&group_a, &topic_a, 1).await.unwrap(), + ResolvedPosition::Exact(11) + ); + assert_eq!( + manager.load_position(&group_b, &topic_a, 0).await.unwrap(), + ResolvedPosition::Exact(20) + ); + assert_eq!( + manager.load_position(&group_a, &topic_b, 0).await.unwrap(), + ResolvedPosition::Exact(30) + ); +} + +#[tokio::test] +async fn local_db_load_position_preserves_db_failure_source() { + let seq = DB_SEQ.fetch_add(1, Ordering::Relaxed); + let dsn = format!("sqlite:file:evbk_offsets_missing_{seq}?mode=memory&cache=shared"); + let _raw = Database::connect(&dsn).await.expect("raw sqlite connect"); + let db = toolkit_db::connect_db( + &dsn, + toolkit_db::ConnectOpts { + max_conns: Some(1), + ..toolkit_db::ConnectOpts::default() + }, + ) + .await + .expect("toolkit db connect"); + + let manager = LocalDbOffsetManager::new(db, Fallback::Earliest); + let err = manager + .load_position(&ConsumerGroupId::new(Uuid::new_v4()), &topic("missing"), 0) + .await + .expect_err("missing table should fail"); + + assert!(err.source().is_some(), "DB error source must be preserved"); +} diff --git a/gears/system/event-broker/event-broker-sdk/src/consumer/progress.rs b/gears/system/event-broker/event-broker-sdk/src/consumer/progress.rs new file mode 100644 index 000000000..4d77ffe60 --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/src/consumer/progress.rs @@ -0,0 +1,35 @@ +use crate::consumer::{BatchHandlerOutcome, RawEvent}; +use crate::error::EventBrokerError; + +pub(crate) fn processed_count_from_outcome( + outcome: &BatchHandlerOutcome, + batch_events: &[RawEvent], +) -> Result, EventBrokerError> { + match outcome { + BatchHandlerOutcome::Success => Ok(Some(batch_events.len())), + BatchHandlerOutcome::AdvanceThrough { offset } => { + processed_count_from_delivered_offset(*offset, batch_events).map(Some) + } + BatchHandlerOutcome::Retry { .. } => Ok(None), + } +} + +pub(crate) fn processed_count_from_delivered_offset( + offset: i64, + batch_events: &[RawEvent], +) -> Result { + batch_events + .iter() + .position(|event| event.offset == offset) + .map(|idx| idx + 1) + .ok_or_else(|| EventBrokerError::InvalidConsumerOptions { + detail: format!( + "offset {offset} is not present in delivered offsets {:?}", + batch_events + .iter() + .map(|event| event.offset) + .collect::>() + ), + instance: String::new(), + }) +} diff --git a/gears/system/event-broker/event-broker-sdk/src/consumer/progress_tests.rs b/gears/system/event-broker/event-broker-sdk/src/consumer/progress_tests.rs new file mode 100644 index 000000000..54c37c08a --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/src/consumer/progress_tests.rs @@ -0,0 +1,112 @@ +use chrono::Utc; +use toolkit_gts::gts_id; +use uuid::Uuid; + +use super::dispatcher::PartitionCursor; +use super::progress::{processed_count_from_delivered_offset, processed_count_from_outcome}; +use super::{BatchHandlerOutcome, RawEvent}; + +fn event_with_offset(offset: i64) -> RawEvent { + RawEvent { + id: Uuid::new_v4(), + type_id: gts_id!("cf.core.events.event_type.v1~example.progress.test.x.v1").to_owned(), + topic: gts_id!("cf.core.events.topic.v1~example.progress.test.x.v1").to_owned(), + tenant_id: Uuid::nil(), + subject: format!("event-{offset}"), + subject_type: "test".to_owned(), + partition_key: None, + partition: 0, + sequence: offset, + offset, + occurred_at: Utc::now(), + sequence_time: Utc::now(), + trace_parent: None, + data: serde_json::json!({ "offset": offset }), + } +} + +fn delivered_sparse_batch() -> Vec { + [10, 17, 19].into_iter().map(event_with_offset).collect() +} + +#[test] +fn advance_through_counts_delivered_prefix_for_sparse_offsets() { + let events = delivered_sparse_batch(); + + let processed = + processed_count_from_outcome(&BatchHandlerOutcome::AdvanceThrough { offset: 17 }, &events) + .expect("handled offset is in delivered batch"); + + assert_eq!(processed, Some(2)); +} + +#[test] +fn full_batch_success_counts_all_delivered_events() { + let events = delivered_sparse_batch(); + + let processed = processed_count_from_outcome(&BatchHandlerOutcome::Success, &events) + .expect("success always maps to full batch"); + + assert_eq!(processed, Some(3)); +} + +#[test] +fn invalid_advance_through_offset_is_rejected_without_progress_count() { + let events = delivered_sparse_batch(); + + let err = + processed_count_from_outcome(&BatchHandlerOutcome::AdvanceThrough { offset: 11 }, &events) + .expect_err("offset not delivered in this batch must be rejected"); + + assert!(err.to_string().contains("not present in delivered offsets")); +} + +#[test] +fn retry_outcome_counts_no_delivered_progress() { + let events = delivered_sparse_batch(); + + let processed = processed_count_from_outcome( + &BatchHandlerOutcome::Retry { + reason: "try later".to_owned(), + }, + &events, + ) + .expect("retry is valid"); + + assert_eq!(processed, None); +} + +#[test] +fn cursor_advances_to_delivered_offset_not_first_offset_plus_count() { + let events = delivered_sparse_batch(); + let mut cursor = PartitionCursor::default(); + + let processed = + processed_count_from_outcome(&BatchHandlerOutcome::AdvanceThrough { offset: 17 }, &events) + .expect("handled offset is in delivered batch") + .expect("partial outcome has progress"); + let frontier = cursor.advance_through_delivered_prefix(&events[..processed]); + + assert_eq!(frontier, 17); + assert_eq!(cursor.latest_offset(), 17); +} + +#[test] +fn tx_committed_offset_counts_delivered_prefix_for_sparse_offsets() { + let events = delivered_sparse_batch(); + + let processed = processed_count_from_delivered_offset(17, &events) + .expect("tx committed offset is in delivered batch"); + + assert_eq!(processed, 2); +} + +#[test] +fn tx_committed_offset_rejects_offsets_outside_delivered_batch() { + let events = delivered_sparse_batch(); + + let err = processed_count_from_delivered_offset(20, &events) + .expect_err("tx committed offset must be delivered in the current batch"); + + assert!(err.to_string().contains("not present in delivered offsets")); +} diff --git a/gears/system/event-broker/event-broker-sdk/src/consumer/runtime.rs b/gears/system/event-broker/event-broker-sdk/src/consumer/runtime.rs new file mode 100644 index 000000000..c16ada371 --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/src/consumer/runtime.rs @@ -0,0 +1,473 @@ +use std::sync::Arc; + +use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; + +use crate::consumer::builder::ConsumerBuilder; +#[cfg(feature = "db")] +use crate::consumer::builder::WithTx; +use crate::consumer::dispatcher::SlotDispatcher; +#[cfg(feature = "db")] +use crate::consumer::dispatcher::TxSlotDispatcher; +use crate::consumer::offset_manager::CommitOffset; +use crate::consumer::{ + BatchHandlerOutcome, ConsumerGroupRef, ConsumerHandler, SingleEventHandler, + SingleEventHandlerAdapter, +}; +#[cfg(feature = "db")] +use crate::consumer::{CommitOffsetInTx, TxConsumerHandler, TxSingleEventHandlerAdapter}; +use crate::consumer::{ConsumerRoute, EventTypeRef, TopicRef}; +use crate::error::{ConsumerError, EventBrokerError}; +use crate::ids::{EventTypeId, SubscriptionId, TopicId}; + +struct SlotHandle { + subscription_id: Arc>>, + cancel: CancellationToken, + join: JoinHandle>, +} + +/// Running consumer handle. Carries N subscription task handles. +pub struct Consumer { + slots: Vec, +} + +/// Public lifecycle handle returned by `ConsumerReady::start()`. +pub struct ConsumerHandle { + consumer: Consumer, +} + +pub(crate) struct RoutedHandlerEntry { + route: ConsumerRoute, + handler: Arc, +} + +pub(crate) struct RoutedBatchHandler { + default_handler: Option>, + routes: Vec, +} + +impl RoutedBatchHandler { + pub(crate) fn new( + default_handler: Option>, + routes: Vec, + route_handlers: Vec>, + ) -> Result { + if routes.len() != route_handlers.len() { + return Err(EventBrokerError::Internal(format!( + "routed consumer has {} route descriptors but {} handlers", + routes.len(), + route_handlers.len() + ))); + } + + let routes = routes + .into_iter() + .zip(route_handlers) + .map(|(route, handler)| RoutedHandlerEntry { route, handler }) + .collect(); + Ok(Self { + default_handler, + routes, + }) + } +} + +#[cfg(feature = "db")] +pub(crate) struct TxRoutedHandlerEntry { + route: ConsumerRoute, + handler: Arc>, +} + +#[cfg(feature = "db")] +pub(crate) struct TxRoutedBatchHandler { + default_handler: Option>>, + routes: Vec>, +} + +#[cfg(feature = "db")] +impl TxRoutedBatchHandler { + pub(crate) fn new( + default_handler: Option>>, + routes: Vec, + route_handlers: Vec>>, + ) -> Result { + if routes.len() != route_handlers.len() { + return Err(EventBrokerError::Internal(format!( + "transactional routed consumer has {} route descriptors but {} handlers", + routes.len(), + route_handlers.len() + ))); + } + + let routes = routes + .into_iter() + .zip(route_handlers) + .map(|(route, handler)| TxRoutedHandlerEntry { route, handler }) + .collect(); + Ok(Self { + default_handler, + routes, + }) + } +} + +#[cfg(feature = "db")] +#[async_trait::async_trait] +impl TxConsumerHandler for TxRoutedBatchHandler +where + OM: CommitOffsetInTx + 'static, +{ + async fn handle_batch( + &self, + batch: &crate::consumer::EventBatch<'_>, + attempts: u16, + commit: crate::consumer::TxCommitHandle, + ) -> Result { + let Some(event) = batch.next_event() else { + return Ok(crate::consumer::HandlerOutcome::Success); + }; + + if let Some(entry) = self + .routes + .iter() + .find(|entry| route_matches(&entry.route, event)) + { + return entry.handler.handle_batch(batch, attempts, commit).await; + } + + if let Some(default_handler) = &self.default_handler { + return default_handler.handle_batch(batch, attempts, commit).await; + } + + Err(EventBrokerError::InvalidConsumerOptions { + detail: format!( + "no transactional consumer route matched topic '{}' and event type '{}'", + event.topic, event.type_id + ), + instance: String::new(), + }) + } +} + +#[async_trait::async_trait] +impl ConsumerHandler for RoutedBatchHandler { + async fn handle_batch( + &self, + batch: &crate::consumer::EventBatch<'_>, + attempts: u16, + ) -> Result { + let Some(event) = batch.next_event() else { + return Ok(BatchHandlerOutcome::Success); + }; + + if let Some(entry) = self + .routes + .iter() + .find(|entry| route_matches(&entry.route, event)) + { + return entry.handler.handle_batch(batch, attempts).await; + } + + if let Some(default_handler) = &self.default_handler { + return default_handler.handle_batch(batch, attempts).await; + } + + Err(EventBrokerError::InvalidConsumerOptions { + detail: format!( + "no consumer route matched topic '{}' and event type '{}'", + event.topic, event.type_id + ), + instance: String::new(), + }) + } +} + +fn route_matches(route: &ConsumerRoute, event: &crate::consumer::RawEvent) -> bool { + topic_matches(&route.topic, &event.topic) + && route + .event_type + .as_ref() + .is_none_or(|event_type| event_type_matches(event_type, &event.type_id)) +} + +fn topic_matches(route_topic: &TopicRef, event_topic: &str) -> bool { + match route_topic { + TopicRef::Gts(gts) => gts == event_topic, + TopicRef::Id(id) => *id == TopicId::from_gts(event_topic), + } +} + +fn event_type_matches(route_type: &EventTypeRef, event_type: &str) -> bool { + match route_type { + EventTypeRef::Gts(gts) => gts == event_type, + EventTypeRef::Id(id) => *id == EventTypeId::from_gts(event_type), + EventTypeRef::GtsPattern(pattern) => gts_pattern_matches(pattern, event_type), + } +} + +fn gts_pattern_matches(pattern: &str, value: &str) -> bool { + if pattern == "*" { + return true; + } + pattern + .strip_suffix('*') + .map_or(pattern == value, |prefix| value.starts_with(prefix)) +} + +impl ConsumerHandle { + pub(crate) fn from_consumer(consumer: Consumer) -> Self { + Self { consumer } + } + + /// Gracefully stop the consumer and await all subscription slots. + pub async fn stop(self) -> Result<(), EventBrokerError> { + self.consumer.shutdown().await + } + + /// Current subscription ids (one per active parallelism slot). + pub fn subscription_ids(&self) -> Vec { + self.consumer.subscription_ids() + } +} + +impl Consumer { + pub fn new(parallelism: u32) -> Self { + let _ = parallelism; + Self { slots: Vec::new() } + } + + pub(crate) async fn new_with_slots( + builder: ConsumerBuilder>, + handler: H, + ) -> Result + where + M: CommitOffset + 'static, + H: SingleEventHandler + 'static, + { + let handler = SingleEventHandlerAdapter::new(Arc::new(handler)); + Self::new_with_batch_slots(builder, handler).await + } + + pub(crate) async fn new_with_batch_slots( + builder: ConsumerBuilder>, + handler: H, + ) -> Result + where + M: CommitOffset + 'static, + H: ConsumerHandler + 'static, + { + Self::new_with_batch_slots_with_cancel(builder, handler, None).await + } + + async fn new_with_batch_slots_with_cancel( + builder: ConsumerBuilder>, + handler: H, + shared_cancel: Option, + ) -> Result + where + M: CommitOffset + 'static, + H: ConsumerHandler + 'static, + { + let settings = builder.effective_settings()?; + let parallelism = builder.parallelism; + let broker = builder.broker.ok_or_else(|| { + EventBrokerError::Internal( + "ConsumerBuilder: broker not wired; use EventBroker::consumer_builder()".into(), + ) + })?; + let handler = Arc::new(handler); + let offset_manager = Arc::new(builder.offset_manager.0); + + let mut slots = Vec::with_capacity(parallelism as usize); + let ctx_arc = Arc::new(builder.security_context); + + for idx in 0..parallelism { + let sub_id = Arc::new(tokio::sync::Mutex::new(None)); + let cancel = shared_cancel.clone().unwrap_or_default(); + + let dispatcher = SlotDispatcher { + slot_idx: idx, + broker: broker.clone(), + offset_manager: offset_manager.clone(), + handler: handler.clone(), + group_ref: builder + .group + .clone() + .unwrap_or(ConsumerGroupRef::AutoAnonymous { + alias: builder.client_agent.clone(), + }), + topics: builder.topics.clone(), + subscription_interests: builder.subscription_interests.clone(), + tenant_id: builder.tenant_id, + tenant_depth: builder.tenant_depth, + barrier_mode: builder.barrier_mode, + event_type_patterns: builder.event_type_patterns.clone(), + client_agent: builder.client_agent.clone(), + session_timeout: builder.session_timeout, + filter: builder.filter.clone(), + heartbeat_drop_threshold: builder.heartbeat_drop_threshold, + retry_base: settings.retry.base_delay, + retry_max: settings.retry.max_delay, + commit_mode: builder.commit_mode, + partition_buffer_capacity: settings.buffering.partition_capacity, + buffer_high_watermark: settings.buffering.high_watermark, + buffer_low_watermark: settings.buffering.low_watermark, + batch_max_events: settings.batching.max_events, + handler_latency: settings.slow_detection.handler_latency, + handler_strikes: settings.slow_detection.handler_strikes, + listeners: builder.listeners.clone(), + listener_timeout: settings.listener.timeout, + max_rejoin_attempts: 16, + subscription_id: sub_id.clone(), + }; + + let task_ctx = ctx_arc.clone(); + let task_cancel = cancel.clone(); + let join = tokio::spawn(async move { dispatcher.run(task_ctx, task_cancel).await }); + + slots.push(SlotHandle { + subscription_id: sub_id, + cancel, + join, + }); + } + + Ok(Self { slots }) + } + + pub(crate) async fn new_with_routed_slots( + builder: ConsumerBuilder>, + default_handler: Option>, + routes: Vec, + route_handlers: Vec>, + ) -> Result + where + M: CommitOffset + 'static, + { + let handler = RoutedBatchHandler::new(default_handler, routes, route_handlers)?; + + Self::new_with_batch_slots(builder, handler).await + } + + #[cfg(feature = "db")] + pub(crate) async fn new_with_tx_slots( + builder: ConsumerBuilder>, + handler: H, + ) -> Result + where + M: CommitOffsetInTx + 'static, + H: crate::consumer::TxSingleEventHandler + 'static, + { + let handler = TxSingleEventHandlerAdapter::new(Arc::new(handler)); + Self::new_with_tx_batch_slots(builder, handler).await + } + + #[cfg(feature = "db")] + pub(crate) async fn new_with_tx_batch_slots( + builder: ConsumerBuilder>, + handler: H, + ) -> Result + where + M: CommitOffsetInTx + 'static, + H: TxConsumerHandler + 'static, + { + let settings = builder.effective_settings()?; + let parallelism = builder.parallelism; + let broker = builder.broker.ok_or_else(|| { + EventBrokerError::Internal( + "ConsumerBuilder: broker not wired; use EventBroker::consumer_builder()".into(), + ) + })?; + let handler = Arc::new(handler); + let offset_manager = Arc::new(builder.offset_manager.0); + + let mut slots = Vec::with_capacity(parallelism as usize); + let ctx_arc = Arc::new(builder.security_context); + + for idx in 0..parallelism { + let sub_id = Arc::new(tokio::sync::Mutex::new(None)); + let cancel = CancellationToken::new(); + + let dispatcher = TxSlotDispatcher { + slot_idx: idx, + broker: broker.clone(), + offset_manager: offset_manager.clone(), + handler: handler.clone(), + group_ref: builder + .group + .clone() + .unwrap_or(ConsumerGroupRef::AutoAnonymous { + alias: builder.client_agent.clone(), + }), + topics: builder.topics.clone(), + subscription_interests: builder.subscription_interests.clone(), + tenant_id: builder.tenant_id, + tenant_depth: builder.tenant_depth, + barrier_mode: builder.barrier_mode, + event_type_patterns: builder.event_type_patterns.clone(), + client_agent: builder.client_agent.clone(), + session_timeout: builder.session_timeout, + filter: builder.filter.clone(), + heartbeat_drop_threshold: builder.heartbeat_drop_threshold, + retry_base: settings.retry.base_delay, + retry_max: settings.retry.max_delay, + partition_buffer_capacity: settings.buffering.partition_capacity, + buffer_high_watermark: settings.buffering.high_watermark, + buffer_low_watermark: settings.buffering.low_watermark, + batch_max_events: settings.batching.max_events, + handler_latency: settings.slow_detection.handler_latency, + handler_strikes: settings.slow_detection.handler_strikes, + listeners: builder.listeners.clone(), + listener_timeout: settings.listener.timeout, + max_rejoin_attempts: 16, + subscription_id: sub_id.clone(), + }; + + let task_ctx = ctx_arc.clone(); + let task_cancel = cancel.clone(); + let join = tokio::spawn(async move { dispatcher.run(task_ctx, task_cancel).await }); + + slots.push(SlotHandle { + subscription_id: sub_id, + cancel, + join, + }); + } + + Ok(Self { slots }) + } + + #[cfg(feature = "db")] + pub(crate) async fn new_with_tx_routed_slots( + builder: ConsumerBuilder>, + default_handler: Option>>, + routes: Vec, + route_handlers: Vec>>, + ) -> Result + where + M: CommitOffsetInTx + 'static, + { + let handler = TxRoutedBatchHandler::new(default_handler, routes, route_handlers)?; + + Self::new_with_tx_batch_slots(builder, handler).await + } + + /// Graceful shutdown: cancel all tasks and await drain. + pub async fn shutdown(mut self) -> Result<(), EventBrokerError> { + for slot in &self.slots { + slot.cancel.cancel(); + } + for slot in self.slots.drain(..) { + let _ = slot.join.await; + } + Ok(()) + } + + /// Current subscription ids (one per parallelism slot). + pub fn subscription_ids(&self) -> Vec { + self.slots + .iter() + .filter_map(|s| s.subscription_id.try_lock().ok().and_then(|g| *g)) + .collect() + } +} diff --git a/gears/system/event-broker/event-broker-sdk/src/consumer/types.rs b/gears/system/event-broker/event-broker-sdk/src/consumer/types.rs new file mode 100644 index 000000000..9de7d0a4c --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/src/consumer/types.rs @@ -0,0 +1,829 @@ +use std::sync::Arc; +use std::time::Duration; +use toolkit_gts::gts_id; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[cfg(feature = "db")] +use super::commit::TxCommitHandle; +#[cfg(feature = "db")] +use super::offset_manager::CommitOffsetInTx; +use crate::api::{AssignedPartition, ResolvedPosition}; +use crate::error::{ConsumerError, EventBrokerError}; +use crate::ids::{ConsumerGroupId, EventTypeId, SubscriptionId, TopicId}; + +/// Raw event delivered to v1 handlers. `data` is untyped JSON; +/// typed dispatch is deferred to v2. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RawEvent { + pub id: Uuid, + pub type_id: String, + pub topic: String, + pub tenant_id: Uuid, + pub subject: String, + pub subject_type: String, + pub partition_key: Option, + pub partition: u32, + pub sequence: i64, + pub offset: i64, + pub occurred_at: DateTime, + pub sequence_time: DateTime, + pub trace_parent: Option, + pub data: serde_json::Value, +} + +/// Handler outcome without DLQ - `Reject` is structurally absent. +#[derive(Debug, Clone)] +pub enum HandlerOutcome { + Success, + Retry { reason: String }, +} + +/// Batch handler outcome. Partial progress is always the contiguous delivered +/// prefix through the given offset. +#[derive(Debug, Clone)] +pub enum BatchHandlerOutcome { + Success, + AdvanceThrough { offset: i64 }, + Retry { reason: String }, +} + +/// Batch cursor over events from one topic partition. +pub struct EventBatch<'a> { + events: &'a [RawEvent], + cursor: usize, +} + +impl<'a> EventBatch<'a> { + pub fn new(events: &'a [RawEvent]) -> Self { + Self { events, cursor: 0 } + } + + pub fn next_event(&self) -> Option<&'a RawEvent> { + self.events.get(self.cursor) + } + + pub fn next_chunk(&self, n: usize) -> &'a [RawEvent] { + let end = self.cursor.saturating_add(n).min(self.events.len()); + &self.events[self.cursor..end] + } + + pub fn iter(&self) -> impl Iterator { + self.events.iter() + } + + pub fn len(&self) -> usize { + self.events.len() + } + + pub fn is_empty(&self) -> bool { + self.events.is_empty() + } +} + +/// Tracks the committed frontier for one topic partition. +#[derive(Debug, Clone)] +pub(crate) struct PartitionFrontier { + committed: i64, +} + +impl PartitionFrontier { + pub(crate) fn new(committed: i64) -> Self { + Self { committed } + } + + pub(crate) fn committed(&self) -> i64 { + self.committed + } +} + +/// Topic reference accepted by the consumer builder before registry resolution. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum TopicRef { + Id(TopicId), + Gts(String), +} + +impl TopicRef { + pub fn id(id: TopicId) -> Self { + Self::Id(id) + } + + pub fn gts(gts: impl Into) -> Self { + Self::Gts(gts.into()) + } +} + +impl From for TopicRef { + fn from(value: TopicId) -> Self { + Self::Id(value) + } +} + +/// Event-type reference accepted by the consumer builder before registry resolution. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum EventTypeRef { + Id(EventTypeId), + Gts(String), + GtsPattern(String), +} + +impl EventTypeRef { + pub fn id(id: EventTypeId) -> Self { + Self::Id(id) + } + + pub fn gts(gts: impl Into) -> Self { + Self::Gts(gts.into()) + } + + pub fn gts_pattern(pattern: impl Into) -> Self { + Self::GtsPattern(pattern.into()) + } +} + +impl From for EventTypeRef { + fn from(value: EventTypeId) -> Self { + Self::Id(value) + } +} + +/// Consumer-group reference for the builder. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum ConsumerGroupRef { + Id(ConsumerGroupId), + Gts(String), + AutoAnonymous { alias: String }, +} + +impl ConsumerGroupRef { + pub fn id(id: ConsumerGroupId) -> Self { + Self::Id(id) + } + + pub fn existing(id: ConsumerGroupId) -> Self { + Self::Id(id) + } + + pub fn gts(gts: impl Into) -> Self { + Self::Gts(gts.into()) + } + + pub fn auto_anonymous(alias: impl Into) -> Self { + Self::AutoAnonymous { + alias: alias.into(), + } + } +} + +impl From for ConsumerGroupRef { + fn from(value: ConsumerGroupId) -> Self { + Self::Id(value) + } +} + +/// Broker-side filter engine reference accepted before registry resolution. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum FilterEngineRef { + Id(Uuid), + Gts(String), +} + +impl FilterEngineRef { + pub fn id(id: Uuid) -> Self { + Self::Id(id) + } + + pub fn gts(gts: impl Into) -> Self { + Self::Gts(gts.into()) + } +} + +/// Per-interest imperative subscription filter. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct SubscriptionFilterRef { + pub engine: FilterEngineRef, + pub expression: String, +} + +impl SubscriptionFilterRef { + pub fn new(engine: FilterEngineRef, expression: impl Into) -> Self { + Self { + engine, + expression: expression.into(), + } + } + + pub fn cel(expression: impl Into) -> Self { + Self::new( + FilterEngineRef::gts(gts_id!( + "cf.core.events.filter.v1~cf.core.expression.cel.v1" + )), + expression, + ) + } +} + +/// Topic-scoped consumer interest. Event type selectors and filters belong to +/// exactly one topic. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct SubscriptionInterest { + pub topic: TopicRef, + pub event_types: Vec, + pub filter: Option, +} + +impl SubscriptionInterest { + pub fn builder() -> SubscriptionInterestBuilder { + SubscriptionInterestBuilder::default() + } +} + +#[derive(Default)] +pub struct SubscriptionInterestBuilder { + topic: Option, + event_types: Vec, + filter: Option, +} + +impl SubscriptionInterestBuilder { + pub fn topic(mut self, topic: impl Into) -> Self { + self.topic = Some(topic.into()); + self + } + + pub fn types(mut self, event_types: I) -> Self + where + I: IntoIterator, + { + self.event_types.extend(event_types); + self + } + + pub fn filter(mut self, filter: SubscriptionFilterRef) -> Self { + self.filter = Some(filter); + self + } + + pub fn build(self) -> Result { + let topic = self + .topic + .ok_or_else(|| EventBrokerError::InvalidConsumerOptions { + detail: "subscription interest requires a topic".to_owned(), + instance: String::new(), + })?; + if self.event_types.is_empty() { + return Err(EventBrokerError::InvalidConsumerOptions { + detail: "subscription interest requires at least one event type selector" + .to_owned(), + instance: String::new(), + }); + } + Ok(SubscriptionInterest { + topic, + event_types: self.event_types, + filter: self.filter, + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConsumerProfile { + pub buffering: ConsumerBuffering, + pub batching: ConsumerBatching, + pub slow_detection: ConsumerSlowDetection, + pub retry: ConsumerRetry, + pub listener: ConsumerListenerSettings, +} + +impl ConsumerProfile { + pub fn default_profile() -> Self { + Self { + buffering: ConsumerBuffering { + partition_capacity: 256, + high_watermark: 205, + low_watermark: 128, + }, + batching: ConsumerBatching { + max_events: 1, + max_wait: Duration::from_millis(0), + }, + slow_detection: ConsumerSlowDetection { + handler_latency: Duration::from_secs(5), + handler_strikes: 3, + }, + retry: ConsumerRetry { + base_delay: Duration::from_secs(1), + max_delay: Duration::from_secs(60), + }, + listener: ConsumerListenerSettings { + timeout: Duration::from_millis(250), + channel_capacity: 1024, + }, + } + } + + pub fn low_latency() -> Self { + Self { + buffering: ConsumerBuffering { + partition_capacity: 128, + high_watermark: 96, + low_watermark: 48, + }, + batching: ConsumerBatching { + max_events: 1, + max_wait: Duration::from_millis(0), + }, + slow_detection: ConsumerSlowDetection { + handler_latency: Duration::from_secs(1), + handler_strikes: 2, + }, + retry: ConsumerRetry { + base_delay: Duration::from_millis(100), + max_delay: Duration::from_secs(5), + }, + listener: ConsumerListenerSettings { + timeout: Duration::from_millis(100), + channel_capacity: 2048, + }, + } + } + + pub fn high_throughput() -> Self { + Self { + buffering: ConsumerBuffering { + partition_capacity: 1024, + high_watermark: 819, + low_watermark: 512, + }, + batching: ConsumerBatching { + max_events: 128, + max_wait: Duration::from_millis(500), + }, + slow_detection: ConsumerSlowDetection { + handler_latency: Duration::from_secs(10), + handler_strikes: 5, + }, + retry: ConsumerRetry { + base_delay: Duration::from_secs(1), + max_delay: Duration::from_secs(120), + }, + listener: ConsumerListenerSettings { + timeout: Duration::from_millis(500), + channel_capacity: 4096, + }, + } + } + + pub fn replay() -> Self { + Self { + buffering: ConsumerBuffering { + partition_capacity: 4096, + high_watermark: 3584, + low_watermark: 2048, + }, + batching: ConsumerBatching { + max_events: 512, + max_wait: Duration::from_secs(1), + }, + slow_detection: ConsumerSlowDetection { + handler_latency: Duration::from_secs(60), + handler_strikes: 10, + }, + retry: ConsumerRetry { + base_delay: Duration::from_secs(2), + max_delay: Duration::from_secs(300), + }, + listener: ConsumerListenerSettings { + timeout: Duration::from_millis(500), + channel_capacity: 1024, + }, + } + } + + pub fn relaxed() -> Self { + Self { + buffering: ConsumerBuffering { + partition_capacity: 128, + high_watermark: 102, + low_watermark: 64, + }, + batching: ConsumerBatching { + max_events: 16, + max_wait: Duration::from_secs(2), + }, + slow_detection: ConsumerSlowDetection { + handler_latency: Duration::from_secs(30), + handler_strikes: 5, + }, + retry: ConsumerRetry { + base_delay: Duration::from_secs(5), + max_delay: Duration::from_secs(300), + }, + listener: ConsumerListenerSettings { + timeout: Duration::from_millis(250), + channel_capacity: 256, + }, + } + } +} + +impl Default for ConsumerProfile { + fn default() -> Self { + Self::default_profile() + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConsumerSettings { + pub buffering: ConsumerBuffering, + pub batching: ConsumerBatching, + pub slow_detection: ConsumerSlowDetection, + pub retry: ConsumerRetry, + pub listener: ConsumerListenerSettings, +} + +impl ConsumerSettings { + pub fn from_profile(profile: ConsumerProfile) -> Self { + Self { + buffering: profile.buffering, + batching: profile.batching, + slow_detection: profile.slow_detection, + retry: profile.retry, + listener: profile.listener, + } + } + + pub fn resolve(profile: ConsumerProfile, overrides: ConsumerSettingsOverrides) -> Self { + Self { + buffering: overrides.buffering.unwrap_or(profile.buffering), + batching: overrides.batching.unwrap_or(profile.batching), + slow_detection: overrides.slow_detection.unwrap_or(profile.slow_detection), + retry: overrides.retry.unwrap_or(profile.retry), + listener: overrides.listener.unwrap_or(profile.listener), + } + } + + pub fn validate(&self) -> Result<(), EventBrokerError> { + if self.buffering.partition_capacity == 0 { + return Err(EventBrokerError::InvalidConsumerOptions { + detail: "partition buffer capacity must be greater than zero".to_owned(), + instance: String::new(), + }); + } + if self.buffering.high_watermark > self.buffering.partition_capacity { + return Err(EventBrokerError::InvalidConsumerOptions { + detail: "buffer high watermark must not exceed partition capacity".to_owned(), + instance: String::new(), + }); + } + if self.buffering.low_watermark > self.buffering.high_watermark { + return Err(EventBrokerError::InvalidConsumerOptions { + detail: "buffer low watermark must not exceed high watermark".to_owned(), + instance: String::new(), + }); + } + if self.batching.max_events == 0 { + return Err(EventBrokerError::InvalidConsumerOptions { + detail: "batching max_events must be greater than zero".to_owned(), + instance: String::new(), + }); + } + if self.slow_detection.handler_strikes == 0 { + return Err(EventBrokerError::InvalidConsumerOptions { + detail: "slow detection handler_strikes must be greater than zero".to_owned(), + instance: String::new(), + }); + } + if self.retry.max_delay < self.retry.base_delay { + return Err(EventBrokerError::InvalidConsumerOptions { + detail: "retry max_delay must be greater than or equal to base_delay".to_owned(), + instance: String::new(), + }); + } + if self.listener.channel_capacity == 0 { + return Err(EventBrokerError::InvalidConsumerOptions { + detail: "listener channel capacity must be greater than zero".to_owned(), + instance: String::new(), + }); + } + if self.listener.timeout.is_zero() { + return Err(EventBrokerError::InvalidConsumerOptions { + detail: "listener timeout must be greater than zero".to_owned(), + instance: String::new(), + }); + } + Ok(()) + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct ConsumerSettingsOverrides { + pub buffering: Option, + pub batching: Option, + pub slow_detection: Option, + pub retry: Option, + pub listener: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ConsumerBuffering { + pub partition_capacity: usize, + pub high_watermark: usize, + pub low_watermark: usize, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ConsumerBatching { + pub max_events: usize, + pub max_wait: Duration, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ConsumerSlowDetection { + pub handler_latency: Duration, + pub handler_strikes: u16, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ConsumerRetry { + pub base_delay: Duration, + pub max_delay: Duration, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConsumerCommitMode { + Auto { interval: Duration }, + Manual, +} + +impl ConsumerCommitMode { + pub fn auto(interval: Duration) -> Self { + Self::Auto { interval } + } + + pub fn manual() -> Self { + Self::Manual + } +} + +impl Default for ConsumerCommitMode { + fn default() -> Self { + Self::Auto { + interval: Duration::from_secs(20), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ConsumerListenerSettings { + pub timeout: Duration, + pub channel_capacity: usize, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PartitionBufferState { + Connected, + SlowDetected, + ConnectionDropped, + Draining, + Rejoining, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SlowConsumerTrigger { + BufferHighWatermark, + HandlerLatencyStrikes, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PartitionBufferStateSnapshot { + pub group_id: ConsumerGroupId, + pub subscription_id: SubscriptionId, + pub topic_id: TopicId, + pub topic: String, + pub partition: u32, + pub state: PartitionBufferState, + pub trigger: Option, + pub buffered_count: usize, + pub capacity: usize, + pub latest_observed_offset: Option, + pub last_delivered_offset: Option, + pub consecutive_slow_handlers: u16, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ConnectionDropReason { + SlowConsumer { + topic_id: TopicId, + topic: String, + partition: u32, + trigger: SlowConsumerTrigger, + }, + HeartbeatThreshold, + StreamTerminated, + Transport, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PartitionProgress { + pub topic_id: TopicId, + pub topic: String, + pub partition: u32, + pub offset: i64, +} + +#[derive(Debug, Clone)] +pub enum ConsumerRuntimeEvent { + SubscriptionJoining { + group_id: ConsumerGroupId, + }, + SubscriptionStarted { + group_id: ConsumerGroupId, + subscription_id: SubscriptionId, + assigned: Vec, + }, + SubscriptionRejoining { + group_id: ConsumerGroupId, + previous_subscription_id: SubscriptionId, + }, + SubscriptionTerminated { + subscription_id: SubscriptionId, + reason: ConnectionDropReason, + }, + SubscriptionConnectionDropped { + subscription_id: SubscriptionId, + reason: ConnectionDropReason, + affected: Vec, + }, + AssignmentChanged { + subscription_id: SubscriptionId, + assigned: Vec, + }, + ProgressAdvanced { + subscription_id: SubscriptionId, + progress: Vec, + }, + PartitionBufferStateChanged { + state: PartitionBufferStateSnapshot, + }, + HandlerBatchStarted { + topic_id: TopicId, + topic: String, + partition: u32, + len: usize, + }, + HandlerBatchCompleted { + topic_id: TopicId, + topic: String, + partition: u32, + outcome: BatchHandlerOutcome, + }, + HandlerFailed { + topic_id: TopicId, + topic: String, + partition: u32, + error: String, + }, + OffsetLoaded { + topic_id: TopicId, + topic: String, + partition: u32, + position: ResolvedPosition, + }, + OffsetCommitted { + topic_id: TopicId, + topic: String, + partition: u32, + offset: i64, + }, + RetryScheduled { + topic_id: TopicId, + topic: String, + partition: u32, + attempt: u16, + delay: Duration, + }, +} + +#[async_trait::async_trait] +pub trait ConsumerRuntimeListener: Send + Sync { + async fn on_consumer_event(&self, event: &ConsumerRuntimeEvent) -> Result<(), ConsumerError>; +} + +/// Single-event non-transactional handler. Offset progress is declared by the +/// returned outcome and the SDK adapter maps success to the delivered event offset. +#[async_trait::async_trait] +pub trait SingleEventHandler: Send + Sync { + async fn handle(&self, event: RawEvent, attempts: u16) + -> Result; +} + +/// Native non-transactional batch handler. Implementations receive one +/// topic-partition batch and declare progress through [`BatchHandlerOutcome`]. +#[async_trait::async_trait] +pub trait ConsumerHandler: Send + Sync { + async fn handle_batch( + &self, + batch: &EventBatch<'_>, + attempts: u16, + ) -> Result; +} + +#[cfg(feature = "db")] +#[async_trait::async_trait] +pub trait TxSingleEventHandler: Send + Sync { + async fn handle( + &self, + event: RawEvent, + attempts: u16, + commit: TxCommitHandle, + ) -> Result; +} + +#[cfg(feature = "db")] +#[async_trait::async_trait] +pub trait TxConsumerHandler: Send + Sync { + async fn handle_batch( + &self, + batch: &EventBatch<'_>, + attempts: u16, + commit: TxCommitHandle, + ) -> Result; +} + +/// Adapts a single-event handler to the batch-first runtime. +pub struct SingleEventHandlerAdapter { + inner: Arc, +} + +impl SingleEventHandlerAdapter { + pub fn new(inner: Arc) -> Self { + Self { inner } + } + + pub fn inner(&self) -> &Arc { + &self.inner + } +} + +#[async_trait::async_trait] +impl ConsumerHandler for SingleEventHandlerAdapter +where + H: SingleEventHandler + 'static, +{ + async fn handle_batch( + &self, + batch: &EventBatch<'_>, + attempts: u16, + ) -> Result { + let Some(event) = batch.next_event().cloned() else { + return Ok(BatchHandlerOutcome::Success); + }; + let offset = event.offset; + + match self.inner.handle(event, attempts).await? { + HandlerOutcome::Success => Ok(BatchHandlerOutcome::AdvanceThrough { offset }), + HandlerOutcome::Retry { reason } => Ok(BatchHandlerOutcome::Retry { reason }), + } + } +} + +#[cfg(feature = "db")] +pub struct TxSingleEventHandlerAdapter { + inner: Arc, + _offset_manager: std::marker::PhantomData, +} + +#[cfg(feature = "db")] +impl TxSingleEventHandlerAdapter { + pub fn new(inner: Arc) -> Self { + Self { + inner, + _offset_manager: std::marker::PhantomData, + } + } +} + +#[cfg(feature = "db")] +#[async_trait::async_trait] +impl TxConsumerHandler for TxSingleEventHandlerAdapter +where + H: TxSingleEventHandler + 'static, + OM: CommitOffsetInTx + 'static, +{ + async fn handle_batch( + &self, + batch: &EventBatch<'_>, + attempts: u16, + commit: TxCommitHandle, + ) -> Result { + let Some(event) = batch.next_event().cloned() else { + return Ok(HandlerOutcome::Success); + }; + + self.inner.handle(event, attempts, commit).await + } +} diff --git a/gears/system/event-broker/event-broker-sdk/src/consumer/types_tests.rs b/gears/system/event-broker/event-broker-sdk/src/consumer/types_tests.rs new file mode 100644 index 000000000..bb8ac3252 --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/src/consumer/types_tests.rs @@ -0,0 +1,305 @@ +use std::time::Duration; +use toolkit_gts::{GTS_ID_PREFIX, gts_id}; + +use uuid::Uuid; + +use super::*; +use crate::error::EventBrokerError; +use crate::ids::{ConsumerGroupId, EventTypeId, TopicId}; + +struct NoSecurityContextHandler; + +#[async_trait::async_trait] +impl SingleEventHandler for NoSecurityContextHandler { + async fn handle( + &self, + _event: RawEvent, + _attempts: u16, + ) -> Result { + Ok(HandlerOutcome::Success) + } +} + +struct NoOffsetDecisionRuntimeListener; + +#[async_trait::async_trait] +impl ConsumerRuntimeListener for NoOffsetDecisionRuntimeListener { + async fn on_consumer_event( + &self, + _event: &ConsumerRuntimeEvent, + ) -> Result<(), EventBrokerError> { + Ok(()) + } +} + +#[test] +fn uuid_identity_newtypes_preserve_uuid_values() { + let topic_uuid = Uuid::new_v4(); + let event_type_uuid = Uuid::new_v4(); + let group_uuid = Uuid::new_v4(); + + let topic_id = TopicId::new(topic_uuid); + let event_type_id = EventTypeId::new(event_type_uuid); + let group_id = ConsumerGroupId::new(group_uuid); + + assert_eq!(topic_id.as_uuid(), topic_uuid); + assert_eq!(event_type_id.as_uuid(), event_type_uuid); + assert_eq!(group_id.as_uuid(), group_uuid); + assert_eq!(topic_id.to_string(), topic_uuid.to_string()); + assert_eq!(event_type_id.to_string(), event_type_uuid.to_string()); + assert_eq!(group_id.to_string(), group_uuid.to_string()); +} + +#[test] +fn handler_signature_has_no_security_context_parameter() { + fn assert_handler() {} + + assert_handler::(); +} + +#[test] +fn runtime_listener_signature_has_no_offset_decision_return() { + fn assert_listener() {} + + assert_listener::(); +} + +#[test] +fn dead_letter_record_exposes_context_fields_for_diagnosis_and_replay() { + let group_id = ConsumerGroupId::new(Uuid::new_v4()); + let topic = gts_id!("cf.core.events.topic.v1~example.orders.x.x.v1"); + let topic_id = TopicId::from_gts(topic); + let payload = serde_json::json!({ "order_id": "order-1" }); + let raw = RawEvent { + id: Uuid::new_v4(), + type_id: gts_id!("cf.core.events.event.v1~example.orders.order_created.x.v1").to_owned(), + topic: topic.to_owned(), + tenant_id: Uuid::new_v4(), + subject: "order-1".to_owned(), + subject_type: "order".to_owned(), + partition_key: Some("customer-1".to_owned()), + partition: 3, + sequence: 42, + offset: 42, + occurred_at: chrono::Utc::now(), + sequence_time: chrono::Utc::now(), + trace_parent: Some("00-test".to_owned()), + data: payload.clone(), + }; + + let record = crate::dlq::DeadLetterRecord::builder(&raw, "schema mismatch") + .group_id(group_id) + .topic_id(topic_id) + .attempts(2) + .build(); + + assert_eq!(record.group_id, Some(group_id)); + assert_eq!(record.topic_id, Some(topic_id)); + assert_eq!(record.topic, topic); + assert_eq!(record.partition_key.as_deref(), Some("customer-1")); + assert_eq!(record.partition, 3); + assert_eq!(record.offset, 42); + assert_eq!(record.payload, payload); + assert_eq!(record.reason, "schema mismatch"); + assert_eq!(record.attempts, Some(2)); +} + +#[test] +fn refs_convert_from_resolved_ids() { + let topic_id = TopicId::new(Uuid::new_v4()); + let event_type_id = EventTypeId::new(Uuid::new_v4()); + let group_id = ConsumerGroupId::new(Uuid::new_v4()); + + assert_eq!(TopicRef::from(topic_id), TopicRef::Id(topic_id)); + assert_eq!( + EventTypeRef::from(event_type_id), + EventTypeRef::Id(event_type_id) + ); + assert_eq!( + ConsumerGroupRef::from(group_id), + ConsumerGroupRef::Id(group_id) + ); +} + +#[test] +fn subscription_interest_builder_keeps_types_and_filter_per_topic() { + let interest = SubscriptionInterest::builder() + .topic(TopicRef::gts(gts_id!( + "cf.core.events.topic.v1~example.orders.x.x.v1" + ))) + .types([ + EventTypeRef::gts(gts_id!( + "cf.core.events.event.v1~example.orders.order_created.x.v1" + )), + EventTypeRef::gts_pattern(format!( + "{GTS_ID_PREFIX}cf.core.events.event.v1~example.orders.*" + )), + ]) + .filter(SubscriptionFilterRef::cel("tenant_id == $tenant_id")) + .build() + .expect("interest should be valid"); + + assert_eq!( + interest.topic, + TopicRef::gts(gts_id!("cf.core.events.topic.v1~example.orders.x.x.v1")) + ); + assert_eq!(interest.event_types.len(), 2); + assert_eq!( + interest.filter, + Some(SubscriptionFilterRef::cel("tenant_id == $tenant_id")) + ); +} + +#[test] +fn subscription_interest_builder_rejects_missing_required_fields() { + let missing_topic = SubscriptionInterest::builder() + .types([EventTypeRef::gts_pattern(format!( + "{GTS_ID_PREFIX}cf.core.events.event.v1~example.orders.*" + ))]) + .build() + .expect_err("topic is required"); + assert!(matches!( + missing_topic, + EventBrokerError::InvalidConsumerOptions { .. } + )); + + let missing_types = SubscriptionInterest::builder() + .topic(TopicRef::gts(gts_id!( + "cf.core.events.topic.v1~example.orders.x.x.v1" + ))) + .build() + .expect_err("event types are required"); + assert!(matches!( + missing_types, + EventBrokerError::InvalidConsumerOptions { .. } + )); +} + +#[test] +fn cel_filter_uses_broker_filter_engine_ref() { + let filter = SubscriptionFilterRef::cel("event.data.amount > 100"); + + assert_eq!(filter.expression, "event.data.amount > 100"); + assert_eq!( + filter.engine, + FilterEngineRef::gts(gts_id!( + "cf.core.events.filter.v1~cf.core.expression.cel.v1" + )) + ); +} + +#[test] +fn consumer_profiles_are_distinct_operating_modes() { + let default = ConsumerProfile::default_profile(); + let low_latency = ConsumerProfile::low_latency(); + let high_throughput = ConsumerProfile::high_throughput(); + let replay = ConsumerProfile::replay(); + let relaxed = ConsumerProfile::relaxed(); + + assert_eq!(default.batching.max_events, 1); + assert_eq!(low_latency.batching.max_wait, Duration::from_millis(0)); + assert!(low_latency.slow_detection.handler_latency < default.slow_detection.handler_latency); + + assert!(high_throughput.batching.max_events > default.batching.max_events); + assert!(high_throughput.buffering.partition_capacity > default.buffering.partition_capacity); + + assert!(replay.batching.max_events > high_throughput.batching.max_events); + assert!(replay.slow_detection.handler_latency > high_throughput.slow_detection.handler_latency); + + assert!(relaxed.listener.channel_capacity < default.listener.channel_capacity); + assert!(relaxed.retry.base_delay > default.retry.base_delay); +} + +#[test] +fn consumer_profile_default_matches_named_default_profile() { + assert_eq!( + ConsumerProfile::default(), + ConsumerProfile::default_profile() + ); +} + +#[test] +fn consumer_settings_resolve_explicit_override_over_profile() { + let profile = ConsumerProfile::high_throughput(); + let override_batching = ConsumerBatching { + max_events: 7, + max_wait: Duration::from_millis(25), + }; + + let settings = ConsumerSettings::resolve( + profile.clone(), + ConsumerSettingsOverrides { + batching: Some(override_batching), + ..ConsumerSettingsOverrides::default() + }, + ); + + assert_eq!(settings.batching, override_batching); + assert_eq!(settings.buffering, profile.buffering); + assert_eq!(settings.slow_detection, profile.slow_detection); + assert_eq!(settings.retry, profile.retry); + assert_eq!(settings.listener, profile.listener); +} + +#[test] +fn consumer_settings_validation_rejects_invalid_values() { + let mut settings = ConsumerSettings::from_profile(ConsumerProfile::default_profile()); + settings.buffering.low_watermark = settings.buffering.high_watermark + 1; + assert!(matches!( + settings.validate(), + Err(EventBrokerError::InvalidConsumerOptions { .. }) + )); + + let mut settings = ConsumerSettings::from_profile(ConsumerProfile::default_profile()); + settings.batching.max_events = 0; + assert!(matches!( + settings.validate(), + Err(EventBrokerError::InvalidConsumerOptions { .. }) + )); + + let mut settings = ConsumerSettings::from_profile(ConsumerProfile::default_profile()); + settings.listener.channel_capacity = 0; + assert!(matches!( + settings.validate(), + Err(EventBrokerError::InvalidConsumerOptions { .. }) + )); + + let mut settings = ConsumerSettings::from_profile(ConsumerProfile::default_profile()); + settings.listener.timeout = Duration::ZERO; + assert!(matches!( + settings.validate(), + Err(EventBrokerError::InvalidConsumerOptions { .. }) + )); +} + +#[test] +fn consumer_commit_mode_defaults_to_auto_commit_interval() { + assert_eq!( + ConsumerCommitMode::default(), + ConsumerCommitMode::Auto { + interval: Duration::from_secs(20), + } + ); + assert_eq!( + ConsumerCommitMode::auto(Duration::from_secs(5)), + ConsumerCommitMode::Auto { + interval: Duration::from_secs(5), + } + ); + assert_eq!(ConsumerCommitMode::manual(), ConsumerCommitMode::Manual); +} + +#[test] +fn consumer_builder_uses_single_commit_mode_setting() { + let auto = ConsumerBuilder::new_unbound() + .commit_mode(ConsumerCommitMode::auto(Duration::from_secs(3))); + assert_eq!( + auto.commit_mode, + ConsumerCommitMode::Auto { + interval: Duration::from_secs(3), + } + ); + + let manual = ConsumerBuilder::new_unbound().commit_mode(ConsumerCommitMode::manual()); + assert_eq!(manual.commit_mode, ConsumerCommitMode::Manual); +} diff --git a/gears/system/event-broker/event-broker-sdk/src/dlq/envelope.rs b/gears/system/event-broker/event-broker-sdk/src/dlq/envelope.rs new file mode 100644 index 000000000..84f72af0f --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/src/dlq/envelope.rs @@ -0,0 +1,99 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::error::ConsumerError; +use crate::ids::{ConsumerGroupId, TopicId}; + +use super::DeadLetterRecord; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct DeadLetterEnvelope { + pub version: u16, + pub group_id: Option, + pub topic_id: Option, + pub topic: String, + pub event_type: String, + pub subject: String, + pub subject_type: String, + pub partition_key: Option, + pub partition: u32, + pub offset: i64, + pub attempts: Option, + pub reason: String, + pub payload: serde_json::Value, + pub occurred_at: DateTime, + pub parked_at: DateTime, + pub event_id: Uuid, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct DeadLetterSourceCoordinates { + pub group_id: Option, + pub topic_id: Option, + pub topic: String, + pub event_type: String, + pub subject: String, + pub subject_type: String, + pub partition_key: Option, + pub partition: u32, + pub offset: i64, + pub event_id: Uuid, +} + +impl DeadLetterEnvelope { + pub const VERSION: u16 = 1; + pub const PAYLOAD_TYPE: &'static str = "application/vnd.cyberfabric.event-broker.dlq+json"; + + pub fn from_record(record: DeadLetterRecord) -> Self { + Self { + version: Self::VERSION, + group_id: record.group_id, + topic_id: record.topic_id, + topic: record.topic, + event_type: record.event_type, + subject: record.subject, + subject_type: record.subject_type, + partition_key: record.partition_key, + partition: record.partition, + offset: record.offset, + attempts: record.attempts, + reason: record.reason, + payload: record.payload, + occurred_at: record.occurred_at, + parked_at: Utc::now(), + event_id: record.event_id, + } + } + + pub fn to_vec(&self) -> Result, ConsumerError> { + serde_json::to_vec(self).map_err(|err| { + crate::error::EventBrokerError::Internal(format!( + "serialize dead-letter envelope: {err}" + )) + }) + } + + pub fn from_slice(payload: &[u8]) -> Result { + serde_json::from_slice(payload).map_err(|err| { + crate::error::EventBrokerError::Internal(format!( + "deserialize dead-letter envelope: {err}" + )) + }) + } + + pub fn source_coordinates(&self) -> DeadLetterSourceCoordinates { + DeadLetterSourceCoordinates { + group_id: self.group_id, + topic_id: self.topic_id, + topic: self.topic.clone(), + event_type: self.event_type.clone(), + subject: self.subject.clone(), + subject_type: self.subject_type.clone(), + partition_key: self.partition_key.clone(), + partition: self.partition, + offset: self.offset, + event_id: self.event_id, + } + } +} diff --git a/gears/system/event-broker/event-broker-sdk/src/dlq/envelope_tests.rs b/gears/system/event-broker/event-broker-sdk/src/dlq/envelope_tests.rs new file mode 100644 index 000000000..120f167b9 --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/src/dlq/envelope_tests.rs @@ -0,0 +1,96 @@ +use chrono::Utc; +use toolkit_gts::gts_id; +use uuid::Uuid; + +use crate::consumer::RawEvent; +use crate::ids::{ConsumerGroupId, TopicId}; + +use super::{DeadLetterEnvelope, DeadLetterRecord}; + +const TOPIC: &str = gts_id!("cf.core.events.topic.v1~example.orders.x.x.v1"); +const EVENT_TYPE: &str = gts_id!("cf.core.events.event.v1~example.orders.rejected.x.v1"); + +fn raw_event() -> RawEvent { + RawEvent { + id: Uuid::new_v4(), + type_id: EVENT_TYPE.to_owned(), + topic: TOPIC.to_owned(), + tenant_id: Uuid::new_v4(), + subject: "order-1".to_owned(), + subject_type: "order".to_owned(), + partition_key: Some("tenant-a/order-1".to_owned()), + partition: 7, + sequence: 99, + offset: 99, + occurred_at: Utc::now(), + sequence_time: Utc::now(), + trace_parent: None, + data: serde_json::json!({ "order_id": "order-1", "valid": false }), + } +} + +#[test] +fn dead_letter_envelope_preserves_record_context_and_payload_type_convention() { + let group_id = ConsumerGroupId::new(Uuid::new_v4()); + let topic_id = TopicId::from_gts(TOPIC); + let raw = raw_event(); + let record = DeadLetterRecord::builder(&raw, "permanent validation failure") + .group_id(group_id) + .topic_id(topic_id) + .attempts(6) + .build(); + + let envelope = DeadLetterEnvelope::from_record(record); + + assert_eq!(DeadLetterEnvelope::VERSION, 1); + assert_eq!( + DeadLetterEnvelope::PAYLOAD_TYPE, + "application/vnd.cyberfabric.event-broker.dlq+json" + ); + assert_eq!(envelope.version, DeadLetterEnvelope::VERSION); + assert_eq!(envelope.group_id, Some(group_id)); + assert_eq!(envelope.topic_id, Some(topic_id)); + assert_eq!(envelope.topic, TOPIC); + assert_eq!(envelope.event_type, EVENT_TYPE); + assert_eq!(envelope.subject, "order-1"); + assert_eq!(envelope.subject_type, "order"); + assert_eq!(envelope.partition_key.as_deref(), Some("tenant-a/order-1")); + assert_eq!(envelope.partition, 7); + assert_eq!(envelope.offset, 99); + assert_eq!(envelope.attempts, Some(6)); + assert_eq!(envelope.reason, "permanent validation failure"); + assert_eq!(envelope.payload, raw.data); + assert_eq!(envelope.event_id, raw.id); +} + +#[test] +fn dead_letter_envelope_round_trips_from_outbox_payload() { + let raw = raw_event(); + let record = DeadLetterRecord::builder(&raw, "cannot project").build(); + let envelope = DeadLetterEnvelope::from_record(record); + + let payload = envelope.to_vec().expect("serialize envelope"); + let decoded = DeadLetterEnvelope::from_slice(&payload).expect("deserialize envelope"); + + assert_eq!(decoded, envelope); + let coordinates = decoded.source_coordinates(); + assert_eq!(coordinates.topic, TOPIC); + assert_eq!(coordinates.event_type, EVENT_TYPE); + assert_eq!(coordinates.subject, "order-1"); + assert_eq!(coordinates.subject_type, "order"); + assert_eq!( + coordinates.partition_key.as_deref(), + Some("tenant-a/order-1") + ); + assert_eq!(coordinates.partition, 7); + assert_eq!(coordinates.offset, 99); + assert_eq!(coordinates.event_id, raw.id); +} + +#[test] +fn invalid_dead_letter_envelope_payload_returns_consumer_error() { + let err = DeadLetterEnvelope::from_slice(b"not json") + .expect_err("invalid payload should not deserialize"); + + assert!(err.to_string().contains("deserialize dead-letter envelope")); +} diff --git a/gears/system/event-broker/event-broker-sdk/src/dlq/mod.rs b/gears/system/event-broker/event-broker-sdk/src/dlq/mod.rs new file mode 100644 index 000000000..72406aa19 --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/src/dlq/mod.rs @@ -0,0 +1,18 @@ +mod envelope; +mod record; + +#[cfg(feature = "outbox")] +mod outbox; + +#[cfg(test)] +mod envelope_tests; +#[cfg(feature = "outbox")] +#[cfg(test)] +mod outbox_tests; +#[cfg(test)] +mod record_tests; + +pub use envelope::{DeadLetterEnvelope, DeadLetterSourceCoordinates}; +#[cfg(feature = "outbox")] +pub use outbox::{ConsumerDlqOutbox, ConsumerDlqOutboxBuilder}; +pub use record::{DeadLetterRecord, DeadLetterRecordBuilder, DeadLetterSink}; diff --git a/gears/system/event-broker/event-broker-sdk/src/dlq/outbox.rs b/gears/system/event-broker/event-broker-sdk/src/dlq/outbox.rs new file mode 100644 index 000000000..bf89bb4b1 --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/src/dlq/outbox.rs @@ -0,0 +1,83 @@ +use std::sync::Arc; + +use crate::error::{ConsumerError, EventBrokerError}; + +use super::{DeadLetterEnvelope, DeadLetterRecord}; + +#[derive(Clone)] +pub struct ConsumerDlqOutbox { + outbox: Arc, + queue: String, + partitions: u32, +} + +pub struct ConsumerDlqOutboxBuilder { + outbox: Arc, + queue: Option, + partitions: Option, +} + +impl ConsumerDlqOutbox { + pub fn builder(outbox: Arc) -> ConsumerDlqOutboxBuilder { + ConsumerDlqOutboxBuilder { + outbox, + queue: None, + partitions: None, + } + } + + pub fn queue(&self) -> &str { + &self.queue + } + + pub fn partitions(&self) -> u32 { + self.partitions + } + + pub fn partition_for_record(&self, record: &DeadLetterRecord) -> u32 { + record.partition % self.partitions + } + + pub async fn enqueue( + &self, + runner: &(impl toolkit_db::secure::DBRunner + Sync + ?Sized), + record: DeadLetterRecord, + ) -> Result { + let partition = self.partition_for_record(&record); + let envelope = DeadLetterEnvelope::from_record(record); + let payload = envelope.to_vec()?; + + self.outbox + .enqueue( + runner, + &self.queue, + partition, + payload, + DeadLetterEnvelope::PAYLOAD_TYPE, + ) + .await + .map_err(|err| { + EventBrokerError::Internal(format!("enqueue dead-letter envelope: {err}")) + }) + } +} + +impl ConsumerDlqOutboxBuilder { + pub fn queue(mut self, queue: impl Into) -> Self { + self.queue = Some(queue.into()); + self + } + + pub fn partitions(mut self, partitions: u32) -> Self { + self.partitions = Some(partitions); + self + } + + pub fn build(self) -> ConsumerDlqOutbox { + ConsumerDlqOutbox { + outbox: self.outbox, + queue: self.queue.unwrap_or_else(|| "consumer-dlq".to_owned()), + partitions: self.partitions.unwrap_or(1).max(1), + } + } +} diff --git a/gears/system/event-broker/event-broker-sdk/src/dlq/outbox_tests.rs b/gears/system/event-broker/event-broker-sdk/src/dlq/outbox_tests.rs new file mode 100644 index 000000000..166e029ba --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/src/dlq/outbox_tests.rs @@ -0,0 +1,162 @@ +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; +use toolkit_gts::gts_id; + +use chrono::Utc; +use uuid::Uuid; + +use crate::consumer::RawEvent; +use crate::ids::ConsumerGroupId; + +use super::{ConsumerDlqOutbox, DeadLetterEnvelope, DeadLetterRecord}; + +const DLQ_QUEUE: &str = "consumer-dlq"; +const DLQ_PARTITIONS: u32 = 4; +const TOPIC: &str = gts_id!("cf.core.events.topic.v1~example.orders.x.x.v1"); +const EVENT_TYPE: &str = gts_id!("cf.core.events.event.v1~example.orders.rejected.x.v1"); + +static DB_SEQ: AtomicU64 = AtomicU64::new(1); + +struct NoopProcessor; + +#[async_trait::async_trait] +impl toolkit_db::outbox::LeasedMessageHandler for NoopProcessor { + async fn handle( + &self, + _msg: &toolkit_db::outbox::OutboxMessage, + ) -> toolkit_db::outbox::MessageResult { + toolkit_db::outbox::MessageResult::Ok + } +} + +fn raw_event(offset: i64) -> RawEvent { + RawEvent { + id: Uuid::new_v4(), + type_id: EVENT_TYPE.to_owned(), + topic: TOPIC.to_owned(), + tenant_id: Uuid::new_v4(), + subject: format!("order-{offset}"), + subject_type: "order".to_owned(), + partition_key: Some(format!("tenant-a/order-{offset}")), + partition: 6, + sequence: offset, + offset, + occurred_at: Utc::now(), + sequence_time: Utc::now(), + trace_parent: None, + data: serde_json::json!({ "offset": offset }), + } +} + +fn dead_letter_record(offset: i64) -> DeadLetterRecord { + DeadLetterRecord::builder(&raw_event(offset), "permanent failure") + .group_id(ConsumerGroupId::from_gts(gts_id!( + "cf.core.events.group.v1~example.orders.projector.x.v1" + ))) + .attempts(3) + .build() +} + +async fn outbox_handle() -> (toolkit_db::outbox::OutboxHandle, toolkit_db::Db) { + let seq = DB_SEQ.fetch_add(1, Ordering::Relaxed); + let dsn = format!("sqlite:file:evbk_dlq_outbox_{seq}?mode=memory&cache=shared"); + let db = toolkit_db::connect_db( + &dsn, + toolkit_db::ConnectOpts { + max_conns: Some(1), + ..toolkit_db::ConnectOpts::default() + }, + ) + .await + .expect("connect toolkit db"); + toolkit_db::migration_runner::run_migrations_for_testing( + &db, + toolkit_db::outbox::outbox_migrations(), + ) + .await + .expect("outbox migrations"); + + let handle = toolkit_db::outbox::Outbox::builder(db.clone()) + .queue( + DLQ_QUEUE, + toolkit_db::outbox::Partitions::of(DLQ_PARTITIONS as u16), + ) + .leased(NoopProcessor) + .start() + .await + .expect("outbox starts"); + (handle, db) +} + +#[tokio::test] +async fn consumer_dlq_outbox_builder_keeps_queue_and_partitions() { + let (handle, _db) = outbox_handle().await; + let helper = ConsumerDlqOutbox::builder(Arc::clone(handle.outbox())) + .queue(DLQ_QUEUE) + .partitions(DLQ_PARTITIONS) + .build(); + + assert_eq!(helper.queue(), DLQ_QUEUE); + assert_eq!(helper.partitions(), DLQ_PARTITIONS); + handle.stop().await; +} + +#[tokio::test] +async fn consumer_dlq_outbox_maps_consumed_event_partition_to_dlq_partition_count() { + let (handle, _db) = outbox_handle().await; + let helper = ConsumerDlqOutbox::builder(Arc::clone(handle.outbox())) + .queue(DLQ_QUEUE) + .partitions(DLQ_PARTITIONS) + .build(); + let record = dead_letter_record(10); + + let actual = helper.partition_for_record(&record); + + assert_eq!(actual, record.partition % DLQ_PARTITIONS); + assert!(actual < DLQ_PARTITIONS); + handle.stop().await; +} + +#[tokio::test] +async fn consumer_dlq_outbox_enqueues_dead_letter_envelope() { + let (handle, db) = outbox_handle().await; + let conn = db.conn().expect("db conn"); + let helper = ConsumerDlqOutbox::builder(Arc::clone(handle.outbox())) + .queue(DLQ_QUEUE) + .partitions(DLQ_PARTITIONS) + .build(); + + let message_id = helper + .enqueue(&conn, dead_letter_record(11)) + .await + .expect("enqueue succeeds"); + + assert!(message_id.0 > 0); + handle.stop().await; +} + +#[tokio::test] +async fn consumer_dlq_outbox_maps_enqueue_failures_to_consumer_error() { + let (handle, db) = outbox_handle().await; + let conn = db.conn().expect("db conn"); + let helper = ConsumerDlqOutbox::builder(Arc::clone(handle.outbox())) + .queue("missing-queue") + .partitions(DLQ_PARTITIONS) + .build(); + + let err = helper + .enqueue(&conn, dead_letter_record(12)) + .await + .expect_err("missing queue should fail"); + + assert!(err.to_string().contains("enqueue dead-letter envelope")); + handle.stop().await; +} + +#[test] +fn dead_letter_envelope_payload_type_is_used_by_processors() { + assert_eq!( + DeadLetterEnvelope::PAYLOAD_TYPE, + "application/vnd.cyberfabric.event-broker.dlq+json" + ); +} diff --git a/gears/system/event-broker/event-broker-sdk/src/dlq/record.rs b/gears/system/event-broker/event-broker-sdk/src/dlq/record.rs new file mode 100644 index 000000000..b75c01e81 --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/src/dlq/record.rs @@ -0,0 +1,102 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::consumer::RawEvent; +use crate::error::ConsumerError; +use crate::ids::{ConsumerGroupId, TopicId}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeadLetterRecord { + pub group_id: Option, + pub topic_id: Option, + pub topic: String, + pub event_type: String, + pub subject: String, + pub subject_type: String, + pub partition_key: Option, + pub partition: u32, + pub offset: i64, + pub attempts: Option, + pub reason: String, + pub payload: serde_json::Value, + pub occurred_at: DateTime, + pub event_id: Uuid, +} + +impl DeadLetterRecord { + pub fn builder(event: &RawEvent, reason: impl Into) -> DeadLetterRecordBuilder { + DeadLetterRecordBuilder { + event: event.clone(), + reason: reason.into(), + group_id: None, + topic_id: Some(TopicId::from_gts(&event.topic)), + attempts: None, + occurred_at: Utc::now(), + } + } + + pub fn from_event(event: &RawEvent, reason: impl Into) -> Self { + Self::builder(event, reason).build() + } +} + +pub struct DeadLetterRecordBuilder { + event: RawEvent, + reason: String, + group_id: Option, + topic_id: Option, + attempts: Option, + occurred_at: DateTime, +} + +impl DeadLetterRecordBuilder { + pub fn group_id(mut self, group_id: ConsumerGroupId) -> Self { + self.group_id = Some(group_id); + self + } + + pub fn topic_id(mut self, topic_id: TopicId) -> Self { + self.topic_id = Some(topic_id); + self + } + + pub fn without_topic_id(mut self) -> Self { + self.topic_id = None; + self + } + + pub fn attempts(mut self, attempts: u16) -> Self { + self.attempts = Some(attempts); + self + } + + pub fn occurred_at(mut self, occurred_at: DateTime) -> Self { + self.occurred_at = occurred_at; + self + } + + pub fn build(self) -> DeadLetterRecord { + DeadLetterRecord { + group_id: self.group_id, + topic_id: self.topic_id, + topic: self.event.topic.clone(), + event_type: self.event.type_id.clone(), + subject: self.event.subject.clone(), + subject_type: self.event.subject_type.clone(), + partition_key: self.event.partition_key.clone(), + partition: self.event.partition, + offset: self.event.offset, + attempts: self.attempts, + reason: self.reason, + payload: self.event.data.clone(), + occurred_at: self.occurred_at, + event_id: self.event.id, + } + } +} + +#[async_trait::async_trait] +pub trait DeadLetterSink: Send + Sync { + async fn park(&self, record: DeadLetterRecord) -> Result<(), ConsumerError>; +} diff --git a/gears/system/event-broker/event-broker-sdk/src/dlq/record_tests.rs b/gears/system/event-broker/event-broker-sdk/src/dlq/record_tests.rs new file mode 100644 index 000000000..7555f039a --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/src/dlq/record_tests.rs @@ -0,0 +1,72 @@ +use chrono::Utc; +use toolkit_gts::gts_id; +use uuid::Uuid; + +use crate::consumer::RawEvent; +use crate::ids::{ConsumerGroupId, TopicId}; + +use super::DeadLetterRecord; + +const TOPIC: &str = gts_id!("cf.core.events.topic.v1~example.orders.x.x.v1"); +const EVENT_TYPE: &str = gts_id!("cf.core.events.event.v1~example.orders.rejected.x.v1"); + +fn raw_event() -> RawEvent { + RawEvent { + id: Uuid::new_v4(), + type_id: EVENT_TYPE.to_owned(), + topic: TOPIC.to_owned(), + tenant_id: Uuid::new_v4(), + subject: "order-1".to_owned(), + subject_type: "order".to_owned(), + partition_key: Some("tenant-a/order-1".to_owned()), + partition: 3, + sequence: 42, + offset: 42, + occurred_at: Utc::now(), + sequence_time: Utc::now(), + trace_parent: Some("00-test".to_owned()), + data: serde_json::json!({ "order_id": "order-1" }), + } +} + +#[test] +fn dead_letter_record_exposes_context_fields_for_diagnosis_and_replay() { + let group_id = ConsumerGroupId::new(Uuid::new_v4()); + let topic_id = TopicId::from_gts(TOPIC); + let raw = raw_event(); + let payload = raw.data.clone(); + + let record = DeadLetterRecord::builder(&raw, "schema mismatch") + .group_id(group_id) + .topic_id(topic_id) + .attempts(2) + .build(); + + assert_eq!(record.group_id, Some(group_id)); + assert_eq!(record.topic_id, Some(topic_id)); + assert_eq!(record.topic, TOPIC); + assert_eq!(record.event_type, EVENT_TYPE); + assert_eq!(record.subject, "order-1"); + assert_eq!(record.subject_type, "order"); + assert_eq!(record.partition_key.as_deref(), Some("tenant-a/order-1")); + assert_eq!(record.partition, 3); + assert_eq!(record.offset, 42); + assert_eq!(record.payload, payload); + assert_eq!(record.reason, "schema mismatch"); + assert_eq!(record.attempts, Some(2)); + assert_eq!(record.event_id, raw.id); +} + +#[test] +fn dead_letter_record_allows_absent_group_and_topic_id() { + let raw = raw_event(); + + let record = DeadLetterRecord::builder(&raw, "no registry context") + .without_topic_id() + .build(); + + assert_eq!(record.group_id, None); + assert_eq!(record.topic_id, None); + assert_eq!(record.topic, TOPIC); + assert_eq!(record.reason, "no registry context"); +} diff --git a/gears/system/event-broker/event-broker-sdk/src/error.rs b/gears/system/event-broker/event-broker-sdk/src/error.rs new file mode 100644 index 000000000..0b335a2a6 --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/src/error.rs @@ -0,0 +1,729 @@ +use std::sync::Arc; + +use toolkit_canonical_errors::CanonicalError; +use toolkit_canonical_errors::resource_error; + +use crate::ids::{ConsumerGroupId, ProducerId, SubscriptionId}; + +#[resource_error(gts_id!("cf.core.event_broker.producer_options.v1~"))] +struct ProducerOptionsResourceError; +#[resource_error(gts_id!("cf.core.event_broker.consumer_options.v1~"))] +struct ConsumerOptionsResourceError; +#[resource_error(gts_id!("cf.core.event_broker.event_type.v1~"))] +struct EventTypeResourceError; +#[resource_error(gts_id!("cf.core.event_broker.topic.v1~"))] +struct TopicResourceError; +#[resource_error(gts_id!("cf.core.event_broker.consumer_group.v1~"))] +struct ConsumerGroupResourceError; +#[resource_error(gts_id!("cf.core.event_broker.subscription.v1~"))] +struct SubscriptionResourceError; +#[resource_error(gts_id!("cf.core.event_broker.producer.v1~"))] +struct ProducerResourceError; +#[resource_error(gts_id!("cf.core.event_broker.partition.v1~"))] +struct PartitionResourceError; +#[resource_error(gts_id!("cf.core.event_broker.event.v1~"))] +struct EventResourceError; +#[resource_error(gts_id!("cf.core.event_broker.stream.v1~"))] +struct StreamResourceError; +#[resource_error(gts_id!("cf.core.event_broker.storage.v1~"))] +struct StorageResourceError; +#[resource_error(gts_id!("cf.core.event_broker.offset.v1~"))] +struct OffsetResourceError; + +pub mod resources { + use toolkit_gts::gts_id; + + pub const PRODUCER_OPTIONS: &str = gts_id!("cf.core.event_broker.producer_options.v1~"); + pub const CONSUMER_OPTIONS: &str = gts_id!("cf.core.event_broker.consumer_options.v1~"); + pub const EVENT_TYPE: &str = gts_id!("cf.core.event_broker.event_type.v1~"); + pub const TOPIC: &str = gts_id!("cf.core.event_broker.topic.v1~"); + pub const CONSUMER_GROUP: &str = gts_id!("cf.core.event_broker.consumer_group.v1~"); + pub const SUBSCRIPTION: &str = gts_id!("cf.core.event_broker.subscription.v1~"); + pub const PRODUCER: &str = gts_id!("cf.core.event_broker.producer.v1~"); + pub const PARTITION: &str = gts_id!("cf.core.event_broker.partition.v1~"); + pub const EVENT: &str = gts_id!("cf.core.event_broker.event.v1~"); + pub const STREAM: &str = gts_id!("cf.core.event_broker.stream.v1~"); + pub const STORAGE: &str = gts_id!("cf.core.event_broker.storage.v1~"); + pub const OFFSET: &str = gts_id!("cf.core.event_broker.offset.v1~"); + pub const TRANSPORT: &str = gts_id!("cf.core.event_broker.transport.v1~"); +} + +pub mod reasons { + pub const INVALID_PRODUCER_OPTIONS: &str = "invalid_producer_options"; + pub const INVALID_CONSUMER_OPTIONS: &str = "invalid_consumer_options"; + pub const EVENT_TYPE_NOT_DECLARED: &str = "event_type_not_declared"; + pub const INVALID_EVENT_FIELD: &str = "invalid_event_field"; + pub const EVENT_DATA_INVALID: &str = "event_data_invalid"; + pub const BATCH_TOO_LARGE: &str = "batch_too_large"; + pub const INVALID_BACKEND_CONFIG: &str = "invalid_backend_config"; + pub const TYPE_NOT_IN_DECLARED_TOPIC: &str = "type_not_in_declared_topic"; + pub const SCHEMA_NOT_PREPARED: &str = "schema_not_prepared"; + pub const CONSUMER_GROUP_HAS_ACTIVE_MEMBERS: &str = "consumer_group_has_active_members"; + pub const SEQUENCE_MISMATCH: &str = "sequence_mismatch"; + pub const POSITIONS_NOT_SET: &str = "positions_not_set"; + pub const PARTITION_NOT_ASSIGNED: &str = "partition_not_assigned"; + pub const STREAMING_IN_PROGRESS: &str = "streaming_in_progress"; + pub const IN_TX_OFFSETS_NOT_SUPPORTED: &str = "in_tx_offsets_not_supported"; + pub const RATE_LIMIT: &str = "event-broker.rate-limit"; + pub const CONSUMER_GROUP_CAPACITY: &str = "event-broker.consumer-group-capacity"; + pub const UNAUTHORIZED: &str = "event_broker_unauthorized"; + pub const STORAGE_UNAVAILABLE: &str = "storage_unavailable"; + pub const OFFSET_MANAGER_UNAVAILABLE: &str = "offset_manager_unavailable"; + pub const TRANSPORT_UNAVAILABLE: &str = "transport_unavailable"; +} + +#[derive(Debug, Clone, thiserror::Error)] +pub enum EventBrokerError { + #[error("invalid producer options: {detail}")] + InvalidProducerOptions { detail: String, instance: String }, + + #[error("invalid consumer options: {detail}")] + InvalidConsumerOptions { detail: String, instance: String }, + + #[error("event type not declared on producer: {type_id}")] + EventTypeNotDeclared { + type_id: String, + detail: String, + instance: String, + }, + + #[error("event type unknown to types-registry: {type_id}")] + EventTypeUnknown { + type_id: String, + detail: String, + instance: String, + }, + + #[error("resolved type's parent topic differs from declared topics: {type_id}")] + TypeNotInDeclaredTopic { + type_id: String, + expected_topic: String, + detail: String, + instance: String, + }, + + #[error( + "schema not prepared for {type_id}; call `producer.prepare::(&ctx)` before opening the txn" + )] + SchemaNotPrepared { + type_id: String, + detail: String, + instance: String, + }, + + #[error("event field invalid: {field}: {detail}")] + InvalidEventField { + field: &'static str, + detail: String, + instance: String, + }, + + #[error("event data invalid for {type_id}: {errors:?}")] + EventDataInvalid { + type_id: String, + errors: Vec, + detail: String, + instance: String, + }, + + #[error("topic not found: {topic}")] + TopicNotFound { + topic: String, + detail: String, + instance: String, + }, + + #[error("consumer group not found")] + ConsumerGroupNotFound { + group_id: ConsumerGroupId, + detail: String, + instance: String, + }, + + #[error("consumer group has active members")] + ConsumerGroupHasActiveMembers { detail: String, instance: String }, + + #[error("subscription not found: {id}")] + SubscriptionNotFound { + id: SubscriptionId, + detail: String, + instance: String, + }, + + #[error("not authorized: {detail}")] + Unauthorized { detail: String, instance: String }, + + #[error("unknown producer: {producer_id:?}")] + UnknownProducer { + producer_id: crate::ids::ProducerId, + detail: String, + instance: String, + }, + + #[error("sequence violation (broker expects previous={expected_previous})")] + SequenceViolation { + expected_previous: i64, + detail: String, + instance: String, + }, + + #[error("rate limit exceeded, retry after {retry_after_secs}s")] + RateLimitExceeded { + retry_after_secs: u32, + detail: String, + instance: String, + }, + + #[error("consumer group at capacity ({active} active members, {partitions} partitions)")] + GroupAtCapacity { + active: u32, + partitions: u32, + detail: String, + instance: String, + }, + + #[error("publish rate limited (429), retry after {retry_after_secs}s")] + RateLimited { + retry_after_secs: u32, + detail: String, + instance: String, + }, + + #[error( + "batch too large: {count} events / {bytes} bytes (limits: {max_count} events, {max_bytes} bytes)" + )] + BatchTooLarge { + count: usize, + bytes: usize, + max_count: usize, + max_bytes: usize, + detail: String, + instance: String, + }, + + #[error("subscription recovery exhausted ({attempts} consecutive re-JOIN failures)")] + SubscriptionRecoveryExhausted { + attempts: u32, + detail: String, + instance: String, + }, + + #[error( + "invalid initial position for {topic}:{partition}: requested {requested}, valid range \ + [retention_floor - 1, high_water_mark]" + )] + InvalidInitialPosition { + topic: String, + partition: u32, + requested: String, + detail: String, + instance: String, + }, + + #[error("positions not set for {} partition(s): {}", unseeded.len(), display_unseeded(unseeded))] + PositionsNotSet { + unseeded: Vec<(String, u32)>, + detail: String, + instance: String, + }, + + #[error("partition {topic}:{partition} is not assigned to this subscription")] + PartitionNotAssigned { + topic: String, + partition: u32, + detail: String, + instance: String, + }, + + #[error("a stream is already open for this subscription")] + StreamingInProgress { detail: String, instance: String }, + + #[error("storage backend error")] + StorageBackend(#[from] StorageBackendError), + + #[error("offset manager error")] + OffsetManager(#[from] OffsetManagerError), + + #[error("transport: {0}")] + Transport(String), + + #[error("internal: {0}")] + Internal(String), + + #[error("{canonical}")] + Other { canonical: CanonicalError }, +} + +pub type ConsumerError = EventBrokerError; + +impl From for CanonicalError { + fn from(err: EventBrokerError) -> Self { + match err { + EventBrokerError::InvalidProducerOptions { detail, .. } => { + ProducerOptionsResourceError::invalid_argument() + .with_field_violation( + "producer_options", + detail, + reasons::INVALID_PRODUCER_OPTIONS, + ) + .create() + } + EventBrokerError::InvalidConsumerOptions { detail, .. } => { + ConsumerOptionsResourceError::invalid_argument() + .with_field_violation( + "consumer_options", + detail, + reasons::INVALID_CONSUMER_OPTIONS, + ) + .create() + } + EventBrokerError::EventTypeNotDeclared { + type_id, detail, .. + } => EventTypeResourceError::invalid_argument() + .with_resource(type_id) + .with_field_violation("event_type", detail, reasons::EVENT_TYPE_NOT_DECLARED) + .create(), + EventBrokerError::EventTypeUnknown { + type_id, detail, .. + } => EventTypeResourceError::not_found(detail) + .with_resource(type_id) + .create(), + EventBrokerError::TypeNotInDeclaredTopic { + type_id, + expected_topic, + detail, + .. + } => EventTypeResourceError::failed_precondition() + .with_resource(type_id.clone()) + .with_precondition_violation( + type_id, + format!("{detail}; expected topic {expected_topic}"), + reasons::TYPE_NOT_IN_DECLARED_TOPIC, + ) + .create(), + EventBrokerError::SchemaNotPrepared { + type_id, detail, .. + } => EventTypeResourceError::failed_precondition() + .with_resource(type_id.clone()) + .with_precondition_violation(type_id, detail, reasons::SCHEMA_NOT_PREPARED) + .create(), + EventBrokerError::InvalidEventField { field, detail, .. } => { + EventResourceError::invalid_argument() + .with_field_violation(field, detail, reasons::INVALID_EVENT_FIELD) + .create() + } + EventBrokerError::EventDataInvalid { + type_id, + errors, + detail, + .. + } => { + let description = if errors.is_empty() { + detail + } else { + format!("{detail}: {}", errors.join("; ")) + }; + EventTypeResourceError::invalid_argument() + .with_resource(type_id) + .with_field_violation("data", description, reasons::EVENT_DATA_INVALID) + .create() + } + EventBrokerError::TopicNotFound { topic, detail, .. } => { + TopicResourceError::not_found(detail) + .with_resource(topic) + .create() + } + EventBrokerError::ConsumerGroupNotFound { + group_id, detail, .. + } => ConsumerGroupResourceError::not_found(detail) + .with_resource(group_id.to_string()) + .create(), + EventBrokerError::ConsumerGroupHasActiveMembers { detail, .. } => { + ConsumerGroupResourceError::failed_precondition() + .with_precondition_violation( + resources::CONSUMER_GROUP, + detail, + reasons::CONSUMER_GROUP_HAS_ACTIVE_MEMBERS, + ) + .create() + } + EventBrokerError::SubscriptionNotFound { id, detail, .. } => { + SubscriptionResourceError::not_found(detail) + .with_resource(id.to_string()) + .create() + } + EventBrokerError::Unauthorized { detail, .. } => { + let _ = detail; + EventResourceError::permission_denied() + .with_reason(reasons::UNAUTHORIZED) + .create() + } + EventBrokerError::UnknownProducer { + producer_id, + detail, + .. + } => ProducerResourceError::not_found(detail) + .with_resource(producer_id.0.to_string()) + .create(), + EventBrokerError::SequenceViolation { + expected_previous, + detail, + .. + } => ProducerResourceError::failed_precondition() + .with_precondition_violation( + "producer_sequence", + format!("{detail}; expected previous {expected_previous}"), + reasons::SEQUENCE_MISMATCH, + ) + .create(), + EventBrokerError::RateLimitExceeded { + retry_after_secs, + detail, + .. + } + | EventBrokerError::RateLimited { + retry_after_secs, + detail, + .. + } => EventResourceError::resource_exhausted(detail.clone()) + .with_quota_violation(reasons::RATE_LIMIT, detail) + .with_quota_violation_retry_after_seconds(u64::from(retry_after_secs)) + .create(), + EventBrokerError::GroupAtCapacity { + active, + partitions, + detail, + .. + } => ConsumerGroupResourceError::resource_exhausted(detail.clone()) + .with_quota_violation( + reasons::CONSUMER_GROUP_CAPACITY, + format!("{detail}; active={active}; partitions={partitions}"), + ) + .create(), + EventBrokerError::BatchTooLarge { + count, + bytes, + max_count, + max_bytes, + detail, + .. + } => EventResourceError::invalid_argument() + .with_field_violation( + "batch.count", + format!("{detail}; count={count}; max_count={max_count}"), + reasons::BATCH_TOO_LARGE, + ) + .with_field_violation( + "batch.bytes", + format!("{detail}; bytes={bytes}; max_bytes={max_bytes}"), + reasons::BATCH_TOO_LARGE, + ) + .create(), + EventBrokerError::SubscriptionRecoveryExhausted { .. } => { + CanonicalError::service_unavailable().create() + } + EventBrokerError::InvalidInitialPosition { + topic, + partition, + requested, + detail, + .. + } => PartitionResourceError::out_of_range(detail.clone()) + .with_resource(format!("{topic}:{partition}")) + .with_field_violation( + "initial_position", + format!("{detail}; requested={requested}"), + "invalid_initial_position", + ) + .create(), + EventBrokerError::PositionsNotSet { + unseeded, detail, .. + } => { + let mut iter = unseeded.into_iter(); + let Some((topic, partition)) = iter.next() else { + return StreamResourceError::failed_precondition() + .with_precondition_violation( + "stream.positions", + detail, + reasons::POSITIONS_NOT_SET, + ) + .create(); + }; + let mut with_violations = StreamResourceError::failed_precondition() + .with_precondition_violation( + format!("{topic}:{partition}"), + detail.clone(), + reasons::POSITIONS_NOT_SET, + ); + for (topic, partition) in iter { + with_violations = with_violations.with_precondition_violation( + format!("{topic}:{partition}"), + detail.clone(), + reasons::POSITIONS_NOT_SET, + ); + } + with_violations.create() + } + EventBrokerError::PartitionNotAssigned { + topic, + partition, + detail, + .. + } => PartitionResourceError::failed_precondition() + .with_resource(format!("{topic}:{partition}")) + .with_precondition_violation( + format!("{topic}:{partition}"), + detail, + reasons::PARTITION_NOT_ASSIGNED, + ) + .create(), + EventBrokerError::StreamingInProgress { detail, .. } => { + StreamResourceError::failed_precondition() + .with_precondition_violation( + resources::STREAM, + detail, + reasons::STREAMING_IN_PROGRESS, + ) + .create() + } + EventBrokerError::StorageBackend(err) => CanonicalError::from(err), + EventBrokerError::OffsetManager(err) => CanonicalError::from(err), + EventBrokerError::Transport(_) => CanonicalError::service_unavailable().create(), + EventBrokerError::Internal(detail) => CanonicalError::internal(detail).create(), + EventBrokerError::Other { canonical } => canonical, + } + } +} + +impl From for EventBrokerError { + fn from(canonical: CanonicalError) -> Self { + let detail = canonical.detail().to_owned(); + match &canonical { + CanonicalError::PermissionDenied { .. } => Self::Unauthorized { + detail, + instance: String::new(), + }, + CanonicalError::ResourceExhausted { ctx, .. } => Self::RateLimitExceeded { + retry_after_secs: ctx + .violations + .first() + .and_then(|v| v.retry_after_seconds) + .and_then(|v| u32::try_from(v).ok()) + .unwrap_or_default(), + detail, + instance: String::new(), + }, + CanonicalError::NotFound { + resource_type, + resource_name, + .. + } if resource_type.as_deref() == Some(resources::PRODUCER) => { + if let Some(producer_id) = resource_name + .as_deref() + .and_then(|id| uuid::Uuid::parse_str(id).ok()) + .map(ProducerId) + { + Self::UnknownProducer { + producer_id, + detail, + instance: String::new(), + } + } else { + Self::Other { canonical } + } + } + CanonicalError::FailedPrecondition { ctx, .. } + if ctx + .violations + .iter() + .any(|v| v.type_ == reasons::SEQUENCE_MISMATCH) => + { + Self::SequenceViolation { + expected_previous: expected_previous_from_detail(&detail), + detail, + instance: String::new(), + } + } + CanonicalError::Internal { .. } => Self::Internal(detail), + CanonicalError::ServiceUnavailable { .. } => Self::Transport(detail), + _ => Self::Other { canonical }, + } + } +} + +fn expected_previous_from_detail(detail: &str) -> i64 { + detail + .split(|ch: char| !ch.is_ascii_digit() && ch != '-') + .rfind(|part| !part.is_empty()) + .and_then(|part| part.parse::().ok()) + .unwrap_or_default() +} + +impl From for CanonicalError { + fn from(err: StorageBackendError) -> Self { + match err { + StorageBackendError::Unavailable { .. } => { + CanonicalError::service_unavailable().create() + } + StorageBackendError::InvalidConfig { detail, .. } => { + StorageResourceError::invalid_argument() + .with_field_violation("backend_config", detail, reasons::INVALID_BACKEND_CONFIG) + .create() + } + StorageBackendError::OffsetOutOfRange { + requested, + oldest, + detail, + .. + } => OffsetResourceError::out_of_range(detail.clone()) + .with_field_violation( + "offset", + format!("{detail}; requested={requested}; oldest={oldest}"), + "offset_out_of_range", + ) + .create(), + StorageBackendError::PartitionNotFound { detail, .. } => { + PartitionResourceError::not_found(detail) + .with_resource(resources::PARTITION) + .create() + } + StorageBackendError::PersistFailed { .. } | StorageBackendError::ReadFailed { .. } => { + CanonicalError::service_unavailable().create() + } + StorageBackendError::Internal(detail) => CanonicalError::internal(detail).create(), + } + } +} + +impl From for CanonicalError { + fn from(err: OffsetManagerError) -> Self { + match err { + OffsetManagerError::InTxNotSupported { detail, .. } => { + OffsetResourceError::failed_precondition() + .with_precondition_violation( + resources::OFFSET, + detail, + reasons::IN_TX_OFFSETS_NOT_SUPPORTED, + ) + .create() + } + OffsetManagerError::PersistFailed { .. } | OffsetManagerError::LoadFailed { .. } => { + CanonicalError::service_unavailable().create() + } + OffsetManagerError::Internal(detail) => CanonicalError::internal(detail).create(), + } + } +} + +/// Bounded `Display` formatter for `PositionsNotSet::unseeded` - first 5 then +/// "...and N more" to keep log output bounded. +fn display_unseeded(unseeded: &[(String, u32)]) -> String { + const CAP: usize = 5; + let shown = unseeded + .iter() + .take(CAP) + .map(|(t, p)| format!("{t}:{p}")) + .collect::>() + .join(", "); + if unseeded.len() > CAP { + format!("{shown}, ...and {} more", unseeded.len() - CAP) + } else { + shown + } +} + +#[derive(Debug, Clone, thiserror::Error)] +pub enum StorageBackendError { + #[error("backend unavailable: {reason}")] + Unavailable { + reason: String, + detail: String, + instance: String, + }, + + #[error("invalid backend config")] + InvalidConfig { detail: String, instance: String }, + + #[error("offset out of range (requested {requested}, oldest available {oldest})")] + OffsetOutOfRange { + requested: i64, + oldest: i64, + detail: String, + instance: String, + }, + + #[error("partition not found")] + PartitionNotFound { detail: String, instance: String }, + + #[error("persist failed: {reason}")] + PersistFailed { + reason: String, + detail: String, + instance: String, + }, + + #[error("read failed: {reason}")] + ReadFailed { + reason: String, + detail: String, + instance: String, + }, + + #[error("internal: {0}")] + Internal(String), +} + +#[derive(Debug, Clone, thiserror::Error)] +pub enum OffsetManagerError { + #[error("offset manager does not support in-tx persistence")] + InTxNotSupported { detail: String, instance: String }, + + #[error("persist failed: {reason}")] + PersistFailed { + reason: String, + detail: String, + instance: String, + #[source] + source: Option>, + }, + + #[error("load failed: {reason}")] + LoadFailed { + reason: String, + detail: String, + instance: String, + #[source] + source: Option>, + }, + + #[error("internal: {0}")] + Internal(String), +} + +impl OffsetManagerError { + pub fn persist_failed( + reason: impl Into, + detail: impl Into, + source: impl std::error::Error + Send + Sync + 'static, + ) -> Self { + Self::PersistFailed { + reason: reason.into(), + detail: detail.into(), + instance: String::new(), + source: Some(Arc::new(source)), + } + } + + pub fn load_failed( + reason: impl Into, + detail: impl Into, + source: impl std::error::Error + Send + Sync + 'static, + ) -> Self { + Self::LoadFailed { + reason: reason.into(), + detail: detail.into(), + instance: String::new(), + source: Some(Arc::new(source)), + } + } +} diff --git a/gears/system/event-broker/event-broker-sdk/src/ids.rs b/gears/system/event-broker/event-broker-sdk/src/ids.rs new file mode 100644 index 000000000..f5a0dd1b1 --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/src/ids.rs @@ -0,0 +1,83 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub struct ProducerId(pub Uuid); + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub struct SubscriptionId(pub Uuid); + +impl std::fmt::Display for SubscriptionId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub struct TopicId(pub Uuid); + +impl TopicId { + pub fn new(id: Uuid) -> Self { + Self(id) + } + + pub fn as_uuid(&self) -> Uuid { + self.0 + } + + pub fn from_gts(gts: &str) -> Self { + Self(Uuid::new_v5(&Uuid::NAMESPACE_OID, gts.as_bytes())) + } +} + +impl std::fmt::Display for TopicId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub struct EventTypeId(pub Uuid); + +impl EventTypeId { + pub fn new(id: Uuid) -> Self { + Self(id) + } + + pub fn as_uuid(&self) -> Uuid { + self.0 + } + + pub fn from_gts(gts: &str) -> Self { + Self(Uuid::new_v5(&Uuid::NAMESPACE_OID, gts.as_bytes())) + } +} + +impl std::fmt::Display for EventTypeId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub struct ConsumerGroupId(pub Uuid); + +impl ConsumerGroupId { + pub fn new(id: Uuid) -> Self { + Self(id) + } + + pub fn as_uuid(&self) -> Uuid { + self.0 + } + + pub fn from_gts(gts: &str) -> Self { + Self(Uuid::new_v5(&Uuid::NAMESPACE_OID, gts.as_bytes())) + } +} + +impl std::fmt::Display for ConsumerGroupId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} diff --git a/gears/system/event-broker/event-broker-sdk/src/lib.rs b/gears/system/event-broker/event-broker-sdk/src/lib.rs new file mode 100644 index 000000000..19eb0ee2e --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/src/lib.rs @@ -0,0 +1,69 @@ +//! Event Broker SDK +//! +//! High-level typed event publishing and consumption for the Cyberfabric Event Broker. +//! +//! See [`EventBroker`] for the entry point; obtain it from `ClientHub`: +//! ```ignore +//! let broker = hub.get::()?; +//! ``` + +#![forbid(unsafe_code)] +#![deny(rust_2018_idioms)] + +pub mod api; +pub mod consumer; +pub mod dlq; +pub mod error; +pub mod ids; +pub mod models; +pub mod producer; +pub mod sdk; +pub mod typed_event; + +#[cfg(test)] +mod api_tests; + +#[cfg(feature = "test-util")] +pub mod mock; + +pub use api::{ + AssignedPartition, BarrierMode, EventBroker, EventBrokerBackend, IngestOutcome, JoinRequest, + ProducerCursor, ProducerMode, ResolvedPosition, SeekResult, StorageBackendConfig, + SubscriptionAssignment, TenantTraversalDepth, +}; +pub use consumer::{ + BatchHandlerOutcome, CommitOffset, ConnectionDropReason, Consumer, ConsumerBatching, + ConsumerBuffering, ConsumerBuilder, ConsumerCommitMode, ConsumerGroupRef, ConsumerHandler, + ConsumerListenerSettings, ConsumerProfile, ConsumerRetry, ConsumerRuntimeEvent, + ConsumerRuntimeListener, ConsumerSettings, ConsumerSettingsOverrides, ConsumerSlowDetection, + ControlCode, EventBatch, EventTypeRef, Fallback, FilterEngineRef, FrameStream, HandlerOutcome, + InMemoryOffsetManager, OffsetStore, PartitionBufferState, PartitionBufferStateSnapshot, + PartitionPosition, PartitionProgress, PartitionSlot, RawEvent, SeekPosition, + SingleEventHandler, SlowConsumerTrigger, SubscriptionFilterRef, SubscriptionInterest, TopicRef, + WireEvent, WireFrame, +}; +#[cfg(feature = "db")] +pub use consumer::{ + CommitOffsetInTx, LocalDbOffsetManager, TxCommitHandle, TxConsumerHandler, + TxSingleEventHandler, WithTx, +}; + +pub use error::{ConsumerError, EventBrokerError, OffsetManagerError, StorageBackendError}; +pub use ids::{ConsumerGroupId, EventTypeId, ProducerId, SubscriptionId, TopicId}; +pub use models::{ + ConsumerGroup, ConsumerGroupKind, ConsumerGroupQuery, CreateConsumerGroupRequest, Event, + EventType, Page, PartitionAssignment, PartitionLeader, PartitionRange, ResetScope, + Subscription, Topic, TopicSegment, +}; +#[cfg(feature = "db")] +pub use producer::{ + DbDeduplication, DbProducer, DbProducerBuilder, ManagedDeduplication, + MissingProducerRegistration, UnknownProducerRegistration, producer_registration_migrations, +}; +pub use producer::{ + DirectDeduplication, Producer, ProducerBuilder, ProducerIdentity, ValidationTiming, +}; +#[cfg(feature = "outbox")] +pub use producer::{ProducerOutbox, ProducerOutboxHandle, ProducerOutboxQueue}; +pub use sdk::EventBrokerSdk; +pub use typed_event::{EnvelopedEvent, TypedEvent}; diff --git a/gears/system/event-broker/event-broker-sdk/src/mock/backend.rs b/gears/system/event-broker/event-broker-sdk/src/mock/backend.rs new file mode 100644 index 000000000..e4bb229ba --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/src/mock/backend.rs @@ -0,0 +1,131 @@ +use async_trait::async_trait; +use toolkit_security::SecurityContext; + +use crate::api::EventBrokerBackend; +use crate::error::StorageBackendError; +use crate::models::Event; +use crate::models::{PartitionLeader, PartitionRange, TopicSegment}; + +use super::core::MockBroker; +use super::ingest::ingest_one; + +/// The mock implements `EventBrokerStorageBackend` over the same `Core.log` as +/// the transport seam (O2 - flattening playground). `persist` goes through the +/// same `ingest_one` pipeline so storage and transport stay in sync. +/// +/// `truncate` and `segments` are stubs marked with `TODO(M5b-flattening)` - +/// they will become the primary surface when the backend-contract is reshaped to +/// the PRD's `persist/query/truncate/segments` form. +#[async_trait] +impl EventBrokerBackend for MockBroker { + async fn persist( + &self, + _ctx: &SecurityContext, + topic: &str, + _partition: u32, + events: &[Event], + ) -> Result<(), StorageBackendError> { + { + let faults = self.faults.lock().await; + if let Some(reason) = &faults.reject_persist { + return Err(StorageBackendError::PersistFailed { + reason: reason.clone(), + detail: "fault injected via MockBrokerHandle::reject_persist".to_owned(), + instance: String::new(), + }); + } + } + let mut core = self.core.lock().await; + for event in events { + let mut e = event.clone(); + if e.topic.is_empty() { + e.topic = topic.to_owned(); + } + ingest_one(&mut core, &e).map_err(|err| StorageBackendError::PersistFailed { + reason: err.to_string(), + detail: String::new(), + instance: String::new(), + })?; + } + self.notify.notify_waiters(); + Ok(()) + } + + async fn read( + &self, + _ctx: &SecurityContext, + topic: &str, + partition: u32, + start_offset: i64, + max_count: usize, + ) -> Result, StorageBackendError> { + let core = self.core.lock().await; + Ok(core + .topics + .get(topic) + .map(|t| { + t.read(partition, start_offset, max_count) + .into_iter() + .map(|se| se.event.clone()) + .collect() + }) + .unwrap_or_default()) + } + + async fn query( + &self, + _ctx: &SecurityContext, + topic: &str, + partition: u32, + _range: PartitionRange, + ) -> Result, StorageBackendError> { + let core = self.core.lock().await; + let t = match core.topics.get(topic) { + Some(t) => t, + None => return Ok(vec![]), + }; + let log = t.log.get(&partition); + Ok(if let Some(events) = log { + if events.is_empty() { + vec![] + } else { + let start = events.first().and_then(|e| e.event.sequence).unwrap_or(0); + let end = events.last().and_then(|e| e.event.sequence).unwrap_or(0); + let ts = events + .first() + .and_then(|e| e.event.sequence_time) + .unwrap_or_else(chrono::Utc::now); + let te = events + .last() + .and_then(|e| e.event.sequence_time) + .unwrap_or_else(chrono::Utc::now); + vec![TopicSegment { + topic: topic.to_owned(), + partition, + start_sequence: start, + end_sequence: end, + start_time: ts, + end_time: te, + segments: vec![], + }] + } + } else { + vec![] + }) + } + + async fn list_partition_leaders( + &self, + _ctx: &SecurityContext, + topic: &str, + ) -> Result, StorageBackendError> { + let core = self.core.lock().await; + let partitions = core.topics.get(topic).map(|t| t.partitions).unwrap_or(0); + Ok((0..partitions) + .map(|p| PartitionLeader { + partition: p, + endpoint: "mock://in-process".to_owned(), + }) + .collect()) + } +} diff --git a/gears/system/event-broker/event-broker-sdk/src/mock/control.rs b/gears/system/event-broker/event-broker-sdk/src/mock/control.rs new file mode 100644 index 000000000..90d517c26 --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/src/mock/control.rs @@ -0,0 +1,272 @@ +use std::sync::Arc; + +use serde_json::Value; +use tokio::sync::Mutex; + +use crate::ids::{ConsumerGroupId, ProducerId, SubscriptionId}; + +/// A `(topic, partition)` pair from a subscription assignment. +/// Returned by [`MockBrokerHandle::assignment`]. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct PartitionSlot { + pub topic: String, + pub partition: u32, +} + +use super::core::{Core, EventTypeReg, FaultConfig, MockBroker, StoredEvent, TopicState}; + +/// Test-facing control API over `MockBroker`. +/// +/// Obtained via `MockBrokerHandle::new(mock)` or `MockBroker::handle()`. +/// Provides setup, fault injection, and assertion helpers without going through +/// the transport trait. +#[derive(Clone, Debug)] +pub struct MockBrokerHandle { + core: Arc>, + faults: Arc>, +} + +impl MockBrokerHandle { + pub fn from_broker(broker: &MockBroker) -> Self { + Self { + core: broker.core.clone(), + faults: broker.faults.clone(), + } + } + + // -- Setup ----------------------------------------------------------------- + + /// Register a topic. `id` must be a GTS topic identifier: + /// `gts.cf.core.events.topic.v1~.<...>.v1`. + /// + /// # Panics + /// Panics if `id` is not a valid GTS identifier (must start with `gts.` and contain `~`) + /// or if `partitions` is zero. + pub async fn register_topic(&self, id: &str, partitions: u32) { + assert_gts_topic(id); + assert!(partitions > 0, "topic partitions must be greater than zero"); + + let mut core = self.core.lock().await; + core.topics + .entry(id.to_owned()) + .or_insert_with(|| TopicState::new(partitions)); + } + + /// Provision a NAMED consumer group - the `types_registry` startup-upsert + /// analog. Named groups are not minted via `POST /v1/consumer-groups`; module + /// code registers them at startup. A subsequent JOIN to the named identifier + /// is then permitted (the `:consume` grant is an HTTP-layer authz concern, + /// out of the mock's scope). + pub async fn register_named_group(&self, gts_id: &str) { + let mut core = self.core.lock().await; + core.groups_registry.insert( + ConsumerGroupId::from_gts(gts_id), + super::core::GroupReg { + kind: crate::models::ConsumerGroupKind::Named, + owner_tenant: uuid::Uuid::nil(), + owner_principal: "types-registry".to_owned(), + }, + ); + } + + /// Register an event type on an already-registered topic. + /// Both `topic` and `type_id` must be GTS identifiers. + /// + /// # Panics + /// Panics if either identifier is not a valid GTS identifier. + pub async fn register_event_type( + &self, + topic: &str, + type_id: &str, + data_schema: Value, + allowed_subjects: &[&str], + ) { + assert_gts_topic(topic); + assert_gts_event_type(type_id); + let mut core = self.core.lock().await; + if let Some(t) = core.topics.get_mut(topic) { + t.event_types.insert( + type_id.to_owned(), + EventTypeReg { + data_schema, + allowed_subject_types: allowed_subjects.iter().map(|s| s.to_string()).collect(), + }, + ); + } + } + + /// Remove a producer registration and its cursor state. + pub async fn forget_producer(&self, producer_id: ProducerId) { + let mut core = self.core.lock().await; + core.producers.remove(&producer_id); + core.producer_state + .retain(|(pid, _, _), _| *pid != producer_id); + } + + // -- Fault injection ------------------------------------------------------- + + /// Cause the next `stream()` poll for this subscription to return a 410-equivalent error. + pub async fn inject_gone(&self, sub: SubscriptionId) { + self.faults.lock().await.force_gone.insert(sub); + } + + /// Cause the next `stream()` poll for this subscription to return a 404-equivalent error. + pub async fn inject_not_found(&self, sub: SubscriptionId) { + self.faults.lock().await.force_not_found.insert(sub); + } + + /// Immediately fire session_timeout for this subscription, triggering a rebalance. + /// Simulates a crash (C6) or standby takeover (C9) without waiting for real wall-clock expiry. + pub async fn expire_subscription(&self, sub_id: SubscriptionId) { + let mut core = self.core.lock().await; + let group_id = match core.subscriptions.get(&sub_id).map(|s| s.group) { + Some(g) => g, + None => return, + }; + core.subscriptions.remove(&sub_id); + if let Some(group) = core.groups.get_mut(&group_id) { + group.members.retain(|m| *m != sub_id); + } + super::rebalance::run_rebalance(&group_id, &mut core); + } + + /// Force a rebalance on a group (direct trigger, no membership change). + pub async fn force_rebalance(&self, group: &ConsumerGroupId) { + let mut core = self.core.lock().await; + super::rebalance::run_rebalance(group, &mut core); + } + + /// Reject the next `persist` / `publish` call with an error (M3 chain-gap surface). + /// Pass `None` to clear the rule. + pub async fn reject_persist(&self, reason: Option<&str>) { + self.faults.lock().await.reject_persist = reason.map(str::to_owned); + } + + /// Set the producer publish rate-limit allowance. `Some(n)` lets the next + /// `n` publishes through (single publish, or per-event within a batch), then + /// further publishes return `EventBrokerError::RateLimited` (429-equivalent). + /// `Some(0)` refuses the very next publish; `None` clears the limit. + pub async fn set_publish_rate_limit(&self, limit: Option) { + self.faults.lock().await.publish_rate_limit = limit; + } + + /// Set the heartbeat interval for stream tests. Default is 5s; set to a tiny value + /// for tests that need to observe a heartbeat quickly. + pub async fn set_heartbeat_interval(&self, d: std::time::Duration) { + self.faults.lock().await.heartbeat_interval = d; + } + + // -- Assertions ------------------------------------------------------------ + + /// Current `cursor.offset` for `(group, topic, partition)`, or `None` if not set. + pub async fn cursor( + &self, + group: &ConsumerGroupId, + topic: &str, + partition: u32, + ) -> Option { + self.core + .lock() + .await + .groups + .get(group) + .and_then(|g| g.cursor.get(&(topic.to_owned(), partition))) + .map(|c| c.offset) + } + + /// Current `cursor.last_examined` for `(group, topic, partition)`, or `None` if not set. + pub async fn last_examined( + &self, + group: &ConsumerGroupId, + topic: &str, + partition: u32, + ) -> Option { + self.core + .lock() + .await + .groups + .get(group) + .and_then(|g| g.cursor.get(&(topic.to_owned(), partition))) + .map(|c| c.last_examined) + } + + /// Partitions currently assigned to a subscription. + pub async fn assignment(&self, sub: SubscriptionId) -> Vec { + self.core + .lock() + .await + .subscriptions + .get(&sub) + .map(|s| { + s.assigned + .iter() + .map(|(topic, partition)| PartitionSlot { + topic: topic.clone(), + partition: *partition, + }) + .collect() + }) + .unwrap_or_default() + } + + /// Active member subscription ids in a group. + pub async fn members(&self, group: &ConsumerGroupId) -> Vec { + self.core + .lock() + .await + .groups + .get(group) + .map(|g| g.members.clone()) + .unwrap_or_default() + } + + /// All stored events on a `(topic, partition)`. + pub async fn stored(&self, topic: &str, partition: u32) -> Vec { + self.core + .lock() + .await + .topics + .get(topic) + .and_then(|t| t.log.get(&partition)) + .cloned() + .unwrap_or_default() + } + + /// Current `topology_version` for a group. + pub async fn topology_version(&self, group: &ConsumerGroupId) -> i64 { + self.core + .lock() + .await + .groups + .get(group) + .map(|g| g.topology_version) + .unwrap_or(0) + } +} + +// -- GTS format validation ----------------------------------------------------- + +/// Assert that a string is a valid GTS identifier, using the `gts-id` library. +/// +/// # Panics +/// Panics with the parse error if `id` is not a valid GTS identifier. +fn assert_gts(id: &str, context: &str) { + if let Err(e) = gts_id::GtsId::try_new(id) { + panic!("mock: {context} must be a GTS identifier, got {id:?}: {e}"); + } +} + +pub(super) fn assert_gts_topic(id: &str) { + assert_gts(id, "topic id"); +} + +pub(super) fn assert_gts_event_type(id: &str) { + assert_gts(id, "event type id"); +} + +impl MockBroker { + /// Get a test-facing handle for setup, fault injection, and assertions. + pub fn handle(&self) -> MockBrokerHandle { + MockBrokerHandle::from_broker(self) + } +} diff --git a/gears/system/event-broker/event-broker-sdk/src/mock/control_tests.rs b/gears/system/event-broker/event-broker-sdk/src/mock/control_tests.rs new file mode 100644 index 000000000..395f196be --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/src/mock/control_tests.rs @@ -0,0 +1,29 @@ +use crate::api::EventBroker; +use crate::mock::MockBroker; +use toolkit_gts::gts_id; + +const TOPIC: &str = gts_id!("cf.core.events.topic.v1~example.mock.control.zero.v1"); + +#[tokio::test] +async fn register_topic_rejects_zero_partitions_without_registering_topic() { + let broker = MockBroker::new(); + let handle = broker.handle(); + + let err = tokio::spawn(async move { + handle.register_topic(TOPIC, 0).await; + }) + .await + .expect_err("zero partitions must panic"); + + assert!(err.is_panic(), "zero partitions must fail immediately"); + + let topics = broker + .list_topics(&toolkit_security::SecurityContext::anonymous()) + .await + .expect("listing topics must succeed"); + + assert!( + topics.iter().all(|topic| topic.id != TOPIC), + "failed zero-partition registration must not leave topic visible" + ); +} diff --git a/gears/system/event-broker/event-broker-sdk/src/mock/core.rs b/gears/system/event-broker/event-broker-sdk/src/mock/core.rs new file mode 100644 index 000000000..700c804f8 --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/src/mock/core.rs @@ -0,0 +1,246 @@ +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use tokio::sync::{Mutex, Notify}; +use uuid::Uuid; + +use crate::api::{ProducerMode, SubscriptionInterest}; +use crate::ids::{ConsumerGroupId, ProducerId, SubscriptionId}; +use crate::models::ConsumerGroupKind; +use crate::models::Event; + +// --- Topic & event log -------------------------------------------------------- + +#[derive(Debug, Clone)] +pub(super) struct EventTypeReg { + pub data_schema: serde_json::Value, + pub allowed_subject_types: Vec, +} + +/// Append-only event stored in the mock log. +#[derive(Debug, Clone)] +pub struct StoredEvent { + pub event: Event, +} + +#[derive(Debug, Clone)] +pub(super) struct TopicState { + pub partitions: u32, + pub event_types: HashMap, // type_id → reg + pub log: HashMap>, // partition → events (offset == index) + pub next_offset: HashMap, +} + +impl TopicState { + pub(super) fn new(partitions: u32) -> Self { + Self { + partitions, + event_types: HashMap::new(), + log: HashMap::new(), + next_offset: HashMap::new(), + } + } + + pub(super) fn next_offset_for(&mut self, partition: u32) -> i64 { + // Offsets are 1-based (A6/A7): sequence floor is 1, never 0. + *self.next_offset.entry(partition).or_insert(1) + } + + pub(super) fn append(&mut self, partition: u32, event: Event) -> i64 { + // Offsets are 1-based (A6/A7): the first event on a partition is offset 1. + let offset = self.next_offset.entry(partition).or_insert(1); + let assigned = *offset; + self.log + .entry(partition) + .or_default() + .push(StoredEvent { event }); + *offset += 1; + assigned + } + + pub(super) fn read( + &self, + partition: u32, + start_offset: i64, + max_count: usize, + ) -> Vec<&StoredEvent> { + self.log + .get(&partition) + .map(|log| { + log.iter() + .skip(start_offset.max(0) as usize) + .take(max_count) + .collect() + }) + .unwrap_or_default() + } +} + +// --- Producer state ----------------------------------------------------------- + +#[derive(Debug, Clone)] +pub(super) struct ProducerReg { + pub mode: ProducerMode, +} + +// --- Consumer group ----------------------------------------------------------- + +#[derive(Debug, Clone)] +pub(super) struct GroupReg { + pub kind: ConsumerGroupKind, + pub owner_tenant: Uuid, + pub owner_principal: String, +} + +/// Group-scoped cursor position (per-`(topic, partition)`). +#[derive(Debug, Clone, Default)] +pub struct CursorEntry { + /// Session cursor set by SEEK. Broker emits from offset+1. + pub offset: i64, + /// Highest offset the broker has scanned for this group/partition (offset-adviser). + pub last_examined: i64, +} + +/// Runtime state for a group with ≥1 active subscription. +#[derive(Debug)] +pub(super) struct GroupState { + /// Sorted by `(created_at, id)` for deterministic v1 rebalance. + pub members: Vec, + /// Per-group topology version - bumped on every JOIN/LEAVE/expiry. + pub topology_version: i64, + /// Inverted map: `(topic, partition)` → owning subscription. + pub assignments: HashMap<(String, u32), SubscriptionId>, + /// Group-scoped cursors (sticky across subscription churn). + pub cursor: HashMap<(String, u32), CursorEntry>, +} + +impl GroupState { + pub(super) fn new() -> Self { + Self { + members: Vec::new(), + topology_version: 0, + assignments: HashMap::new(), + cursor: HashMap::new(), + } + } +} + +// --- Subscription state ------------------------------------------------------- + +/// Per-subscription ephemeral state (DESIGN §3.1 Subscription schema, wire-aligned). +#[derive(Debug)] +pub(super) struct SubState { + pub group: ConsumerGroupId, + /// Per-member interests (topic-anchored typed-filter selections; C8/C8a rolling deploy). + pub interests: Vec, + /// Derived from interests; used by rebalance eligibility check. + pub topics: HashSet, + /// Partitions owned by this subscription (updated on every rebalance). + pub assigned: Vec<(String, u32)>, + /// Topology version *at last poll* - consumer detects change by comparing. + pub topology_version: i64, + /// Sort key for deterministic v1 round-robin rebalance. + pub created_at: Instant, + pub session_timeout: Duration, + /// Refreshed on stream/seek; `expires_at = now + session_timeout`. + pub expires_at: Instant, + /// Explicit per-partition seek override (set by SEEK; last-processed offset). + pub seek: HashMap<(String, u32), i64>, + /// Highest offset delivered per `(topic, partition)` (last-processed; SEEK is the + /// only cursor-advance mechanism - there is no ack). Reset on partition migration + /// for at-least-once redelivery. + pub sent: HashMap<(String, u32), i64>, + /// Highest offset SCANNED per `(topic, partition)` regardless of filter match + /// (the offset-adviser frontier). Drives `Control{Progress}` emission and the + /// read skip-cursor so server-side-filtered events are not re-scanned. + pub scanned: HashMap<(String, u32), i64>, + /// Set when the subscription is terminated by a gain / lose-all rebalance + /// (after emitting the terminal control frame). Any reuse of this + /// `subscription_id` thereafter returns `410 SubscriptionTerminated`. + pub terminated: bool, +} + +// --- Fault injection ---------------------------------------------------------- + +#[derive(Debug, Default)] +pub struct FaultConfig { + /// Immediately terminates the stream with a 410-equivalent error. + pub force_gone: HashSet, + /// Immediately terminates the stream with a 404-equivalent error. + pub force_not_found: HashSet, + /// Immediately fires session_timeout for this sub → triggers rebalance (C6/C9). + pub expire_sub: HashSet, + /// If set, `persist` and `publish` return an error matching the rule (M3 chain-gap surface). + pub reject_persist: Option, + /// Producer rate-limit allowance. When `Some(n)`, the next `n` publishes + /// (single or per-event in a batch) succeed; once the allowance is exhausted, + /// further publishes return `EventBrokerError::RateLimited` (429-equivalent). + /// `Some(0)` refuses the very next publish. `None` disables the limit. + pub publish_rate_limit: Option, + /// Heartbeat cadence for the stream. Tests set it tiny/zero to trigger heartbeats quickly. + pub heartbeat_interval: Duration, +} + +impl FaultConfig { + pub fn new() -> Self { + Self { + heartbeat_interval: Duration::from_secs(5), + ..Default::default() + } + } +} + +// --- Core aggregate ----------------------------------------------------------- + +#[derive(Debug, Default)] +pub struct Core { + pub(super) topics: HashMap, + pub(super) producers: HashMap, + /// Chain dedup state: last_sequence per `(producer_id, topic, partition)`. + pub(super) producer_state: HashMap<(ProducerId, String, u32), i64>, + /// ALL registered groups (exists before any JOIN; needed for NotFound vs HasActiveMembers). + pub(super) groups_registry: HashMap, + /// Groups with ≥1 active subscription. + pub(super) groups: HashMap, + pub(super) subscriptions: HashMap, +} + +// --- MockBroker --------------------------------------------------------------- + +/// In-memory mock Event Broker. Implements `EventBrokerTransport`, `EventBroker`, +/// and `EventBrokerStorageBackend`. All state is shared behind `Arc>`. +/// +/// Obtain via `MockBroker::new()` and pass to producers: +/// ```ignore +/// let mock = MockBroker::new(); +/// let producer = Producer::builder().broker(Arc::new(mock.clone())); +/// ``` +#[derive(Clone, Debug)] +pub struct MockBroker { + pub(super) core: Arc>, + /// Fires on every `publish`/`persist` to wake waiting stream readers. + pub(super) notify: Arc, + pub(super) faults: Arc>, + /// Subscriptions with a currently-open stream. A second `stream()` or any + /// `seek()` while present is rejected with `StreamingInProgress` (A2). A + /// `std::sync::Mutex` so the stream's `Drop` guard can clear it synchronously. + pub(super) streaming: Arc>>, +} + +impl MockBroker { + pub fn new() -> Self { + Self { + core: Arc::new(Mutex::new(Core::default())), + notify: Arc::new(Notify::new()), + faults: Arc::new(Mutex::new(FaultConfig::new())), + streaming: Arc::new(std::sync::Mutex::new(HashSet::new())), + } + } +} + +impl Default for MockBroker { + fn default() -> Self { + Self::new() + } +} diff --git a/gears/system/event-broker/event-broker-sdk/src/mock/ingest.rs b/gears/system/event-broker/event-broker-sdk/src/mock/ingest.rs new file mode 100644 index 000000000..17fa65265 --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/src/mock/ingest.rs @@ -0,0 +1,299 @@ +use chrono::Utc; + +use crate::api::{IngestOutcome, ProducerMode}; +use crate::error::EventBrokerError; +use crate::ids::ProducerId; +use crate::models::Event; + +use super::core::Core; +use super::partitioning::partition_for; + +/// Infer the producer mode from the `meta` chain fields on the event. +fn detect_mode(event: &Event) -> ProducerMode { + match &event.meta { + Some(m) if m.producer_id.is_some() && m.previous.is_some() && m.sequence.is_some() => { + ProducerMode::Chained + } + Some(m) if m.producer_id.is_some() && m.sequence.is_some() => ProducerMode::Monotonic, + _ => ProducerMode::Stateless, + } +} + +/// Core ingest pipeline. Called under the `Core` mutex (held by caller). +/// +/// Steps: topic lookup → event-type lookup → schema validation → partition +/// derivation (ADR-0002: `partition_key` else `tenant_id`) → mode detection → +/// chain/monotonic dedup → offset assignment → append to log. +/// +/// Returns `(IngestOutcome, stamped_event)` on success where the stamped event +/// has `partition`/`sequence`/`offset` populated. +pub(super) fn ingest_one( + core: &mut Core, + event: &Event, +) -> Result<(IngestOutcome, Event), EventBrokerError> { + // -- 0. GTS format validation ---------------------------------------------- + if let Err(e) = gts_id::GtsId::try_new(&event.topic) { + return Err(EventBrokerError::InvalidEventField { + field: "topic", + detail: format!("topic must be a GTS identifier: {e}"), + instance: String::new(), + }); + } + if let Err(e) = gts_id::GtsId::try_new(&event.type_id) { + return Err(EventBrokerError::InvalidEventField { + field: "type", + detail: format!("event type must be a GTS identifier: {e}"), + instance: String::new(), + }); + } + if event.partition.is_some() { + return Err(EventBrokerError::InvalidEventField { + field: "partition", + detail: "partition is broker-stamped and read-only on publish".to_owned(), + instance: "/v1/events".to_owned(), + }); + } + + // -- 1. Topic lookup ------------------------------------------------------- + if !core.topics.contains_key(&event.topic) { + return Err(EventBrokerError::TopicNotFound { + topic: event.topic.clone(), + detail: format!("topic '{}' not registered in mock", event.topic), + instance: String::new(), + }); + } + + // -- 2. Event-type lookup -------------------------------------------------- + let has_event_type = core + .topics + .get(&event.topic) + .map(|t| t.event_types.contains_key(&event.type_id)) + .unwrap_or(false); + if !has_event_type { + // No event type registered - if the mock is in permissive mode + // (no event types registered at all), skip validation. Otherwise error. + let any_types = core + .topics + .get(&event.topic) + .map(|t| !t.event_types.is_empty()) + .unwrap_or(false); + if any_types { + return Err(EventBrokerError::EventTypeUnknown { + type_id: event.type_id.clone(), + detail: format!("event type '{}' not registered in mock", event.type_id), + instance: String::new(), + }); + } + // Permissive: no event types registered → skip schema validation. + } else { + let reg = core + .topics + .get(&event.topic) + .and_then(|t| t.event_types.get(&event.type_id)) + .expect("event type presence checked above"); + if !reg.allowed_subject_types.is_empty() + && !reg + .allowed_subject_types + .iter() + .any(|allowed| allowed == &event.subject_type) + { + return Err(EventBrokerError::InvalidEventField { + field: "subject_type", + detail: format!( + "subject_type '{}' is not allowed for event type '{}'", + event.subject_type, event.type_id + ), + instance: "/v1/events".to_owned(), + }); + } + + // -- 3. Payload schema validation (M4) --------------------------------- + let schema_val = Some(reg.data_schema.clone()); + + if let (Some(data), Some(schema_val)) = (&event.data, schema_val) { + match jsonschema::validator_for(&schema_val) { + Ok(validator) => { + let errs: Vec = + validator.iter_errors(data).map(|e| e.to_string()).collect(); + if !errs.is_empty() { + let detail = errs.join("; "); + return Err(EventBrokerError::EventDataInvalid { + type_id: event.type_id.clone(), + errors: vec![detail.clone()], + detail, + instance: String::new(), + }); + } + } + Err(e) => { + return Err(EventBrokerError::EventDataInvalid { + type_id: event.type_id.clone(), + errors: vec![e.to_string()], + detail: format!("schema compile error: {e}"), + instance: String::new(), + }); + } + } + } + } + + let partitions = core.topics[&event.topic].partitions; + + // -- 4. Partition derivation (ADR-0002: partition_key else tenant_id) ------- + let partition_key_owned; + let partition_input: &str = if let Some(pk) = event.partition_key.as_deref() { + pk + } else { + partition_key_owned = event.tenant_id.to_string(); + &partition_key_owned + }; + let partition = partition_for(partition_input, partitions); + + // -- 5. Producer mode detection -------------------------------------------- + let mode = detect_mode(event); + + // -- 6. Chain / monotonic dedup -------------------------------------------- + if mode != ProducerMode::Stateless { + let meta = event.meta.as_ref().expect("meta present for non-stateless"); + let producer_id = ProducerId(meta.producer_id.expect("producer_id present")); + // B1: a chained/monotonic publish must carry a registered Producer-Id + // (issued by POST /v1/producers). An unknown/expired id is rejected. + let producer_reg = core.producers.get(&producer_id).ok_or_else(|| { + EventBrokerError::UnknownProducer { + producer_id, + detail: format!( + "unknown producer_id {producer_id:?}; register via POST /v1/producers before publishing" + ), + instance: "/v1/events".to_owned(), + } + })?; + if producer_reg.mode != mode { + return Err(EventBrokerError::InvalidEventField { + field: "meta", + detail: format!( + "producer_id {producer_id:?} is registered as {:?}, but event metadata is {:?}", + producer_reg.mode, mode + ), + instance: "/v1/events".to_owned(), + }); + } + let seq = meta.sequence.expect("sequence present"); + let key = (producer_id, event.topic.clone(), partition); + let last = core.producer_state.get(&key).copied().unwrap_or(-1); + + match mode { + ProducerMode::Chained => { + let prev = meta.previous.expect("previous present for chained"); + if seq <= last { + // Duplicate - do NOT advance state (M2). + return Ok((IngestOutcome::Duplicate, event.clone())); + } + if prev != last { + return Err(EventBrokerError::SequenceViolation { + expected_previous: last, + detail: format!( + "expected previous={last}, got previous={prev} for ({producer_id:?}, {}, {partition})", + event.topic + ), + instance: String::new(), + }); + } + core.producer_state.insert(key, seq); + } + ProducerMode::Monotonic => { + if seq <= last { + return Ok((IngestOutcome::Duplicate, event.clone())); + } + core.producer_state.insert(key, seq); + } + ProducerMode::Stateless => unreachable!(), + } + } + + // -- 7. Offset assignment + append (serialised under Mutex → prevents M1) -- + let now = Utc::now(); + let topic_state = core.topics.get_mut(&event.topic).expect("checked above"); + let offset = topic_state.next_offset_for(partition); + let mut stamped = event.clone(); + stamped.partition = Some(partition); + stamped.sequence = Some(offset); + stamped.sequence_time = Some(now); + stamped.offset = Some(offset); + stamped.offset_time = Some(now); + // Strip writeOnly publish-input fields from the stored read-projection. + stamped.meta = None; + topic_state.append(partition, stamped.clone()); + + Ok((IngestOutcome::Accepted, stamped)) +} + +/// Batch ingest: all events must resolve to the same `(topic, partition)`. +/// Called under the `Core` mutex. +pub(super) fn ingest_batch( + core: &mut Core, + events: &[Event], +) -> Result, EventBrokerError> { + if events.is_empty() { + return Ok(vec![]); + } + + // Validate batch homogeneity (same topic + same partition key → same partition). + let first = &events[0]; + let partitions = core + .topics + .get(&first.topic) + .map(|t| t.partitions) + .unwrap_or(1); + let first_partition_key_owned; + let first_partition_input: &str = if let Some(pk) = first.partition_key.as_deref() { + pk + } else { + first_partition_key_owned = first.tenant_id.to_string(); + &first_partition_key_owned + }; + let expected_partition = partition_for(first_partition_input, partitions); + + for event in events.iter().skip(1) { + if event.topic != first.topic { + return Err(EventBrokerError::InvalidEventField { + field: "topic", + detail: format!( + "batch.mixed_partition: all events must share the same topic; got '{}' and '{}'", + first.topic, event.topic + ), + instance: String::new(), + }); + } + let partition_key_owned; + let partition_input: &str = if let Some(pk) = event.partition_key.as_deref() { + pk + } else { + partition_key_owned = event.tenant_id.to_string(); + &partition_key_owned + }; + let this_partition = partition_for(partition_input, partitions); + if this_partition != expected_partition { + return Err(EventBrokerError::InvalidEventField { + field: "partition_key", + detail: format!( + "batch.mixed_partition: events resolve to different partitions ({expected_partition} vs {this_partition})" + ), + instance: String::new(), + }); + } + } + + let mut staged = Core { + topics: core.topics.clone(), + producers: core.producers.clone(), + producer_state: core.producer_state.clone(), + ..Core::default() + }; + let results = events + .iter() + .map(|event| ingest_one(&mut staged, event)) + .collect::, _>>()?; + core.topics = staged.topics; + core.producer_state = staged.producer_state; + Ok(results) +} diff --git a/gears/system/event-broker/event-broker-sdk/src/mock/mod.rs b/gears/system/event-broker/event-broker-sdk/src/mock/mod.rs new file mode 100644 index 000000000..9f5c41665 --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/src/mock/mod.rs @@ -0,0 +1,23 @@ +pub mod control; +pub mod core; + +#[cfg(test)] +mod tests; + +mod backend; +mod ingest; +mod partitioning; +#[cfg(test)] +mod partitioning_tests; +mod rebalance; +mod stream; +pub mod stubs; +mod transport; + +#[cfg(all(test, feature = "test-util"))] +mod control_tests; +#[cfg(all(test, feature = "test-util"))] +mod stream_tests; + +pub use control::{MockBrokerHandle, PartitionSlot}; +pub use core::{CursorEntry, MockBroker, StoredEvent}; diff --git a/gears/system/event-broker/event-broker-sdk/src/mock/partitioning.rs b/gears/system/event-broker/event-broker-sdk/src/mock/partitioning.rs new file mode 100644 index 000000000..897400829 --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/src/mock/partitioning.rs @@ -0,0 +1,5 @@ +use toolkit_stable_hash::murmur3_x86_32; + +pub(super) fn partition_for(key: &str, partition_count: u32) -> u32 { + (murmur3_x86_32(key.as_bytes(), 0) & 0x7FFF_FFFF) % partition_count +} diff --git a/gears/system/event-broker/event-broker-sdk/src/mock/partitioning_tests.rs b/gears/system/event-broker/event-broker-sdk/src/mock/partitioning_tests.rs new file mode 100644 index 000000000..f30703e04 --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/src/mock/partitioning_tests.rs @@ -0,0 +1,19 @@ +use super::partitioning::partition_for; + +#[test] +fn single_partition_is_always_zero() { + for key in ["foo", "bar", "tenant-id", ""] { + assert_eq!(partition_for(key, 1), 0); + } +} + +#[test] +fn partition_is_deterministic_and_within_bounds() { + for partition_count in [1_u32, 2, 8, 16, 64] { + for key in ["foo", "bar", "some-key", ""] { + let partition = partition_for(key, partition_count); + assert!(partition < partition_count); + assert_eq!(partition_for(key, partition_count), partition); + } + } +} diff --git a/gears/system/event-broker/event-broker-sdk/src/mock/rebalance.rs b/gears/system/event-broker/event-broker-sdk/src/mock/rebalance.rs new file mode 100644 index 000000000..b5af8cd71 --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/src/mock/rebalance.rs @@ -0,0 +1,106 @@ +use crate::ids::ConsumerGroupId; + +use super::core::Core; + +/// v1 round-robin rebalance per USE_CASES.md §5. +/// +/// For each topic in the group's effective topic set (union of all members' +/// topics), distributes partitions round-robin among members subscribed to +/// that topic, sorted by `(created_at, id)` for determinism. +/// +/// - Cursor entries are created-if-absent (sticky: existing `acked`/`last_examined` +/// survive partition handoff). +/// - `GroupState.assignments` inverted map is rebuilt from scratch. +/// - `GroupState.topology_version` is incremented. +/// - Each `SubState.assigned` and `SubState.topology_version` are updated. +pub(super) fn run_rebalance(group_id: &ConsumerGroupId, core: &mut Core) { + let group = match core.groups.get_mut(group_id) { + Some(g) => g, + None => return, + }; + + // Sort members by (created_at, id) for deterministic round-robin. + let mut members: Vec<_> = group + .members + .iter() + .filter_map(|sub_id| { + core.subscriptions + .get(sub_id) + .map(|s| (*sub_id, s.created_at, s.topics.clone())) + }) + .collect(); + members.sort_by_key(|(id, created_at, _)| (*created_at, id.0)); + + // Compute effective topic set: union of all members' topics. + let all_topics: std::collections::HashSet = members + .iter() + .flat_map(|(_, _, topics)| topics.iter().cloned()) + .collect(); + + // Build new assignments: (topic, partition) → sub_id. + let mut new_assignments = std::collections::HashMap::new(); + + for topic in &all_topics { + let partition_count = match core.topics.get(topic.as_str()) { + Some(t) => t.partitions, + None => continue, // topic not registered in mock - skip + }; + + // Only members subscribed to this topic are eligible. + let eligible: Vec<_> = members + .iter() + .filter(|(_, _, topics)| topics.contains(topic)) + .map(|(sub_id, _, _)| *sub_id) + .collect(); + + if eligible.is_empty() { + continue; + } + + let n = partition_count as usize; + let s = eligible.len(); + + if s >= n { + // More members than partitions: each partition gets one member. + for (i, sub_id) in eligible.iter().enumerate().take(n) { + new_assignments.insert((topic.clone(), i as u32), *sub_id); + } + } else { + // Distribute partitions round-robin. + let base = n / s; + let extra = n % s; + let mut cursor = 0usize; + for (i, sub_id) in eligible.iter().enumerate() { + let count = base + if i < extra { 1 } else { 0 }; + for p in cursor..cursor + count { + new_assignments.insert((topic.clone(), p as u32), *sub_id); + } + cursor += count; + } + } + } + + // Cursor entries are NOT pre-created here: a partition has no committed cursor + // until the consumer SEEKs it (contract: an unseeded partition → PositionsNotSet). + // Existing cursors (set by a prior SEEK) survive rebalance - they live in + // `group.cursor` and are never cleared here, preserving stickiness across handoff. + let group = core.groups.get_mut(group_id).unwrap(); + + // Update GroupState.assignments and bump topology_version. + group.assignments = new_assignments.clone(); + group.topology_version += 1; + let new_tv = group.topology_version; + + // Update each SubState.assigned and topology_version. + for (sub_id, _, _) in &members { + let sub_assignments: Vec<(String, u32)> = new_assignments + .iter() + .filter(|(_, owner)| *owner == sub_id) + .map(|((topic, partition), _)| (topic.clone(), *partition)) + .collect(); + if let Some(sub) = core.subscriptions.get_mut(sub_id) { + sub.assigned = sub_assignments; + sub.topology_version = new_tv; + } + } +} diff --git a/gears/system/event-broker/event-broker-sdk/src/mock/stream.rs b/gears/system/event-broker/event-broker-sdk/src/mock/stream.rs new file mode 100644 index 000000000..d3557f176 --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/src/mock/stream.rs @@ -0,0 +1,349 @@ +use std::collections::HashSet; +use std::pin::Pin; +use std::sync::{Arc, Mutex}; +use std::task::{Context, Poll}; + +use tokio::time::sleep; + +use crate::api::{ + ControlCode, FrameStream, PartitionPosition, PartitionSlot, WireEvent, WireFrame, +}; +use crate::error::EventBrokerError; +use crate::ids::SubscriptionId; + +use super::core::MockBroker; + +type AssignedPartitionRef = (String, u32); + +struct StreamGuard { + set: Arc>>, + id: SubscriptionId, +} + +impl Drop for StreamGuard { + fn drop(&mut self) { + if let Ok(mut s) = self.set.lock() { + s.remove(&self.id); + } + } +} + +struct GuardedStream { + inner: FrameStream, + _guard: StreamGuard, +} + +impl futures_core::Stream for GuardedStream { + type Item = Result; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + self.inner.as_mut().poll_next(cx) + } +} + +struct StreamBaseline { + topology_version: i64, + assigned: Vec, + positions: Vec, +} + +/// Match an event `type_id` against a subscription interest's GTS type patterns. +/// `"*"` matches everything; a trailing-`*` pattern is a prefix match; otherwise +/// an exact match. (Sufficient for the mock; full GTS wildcard semantics live in +/// the real filter engine.) +fn type_matches(patterns: &[String], type_id: &str) -> bool { + patterns.iter().any(|p| { + if p == "*" { + true + } else if let Some(prefix) = p.strip_suffix('*') { + type_id.starts_with(prefix) + } else { + p == type_id + } + }) +} + +/// Open a live `FrameStream` for the given subscription. +/// +/// Emits: +/// 1. Initial `Topology` frame. +/// 2. Events loop: deliver new events, emit `Heartbeat` on idle. +/// 3. `Topology` frame whenever the group's `topology_version` advances. +/// 4. Fault: `SubscriptionGone` (410) or `NotFound` (404) if injected. +pub(super) fn open_stream(broker: MockBroker, sub_id: SubscriptionId) -> FrameStream { + let guard = StreamGuard { + set: broker.streaming.clone(), + id: sub_id, + }; + let stream = async_stream::try_stream! { + // -- 0. Emit the open-time topology baseline (guaranteed first frame) -- + // Build the snapshot under the lock, then DROP the lock before yielding - + // holding `core` across a yield would deadlock any broker call (leave, + // rebalance) made while the stream is parked here. + // Compute the snapshot under the lock and return an owned Result, so the + // guard is dropped at the end of this block - BEFORE the `?` can yield an + // Err. Holding `core` across the error yield would deadlock any later + // broker call once the consumer stops polling the errored stream. + let baseline: Result = { + let core = broker.core.lock().await; + match core.subscriptions.get(&sub_id) { + None => Err(EventBrokerError::Internal(format!("subscription {sub_id:?} not found"))), + Some(sub) => { + let group = core.groups.get(&sub.group); + let topology_version = group.map(|g| g.topology_version).unwrap_or(0); + let assigned = sub.assigned.clone(); + let positions: Vec = assigned.iter().map(|(topic, partition)| { + let offset = group + .and_then(|g| g.cursor.get(&(topic.clone(), *partition))) + .map(|c| c.offset) + .unwrap_or(0); + let last_examined = core.topics.get(topic.as_str()) + .and_then(|t| t.next_offset.get(partition).copied()) + .unwrap_or(0) + .saturating_sub(1); + PartitionPosition { + slot: PartitionSlot { topic_ix: 0, partition: *partition }, + offset, + last_examined, + } + }).collect(); + Ok(StreamBaseline { + topology_version, + assigned, + positions, + }) + } + } + }; + let baseline = baseline?; + let baseline_tv = baseline.topology_version; + let mut current = baseline.assigned; + let baseline_positions = baseline.positions; + yield WireFrame::Topology { topology_version: baseline_tv, assigned: baseline_positions }; + // Track the topology version this stream has observed. The mock keeps + // `sub.topology_version` equal to the group's at all times, so we cannot + // rely on it to detect a change - compare against the group's tv directly. + let mut observed_tv = baseline_tv; + + // -- 1. Stream loop ---------------------------------------------------- + loop { + // Check fault injection first. + { + let faults = broker.faults.lock().await; + if faults.force_gone.contains(&sub_id) { + // 410 Gone - graceful shard shutdown. + Err(EventBrokerError::Internal( + "410: Subscription terminated; re-JOIN to recover".to_owned() + ))?; + } + if faults.force_not_found.contains(&sub_id) { + // 404 SubscriptionNotFound. + Err(EventBrokerError::Internal( + "404: Subscription not found or expired".to_owned() + ))?; + } + } + + // Detect a rebalance (topology change) on this subscription. + { + let mut core = broker.core.lock().await; + // Drop the guard BEFORE the error yield - holding `core` across the + // `?` would deadlock later broker calls once the consumer stops + // polling the errored stream. + if !core.subscriptions.contains_key(&sub_id) { + drop(core); + Err::<(), _>(EventBrokerError::Internal("subscription gone".to_owned()))?; + unreachable!(); + } + let sub = core.subscriptions.get(&sub_id).expect("present (checked above)"); + let group = core.groups.get(&sub.group); + let current_tv = group.map(|g| g.topology_version).unwrap_or(0); + if current_tv > observed_tv { + let new_assigned = sub.assigned.clone(); + let positions: Vec = new_assigned.iter().map(|(topic, partition)| { + let offset = group + .and_then(|g| g.cursor.get(&(topic.clone(), *partition))) + .map(|c| c.offset) + .unwrap_or(0); + let last_examined = core.topics.get(topic.as_str()) + .and_then(|t| t.next_offset.get(partition).copied()) + .unwrap_or(0) + .saturating_sub(1); + PartitionPosition { + slot: PartitionSlot { topic_ix: 0, partition: *partition }, + offset, + last_examined, + } + }).collect(); + let current_set: std::collections::HashSet<(String, u32)> = + current.iter().cloned().collect(); + let gained = new_assigned.iter().any(|p| !current_set.contains(p)); + let lose_all = new_assigned.is_empty(); + if gained || lose_all { + // Mark terminated so any reuse of this id returns 410, and + // remove it from group membership so it no longer holds + // partitions - the consumer must re-JOIN to get a fresh + // subscription. (The terminated SubState is retained for the + // 410-on-reuse signal; it just stops participating in rebalance.) + let group_id = core.subscriptions.get(&sub_id).map(|s| s.group); + if let Some(sub_mut) = core.subscriptions.get_mut(&sub_id) { + sub_mut.terminated = true; + } + if let Some(gid) = group_id + && let Some(group) = core.groups.get_mut(&gid) + { + group.members.retain(|m| *m != sub_id); + } + } + observed_tv = current_tv; + drop(core); + if gained || lose_all { + // Gain / lose-all → terminate: a `terminal` control frame with the + // complete final positions, then a graceful close. The consumer + // commits the positions and re-JOINs. + yield WireFrame::Control { + code: ControlCode::Terminal, + positions, + reason: Some(if lose_all { "lose_all".to_owned() } else { "rebalanced".to_owned() }), + }; + return; + } + // Loss / version-only change → non-terminal topology frame; keep streaming. + current = new_assigned; + yield WireFrame::Topology { topology_version: current_tv, assigned: positions }; + } + } + + // Collect pending events under the lock, then yield outside it. + let mut pending: Vec = Vec::new(); + // Sparse offset-adviser positions: partitions whose scan frontier advanced + // past what was delivered (events scanned but filtered out this round). + let mut progress_positions: Vec = Vec::new(); + { + let mut core = broker.core.lock().await; + let sub = match core.subscriptions.get(&sub_id) { + Some(s) => s, + None => break, + }; + let assigned = sub.assigned.clone(); + + for (topic, partition) in &assigned { + let sub = core.subscriptions.get(&sub_id).unwrap(); + let seek_offset = sub.seek.get(&(topic.clone(), *partition)).copied(); + let sent_offset = sub.sent.get(&(topic.clone(), *partition)).copied().unwrap_or(0); + let scanned_off = sub.scanned.get(&(topic.clone(), *partition)).copied().unwrap_or(0); + // Skip past the seek floor AND whatever we've already scanned (incl. + // filtered events) so the scan frontier only moves forward. + let start = seek_offset.unwrap_or(0).max(scanned_off).max(sent_offset).max(0); + // Per-interest type-pattern filter for this topic (A5). `"*"` matches all. + let patterns: Vec = sub + .interests + .iter() + .find(|i| &i.topic == topic) + .map(|i| i.types.clone()) + .unwrap_or_else(|| vec!["*".to_owned()]); + + let events: Vec<_> = core.topics + .get(topic.as_str()) + .map(|t| t.read(*partition, start, 100).into_iter().map(|se| se.event.clone()).collect()) + .unwrap_or_default(); + + let mut frontier = start; + let mut last_delivered = sent_offset; + let mut round_delivered = 0usize; + for stamped in events { + let off = stamped.offset.unwrap_or(0); + frontier = frontier.max(off); // scanned regardless of filter match + if !type_matches(&patterns, &stamped.type_id) { + continue; // scanned but filtered out - advances the frontier only + } + last_delivered = off; + round_delivered += 1; + pending.push(WireFrame::Event(WireEvent { + id: stamped.id, + type_id: stamped.type_id.clone(), + topic: stamped.topic.clone(), + tenant_id: stamped.tenant_id, + subject: stamped.subject.clone(), + subject_type: stamped.subject_type.clone(), + partition_key: stamped.partition_key.clone(), + partition: stamped.partition.unwrap_or(*partition), + sequence: stamped.sequence.unwrap_or(0), + offset: stamped.offset.unwrap_or(0), + occurred_at: stamped.occurred_at, + sequence_time: stamped.sequence_time.unwrap_or_else(chrono::Utc::now), + trace_parent: stamped.trace_parent.clone(), + data: stamped.data.clone().unwrap_or(serde_json::Value::Null), + })); + } + + // Persist the scan frontier + delivered position under lock. + if let Some(sub_mut) = core.subscriptions.get_mut(&sub_id) { + sub_mut.scanned.insert((topic.clone(), *partition), frontier); + if round_delivered > 0 { + sub_mut.sent.insert((topic.clone(), *partition), last_delivered); + } + } + // Mirror the frontier into the group cursor's `last_examined`. + let group_id = core.subscriptions.get(&sub_id).map(|s| s.group); + if let Some(gid) = group_id + && let Some(g) = core.groups.get_mut(&gid) + { + let entry = g.cursor.entry((topic.clone(), *partition)).or_default(); + entry.last_examined = entry.last_examined.max(frontier); + } + // Drift: events were scanned-and-filtered (frontier moved) but none + // delivered this round → a sparse Progress position so the consumer + // learns the true frontier without re-scanning on reconnect (R57). + if round_delivered == 0 && frontier > last_delivered { + let delivered_pos = seek_offset.unwrap_or(0).max(last_delivered); + progress_positions.push(PartitionPosition { + slot: PartitionSlot { topic_ix: 0, partition: *partition }, + offset: delivered_pos, + last_examined: frontier, + }); + } + } + // Lock released here (end of block). + } + let delivered_any = !pending.is_empty(); + + // Yield collected frames outside the lock. + for frame in pending { + yield frame; + } + + // Emit a conditional, sparse Progress control frame for filter-saturated + // partitions (A5) - only when this round scanned events but delivered none. + if !progress_positions.is_empty() { + yield WireFrame::Control { + code: ControlCode::Progress, + positions: progress_positions, + reason: None, + }; + } + + if !delivered_any { + // Idle - wait for a new event or heartbeat timeout. + let heartbeat_interval = { + let faults = broker.faults.lock().await; + faults.heartbeat_interval + }; + + tokio::select! { + _ = broker.notify.notified() => { + // New event published or topology changed - loop again. + } + _ = sleep(heartbeat_interval) => { + yield WireFrame::Heartbeat { at: chrono::Utc::now().to_rfc3339() }; + } + } + } + } + }; + + Box::pin(GuardedStream { + inner: Box::pin(stream), + _guard: guard, + }) +} diff --git a/gears/system/event-broker/event-broker-sdk/src/mock/stream_tests.rs b/gears/system/event-broker/event-broker-sdk/src/mock/stream_tests.rs new file mode 100644 index 000000000..42774fcc7 --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/src/mock/stream_tests.rs @@ -0,0 +1,72 @@ +use std::time::Duration; +use toolkit_gts::gts_id; + +use uuid::Uuid; + +use crate::ResolvedPosition; +use crate::api::{ + BarrierMode, EventBroker, JoinRequest, SeekPosition, SubscriptionInterest, TenantTraversalDepth, +}; +use crate::mock::MockBroker; +use crate::mock::stubs::test_ctx_for_tenant; +use crate::models::CreateConsumerGroupRequest; + +const TOPIC: &str = gts_id!("cf.core.events.topic.v1~example.mock.stream.unit.v1"); + +#[tokio::test] +async fn dropping_unpolled_stream_clears_active_marker() { + let broker = MockBroker::new(); + broker.handle().register_topic(TOPIC, 1).await; + let ctx = test_ctx_for_tenant(Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap()); + let group = broker + .create_consumer_group( + &ctx, + CreateConsumerGroupRequest { + client_agent: "test-agent/1.0".to_owned(), + description: None, + }, + ) + .await + .unwrap() + .id; + let sub = broker + .join( + &ctx, + JoinRequest { + group, + client_agent: "test-consumer/1.0".to_owned(), + interests: vec![SubscriptionInterest { + topic: TOPIC.to_owned(), + tenant_id: Uuid::nil(), + tenant_depth: TenantTraversalDepth::CurrentTenant, + barrier_mode: BarrierMode::Respect, + types: vec!["*".to_owned()], + filter: None, + }], + session_timeout: Some(Duration::from_secs(30)), + }, + ) + .await + .unwrap(); + broker + .seek( + &ctx, + sub.subscription_id, + &[SeekPosition { + topic: TOPIC.to_owned(), + partition: 0, + value: ResolvedPosition::Earliest, + }], + ) + .await + .unwrap(); + + let stream = broker.stream(&ctx, sub.subscription_id).await.unwrap(); + drop(stream); + + let second = broker.stream(&ctx, sub.subscription_id).await; + assert!( + second.is_ok(), + "dropping an unpolled stream must clear StreamingInProgress" + ); +} diff --git a/gears/system/event-broker/event-broker-sdk/src/mock/stubs.rs b/gears/system/event-broker/event-broker-sdk/src/mock/stubs.rs new file mode 100644 index 000000000..ff519db24 --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/src/mock/stubs.rs @@ -0,0 +1,18 @@ +use toolkit_security::SecurityContext; +use uuid::Uuid; + +/// Build a pass-through `SecurityContext` for tests. +/// Uses `SecurityContext::anonymous()` as a base - sufficient for the mock +/// since it does no real authz/tenant resolution. +pub fn test_ctx_for_tenant(tenant_id: Uuid) -> SecurityContext { + SecurityContext::builder() + .subject_id(Uuid::new_v4()) + .subject_tenant_id(tenant_id) + .build() + .unwrap_or_else(|_| SecurityContext::anonymous()) +} + +/// Build a `SecurityContext` with a random tenant - useful for isolation between test cases. +pub fn random_ctx() -> SecurityContext { + test_ctx_for_tenant(Uuid::new_v4()) +} diff --git a/gears/system/event-broker/event-broker-sdk/src/mock/tests/consumer/flows.rs b/gears/system/event-broker/event-broker-sdk/src/mock/tests/consumer/flows.rs new file mode 100644 index 000000000..b385a7448 --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/src/mock/tests/consumer/flows.rs @@ -0,0 +1,408 @@ +//! Mirrors scenarios/consumer/flows/. Tests migrated per mock-reference-alignment. + +#[cfg(test)] +use super::super::helpers::*; + +use super::super::helpers::{broker_with_topic, ctx, ctx2, join_group, make_group, wire_event}; +use crate::ResolvedPosition; +use crate::api::EventBroker; +use crate::api::{ControlCode, SeekPosition, WireFrame}; +use futures_util::StreamExt; +use std::time::Duration; + +/// Read the next stream frame within a bounded timeout (live streams never end). +async fn next_frame( + s: &mut crate::api::FrameStream, +) -> Option> { + tokio::time::timeout(Duration::from_millis(200), s.next()) + .await + .unwrap_or_default() +} + +/// Scenario: consumer/flows/1.01-flow-two-consumer-rebalance.md +/// +/// A group grows from one consumer to two: A owns all 4 partitions and streams; B +/// JOINs, triggering a rebalance to 2/2; A's open stream receives a non-terminal +/// `Topology` frame (A loses 2 partitions, keeps streaming); B SEEKs its gained +/// partitions and streams. Strictly broker-observable. +#[tokio::test] +async fn s1_01_flow_two_consumer_rebalance() { + let (broker, h) = broker_with_topic(TOPIC, 4).await; + let c = ctx(); + let c2 = ctx2(); + let gid = make_group(&c, &broker).await; + + // Exchange 1 - A JOINs (owns all 4). + let sub_a = join_group(&c, &broker, &gid, TOPIC).await; + assert_eq!(sub_a.topology_version, 1); + assert_eq!( + h.assignment(sub_a.subscription_id).await.len(), + 4, + "A owns all 4 partitions" + ); + + // Exchange 2 - A SEEKs all four, then streams. + broker + .seek( + &c, + sub_a.subscription_id, + &(0u32..4) + .map(|p| SeekPosition { + topic: TOPIC.to_owned(), + partition: p, + value: ResolvedPosition::Earliest, + }) + .collect::>(), + ) + .await + .unwrap(); + let mut s_a = broker.stream(&c, sub_a.subscription_id).await.unwrap(); + let _ = next_frame(&mut s_a).await; // open-time baseline (4 partitions) + + // Exchange 3 - B JOINs (triggers rebalance to 2/2). + let sub_b = join_group(&c2, &broker, &gid, TOPIC).await; + assert_eq!(sub_b.topology_version, 2, "topology_version advances 1 → 2"); + assert_eq!( + h.assignment(sub_b.subscription_id).await.len(), + 2, + "B gains 2 partitions" + ); + assert_eq!( + h.assignment(sub_a.subscription_id).await.len(), + 2, + "A keeps 2 partitions" + ); + + // Exchange 4 - A's open stream receives a non-terminal topology frame. + let mut reduced = None; + for _ in 0..50 { + match next_frame(&mut s_a).await { + Some(Ok(WireFrame::Topology { + topology_version, + assigned, + })) => { + assert_eq!(topology_version, 2); + reduced = Some(assigned); + break; + } + Some(Ok(WireFrame::Control { .. })) => panic!("a loss must not terminate A"), + Some(Ok(_)) => {} + _ => {} + } + } + assert_eq!( + reduced.expect("A receives a topology frame").len(), + 2, + "A reduced to 2 partitions" + ); + + // Exchange 5/6 - B SEEKs its gained partitions and opens its stream. + let b_slots = h.assignment(sub_b.subscription_id).await; + broker + .seek( + &c2, + sub_b.subscription_id, + &b_slots + .iter() + .map(|sl| SeekPosition { + topic: sl.topic.clone(), + partition: sl.partition, + value: ResolvedPosition::Earliest, + }) + .collect::>(), + ) + .await + .unwrap(); + assert!( + broker.stream(&c2, sub_b.subscription_id).await.is_ok(), + "B opens its stream after seeding" + ); + + // No partition delivered to both A and B. + let a_set: std::collections::HashSet = h + .assignment(sub_a.subscription_id) + .await + .iter() + .map(|s| s.partition) + .collect(); + let b_set: std::collections::HashSet = b_slots.iter().map(|s| s.partition).collect(); + assert!( + a_set.is_disjoint(&b_set), + "no partition is owned by both A and B" + ); +} + +/// Scenario: consumer/flows/1.02-flow-positions-not-set-recovery.md +/// +/// DIVERGENCE (PositionsNotSet 409 is unrepresentable in the mock): the scenario's +/// recovery transcript is open-without-seed → `409 PositionsNotSet` → SEEK the listed +/// partitions → retry stream → `200`. The mock has no unseeded-stream precondition, so +/// the rejecting first exchange cannot occur (the stream opens unconditionally). The +/// representable half is exercised: SEEK seeds the partition, then the stream delivers +/// from the seeded floor. The 409-then-retry control flow is HTTP/SDK-layer. +#[tokio::test] +async fn s1_02_flow_positions_not_set_recovery() { + let (broker, h) = broker_with_topic(TOPIC, 1).await; + let c = ctx(); + broker + .publish(&c, &wire_event(TOPIC, EVT, c.subject_tenant_id())) + .await + .unwrap(); + let gid = make_group(&c, &broker).await; + let sub = join_group(&c, &broker, &gid, TOPIC).await; + + // Exchange 1 (divergence): opening before seeding does NOT 409 in the mock - it + // opens. We instead assert the recovery half directly. + assert_eq!( + h.cursor(&gid, TOPIC, 0).await, + None, + "no committed cursor before SEEK" + ); + + // Exchange 2 - SEEK the unseeded partition. + let results = broker + .seek( + &c, + sub.subscription_id, + &[SeekPosition { + topic: TOPIC.to_owned(), + partition: 0, + value: ResolvedPosition::Earliest, + }], + ) + .await + .unwrap(); + assert_eq!(results[0].offset, 0); + assert_eq!( + h.cursor(&gid, TOPIC, 0).await, + Some(0), + "SEEK seeds the cursor" + ); + + // Exchange 3 - the (re)opened stream delivers from the seeded floor. + let mut s = broker.stream(&c, sub.subscription_id).await.unwrap(); + let _ = next_frame(&mut s).await; // baseline + let mut got_event = false; + for _ in 0..10 { + match next_frame(&mut s).await { + Some(Ok(WireFrame::Event(e))) => { + assert_eq!( + e.offset, 1, + "emission begins at the seeded floor (1-based; Earliest→cursor 0, emit from 1)" + ); + got_event = true; + break; + } + Some(Ok(_)) => {} + _ => break, + } + } + assert!(got_event, "after seeding, the stream delivers the event"); +} + +/// Scenario: consumer/flows/1.03-flow-path-a-consumer-with-db.md +/// +/// A consumer with its own offset DB: on (re)start it JOINs, SEEKs from its stored +/// last-processed offsets, and streams. On reconnect after a crash it resumes from +/// the persisted offset with no reprocessing. (Exchanges 1/5 are consumer-internal.) +/// +/// DIVERGENCE (emit-from-offset semantics): the scenario treats the SEEK integer as +/// the *last-processed* offset and emits from `offset + 1`. The mock emits from the +/// SEEK offset *inclusive* (`start = seek_offset`), so a SEEK(510) delivers offset 510 +/// first. The broker-observable JOIN → SEEK(exact) → stream → re-JOIN → SEEK(exact) +/// resume-without-reprocessing path is asserted with the mock's inclusive semantics. +#[tokio::test] +async fn s1_03_flow_path_a_consumer_with_db() { + let (broker, h) = broker_with_topic(TOPIC, 1).await; + let c = ctx(); + // 600 events so exact offsets like 510 are valid. + for _ in 0..600 { + broker + .publish(&c, &wire_event(TOPIC, EVT, c.subject_tenant_id())) + .await + .unwrap(); + } + let gid = make_group(&c, &broker).await; + + // Exchange 2 - JOIN. + let sub = join_group(&c, &broker, &gid, TOPIC).await; + + // Exchange 3 - SEEK from the consumer's own DB (last-processed offset 510). + let results = broker + .seek( + &c, + sub.subscription_id, + &[SeekPosition { + topic: TOPIC.to_owned(), + partition: 0, + value: ResolvedPosition::Exact(510), + }], + ) + .await + .unwrap(); + assert_eq!( + results[0].offset, 510, + "exact stored offset is echoed verbatim" + ); + assert_eq!(h.cursor(&gid, TOPIC, 0).await, Some(510)); + + // Exchange 4 - stream delivers from the seeked offset (mock: inclusive → 510). + let mut s = broker.stream(&c, sub.subscription_id).await.unwrap(); + let _ = next_frame(&mut s).await; // baseline + let mut first_event_offset = None; + for _ in 0..10 { + match next_frame(&mut s).await { + Some(Ok(WireFrame::Event(e))) => { + first_event_offset = Some(e.offset); + break; + } + Some(Ok(_)) => {} + _ => break, + } + } + assert_eq!( + first_event_offset, + Some(511), + "emit from cursor+1: SEEK 510 (last-processed) → first delivered is 511" + ); + drop(s); + + // Exchange 6 - reconnect after crash: re-JOIN, SEEK with the next unprocessed + // offset (511), resume from 511 (mock inclusive). No reprocessing of 510. + broker.leave(&c, sub.subscription_id).await.unwrap(); + let sub2 = join_group(&c, &broker, &gid, TOPIC).await; + broker + .seek( + &c, + sub2.subscription_id, + &[SeekPosition { + topic: TOPIC.to_owned(), + partition: 0, + value: ResolvedPosition::Exact(511), + }], + ) + .await + .unwrap(); + let mut s2 = broker.stream(&c, sub2.subscription_id).await.unwrap(); + let _ = next_frame(&mut s2).await; // baseline + let mut resume_offset = None; + for _ in 0..10 { + match next_frame(&mut s2).await { + Some(Ok(WireFrame::Event(e))) => { + resume_offset = Some(e.offset); + break; + } + Some(Ok(_)) => {} + _ => break, + } + } + assert_eq!( + resume_offset, + Some(512), + "after reconnect, resumes from cursor+1 (SEEK 511 → first delivered 512)" + ); +} + +/// Scenario: consumer/flows/1.04-flow-leave-triggers-gain-terminate.md +/// +/// The gain → terminate transcript: A=[0,1], B=[2,3], both streaming. B LEAVEs; the +/// rebalance gives A all 4 (A GAINS). Because a gained partition is unseeded, A's +/// subscription terminates: its stream emits a `Control{code: Terminal}` carrying the +/// final positions, then closes. A re-JOINs (new id), SEEKs, reopens. Reuse of the old +/// (terminated) id is rejected (410-equivalent). +#[tokio::test] +async fn s1_04_flow_leave_triggers_gain_terminate() { + let (broker, h) = broker_with_topic(TOPIC, 4).await; + let c = ctx(); + let c2 = ctx2(); + let gid = make_group(&c, &broker).await; + + // A and B split 2/2; A is streaming. + let sub_a = join_group(&c, &broker, &gid, TOPIC).await; + // Seed while A still owns all 4 (before B joins); the kept partitions stay seeded. + super::super::helpers::seek_all_earliest(&c, &broker, &sub_a).await; + let sub_b = join_group(&c2, &broker, &gid, TOPIC).await; + assert_eq!(h.assignment(sub_a.subscription_id).await.len(), 2); + assert_eq!(h.assignment(sub_b.subscription_id).await.len(), 2); + + let mut s_a = broker.stream(&c, sub_a.subscription_id).await.unwrap(); + let _ = next_frame(&mut s_a).await; // open-time baseline (2 partitions) + + // Exchange 1 - B LEAVEs → rebalance gives A all 4 (A gains). + broker.leave(&c2, sub_b.subscription_id).await.unwrap(); + + // Exchange 2 - A's stream emits a terminal control frame, then closes. + let mut positions = None; + let mut saw_event_or_hb = Vec::new(); + for _ in 0..50 { + match next_frame(&mut s_a).await { + Some(Ok(WireFrame::Control { + code: ControlCode::Terminal, + positions: p, + reason, + })) => { + assert_eq!( + reason.as_deref(), + Some("rebalanced"), + "terminal reason is 'rebalanced'" + ); + positions = Some(p); + break; + } + Some(Ok(WireFrame::Event(_))) => saw_event_or_hb.push("event"), + Some(Ok(WireFrame::Heartbeat { .. })) => saw_event_or_hb.push("heartbeat"), + Some(Ok(WireFrame::Topology { .. })) => saw_event_or_hb.push("topology"), + Some(Ok(WireFrame::Control { .. })) => saw_event_or_hb.push("control(other)"), + Some(Err(e)) => panic!("stream error before terminal: {e:?}; saw {saw_event_or_hb:?}"), + None => break, + } + } + let positions = + positions.unwrap_or_else(|| panic!("no terminal control frame; saw {saw_event_or_hb:?}")); + assert!( + !positions.is_empty(), + "terminal frame carries the final positions" + ); + assert!( + next_frame(&mut s_a).await.is_none(), + "stream closes after the terminal frame" + ); + + // Exchange 3 - A re-JOINs (new subscription, owns all 4). + let sub_a2 = join_group(&c, &broker, &gid, TOPIC).await; + assert_ne!( + sub_a2.subscription_id, sub_a.subscription_id, + "a new subscription id is issued" + ); + assert_eq!( + h.assignment(sub_a2.subscription_id).await.len(), + 4, + "the re-JOIN owns all 4" + ); + + // Exchange 4 - A SEEKs all four and reopens. + broker + .seek( + &c, + sub_a2.subscription_id, + &(0u32..4) + .map(|p| SeekPosition { + topic: TOPIC.to_owned(), + partition: p, + value: ResolvedPosition::Earliest, + }) + .collect::>(), + ) + .await + .unwrap(); + assert!( + broker.stream(&c, sub_a2.subscription_id).await.is_ok(), + "A reopens on the new id" + ); + + // Exchange 5 - reuse of the old (terminated) id is rejected (410-equivalent). + assert!( + broker.stream(&c, sub_a.subscription_id).await.is_err(), + "reusing the terminated subscription id is rejected (410)" + ); +} diff --git a/gears/system/event-broker/event-broker-sdk/src/mock/tests/consumer/groups.rs b/gears/system/event-broker/event-broker-sdk/src/mock/tests/consumer/groups.rs new file mode 100644 index 000000000..b68134924 --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/src/mock/tests/consumer/groups.rs @@ -0,0 +1,194 @@ +//! Mirrors scenarios/consumer/groups/. Tests migrated per mock-reference-alignment. +use super::super::helpers::*; +#[cfg(test)] +use toolkit_gts::gts_id; + +use crate::api::EventBroker; +use crate::error::EventBrokerError; +use crate::ids::ConsumerGroupId; +use crate::models::{ConsumerGroupKind, CreateConsumerGroupRequest}; + +/// Scenario: consumer/groups/1.01-positive-create-anonymous-group.md +#[tokio::test] +async fn s1_01_positive_create_anonymous_group() { + let broker = crate::mock::MockBroker::new(); + let c = ctx(); + + let group = broker + .create_consumer_group( + &c, + CreateConsumerGroupRequest { + client_agent: "order-worker/1.4.0 cf-event-broker-sdk/0.1.0".to_owned(), + description: Some("order-fulfilment workers".to_owned()), + }, + ) + .await + .unwrap(); + + assert_ne!(group.id.as_uuid(), uuid::Uuid::nil()); + // kind is "anonymous". + assert_eq!(group.kind, ConsumerGroupKind::Anonymous); + // tenant_id and owner_principal_id come from the caller's SecurityContext, not the body. + assert_eq!(group.tenant_id, c.subject_tenant_id()); + assert_eq!(group.owner_principal_id, c.subject_id().to_string()); + + // Side effect: a subsequent GET returns the same record. + let fetched = broker.get_consumer_group(&c, &group.id).await.unwrap(); + assert_eq!(fetched.id, group.id); + assert_eq!(fetched.kind, ConsumerGroupKind::Anonymous); + assert_eq!(fetched.tenant_id, c.subject_tenant_id()); +} + +/// Scenario: consumer/groups/1.02-positive-get-group-by-id.md +#[tokio::test] +async fn s1_02_positive_get_group_by_id() { + let broker = crate::mock::MockBroker::new(); + let c = ctx(); + let gid = make_group(&c, &broker).await; + + let fetched = broker.get_consumer_group(&c, &gid).await.unwrap(); + assert_eq!(fetched.id, gid); + assert_eq!(fetched.kind, ConsumerGroupKind::Anonymous); + assert_eq!(fetched.tenant_id, c.subject_tenant_id()); + assert_eq!(fetched.owner_principal_id, c.subject_id().to_string()); +} + +/// Scenario: consumer/groups/1.03-positive-list-groups.md +#[tokio::test] +async fn s1_03_positive_list_groups() { + let broker = crate::mock::MockBroker::new(); + let c = ctx(); + let gid = make_group(&c, &broker).await; + + let page = broker + .list_consumer_groups(&c, crate::models::ConsumerGroupQuery::default()) + .await + .unwrap(); + + assert!( + page.items.iter().any(|g| g.id == gid), + "the created group must appear in the listing" + ); + let listed = page.items.iter().find(|g| g.id == gid).unwrap(); + assert_eq!(listed.kind, ConsumerGroupKind::Anonymous); + assert_eq!(listed.tenant_id, c.subject_tenant_id()); +} + +/// Scenario: consumer/groups/1.04-positive-delete-empty-group.md +#[tokio::test] +async fn s1_04_positive_delete_empty_group() { + let broker = crate::mock::MockBroker::new(); + let c = ctx(); + let gid = make_group(&c, &broker).await; + + // No subscriptions reference the group → DELETE succeeds. + broker.delete_consumer_group(&c, &gid).await.unwrap(); + + // Side effect: the group is absent; a subsequent GET returns ConsumerGroupNotFound. + let err = broker.get_consumer_group(&c, &gid).await.unwrap_err(); + assert!( + matches!(err, EventBrokerError::ConsumerGroupNotFound { .. }), + "deleted group must be absent, got {err:?}" + ); +} + +/// Scenario: consumer/groups/1.05-negative-delete-group-with-active-members.md +#[tokio::test] +async fn s1_05_negative_delete_group_with_active_members() { + // Setup: create a group, then perform a cold JOIN so one active member exists. + let (broker, _h) = broker_with_topic(TOPIC, 4).await; + let c = ctx(); + let gid = make_group(&c, &broker).await; + let sub = join_group(&c, &broker, &gid, TOPIC).await; + + // DELETE is rejected while the group has an active member. + let err = broker.delete_consumer_group(&c, &gid).await.unwrap_err(); + assert!( + matches!(err, EventBrokerError::ConsumerGroupHasActiveMembers { .. }), + "delete must be refused while members are active, got {err:?}" + ); + + // Side effect: the group is unchanged (still present). + assert!(broker.get_consumer_group(&c, &gid).await.is_ok()); + + // After the active subscription LEAVEs, a retried DELETE succeeds (per positive-1.4). + broker.leave(&c, sub.subscription_id).await.unwrap(); + broker.delete_consumer_group(&c, &gid).await.unwrap(); +} + +/// Scenario: consumer/groups/1.07-negative-get-unknown-group.md +#[tokio::test] +async fn s1_07_negative_get_unknown_group() { + let broker = crate::mock::MockBroker::new(); + let c = ctx(); + let unknown = ConsumerGroupId::new(uuid::Uuid::nil()); + + let err = broker.get_consumer_group(&c, &unknown).await.unwrap_err(); + assert!( + matches!(err, EventBrokerError::ConsumerGroupNotFound { .. }), + "unknown group lookup must return ConsumerGroupNotFound, got {err:?}" + ); +} + +/// Scenario: consumer/groups/1.06-negative-invalid-client-agent.md +#[tokio::test] +async fn s1_06_negative_invalid_client_agent() { + let broker = crate::mock::MockBroker::new(); + let c = ctx(); + // Non-ASCII client_agent is rejected (must be ASCII, 1-256 bytes). + let err = broker + .create_consumer_group( + &c, + CreateConsumerGroupRequest { + client_agent: "consumer/✓1.0".to_owned(), + description: None, + }, + ) + .await + .expect_err("non-ASCII client_agent must be rejected"); + assert!( + matches!(err, EventBrokerError::InvalidEventField { field, .. } if field == "client_agent"), + "expected InvalidEventField(client_agent), got {err:?}" + ); + + // An oversized (>256 byte) client_agent is also rejected. + let err = broker + .create_consumer_group( + &c, + CreateConsumerGroupRequest { + client_agent: "a".repeat(257), + description: None, + }, + ) + .await + .expect_err("oversized client_agent must be rejected"); + assert!( + matches!(err, EventBrokerError::InvalidEventField { field, .. } if field == "client_agent") + ); +} + +/// Scenario: consumer/groups/1.08-positive-named-group-join.md +#[tokio::test] +async fn s1_08_positive_named_group_join() { + let (broker, h) = broker_with_topic(TOPIC, 1).await; + let c = ctx(); + // A named group is provisioned via types_registry (no POST /v1/consumer-groups). + let named_gts = gts_id!("cf.core.events.consumer_group.v1~vendor.audit.pipeline.x.v1"); + let named = ConsumerGroupId::from_gts(named_gts); + h.register_named_group(named_gts).await; + + // JOIN the well-known identifier directly (the :consume grant is HTTP-layer authz). + let assignment = join_group(&c, &broker, &named, TOPIC).await; + assert!( + !assignment.assigned.is_empty(), + "JOIN to a provisioned named group is admitted and assigned a partition" + ); + + // The registry record reports kind = Named. + let group = broker.get_consumer_group(&c, &named).await.unwrap(); + assert_eq!( + group.kind, + ConsumerGroupKind::Named, + "provisioned group is Named" + ); +} diff --git a/gears/system/event-broker/event-broker-sdk/src/mock/tests/consumer/mod.rs b/gears/system/event-broker/event-broker-sdk/src/mock/tests/consumer/mod.rs new file mode 100644 index 000000000..4a5a941d9 --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/src/mock/tests/consumer/mod.rs @@ -0,0 +1,7 @@ +//! Mirrors scenarios/consumer/. + +mod flows; +mod groups; +mod positions; +mod stream; +mod subscriptions; diff --git a/gears/system/event-broker/event-broker-sdk/src/mock/tests/consumer/positions.rs b/gears/system/event-broker/event-broker-sdk/src/mock/tests/consumer/positions.rs new file mode 100644 index 000000000..bcaf70246 --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/src/mock/tests/consumer/positions.rs @@ -0,0 +1,590 @@ +//! Mirrors scenarios/consumer/positions/. Tests migrated per mock-reference-alignment. + +#[cfg(test)] +use super::super::helpers::*; + +use super::super::helpers::{broker_with_topic, ctx, join_group, make_group, wire_event}; +use crate::ResolvedPosition; +use crate::api::EventBroker; +use crate::api::SeekPosition; +use crate::models::Event; +use uuid::Uuid; + +/// Build a wire event with an explicit `occurred_at` so timestamp-seek scenarios +/// can place events on the partition's time axis deterministically. +fn wire_event_at(topic: &str, type_id: &str, tenant_id: Uuid, occurred_at: &str) -> Event { + let mut ev = wire_event(topic, type_id, tenant_id); + ev.occurred_at = chrono::DateTime::parse_from_rfc3339(occurred_at) + .expect("wire_event_at: occurred_at must be RFC3339") + .with_timezone(&chrono::Utc); + ev +} + +/// Scenario: consumer/positions/1.01-positive-seek-earliest.md +/// +/// SEEK with the `Earliest` sentinel resolves server-side to an integer cursor. +/// (Divergence: the scenario resolves `earliest` → `RF - 1`; the mock has no +/// retention floor, so `Earliest` resolves to `0` - the floor of an unpruned log. +/// The asserted contract is "sentinel resolved server-side to an integer cursor".) +#[tokio::test] +async fn s1_01_positive_seek_earliest() { + let (broker, h) = broker_with_topic(TOPIC, 1).await; + let c = ctx(); + for _ in 0..3 { + broker + .publish(&c, &wire_event(TOPIC, EVT, c.subject_tenant_id())) + .await + .unwrap(); + } + let gid = make_group(&c, &broker).await; + let sub = join_group(&c, &broker, &gid, TOPIC).await; + + let results = broker + .seek( + &c, + sub.subscription_id, + &[SeekPosition { + topic: TOPIC.to_owned(), + partition: 0, + value: ResolvedPosition::Earliest, + }], + ) + .await + .unwrap(); + + assert_eq!( + results.len(), + 1, + "one resolved position per requested partition" + ); + assert_eq!(results[0].topic, TOPIC); + assert_eq!(results[0].partition, 0); + assert_eq!( + results[0].offset, 0, + "Earliest resolves to the floor cursor (0)" + ); + assert_eq!( + h.cursor(&gid, TOPIC, 0).await, + Some(0), + "group cursor is seeded to the resolved earliest offset" + ); +} + +/// Scenario: consumer/positions/1.02-positive-seek-latest.md +/// +/// SEEK with the `Latest` sentinel resolves to the current high-water mark, so +/// only events admitted after the SEEK are delivered. Mock HWM = `next_offset - 1`. +#[tokio::test] +async fn s1_02_positive_seek_latest() { + let (broker, h) = broker_with_topic(TOPIC, 1).await; + let c = ctx(); + // Five events → offsets 0..4, next_offset = 5, HWM = 4. + for _ in 0..5 { + broker + .publish(&c, &wire_event(TOPIC, EVT, c.subject_tenant_id())) + .await + .unwrap(); + } + let gid = make_group(&c, &broker).await; + let sub = join_group(&c, &broker, &gid, TOPIC).await; + + let results = broker + .seek( + &c, + sub.subscription_id, + &[SeekPosition { + topic: TOPIC.to_owned(), + partition: 0, + value: ResolvedPosition::Latest, + }], + ) + .await + .unwrap(); + + assert_eq!( + results[0].offset, 5, + "Latest resolves to the current HWM (1-based: 5 events → offset 5)" + ); + assert_eq!( + h.cursor(&gid, TOPIC, 0).await, + Some(5), + "group cursor is seeded to the HWM" + ); +} + +/// Scenario: consumer/positions/1.03-positive-seek-exact-offset.md +/// +/// An explicit integer SEEK value is stored verbatim as the last-processed offset. +#[tokio::test] +async fn s1_03_positive_seek_exact_offset() { + let (broker, h) = broker_with_topic(TOPIC, 1).await; + let c = ctx(); + for _ in 0..100 { + broker + .publish(&c, &wire_event(TOPIC, EVT, c.subject_tenant_id())) + .await + .unwrap(); + } + let gid = make_group(&c, &broker).await; + let sub = join_group(&c, &broker, &gid, TOPIC).await; + + let results = broker + .seek( + &c, + sub.subscription_id, + &[SeekPosition { + topic: TOPIC.to_owned(), + partition: 0, + value: ResolvedPosition::Exact(42), + }], + ) + .await + .unwrap(); + + assert_eq!( + results[0].offset, 42, + "exact offset is stored verbatim (no +1 on the wire)" + ); + assert_eq!(h.cursor(&gid, TOPIC, 0).await, Some(42)); +} + +/// Scenario: consumer/positions/1.04-positive-mixed-sentinels.md +/// +/// One SEEK request carries a different value kind per partition; each is resolved +/// independently. (Divergence: the scenario spans three partitions on one topic +/// with distinct RF/HWM; tenant-hash partitioning in the mock routes all events to +/// a single partition, so the per-partition independence is exercised across three +/// single-partition topics instead - same resolution contract.) +#[tokio::test] +async fn s1_04_positive_mixed_sentinels() { + let (broker, h) = broker_with_topic(TOPIC, 1).await; + h.register_topic(TOPIC2, 1).await; + h.register_topic(TOPIC3, 1).await; + let c = ctx(); + // TOPIC: events so an exact offset is valid. TOPIC3: 5 events → HWM 4. + for _ in 0..100 { + broker + .publish(&c, &wire_event(TOPIC, EVT, c.subject_tenant_id())) + .await + .unwrap(); + } + for _ in 0..5 { + broker + .publish(&c, &wire_event(TOPIC3, EVT, c.subject_tenant_id())) + .await + .unwrap(); + } + let gid = make_group(&c, &broker).await; + let sub = broker + .join( + &c, + crate::api::JoinRequest { + group: gid, + client_agent: "mixed-consumer/1.0".into(), + interests: vec![interest(TOPIC), interest(TOPIC2), interest(TOPIC3)], + session_timeout: None, + }, + ) + .await + .unwrap(); + + let results = broker + .seek( + &c, + sub.subscription_id, + &[ + SeekPosition { + topic: TOPIC.to_owned(), + partition: 0, + value: ResolvedPosition::Exact(42), + }, + SeekPosition { + topic: TOPIC2.to_owned(), + partition: 0, + value: ResolvedPosition::Earliest, + }, + SeekPosition { + topic: TOPIC3.to_owned(), + partition: 0, + value: ResolvedPosition::Latest, + }, + ], + ) + .await + .unwrap(); + + let by_topic = |t: &str| results.iter().find(|r| r.topic == t).unwrap().offset; + assert_eq!(by_topic(TOPIC), 42, "exact verbatim"); + assert_eq!(by_topic(TOPIC2), 0, "Earliest → floor cursor (RF−1 = 0)"); + assert_eq!( + by_topic(TOPIC3), + 5, + "Latest → HWM (1-based: 5 events → offset 5)" + ); + assert_eq!(h.cursor(&gid, TOPIC, 0).await, Some(42)); + assert_eq!(h.cursor(&gid, TOPIC2, 0).await, Some(0)); + assert_eq!(h.cursor(&gid, TOPIC3, 0).await, Some(5)); +} + +/// Scenario: consumer/positions/1.05-negative-out-of-range-offset.md +/// +/// DIVERGENCE (range validation is unrepresentable in the mock): the scenario +/// expects `400 InvalidInitialPosition` for an offset below `RF - 1`, with nothing +/// committed (per-request atomic). The mock's `seek` performs no range validation - +/// it stores any integer verbatim. The closest real assertion is that the mock +/// accepts the value (no broker-side range guard), documenting the divergence: the +/// 400 path is an HTTP-layer concern not implemented in the in-process mock. +#[tokio::test] +async fn s1_05_negative_out_of_range_offset() { + let (broker, h) = broker_with_topic(TOPIC, 1).await; + let c = ctx(); + let gid = make_group(&c, &broker).await; + let sub = join_group(&c, &broker, &gid, TOPIC).await; + + // Empty partition → HWM = 1, valid range [0, 1]. Offset 5 is above range. + let err = broker + .seek( + &c, + sub.subscription_id, + &[SeekPosition { + topic: TOPIC.to_owned(), + partition: 0, + value: ResolvedPosition::Exact(5), + }], + ) + .await + .expect_err("an out-of-range seek must be rejected"); + assert!( + matches!( + err, + crate::error::EventBrokerError::InvalidInitialPosition { .. } + ), + "expected InvalidInitialPosition, got {err:?}" + ); + assert_eq!( + h.cursor(&gid, TOPIC, 0).await, + None, + "no cursor committed on a rejected seek" + ); +} + +/// Scenario: consumer/positions/1.06-negative-offset-above-hwm.md +/// +/// DIVERGENCE (range validation is unrepresentable in the mock): the scenario +/// expects `400 InvalidInitialPosition` for an offset above HWM. The mock applies +/// no upper-bound check; it stores the value verbatim. Closest real assertion: the +/// seek succeeds and the cursor advances (the 400 is an HTTP-only guardrail). +#[tokio::test] +async fn s1_06_negative_offset_above_hwm() { + let (broker, h) = broker_with_topic(TOPIC, 1).await; + let c = ctx(); + for _ in 0..3 { + broker + .publish(&c, &wire_event(TOPIC, EVT, c.subject_tenant_id())) + .await + .unwrap(); + } + let gid = make_group(&c, &broker).await; + let sub = join_group(&c, &broker, &gid, TOPIC).await; + + // 3 events → HWM = 4, valid range [0, 4]. Offset 100_000 is above HWM. + let err = broker + .seek( + &c, + sub.subscription_id, + &[SeekPosition { + topic: TOPIC.to_owned(), + partition: 0, + value: ResolvedPosition::Exact(100_000), + }], + ) + .await + .expect_err("a seek above the HWM must be rejected"); + assert!( + matches!( + err, + crate::error::EventBrokerError::InvalidInitialPosition { .. } + ), + "expected InvalidInitialPosition, got {err:?}" + ); + assert_eq!(h.cursor(&gid, TOPIC, 0).await, None); +} + +/// Scenario: consumer/positions/1.07-negative-seek-while-streaming.md +/// +/// SEEK is a pre-stream operation. A SEEK issued while a stream is open is rejected +/// with `StreamingInProgress` and leaves the cursor unchanged. +#[tokio::test] +async fn s1_07_negative_seek_while_streaming() { + use futures_util::StreamExt; + let (broker, h) = broker_with_topic(TOPIC, 1).await; + let c = ctx(); + let gid = make_group(&c, &broker).await; + let sub = join_group(&c, &broker, &gid, TOPIC).await; + + // Seed the cursor (Earliest → 0; in range on an empty partition). + broker + .seek( + &c, + sub.subscription_id, + &[SeekPosition { + topic: TOPIC.to_owned(), + partition: 0, + value: ResolvedPosition::Earliest, + }], + ) + .await + .unwrap(); + assert_eq!(h.cursor(&gid, TOPIC, 0).await, Some(0)); + + // Open a stream (drain the open-time topology baseline). + let mut stream = broker.stream(&c, sub.subscription_id).await.unwrap(); + let _ = stream.next().await; + + // SEEK is a pre-stream operation: with a stream open it is rejected. + let err = broker + .seek( + &c, + sub.subscription_id, + &[SeekPosition { + topic: TOPIC.to_owned(), + partition: 0, + value: ResolvedPosition::Exact(400), + }], + ) + .await + .expect_err("SEEK while streaming must be rejected"); + assert!( + matches!( + err, + crate::error::EventBrokerError::StreamingInProgress { .. } + ), + "expected StreamingInProgress, got {err:?}" + ); + // Cursor is unchanged by the rejected seek. + assert_eq!(h.cursor(&gid, TOPIC, 0).await, Some(0)); +} + +/// Scenario: consumer/positions/1.09-negative-seek-unassigned-partition.md +/// +/// DIVERGENCE (PartitionNotAssigned is unrepresentable in the mock): the scenario +/// expects `409 PartitionNotAssigned` (atomic - nothing applied) when a SEEK names a +/// partition not assigned to the subscription. The mock's `seek` does not validate +/// the requested `(topic, partition)` against the subscription's assignment - it +/// resolves and commits to the GROUP cursor regardless. Closest real assertion: a +/// SEEK on a partition outside the assignment still succeeds and writes the group +/// cursor (the 409 assignment guard is HTTP-only). +#[tokio::test] +async fn s1_09_negative_seek_unassigned_partition() { + let (broker, h) = broker_with_topic(TOPIC, 4).await; + let c = ctx(); + let c2 = ctx2(); + let gid = make_group(&c, &broker).await; + // Two members split 4 partitions 2/2 so each owns only a subset. + let sub_a = join_group(&c, &broker, &gid, TOPIC).await; + let _sub_b = join_group(&c2, &broker, &gid, TOPIC).await; + + let a_slots = h.assignment(sub_a.subscription_id).await; + assert_eq!(a_slots.len(), 2, "A owns 2 of 4 partitions"); + // Pick a partition A does NOT own. + let owned: std::collections::HashSet = a_slots.iter().map(|s| s.partition).collect(); + let unassigned = (0u32..4).find(|p| !owned.contains(p)).unwrap(); + + let err = broker + .seek( + &c, + sub_a.subscription_id, + &[SeekPosition { + topic: TOPIC.to_owned(), + partition: unassigned, + value: ResolvedPosition::Earliest, + }], + ) + .await + .expect_err("seek to an unassigned partition must be rejected"); + assert!( + matches!(err, crate::error::EventBrokerError::PartitionNotAssigned { partition, .. } if partition == unassigned), + "expected PartitionNotAssigned for {unassigned}, got {err:?}" + ); + // No cursor is committed for the unassigned partition. + assert_eq!(h.cursor(&gid, TOPIC, unassigned).await, None); +} + +/// Scenario: consumer/positions/1.10-positive-seek-any-value-in-range.md +/// +/// Pre-stream SEEK is not subject to a forward-only rule against the SESSION cursor; +/// a fresh subscription may seed any value. (Divergence: the mock's group cursor is +/// forward-only MAX, so a *lower* group cursor cannot be set by a later SEEK from a +/// continuing group; this test seeds a fresh group, where any value is accepted, and +/// asserts the resolved value is committed verbatim.) +#[tokio::test] +async fn s1_10_positive_seek_any_value_in_range() { + let (broker, h) = broker_with_topic(TOPIC, 1).await; + let c = ctx(); + for _ in 0..600 { + broker + .publish(&c, &wire_event(TOPIC, EVT, c.subject_tenant_id())) + .await + .unwrap(); + } + let gid = make_group(&c, &broker).await; + let sub = join_group(&c, &broker, &gid, TOPIC).await; + + // Fresh subscription, no prior cursor: seed 100 directly. + let results = broker + .seek( + &c, + sub.subscription_id, + &[SeekPosition { + topic: TOPIC.to_owned(), + partition: 0, + value: ResolvedPosition::Exact(100), + }], + ) + .await + .unwrap(); + assert_eq!( + results[0].offset, 100, + "pre-stream SEEK accepts any in-range value" + ); + assert_eq!(h.cursor(&gid, TOPIC, 0).await, Some(100)); +} + +/// Scenario: consumer/positions/1.11-positive-seek-at-timestamp.md +/// +/// `AtTimestamp` resolves to the offset of the first event whose `occurred_at` is at +/// or after the timestamp (linear `occurred_at` scan). With one event strictly before +/// the timestamp and one at/after it, the resolved cursor is the at/after event's +/// offset. (Divergence: the scenario partitions 0..3 each resolve independently to +/// different offsets; the mock routes a tenant's events to a single partition, so the +/// at-timestamp resolution is exercised on partition 0 only - same scan contract.) +#[tokio::test] +async fn s1_11_positive_seek_at_timestamp() { + let (broker, _h) = broker_with_topic(TOPIC, 1).await; + let c = ctx(); + let t = c.subject_tenant_id(); + // offset 0 @ 09:59:50 (before), offset 1 @ 10:00:03 (at/after target 10:00:00). + broker + .publish(&c, &wire_event_at(TOPIC, EVT, t, "2026-06-14T09:59:50Z")) + .await + .unwrap(); + broker + .publish(&c, &wire_event_at(TOPIC, EVT, t, "2026-06-14T10:00:03Z")) + .await + .unwrap(); + + let gid = make_group(&c, &broker).await; + let sub = join_group(&c, &broker, &gid, TOPIC).await; + + let results = broker + .seek( + &c, + sub.subscription_id, + &[SeekPosition { + topic: TOPIC.to_owned(), + partition: 0, + value: ResolvedPosition::AtTimestamp("2026-06-14T10:00:00Z".to_owned()), + }], + ) + .await + .unwrap(); + + assert_eq!( + results[0].offset, 1, + "resolves to the first event at/after the timestamp (offset 1)" + ); +} + +/// Scenario: consumer/positions/1.12-positive-seek-at-timestamp-before-retention.md +/// +/// A timestamp at/before the oldest stored event resolves to the retention floor +/// (the oldest stored offset) - behaviour identical to `Earliest`. +#[tokio::test] +async fn s1_12_positive_seek_at_timestamp_before_retention() { + let (broker, _h) = broker_with_topic(TOPIC, 1).await; + let c = ctx(); + let t = c.subject_tenant_id(); + // Oldest stored event is at 2026-01-01; the requested ts (2025) predates it. + broker + .publish(&c, &wire_event_at(TOPIC, EVT, t, "2026-01-01T00:00:00Z")) + .await + .unwrap(); + broker + .publish(&c, &wire_event_at(TOPIC, EVT, t, "2026-02-01T00:00:00Z")) + .await + .unwrap(); + + let gid = make_group(&c, &broker).await; + let sub = join_group(&c, &broker, &gid, TOPIC).await; + + let results = broker + .seek( + &c, + sub.subscription_id, + &[SeekPosition { + topic: TOPIC.to_owned(), + partition: 0, + value: ResolvedPosition::AtTimestamp("2025-06-01T00:00:00Z".to_owned()), + }], + ) + .await + .unwrap(); + + assert_eq!( + results[0].offset, 0, + "ts before the floor clamps to the oldest stored offset (the floor)" + ); +} + +/// Scenario: consumer/positions/1.13-positive-seek-at-timestamp-beyond-hwm.md +/// +/// A timestamp beyond the newest stored event resolves to the high-water mark - +/// behaviour identical to `Latest`. +#[tokio::test] +async fn s1_13_positive_seek_at_timestamp_beyond_hwm() { + let (broker, _h) = broker_with_topic(TOPIC, 1).await; + let c = ctx(); + let t = c.subject_tenant_id(); + // Two events; newest at 2026-06; HWM = offset 1. Requested ts (2030) is beyond. + broker + .publish(&c, &wire_event_at(TOPIC, EVT, t, "2026-06-01T00:00:00Z")) + .await + .unwrap(); + broker + .publish(&c, &wire_event_at(TOPIC, EVT, t, "2026-06-02T00:00:00Z")) + .await + .unwrap(); + + let gid = make_group(&c, &broker).await; + let sub = join_group(&c, &broker, &gid, TOPIC).await; + + let results = broker + .seek( + &c, + sub.subscription_id, + &[SeekPosition { + topic: TOPIC.to_owned(), + partition: 0, + value: ResolvedPosition::AtTimestamp("2030-01-01T00:00:00Z".to_owned()), + }], + ) + .await + .unwrap(); + + assert_eq!( + results[0].offset, 2, + "ts beyond the newest event resolves to the HWM (2 events → HWM offset 2)" + ); +} + +/// JOIN interest helper mirroring `helpers::join_group`'s single interest. +fn interest(topic: &str) -> crate::api::SubscriptionInterest { + crate::api::SubscriptionInterest { + topic: topic.to_owned(), + tenant_id: uuid::Uuid::nil(), + tenant_depth: crate::api::TenantTraversalDepth::CurrentTenant, + barrier_mode: crate::api::BarrierMode::Respect, + types: vec!["*".to_owned()], + filter: None, + } +} diff --git a/gears/system/event-broker/event-broker-sdk/src/mock/tests/consumer/stream.rs b/gears/system/event-broker/event-broker-sdk/src/mock/tests/consumer/stream.rs new file mode 100644 index 000000000..939079dcd --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/src/mock/tests/consumer/stream.rs @@ -0,0 +1,489 @@ +//! Mirrors scenarios/consumer/stream/. Tests migrated per mock-reference-alignment. + +use super::super::helpers::*; +#[cfg(test)] +use toolkit_gts::gts_id; + +use super::super::helpers::{broker_with_topic, ctx, ctx2, join_group, make_group, wire_event}; +use crate::ResolvedPosition; +use crate::api::EventBroker; +use crate::api::{ControlCode, SeekPosition, WireFrame}; +use crate::ids::SubscriptionId; +use futures_util::StreamExt; +use std::time::Duration; +use uuid::Uuid; + +/// Read the next stream frame within a bounded timeout (live streams never end). +async fn next_frame( + s: &mut crate::api::FrameStream, +) -> Option> { + tokio::time::timeout(Duration::from_millis(200), s.next()) + .await + .unwrap_or_default() +} + +/// Scenario: consumer/stream/1.01-positive-stream-multipart-frames.md +/// +/// A seeded subscription's stream emits one `Event` frame per event in offset- +/// monotonic order, then a `Heartbeat` once the backlog drains. (The open-time +/// `Topology` baseline is the guaranteed first frame in the mock.) +#[tokio::test] +async fn s1_01_positive_stream_multipart_frames() { + let (broker, h) = broker_with_topic(TOPIC, 1).await; + h.set_heartbeat_interval(Duration::from_millis(20)).await; + let c = ctx(); + // Three events → offsets 1, 2, 3 (1-based). + for _ in 0..3 { + broker + .publish(&c, &wire_event(TOPIC, EVT, c.subject_tenant_id())) + .await + .unwrap(); + } + let gid = make_group(&c, &broker).await; + let sub = join_group(&c, &broker, &gid, TOPIC).await; + broker + .seek( + &c, + sub.subscription_id, + &[SeekPosition { + topic: TOPIC.to_owned(), + partition: 0, + value: ResolvedPosition::Earliest, + }], + ) + .await + .unwrap(); + + let mut s = broker.stream(&c, sub.subscription_id).await.unwrap(); + // First frame: the open-time topology baseline. + assert!( + matches!( + next_frame(&mut s).await, + Some(Ok(WireFrame::Topology { .. })) + ), + "first frame must be the topology baseline" + ); + + // Next three event frames carry offsets 0, 1, 2 in strict order. + let mut offsets = Vec::new(); + let mut saw_heartbeat = false; + for _ in 0..20 { + match next_frame(&mut s).await { + Some(Ok(WireFrame::Event(e))) => offsets.push(e.offset), + Some(Ok(WireFrame::Heartbeat { .. })) => { + if offsets.len() == 3 { + saw_heartbeat = true; + break; + } + } + Some(Ok(_)) => {} + _ => break, + } + } + assert_eq!( + offsets, + vec![1, 2, 3], + "events delivered in offset-monotonic order (1-based)" + ); + assert!(saw_heartbeat, "a heartbeat arrives once the backlog drains"); +} + +/// Scenario: consumer/stream/1.02-positive-stream-heartbeat-cadence.md +/// +/// An idle subscription (no matching events) emits `Heartbeat` frames at the +/// configured cadence. With a tiny cadence, several arrive in a short window. +#[tokio::test] +async fn s1_02_positive_stream_heartbeat_cadence() { + let (broker, h) = broker_with_topic(TOPIC, 1).await; + h.set_heartbeat_interval(Duration::from_millis(10)).await; + let c = ctx(); + let gid = make_group(&c, &broker).await; + let sub = join_group(&c, &broker, &gid, TOPIC).await; + broker + .seek( + &c, + sub.subscription_id, + &[SeekPosition { + topic: TOPIC.to_owned(), + partition: 0, + value: ResolvedPosition::Earliest, + }], + ) + .await + .unwrap(); + + let mut s = broker.stream(&c, sub.subscription_id).await.unwrap(); + let _ = next_frame(&mut s).await; // topology baseline + + let mut heartbeats = 0; + for _ in 0..40 { + match next_frame(&mut s).await { + Some(Ok(WireFrame::Heartbeat { at })) => { + assert!(!at.is_empty(), "heartbeat carries an ISO-8601 timestamp"); + heartbeats += 1; + if heartbeats >= 3 { + break; + } + } + Some(Ok(_)) => {} + _ => break, + } + } + assert!( + heartbeats >= 3, + "at least 3 heartbeats arrive on an idle stream" + ); +} + +/// Scenario: consumer/stream/1.03-positive-stream-topology-frame-on-rebalance.md +/// +/// When a second consumer JOINs and the rebalance makes A LOSE partitions (not +/// gain), A's open stream receives a non-terminal `Topology` frame with the reduced +/// assignment and the stream stays open. +#[tokio::test] +async fn s1_03_positive_stream_topology_frame_on_rebalance() { + let (broker, _h) = broker_with_topic(TOPIC, 4).await; + let c = ctx(); + let c2 = ctx2(); + let gid = make_group(&c, &broker).await; + let sub_a = join_group(&c, &broker, &gid, TOPIC).await; + super::super::helpers::seek_all_earliest(&c, &broker, &sub_a).await; + + let mut s = broker.stream(&c, sub_a.subscription_id).await.unwrap(); + let _ = next_frame(&mut s).await; // open-time baseline (4 partitions) + + // B JOINs → rebalance to 2/2; A loses 2 partitions. + let _sub_b = join_group(&c2, &broker, &gid, TOPIC).await; + + let mut reduced = None; + for _ in 0..50 { + match next_frame(&mut s).await { + Some(Ok(WireFrame::Topology { + topology_version, + assigned, + })) => { + assert_eq!(topology_version, 2, "topology_version advances to 2"); + reduced = Some(assigned); + break; + } + Some(Ok(WireFrame::Control { .. })) => { + panic!("a loss must NOT emit a control frame") + } + Some(Ok(_)) => {} + _ => {} + } + } + let reduced = reduced.expect("a loss yields a non-terminal topology frame"); + assert_eq!(reduced.len(), 2, "A's reduced assignment is 2 partitions"); + // Stream stays open (not terminated): a second stream is rejected with + // StreamingInProgress (409) - NOT SubscriptionTerminated (410). The 409 + // proves the first stream is still active and the subscription is alive. + let err = broker + .stream(&c, sub_a.subscription_id) + .await + .err() + .expect("second concurrent stream must be rejected"); + assert!( + matches!( + err, + crate::error::EventBrokerError::StreamingInProgress { .. } + ), + "expected StreamingInProgress (still open, not terminated), got {err:?}" + ); +} + +/// Scenario: consumer/stream/1.04-negative-stream-positions-not-set.md +/// +/// Opening `:stream` before seeding any assigned partition is rejected with +/// `PositionsNotSet` (the SDK analog of the 409 cursor_missing backstop). A +/// well-behaved consumer SEEKs every assigned partition first. +#[tokio::test] +async fn s1_04_negative_stream_positions_not_set() { + let (broker, _h) = broker_with_topic(TOPIC, 4).await; + let c = ctx(); + let gid = make_group(&c, &broker).await; + let sub = join_group(&c, &broker, &gid, TOPIC).await; + // No SEEK performed - every assigned partition lacks a committed cursor. + + let err = broker + .stream(&c, sub.subscription_id) + .await + .err() + .expect("opening an unseeded stream must be rejected"); + match err { + crate::error::EventBrokerError::PositionsNotSet { unseeded, .. } => { + assert_eq!(unseeded.len(), 4, "all 4 assigned partitions are unseeded"); + } + other => panic!("expected PositionsNotSet, got {other:?}"), + } +} + +/// Scenario: consumer/stream/1.05-negative-stream-unknown-subscription.md +/// +/// Opening `:stream` with a subscription id that never existed is rejected before +/// any stream object or active-stream marker is created. +#[tokio::test] +async fn s1_05_negative_stream_unknown_subscription() { + let (broker, _h) = broker_with_topic(TOPIC, 1).await; + let c = ctx(); + let unknown = SubscriptionId(Uuid::new_v4()); + + let err = broker + .stream(&c, unknown) + .await + .err() + .expect("unknown subscription must be rejected"); + assert!( + matches!( + err, + crate::error::EventBrokerError::SubscriptionNotFound { .. } + ), + "expected SubscriptionNotFound, got {err:?}" + ); +} + +/// Scenario: consumer/stream/1.06-negative-stream-terminated-subscription.md +/// +/// Opening `:stream` on a subscription terminated by a gain/lose-all rebalance is +/// rejected (`410`-equivalent). The mock guards this in `stream()` via the +/// `terminated` flag → `EventBrokerError`. +#[tokio::test] +async fn s1_06_negative_stream_terminated_subscription() { + let (broker, _h) = broker_with_topic(TOPIC, 4).await; + let c = ctx(); + let c2 = ctx2(); + let gid = make_group(&c, &broker).await; + let sub_a = join_group(&c, &broker, &gid, TOPIC).await; + // Seed while A still owns all 4 (before B joins) - the group cursors then + // survive the rebalance for the partitions A keeps. + super::super::helpers::seek_all_earliest(&c, &broker, &sub_a).await; + let sub_b = join_group(&c2, &broker, &gid, TOPIC).await; + + // A is streaming; B leaves → A gains → A is terminated. + let mut s = broker.stream(&c, sub_a.subscription_id).await.unwrap(); + let _ = next_frame(&mut s).await; // baseline + broker.leave(&c2, sub_b.subscription_id).await.unwrap(); + // Drive the stream until it closes (terminal control frame then end). + for _ in 0..50 { + match next_frame(&mut s).await { + Some(Ok(WireFrame::Control { + code: ControlCode::Terminal, + .. + })) => break, + Some(_) => {} + None => break, + } + } + + // Reopening the terminated id is rejected (410-equivalent). + assert!( + broker.stream(&c, sub_a.subscription_id).await.is_err(), + "reopening a terminated subscription is rejected (410-equivalent)" + ); +} + +/// Scenario: consumer/stream/1.11-negative-streaming-in-progress.md +/// +/// A subscription permits only one active stream at a time. A second concurrent +/// stream open is rejected with `StreamingInProgress`. +#[tokio::test] +async fn s1_11_negative_streaming_in_progress() { + let (broker, _h) = broker_with_topic(TOPIC, 1).await; + let c = ctx(); + let gid = make_group(&c, &broker).await; + let sub = join_group(&c, &broker, &gid, TOPIC).await; + broker + .seek( + &c, + sub.subscription_id, + &[SeekPosition { + topic: TOPIC.to_owned(), + partition: 0, + value: ResolvedPosition::Earliest, + }], + ) + .await + .unwrap(); + + let _first = broker.stream(&c, sub.subscription_id).await.unwrap(); + // A second concurrent stream on the same subscription is rejected. + let err = broker + .stream(&c, sub.subscription_id) + .await + .err() + .expect("second concurrent stream must be rejected"); + assert!( + matches!( + err, + crate::error::EventBrokerError::StreamingInProgress { .. } + ), + "expected StreamingInProgress, got {err:?}" + ); +} + +/// Scenario: consumer/stream/1.13-positive-delete-while-streaming.md +/// +/// DELETE (mapped to `leave` in the mock) while a stream is open removes the +/// subscription and the open stream ends. A subsequent open of the same id errors +/// (the subscription no longer exists). No control/topology frame precedes the close. +#[tokio::test] +async fn s1_13_positive_delete_while_streaming() { + let (broker, _h) = broker_with_topic(TOPIC, 1).await; + let c = ctx(); + let gid = make_group(&c, &broker).await; + let sub = join_group(&c, &broker, &gid, TOPIC).await; + broker + .seek( + &c, + sub.subscription_id, + &[SeekPosition { + topic: TOPIC.to_owned(), + partition: 0, + value: ResolvedPosition::Earliest, + }], + ) + .await + .unwrap(); + + let mut s = broker.stream(&c, sub.subscription_id).await.unwrap(); + let _ = next_frame(&mut s).await; // baseline + + // DELETE the subscription (priority interrupt). + broker.leave(&c, sub.subscription_id).await.unwrap(); + + // The open stream ends (the loop `break`s when the subscription is gone) with no + // terminal control/topology frame. + let mut closed = false; + for _ in 0..50 { + match next_frame(&mut s).await { + None => { + closed = true; + break; + } + Some(Ok(WireFrame::Control { .. })) => { + panic!("DELETE must not emit a control frame before close") + } + Some(Ok(_)) => {} + Some(Err(_)) => { + closed = true; + break; + } + } + } + assert!(closed, "the open stream closes after DELETE"); + assert!( + broker.list_subscriptions(&c).await.unwrap().is_empty(), + "the subscription is removed" + ); +} + +/// Scenario: consumer/stream/1.14-positive-control-progress-frame.md +/// +/// DIVERGENCE (filter-saturated progress control frame is not emitted by the mock): +/// the scenario expects a `Control{code: Progress}` carrying the sparse advanced +/// `last_examined` positions when the filter rejects most events. The mock's +/// `open_stream` emits only `Event`/`Heartbeat`/`Topology`/`Terminal-Control` frames - +/// there is no progress-frame emission path, and the mock applies no server-side type +/// filter (it delivers all stored events on assigned partitions). NOT IMPLEMENTABLE as +/// a Progress-frame assertion; recorded as a divergence. Closest real check: the +/// `WireFrame::Control{ ControlCode::Progress, .. }` variant exists and is constructible +/// (the wire contract is present even though the mock never emits it). +#[tokio::test] +async fn s1_14_positive_control_progress_frame() { + use crate::api::JoinRequest; + use crate::api::{BarrierMode, SubscriptionInterest}; + + let (broker, h) = broker_with_topic(TOPIC, 1).await; + h.set_heartbeat_interval(Duration::from_millis(20)).await; + let c = ctx(); + let gid = make_group(&c, &broker).await; + + // JOIN with a narrow filter: a type pattern that no published event matches. + let sub = broker + .join( + &c, + JoinRequest { + group: gid, + client_agent: "audit/1.0".to_owned(), + interests: vec![SubscriptionInterest { + topic: TOPIC.to_owned(), + tenant_id: Uuid::nil(), + tenant_depth: crate::api::TenantTraversalDepth::CurrentTenant, + barrier_mode: BarrierMode::Respect, + types: vec![ + gts_id!("cf.core.events.event_type.v1~example.mock.broker.no_match.v1") + .to_owned(), + ], + filter: None, + }], + session_timeout: None, + }, + ) + .await + .unwrap(); + broker + .seek( + &c, + sub.subscription_id, + &[SeekPosition { + topic: TOPIC.to_owned(), + partition: 0, + value: ResolvedPosition::Earliest, + }], + ) + .await + .unwrap(); + + let mut s = broker.stream(&c, sub.subscription_id).await.unwrap(); + let _ = next_frame(&mut s).await; // open-time topology baseline + + // Append heavily; every event is rejected by the narrow filter. + for _ in 0..5 { + broker + .publish(&c, &wire_event(TOPIC, EVT, c.subject_tenant_id())) + .await + .unwrap(); + } + + // The stream emits no Event frames, but a sparse `Control{Progress}` advances + // the frontier while the delivered offset stays at the seeded floor (0). + let mut saw_progress = false; + for _ in 0..30 { + match next_frame(&mut s).await { + Some(Ok(WireFrame::Event(_))) => panic!("a filtered event must not be delivered"), + Some(Ok(WireFrame::Control { + code: ControlCode::Progress, + positions, + reason, + })) => { + assert_eq!( + positions.len(), + 1, + "sparse: only the drifted partition appears" + ); + assert_eq!( + positions[0].offset, 0, + "delivered offset stays at the seeded floor" + ); + assert!( + positions[0].last_examined >= 5, + "frontier advanced past the filtered events" + ); + assert!(reason.is_none(), "Progress carries no reason"); + saw_progress = true; + break; + } + Some(Ok(_)) => {} // heartbeat / topology + _ => {} + } + } + assert!( + saw_progress, + "a filter-saturated stream emits a Control{{Progress}} frame" + ); + // Side effect: the group's offset-adviser frontier advanced past the scanned events. + assert!( + h.last_examined(&gid, TOPIC, 0).await.unwrap_or(0) >= 5, + "broker last_examined frontier advanced" + ); +} diff --git a/gears/system/event-broker/event-broker-sdk/src/mock/tests/consumer/subscriptions.rs b/gears/system/event-broker/event-broker-sdk/src/mock/tests/consumer/subscriptions.rs new file mode 100644 index 000000000..8afaa1b07 --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/src/mock/tests/consumer/subscriptions.rs @@ -0,0 +1,398 @@ +//! Mirrors scenarios/consumer/subscriptions/. Tests migrated per mock-reference-alignment. +use super::super::helpers::*; +#[cfg(test)] +use toolkit_gts::gts_id; + +use crate::api::{BarrierMode, Filter, SubscriptionInterest}; +use crate::api::{EventBroker, JoinRequest}; +use crate::error::EventBrokerError; +use crate::ids::SubscriptionId; +use uuid::Uuid; + +/// Scenario: consumer/subscriptions/1.01-positive-cold-join-fresh-group.md +#[tokio::test] +async fn s1_01_positive_cold_join_fresh_group() { + // 4-partition topic, fresh single-member group → all 4 partitions, topology_version 1. + let (broker, h) = broker_with_topic(TOPIC, 4).await; + let c = ctx(); + let gid = make_group(&c, &broker).await; + + assert_eq!(h.topology_version(&gid).await, 0, "fresh group starts at 0"); + + let assignment = join_group(&c, &broker, &gid, TOPIC).await; + + assert_eq!( + assignment.assigned.len(), + 4, + "sole member of a 4-partition topic owns all partitions" + ); + assert_eq!(assignment.topology_version, 1); + // Side effect: the group's topology_version advances 0 → 1. + assert_eq!(h.topology_version(&gid).await, 1); +} + +/// Scenario: consumer/subscriptions/1.02-positive-join-multi-topic-interests.md +#[tokio::test] +async fn s1_02_positive_join_multi_topic_interests() { + // Two topics with 2 partitions each; sole member owns all 4 (topic, partition) pairs. + let (broker, h) = broker_with_topic(TOPIC, 2).await; + h.register_topic(TOPIC2, 2).await; + let c = ctx(); + let gid = make_group(&c, &broker).await; + + let mk_interest = |topic: &str| SubscriptionInterest { + topic: topic.to_owned(), + tenant_id: Uuid::nil(), + tenant_depth: crate::api::TenantTraversalDepth::CurrentTenant, + barrier_mode: BarrierMode::Respect, + types: vec!["*".to_owned()], + filter: None, + }; + + let assignment = broker + .join( + &c, + JoinRequest { + group: gid, + client_agent: "fulfilment-worker/2.0.0".to_owned(), + interests: vec![mk_interest(TOPIC), mk_interest(TOPIC2)], + session_timeout: None, + }, + ) + .await + .unwrap(); + + assert_eq!( + assignment.assigned.len(), + 4, + "assignment must span all partitions of both topics (2 + 2)" + ); + let t1 = assignment + .assigned + .iter() + .filter(|a| a.topic == TOPIC) + .count(); + let t2 = assignment + .assigned + .iter() + .filter(|a| a.topic == TOPIC2) + .count(); + assert_eq!(t1, 2, "both partitions of TOPIC are assigned"); + assert_eq!(t2, 2, "both partitions of TOPIC2 are assigned"); + assert_eq!(assignment.topology_version, 1); +} + +/// Scenario: consumer/subscriptions/1.03-positive-join-with-typed-filter.md +#[tokio::test] +async fn s1_03_positive_join_with_typed_filter() { + // An interest carries a compiled filter; the JOIN is accepted and the member streams. + let (broker, _h) = broker_with_topic(TOPIC, 2).await; + let c = ctx(); + let gid = make_group(&c, &broker).await; + + let assignment = broker + .join( + &c, + JoinRequest { + group: gid, + client_agent: "high-value-worker/1.0.0".to_owned(), + interests: vec![SubscriptionInterest { + topic: TOPIC.to_owned(), + tenant_id: Uuid::nil(), + tenant_depth: crate::api::TenantTraversalDepth::CurrentTenant, + barrier_mode: BarrierMode::Respect, + types: vec![EVT.to_owned()], + filter: Some(Filter { + engine: gts_id!("cf.core.events.filter.v1~cf.core.expression.cel.v1") + .to_owned(), + expression: "event.data.total_cents > 100000".to_owned(), + }), + }], + session_timeout: None, + }, + ) + .await + .unwrap(); + + assert_eq!( + assignment.assigned.len(), + 2, + "filtered member still owns all partitions (filter applies on delivery, not assignment)" + ); + assert_eq!(assignment.topology_version, 1); +} + +/// Scenario: consumer/subscriptions/1.04-positive-parallelism-multiple-subscriptions.md +#[tokio::test] +async fn s1_04_positive_parallelism_multiple_subscriptions() { + // Two JOINs on a 4-partition topic split the partitions 2/2 (disjoint, full cover). + let (broker, h) = broker_with_topic(TOPIC, 4).await; + let c = ctx(); + let gid = make_group(&c, &broker).await; + + let sub_a = join_group(&c, &broker, &gid, TOPIC).await; + assert_eq!(sub_a.assigned.len(), 4, "first member owns all 4"); + assert_eq!(h.topology_version(&gid).await, 1); + + let sub_b = join_group(&c, &broker, &gid, TOPIC).await; + assert_eq!(sub_b.topology_version, 2); + assert_eq!(h.topology_version(&gid).await, 2, "topology advances 1 → 2"); + + let a_slots = h.assignment(sub_a.subscription_id).await; + let b_slots = h.assignment(sub_b.subscription_id).await; + assert_eq!(a_slots.len(), 2, "sub_a reduced to 2 partitions"); + assert_eq!(b_slots.len(), 2, "sub_b assigned 2 partitions"); + // Disjoint and together covering all 4 exactly once. + for p in &a_slots { + assert!(!b_slots.contains(p), "partition {p:?} must not be in both"); + } + let mut all: Vec<_> = a_slots.iter().chain(b_slots.iter()).cloned().collect(); + all.sort(); + all.dedup(); + assert_eq!(all.len(), 4, "the 2/2 split covers all 4 partitions"); +} + +/// Scenario: consumer/subscriptions/1.05-positive-leave-subscription.md +#[tokio::test] +async fn s1_05_positive_leave_subscription() { + // LEAVE removes the subscription and releases its partitions back to the group. + let (broker, h) = broker_with_topic(TOPIC, 4).await; + let c = ctx(); + let gid = make_group(&c, &broker).await; + let sub_a = join_group(&c, &broker, &gid, TOPIC).await; + let sub_b = join_group(&c, &broker, &gid, TOPIC).await; + let tv_before = h.topology_version(&gid).await; + + broker.leave(&c, sub_b.subscription_id).await.unwrap(); + + // Side effects: sub_b is gone; surviving member inherits all partitions; topology advances. + assert!( + broker + .get_subscription(&c, sub_b.subscription_id) + .await + .is_err(), + "left subscription must be absent" + ); + assert!( + h.topology_version(&gid).await > tv_before, + "LEAVE must bump topology_version for survivors" + ); + assert_eq!( + h.assignment(sub_a.subscription_id).await.len(), + 4, + "released partitions return to the surviving member" + ); +} + +/// Scenario: consumer/subscriptions/1.08-negative-leave-unknown-subscription.md +#[tokio::test] +async fn s1_08_negative_leave_unknown_subscription() { + // Leaving an unknown/expired subscription is rejected with 404 SubscriptionNotFound. + let broker = crate::mock::MockBroker::new(); + let c = ctx(); + let unknown = SubscriptionId(Uuid::parse_str("99999999-8888-7777-6666-555555555555").unwrap()); + + let err = broker + .leave(&c, unknown) + .await + .expect_err("leaving an unknown subscription must be rejected"); + assert!( + matches!( + err, + crate::error::EventBrokerError::SubscriptionNotFound { .. } + ), + "expected SubscriptionNotFound, got {err:?}" + ); +} + +/// Scenario: consumer/subscriptions/1.09-positive-list-subscriptions.md +#[tokio::test] +async fn s1_09_positive_list_subscriptions() { + let (broker, _h) = broker_with_topic(TOPIC, 4).await; + let c = ctx(); + let gid = make_group(&c, &broker).await; + let sub = join_group(&c, &broker, &gid, TOPIC).await; + + let list = broker.list_subscriptions(&c).await.unwrap(); + assert_eq!(list.len(), 1, "exactly one active subscription is listed"); + let item = &list[0]; + assert_eq!(item.id, sub.subscription_id); + assert_eq!(item.consumer_group, gid); + assert_eq!(item.assigned.len(), 4); + assert_eq!(item.topology_version, 1); +} + +/// Scenario: consumer/subscriptions/1.10-positive-read-subscription.md +#[tokio::test] +async fn s1_10_positive_read_subscription() { + let (broker, _h) = broker_with_topic(TOPIC, 4).await; + let c = ctx(); + let gid = make_group(&c, &broker).await; + let sub = join_group(&c, &broker, &gid, TOPIC).await; + + let record = broker + .get_subscription(&c, sub.subscription_id) + .await + .unwrap(); + assert_eq!(record.id, sub.subscription_id); + assert_eq!(record.consumer_group, gid); + assert_eq!( + record.assigned.len(), + 4, + "sole member owns all 4 partitions" + ); + assert_eq!(record.topology_version, 1); +} + +/// Scenario: consumer/subscriptions/1.11-positive-second-join-triggers-rebalance.md +#[tokio::test] +async fn s1_11_positive_second_join_triggers_rebalance() { + // Second JOIN rebalances a 4-partition topic to a 2+2 split; topology advances to 2. + let (broker, h) = broker_with_topic(TOPIC, 4).await; + let c = ctx(); + let gid = make_group(&c, &broker).await; + + let sub_a = join_group(&c, &broker, &gid, TOPIC).await; + assert_eq!(h.assignment(sub_a.subscription_id).await.len(), 4); + + let sub_b = join_group(&c, &broker, &gid, TOPIC).await; + assert_eq!( + sub_b.assigned.len(), + 2, + "new member gets half the partitions" + ); + assert_eq!(sub_b.topology_version, 2); + + // Side effects: sub_a reduced to 2 at topology_version 2; sub_b holds the other 2; disjoint. + assert_eq!(h.assignment(sub_a.subscription_id).await.len(), 2); + assert_eq!(h.topology_version(&gid).await, 2); + let a_slots = h.assignment(sub_a.subscription_id).await; + let b_slots = h.assignment(sub_b.subscription_id).await; + for p in &a_slots { + assert!( + !b_slots.contains(p), + "single-consumer-per-partition invariant" + ); + } +} + +/// Scenario: consumer/subscriptions/1.12-positive-third-join-triggers-rebalance.md +#[tokio::test] +async fn s1_12_positive_third_join_triggers_rebalance() { + // Third JOIN redistributes 4 partitions across 3 members; topology advances to 3. + let (broker, h) = broker_with_topic(TOPIC, 4).await; + let c = ctx(); + let gid = make_group(&c, &broker).await; + + let sub_a = join_group(&c, &broker, &gid, TOPIC).await; + let sub_b = join_group(&c, &broker, &gid, TOPIC).await; + let sub_c = join_group(&c, &broker, &gid, TOPIC).await; + + assert_eq!(sub_c.topology_version, 3); + assert_eq!(h.topology_version(&gid).await, 3); + + let a_slots = h.assignment(sub_a.subscription_id).await; + let b_slots = h.assignment(sub_b.subscription_id).await; + let c_slots = h.assignment(sub_c.subscription_id).await; + + // Each member holds at least one partition; the single-consumer-per-partition + // invariant holds and all 4 partitions are covered exactly once. + assert!(!a_slots.is_empty() && !b_slots.is_empty() && !c_slots.is_empty()); + let mut all: Vec<_> = a_slots + .iter() + .chain(b_slots.iter()) + .chain(c_slots.iter()) + .cloned() + .collect(); + let total = all.len(); + all.sort(); + all.dedup(); + assert_eq!(all.len(), 4, "all 4 partitions covered"); + assert_eq!(total, 4, "no partition owned by more than one member"); +} + +/// Scenario: consumer/subscriptions/1.13-negative-join-group-at-capacity.md +#[tokio::test] +async fn s1_13_negative_join_group_at_capacity() { + // A 1-partition topic admits exactly one member; a second JOIN that would receive + // zero partitions is refused with GroupAtCapacity (the 429 wire refusal). + let (broker, _h) = broker_with_topic(TOPIC, 1).await; + let c = ctx(); + let gid = make_group(&c, &broker).await; + + // First member fills the only partition slot. + let _sub_a = join_group(&c, &broker, &gid, TOPIC).await; + + // Second JOIN is refused - no partition slot available. + let err = broker + .join( + &c, + JoinRequest { + group: gid, + client_agent: "order-worker/1.4.0".to_owned(), + interests: vec![SubscriptionInterest { + topic: TOPIC.to_owned(), + tenant_id: Uuid::nil(), + tenant_depth: crate::api::TenantTraversalDepth::CurrentTenant, + barrier_mode: BarrierMode::Respect, + types: vec!["*".to_owned()], + filter: None, + }], + session_timeout: None, + }, + ) + .await + .unwrap_err(); + + match err { + EventBrokerError::GroupAtCapacity { + active, partitions, .. + } => { + assert_eq!(active, 1, "one active member already holds the partition"); + assert_eq!(partitions, 1, "the topic has a single partition"); + } + other => panic!("expected GroupAtCapacity, got {other:?}"), + } + + // Side effect: no subscription is created (no zero-partition standby). + assert_eq!( + broker.list_subscriptions(&c).await.unwrap().len(), + 1, + "the refused JOIN must not create a second subscription" + ); +} + +/// Scenario: consumer/subscriptions/1.07-negative-join-too-many-interests.md +#[tokio::test] +async fn s1_07_negative_join_too_many_interests() { + let (broker, _h) = broker_with_topic(TOPIC, 1).await; + let c = ctx(); + let gid = make_group(&c, &broker).await; + // 65 interests exceeds the per-subscription cap of 64. + let interests: Vec = (0..65) + .map(|_| SubscriptionInterest { + topic: TOPIC.to_owned(), + tenant_id: Uuid::nil(), + tenant_depth: crate::api::TenantTraversalDepth::CurrentTenant, + barrier_mode: BarrierMode::Respect, + types: vec!["*".to_owned()], + filter: None, + }) + .collect(); + let err = broker + .join( + &c, + JoinRequest { + group: gid, + client_agent: "test-consumer/1.0".to_owned(), + interests, + session_timeout: None, + }, + ) + .await + .expect_err("more than 64 interests must be rejected"); + assert!( + matches!(err, EventBrokerError::InvalidEventField { field, .. } if field == "interests"), + "expected InvalidEventField(interests), got {err:?}" + ); +} diff --git a/gears/system/event-broker/event-broker-sdk/src/mock/tests/coverage_guard.rs b/gears/system/event-broker/event-broker-sdk/src/mock/tests/coverage_guard.rs new file mode 100644 index 000000000..98930800a --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/src/mock/tests/coverage_guard.rs @@ -0,0 +1,100 @@ +//! Coverage guard (mock-reference-alignment design D5): the mock test tree must +//! mirror the scenario tree 1:1. Every in-scope scenario has exactly one test that +//! references it via a `/// Scenario: ` doc comment; the only permitted +//! absences are the explicit `OUT_OF_SCOPE` entries (auth + pure-HTTP-transport +//! guardrails, covered at the HTTP/integration layer). + +#![cfg(test)] + +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; + +/// Scenarios the in-process mock deliberately does NOT implement (auth + +/// transport-edge concerns). These live at the HTTP/integration layer and are the +/// only scenarios permitted to lack a mock test. +const OUT_OF_SCOPE: &[&str] = &[ + "auth/1.01-negative-missing-bearer-token.md", + "auth/1.02-negative-invalid-bearer-token.md", + "auth/1.03-negative-insufficient-permission-produce.md", + "auth/1.04-negative-insufficient-permission-consume.md", + "auth/1.05-negative-cross-tenant-anonymous-group.md", + "consumer/subscriptions/1.06-negative-join-unauthorized-topic.md", + "consumer/stream/1.07-guardrail-stream-accept-json-rejected.md", + "consumer/stream/1.08-guardrail-sse-from-stream-endpoint.md", + "consumer/stream/1.09-positive-sse-event-stream.md", + "consumer/stream/1.10-negative-stream-rejects-timeout-collect-params.md", + "errors/1.02-negative-401-unauthenticated.md", + "errors/1.03-negative-403-unauthorized.md", +]; + +fn collect_scenarios(dir: &Path, base: &Path, out: &mut BTreeSet) { + for entry in std::fs::read_dir(dir).expect("read scenarios dir") { + let p = entry.unwrap().path(); + if p.is_dir() { + collect_scenarios(&p, base, out); + } else if p.extension().and_then(|e| e.to_str()) == Some("md") { + if p.file_name().and_then(|n| n.to_str()) == Some("INDEX.md") { + continue; + } + let rel = p + .strip_prefix(base) + .unwrap() + .to_string_lossy() + .replace('\\', "/"); + out.insert(rel); + } + } +} + +fn collect_referenced(dir: &Path, out: &mut BTreeSet) { + for entry in std::fs::read_dir(dir).expect("read tests dir") { + let p = entry.unwrap().path(); + if p.is_dir() { + collect_referenced(&p, out); + } else if p.extension().and_then(|e| e.to_str()) == Some("rs") { + let txt = std::fs::read_to_string(&p).unwrap(); + for line in txt.lines() { + let trimmed = line.trim_start(); + // Real doc comments START with the marker; a string literal that merely + // contains it (e.g. this guard's own source) does not. + if let Some(rest) = trimmed.strip_prefix("/// Scenario:") { + let path = rest.trim(); + if !path.is_empty() { + out.insert(path.to_string()); + } + } + } + } + } +} + +#[test] +fn every_in_scope_scenario_has_exactly_one_test() { + let manifest = env!("CARGO_MANIFEST_DIR"); + let scen_root = PathBuf::from(manifest).join("../scenarios"); + let tests_root = PathBuf::from(manifest).join("src/mock/tests"); + + let mut scenarios = BTreeSet::new(); + collect_scenarios(&scen_root, &scen_root, &mut scenarios); + + let oos: BTreeSet = OUT_OF_SCOPE.iter().map(|s| s.to_string()).collect(); + // Every OUT_OF_SCOPE entry must name a real scenario (catches typos / renames). + let stale_oos: Vec<&String> = oos.difference(&scenarios).collect(); + assert!( + stale_oos.is_empty(), + "OUT_OF_SCOPE names non-existent scenarios: {stale_oos:#?}" + ); + + let expected: BTreeSet = scenarios.difference(&oos).cloned().collect(); + + let mut referenced = BTreeSet::new(); + collect_referenced(&tests_root, &mut referenced); + + let missing: Vec<&String> = expected.difference(&referenced).collect(); + let dangling: Vec<&String> = referenced.difference(&expected).collect(); + + assert!( + missing.is_empty() && dangling.is_empty(), + "scenario↔test mirror is broken.\n MISSING tests for in-scope scenarios: {missing:#?}\n DANGLING `/// Scenario:` refs (no such in-scope scenario): {dangling:#?}", + ); +} diff --git a/gears/system/event-broker/event-broker-sdk/src/mock/tests/errors.rs b/gears/system/event-broker/event-broker-sdk/src/mock/tests/errors.rs new file mode 100644 index 000000000..2762a7de9 --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/src/mock/tests/errors.rs @@ -0,0 +1,239 @@ +//! Mirrors scenarios/errors/. Tests migrated per mock-reference-alignment. +//! +//! The in-process mock has no HTTP layer, so it cannot reproduce status codes, +//! the `application/problem+json` envelope, `Retry-After` headers, or +//! auth (401/403). Each test here asserts the *closest real thing* the mock +//! returns - the `EventBrokerError` variant the broker error model maps onto +//! that status. Scenarios that can only be expressed at the HTTP/auth layer are +//! recorded as divergences in the change notes, not faked. +use super::helpers::*; +#[cfg(test)] +use toolkit_gts::gts_id; + +use super::helpers::{broker_with_topic, ctx, wire_event}; +use crate::api::{EventBroker, ProducerMode}; +use crate::error::EventBrokerError; +use crate::ids::{ConsumerGroupId, SubscriptionId}; +use uuid::Uuid; + +/// Scenario: errors/1.01-positive-problem-details-envelope.md +/// +/// The reference defines the RFC-9457 `problem+json` envelope and triggers it +/// with a representative `404` (GET unknown consumer group). The mock has no +/// HTTP envelope; this asserts the SDK variant that carries the same domain +/// identity the envelope's `context.resource_*` would - `ConsumerGroupNotFound` +/// naming the missing group. +#[tokio::test] +async fn s1_01_positive_problem_details_envelope() { + let (broker, _h) = broker_with_topic(TOPIC, 1).await; + let c = ctx(); + let missing = ConsumerGroupId::from_gts(gts_id!( + "cf.core.events.consumer_group.v1~example.missing.group.x.v1" + )); + + let err = broker.get_consumer_group(&c, &missing).await.unwrap_err(); + match err { + EventBrokerError::ConsumerGroupNotFound { + ref group_id, + ref detail, + .. + } => { + assert_eq!(group_id, &missing, "error identifies the missing group"); + assert!( + detail.contains(&missing.to_string()), + "detail names the missing resource: {detail}" + ); + } + other => panic!("expected ConsumerGroupNotFound, got {other:?}"), + } +} + +/// Scenario: errors/1.04-negative-404-not-found.md +/// +/// The reference triggers `404` by opening a stream for an unknown subscription. +/// The mock has no HTTP envelope; this asserts the SDK variant that carries the +/// same domain identity - `SubscriptionNotFound` naming the missing +/// subscription. +#[tokio::test] +async fn s1_04_negative_404_not_found() { + let (broker, _h) = broker_with_topic(TOPIC, 1).await; + let c = ctx(); + let unknown = SubscriptionId(Uuid::parse_str("99999999-8888-7777-6666-555555555555").unwrap()); + + let err = match broker.stream(&c, unknown).await { + Ok(_) => panic!("unknown subscription must be rejected"), + Err(err) => err, + }; + match err { + EventBrokerError::SubscriptionNotFound { id, .. } => { + assert_eq!(id, unknown, "error identifies the missing subscription"); + } + other => panic!("expected SubscriptionNotFound, got {other:?}"), + } +} + +/// Scenario: errors/1.05-negative-409-conflict.md +/// +/// DIVERGENCE: not faithfully reproducible. The reference `409` (open a stream +/// whose assigned partitions have no committed cursor → `cursor_missing`) is +/// enforced only at the HTTP edge. The mock's `stream()` does NOT require a +/// committed cursor before opening, so the `cursor_missing` precondition cannot +/// be provoked here. The closest representable conflict in the SDK error model +/// is `PositionsNotSet` (unseeded partitions), which the mock does not raise on +/// stream-open. This test documents that the mock streams without a committed +/// cursor - the inverse of the reference's precondition - so the `409` is a +/// divergence. +#[tokio::test] +async fn s1_05_negative_409_conflict() { + let (broker, _h) = broker_with_topic(TOPIC, 1).await; + let c = ctx(); + let gid = broker + .create_consumer_group( + &c, + crate::models::CreateConsumerGroupRequest { + client_agent: "test-agent/1.0".to_owned(), + description: None, + }, + ) + .await + .unwrap() + .id; + let sub = super::helpers::join_group(&c, &broker, &gid, TOPIC).await; + + // No SEEK / no committed cursor → the stream-open precondition fails with + // PositionsNotSet (the SDK-level analog of the 409 cursor_missing conflict). + let err = broker + .stream(&c, sub.subscription_id) + .await + .err() + .expect("unseeded stream-open must be rejected"); + assert!( + matches!(err, crate::error::EventBrokerError::PositionsNotSet { .. }), + "expected PositionsNotSet, got {err:?}" + ); +} + +/// Scenario: errors/1.06-negative-412-sequence-violation.md +/// +/// The one producer-protocol error that escapes to callers: a chained-mode +/// publish whose `meta.previous` doesn't match the broker's `last_sequence` is +/// rejected. The mock surfaces this as `EventBrokerError::SequenceViolation`, +/// the SDK mapping of the `412` failed-precondition envelope, carrying the +/// expected previous for resync. +#[tokio::test] +async fn s1_06_negative_412_sequence_violation() { + let (broker, _h) = broker_with_topic(TOPIC, 1).await; + let c = ctx(); + let pid = broker + .register_producer(&c, ProducerMode::Chained, "svc/1.0") + .await + .unwrap() + .0; + + // Seed a chain at sequence=1 (previous=-1), so broker last_sequence becomes 1. + broker + .publish(&c, &chained_event(&c, TOPIC, pid, 1, -1)) + .await + .unwrap(); + + // Publish sequence=2 with a wrong previous (3 instead of 1) → violation. + let err = broker + .publish(&c, &chained_event(&c, TOPIC, pid, 2, 3)) + .await + .unwrap_err(); + match err { + EventBrokerError::SequenceViolation { + expected_previous, .. + } => { + assert_eq!( + expected_previous, 1, + "broker reports its current last_sequence for resync" + ); + } + other => panic!("expected SequenceViolation, got {other:?}"), + } +} + +/// Scenario: errors/1.07-negative-429-rate-limited.md +/// +/// A tenant exceeding the publish quota is rejected. The mock models the quota +/// via `set_publish_rate_limit`; once the allowance is exhausted, `publish` +/// returns `EventBrokerError::RateLimited` carrying `retry_after_secs` - the SDK +/// mapping of the `429` resource-exhausted envelope. +/// +/// DIVERGENCE (partial): the HTTP `Retry-After` header and `problem+json` body +/// are not represented; only `retry_after_secs` on the variant is asserted. +#[tokio::test] +async fn s1_07_negative_429_rate_limited() { + let (broker, h) = broker_with_topic(TOPIC, 1).await; + let c = ctx(); + + // Allow exactly one publish, then throttle. + h.set_publish_rate_limit(Some(1)).await; + broker + .publish(&c, &wire_event(TOPIC, EVT, c.subject_tenant_id())) + .await + .unwrap(); + + let err = broker + .publish(&c, &wire_event(TOPIC, EVT, c.subject_tenant_id())) + .await + .unwrap_err(); + match err { + EventBrokerError::RateLimited { + retry_after_secs, .. + } => { + assert!( + retry_after_secs > 0, + "throttled publish carries a retry-after hint" + ); + } + other => panic!("expected RateLimited, got {other:?}"), + } +} + +/// Scenario: errors/1.08-negative-500-internal.md +/// +/// An internal invariant failure returns a generic error with no leaked +/// internals. The mock injects this via `reject_persist`, which surfaces as +/// `EventBrokerError::Internal` - the SDK mapping of the `500` envelope. +/// +/// DIVERGENCE (partial): the mock echoes the injected reason in the `Internal` +/// payload (a test affordance); the production HTTP layer scrubs `detail` to a +/// generic message. The scrubbing happens at the edge, not in the SDK variant, +/// so only the variant kind is asserted. +#[tokio::test] +async fn s1_08_negative_500_internal() { + let (broker, h) = broker_with_topic(TOPIC, 1).await; + let c = ctx(); + + h.reject_persist(Some("simulated invariant failure")).await; + let err = broker + .publish(&c, &wire_event(TOPIC, EVT, c.subject_tenant_id())) + .await + .unwrap_err(); + assert!( + matches!(err, EventBrokerError::Internal(_)), + "an internal invariant failure surfaces as EventBrokerError::Internal, got {err:?}" + ); +} + +// -- Local helper: chained-mode producer event (mirrors idempotency.rs) --------- + +fn chained_event( + ctx: &toolkit_security::SecurityContext, + topic: &str, + producer_id: Uuid, + sequence: i64, + previous: i64, +) -> crate::models::Event { + let mut ev = wire_event(topic, EVT, ctx.subject_tenant_id()); + ev.meta = Some(crate::models::ProducerMeta { + version: 1, + producer_id: Some(producer_id), + previous: Some(previous), + sequence: Some(sequence), + partition_hint: None, + }); + ev +} diff --git a/gears/system/event-broker/event-broker-sdk/src/mock/tests/flows.rs b/gears/system/event-broker/event-broker-sdk/src/mock/tests/flows.rs new file mode 100644 index 000000000..a2f274d9b --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/src/mock/tests/flows.rs @@ -0,0 +1,120 @@ +//! Mirrors scenarios/flows/. Tests migrated per mock-reference-alignment. +#[cfg(test)] +use super::helpers::*; + +use super::helpers::{broker_with_topic, ctx, join_group, make_group, wire_event}; +use crate::ResolvedPosition; +use crate::api::{EventBroker, IngestOutcome, SeekPosition, WireFrame}; +use futures_util::StreamExt; + +/// Scenario: flows/1.01-flow-publish-subscribe-consume.md +/// +/// End-to-end transcript: publish three events → create group → JOIN → SEEK +/// earliest → stream the three events in strict offset order → ack via SEEK → +/// re-JOIN on the same group resumes past the ack (no redelivery). +/// +/// The reference fixes RF=100 so offsets are 100/101/102; the in-process mock +/// has no retention floor (offsets start at 0), so this asserts the *shape* of +/// the journey - three in-order events, an advancing group cursor, and a +/// re-JOIN that resumes from the committed cursor - rather than the literal +/// 100-based offsets. +#[tokio::test] +async fn s1_01_flow_publish_subscribe_consume() { + // 4 partitions; all helper events hash to the tenant's partition (single + // partition exercised), mirroring "all three events hash to partition 0". + let (broker, h) = broker_with_topic(TOPIC, 4).await; + let c = ctx(); + + // -- Exchanges 1-3: publish three events (persist-confirming path). ---------- + for _ in 0..3 { + let outcome = broker + .publish_sync(&c, &wire_event(TOPIC, EVT, c.subject_tenant_id())) + .await + .unwrap(); + assert!( + matches!(outcome, IngestOutcome::Accepted | IngestOutcome::Persisted), + "publish must be accepted/persisted, got {outcome:?}" + ); + } + + // Identify the partition the three events landed on. + let mut partition = None; + for p in 0..4u32 { + if h.stored(TOPIC, p).await.len() == 3 { + partition = Some(p); + break; + } + } + let partition = partition.expect("all three events must land on a single partition"); + + // -- Exchange 4: create the consumer group. ---------------------------------- + let gid = make_group(&c, &broker).await; + + // -- Exchange 5: JOIN - assignment covers all partitions. -------------------- + let sub = join_group(&c, &broker, &gid, TOPIC).await; + assert_eq!(sub.topology_version, 1, "first JOIN is topology v1"); + assert_eq!( + sub.assigned.len(), + 4, + "solo member is assigned all four partitions" + ); + + // -- Exchange 6: SEEK earliest on every assigned partition (required before + // streaming - PositionsNotSet otherwise). --------------------------------- + super::helpers::seek_all_earliest(&c, &broker, &sub).await; + assert_eq!( + h.cursor(&gid, TOPIC, partition).await, + Some(0), + "Earliest resolves to 0 in the mock for the populated partition" + ); + + // -- Exchange 7: open the stream and read the three events in order. --------- + let mut stream = broker.stream(&c, sub.subscription_id).await.unwrap(); + let mut offsets = Vec::new(); + for _ in 0..12 { + match tokio::time::timeout(std::time::Duration::from_millis(50), stream.next()).await { + Ok(Some(Ok(WireFrame::Event(we)))) => { + offsets.push(we.offset); + if offsets.len() == 3 { + break; + } + } + Ok(Some(Ok(_))) => {} // Topology / Heartbeat / Control - skip. + _ => break, + } + } + drop(stream); // release the stream so SEEK is not "streaming_in_progress". + assert_eq!( + offsets, + vec![1, 2, 3], + "events delivered in strict offset order (1-based)" + ); + + // -- Exchange 8: ack through the last delivered offset. ---------------------- + broker + .seek( + &c, + sub.subscription_id, + &[SeekPosition { + topic: TOPIC.to_owned(), + partition, + value: ResolvedPosition::Exact(2), + }], + ) + .await + .unwrap(); + assert_eq!( + h.cursor(&gid, TOPIC, partition).await, + Some(2), + "group cursor advances to the acked offset" + ); + + // -- Exchange 9: re-JOIN on the same group resumes from the committed cursor. - + broker.leave(&c, sub.subscription_id).await.unwrap(); + let _sub2 = join_group(&c, &broker, &gid, TOPIC).await; + assert_eq!( + h.cursor(&gid, TOPIC, partition).await, + Some(2), + "re-JOIN carries the committed cursor (no redelivery of 0-2)" + ); +} diff --git a/gears/system/event-broker/event-broker-sdk/src/mock/tests/helpers.rs b/gears/system/event-broker/event-broker-sdk/src/mock/tests/helpers.rs new file mode 100644 index 000000000..ce8598fcb --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/src/mock/tests/helpers.rs @@ -0,0 +1,124 @@ +use crate::api::SubscriptionInterest; +use crate::api::{EventBroker, JoinRequest, SubscriptionAssignment}; +use crate::ids::ConsumerGroupId; +use crate::mock::stubs::test_ctx_for_tenant; +use crate::mock::{MockBroker, MockBrokerHandle}; +use crate::models::CreateConsumerGroupRequest; +use crate::models::Event; +use toolkit_gts::gts_id; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +// -- GTS identifier constants used across all mock tests ---------------------- + +pub const TOPIC: &str = gts_id!("cf.core.events.topic.v1~example.mock.broker.audit.v1"); +pub const TOPIC2: &str = gts_id!("cf.core.events.topic.v1~example.mock.broker.notify.v1"); +pub const TOPIC3: &str = gts_id!("cf.core.events.topic.v1~example.mock.broker.analytics.v1"); +pub const EVT: &str = gts_id!("cf.core.events.event_type.v1~example.mock.broker.event.v1"); +pub const EVT2: &str = gts_id!("cf.core.events.event_type.v1~example.mock.broker.event2.v1"); + +// -- Helpers ------------------------------------------------------------------- + +pub async fn broker_with_topic(topic: &str, partitions: u32) -> (MockBroker, MockBrokerHandle) { + let broker = MockBroker::new(); + let h = broker.handle(); + h.register_topic(topic, partitions).await; + (broker, h) +} + +pub fn ctx() -> SecurityContext { + test_ctx_for_tenant(Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap()) +} + +pub fn ctx2() -> SecurityContext { + test_ctx_for_tenant(Uuid::parse_str("00000000-0000-0000-0000-000000000002").unwrap()) +} + +pub fn wire_event(topic: &str, type_id: &str, tenant_id: Uuid) -> Event { + Event { + id: Uuid::new_v4(), + type_id: type_id.to_owned(), + topic: topic.to_owned(), + tenant_id, + source: "test.mock".to_owned(), + subject: "test-subject".to_owned(), + subject_type: "test-type".to_owned(), + partition_key: None, + occurred_at: chrono::Utc::now(), + trace_parent: None, + data: None, + partition: None, + sequence: None, + sequence_time: None, + offset: None, + offset_time: None, + meta: None, + } +} + +pub async fn make_group(ctx: &SecurityContext, broker: &MockBroker) -> ConsumerGroupId { + broker + .create_consumer_group( + ctx, + CreateConsumerGroupRequest { + client_agent: "test-agent/1.0".to_owned(), + description: None, + }, + ) + .await + .unwrap() + .id +} + +pub async fn join_group( + ctx: &SecurityContext, + broker: &MockBroker, + group: &ConsumerGroupId, + topic: &str, +) -> SubscriptionAssignment { + broker + .join( + ctx, + JoinRequest { + group: *group, + client_agent: "test-consumer/1.0".to_owned(), + interests: vec![SubscriptionInterest { + topic: topic.to_owned(), + tenant_id: uuid::Uuid::nil(), + tenant_depth: crate::api::TenantTraversalDepth::CurrentTenant, + barrier_mode: crate::api::BarrierMode::Respect, + types: vec!["*".to_owned()], + filter: None, + }], + session_timeout: Some(std::time::Duration::from_secs(30)), + }, + ) + .await + .unwrap() +} + +/// Seed every assigned partition of a subscription to `Earliest` - the standard +/// "well-behaved consumer SEEKs before streaming" step (satisfies PositionsNotSet). +pub async fn seek_all_earliest( + ctx: &SecurityContext, + broker: &MockBroker, + sub: &SubscriptionAssignment, +) { + use crate::ResolvedPosition; + use crate::api::SeekPosition; + let positions: Vec = sub + .assigned + .iter() + .map(|a| SeekPosition { + topic: a.topic.clone(), + partition: a.partition, + value: ResolvedPosition::Earliest, + }) + .collect(); + if !positions.is_empty() { + broker + .seek(ctx, sub.subscription_id, &positions) + .await + .unwrap(); + } +} diff --git a/gears/system/event-broker/event-broker-sdk/src/mock/tests/mod.rs b/gears/system/event-broker/event-broker-sdk/src/mock/tests/mod.rs new file mode 100644 index 000000000..366920655 --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/src/mock/tests/mod.rs @@ -0,0 +1,9 @@ +pub(super) mod helpers; + +// Mirrored test tree (mock-reference-alignment): module path == scenario folder path. +mod consumer; +mod coverage_guard; +mod errors; +mod flows; +mod producer; +mod topics; diff --git a/gears/system/event-broker/event-broker-sdk/src/mock/tests/producer/batch.rs b/gears/system/event-broker/event-broker-sdk/src/mock/tests/producer/batch.rs new file mode 100644 index 000000000..0d736fe6c --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/src/mock/tests/producer/batch.rs @@ -0,0 +1,135 @@ +//! Mirrors scenarios/producer/batch/. Tests migrated per mock-reference-alignment. + +#[cfg(test)] +use super::super::helpers::*; + +use super::super::helpers::{broker_with_topic, ctx, wire_event}; +use crate::api::{EventBroker, IngestOutcome}; + +/// Scenario: producer/batch/1.01-positive-publish-batch.md +#[tokio::test] +async fn s1_01_publish_batch() { + // A homogeneous batch (same topic, same resolved partition) is accepted + // all-or-nothing; both events durably enqueued. + let (broker, h) = broker_with_topic(TOPIC, 1).await; + let c = ctx(); + let evs: Vec<_> = (0..2) + .map(|_| wire_event(TOPIC, EVT, c.subject_tenant_id())) + .collect(); + let outcomes = broker.publish_batch(&c, &evs).await.unwrap(); + assert_eq!(outcomes.len(), 2); + assert!(outcomes.iter().all(|o| *o == IngestOutcome::Accepted)); + assert_eq!(h.stored(TOPIC, 0).await.len(), 2); +} + +/// Scenario: producer/batch/1.02-negative-mixed-partition-batch.md +#[tokio::test] +async fn s1_02_mixed_partition_batch() { + // A batch is atomic per topic. The scenario rejects a batch that mixes + // topics; the mock additionally enforces a single resolved partition per + // batch. We assert BOTH: (a) mixed-topic batch is rejected, and (b) + // mixed-partition batch is rejected. Neither admits any event. + let (broker, h) = broker_with_topic(TOPIC, 2).await; + h.register_topic(TOPIC2, 2).await; + let c = ctx(); + + // (a) Mixed topics → rejected. + let err = broker + .publish_batch( + &c, + &[ + wire_event(TOPIC, EVT, c.subject_tenant_id()), + wire_event(TOPIC2, EVT, c.subject_tenant_id()), + ], + ) + .await + .unwrap_err(); + let msg = format!("{err:?}"); + assert!( + msg.contains("topic") || msg.contains("mixed"), + "mixed-topic batch must be rejected: {msg}" + ); + assert!(h.stored(TOPIC, 0).await.is_empty()); + assert!(h.stored(TOPIC, 1).await.is_empty()); + + // (b) Mixed partitions within one topic → rejected (per-batch single partition). + use crate::mock::partitioning::partition_for; + let p0 = partition_for(&c.subject_tenant_id().to_string(), 2); + let other = if p0 == 0 { "other-one" } else { "other-zero" }; + if partition_for(other, 2) != p0 { + let mut ev2 = wire_event(TOPIC, EVT, c.subject_tenant_id()); + ev2.partition_key = Some(other.to_owned()); + let err = broker + .publish_batch(&c, &[wire_event(TOPIC, EVT, c.subject_tenant_id()), ev2]) + .await + .unwrap_err(); + let msg = format!("{err:?}"); + assert!( + msg.contains("mixed") || msg.contains("partition"), + "mixed-partition batch must be rejected: {msg}" + ); + } +} + +/// Scenario: producer/batch/1.03-negative-batch-too-large.md +#[tokio::test] +async fn s1_03_batch_too_large() { + // A batch may carry at most 100 events; 101 is rejected with BatchTooLarge + // (413). No event from the batch is admitted. + let (broker, h) = broker_with_topic(TOPIC, 1).await; + let c = ctx(); + // 101 events, all on the same partition (single-partition topic). + let evs: Vec<_> = (0..101) + .map(|_| wire_event(TOPIC, EVT, c.subject_tenant_id())) + .collect(); + let err = broker.publish_batch(&c, &evs).await.unwrap_err(); + let msg = format!("{err:?}"); + assert!( + msg.contains("BatchTooLarge") || msg.contains("too large") || msg.contains("exceeds"), + "101-event batch must be BatchTooLarge: {msg}" + ); + // All-or-nothing: nothing admitted. + assert!(h.stored(TOPIC, 0).await.is_empty()); +} + +/// Scenario: producer/batch/1.04-negative-batch-late-validation-failure.md +#[tokio::test] +async fn s1_04_late_validation_failure_is_atomic() { + let (broker, h) = broker_with_topic(TOPIC, 1).await; + h.register_event_type( + TOPIC, + EVT, + serde_json::json!({ + "type": "object", + "required": ["order_id", "total_cents"], + "properties": { + "order_id": { "type": "string" }, + "total_cents": { "type": "integer" } + } + }), + &["test-type"], + ) + .await; + let c = ctx(); + let mut valid = wire_event(TOPIC, EVT, c.subject_tenant_id()); + valid.data = Some(serde_json::json!({ + "order_id": "order-atomic", + "total_cents": 100 + })); + let mut invalid = wire_event(TOPIC, EVT, c.subject_tenant_id()); + invalid.data = Some(serde_json::json!({ + "order_id": "order-atomic" + })); + + let err = broker + .publish_batch(&c, &[valid, invalid]) + .await + .unwrap_err(); + + let msg = format!("{err:?}"); + assert!( + msg.contains("EventDataInvalid") || msg.contains("total_cents"), + "late invalid event must reject the batch: {msg}" + ); + assert!(h.stored(TOPIC, 0).await.is_empty()); +} diff --git a/gears/system/event-broker/event-broker-sdk/src/mock/tests/producer/flows.rs b/gears/system/event-broker/event-broker-sdk/src/mock/tests/producer/flows.rs new file mode 100644 index 000000000..85911f033 --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/src/mock/tests/producer/flows.rs @@ -0,0 +1,480 @@ +//! Mirrors scenarios/producer/flows/. Tests migrated per mock-reference-alignment. + +#[cfg(test)] +use super::super::helpers::*; + +use super::super::helpers::{broker_with_topic, ctx, wire_event}; +use crate::api::{EventBroker, IngestOutcome, ProducerMode}; +use crate::error::EventBrokerError; +use crate::models::{Event, ProducerMeta}; +use uuid::Uuid; + +// -- Local meta-stamping builders (mirror idempotency.rs idioms) --------------- +// The mock detects producer mode from the `meta` block; we stamp it directly. + +fn chained_event( + ctx: &toolkit_security::SecurityContext, + topic: &str, + producer_id: Uuid, + sequence: i64, + previous: i64, +) -> Event { + let mut ev = wire_event(topic, EVT, ctx.subject_tenant_id()); + ev.meta = Some(ProducerMeta { + version: 1, + producer_id: Some(producer_id), + previous: Some(previous), + sequence: Some(sequence), + partition_hint: None, + }); + ev +} + +fn monotonic_event( + ctx: &toolkit_security::SecurityContext, + topic: &str, + producer_id: Uuid, + sequence: i64, +) -> Event { + let mut ev = wire_event(topic, EVT, ctx.subject_tenant_id()); + ev.meta = Some(ProducerMeta { + version: 1, + producer_id: Some(producer_id), + previous: None, + sequence: Some(sequence), + partition_hint: None, + }); + ev +} + +/// Scenario: producer/flows/1.01-positive-register-chained-producer.md +#[tokio::test] +async fn s1_01_register_chained_producer() { + // Registering a chained-mode producer mints a producer_id bound to the + // caller's principal (201 Created). + let (broker, _h) = broker_with_topic(TOPIC, 1).await; + let c = ctx(); + let pid = broker + .register_producer(&c, ProducerMode::Chained, "order-service/1.0") + .await + .unwrap(); + // A fresh producer has no chain state yet. + assert!( + broker + .get_producer_cursors(&c, pid) + .await + .unwrap() + .is_empty() + ); +} + +/// Scenario: producer/flows/1.02-positive-register-monotonic-producer.md +#[tokio::test] +async fn s1_02_register_monotonic_producer() { + // Registering a monotonic-mode producer mints a producer_id (201 Created). + let (broker, _h) = broker_with_topic(TOPIC, 1).await; + let c = ctx(); + let pid = broker + .register_producer(&c, ProducerMode::Monotonic, "order-service/1.0") + .await + .unwrap(); + assert!( + broker + .get_producer_cursors(&c, pid) + .await + .unwrap() + .is_empty() + ); + + // Monotonic publishes (producer_id + sequence, no previous) are accepted and + // dedupe on (producer_id, topic, partition, sequence). + let mpid = broker + .register_producer(&c, ProducerMode::Monotonic, "svc/1.0") + .await + .unwrap() + .0; + assert_eq!( + broker + .publish(&c, &monotonic_event(&c, TOPIC, mpid, 1)) + .await + .unwrap(), + IngestOutcome::Accepted + ); + assert_eq!( + broker + .publish(&c, &monotonic_event(&c, TOPIC, mpid, 1)) + .await + .unwrap(), + IngestOutcome::Duplicate + ); +} + +#[tokio::test] +async fn registered_producer_mode_must_match_event_metadata() { + let (broker, _h) = broker_with_topic(TOPIC, 1).await; + let c = ctx(); + let pid = broker + .register_producer(&c, ProducerMode::Monotonic, "svc/1.0") + .await + .unwrap() + .0; + + let err = broker + .publish(&c, &chained_event(&c, TOPIC, pid, 1, -1)) + .await + .unwrap_err(); + assert!( + matches!(err, EventBrokerError::InvalidEventField { field, .. } if field == "meta"), + "registered monotonic producer must reject chained metadata: {err:?}" + ); +} + +/// Scenario: producer/flows/1.03-positive-chained-mode-sequence.md +#[tokio::test] +async fn s1_03_chained_mode_sequence() { + // Chained publish whose meta.previous matches the stored last_sequence is + // admitted (202) and advances last_sequence by one. + let (broker, _h) = broker_with_topic(TOPIC, 1).await; + let c = ctx(); + let pid = broker + .register_producer(&c, ProducerMode::Chained, "svc/1.0") + .await + .unwrap() + .0; + // Seed the chain to last_sequence = 7 (single partition 0). + let mut prev = -1; + for seq in 1..=7 { + broker + .publish(&c, &chained_event(&c, TOPIC, pid, seq, prev)) + .await + .unwrap(); + prev = seq; + } + // Next publish previous=7, sequence=8 matches → Accepted. + assert_eq!( + broker + .publish(&c, &chained_event(&c, TOPIC, pid, 8, 7)) + .await + .unwrap(), + IngestOutcome::Accepted + ); + // last_sequence advanced 7 → 8. + let cursors = broker + .get_producer_cursors(&c, crate::ids::ProducerId(pid)) + .await + .unwrap(); + let c0 = cursors + .iter() + .find(|cur| cur.topic == TOPIC && cur.partition == 0) + .expect("cursor for (topic, 0) present"); + assert_eq!(c0.last_sequence, 8); +} + +/// Scenario: producer/flows/1.04-positive-idempotency-key-dedup.md +#[tokio::test] +async fn s1_04_idempotency_key_dedup() { + // Re-publishing an already-admitted chained event is recognised as a + // duplicate: not re-admitted, last_sequence does not advance again, and the + // chain is not poisoned (next sequence still succeeds). + let (broker, h) = broker_with_topic(TOPIC, 1).await; + let c = ctx(); + let pid = broker + .register_producer(&c, ProducerMode::Chained, "svc/1.0") + .await + .unwrap() + .0; + let mut prev = -1; + for seq in 1..=8 { + broker + .publish(&c, &chained_event(&c, TOPIC, pid, seq, prev)) + .await + .unwrap(); + prev = seq; + } + let stored_before = h.stored(TOPIC, 0).await.len(); + + // Re-send the same (previous=7, sequence=8) event. + let dup = broker + .publish(&c, &chained_event(&c, TOPIC, pid, 8, 7)) + .await + .unwrap(); + assert_eq!(dup, IngestOutcome::Duplicate, "retry must be Duplicate"); + + // No new event appended. + assert_eq!(h.stored(TOPIC, 0).await.len(), stored_before); + // last_sequence stays at 8. + let cursors = broker + .get_producer_cursors(&c, crate::ids::ProducerId(pid)) + .await + .unwrap(); + let c0 = cursors.iter().find(|cur| cur.partition == 0).unwrap(); + assert_eq!(c0.last_sequence, 8); + + // Chain not poisoned: next sequence still admits. + assert_eq!( + broker + .publish(&c, &chained_event(&c, TOPIC, pid, 9, 8)) + .await + .unwrap(), + IngestOutcome::Accepted + ); +} + +/// Scenario: producer/flows/1.05-negative-chained-sequence-violation.md +#[tokio::test] +async fn s1_05_chained_sequence_violation() { + // A chained publish whose meta.previous does not match the stored + // last_sequence is rejected (412 SequenceViolation); the error carries the + // broker's current last_sequence and no event is admitted. + let (broker, h) = broker_with_topic(TOPIC, 1).await; + let c = ctx(); + let pid = broker + .register_producer(&c, ProducerMode::Chained, "svc/1.0") + .await + .unwrap() + .0; + let mut prev = -1; + for seq in 1..=7 { + broker + .publish(&c, &chained_event(&c, TOPIC, pid, seq, prev)) + .await + .unwrap(); + prev = seq; + } + let stored_before = h.stored(TOPIC, 0).await.len(); + + // Stale previous=3 (should be 7). Use a forward sequence (8) so the broker + // treats it as a chain-link check (not a backward duplicate): seq>last but + // previous mismatches → SequenceViolation. + let err = broker + .publish(&c, &chained_event(&c, TOPIC, pid, 8, 3)) + .await + .unwrap_err(); + let msg = format!("{err:?}"); + assert!( + msg.contains("SequenceViolation") || msg.contains("sequence"), + "stale previous must SequenceViolation: {msg}" + ); + // Error reports the broker's expected previous (7). + assert!( + msg.contains("expected_previous: 7") || msg.contains("expected previous=7"), + "violation should surface expected_previous=7: {msg}" + ); + // No event admitted; last_sequence unchanged. + assert_eq!(h.stored(TOPIC, 0).await.len(), stored_before); + let cursors = broker + .get_producer_cursors(&c, crate::ids::ProducerId(pid)) + .await + .unwrap(); + assert_eq!( + cursors + .iter() + .find(|cur| cur.partition == 0) + .unwrap() + .last_sequence, + 7 + ); +} + +/// Scenario: producer/flows/1.06-positive-cursor-recovery.md +#[tokio::test] +async fn s1_06_cursor_recovery() { + // After publishing on two partitions, GET cursors reports last_sequence per + // (topic, partition) so a desynced producer can re-seed meta.previous. + let (broker, _h) = broker_with_topic(TOPIC, 8).await; + let c = ctx(); + let pid = broker + .register_producer(&c, ProducerMode::Chained, "svc/1.0") + .await + .unwrap() + .0; + + // Drive partition 0 to last_sequence=7 using an explicit partition key. + let key_p0 = partition_key_for(0, 8); + let mut prev = -1; + for seq in 1..=7 { + let mut ev = chained_event(&c, TOPIC, pid, seq, prev); + ev.partition_key = Some(key_p0.clone()); + broker.publish(&c, &ev).await.unwrap(); + prev = seq; + } + // Drive partition 1 to last_sequence=3. + let key_p1 = partition_key_for(1, 8); + let mut prev = -1; + for seq in 1..=3 { + let mut ev = chained_event(&c, TOPIC, pid, seq, prev); + ev.partition_key = Some(key_p1.clone()); + broker.publish(&c, &ev).await.unwrap(); + prev = seq; + } + + let cursors = broker + .get_producer_cursors(&c, crate::ids::ProducerId(pid)) + .await + .unwrap(); + let c0 = cursors + .iter() + .find(|cur| cur.partition == 0) + .expect("cursor for p0"); + let c1 = cursors + .iter() + .find(|cur| cur.partition == 1) + .expect("cursor for p1"); + assert_eq!(c0.last_sequence, 7); + assert_eq!(c1.last_sequence, 3); +} + +/// Scenario: producer/flows/1.07-positive-chain-reset.md +#[tokio::test] +async fn s1_07_chain_reset() { + // Operator reset clears the chain for a producer; the next publish starts a + // fresh chain from sequence 1. + let (broker, _h) = broker_with_topic(TOPIC, 1).await; + let c = ctx(); + let pid = broker + .register_producer(&c, ProducerMode::Chained, "svc/1.0") + .await + .unwrap() + .0; + let mut prev = -1; + for seq in 1..=5 { + broker + .publish(&c, &chained_event(&c, TOPIC, pid, seq, prev)) + .await + .unwrap(); + prev = seq; + } + assert!( + !broker + .get_producer_cursors(&c, crate::ids::ProducerId(pid)) + .await + .unwrap() + .is_empty() + ); + + // Reset all state for the producer (omitting topic/partition resets all). + broker + .reset_producer_chain( + &c, + crate::ids::ProducerId(pid), + crate::models::ResetScope::AllTopics, + ) + .await + .unwrap(); + assert!( + broker + .get_producer_cursors(&c, crate::ids::ProducerId(pid)) + .await + .unwrap() + .is_empty(), + "after reset, cursors must be empty" + ); + + // Fresh chain from sequence 1 is accepted again. + assert_eq!( + broker + .publish(&c, &chained_event(&c, TOPIC, pid, 1, -1)) + .await + .unwrap(), + IngestOutcome::Accepted + ); +} + +/// Scenario: producer/flows/1.08-negative-unknown-producer.md +#[tokio::test] +async fn s1_08_unknown_producer() { + // A chained publish carrying an unregistered Producer-Id is rejected: the id + // must come from POST /v1/producers first. + let (broker, _h) = broker_with_topic(TOPIC, 1).await; + let c = ctx(); + let unknown = Uuid::parse_str("deadbeef-0000-0000-0000-000000000000").unwrap(); + let err = broker + .publish(&c, &chained_event(&c, TOPIC, unknown, 1, -1)) + .await + .expect_err("publishing with an unregistered producer_id must be rejected"); + assert!( + matches!(err, crate::error::EventBrokerError::UnknownProducer { .. }), + "expected UnknownProducer for an unknown producer, got {err:?}" + ); +} + +/// Scenario: producer/flows/1.09-flow-chained-producer-desync-recovery.md +#[tokio::test] +async fn s1_09_chained_producer_desync_recovery() { + // Three-exchange flow: + // 1. Stale-sequence publish → SequenceViolation (412). + // 2. Read broker cursor → authoritative last_sequence=7. + // 3. Republish with corrected previous=7 → Accepted, chain advances to 8. + let (broker, _h) = broker_with_topic(TOPIC, 1).await; + let c = ctx(); + let pid = broker + .register_producer(&c, ProducerMode::Chained, "svc/1.0") + .await + .unwrap() + .0; + // Broker has accepted through sequence 7 on partition 0. + let mut prev = -1; + for seq in 1..=7 { + broker + .publish(&c, &chained_event(&c, TOPIC, pid, seq, prev)) + .await + .unwrap(); + prev = seq; + } + + // Exchange 1: producer thinks last_sequence=3 and resumes its chain, sending + // the next forward sequence with previous=3 → broker expected previous=7, so + // SequenceViolation (412). + let err = broker + .publish(&c, &chained_event(&c, TOPIC, pid, 8, 3)) + .await + .unwrap_err(); + assert!( + format!("{err:?}").contains("SequenceViolation") || format!("{err:?}").contains("sequence"), + "stale publish must SequenceViolation: {err:?}" + ); + + // Exchange 2: read cursor → authoritative last_sequence = 7. + let cursors = broker + .get_producer_cursors(&c, crate::ids::ProducerId(pid)) + .await + .unwrap(); + let c0 = cursors.iter().find(|cur| cur.partition == 0).unwrap(); + assert_eq!(c0.last_sequence, 7); + + // Exchange 3: republish with corrected previous=7, sequence=8 → Accepted. + assert_eq!( + broker + .publish(&c, &chained_event(&c, TOPIC, pid, 8, c0.last_sequence)) + .await + .unwrap(), + IngestOutcome::Accepted + ); + let cursors = broker + .get_producer_cursors(&c, crate::ids::ProducerId(pid)) + .await + .unwrap(); + assert_eq!( + cursors + .iter() + .find(|cur| cur.partition == 0) + .unwrap() + .last_sequence, + 8 + ); +} + +// -- Test-local helper --------------------------------------------------------- +// Find a partition_key string that the mock routes to `target` +// partition under `parts` partitions, so chained-flow tests can pin events to a +// chosen partition. +fn partition_key_for(target: u32, parts: u32) -> String { + use crate::mock::partitioning::partition_for; + for i in 0..100_000u32 { + let key = format!("flowkey-{i}"); + if partition_for(&key, parts) == target { + return key; + } + } + panic!("no partition key found for target {target} / {parts} partitions"); +} diff --git a/gears/system/event-broker/event-broker-sdk/src/mock/tests/producer/mod.rs b/gears/system/event-broker/event-broker-sdk/src/mock/tests/producer/mod.rs new file mode 100644 index 000000000..07f795205 --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/src/mock/tests/producer/mod.rs @@ -0,0 +1,5 @@ +//! Producer scenario areas (mirrors scenarios/producer/). + +mod batch; +mod flows; +mod single; diff --git a/gears/system/event-broker/event-broker-sdk/src/mock/tests/producer/single.rs b/gears/system/event-broker/event-broker-sdk/src/mock/tests/producer/single.rs new file mode 100644 index 000000000..f95d29a62 --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/src/mock/tests/producer/single.rs @@ -0,0 +1,266 @@ +//! Mirrors scenarios/producer/single/. Tests migrated per mock-reference-alignment. + +#[cfg(test)] +use super::super::helpers::*; + +use super::super::helpers::{broker_with_topic, ctx, wire_event}; +use crate::api::{EventBroker, IngestOutcome}; +use crate::error::EventBrokerError; +use crate::mock::{MockBrokerHandle, stubs::test_ctx_for_tenant}; +use uuid::Uuid; + +async fn populated_partition(handle: &MockBrokerHandle, topic: &str, partition_count: u32) -> u32 { + let mut found = None; + for partition in 0..partition_count { + if !handle.stored(topic, partition).await.is_empty() { + assert!(found.is_none(), "events span multiple partitions"); + found = Some(partition); + } + } + found.expect("event was not stored") +} + +/// Scenario: producer/single/1.01-positive-publish-single-async.md +#[tokio::test] +async fn s1_01_publish_single_async() { + // Default publish path is async: durably enqueued → Accepted (202). No + // offset/partition/sequence returned inline; they are server-stamped. + let (broker, h) = broker_with_topic(TOPIC, 4).await; + let c = ctx(); + let outcome = broker + .publish(&c, &wire_event(TOPIC, EVT, c.subject_tenant_id())) + .await + .unwrap(); + assert_eq!(outcome, IngestOutcome::Accepted); + + // Side effect: event lands on the tenant-derived partition and is stored. + let p = populated_partition(&h, TOPIC, 4).await; + assert_eq!(h.stored(TOPIC, p).await.len(), 1); +} + +/// Scenario: producer/single/1.02-positive-publish-sync-wait-persisted.md +#[tokio::test] +async fn s1_02_publish_sync_wait_persisted() { + // Sync-wait publish holds until the backend persists, returning Persisted + // (201) instead of the default Accepted (202). + let (broker, h) = broker_with_topic(TOPIC, 1).await; + let c = ctx(); + let outcome = broker + .publish_sync(&c, &wire_event(TOPIC, EVT, c.subject_tenant_id())) + .await + .unwrap(); + assert_eq!(outcome, IngestOutcome::Persisted); + // Persisted means the event is durably in the log before the call returns. + assert_eq!(h.stored(TOPIC, 0).await.len(), 1); +} + +/// Scenario: producer/single/1.03-negative-schema-validation-failure.md +#[tokio::test] +async fn s1_03_schema_validation_failure() { + // data is validated against the event type's data_schema at ingest; a payload + // failing validation is rejected (400 / EventDataInvalid), no event admitted. + let (broker, h) = broker_with_topic(TOPIC, 1).await; + h.register_event_type( + TOPIC, + EVT, + serde_json::json!({ + "type": "object", + "required": ["order_id", "total_cents"], + "properties": { + "order_id": { "type": "string" }, + "total_cents": { "type": "integer" } + } + }), + &[], + ) + .await; + let c = ctx(); + let mut ev = wire_event(TOPIC, EVT, c.subject_tenant_id()); + // Missing the required `total_cents`. + ev.data = Some(serde_json::json!({ "order_id": "order-bad" })); + let err = broker.publish(&c, &ev).await.unwrap_err(); + let msg = format!("{err:?}"); + assert!( + msg.contains("EventDataInvalid") || msg.contains("validation") || msg.contains("invalid"), + "bad payload must fail validation: {msg}" + ); + // No event admitted. + assert!(h.stored(TOPIC, 0).await.is_empty()); +} + +#[tokio::test] +async fn registered_event_type_allowed_subjects_are_enforced() { + let (broker, h) = broker_with_topic(TOPIC, 1).await; + h.register_event_type( + TOPIC, + EVT, + serde_json::json!({ + "type": "object" + }), + &["test-type"], + ) + .await; + let c = ctx(); + let accepted = wire_event(TOPIC, EVT, c.subject_tenant_id()); + assert_eq!( + broker.publish(&c, &accepted).await.unwrap(), + IngestOutcome::Accepted + ); + + let mut rejected = wire_event(TOPIC, EVT, c.subject_tenant_id()); + rejected.subject_type = "other-type".to_owned(); + let err = broker.publish(&c, &rejected).await.unwrap_err(); + assert!( + matches!(err, EventBrokerError::InvalidEventField { field, .. } if field == "subject_type"), + "disallowed subject_type must be rejected: {err:?}" + ); + assert_eq!( + h.stored(TOPIC, 0).await.len(), + 1, + "rejected subject_type must not be admitted" + ); +} + +/// Scenario: producer/single/1.04-negative-rate-limited.md +#[tokio::test] +async fn s1_04_rate_limited() { + // When the tenant publish quota is exhausted, the next publish is refused + // (429 / RateLimited) and no event is admitted. + let (broker, h) = broker_with_topic(TOPIC, 1).await; + let handle = broker.handle(); + // Some(0) → zero allowance: the next publish (which charges 1 unit) is refused. + handle.set_publish_rate_limit(Some(0)).await; + let c = ctx(); + let err = broker + .publish(&c, &wire_event(TOPIC, EVT, c.subject_tenant_id())) + .await + .unwrap_err(); + let msg = format!("{err:?}"); + assert!( + msg.contains("RateLimited") || msg.contains("rate limit"), + "exhausted quota must yield RateLimited: {msg}" + ); + // No event admitted. + assert!(h.stored(TOPIC, 0).await.is_empty()); +} + +/// Scenario: producer/single/1.05-negative-readonly-partition-rejected.md +#[tokio::test] +async fn s1_05_readonly_partition_rejected() { + let (broker, h) = broker_with_topic(TOPIC, 4).await; + let c = ctx(); + let mut ev = wire_event(TOPIC, EVT, c.subject_tenant_id()); + ev.partition_key = Some("customer-42".to_owned()); + ev.partition = Some(3); + + let err = broker.publish(&c, &ev).await.unwrap_err(); + assert!( + matches!(err, EventBrokerError::InvalidEventField { field, .. } if field == "partition"), + "producer-supplied partition must be rejected as read-only: {err:?}" + ); + for partition in 0..4 { + assert!( + h.stored(TOPIC, partition).await.is_empty(), + "rejected event must not be admitted to partition {partition}" + ); + } +} + +/// Scenario: producer/single/1.01-positive-publish-single-async.md +#[tokio::test] +async fn s1_01_tenant_default_routes_by_tenant() { + // No partition_key set -> routes by tenant per ADR-0002. + let (broker, h) = broker_with_topic(TOPIC, 8).await; + let t1 = Uuid::parse_str("aaaaaaaa-0000-0000-0000-000000000001").unwrap(); + let c1 = test_ctx_for_tenant(t1); + let mut first = wire_event(TOPIC, EVT, c1.subject_tenant_id()); + first.subject = "first-subject".to_owned(); + let mut second = wire_event(TOPIC, EVT, c1.subject_tenant_id()); + second.subject = "different-subject".to_owned(); + + broker.publish(&c1, &first).await.unwrap(); + broker.publish(&c1, &second).await.unwrap(); + + let partition = populated_partition(&h, TOPIC, 8).await; + let stored = h.stored(TOPIC, partition).await; + assert_eq!(stored.len(), 2, "same tenant must select one partition"); + assert!( + stored + .iter() + .all(|event| event.event.partition == Some(partition)) + ); + assert!( + stored + .iter() + .all(|event| event.event.partition_key.is_none()) + ); +} + +/// Scenario: producer/single/1.01-positive-publish-single-async.md +#[tokio::test] +async fn s1_01_explicit_partition_key_selects_partition_input() { + let (broker, h) = broker_with_topic(TOPIC2, 4).await; + let c = ctx(); + let mut ev = wire_event(TOPIC2, EVT, c.subject_tenant_id()); + ev.partition_key = Some("explicit-key".to_owned()); + let other = + test_ctx_for_tenant(Uuid::parse_str("bbbbbbbb-0000-0000-0000-000000000001").unwrap()); + let mut other_ev = wire_event(TOPIC2, EVT, other.subject_tenant_id()); + other_ev.partition_key = Some("explicit-key".to_owned()); + + broker.publish(&c, &ev).await.unwrap(); + broker.publish(&other, &other_ev).await.unwrap(); + + let partition = populated_partition(&h, TOPIC2, 4).await; + let stored = h.stored(TOPIC2, partition).await; + assert_eq!( + stored.len(), + 2, + "same explicit key must select one partition" + ); + assert!( + stored + .iter() + .all(|event| event.event.partition == Some(partition)) + ); + assert!( + stored + .iter() + .all(|event| event.event.partition_key.as_deref() == Some("explicit-key")) + ); +} + +/// Scenario: producer/single/1.01-positive-publish-single-async.md +#[tokio::test] +async fn s1_01_same_tenant_events_same_partition() { + let (broker, h) = broker_with_topic(TOPIC3, 8).await; + let c = ctx(); + for _ in 0..5 { + broker + .publish(&c, &wire_event(TOPIC3, EVT, c.subject_tenant_id())) + .await + .unwrap(); + } + let expected_p = populated_partition(&h, TOPIC3, 8).await; + assert_eq!(h.stored(TOPIC3, expected_p).await.len(), 5); +} + +/// Scenario: producer/single/1.01-positive-publish-single-async.md +#[tokio::test] +async fn s1_01_offsets_are_monotonic_per_partition() { + // Offsets are server-assigned; per partition they are monotonic from 0. + let (broker, h) = broker_with_topic(TOPIC, 1).await; + let c = ctx(); + for _ in 0..5 { + broker + .publish(&c, &wire_event(TOPIC, EVT, c.subject_tenant_id())) + .await + .unwrap(); + } + let stored = h.stored(TOPIC, 0).await; + assert_eq!(stored.len(), 5); + for (i, se) in stored.iter().enumerate() { + // Offsets are 1-based (A6/A7): the i-th stored event has offset i+1. + assert_eq!(se.event.offset.unwrap(), (i as i64) + 1); + } +} diff --git a/gears/system/event-broker/event-broker-sdk/src/mock/tests/topics.rs b/gears/system/event-broker/event-broker-sdk/src/mock/tests/topics.rs new file mode 100644 index 000000000..1b0d482c6 --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/src/mock/tests/topics.rs @@ -0,0 +1,137 @@ +//! Mirrors scenarios/topics/. Tests migrated per mock-reference-alignment. +use super::helpers::*; +#[cfg(test)] +use toolkit_gts::gts_id; + +use super::helpers::{broker_with_topic, ctx, wire_event}; +use crate::api::EventBroker; +use crate::models::PartitionRange; + +/// Scenario: topics/1.01-positive-list-topics.md +#[tokio::test] +async fn s1_01_positive_list_topics() { + // GET /v1/topics → array of topic records (id + partitions). + let (broker, h) = broker_with_topic(TOPIC, 4).await; + h.register_topic(TOPIC2, 2).await; + let c = ctx(); + + let topics = broker.list_topics(&c).await.unwrap(); + assert_eq!(topics.len(), 2, "both registered topics are visible"); + + let audit = topics + .iter() + .find(|t| t.id == TOPIC) + .expect("audit topic must be listed"); + assert_eq!( + audit.partitions, 4, + "partition count echoed per topic record" + ); + + let notify = topics + .iter() + .find(|t| t.id == TOPIC2) + .expect("notify topic must be listed"); + assert_eq!(notify.partitions, 2); +} + +/// Scenario: topics/1.02-positive-list-topic-segments.md +#[tokio::test] +async fn s1_02_positive_list_topic_segments() { + // GET /v1/topics/segments?topic=&partition=0 → manifest spanning stored sequences. + let (broker, _h) = broker_with_topic(TOPIC, 1).await; + let c = ctx(); + + // Publish a few events so partition 0 has a non-empty log. + for _ in 0..3 { + broker + .publish(&c, &wire_event(TOPIC, EVT, c.subject_tenant_id())) + .await + .unwrap(); + } + + let range = PartitionRange { + start_offset: None, + end_offset: None, + limit: 100, + }; + let segments = broker + .list_topic_segments(&c, TOPIC, 0, range) + .await + .unwrap(); + + assert_eq!( + segments.len(), + 1, + "non-empty partition yields a segment manifest" + ); + let seg = &segments[0]; + assert_eq!(seg.topic, TOPIC, "manifest echoes the requested topic"); + assert_eq!(seg.partition, 0, "manifest echoes the requested partition"); + assert!( + seg.start_sequence <= seg.end_sequence, + "manifest sequence span must be well-ordered (start={} end={})", + seg.start_sequence, + seg.end_sequence + ); +} + +/// Scenario: topics/1.03-negative-segments-unknown-topic.md +#[tokio::test] +async fn s1_03_negative_segments_unknown_topic() { + // GET segments for an unregistered topic → 404 not_found (SDK: TopicNotFound). + let broker = crate::mock::MockBroker::new(); + let c = ctx(); + let unknown = gts_id!("cf.core.events.topic.v1~acme.nonexistent.x.x.v1"); + + let range = PartitionRange { + start_offset: None, + end_offset: None, + limit: 100, + }; + let err = broker + .list_topic_segments(&c, unknown, 0, range) + .await + .unwrap_err(); + + match err { + crate::error::EventBrokerError::TopicNotFound { ref topic, .. } => { + assert_eq!(topic, unknown, "error must name the missing topic"); + } + other => panic!("expected TopicNotFound, got {other:?}"), + } +} + +/// Scenario: topics/1.04-positive-list-event-types.md +#[tokio::test] +async fn s1_04_positive_list_event_types() { + // GET /v1/event_types → registered types, each anchored to its parent topic. + let (broker, h) = broker_with_topic(TOPIC, 1).await; + let c = ctx(); + + let schema = serde_json::json!({ "type": "object" }); + h.register_event_type(TOPIC, EVT, schema.clone(), &[]).await; + h.register_event_type(TOPIC, EVT2, schema, &[]).await; + + let types = broker.list_event_types(&c).await.unwrap(); + assert_eq!(types.len(), 2, "both registered event types are listed"); + assert!( + types.iter().all(|et| et.topic == TOPIC), + "each event type is anchored to its parent topic" + ); + assert!(types.iter().any(|et| et.id == EVT)); + assert!(types.iter().any(|et| et.id == EVT2)); + + // get_event_type round-trips a known id and rejects an unknown one. + let one = broker.get_event_type(&c, EVT).await.unwrap(); + assert_eq!(one.id, EVT); + assert_eq!(one.topic, TOPIC); + + let ghost = gts_id!("cf.core.events.event_type.v1~example.mock.broker.ghost.v1"); + let err = broker.get_event_type(&c, ghost).await.unwrap_err(); + match err { + crate::error::EventBrokerError::EventTypeUnknown { ref type_id, .. } => { + assert_eq!(type_id, ghost, "unknown event type id is echoed back"); + } + other => panic!("expected EventTypeUnknown, got {other:?}"), + } +} diff --git a/gears/system/event-broker/event-broker-sdk/src/mock/transport.rs b/gears/system/event-broker/event-broker-sdk/src/mock/transport.rs new file mode 100644 index 000000000..169b4bb37 --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/src/mock/transport.rs @@ -0,0 +1,898 @@ +use std::collections::HashMap; +use std::time::Instant; + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +use crate::ResolvedPosition; +use crate::api::{ + AssignedPartition, EventBroker, IngestOutcome, JoinRequest, ProducerCursor, ProducerMode, + SeekResult, SubscriptionAssignment, +}; +use crate::api::{FrameStream, SeekPosition}; +use crate::error::EventBrokerError; +use crate::ids::{ConsumerGroupId, ProducerId, SubscriptionId}; +use crate::models::Event; +use crate::models::{ + ConsumerGroup, ConsumerGroupKind, ConsumerGroupQuery, CreateConsumerGroupRequest, EventType, + Page, PartitionRange, ResetScope, Subscription, Topic, TopicSegment, +}; + +use super::core::{Core, GroupReg, GroupState, MockBroker, SubState}; +use super::ingest::{ingest_batch, ingest_one}; +use super::rebalance::run_rebalance; +use super::stream::open_stream; + +// --- Helper -------------------------------------------------------------------- + +fn principal(ctx: &SecurityContext) -> String { + ctx.subject_id().to_string() +} + +fn tenant(ctx: &SecurityContext) -> Uuid { + ctx.subject_tenant_id() +} + +fn instant_to_utc(instant: Instant) -> DateTime { + let now_instant = Instant::now(); + let now_utc = Utc::now(); + if instant >= now_instant { + now_utc + + chrono::Duration::from_std(instant.duration_since(now_instant)).unwrap_or_default() + } else { + now_utc + - chrono::Duration::from_std(now_instant.duration_since(instant)).unwrap_or_default() + } +} + +/// Batch publish limits (DESIGN: ingest batch caps). +const MAX_BATCH_EVENTS: usize = 100; +const MAX_BATCH_BYTES: usize = 1024 * 1024; // 1 MiB total payload + +/// Reject a batch exceeding the event-count or total-payload-byte cap. +fn check_batch_size(events: &[Event]) -> Result<(), EventBrokerError> { + let count = events.len(); + let bytes: usize = events + .iter() + .map(|e| { + e.data + .as_ref() + .map(|d| serde_json::to_vec(d).map(|v| v.len()).unwrap_or(0)) + .unwrap_or(0) + }) + .sum(); + if count > MAX_BATCH_EVENTS || bytes > MAX_BATCH_BYTES { + return Err(EventBrokerError::BatchTooLarge { + count, + bytes, + max_count: MAX_BATCH_EVENTS, + max_bytes: MAX_BATCH_BYTES, + detail: format!( + "batch of {count} events / {bytes} bytes exceeds limits ({MAX_BATCH_EVENTS} events, {MAX_BATCH_BYTES} bytes)" + ), + instance: String::new(), + }); + } + Ok(()) +} + +/// Resolve `AtTimestamp` against the in-memory log by a linear scan over stored +/// events ordered by offset (offset == storage order == `occurred_at` order for +/// the mock's append-only log). Returns the offset of the first event whose +/// `occurred_at >= ts`. A timestamp at/before the retention floor resolves to +/// the floor offset; a timestamp beyond the high-water mark resolves to the HWM. +fn resolve_at_timestamp(core: &Core, topic: &str, partition: u32, ts: &str) -> i64 { + let topic_state = match core.topics.get(topic) { + Some(t) => t, + None => return 0, + }; + // High-water mark = last assigned offset (next_offset - 1), floored at 0. + let hwm = topic_state + .next_offset + .get(&partition) + .copied() + .unwrap_or(0) + .saturating_sub(1) + .max(0); + let log = match topic_state.log.get(&partition) { + Some(l) if !l.is_empty() => l, + _ => return hwm, + }; + + // Parse the requested timestamp; an unparseable timestamp falls back to the + // retention floor (the earliest stored offset). + let target = match chrono::DateTime::parse_from_rfc3339(ts) { + Ok(dt) => dt.with_timezone(&chrono::Utc), + Err(_) => { + return log + .first() + .and_then(|e| e.event.offset) + .map(|o| o - 1) + .unwrap_or(0); + } + }; + + // Cursor is last-processed (emit from cursor+1). To DELIVER the first event at + // or after `target`, the cursor is that event's offset minus 1. + // Timestamp at/before the retention floor → clamp to floor (deliver the first event). + let floor_offset = log.first().and_then(|e| e.event.offset).unwrap_or(1); + if let Some(first) = log.first() + && target <= first.event.occurred_at + { + return floor_offset - 1; + } + + // Linear scan for the first event with occurred_at >= target. + for stored in log { + if stored.event.occurred_at >= target { + return stored.event.offset.unwrap_or(floor_offset) - 1; + } + } + + // Timestamp beyond the last stored event → high-water mark (equivalent to Latest: + // cursor = last existing offset, emit only future events). + hwm +} + +impl MockBroker { + /// Honour the `reject_persist` fault (M3 chain-gap surface). + async fn check_reject_persist(&self) -> Result<(), EventBrokerError> { + let faults = self.faults.lock().await; + if let Some(reason) = &faults.reject_persist { + return Err(EventBrokerError::Internal(reason.clone())); + } + Ok(()) + } + + /// Charge `n` units against the producer publish rate-limit allowance. When + /// the allowance is insufficient, refuse with `RateLimited` and do not + /// consume any allowance. + async fn consume_publish_allowance(&self, n: u32) -> Result<(), EventBrokerError> { + let mut faults = self.faults.lock().await; + if let Some(remaining) = faults.publish_rate_limit { + if remaining < n { + return Err(EventBrokerError::RateLimited { + retry_after_secs: 30, + detail: format!( + "publish rate limit exhausted ({remaining} of {n} requested unit(s) available)" + ), + instance: String::new(), + }); + } + faults.publish_rate_limit = Some(remaining - n); + } + Ok(()) + } +} + +#[async_trait] +impl EventBroker for MockBroker { + // -- Producer -------------------------------------------------------------- + async fn register_producer( + &self, + _ctx: &SecurityContext, + mode: ProducerMode, + client_agent: &str, + ) -> Result { + let id = ProducerId(Uuid::new_v4()); + let mut core = self.core.lock().await; + core.producers.insert(id, super::core::ProducerReg { mode }); + let _ = client_agent; // stored for logs in production; no-op in mock + Ok(id) + } + + async fn publish( + &self, + _ctx: &SecurityContext, + event: &Event, + ) -> Result { + self.check_reject_persist().await?; + self.consume_publish_allowance(1).await?; + let mut core = self.core.lock().await; + let (outcome, _) = ingest_one(&mut core, event)?; + if outcome == IngestOutcome::Accepted { + self.notify.notify_waiters(); + } + Ok(outcome) + } + + async fn publish_sync( + &self, + _ctx: &SecurityContext, + event: &Event, + ) -> Result { + self.check_reject_persist().await?; + self.consume_publish_allowance(1).await?; + // The mock's `ingest_one` appends to the in-memory log synchronously, so + // by the time it returns the event is durably "persisted". Report the + // persist-confirmed outcome (Accepted → Persisted; Duplicate stays). + let mut core = self.core.lock().await; + let (outcome, _) = ingest_one(&mut core, event)?; + let outcome = match outcome { + IngestOutcome::Accepted => IngestOutcome::Persisted, + other => other, + }; + if outcome == IngestOutcome::Persisted { + self.notify.notify_waiters(); + } + Ok(outcome) + } + + async fn publish_batch( + &self, + _ctx: &SecurityContext, + events: &[Event], + ) -> Result, EventBrokerError> { + self.check_reject_persist().await?; + check_batch_size(events)?; + self.consume_publish_allowance(events.len() as u32).await?; + let mut core = self.core.lock().await; + let results = ingest_batch(&mut core, events)?; + let any_accepted = results.iter().any(|(o, _)| *o == IngestOutcome::Accepted); + if any_accepted { + self.notify.notify_waiters(); + } + Ok(results.into_iter().map(|(o, _)| o).collect()) + } + + async fn get_producer_cursors( + &self, + _ctx: &SecurityContext, + producer_id: ProducerId, + ) -> Result, EventBrokerError> { + let core = self.core.lock().await; + let cursors = core + .producer_state + .iter() + .filter(|((pid, _, _), _)| *pid == producer_id) + .map(|((_, topic, partition), seq)| ProducerCursor { + topic: topic.clone(), + partition: *partition, + last_sequence: *seq, + }) + .collect(); + Ok(cursors) + } + + async fn reset_producer_chain( + &self, + _ctx: &SecurityContext, + producer_id: ProducerId, + scope: ResetScope<'_>, + ) -> Result<(), EventBrokerError> { + let mut core = self.core.lock().await; + match scope { + ResetScope::Partition { topic, partition } => { + // Reset single (producer, topic, partition). + core.producer_state + .remove(&(producer_id, topic.to_owned(), partition)); + } + ResetScope::Topic(topic) => { + // Reset all (producer, topic, *) - M7 branch 2. + let keys: Vec<_> = core + .producer_state + .keys() + .filter(|(pid, tp, _)| *pid == producer_id && tp == topic) + .cloned() + .collect(); + for k in keys { + core.producer_state.remove(&k); + } + } + ResetScope::AllTopics => { + // Reset all (producer, *, *) - M7 branch 1 (reset-all). + let keys: Vec<_> = core + .producer_state + .keys() + .filter(|(pid, _, _)| *pid == producer_id) + .cloned() + .collect(); + for k in keys { + core.producer_state.remove(&k); + } + } + } + Ok(()) + } + + // -- Consumer groups ------------------------------------------------------- + async fn create_consumer_group( + &self, + ctx: &SecurityContext, + req: CreateConsumerGroupRequest, + ) -> Result { + // B3: client_agent must be ASCII, 1-256 bytes. + if req.client_agent.is_empty() + || req.client_agent.len() > 256 + || !req.client_agent.is_ascii() + { + return Err(EventBrokerError::InvalidEventField { + field: "client_agent", + detail: "client_agent must be ASCII and 1-256 bytes".to_owned(), + instance: "/v1/consumer-groups".to_owned(), + }); + } + let group_id = ConsumerGroupId::new(Uuid::new_v4()); + let mut core = self.core.lock().await; + core.groups_registry.insert( + group_id, + GroupReg { + kind: ConsumerGroupKind::Anonymous, + owner_tenant: tenant(ctx), + owner_principal: principal(ctx), + }, + ); + Ok(ConsumerGroup { + id: group_id, + tenant_id: tenant(ctx), + owner_principal_id: principal(ctx), + kind: ConsumerGroupKind::Anonymous, + description: None, + created_at: Utc::now(), + }) + } + + async fn get_consumer_group( + &self, + _ctx: &SecurityContext, + id: &ConsumerGroupId, + ) -> Result { + let core = self.core.lock().await; + let reg = core.groups_registry.get(id).ok_or_else(|| { + EventBrokerError::ConsumerGroupNotFound { + group_id: *id, + detail: format!("consumer group '{id}' not found"), + instance: String::new(), + } + })?; + Ok(ConsumerGroup { + id: *id, + tenant_id: reg.owner_tenant, + owner_principal_id: reg.owner_principal.clone(), + kind: reg.kind, + description: None, + created_at: Utc::now(), + }) + } + + async fn list_consumer_groups( + &self, + _ctx: &SecurityContext, + query: ConsumerGroupQuery, + ) -> Result, EventBrokerError> { + // Mock: cursor, filter, and orderby are accepted but not implemented. + // Returns the first page only. + let page_limit = query.limit.unwrap_or(25) as usize; + let core = self.core.lock().await; + let items: Vec = core + .groups_registry + .iter() + .take(page_limit) + .map(|(id, reg)| ConsumerGroup { + id: *id, + tenant_id: reg.owner_tenant, + owner_principal_id: reg.owner_principal.clone(), + kind: reg.kind, + description: None, + created_at: Utc::now(), + }) + .collect(); + let total = core.groups_registry.len(); + let next_cursor = if total > page_limit { + Some("mock-next-cursor".to_owned()) + } else { + None + }; + Ok(Page { + items, + next_cursor, + prev_cursor: None, + limit: page_limit as u32, + }) + } + + async fn delete_consumer_group( + &self, + _ctx: &SecurityContext, + id: &ConsumerGroupId, + ) -> Result<(), EventBrokerError> { + let mut core = self.core.lock().await; + if !core.groups_registry.contains_key(id) { + return Err(EventBrokerError::ConsumerGroupNotFound { + group_id: *id, + detail: format!("consumer group '{id}' not found"), + instance: String::new(), + }); + } + if core.groups.contains_key(id) { + let has_members = core + .groups + .get(id) + .map(|g| !g.members.is_empty()) + .unwrap_or(false); + if has_members { + return Err(EventBrokerError::ConsumerGroupHasActiveMembers { + detail: format!("consumer group '{id}' has active subscriptions"), + instance: String::new(), + }); + } + } + core.groups_registry.remove(id); + core.groups.remove(id); + Ok(()) + } + + // -- Subscriptions --------------------------------------------------------- + async fn join( + &self, + _ctx: &SecurityContext, + req: JoinRequest, + ) -> Result { + // B3: client_agent must be ASCII, 1-256 bytes (RFC 9110 User-Agent grammar). + if req.client_agent.is_empty() + || req.client_agent.len() > 256 + || !req.client_agent.is_ascii() + { + return Err(EventBrokerError::InvalidEventField { + field: "client_agent", + detail: "client_agent must be ASCII and 1-256 bytes".to_owned(), + instance: "/v1/subscriptions".to_owned(), + }); + } + // B4: a subscription carries 1-64 interests. + const MAX_INTERESTS: usize = 64; + if req.interests.is_empty() || req.interests.len() > MAX_INTERESTS { + return Err(EventBrokerError::InvalidEventField { + field: "interests", + detail: format!( + "interests must be 1..={MAX_INTERESTS} (got {})", + req.interests.len() + ), + instance: "/v1/subscriptions".to_owned(), + }); + } + let now = Instant::now(); + let sub_id = SubscriptionId(Uuid::new_v4()); + let timeout = req + .session_timeout + .unwrap_or(std::time::Duration::from_secs(30)); + + let mut core = self.core.lock().await; + + // Validate group exists. + if !core.groups_registry.contains_key(&req.group) { + return Err(EventBrokerError::ConsumerGroupNotFound { + group_id: req.group, + detail: format!("consumer group '{}' not registered", req.group), + instance: String::new(), + }); + } + + // Build the set of topics from interests. + let topics: std::collections::HashSet = + req.interests.iter().map(|i| i.topic.clone()).collect(); + + // Capacity guard: a group can carry at most one active member per partition + // for any given topic (v1 round-robin). If every topic this member is + // interested in is already saturated (active members ≥ partitions), the + // member could never be assigned a partition - refuse the JOIN. + let active_members = core + .groups + .get(&req.group) + .map(|g| g.members.len() as u32) + .unwrap_or(0); + let max_partitions = topics + .iter() + .filter_map(|t| core.topics.get(t).map(|ts| ts.partitions)) + .max(); + if let Some(partitions) = max_partitions + && active_members >= partitions + { + return Err(EventBrokerError::GroupAtCapacity { + active: active_members, + partitions, + detail: format!( + "consumer group '{}' already has {active_members} active member(s) for a topic with {partitions} partition(s); no partition available for a further member", + req.group + ), + instance: String::new(), + }); + } + + // Build SubState. + let sub = SubState { + group: req.group, + interests: req.interests, + topics, + assigned: Vec::new(), + topology_version: 0, + created_at: now, + session_timeout: timeout, + expires_at: now + timeout, + seek: HashMap::new(), + sent: HashMap::new(), + scanned: HashMap::new(), + terminated: false, + }; + core.subscriptions.insert(sub_id, sub); + + // Ensure GroupState exists. + core.groups.entry(req.group).or_insert_with(GroupState::new); + let group = core.groups.get_mut(&req.group).unwrap(); + group.members.push(sub_id); + + // Run v1 rebalance. + run_rebalance(&req.group, &mut core); + self.notify.notify_waiters(); + + // Build SubscriptionAssignment from the group's cursor state. + let group = core.groups.get(&req.group).unwrap(); + let sub = core.subscriptions.get(&sub_id).unwrap(); + let topology_version = group.topology_version; + let assigned: Vec = sub + .assigned + .iter() + .map(|(topic, partition)| AssignedPartition { + topic: topic.clone(), + partition: *partition, + }) + .collect(); + + Ok(SubscriptionAssignment { + subscription_id: sub_id, + topology_version, + expires_at: instant_to_utc(sub.expires_at), + assigned, + }) + } + + async fn get_subscription( + &self, + _ctx: &SecurityContext, + id: SubscriptionId, + ) -> Result { + let core = self.core.lock().await; + let sub = core + .subscriptions + .get(&id) + .ok_or_else(|| EventBrokerError::Internal(format!("subscription {id:?} not found")))?; + let group = core.groups.get(&sub.group); + let topology_version = group.map(|g| g.topology_version).unwrap_or(0); + Ok(Subscription { + id, + consumer_group: sub.group, + assigned: sub + .assigned + .iter() + .map(|(_, p)| crate::models::PartitionAssignment { + topic_ix: 0, + partition: *p, + }) + .collect(), + topology_version, + expires_at: instant_to_utc(sub.expires_at), + }) + } + + async fn list_subscriptions( + &self, + _ctx: &SecurityContext, + ) -> Result, EventBrokerError> { + let core = self.core.lock().await; + Ok(core + .subscriptions + .iter() + .map(|(id, sub)| { + let tv = core + .groups + .get(&sub.group) + .map(|g| g.topology_version) + .unwrap_or(0); + Subscription { + id: *id, + consumer_group: sub.group, + assigned: sub + .assigned + .iter() + .map(|(_, p)| crate::models::PartitionAssignment { + topic_ix: 0, + partition: *p, + }) + .collect(), + topology_version: tv, + expires_at: instant_to_utc(sub.expires_at), + } + }) + .collect()) + } + + async fn leave( + &self, + _ctx: &SecurityContext, + id: SubscriptionId, + ) -> Result<(), EventBrokerError> { + let mut core = self.core.lock().await; + match core.subscriptions.remove(&id) { + Some(sub) => { + let group_id = sub.group; + if let Some(group) = core.groups.get_mut(&group_id) { + group.members.retain(|m| *m != id); + } + run_rebalance(&group_id, &mut core); + self.notify.notify_waiters(); + Ok(()) + } + // B2: leaving an unknown/expired subscription is a 404, not a silent no-op. + None => Err(EventBrokerError::SubscriptionNotFound { + id, + detail: "no such subscription (unknown or already removed)".to_owned(), + instance: format!("/v1/subscriptions/{id:?}"), + }), + } + } + + async fn stream( + &self, + _ctx: &SecurityContext, + id: SubscriptionId, + ) -> Result { + // A subscription terminated by a gain / lose-all rebalance is dead - any + // reuse of its id returns 410 (the safety net for a consumer that missed + // the terminal control frame). + { + let mut core = self.core.lock().await; + let sub = core.subscriptions.get(&id).ok_or_else(|| { + EventBrokerError::SubscriptionNotFound { + id, + detail: "no such subscription (unknown or expired)".to_owned(), + instance: format!("/v1/events:stream?subscription_id={id:?}"), + } + })?; + if sub.terminated { + return Err(EventBrokerError::Internal( + "410: Subscription terminated; re-JOIN to recover".to_owned(), + )); + } + // A1: every assigned partition must have a committed cursor (set via SEEK, + // or inherited group-scoped from a prior member) before the stream opens. + let group_cursors = core.groups.get(&sub.group).map(|g| &g.cursor); + let unseeded: Vec<(String, u32)> = sub + .assigned + .iter() + .filter(|(t, p)| { + group_cursors + .map(|c| !c.contains_key(&(t.clone(), *p))) + .unwrap_or(true) + }) + .cloned() + .collect(); + if !unseeded.is_empty() { + return Err(EventBrokerError::PositionsNotSet { + unseeded, + detail: "SEEK every assigned partition before opening the stream".to_owned(), + instance: format!("/v1/events:stream?subscription_id={id:?}"), + }); + } + if let Some(sub) = core.subscriptions.get_mut(&id) { + sub.expires_at = Instant::now() + sub.session_timeout; + } + } + // A2: one open stream per subscription. Mark it streaming; the stream's + // Drop guard clears the marker when it ends/drops. + { + let mut streaming = self.streaming.lock().unwrap(); + if streaming.contains(&id) { + return Err(EventBrokerError::StreamingInProgress { + detail: "a stream is already open for this subscription".to_owned(), + instance: format!("/v1/events:stream?subscription_id={id:?}"), + }); + } + streaming.insert(id); + } + Ok(open_stream(self.clone(), id)) + } + + async fn seek( + &self, + _ctx: &SecurityContext, + id: SubscriptionId, + positions: &[SeekPosition], + ) -> Result, EventBrokerError> { + // A2: SEEK is a pre-stream operation - rejected while a stream is open. + if self.streaming.lock().unwrap().contains(&id) { + return Err(EventBrokerError::StreamingInProgress { + detail: "SEEK is not allowed while a stream is open; it is a pre-stream operation" + .to_owned(), + instance: format!("/v1/subscriptions/{id:?}:seek"), + }); + } + let mut core = self.core.lock().await; + // A3: SEEK is only valid for partitions assigned to this subscription. + let assigned: Vec<(String, u32)> = core + .subscriptions + .get(&id) + .map(|s| s.assigned.clone()) + .unwrap_or_default(); + for pos in positions { + if !assigned + .iter() + .any(|(t, p)| t == &pos.topic && *p == pos.partition) + { + return Err(EventBrokerError::PartitionNotAssigned { + topic: pos.topic.clone(), + partition: pos.partition, + detail: "seek targets a partition not in the subscription's assignment" + .to_owned(), + instance: format!("/v1/subscriptions/{id:?}:seek"), + }); + } + } + let mut results: Vec = Vec::with_capacity(positions.len()); + let mut internal: HashMap<(String, u32), i64> = HashMap::new(); + for pos in positions { + let offset = match &pos.value { + ResolvedPosition::Exact(n) => *n, + ResolvedPosition::Earliest => 0, + ResolvedPosition::Latest => core + .topics + .get(&pos.topic) + .and_then(|t| t.next_offset.get(&pos.partition).copied()) + .unwrap_or(0) + .saturating_sub(1), + ResolvedPosition::AtTimestamp(ts) => { + resolve_at_timestamp(&core, &pos.topic, pos.partition, ts) + } + }; + // A4: the resolved cursor must lie in [RF-1, HWM] = [0, next_offset]. + // (RF=1 on a 1-based log, so RF-1=0; HWM = next offset to be admitted.) + let hwm = core + .topics + .get(&pos.topic) + .and_then(|t| t.next_offset.get(&pos.partition).copied()) + .unwrap_or(1); + if offset < 0 || offset > hwm { + return Err(EventBrokerError::InvalidInitialPosition { + topic: pos.topic.clone(), + partition: pos.partition, + requested: offset.to_string(), + detail: format!( + "resolved offset {offset} is outside the valid range [0, {hwm}]" + ), + instance: format!("/v1/subscriptions/{id:?}:seek"), + }); + } + results.push(SeekResult { + topic: pos.topic.clone(), + partition: pos.partition, + offset, + }); + internal.insert((pos.topic.clone(), pos.partition), offset); + } + // Advance group cursor using MAX rule (forward-only, equivalent to old ack behaviour). + let group_id = core.subscriptions.get(&id).map(|s| s.group); + if let Some(gid) = group_id + && let Some(group) = core.groups.get_mut(&gid) + { + for ((topic, partition), offset) in &internal { + let entry = group.cursor.entry((topic.clone(), *partition)).or_default(); + entry.offset = entry.offset.max(*offset); + } + } + if let Some(sub) = core.subscriptions.get_mut(&id) { + sub.expires_at = Instant::now() + sub.session_timeout; + sub.seek.extend(internal); + } + self.notify.notify_waiters(); + Ok(results) + } + + // -- Introspection --------------------------------------------------------- + async fn list_topics(&self, _ctx: &SecurityContext) -> Result, EventBrokerError> { + let core = self.core.lock().await; + Ok(core + .topics + .keys() + .map(|id| Topic { + id: id.clone(), + description: None, + partitions: core.topics[id].partitions, + retention: None, + streaming: None, + created_at: Utc::now(), + }) + .collect()) + } + + async fn list_topic_segments( + &self, + _ctx: &SecurityContext, + topic: &str, + partition: u32, + _range: PartitionRange, + ) -> Result, EventBrokerError> { + let core = self.core.lock().await; + let t = core + .topics + .get(topic) + .ok_or_else(|| EventBrokerError::TopicNotFound { + topic: topic.to_owned(), + detail: String::new(), + instance: String::new(), + })?; + let log = t.log.get(&partition); + let segments = if let Some(events) = log { + if events.is_empty() { + vec![] + } else { + let start = events.first().and_then(|e| e.event.sequence).unwrap_or(0); + let end = events.last().and_then(|e| e.event.sequence).unwrap_or(0); + let ts = events + .first() + .and_then(|e| e.event.sequence_time) + .unwrap_or_else(Utc::now); + let te = events + .last() + .and_then(|e| e.event.sequence_time) + .unwrap_or_else(Utc::now); + vec![TopicSegment { + topic: topic.to_owned(), + partition, + start_sequence: start, + end_sequence: end, + start_time: ts, + end_time: te, + segments: vec![], + }] + } + } else { + vec![] + }; + Ok(segments) + } + + async fn list_event_types( + &self, + _ctx: &SecurityContext, + ) -> Result, EventBrokerError> { + let core = self.core.lock().await; + Ok(core + .topics + .iter() + .flat_map(|(topic_id, t)| { + t.event_types.iter().map(move |(type_id, reg)| EventType { + id: type_id.clone(), + topic: topic_id.clone(), + description: None, + data_schema: reg.data_schema.clone(), + created_at: Utc::now(), + }) + }) + .collect()) + } + + async fn get_event_type( + &self, + _ctx: &SecurityContext, + id: &str, + ) -> Result { + let core = self.core.lock().await; + for (topic_id, t) in &core.topics { + if let Some(reg) = t.event_types.get(id) { + return Ok(EventType { + id: id.to_owned(), + topic: topic_id.clone(), + description: None, + data_schema: reg.data_schema.clone(), + created_at: Utc::now(), + }); + } + } + Err(EventBrokerError::EventTypeUnknown { + type_id: id.to_owned(), + detail: format!("event type '{id}' not registered in mock"), + instance: String::new(), + }) + } +} diff --git a/gears/system/event-broker/event-broker-sdk/src/models.rs b/gears/system/event-broker/event-broker-sdk/src/models.rs new file mode 100644 index 000000000..472c9ea12 --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/src/models.rs @@ -0,0 +1,204 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::api::StorageBackendConfig; +use crate::ids::ConsumerGroupId; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Topic { + pub id: String, + pub description: Option, + pub partitions: u32, + pub retention: Option, + pub streaming: Option, + pub created_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EventType { + pub id: String, + pub topic: String, + pub description: Option, + pub data_schema: serde_json::Value, + pub created_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConsumerGroup { + pub id: ConsumerGroupId, + pub tenant_id: Uuid, + pub owner_principal_id: String, + pub kind: ConsumerGroupKind, + pub description: Option, + pub created_at: DateTime, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ConsumerGroupKind { + Named, + Anonymous, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Subscription { + pub id: crate::ids::SubscriptionId, + pub consumer_group: ConsumerGroupId, + pub assigned: Vec, + pub topology_version: i64, + pub expires_at: DateTime, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub struct PartitionAssignment { + pub topic_ix: u16, + pub partition: u32, +} + +#[derive(Debug, Clone)] +pub struct CreateConsumerGroupRequest { + /// RFC 9110 User-Agent grammar; ASCII 1-256 bytes. Diagnostic only - no broker semantic. + pub client_agent: String, + pub description: Option, +} + +#[derive(Debug, Clone, Copy)] +pub struct PartitionRange { + pub start_offset: Option, + pub end_offset: Option, + pub limit: u32, +} + +#[derive(Debug, Clone)] +pub struct TopicSegment { + pub topic: String, + pub partition: u32, + pub start_sequence: i64, + pub end_sequence: i64, + pub start_time: DateTime, + pub end_time: DateTime, + /// Backend-specific per-segment opaque entries. Required in the wire response envelope. + pub segments: Vec, +} + +#[derive(Debug, Clone)] +pub struct PartitionLeader { + pub partition: u32, + pub endpoint: String, +} + +/// Paginated result wrapper used by list endpoints (e.g. GET /v1/consumer-groups). +#[derive(Debug, Clone)] +pub struct Page { + pub items: Vec, + pub next_cursor: Option, + pub prev_cursor: Option, + pub limit: u32, +} + +/// Query parameters for [`EventBroker::list_consumer_groups`](crate::api::EventBroker::list_consumer_groups). +/// Built fluently; `ConsumerGroupQuery::default()` requests the first page with the +/// broker's default limit and no filter/order. +/// +/// ```ignore +/// let q = ConsumerGroupQuery::new().limit(50).filter("name eq 'orders'"); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct ConsumerGroupQuery { + /// Max items per page (broker default when unset). + pub limit: Option, + /// Opaque pagination cursor from a previous page's `next_cursor`. + pub cursor: Option, + /// Filter expression (backend-defined grammar). + pub filter: Option, + /// Ordering expression (backend-defined grammar). + pub orderby: Option, +} + +impl ConsumerGroupQuery { + #[must_use] + pub fn new() -> Self { + Self::default() + } + #[must_use] + pub fn limit(mut self, limit: u32) -> Self { + self.limit = Some(limit); + self + } + #[must_use] + pub fn cursor(mut self, cursor: impl Into) -> Self { + self.cursor = Some(cursor.into()); + self + } + #[must_use] + pub fn filter(mut self, filter: impl Into) -> Self { + self.filter = Some(filter.into()); + self + } + #[must_use] + pub fn orderby(mut self, orderby: impl Into) -> Self { + self.orderby = Some(orderby.into()); + self + } +} + +/// Scope of a producer chain reset +/// ([`EventBroker::reset_producer_chain`](crate::api::EventBroker::reset_producer_chain)). +/// Models the valid combinations directly - a partition reset always names its topic, +/// so "partition without topic" is unrepresentable. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ResetScope<'a> { + /// Reset every (topic, partition) chain for the producer. + AllTopics, + /// Reset every partition chain under one topic. + Topic(&'a str), + /// Reset a single (topic, partition) chain. + Partition { topic: &'a str, partition: u32 }, +} + +/// The event envelope. Matches `event.v1.schema.json` in the design and is the +/// parameter/return type on the public [`EventBroker`](crate::api::EventBroker) +/// (publish/storage side). Broker-stamped fields (`partition`, `sequence`, +/// `sequence_time`, `offset`, `offset_time`) are `None` on publish payloads; the +/// broker populates them on receipt. +/// +/// This is a plain domain type with no serde derives - construct it via field +/// init. Wire (de)serialization is the transport's concern: the `outbox` async +/// producer round-trips it through its own `OutboxEvent` DTO, and an HTTP backend +/// owns its own wire mapping. +#[derive(Debug, Clone)] +pub struct Event { + pub id: Uuid, + pub type_id: String, + pub topic: String, + pub tenant_id: Uuid, + pub source: String, + pub subject: String, + pub subject_type: String, + pub partition_key: Option, + pub occurred_at: DateTime, + pub trace_parent: Option, + pub data: Option, + + // Broker-stamped (readOnly on the wire; absent on publish) + pub partition: Option, + pub sequence: Option, + pub sequence_time: Option>, + pub offset: Option, + pub offset_time: Option>, + + // Publisher-only (writeOnly; stripped on read) + pub meta: Option, +} + +/// Publisher-only chain/idempotency metadata stamped onto an [`Event`] before +/// publish (`writeOnly`; the broker strips it on read). +#[derive(Debug, Clone)] +pub struct ProducerMeta { + pub version: u8, + pub producer_id: Option, + pub previous: Option, + pub sequence: Option, + pub partition_hint: Option, +} diff --git a/gears/system/event-broker/event-broker-sdk/src/producer/db.rs b/gears/system/event-broker/event-broker-sdk/src/producer/db.rs new file mode 100644 index 000000000..a6b3281a4 --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/src/producer/db.rs @@ -0,0 +1,510 @@ +use std::marker::PhantomData; +use std::sync::Arc; + +use tokio::sync::Mutex; +use toolkit_security::SecurityContext; + +use crate::api::{EventBroker, ProducerMode}; +use crate::error::EventBrokerError; +use crate::ids::ProducerId; +use crate::models::{ProducerMeta, ResetScope}; +use crate::sdk::EventBrokerSdk; +use crate::typed_event::TypedEvent; + +use super::direct::{Has, Missing}; +use super::event_factory::prepare_event; +use super::outbox::{ProducerOutboxEnvelope, ProducerOutboxQueue}; +use super::registration::{ProducerRegistration, ProducerRegistrationStore}; +use super::schema_cache::ProducerSchemaCache; +use super::types::{ + DbDeduplication, MissingProducerRegistration, ProducerIdentity, UnknownProducerRegistration, + ValidationTiming, +}; + +type DbProducerBuilderState = + PhantomData<(Broker, Db, Ctx, Identity, Dedup, Topics, Patterns)>; + +pub struct DbProducerBuilder< + Broker = Missing, + Db = Missing, + Ctx = Missing, + Identity = Missing, + Dedup = Missing, + Topics = Missing, + Patterns = Missing, +> { + broker: Option>, + db: Option, + ctx: Option, + identity: Option, + deduplication: Option, + topics: Vec, + event_type_patterns: Vec, + validation_timing: ValidationTiming, + _state: DbProducerBuilderState, +} + +impl DbProducerBuilder { + pub(crate) fn new() -> Self { + Self { + broker: None, + db: None, + ctx: None, + identity: None, + deduplication: None, + topics: Vec::new(), + event_type_patterns: Vec::new(), + validation_timing: ValidationTiming::Eager, + _state: PhantomData, + } + } +} + +impl DbProducerBuilder { + #[must_use] + pub fn broker(self, broker: Arc) -> DbProducerBuilder { + DbProducerBuilder { + broker: Some(broker), + db: self.db, + ctx: self.ctx, + identity: self.identity, + deduplication: self.deduplication, + topics: self.topics, + event_type_patterns: self.event_type_patterns, + validation_timing: self.validation_timing, + _state: PhantomData, + } + } + + #[must_use] + pub fn db(self, db: toolkit_db::Db) -> DbProducerBuilder { + DbProducerBuilder { + broker: self.broker, + db: Some(db), + ctx: self.ctx, + identity: self.identity, + deduplication: self.deduplication, + topics: self.topics, + event_type_patterns: self.event_type_patterns, + validation_timing: self.validation_timing, + _state: PhantomData, + } + } + + #[must_use] + pub fn security_context( + self, + ctx: SecurityContext, + ) -> DbProducerBuilder { + DbProducerBuilder { + broker: self.broker, + db: self.db, + ctx: Some(ctx), + identity: self.identity, + deduplication: self.deduplication, + topics: self.topics, + event_type_patterns: self.event_type_patterns, + validation_timing: self.validation_timing, + _state: PhantomData, + } + } + + #[must_use] + pub fn identity(self, identity: ProducerIdentity) -> DbProducerBuilder { + DbProducerBuilder { + broker: self.broker, + db: self.db, + ctx: self.ctx, + identity: Some(identity), + deduplication: self.deduplication, + topics: self.topics, + event_type_patterns: self.event_type_patterns, + validation_timing: self.validation_timing, + _state: PhantomData, + } + } + + #[must_use] + pub fn deduplication( + self, + deduplication: impl Into, + ) -> DbProducerBuilder { + DbProducerBuilder { + broker: self.broker, + db: self.db, + ctx: self.ctx, + identity: self.identity, + deduplication: Some(deduplication.into()), + topics: self.topics, + event_type_patterns: self.event_type_patterns, + validation_timing: self.validation_timing, + _state: PhantomData, + } + } + + #[must_use] + pub fn topics(self, topics: It) -> DbProducerBuilder + where + S: Into, + It: IntoIterator, + { + DbProducerBuilder { + broker: self.broker, + db: self.db, + ctx: self.ctx, + identity: self.identity, + deduplication: self.deduplication, + topics: topics.into_iter().map(Into::into).collect(), + event_type_patterns: self.event_type_patterns, + validation_timing: self.validation_timing, + _state: PhantomData, + } + } + + #[must_use] + pub fn event_type_patterns( + self, + patterns: It, + ) -> DbProducerBuilder + where + S: Into, + It: IntoIterator, + { + DbProducerBuilder { + broker: self.broker, + db: self.db, + ctx: self.ctx, + identity: self.identity, + deduplication: self.deduplication, + topics: self.topics, + event_type_patterns: patterns.into_iter().map(Into::into).collect(), + validation_timing: self.validation_timing, + _state: PhantomData, + } + } + + #[must_use] + pub fn lazy_validation(mut self) -> Self { + self.validation_timing = ValidationTiming::Lazy; + self + } +} + +impl DbProducerBuilder { + pub async fn prepare_all(self) -> Result { + self.build_inner(true).await + } + + pub async fn build(self) -> Result { + let prepare_all = matches!(self.validation_timing, ValidationTiming::Eager); + self.build_inner(prepare_all).await + } + + async fn build_inner(self, prepare_all: bool) -> Result { + let broker = self.broker.expect("typestate requires broker"); + let db = self.db.expect("typestate requires db"); + let ctx = self.ctx.expect("typestate requires security context"); + let identity = self.identity.expect("typestate requires identity"); + let deduplication = self + .deduplication + .expect("typestate requires deduplication"); + identity.validate()?; + if self.topics.is_empty() || self.event_type_patterns.is_empty() { + return Err(EventBrokerError::InvalidProducerOptions { + detail: "topics and event_type_patterns are required".to_owned(), + instance: String::new(), + }); + } + + let cache = Arc::new(ProducerSchemaCache::default()); + if prepare_all { + cache + .prepare_all(&broker, &ctx, &self.topics, &self.event_type_patterns) + .await?; + } + + let registration_store = ProducerRegistrationStore::new(db.clone()); + let registration = match &deduplication { + DbDeduplication::Stateless => None, + DbDeduplication::Managed(managed) => { + let client_agent = identity.client_agent_ref().map_or_else( + || EventBrokerSdk::default_client_agent().to_owned(), + str::to_owned, + ); + Some( + resolve_managed_registration( + &broker, + &ctx, + ®istration_store, + managed, + &client_agent, + ) + .await?, + ) + } + }; + + Ok(DbProducer { + broker, + ctx, + identity, + deduplication, + registration_state: Arc::new(Mutex::new(RegistrationState::from_registration( + registration, + ))), + registration_store, + topics: self.topics, + event_type_patterns: self.event_type_patterns, + validation_timing: self.validation_timing, + cache, + }) + } +} + +#[derive(Clone)] +pub struct DbProducer { + pub(crate) broker: Arc, + pub(crate) ctx: SecurityContext, + pub(crate) identity: ProducerIdentity, + pub(crate) deduplication: DbDeduplication, + pub(crate) registration_state: Arc>, + registration_store: ProducerRegistrationStore, + topics: Vec, + event_type_patterns: Vec, + validation_timing: ValidationTiming, + cache: Arc, +} + +impl DbProducer { + #[must_use] + pub fn builder() -> DbProducerBuilder { + DbProducerBuilder::new() + } + + pub async fn prepare(&self) -> Result<(), EventBrokerError> { + self.cache + .prepare_one( + &self.broker, + &self.ctx, + &self.topics, + &self.event_type_patterns, + E::TYPE_ID, + ) + .await + } + + pub async fn prepare_all(&self) -> Result<(), EventBrokerError> { + self.cache + .prepare_all( + &self.broker, + &self.ctx, + &self.topics, + &self.event_type_patterns, + ) + .await + } + + pub fn outbox_queue( + &self, + queue: impl Into, + partitions: toolkit_db::outbox::Partitions, + ) -> Result { + ProducerOutboxQueue::new(self.clone(), queue.into(), partitions) + } + + pub async fn reset_chain(&self, scope: ResetScope<'_>) -> Result<(), EventBrokerError> { + let producer_id = self + .registration_state + .lock() + .await + .producer_id + .ok_or_else(|| { + EventBrokerError::Internal("reset_chain called on a stateless producer".to_owned()) + })?; + self.broker + .reset_producer_chain(&self.ctx, producer_id, scope) + .await + } + + pub async fn rotate_registration(&mut self) -> Result { + let current = { + let state = self.registration_state.lock().await; + let Some(current) = state.registration.as_ref() else { + return Err(EventBrokerError::Internal( + "rotate_registration called on a stateless producer".to_owned(), + )); + }; + current.clone() + }; + let client_agent = current.client_agent.clone(); + let producer_id = self + .broker + .register_producer(&self.ctx, current.mode, &client_agent) + .await?; + let next = self + .registration_store + .replace_with_new_generation(¤t, producer_id) + .await?; + self.registration_state.lock().await.set(next); + Ok(producer_id) + } + + #[doc(hidden)] + pub async fn handle_unknown_producer( + &self, + rejected_producer_id: ProducerId, + ) -> Result { + let DbDeduplication::Managed(managed) = &self.deduplication else { + return Ok(UnknownProducerAction::Fail); + }; + if managed.on_unknown == UnknownProducerRegistration::Fail { + return Ok(UnknownProducerAction::Fail); + } + let current = { + let state = self.registration_state.lock().await; + let Some(current) = state.registration.as_ref() else { + return Ok(UnknownProducerAction::Fail); + }; + if current.producer_id != rejected_producer_id { + return Ok(UnknownProducerAction::AlreadyRotated); + } + current.clone() + }; + let producer_id = self + .broker + .register_producer(&self.ctx, current.mode, ¤t.client_agent) + .await?; + let next = self + .registration_store + .replace_with_new_generation(¤t, producer_id) + .await?; + self.registration_state.lock().await.set(next); + Ok(UnknownProducerAction::Rotated) + } + + #[doc(hidden)] + pub async fn outbox_envelope( + &self, + event: E, + outbox_partitions: u32, + ) -> Result<(u32, ProducerOutboxEnvelope), EventBrokerError> { + self.cache + .ensure_declared(&self.event_type_patterns, E::TYPE_ID) + .await?; + if matches!(self.validation_timing, ValidationTiming::Lazy) + && !self.cache.is_prepared(E::TYPE_ID).await + { + return Err(EventBrokerError::SchemaNotPrepared { + type_id: E::TYPE_ID.to_owned(), + detail: "call producer.prepare::() outside the business transaction first" + .to_owned(), + instance: String::new(), + }); + } + let mode = self.deduplication.mode(); + let state = self.registration_state.lock().await.clone(); + let producer_id = state.producer_id; + let generation = state.generation; + let prepared = prepare_event( + &self.cache, + &self.identity, + &self.ctx, + event, + |_, partition| match (mode, producer_id) { + (ProducerMode::Stateless, _) => None, + (ProducerMode::Monotonic, Some(pid)) | (ProducerMode::Chained, Some(pid)) => { + Some(ProducerMeta { + version: 1, + producer_id: Some(pid.0), + previous: None, + sequence: None, + partition_hint: Some(partition), + }) + } + (_, None) => None, + }, + ) + .await?; + let outbox_partition = super::partitioning::producer_outbox_partition( + &prepared.event.topic, + prepared.broker_partition, + outbox_partitions, + ); + let envelope = ProducerOutboxEnvelope::from_event( + prepared.event, + prepared.broker_partition, + mode, + producer_id, + generation, + self.identity.client_agent_ref().map_or_else( + || EventBrokerSdk::default_client_agent().to_owned(), + str::to_owned, + ), + ); + Ok((outbox_partition, envelope)) + } +} + +#[derive(Clone)] +pub(crate) struct RegistrationState { + pub(crate) producer_id: Option, + pub(crate) generation: Option, + pub(crate) registration: Option, +} + +impl RegistrationState { + fn from_registration(registration: Option) -> Self { + let producer_id = registration + .as_ref() + .map(|registration| registration.producer_id); + let generation = registration + .as_ref() + .map(|registration| registration.generation); + Self { + producer_id, + generation, + registration, + } + } + + fn set(&mut self, registration: ProducerRegistration) { + self.producer_id = Some(registration.producer_id); + self.generation = Some(registration.generation); + self.registration = Some(registration); + } +} + +#[doc(hidden)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UnknownProducerAction { + Fail, + Rotated, + AlreadyRotated, +} + +async fn resolve_managed_registration( + broker: &Arc, + ctx: &SecurityContext, + store: &ProducerRegistrationStore, + managed: &super::types::ManagedDeduplication, + client_agent: &str, +) -> Result { + match store.load(&managed.key).await? { + Some(registration) => { + registration.validate_matches(managed, client_agent)?; + Ok(registration) + } + None if managed.on_missing == MissingProducerRegistration::Fail => { + Err(EventBrokerError::InvalidProducerOptions { + detail: format!("managed producer registration '{}' is missing", managed.key), + instance: String::new(), + }) + } + None => { + let producer_id = broker + .register_producer(ctx, managed.mode, client_agent) + .await?; + store.insert_new(managed, producer_id, client_agent).await + } + } +} diff --git a/gears/system/event-broker/event-broker-sdk/src/producer/direct.rs b/gears/system/event-broker/event-broker-sdk/src/producer/direct.rs new file mode 100644 index 000000000..97220a052 --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/src/producer/direct.rs @@ -0,0 +1,496 @@ +use std::collections::HashMap; +use std::marker::PhantomData; +use std::sync::Arc; + +use toolkit_security::SecurityContext; + +use crate::api::{EventBroker, IngestOutcome, ProducerMode}; +use crate::error::EventBrokerError; +use crate::ids::ProducerId; +use crate::models::{ProducerMeta, ResetScope}; +use crate::sdk::EventBrokerSdk; +use crate::typed_event::TypedEvent; + +use super::event_factory::prepare_event; +use super::schema_cache::ProducerSchemaCache; +use super::types::{DirectDeduplication, ProducerIdentity, ValidationTiming}; + +pub struct Missing; +pub struct Has; + +type ProducerBuilderState = + PhantomData<(Broker, Ctx, Identity, Dedup, Topics, Patterns)>; +type ProducerSequenceKey = (ProducerId, String, u32); +pub(super) type ProducerSequenceCursor = HashMap; +type SharedProducerSequenceCursor = Arc>; + +pub struct ProducerBuilder< + Broker = Missing, + Ctx = Missing, + Identity = Missing, + Dedup = Missing, + Topics = Missing, + Patterns = Missing, +> { + broker: Option>, + ctx: Option, + identity: Option, + deduplication: Option, + topics: Vec, + event_type_patterns: Vec, + validation_timing: ValidationTiming, + _state: ProducerBuilderState, +} + +impl ProducerBuilder { + pub(crate) fn new() -> Self { + Self { + broker: None, + ctx: None, + identity: None, + deduplication: None, + topics: Vec::new(), + event_type_patterns: Vec::new(), + validation_timing: ValidationTiming::Eager, + _state: PhantomData, + } + } +} + +impl ProducerBuilder { + #[must_use] + pub fn broker(self, broker: Arc) -> ProducerBuilder { + ProducerBuilder { + broker: Some(broker), + ctx: self.ctx, + identity: self.identity, + deduplication: self.deduplication, + topics: self.topics, + event_type_patterns: self.event_type_patterns, + validation_timing: self.validation_timing, + _state: PhantomData, + } + } + + #[must_use] + pub fn security_context(self, ctx: SecurityContext) -> ProducerBuilder { + ProducerBuilder { + broker: self.broker, + ctx: Some(ctx), + identity: self.identity, + deduplication: self.deduplication, + topics: self.topics, + event_type_patterns: self.event_type_patterns, + validation_timing: self.validation_timing, + _state: PhantomData, + } + } + + #[must_use] + pub fn identity(self, identity: ProducerIdentity) -> ProducerBuilder { + ProducerBuilder { + broker: self.broker, + ctx: self.ctx, + identity: Some(identity), + deduplication: self.deduplication, + topics: self.topics, + event_type_patterns: self.event_type_patterns, + validation_timing: self.validation_timing, + _state: PhantomData, + } + } + + #[must_use] + pub fn deduplication( + self, + deduplication: DirectDeduplication, + ) -> ProducerBuilder { + ProducerBuilder { + broker: self.broker, + ctx: self.ctx, + identity: self.identity, + deduplication: Some(deduplication), + topics: self.topics, + event_type_patterns: self.event_type_patterns, + validation_timing: self.validation_timing, + _state: PhantomData, + } + } + + #[must_use] + pub fn topics(self, topics: It) -> ProducerBuilder + where + S: Into, + It: IntoIterator, + { + ProducerBuilder { + broker: self.broker, + ctx: self.ctx, + identity: self.identity, + deduplication: self.deduplication, + topics: topics.into_iter().map(Into::into).collect(), + event_type_patterns: self.event_type_patterns, + validation_timing: self.validation_timing, + _state: PhantomData, + } + } + + #[must_use] + pub fn event_type_patterns(self, patterns: It) -> ProducerBuilder + where + S: Into, + It: IntoIterator, + { + ProducerBuilder { + broker: self.broker, + ctx: self.ctx, + identity: self.identity, + deduplication: self.deduplication, + topics: self.topics, + event_type_patterns: patterns.into_iter().map(Into::into).collect(), + validation_timing: self.validation_timing, + _state: PhantomData, + } + } + + #[must_use] + pub fn lazy_validation(mut self) -> Self { + self.validation_timing = ValidationTiming::Lazy; + self + } +} + +impl ProducerBuilder { + pub async fn prepare_all(self) -> Result { + self.build_inner(true).await + } + + pub async fn build(self) -> Result { + let prepare_all = matches!(self.validation_timing, ValidationTiming::Eager); + self.build_inner(prepare_all).await + } + + async fn build_inner(self, prepare_all: bool) -> Result { + let broker = self.broker.expect("typestate requires broker"); + let ctx = self.ctx.expect("typestate requires security context"); + let identity = self.identity.expect("typestate requires identity"); + let deduplication = self + .deduplication + .expect("typestate requires deduplication"); + validate_non_empty("topics", &self.topics)?; + validate_non_empty("event_type_patterns", &self.event_type_patterns)?; + identity.validate()?; + deduplication.validate()?; + + let cache = Arc::new(ProducerSchemaCache::default()); + if prepare_all { + cache + .prepare_all(&broker, &ctx, &self.topics, &self.event_type_patterns) + .await?; + } + + let (producer_id, mode) = match deduplication { + DirectDeduplication::Stateless => (None, ProducerMode::Stateless), + DirectDeduplication::RegisterOnStart { mode } => { + let client_agent = identity.client_agent_ref().map_or_else( + || EventBrokerSdk::default_client_agent().to_owned(), + str::to_owned, + ); + let producer_id = broker.register_producer(&ctx, mode, &client_agent).await?; + (Some(producer_id), mode) + } + DirectDeduplication::Reuse { mode, producer_id } => (Some(producer_id), mode), + }; + + let producer = Producer { + broker, + ctx, + identity, + mode, + producer_id, + topics: self.topics, + event_type_patterns: self.event_type_patterns, + validation_timing: self.validation_timing, + cache, + sequence_state: Arc::new(std::sync::RwLock::new(HashMap::new())), + }; + producer.prime_sequence_state().await?; + Ok(producer) + } +} + +pub struct Producer { + broker: Arc, + ctx: SecurityContext, + identity: ProducerIdentity, + mode: ProducerMode, + producer_id: Option, + topics: Vec, + event_type_patterns: Vec, + validation_timing: ValidationTiming, + cache: Arc, + sequence_state: SharedProducerSequenceCursor, +} + +impl Producer { + #[must_use] + pub fn builder() -> ProducerBuilder { + ProducerBuilder::new() + } + + pub async fn prepare(&self) -> Result<(), EventBrokerError> { + self.cache + .prepare_one( + &self.broker, + &self.ctx, + &self.topics, + &self.event_type_patterns, + E::TYPE_ID, + ) + .await + } + + pub async fn prepare_all(&self) -> Result<(), EventBrokerError> { + self.cache + .prepare_all( + &self.broker, + &self.ctx, + &self.topics, + &self.event_type_patterns, + ) + .await + } + + pub fn producer_id(&self) -> Option { + self.producer_id + } + + pub async fn publish( + &self, + event: E, + ) -> Result { + self.publish_inner(event, false).await + } + + pub async fn publish_persisted( + &self, + event: E, + ) -> Result { + self.publish_inner(event, true).await + } + + pub async fn publish_batch( + &self, + events: Vec, + ) -> Result, EventBrokerError> { + let mut prepared = Vec::with_capacity(events.len()); + let mut batch_sequence_state = self + .sequence_state + .read() + .expect("producer sequence state lock poisoned") + .clone(); + for event in events { + let event = self + .prepare_wire_event_with_meta(event, |topic, partition| { + self.producer_meta_from_cursor(&batch_sequence_state, topic, partition) + }) + .await?; + advance_cursor( + &mut batch_sequence_state, + &event.event.topic, + event.broker_partition, + event.event.meta.as_ref(), + ); + prepared.push(event); + } + let wire_events = prepared + .iter() + .map(|prepared| prepared.event.clone()) + .collect::>(); + let outcomes = self.broker.publish_batch(&self.ctx, &wire_events).await?; + for (prepared, outcome) in prepared.iter().zip(outcomes.iter()) { + self.advance_after_acceptance(prepared, *outcome).await; + } + Ok(outcomes) + } + + pub async fn reset_chain(&self, scope: ResetScope<'_>) -> Result<(), EventBrokerError> { + let producer_id = self.producer_id.ok_or_else(|| { + EventBrokerError::Internal("reset_chain called on a stateless producer".to_owned()) + })?; + self.broker + .reset_producer_chain(&self.ctx, producer_id, scope) + .await + } + + async fn publish_inner( + &self, + event: E, + persisted: bool, + ) -> Result { + let prepared = self.prepare_wire_event(event).await?; + let outcome = if persisted { + self.broker.publish_sync(&self.ctx, &prepared.event).await? + } else { + self.broker.publish(&self.ctx, &prepared.event).await? + }; + self.advance_after_acceptance(&prepared, outcome).await; + Ok(outcome) + } + + async fn prepare_wire_event( + &self, + event: E, + ) -> Result { + self.prepare_wire_event_with_meta(event, |topic, partition| { + self.producer_meta(topic, partition) + }) + .await + } + + async fn prepare_wire_event_with_meta( + &self, + event: E, + meta_for_partition: impl FnOnce(&str, u32) -> Option, + ) -> Result { + self.cache + .ensure_declared(&self.event_type_patterns, E::TYPE_ID) + .await?; + if matches!(self.validation_timing, ValidationTiming::Lazy) + && !self.cache.is_prepared(E::TYPE_ID).await + { + self.prepare::().await?; + } + prepare_event( + &self.cache, + &self.identity, + &self.ctx, + event, + meta_for_partition, + ) + .await + } + + fn producer_meta(&self, topic: &str, partition: u32) -> Option { + let sequence_state = self + .sequence_state + .read() + .expect("producer sequence state lock poisoned"); + self.producer_meta_from_cursor(&sequence_state, topic, partition) + } + + fn producer_meta_from_cursor( + &self, + cursor: &ProducerSequenceCursor, + topic: &str, + partition: u32, + ) -> Option { + let producer_id = self.producer_id?; + let last = cursor + .get(&(producer_id, topic.to_owned(), partition)) + .copied() + .unwrap_or(-1); + producer_meta_for_last(self.mode, producer_id, last, partition) + } + + async fn advance_after_acceptance( + &self, + prepared: &super::event_factory::PreparedEvent, + outcome: IngestOutcome, + ) { + if !matches!(outcome, IngestOutcome::Accepted | IngestOutcome::Persisted) { + return; + } + let Some(meta) = &prepared.event.meta else { + return; + }; + let (Some(producer_id), Some(sequence)) = (meta.producer_id, meta.sequence) else { + return; + }; + self.sequence_state + .write() + .expect("producer sequence state lock poisoned") + .insert( + ( + ProducerId(producer_id), + prepared.event.topic.clone(), + prepared.broker_partition, + ), + sequence, + ); + } + + async fn prime_sequence_state(&self) -> Result<(), EventBrokerError> { + let Some(producer_id) = self.producer_id else { + return Ok(()); + }; + let cursors = self + .broker + .get_producer_cursors(&self.ctx, producer_id) + .await?; + self.sequence_state + .write() + .expect("producer sequence state lock poisoned") + .extend(cursors.into_iter().map(|cursor| { + ( + (producer_id, cursor.topic, cursor.partition), + cursor.last_sequence, + ) + })); + Ok(()) + } +} + +pub(super) fn advance_cursor( + cursor: &mut ProducerSequenceCursor, + topic: &str, + partition: u32, + meta: Option<&ProducerMeta>, +) { + let Some(meta) = meta else { + return; + }; + let (Some(producer_id), Some(sequence)) = (meta.producer_id, meta.sequence) else { + return; + }; + cursor.insert( + (ProducerId(producer_id), topic.to_owned(), partition), + sequence, + ); +} + +pub(super) fn producer_meta_for_last( + mode: ProducerMode, + producer_id: ProducerId, + last: i64, + partition: u32, +) -> Option { + match mode { + ProducerMode::Stateless => None, + ProducerMode::Monotonic => Some(ProducerMeta { + version: 1, + producer_id: Some(producer_id.0), + previous: None, + sequence: Some(last + 1), + partition_hint: Some(partition), + }), + ProducerMode::Chained => Some(ProducerMeta { + version: 1, + producer_id: Some(producer_id.0), + previous: Some(last), + sequence: Some(last + 1), + partition_hint: Some(partition), + }), + } +} + +fn validate_non_empty(field: &'static str, values: &[String]) -> Result<(), EventBrokerError> { + if values.is_empty() || values.iter().any(|value| value.trim().is_empty()) { + Err(EventBrokerError::InvalidProducerOptions { + detail: format!("{field} must contain at least one non-empty entry"), + instance: String::new(), + }) + } else { + Ok(()) + } +} diff --git a/gears/system/event-broker/event-broker-sdk/src/producer/direct_tests.rs b/gears/system/event-broker/event-broker-sdk/src/producer/direct_tests.rs new file mode 100644 index 000000000..5078b6554 --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/src/producer/direct_tests.rs @@ -0,0 +1,39 @@ +use toolkit_gts::gts_id; +use uuid::Uuid; + +use crate::api::ProducerMode; +use crate::ids::ProducerId; + +use super::direct::{ProducerSequenceCursor, advance_cursor, producer_meta_for_last}; + +const TOPIC: &str = gts_id!("cf.core.events.topic.v1~example.sdk.producer.unit_orders.v1"); +const PARTITION: u32 = 2; + +#[test] +fn monotonic_batch_advances_sequence_between_prepared_events() { + let second = second_batch_meta(ProducerMode::Monotonic); + + assert_eq!(second.sequence, Some(1)); + assert_eq!(second.previous, None); +} + +#[test] +fn chained_batch_advances_previous_between_prepared_events() { + let second = second_batch_meta(ProducerMode::Chained); + + assert_eq!(second.sequence, Some(1)); + assert_eq!(second.previous, Some(0)); +} + +fn second_batch_meta(mode: ProducerMode) -> crate::models::ProducerMeta { + let producer_id = ProducerId(Uuid::from_u128(1)); + let first = producer_meta_for_last(mode, producer_id, -1, PARTITION).unwrap(); + let mut cursor = ProducerSequenceCursor::new(); + advance_cursor(&mut cursor, TOPIC, PARTITION, Some(&first)); + let last = cursor + .get(&(producer_id, TOPIC.to_owned(), PARTITION)) + .copied() + .unwrap(); + + producer_meta_for_last(mode, producer_id, last, PARTITION).unwrap() +} diff --git a/gears/system/event-broker/event-broker-sdk/src/producer/event_factory.rs b/gears/system/event-broker/event-broker-sdk/src/producer/event_factory.rs new file mode 100644 index 000000000..54a8c1f5e --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/src/producer/event_factory.rs @@ -0,0 +1,61 @@ +use chrono::Utc; +use uuid::Uuid; + +use crate::error::EventBrokerError; +use crate::models::{Event, ProducerMeta}; +use crate::typed_event::TypedEvent; + +use super::partitioning::{broker_partition, broker_partition_input}; +use super::schema_cache::ProducerSchemaCache; +use super::types::ProducerIdentity; + +pub(crate) struct PreparedEvent { + pub(crate) event: Event, + pub(crate) broker_partition: u32, +} + +pub(crate) async fn prepare_event( + cache: &ProducerSchemaCache, + identity: &ProducerIdentity, + ctx: &toolkit_security::SecurityContext, + event: E, + meta_for_partition: impl FnOnce(&str, u32) -> Option, +) -> Result { + let type_id = E::TYPE_ID; + let topic = E::TOPIC; + let subject = event.subject(); + let partition_key = event.partition_key(); + let tenant_id = event.tenant_id().unwrap_or_else(|| ctx.subject_tenant_id()); + let data = serde_json::to_value(&event) + .map_err(|err| EventBrokerError::Internal(format!("serialize event data: {err}")))?; + + cache.validate_prepared(type_id, topic, &data).await?; + + let partition_input = broker_partition_input(partition_key.as_deref(), tenant_id); + let partition_count = cache.partition_count(topic).await?; + let partition = broker_partition(&partition_input, partition_count); + let meta = meta_for_partition(topic, partition); + + Ok(PreparedEvent { + broker_partition: partition, + event: Event { + id: Uuid::now_v7(), + type_id: type_id.to_owned(), + topic: topic.to_owned(), + tenant_id, + source: identity.source_ref().to_owned(), + subject: subject.into_owned(), + subject_type: E::SUBJECT_TYPE.to_owned(), + partition_key: partition_key.map(|value| value.into_owned()), + occurred_at: Utc::now(), + trace_parent: event.trace_parent().map(|value| value.into_owned()), + data: Some(data), + partition: None, + sequence: None, + sequence_time: None, + offset: None, + offset_time: None, + meta, + }, + }) +} diff --git a/gears/system/event-broker/event-broker-sdk/src/producer/migrations.rs b/gears/system/event-broker/event-broker-sdk/src/producer/migrations.rs new file mode 100644 index 000000000..54f651a66 --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/src/producer/migrations.rs @@ -0,0 +1,77 @@ +use toolkit_db::sea_orm_migration::prelude::*; +use toolkit_db::sea_orm_migration::sea_orm::{ConnectionTrait, DatabaseBackend, DbErr, Statement}; + +struct CreateProducerRegistrationSchema; + +impl MigrationName for CreateProducerRegistrationSchema { + fn name(&self) -> &'static str { + "m001_create_event_broker_producer_registrations" + } +} + +#[async_trait::async_trait] +impl MigrationTrait for CreateProducerRegistrationSchema { + // Signature must mirror `MigrationTrait`; spelling `SchemaManager<'_>` changes lifetime binding. + #[allow(elided_lifetimes_in_paths)] + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let conn = manager.get_connection(); + let backend = conn.get_database_backend(); + conn.execute(Statement::from_string( + backend, + match backend { + DatabaseBackend::Postgres => { + "CREATE TABLE IF NOT EXISTS event_broker_producer_registrations ( + registration_key VARCHAR(1024) PRIMARY KEY, + producer_id UUID NOT NULL, + mode VARCHAR(32) NOT NULL, + client_agent VARCHAR(256) NOT NULL, + generation BIGINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + )" + } + DatabaseBackend::Sqlite => { + "CREATE TABLE IF NOT EXISTS event_broker_producer_registrations ( + registration_key TEXT PRIMARY KEY, + producer_id TEXT NOT NULL, + mode TEXT NOT NULL, + client_agent TEXT NOT NULL, + generation INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + )" + } + DatabaseBackend::MySql => { + "CREATE TABLE IF NOT EXISTS event_broker_producer_registrations ( + registration_key VARCHAR(1024) PRIMARY KEY, + producer_id CHAR(36) NOT NULL, + mode VARCHAR(32) NOT NULL, + client_agent VARCHAR(256) NOT NULL, + generation BIGINT NOT NULL, + created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + updated_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) + )" + } + }, + )) + .await?; + Ok(()) + } + + // Signature must mirror `MigrationTrait`; spelling `SchemaManager<'_>` changes lifetime binding. + #[allow(elided_lifetimes_in_paths)] + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let conn = manager.get_connection(); + let backend = conn.get_database_backend(); + conn.execute(Statement::from_string( + backend, + "DROP TABLE IF EXISTS event_broker_producer_registrations", + )) + .await?; + Ok(()) + } +} + +pub fn producer_registration_migrations() -> Vec> { + vec![Box::new(CreateProducerRegistrationSchema)] +} diff --git a/gears/system/event-broker/event-broker-sdk/src/producer/mod.rs b/gears/system/event-broker/event-broker-sdk/src/producer/mod.rs new file mode 100644 index 000000000..7702e095b --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/src/producer/mod.rs @@ -0,0 +1,36 @@ +mod direct; +mod event_factory; +#[cfg(feature = "db")] +mod migrations; +mod partitioning; +#[cfg(feature = "db")] +mod registration; +mod schema_cache; +mod types; + +#[cfg(all(test, feature = "test-util"))] +mod direct_tests; +#[cfg(test)] +mod partitioning_tests; + +#[cfg(feature = "db")] +mod db; +#[cfg(feature = "outbox")] +mod outbox; + +pub use crate::api::{IngestOutcome, ProducerCursor, ProducerMode}; +pub use direct::{Producer, ProducerBuilder}; +pub use types::{DirectDeduplication, ProducerIdentity, ValidationTiming}; + +#[cfg(feature = "db")] +pub use db::{DbProducer, DbProducerBuilder, UnknownProducerAction}; +#[cfg(feature = "db")] +pub use migrations::producer_registration_migrations; +#[cfg(feature = "outbox")] +pub use outbox::{ + ProducerOutbox, ProducerOutboxEnvelope, ProducerOutboxHandle, ProducerOutboxQueue, +}; +#[cfg(feature = "db")] +pub use types::{ + DbDeduplication, ManagedDeduplication, MissingProducerRegistration, UnknownProducerRegistration, +}; diff --git a/gears/system/event-broker/event-broker-sdk/src/producer/outbox.rs b/gears/system/event-broker/event-broker-sdk/src/producer/outbox.rs new file mode 100644 index 000000000..cc92cd64c --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/src/producer/outbox.rs @@ -0,0 +1,517 @@ +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use toolkit_db::outbox::{EnqueueMessage, LeasedMessageHandler, MessageResult, OutboxMessage}; + +use crate::api::{IngestOutcome, ProducerMode}; +use crate::error::EventBrokerError; +use crate::ids::ProducerId; +use crate::models::{Event, ProducerMeta}; + +use super::db::{DbProducer, UnknownProducerAction}; + +pub const PRODUCER_OUTBOX_ENVELOPE_VERSION: u16 = 1; +pub const PRODUCER_OUTBOX_PAYLOAD_TYPE: &str = + "application/vnd.cyberware.event-broker.producer-outbox+json;version=1"; + +type ProducerOutboxCursorKey = (ProducerId, String, u32); +type ProducerOutboxCursorMap = HashMap; +type SharedProducerOutboxCursor = Arc>; + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct ProducerOutboxEnvelope { + version: u16, + event_id: uuid::Uuid, + #[serde(rename = "type")] + event_type_id: String, + topic: String, + tenant_id: uuid::Uuid, + source: String, + subject: String, + subject_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + partition_key: Option, + occurred_at: chrono::DateTime, + #[serde(skip_serializing_if = "Option::is_none")] + trace_parent: Option, + #[serde(skip_serializing_if = "Option::is_none")] + data: Option, + broker_partition: u32, + producer_mode: ProducerMode, + #[serde(skip_serializing_if = "Option::is_none")] + producer_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + generation: Option, + diagnostic_metadata: ProducerOutboxDiagnostics, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct ProducerOutboxDiagnostics { + sdk_client_agent: String, +} + +impl ProducerOutboxEnvelope { + pub(crate) fn from_event( + event: Event, + broker_partition: u32, + producer_mode: ProducerMode, + producer_id: Option, + generation: Option, + sdk_client_agent: String, + ) -> Self { + Self { + version: PRODUCER_OUTBOX_ENVELOPE_VERSION, + event_id: event.id, + event_type_id: event.type_id, + topic: event.topic, + tenant_id: event.tenant_id, + source: event.source, + subject: event.subject, + subject_type: event.subject_type, + partition_key: event.partition_key, + occurred_at: event.occurred_at, + trace_parent: event.trace_parent, + data: event.data, + broker_partition, + producer_mode, + producer_id, + generation, + diagnostic_metadata: ProducerOutboxDiagnostics { sdk_client_agent }, + } + } + + fn to_event(&self, seq: i64, chained_previous: Option) -> Result { + if self.version != PRODUCER_OUTBOX_ENVELOPE_VERSION { + return Err(EventBrokerError::Internal(format!( + "unsupported producer outbox envelope version {}", + self.version + ))); + } + let meta = match self.producer_mode { + ProducerMode::Stateless => None, + ProducerMode::Monotonic => { + let producer_id = self.producer_id.ok_or_else(|| { + EventBrokerError::Internal( + "producer outbox envelope is missing producer_id".to_owned(), + ) + })?; + Some(ProducerMeta { + version: 1, + producer_id: Some(producer_id.0), + previous: None, + sequence: Some(seq), + partition_hint: Some(self.broker_partition), + }) + } + ProducerMode::Chained => { + let producer_id = self.producer_id.ok_or_else(|| { + EventBrokerError::Internal( + "producer outbox envelope is missing producer_id".to_owned(), + ) + })?; + let previous = chained_previous.ok_or_else(|| { + EventBrokerError::Internal( + "producer outbox chained publish is missing previous sequence".to_owned(), + ) + })?; + Some(ProducerMeta { + version: 1, + producer_id: Some(producer_id.0), + previous: Some(previous), + sequence: Some(seq), + partition_hint: Some(self.broker_partition), + }) + } + }; + Ok(Event { + id: self.event_id, + type_id: self.event_type_id.clone(), + topic: self.topic.clone(), + tenant_id: self.tenant_id, + source: self.source.clone(), + subject: self.subject.clone(), + subject_type: self.subject_type.clone(), + partition_key: self.partition_key.clone(), + occurred_at: self.occurred_at, + trace_parent: self.trace_parent.clone(), + data: self.data.clone(), + partition: None, + sequence: None, + sequence_time: None, + offset: None, + offset_time: None, + meta, + }) + } +} + +#[derive(Clone)] +pub struct ProducerOutbox { + producer: DbProducer, + outbox: Arc, + queue: String, + partitions: u32, +} + +impl ProducerOutbox { + pub async fn enqueue( + &self, + runner: &(impl toolkit_db::secure::DBRunner + Sync + ?Sized), + event: E, + ) -> Result { + let (partition, envelope) = self + .producer + .outbox_envelope(event, self.partitions) + .await?; + let payload = serde_json::to_vec(&envelope).map_err(|err| { + EventBrokerError::Internal(format!("serialize producer outbox envelope: {err}")) + })?; + self.outbox + .enqueue( + runner, + &self.queue, + partition, + payload, + PRODUCER_OUTBOX_PAYLOAD_TYPE, + ) + .await + .map_err(|err| EventBrokerError::Internal(format!("producer outbox enqueue: {err}"))) + } + + pub async fn enqueue_batch( + &self, + runner: &(impl toolkit_db::secure::DBRunner + Sync + ?Sized), + events: impl IntoIterator, + ) -> Result, EventBrokerError> { + let mut items = Vec::new(); + for event in events { + let (partition, envelope) = self + .producer + .outbox_envelope(event, self.partitions) + .await?; + let payload = serde_json::to_vec(&envelope).map_err(|err| { + EventBrokerError::Internal(format!("serialize producer outbox envelope: {err}")) + })?; + items.push(EnqueueMessage { + partition, + payload, + payload_type: PRODUCER_OUTBOX_PAYLOAD_TYPE, + }); + } + self.outbox + .enqueue_batch(runner, &self.queue, &items) + .await + .map_err(|err| { + EventBrokerError::Internal(format!("producer outbox batch enqueue: {err}")) + }) + } +} + +#[derive(Clone)] +pub struct ProducerOutboxQueue { + producer: DbProducer, + queue: String, + partitions: toolkit_db::outbox::Partitions, +} + +impl ProducerOutboxQueue { + pub(crate) fn new( + producer: DbProducer, + queue: String, + partitions: toolkit_db::outbox::Partitions, + ) -> Result { + if queue.trim().is_empty() { + return Err(EventBrokerError::InvalidProducerOptions { + detail: "producer outbox queue name must not be empty".to_owned(), + instance: String::new(), + }); + } + Ok(Self { + producer, + queue, + partitions, + }) + } + + pub fn register( + &self, + builder: toolkit_db::outbox::OutboxBuilder, + ) -> toolkit_db::outbox::LeasedQueueBuilder { + builder + .queue(&self.queue, self.partitions) + .leased(ProducerOutboxProcessor { + producer: self.producer.clone(), + cursor_state: Arc::new(Mutex::new(HashMap::new())), + }) + } + + pub fn bind(&self, handle: &toolkit_db::outbox::OutboxHandle) -> ProducerOutbox { + ProducerOutbox { + producer: self.producer.clone(), + outbox: Arc::clone(handle.outbox()), + queue: self.queue.clone(), + partitions: u32::from(self.partitions.count()), + } + } + + pub async fn start( + &self, + builder: toolkit_db::outbox::OutboxBuilder, + ) -> Result { + let handle = + self.register(builder).start().await.map_err(|err| { + EventBrokerError::Internal(format!("start producer outbox: {err}")) + })?; + let outbox = self.bind(&handle); + Ok(ProducerOutboxHandle { handle, outbox }) + } +} + +pub struct ProducerOutboxHandle { + handle: toolkit_db::outbox::OutboxHandle, + outbox: ProducerOutbox, +} + +impl ProducerOutboxHandle { + pub fn outbox(&self) -> &ProducerOutbox { + &self.outbox + } + + pub async fn stop(self) { + self.handle.stop().await; + } +} + +impl DbProducer { + #[doc(hidden)] + pub async fn process_outbox_payload_for_test( + &self, + payload: Vec, + seq: i64, + ) -> MessageResult { + self.process_outbox_payload_with_cursor_for_test(payload, seq, None) + .await + } + + #[doc(hidden)] + pub async fn process_outbox_payload_with_cursor_for_test( + &self, + payload: Vec, + seq: i64, + initial_chained_previous: Option, + ) -> MessageResult { + let cursor_state = Arc::new(Mutex::new(HashMap::new())); + if let Some(previous) = initial_chained_previous + && let Ok(envelope) = serde_json::from_slice::(&payload) + && let Ok(key) = cursor_key(&envelope) + { + cursor_state + .lock() + .expect("producer outbox cursor state lock poisoned") + .insert(key, previous); + } + let processor = ProducerOutboxProcessor { + producer: self.clone(), + cursor_state, + }; + let msg = OutboxMessage { + partition_id: 0, + seq, + payload, + payload_type: PRODUCER_OUTBOX_PAYLOAD_TYPE.to_owned(), + created_at: chrono::Utc::now(), + attempts: 0, + }; + processor.handle(&msg).await + } +} + +struct ProducerOutboxProcessor { + producer: DbProducer, + cursor_state: SharedProducerOutboxCursor, +} + +impl ProducerOutboxProcessor { + async fn event_for_message( + &self, + envelope: &ProducerOutboxEnvelope, + seq: i64, + ) -> Result { + match envelope.producer_mode { + ProducerMode::Stateless | ProducerMode::Monotonic => envelope.to_event(seq, None), + ProducerMode::Chained => { + let previous = self.cursor_for(envelope).await?; + envelope.to_event(seq, Some(previous)) + } + } + } + + async fn cursor_for(&self, envelope: &ProducerOutboxEnvelope) -> Result { + let key = cursor_key(envelope)?; + if let Some(previous) = self + .cursor_state + .lock() + .expect("producer outbox cursor state lock poisoned") + .get(&key) + .copied() + { + return Ok(previous); + } + self.refresh_cursor(key).await + } + + async fn refresh_cursor(&self, key: ProducerOutboxCursorKey) -> Result { + let (producer_id, topic, partition) = key.clone(); + let cursors = self + .producer + .broker + .get_producer_cursors(&self.producer.ctx, producer_id) + .await?; + let last_sequence = cursors + .into_iter() + .find(|cursor| cursor.topic == topic && cursor.partition == partition) + .map_or(-1, |cursor| cursor.last_sequence); + self.cursor_state + .lock() + .expect("producer outbox cursor state lock poisoned") + .insert(key, last_sequence); + Ok(last_sequence) + } + + async fn handle_sequence_violation( + &self, + envelope: &ProducerOutboxEnvelope, + seq: i64, + ) -> MessageResult { + let key = match cursor_key(envelope) { + Ok(key) => key, + Err(err) => return MessageResult::Reject(err.to_string()), + }; + let previous = match self.refresh_cursor(key).await { + Ok(previous) => previous, + Err(EventBrokerError::Transport(_)) + | Err(EventBrokerError::RateLimitExceeded { .. }) + | Err(EventBrokerError::RateLimited { .. }) => return MessageResult::Retry, + Err(err) => return MessageResult::Reject(err.to_string()), + }; + if seq <= previous { + return MessageResult::Ok; + } + let event = match envelope.to_event(seq, Some(previous)) { + Ok(event) => event, + Err(err) => return MessageResult::Reject(err.to_string()), + }; + self.publish_event(event).await + } + + async fn publish_event(&self, event: Event) -> MessageResult { + match self + .producer + .broker + .publish(&self.producer.ctx, &event) + .await + { + Ok(IngestOutcome::Accepted | IngestOutcome::Persisted) => { + if let Some(meta) = event.meta + && let (Some(producer_id), Some(sequence), Some(partition)) = + (meta.producer_id, meta.sequence, meta.partition_hint) + { + self.cursor_state + .lock() + .expect("producer outbox cursor state lock poisoned") + .insert((ProducerId(producer_id), event.topic, partition), sequence); + } + MessageResult::Ok + } + Ok(IngestOutcome::Duplicate) => MessageResult::Ok, + Err(EventBrokerError::SequenceViolation { .. }) => MessageResult::Reject( + "producer outbox chained sequence divergence persisted after cursor refresh" + .to_owned(), + ), + Err(EventBrokerError::UnknownProducer { producer_id, .. }) => { + self.handle_unknown_producer(producer_id).await + } + Err(EventBrokerError::Transport(_)) + | Err(EventBrokerError::RateLimitExceeded { .. }) + | Err(EventBrokerError::RateLimited { .. }) => MessageResult::Retry, + Err(err) => MessageResult::Reject(err.to_string()), + } + } + + async fn handle_unknown_producer(&self, producer_id: ProducerId) -> MessageResult { + match self.producer.handle_unknown_producer(producer_id).await { + Ok(UnknownProducerAction::Rotated) => MessageResult::Reject(format!( + "producer_id {producer_id:?} is unknown; registered replacement for future enqueues" + )), + Ok(UnknownProducerAction::AlreadyRotated) => MessageResult::Reject(format!( + "producer_id {producer_id:?} is unknown; local registration already rotated" + )), + Ok(UnknownProducerAction::Fail) => { + MessageResult::Reject(format!("producer_id {producer_id:?} is unknown")) + } + Err(EventBrokerError::Transport(_)) + | Err(EventBrokerError::RateLimitExceeded { .. }) + | Err(EventBrokerError::RateLimited { .. }) => MessageResult::Retry, + Err(err) => MessageResult::Reject(err.to_string()), + } + } +} + +#[async_trait::async_trait] +impl LeasedMessageHandler for ProducerOutboxProcessor { + async fn handle(&self, msg: &OutboxMessage) -> MessageResult { + let envelope = match serde_json::from_slice::(&msg.payload) { + Ok(envelope) => envelope, + Err(err) => return MessageResult::Reject(format!("decode producer envelope: {err}")), + }; + let event = match self.event_for_message(&envelope, msg.seq).await { + Ok(event) => event, + Err(err) => return MessageResult::Reject(err.to_string()), + }; + match self + .producer + .broker + .publish(&self.producer.ctx, &event) + .await + { + Ok(IngestOutcome::Accepted | IngestOutcome::Persisted) => { + if let Some(meta) = event.meta + && let (Some(producer_id), Some(sequence), Some(partition)) = + (meta.producer_id, meta.sequence, meta.partition_hint) + { + self.cursor_state + .lock() + .expect("producer outbox cursor state lock poisoned") + .insert((ProducerId(producer_id), event.topic, partition), sequence); + } + MessageResult::Ok + } + Ok(IngestOutcome::Duplicate) => MessageResult::Ok, + Err(EventBrokerError::SequenceViolation { .. }) + if envelope.producer_mode == ProducerMode::Chained => + { + self.handle_sequence_violation(&envelope, msg.seq).await + } + Err(EventBrokerError::UnknownProducer { producer_id, .. }) => { + self.handle_unknown_producer(producer_id).await + } + Err(EventBrokerError::Transport(_)) + | Err(EventBrokerError::RateLimitExceeded { .. }) + | Err(EventBrokerError::RateLimited { .. }) => MessageResult::Retry, + Err(err) => MessageResult::Reject(err.to_string()), + } + } +} + +fn cursor_key( + envelope: &ProducerOutboxEnvelope, +) -> Result { + let producer_id = envelope.producer_id.ok_or_else(|| { + EventBrokerError::Internal("producer outbox envelope is missing producer_id".to_owned()) + })?; + Ok(( + producer_id, + envelope.topic.clone(), + envelope.broker_partition, + )) +} diff --git a/gears/system/event-broker/event-broker-sdk/src/producer/partitioning.rs b/gears/system/event-broker/event-broker-sdk/src/producer/partitioning.rs new file mode 100644 index 000000000..6c95ac16c --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/src/producer/partitioning.rs @@ -0,0 +1,32 @@ +use toolkit_stable_hash::murmur3_x86_32; + +/// First-party local partition assignment per ADR-0002. +/// +/// The caller passes `event.partition_key` if present, else `event.tenant_id`. +/// Must be ASCII. +/// Formula: `(murmur3_x86_32(key.as_bytes(), 0) & 0x7FFFFFFF) % partition_count`. +pub(crate) fn partition_for(key: &str, partition_count: u32) -> u32 { + let h = murmur3_x86_32(key.as_bytes(), 0) & 0x7FFF_FFFF; + h % partition_count +} + +pub(crate) fn broker_partition_input(partition_key: Option<&str>, tenant_id: uuid::Uuid) -> String { + partition_key + .map(ToOwned::to_owned) + .unwrap_or_else(|| tenant_id.to_string()) +} + +pub(crate) fn broker_partition(key: &str, broker_partition_count: u32) -> u32 { + partition_for(key, broker_partition_count.max(1)) +} + +#[cfg(feature = "outbox")] +pub(crate) fn producer_outbox_partition( + topic: &str, + broker_partition: u32, + outbox_partition_count: u32, +) -> u32 { + let key = format!("{topic}:{broker_partition}"); + let hash = murmur3_x86_32(key.as_bytes(), 0) & 0x7FFF_FFFF; + hash % outbox_partition_count.max(1) +} diff --git a/gears/system/event-broker/event-broker-sdk/src/producer/partitioning_tests.rs b/gears/system/event-broker/event-broker-sdk/src/producer/partitioning_tests.rs new file mode 100644 index 000000000..8b289e0c6 --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/src/producer/partitioning_tests.rs @@ -0,0 +1,83 @@ +//! ADR-0002 first-party partition-hint fixture tests. + +use super::partitioning::partition_for; +use toolkit_gts::GTS_ID_PREFIX; + +#[test] +fn empty_string_partitions_consistently() { + assert_eq!(partition_for("", 16), 0); +} + +#[test] +fn single_char_subject() { + assert_eq!(partition_for("a", 2), 0); +} + +#[test] +fn uuid_subject_partition_vectors() { + let key = "550e8400-e29b-41d4-a716-446655440000"; + assert_eq!(partition_for(key, 16), 12); + assert_eq!(partition_for(key, 64), 28); +} + +#[test] +fn single_partition_always_zero() { + for key in [ + "foo", + "bar", + "baz", + "order-service", + "some-long-key-with-words", + ] { + assert_eq!(partition_for(key, 1), 0); + } +} + +#[test] +fn known_ascii_key_two_partitions() { + assert_eq!(partition_for("order-service", 2), 0); +} + +#[test] +fn integration_fixture_vectors_are_pinned() { + assert_eq!(partition_for("00000000-0000-0000-0000-000000000001", 4), 2); + assert_eq!(partition_for("00000000-0000-0000-0000-000000000002", 4), 0); + assert_eq!(partition_for("explicit-key", 4), 3); + assert_eq!(partition_for("explicit-key", 16), 15); + assert_eq!( + partition_for( + &format!("{GTS_ID_PREFIX}cf.core.events.topic.v1~example.sdk.outbox.orders.v1:15"), + 4, + ), + 0 + ); + assert_eq!(partition_for("fixture-partition-key-0-0", 2), 0); + assert_eq!(partition_for("fixture-partition-key-1-0", 2), 1); +} + +#[test] +fn sign_bit_mask_is_applied_before_modulo() { + assert_eq!( + partition_for("00000000-0000-0000-0000-000000000001", 100), + 18 + ); +} + +#[test] +fn partition_is_within_bounds() { + for partitions in [1_u32, 2, 16, 64, 100] { + for key in ["foo", "bar", "some-partition-key", ""] { + let partition = partition_for(key, partitions); + assert!(partition < partitions); + } + } +} + +#[test] +fn partition_is_deterministic() { + let key = "repeat-test-subject"; + let first = partition_for(key, 32); + for _ in 0..100 { + assert_eq!(partition_for(key, 32), first); + } +} diff --git a/gears/system/event-broker/event-broker-sdk/src/producer/registration.rs b/gears/system/event-broker/event-broker-sdk/src/producer/registration.rs new file mode 100644 index 000000000..1c92e2e08 --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/src/producer/registration.rs @@ -0,0 +1,233 @@ +use chrono::Utc; +use sea_orm::entity::prelude::*; +use sea_orm::{ActiveValue, ColumnTrait, EntityTrait, QueryFilter}; +use toolkit_db::secure::{ + AccessScope, ScopableEntity, ScopeError, SecureEntityExt, SecureUpdateExt, secure_insert, +}; + +use crate::api::ProducerMode; +use crate::error::EventBrokerError; +use crate::ids::ProducerId; + +use super::types::ManagedDeduplication; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "event_broker_producer_registrations")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub registration_key: String, + pub producer_id: uuid::Uuid, + pub mode: String, + pub client_agent: String, + pub generation: i64, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} + +impl ScopableEntity for Entity { + const IS_UNRESTRICTED: bool = true; + + fn tenant_col() -> Option { + None + } + + fn resource_col() -> Option { + None + } + + fn owner_col() -> Option { + None + } + + fn type_col() -> Option { + None + } + + fn resolve_property(_property: &str) -> Option { + None + } +} + +#[derive(Debug, Clone)] +pub(crate) struct ProducerRegistration { + pub(crate) key: String, + pub(crate) producer_id: ProducerId, + pub(crate) mode: ProducerMode, + pub(crate) client_agent: String, + pub(crate) generation: i64, +} + +#[derive(Clone)] +pub(crate) struct ProducerRegistrationStore { + db: toolkit_db::Db, +} + +impl ProducerRegistrationStore { + pub(crate) fn new(db: toolkit_db::Db) -> Self { + Self { db } + } + + pub(crate) async fn load( + &self, + key: &str, + ) -> Result, EventBrokerError> { + let conn = self.db.conn().map_err(map_db_err)?; + let row = Entity::find() + .filter(Column::RegistrationKey.eq(key)) + .secure() + .scope_with(&AccessScope::allow_all()) + .one(&conn) + .await + .map_err(map_scope_err)?; + row.map(ProducerRegistration::try_from).transpose() + } + + pub(crate) async fn insert_new( + &self, + managed: &ManagedDeduplication, + producer_id: ProducerId, + client_agent: &str, + ) -> Result { + let now = now_string(); + let registration = ProducerRegistration { + key: managed.key.clone(), + producer_id, + mode: managed.mode, + client_agent: client_agent.to_owned(), + generation: 1, + }; + let am = ActiveModel { + registration_key: ActiveValue::Set(registration.key.clone()), + producer_id: ActiveValue::Set(registration.producer_id.0), + mode: ActiveValue::Set(mode_to_str(registration.mode).to_owned()), + client_agent: ActiveValue::Set(registration.client_agent.clone()), + generation: ActiveValue::Set(registration.generation), + created_at: ActiveValue::Set(now.clone()), + updated_at: ActiveValue::Set(now), + }; + let conn = self.db.conn().map_err(map_db_err)?; + secure_insert::(am, &AccessScope::allow_all(), &conn) + .await + .map_err(map_scope_err)?; + Ok(registration) + } + + pub(crate) async fn replace_with_new_generation( + &self, + current: &ProducerRegistration, + producer_id: ProducerId, + ) -> Result { + let generation = current.generation + 1; + let now = now_string(); + let conn = self.db.conn().map_err(map_db_err)?; + let result = Entity::update_many() + .col_expr( + Column::ProducerId, + sea_orm::sea_query::Expr::value(producer_id.0), + ) + .col_expr( + Column::Generation, + sea_orm::sea_query::Expr::value(generation), + ) + .col_expr(Column::UpdatedAt, sea_orm::sea_query::Expr::value(now)) + .filter(Column::RegistrationKey.eq(current.key.as_str())) + .secure() + .scope_with(&AccessScope::allow_all()) + .exec(&conn) + .await + .map_err(map_scope_err)?; + if result.rows_affected != 1 { + return Err(EventBrokerError::Internal(format!( + "producer registration '{}' was not updated", + current.key + ))); + } + Ok(ProducerRegistration { + key: current.key.clone(), + producer_id, + mode: current.mode, + client_agent: current.client_agent.clone(), + generation, + }) + } +} + +impl ProducerRegistration { + pub(crate) fn validate_matches( + &self, + managed: &ManagedDeduplication, + client_agent: &str, + ) -> Result<(), EventBrokerError> { + if self.mode != managed.mode { + return Err(EventBrokerError::InvalidProducerOptions { + detail: format!( + "managed producer registration '{}' has mode {}, expected {}", + self.key, + mode_to_str(self.mode), + mode_to_str(managed.mode) + ), + instance: String::new(), + }); + } + if self.client_agent != client_agent { + return Err(EventBrokerError::InvalidProducerOptions { + detail: format!( + "managed producer registration '{}' has client_agent '{}', expected '{}'", + self.key, self.client_agent, client_agent + ), + instance: String::new(), + }); + } + Ok(()) + } +} + +impl TryFrom for ProducerRegistration { + type Error = EventBrokerError; + + fn try_from(model: Model) -> Result { + Ok(Self { + key: model.registration_key, + producer_id: ProducerId(model.producer_id), + mode: mode_from_str(&model.mode)?, + client_agent: model.client_agent, + generation: model.generation, + }) + } +} + +pub(crate) fn mode_to_str(mode: ProducerMode) -> &'static str { + match mode { + ProducerMode::Stateless => "stateless", + ProducerMode::Monotonic => "monotonic", + ProducerMode::Chained => "chained", + } +} + +fn mode_from_str(mode: &str) -> Result { + match mode { + "stateless" => Ok(ProducerMode::Stateless), + "monotonic" => Ok(ProducerMode::Monotonic), + "chained" => Ok(ProducerMode::Chained), + other => Err(EventBrokerError::Internal(format!( + "unknown persisted producer mode '{other}'" + ))), + } +} + +fn now_string() -> String { + Utc::now().to_rfc3339() +} + +fn map_db_err(err: toolkit_db::DbError) -> EventBrokerError { + EventBrokerError::Internal(format!("producer registration db access: {err}")) +} + +fn map_scope_err(err: ScopeError) -> EventBrokerError { + EventBrokerError::Internal(format!("producer registration store: {err}")) +} diff --git a/gears/system/event-broker/event-broker-sdk/src/producer/schema_cache.rs b/gears/system/event-broker/event-broker-sdk/src/producer/schema_cache.rs new file mode 100644 index 000000000..a99c85f29 --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/src/producer/schema_cache.rs @@ -0,0 +1,242 @@ +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use toolkit_security::SecurityContext; + +use crate::api::EventBroker; +use crate::error::EventBrokerError; +use crate::models::EventType; + +#[derive(Clone)] +pub(crate) struct PreparedEventType { + pub(crate) type_id: String, + pub(crate) topic: String, + validator: Arc, +} + +impl PreparedEventType { + fn new(event_type: EventType) -> Result { + let validator = jsonschema::validator_for(&event_type.data_schema).map_err(|err| { + EventBrokerError::Internal(format!("compile schema for {}: {err}", event_type.id)) + })?; + Ok(Self { + type_id: event_type.id, + topic: event_type.topic, + validator: Arc::new(validator), + }) + } + + pub(crate) fn validate(&self, data: &serde_json::Value) -> Result<(), EventBrokerError> { + let errors = self + .validator + .iter_errors(data) + .map(|err| err.to_string()) + .collect::>(); + if errors.is_empty() { + Ok(()) + } else { + Err(EventBrokerError::EventDataInvalid { + type_id: self.type_id.clone(), + errors, + detail: "event payload failed schema validation".to_owned(), + instance: String::new(), + }) + } + } +} + +#[derive(Default)] +pub(crate) struct ProducerSchemaCache { + topics: tokio::sync::RwLock>, + event_types: tokio::sync::RwLock>, + resolved_type_ids: tokio::sync::RwLock>, +} + +impl ProducerSchemaCache { + pub(crate) async fn prepare_all( + &self, + broker: &Arc, + ctx: &SecurityContext, + topics: &[String], + patterns: &[String], + ) -> Result<(), EventBrokerError> { + self.prepare_topics(broker, ctx, topics).await?; + + let event_types = broker.list_event_types(ctx).await?; + let selected = event_types + .into_iter() + .filter(|event_type| { + patterns + .iter() + .any(|pattern| gts_pattern_matches(pattern, &event_type.id)) + }) + .collect::>(); + + if selected.is_empty() { + return Err(EventBrokerError::EventTypeUnknown { + type_id: patterns.join(","), + detail: "declared producer event type patterns matched zero event types".to_owned(), + instance: String::new(), + }); + } + + let declared_topics = topics.iter().cloned().collect::>(); + let mut cached = self.event_types.write().await; + let mut resolved = self.resolved_type_ids.write().await; + for event_type in selected { + if !declared_topics.contains(&event_type.topic) { + return Err(EventBrokerError::TypeNotInDeclaredTopic { + type_id: event_type.id, + expected_topic: topics.join(","), + detail: "resolved event type belongs to a topic not declared on producer" + .to_owned(), + instance: String::new(), + }); + } + let prepared = PreparedEventType::new(event_type)?; + resolved.insert(prepared.type_id.clone()); + cached.insert(prepared.type_id.clone(), prepared); + } + Ok(()) + } + + pub(crate) async fn prepare_one( + &self, + broker: &Arc, + ctx: &SecurityContext, + topics: &[String], + patterns: &[String], + type_id: &str, + ) -> Result<(), EventBrokerError> { + self.ensure_declared(patterns, type_id).await?; + self.prepare_topics(broker, ctx, topics).await?; + let event_type = broker.get_event_type(ctx, type_id).await?; + if !topics.iter().any(|topic| topic == &event_type.topic) { + return Err(EventBrokerError::TypeNotInDeclaredTopic { + type_id: type_id.to_owned(), + expected_topic: topics.join(","), + detail: "event type belongs to a topic not declared on producer".to_owned(), + instance: String::new(), + }); + } + let prepared = PreparedEventType::new(event_type)?; + self.resolved_type_ids + .write() + .await + .insert(type_id.to_owned()); + self.event_types + .write() + .await + .insert(type_id.to_owned(), prepared); + Ok(()) + } + + pub(crate) async fn ensure_declared( + &self, + patterns: &[String], + type_id: &str, + ) -> Result<(), EventBrokerError> { + if self.resolved_type_ids.read().await.contains(type_id) { + return Ok(()); + } + if patterns + .iter() + .any(|pattern| gts_pattern_matches(pattern, type_id)) + { + Ok(()) + } else { + Err(EventBrokerError::EventTypeNotDeclared { + type_id: type_id.to_owned(), + detail: "this event type does not match any declared event_type_patterns" + .to_owned(), + instance: String::new(), + }) + } + } + + pub(crate) async fn is_prepared(&self, type_id: &str) -> bool { + self.event_types.read().await.contains_key(type_id) + } + + pub(crate) async fn validate_prepared( + &self, + type_id: &str, + topic: &str, + data: &serde_json::Value, + ) -> Result<(), EventBrokerError> { + let prepared = self + .event_types + .read() + .await + .get(type_id) + .cloned() + .ok_or_else(|| EventBrokerError::SchemaNotPrepared { + type_id: type_id.to_owned(), + detail: "schema must be prepared before validating this event".to_owned(), + instance: String::new(), + })?; + if prepared.topic != topic { + return Err(EventBrokerError::TypeNotInDeclaredTopic { + type_id: type_id.to_owned(), + expected_topic: topic.to_owned(), + detail: format!("event type belongs to topic {}", prepared.topic), + instance: String::new(), + }); + } + prepared.validate(data) + } + + pub(crate) async fn partition_count(&self, topic: &str) -> Result { + self.topics.read().await.get(topic).copied().ok_or_else(|| { + EventBrokerError::TopicNotFound { + topic: topic.to_owned(), + detail: "topic was not prepared for this producer".to_owned(), + instance: String::new(), + } + }) + } + + async fn prepare_topics( + &self, + broker: &Arc, + ctx: &SecurityContext, + topics: &[String], + ) -> Result<(), EventBrokerError> { + let cached_topics = self.topics.read().await; + let missing = topics + .iter() + .filter(|topic| !cached_topics.contains_key(*topic)) + .count(); + drop(cached_topics); + if missing == 0 { + return Ok(()); + } + + let declared = topics.iter().cloned().collect::>(); + let remote = broker.list_topics(ctx).await?; + let mut cached = self.topics.write().await; + for topic in remote { + if declared.contains(&topic.id) { + cached.insert(topic.id, topic.partitions.max(1)); + } + } + for topic in topics { + if !cached.contains_key(topic) { + return Err(EventBrokerError::TopicNotFound { + topic: topic.clone(), + detail: "declared producer topic was not returned by Event Broker".to_owned(), + instance: String::new(), + }); + } + } + Ok(()) + } +} + +pub(crate) fn gts_pattern_matches(pattern: &str, type_id: &str) -> bool { + if let Some(prefix) = pattern.strip_suffix(".*") { + type_id.starts_with(prefix) + } else { + pattern == type_id + } +} diff --git a/gears/system/event-broker/event-broker-sdk/src/producer/types.rs b/gears/system/event-broker/event-broker-sdk/src/producer/types.rs new file mode 100644 index 000000000..bbf31b70c --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/src/producer/types.rs @@ -0,0 +1,231 @@ +use crate::api::ProducerMode; +use crate::ids::ProducerId; + +/// Controls when producer schemas are fetched. Validation is always enabled. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ValidationTiming { + #[default] + Eager, + Lazy, +} + +/// Event authoring and broker registration diagnostic identity. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProducerIdentity { + source: String, + client_agent: Option, +} + +impl ProducerIdentity { + #[must_use] + pub fn new() -> Self { + Self { + source: String::new(), + client_agent: None, + } + } + + #[must_use] + pub fn source(mut self, source: impl Into) -> Self { + self.source = source.into(); + self + } + + #[must_use] + pub fn client_agent(mut self, client_agent: impl Into) -> Self { + self.client_agent = Some(client_agent.into()); + self + } + + pub(crate) fn validate(&self) -> Result<(), crate::error::EventBrokerError> { + if self.source.trim().is_empty() { + return Err(crate::error::EventBrokerError::InvalidProducerOptions { + detail: "producer identity source is required".to_owned(), + instance: String::new(), + }); + } + Ok(()) + } + + pub fn source_ref(&self) -> &str { + &self.source + } + + pub fn client_agent_ref(&self) -> Option<&str> { + self.client_agent.as_deref() + } +} + +impl Default for ProducerIdentity { + fn default() -> Self { + Self::new() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DirectDeduplication { + Stateless, + RegisterOnStart { + mode: ProducerMode, + }, + Reuse { + mode: ProducerMode, + producer_id: ProducerId, + }, +} + +impl DirectDeduplication { + #[must_use] + pub fn stateless() -> Self { + Self::Stateless + } + + #[must_use] + pub fn register_on_start(mode: ProducerMode) -> Self { + Self::RegisterOnStart { mode } + } + + #[must_use] + pub fn reuse(mode: ProducerMode, producer_id: ProducerId) -> Self { + Self::Reuse { mode, producer_id } + } + + pub(crate) fn validate(&self) -> Result<(), crate::error::EventBrokerError> { + match *self { + Self::Stateless => Ok(()), + Self::RegisterOnStart { + mode: ProducerMode::Stateless, + } + | Self::Reuse { + mode: ProducerMode::Stateless, + .. + } => Err(crate::error::EventBrokerError::InvalidProducerOptions { + detail: "registration-backed deduplication requires monotonic or chained mode" + .to_owned(), + instance: String::new(), + }), + Self::RegisterOnStart { .. } | Self::Reuse { .. } => Ok(()), + } + } +} + +#[cfg(feature = "db")] +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DbDeduplication { + Stateless, + Managed(ManagedDeduplication), +} + +#[cfg(feature = "db")] +impl DbDeduplication { + #[must_use] + pub fn stateless() -> Self { + Self::Stateless + } + + #[must_use] + pub fn managed(mode: ProducerMode) -> ManagedDeduplicationBuilder { + ManagedDeduplicationBuilder { + mode, + key: None, + on_missing: MissingProducerRegistration::Fail, + on_unknown: UnknownProducerRegistration::Fail, + } + } + + pub(crate) fn mode(&self) -> ProducerMode { + match self { + Self::Stateless => ProducerMode::Stateless, + Self::Managed(managed) => managed.mode, + } + } +} + +#[cfg(feature = "db")] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ManagedDeduplication { + pub(crate) mode: ProducerMode, + pub(crate) key: String, + pub(crate) on_missing: MissingProducerRegistration, + pub(crate) on_unknown: UnknownProducerRegistration, +} + +#[cfg(feature = "db")] +pub struct ManagedDeduplicationBuilder { + mode: ProducerMode, + key: Option, + on_missing: MissingProducerRegistration, + on_unknown: UnknownProducerRegistration, +} + +#[cfg(feature = "db")] +impl ManagedDeduplicationBuilder { + #[must_use] + pub fn key(mut self, key: impl Into) -> Self { + self.key = Some(key.into()); + self + } + + #[must_use] + pub fn on_missing(mut self, policy: MissingProducerRegistration) -> Self { + self.on_missing = policy; + self + } + + #[must_use] + pub fn on_unknown(mut self, policy: UnknownProducerRegistration) -> Self { + self.on_unknown = policy; + self + } + + pub fn build(self) -> Result { + if self.mode == ProducerMode::Stateless { + return Err(crate::error::EventBrokerError::InvalidProducerOptions { + detail: "managed producer registration requires monotonic or chained mode" + .to_owned(), + instance: String::new(), + }); + } + let key = + self.key + .ok_or_else(|| crate::error::EventBrokerError::InvalidProducerOptions { + detail: "managed producer registration key is required".to_owned(), + instance: String::new(), + })?; + if key.trim().is_empty() { + return Err(crate::error::EventBrokerError::InvalidProducerOptions { + detail: "managed producer registration key must not be empty".to_owned(), + instance: String::new(), + }); + } + Ok(DbDeduplication::Managed(ManagedDeduplication { + mode: self.mode, + key, + on_missing: self.on_missing, + on_unknown: self.on_unknown, + })) + } +} + +#[cfg(feature = "db")] +impl From for DbDeduplication { + fn from(builder: ManagedDeduplicationBuilder) -> Self { + builder + .build() + .expect("managed producer deduplication must be complete") + } +} + +#[cfg(feature = "db")] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MissingProducerRegistration { + Fail, + RegisterNew, +} + +#[cfg(feature = "db")] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UnknownProducerRegistration { + Fail, + RegisterNew, +} diff --git a/gears/system/event-broker/event-broker-sdk/src/sdk.rs b/gears/system/event-broker/event-broker-sdk/src/sdk.rs new file mode 100644 index 000000000..c1a4f6510 --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/src/sdk.rs @@ -0,0 +1,10 @@ +pub struct EventBrokerSdk; + +impl EventBrokerSdk { + pub const DEFAULT_CLIENT_AGENT: &'static str = + concat!("event-broker-sdk/", env!("CARGO_PKG_VERSION")); + + pub fn default_client_agent() -> &'static str { + Self::DEFAULT_CLIENT_AGENT + } +} diff --git a/gears/system/event-broker/event-broker-sdk/src/typed_event.rs b/gears/system/event-broker/event-broker-sdk/src/typed_event.rs new file mode 100644 index 000000000..7faedcf5d --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/src/typed_event.rs @@ -0,0 +1,81 @@ +use std::borrow::Cow; + +use chrono::{DateTime, Utc}; +use serde::{Serialize, de::DeserializeOwned}; +use uuid::Uuid; + +/// Implement this on your own event struct to publish or consume it through the SDK. +/// +/// ```ignore +/// #[derive(Debug, Clone, Serialize, Deserialize)] +/// pub struct OrderCreated { +/// pub order_id: Uuid, +/// pub customer_id: Uuid, +/// pub total_cents: i64, +/// } +/// +/// impl TypedEvent for OrderCreated { +/// const TYPE_ID: &'static str = "gts.cf.core.events.event.v1~example.orders.created.v1"; +/// const TOPIC: &'static str = "gts.cf.core.events.topic.v1~example.orders.v1"; +/// const SUBJECT_TYPE: &'static str = "gts.cf.core.events.subject.v1~example.order.v1"; +/// const SOURCE: &'static str = "order-service"; +/// +/// fn subject(&self) -> Cow<'_, str> { +/// Cow::Owned(self.order_id.to_string()) +/// } +/// } +/// ``` +pub trait TypedEvent: Serialize + DeserializeOwned + Send + Sync + 'static { + const TYPE_ID: &'static str; + const TOPIC: &'static str; + const SUBJECT_TYPE: &'static str; + const SOURCE: &'static str; + + fn subject(&self) -> Cow<'_, str>; + + /// Returns the stable grouping identifier used for partition selection. + /// + /// Prefer an authenticated, normalized identifier whose representation is + /// controlled by the producer. Do not pass raw attacker-controlled + /// free-form values: MurmurHash3 is non-cryptographic, so adversarial keys + /// can deliberately hot-spot a partition. + fn partition_key(&self) -> Option> { + None + } + + /// Overrides the authenticated security-context tenant for this event. + /// + /// Explicit tenant IDs must pass broker authorization. Returning `None` + /// uses the authenticated tenant as the default partition input. + fn tenant_id(&self) -> Option { + None + } + + fn trace_parent(&self) -> Option> { + None + } +} + +/// Typed event envelope handed to v2 consumers. `Deref` lets callers +/// access payload fields directly while broker-stamped metadata remains accessible. +#[derive(Debug, Clone)] +pub struct EnvelopedEvent { + pub payload: E, + pub id: Uuid, + pub tenant_id: Uuid, + pub subject: String, + pub partition: u32, + pub sequence: i64, + pub offset: i64, + pub occurred_at: DateTime, + pub sequence_time: DateTime, + pub trace_parent: Option, +} + +impl std::ops::Deref for EnvelopedEvent { + type Target = E; + + fn deref(&self) -> &E { + &self.payload + } +} diff --git a/gears/system/event-broker/event-broker-sdk/tests/consumer.rs b/gears/system/event-broker/event-broker-sdk/tests/consumer.rs new file mode 100644 index 000000000..2e9fb85e4 --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/tests/consumer.rs @@ -0,0 +1,18 @@ +#![cfg(feature = "test-util")] + +mod consumer { + mod common; + + mod batch_handler; + mod builder; + mod custom_offset_store; + #[cfg(feature = "db")] + mod db_tx; + mod dlq; + mod in_memory; + mod offset_manager; + mod remote_calls; + mod routed_handlers; + mod single_handler; + mod slow_consumer; +} diff --git a/gears/system/event-broker/event-broker-sdk/tests/consumer/batch_handler.rs b/gears/system/event-broker/event-broker-sdk/tests/consumer/batch_handler.rs new file mode 100644 index 000000000..c859377da --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/tests/consumer/batch_handler.rs @@ -0,0 +1,83 @@ +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use async_trait::async_trait; +use event_broker_sdk::{ + BatchHandlerOutcome, ConsumerBatching, ConsumerBuilder, ConsumerError, ConsumerGroupRef, + ConsumerHandler, EventBatch, Fallback, InMemoryOffsetManager, +}; + +use super::common::{publish_json, topic_fixture, wait_until}; + +const TOPIC: &str = "gts.cf.core.events.topic.v1~example.mock.showcase.batch.v1"; +const EVENT_TYPE: &str = "gts.cf.core.events.event_type.v1~example.mock.showcase.batch.v1"; + +type RecordedBatch = (u32, Vec); +type RecordedBatches = Arc>>; + +struct BatchProjector { + batches: RecordedBatches, +} + +#[async_trait] +impl ConsumerHandler for BatchProjector { + async fn handle_batch( + &self, + batch: &EventBatch<'_>, + _attempts: u16, + ) -> Result { + let chunk = batch.next_chunk(batch.len()); + self.batches.lock().unwrap().push(( + chunk[0].partition, + chunk.iter().map(|event| event.offset).collect(), + )); + Ok(BatchHandlerOutcome::AdvanceThrough { + offset: chunk.last().expect("showcase batch is not empty").offset, + }) + } +} + +#[tokio::test] +async fn if_i_want_native_batches_they_stay_inside_one_partition() { + let fixture = topic_fixture(TOPIC, EVENT_TYPE, 2).await; + let batches = Arc::new(Mutex::new(Vec::new())); + + let handle = ConsumerBuilder::new(fixture.broker.clone()) + .group(ConsumerGroupRef::auto_anonymous("showcase-batch")) + .topics([TOPIC]) + .batching(ConsumerBatching { + max_events: 8, + max_wait: Duration::from_millis(10), + }) + .offset_manager(InMemoryOffsetManager::new(Fallback::Earliest)) + .batch_handler(BatchProjector { + batches: batches.clone(), + }) + .start() + .await + .expect("consumer starts"); + + for partition in [0, 1] { + publish_json( + &fixture.broker, + &fixture.ctx, + TOPIC, + EVENT_TYPE, + &format!("batch-{partition}"), + Some(partition), + serde_json::json!({ "partition": partition }), + ) + .await; + } + + wait_until(|| batches.lock().unwrap().len() >= 2).await; + handle.stop().await.expect("consumer stops"); + + assert!( + batches + .lock() + .unwrap() + .iter() + .all(|(_, offsets)| !offsets.is_empty()) + ); +} diff --git a/gears/system/event-broker/event-broker-sdk/tests/consumer/builder.rs b/gears/system/event-broker/event-broker-sdk/tests/consumer/builder.rs new file mode 100644 index 000000000..47eb26b80 --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/tests/consumer/builder.rs @@ -0,0 +1,57 @@ +//! Consumer builder and handle showcase tests. + +use std::sync::Arc; + +use event_broker_sdk::error::ConsumerError; +use event_broker_sdk::{ + Consumer, ConsumerBuilder, ConsumerGroupRef, Fallback, HandlerOutcome, InMemoryOffsetManager, + RawEvent, SingleEventHandler, +}; + +struct NoopHandler; + +#[async_trait::async_trait] +impl SingleEventHandler for NoopHandler { + async fn handle( + &self, + _event: RawEvent, + _attempts: u16, + ) -> Result { + Ok(HandlerOutcome::Success) + } +} + +#[test] +fn consumer_builder_chains_correctly() { + // Verify that the typestate builder compiles to the expected quadrant. + // This test doesn't run any async code — it just verifies the type-state + // machinery routes correctly at compile time. + let builder: ConsumerBuilder<()> = ConsumerBuilder::new_unbound() + .group(ConsumerGroupRef::auto_anonymous("test")) + .topics(["gts.cf.core.events.topic.v1~example.orders.v1"]) + .parallelism(3); + + let with_om = builder.offset_manager(InMemoryOffsetManager::new(Fallback::Earliest)); + let _ready = with_om.handler(NoopHandler); +} + +#[test] +fn async_commit_om_does_not_implement_commit_offset_in_tx() { + // Compile-time assertion: InMemoryOffsetManager implements CommitOffset (async) + // but NOT CommitOffsetInTx. The line below MUST NOT compile: + // + // let _: &dyn event_broker_sdk::CommitOffsetInTx = &InMemoryOffsetManager::new(Fallback::Earliest); + // + // The positive assertion (it implements CommitOffset) is tested here. + let _om: Arc = + Arc::new(InMemoryOffsetManager::new(Fallback::Earliest)); +} + +#[tokio::test] +async fn consumer_new_unbound_starts_empty() { + let consumer = Consumer::new(3); + // No slots were spawned; subscription_ids is empty. + assert!(consumer.subscription_ids().is_empty()); + // Shutdown on an unbound consumer is a no-op. + consumer.shutdown().await.unwrap(); +} diff --git a/gears/system/event-broker/event-broker-sdk/tests/consumer/common.rs b/gears/system/event-broker/event-broker-sdk/tests/consumer/common.rs new file mode 100644 index 000000000..9ae8a127f --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/tests/consumer/common.rs @@ -0,0 +1,132 @@ +use std::sync::Arc; +use std::time::Duration; + +use event_broker_sdk::mock::stubs::test_ctx_for_tenant; +use event_broker_sdk::mock::{MockBroker, MockBrokerHandle}; +use event_broker_sdk::{Event, EventBroker}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +pub const TENANT: &str = "00000000-0000-0000-0000-000000000001"; + +pub struct TopicFixture { + pub broker: Arc, + pub control: MockBrokerHandle, + pub ctx: SecurityContext, +} + +pub struct PublishJson<'a> { + pub broker: &'a Arc, + pub ctx: &'a SecurityContext, + pub topic: &'a str, + pub event_type: &'a str, + pub subject: &'a str, + pub partition_key: Option<&'a str>, + pub partition: Option, + pub data: serde_json::Value, +} + +pub async fn topic_fixture(topic: &str, event_type: &str, partitions: u32) -> TopicFixture { + let mock = MockBroker::new(); + let control = MockBrokerHandle::from_broker(&mock); + control.register_topic(topic, partitions).await; + control + .register_event_type( + topic, + event_type, + serde_json::json!({ "type": "object" }), + &[], + ) + .await; + control + .set_heartbeat_interval(Duration::from_millis(10)) + .await; + + TopicFixture { + broker: Arc::new(mock), + control, + ctx: test_ctx_for_tenant(Uuid::parse_str(TENANT).expect("tenant uuid")), + } +} + +pub async fn publish_json( + broker: &Arc, + ctx: &SecurityContext, + topic: &str, + event_type: &str, + subject: &str, + partition: Option, + data: serde_json::Value, +) { + publish_json_with_partition_key(PublishJson { + broker, + ctx, + topic, + event_type, + subject, + partition_key: None, + partition, + data, + }) + .await; +} + +pub async fn publish_json_with_partition_key(request: PublishJson<'_>) { + let PublishJson { + broker, + ctx, + topic, + event_type, + subject, + partition_key, + partition, + data, + } = request; + let resolved_partition_key = partition_key + .map(str::to_owned) + .or_else(|| partition.map(partition_key_for_two_partition_fixture)); + broker + .publish( + ctx, + &Event { + id: Uuid::new_v4(), + type_id: event_type.to_owned(), + topic: topic.to_owned(), + tenant_id: ctx.subject_tenant_id(), + source: "event-broker-sdk.consumer.showcase".to_owned(), + subject: subject.to_owned(), + subject_type: "showcase".to_owned(), + partition_key: resolved_partition_key, + occurred_at: chrono::Utc::now(), + trace_parent: None, + data: Some(data), + partition: None, + sequence: None, + sequence_time: None, + offset: None, + offset_time: None, + meta: None, + }, + ) + .await + .expect("event published"); +} + +fn partition_key_for_two_partition_fixture(target: u32) -> String { + match target { + 0 => "fixture-partition-key-0-0", + 1 => "fixture-partition-key-1-0", + _ => panic!("two-partition fixture cannot target partition {target}"), + } + .to_owned() +} + +pub async fn wait_until(mut predicate: impl FnMut() -> bool) { + for _ in 0..100 { + if predicate() { + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + panic!("condition was not observed before timeout"); +} diff --git a/gears/system/event-broker/event-broker-sdk/tests/consumer/custom_offset_store.rs b/gears/system/event-broker/event-broker-sdk/tests/consumer/custom_offset_store.rs new file mode 100644 index 000000000..4474fdc1e --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/tests/consumer/custom_offset_store.rs @@ -0,0 +1,106 @@ +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use async_trait::async_trait; +use event_broker_sdk::{ + CommitOffset, ConsumerBuilder, ConsumerCommitMode, ConsumerError, ConsumerGroupId, + ConsumerGroupRef, Fallback, HandlerOutcome, OffsetManagerError, OffsetStore, RawEvent, + ResolvedPosition, SingleEventHandler, TopicId, +}; + +use super::common::{publish_json, topic_fixture, wait_until}; + +const TOPIC: &str = "gts.cf.core.events.topic.v1~example.mock.showcase.customoffset.v1"; +const EVENT_TYPE: &str = "gts.cf.core.events.event_type.v1~example.mock.showcase.customoffset.v1"; + +type LoadCall = (ConsumerGroupId, TopicId, u32); +type CommitCall = (ConsumerGroupId, TopicId, u32, i64); +type RecordedLoads = Arc>>; +type RecordedCommits = Arc>>; + +#[derive(Clone)] +struct RecordingOffsetStore { + loads: RecordedLoads, + commits: RecordedCommits, +} + +#[async_trait] +impl OffsetStore for RecordingOffsetStore { + async fn load_position( + &self, + group: &ConsumerGroupId, + topic: &TopicId, + partition: u32, + ) -> Result { + self.loads.lock().unwrap().push((*group, *topic, partition)); + Ok(Fallback::Earliest.into()) + } +} + +#[async_trait] +impl CommitOffset for RecordingOffsetStore { + async fn commit( + &self, + group: &ConsumerGroupId, + topic: &TopicId, + partition: u32, + offset: i64, + ) -> Result<(), OffsetManagerError> { + self.commits + .lock() + .unwrap() + .push((*group, *topic, partition, offset)); + Ok(()) + } +} + +struct AckingHandler; + +#[async_trait] +impl SingleEventHandler for AckingHandler { + async fn handle( + &self, + _event: RawEvent, + _attempts: u16, + ) -> Result { + Ok(HandlerOutcome::Success) + } +} + +#[tokio::test] +async fn if_i_want_my_own_offset_store_i_implement_the_minimal_traits() { + let fixture = topic_fixture(TOPIC, EVENT_TYPE, 1).await; + let store = RecordingOffsetStore { + loads: Arc::new(Mutex::new(Vec::new())), + commits: Arc::new(Mutex::new(Vec::new())), + }; + let commits = store.commits.clone(); + + let handle = ConsumerBuilder::new(fixture.broker.clone()) + .group(ConsumerGroupRef::auto_anonymous("showcase-custom-offset")) + .topics([TOPIC]) + .commit_mode(ConsumerCommitMode::auto(Duration::from_millis(5))) + .offset_manager(store) + .handler(AckingHandler) + .start() + .await + .expect("consumer starts"); + + publish_json( + &fixture.broker, + &fixture.ctx, + TOPIC, + EVENT_TYPE, + "custom-offset-1", + None, + serde_json::json!({ "custom": true }), + ) + .await; + + wait_until(|| commits.lock().unwrap().iter().any(|commit| commit.3 >= 0)).await; + handle.stop().await.expect("consumer stops"); + + let committed = commits.lock().unwrap(); + assert_eq!(committed[0].2, 0); + assert!(committed.iter().any(|commit| commit.3 >= 0)); +} diff --git a/gears/system/event-broker/event-broker-sdk/tests/consumer/db_tx.rs b/gears/system/event-broker/event-broker-sdk/tests/consumer/db_tx.rs new file mode 100644 index 000000000..ea10b9e0f --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/tests/consumer/db_tx.rs @@ -0,0 +1,469 @@ +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use async_trait::async_trait; +use chrono::Utc; +use event_broker_sdk::consumer::LOCAL_DB_OFFSET_STORE_MIGRATION_SQL; +use event_broker_sdk::dlq::DeadLetterRecord; +use event_broker_sdk::{ + CommitOffsetInTx, ConsumerBatching, ConsumerBuilder, ConsumerError, ConsumerGroupRef, + EventBatch, EventBrokerError, Fallback, HandlerOutcome, LocalDbOffsetManager, OffsetStore, + RawEvent, ResolvedPosition, TxCommitHandle, TxConsumerHandler, TxSingleEventHandler, +}; +use sea_orm::{ConnectionTrait, Database, EntityTrait, PaginatorTrait, Set, Statement}; +use toolkit_db::secure::{AccessScope, SecureInsertExt}; +use uuid::Uuid; + +use super::common::{publish_json, topic_fixture, wait_until}; + +const TX_SINGLE_TOPIC: &str = "gts.cf.core.events.topic.v1~example.mock.showcase.txsingle.v1"; +const TX_SINGLE_EVENT: &str = "gts.cf.core.events.event_type.v1~example.mock.showcase.txsingle.v1"; +const TX_SINGLE_GROUP: &str = + "gts.cf.core.events.consumer_group.v1~example.mock.showcase.txsingle.v1"; +const TX_BATCH_TOPIC: &str = "gts.cf.core.events.topic.v1~example.mock.showcase.txbatch.v1"; +const TX_BATCH_EVENT: &str = "gts.cf.core.events.event_type.v1~example.mock.showcase.txbatch.v1"; +const TX_BATCH_GROUP: &str = + "gts.cf.core.events.consumer_group.v1~example.mock.showcase.txbatch.v1"; + +mod dlq_row { + use sea_orm::entity::prelude::*; + + #[derive(Clone, Debug, PartialEq, DeriveEntityModel)] + #[sea_orm(table_name = "showcase_dead_letters")] + pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub event_id: Uuid, + pub topic: String, + pub event_type: String, + pub partition: i32, + pub offset: i64, + pub reason: String, + pub payload: String, + pub occurred_at: DateTimeUtc, + } + + #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] + pub enum Relation {} + + impl ActiveModelBehavior for ActiveModel {} + + impl toolkit_db::secure::ScopableEntity for Entity { + const IS_UNRESTRICTED: bool = true; + + fn tenant_col() -> Option { + None + } + + fn resource_col() -> Option { + None + } + + fn owner_col() -> Option { + None + } + + fn type_col() -> Option { + None + } + + fn resolve_property(_property: &str) -> Option { + None + } + } +} + +struct TxSingleProjector { + db: toolkit_db::Db, + committed_offsets: Arc>>, +} + +#[async_trait] +impl TxSingleEventHandler for TxSingleProjector { + async fn handle( + &self, + event: RawEvent, + _attempts: u16, + commit: TxCommitHandle, + ) -> Result { + let db = self.db.clone(); + let offset = event.offset; + db.transaction_ref(move |tx| { + Box::pin(async move { + commit + .commit_offset_in_tx(tx, offset) + .await + .map_err(|err| toolkit_db::DbError::InvalidConfig(err.to_string()))?; + Ok(()) + }) + }) + .await + .map_err(|err| EventBrokerError::Internal(err.to_string()))?; + + self.committed_offsets.lock().unwrap().push(offset); + Ok(HandlerOutcome::Success) + } +} + +struct TxBatchProjector { + db: toolkit_db::Db, + committed_offsets: Arc>>, +} + +#[async_trait] +impl TxConsumerHandler for TxBatchProjector { + async fn handle_batch( + &self, + batch: &EventBatch<'_>, + _attempts: u16, + commit: TxCommitHandle, + ) -> Result { + let target_offset = batch + .iter() + .last() + .ok_or_else(|| EventBrokerError::Internal("empty transactional batch".to_owned()))? + .offset; + let db = self.db.clone(); + + db.transaction_ref(move |tx| { + Box::pin(async move { + commit + .commit_offset_in_tx(tx, target_offset) + .await + .map_err(|err| toolkit_db::DbError::InvalidConfig(err.to_string()))?; + Ok(()) + }) + }) + .await + .map_err(|err| EventBrokerError::Internal(err.to_string()))?; + + self.committed_offsets.lock().unwrap().push(target_offset); + Ok(HandlerOutcome::Success) + } +} + +static DB_SEQ: AtomicU64 = AtomicU64::new(1); + +async fn db_with_showcase_tables() -> (sea_orm::DatabaseConnection, toolkit_db::Db) { + let seq = DB_SEQ.fetch_add(1, Ordering::Relaxed); + let dsn = format!("sqlite:file:evbk_showcase_db_tx_{seq}?mode=memory&cache=shared"); + let raw = Database::connect(&dsn).await.expect("raw sqlite connect"); + let backend = raw.get_database_backend(); + raw.execute(Statement::from_string( + backend, + LOCAL_DB_OFFSET_STORE_MIGRATION_SQL.to_owned(), + )) + .await + .expect("offset table"); + raw.execute(Statement::from_string( + backend, + r#" +CREATE TABLE IF NOT EXISTS showcase_dead_letters ( + event_id UUID NOT NULL PRIMARY KEY, + topic TEXT NOT NULL, + event_type TEXT NOT NULL, + partition INTEGER NOT NULL, + offset BIGINT NOT NULL, + reason TEXT NOT NULL, + payload TEXT NOT NULL, + occurred_at TIMESTAMP NOT NULL +); +"# + .to_owned(), + )) + .await + .expect("dead-letter table"); + let db = toolkit_db::connect_db( + &dsn, + toolkit_db::ConnectOpts { + max_conns: Some(1), + ..toolkit_db::ConnectOpts::default() + }, + ) + .await + .expect("toolkit db"); + + (raw, db) +} + +fn raw_event_for_dead_letter() -> event_broker_sdk::RawEvent { + event_broker_sdk::RawEvent { + id: Uuid::new_v4(), + type_id: "gts.cf.core.events.event_type.v1~example.showcase.db.dlq.v1".to_owned(), + topic: "gts.cf.core.events.topic.v1~example.showcase.db.dlq.v1".to_owned(), + tenant_id: Uuid::nil(), + subject: "db-dlq-1".to_owned(), + subject_type: "test".to_owned(), + partition_key: Some("db-dlq-1".to_owned()), + partition: 0, + sequence: 42, + offset: 42, + occurred_at: Utc::now(), + sequence_time: Utc::now(), + trace_parent: None, + data: serde_json::json!({ "reject": true }), + } +} + +async fn insert_dead_letter( + tx: &TX, + record: &DeadLetterRecord, +) -> Result<(), toolkit_db::DbError> +where + TX: toolkit_db::secure::DBRunner + Sync, +{ + dlq_row::Entity::insert(dlq_row::ActiveModel { + event_id: Set(record.event_id), + topic: Set(record.topic.clone()), + event_type: Set(record.event_type.clone()), + partition: Set(record.partition as i32), + offset: Set(record.offset), + reason: Set(record.reason.clone()), + payload: Set(record.payload.to_string()), + occurred_at: Set(record.occurred_at), + }) + .secure() + .scope_unchecked(&AccessScope::allow_all()) + .map_err(|err| toolkit_db::DbError::InvalidConfig(err.to_string()))? + .exec(tx) + .await + .map_err(|err| toolkit_db::DbError::InvalidConfig(err.to_string()))?; + Ok(()) +} + +async fn dead_letter_count(conn: &sea_orm::DatabaseConnection) -> u64 { + dlq_row::Entity::find() + .count(conn) + .await + .expect("count dead-letter rows") +} + +#[tokio::test] +async fn if_i_want_transactional_single_event_handling_i_commit_the_delivered_offset_in_my_tx() { + let fixture = topic_fixture(TX_SINGLE_TOPIC, TX_SINGLE_EVENT, 1).await; + let (_raw, db) = db_with_showcase_tables().await; + fixture.control.register_named_group(TX_SINGLE_GROUP).await; + let group = event_broker_sdk::ConsumerGroupId::from_gts(TX_SINGLE_GROUP); + let topic = event_broker_sdk::TopicId::from_gts(TX_SINGLE_TOPIC); + let committed_offsets = Arc::new(Mutex::new(Vec::new())); + let assertion_manager = LocalDbOffsetManager::new(db.clone(), Fallback::Earliest); + + let handle = ConsumerBuilder::new(fixture.broker.clone()) + .group(ConsumerGroupRef::existing(group)) + .topics([TX_SINGLE_TOPIC]) + .offset_manager(LocalDbOffsetManager::new(db.clone(), Fallback::Earliest)) + .handler(TxSingleProjector { + db: db.clone(), + committed_offsets: committed_offsets.clone(), + }) + .start() + .await + .expect("transactional single consumer starts"); + + publish_json( + &fixture.broker, + &fixture.ctx, + TX_SINGLE_TOPIC, + TX_SINGLE_EVENT, + "tx-single-1", + None, + serde_json::json!({ "tx": "single" }), + ) + .await; + + wait_until(|| committed_offsets.lock().unwrap().len() == 1).await; + handle.stop().await.expect("consumer stops"); + + let committed = committed_offsets.lock().unwrap()[0]; + assert_eq!( + assertion_manager + .load_position(&group, &topic, 0) + .await + .unwrap(), + ResolvedPosition::Exact(committed) + ); +} + +#[tokio::test] +async fn if_i_want_transactional_batch_handling_i_commit_the_last_handled_offset_in_my_tx() { + let fixture = topic_fixture(TX_BATCH_TOPIC, TX_BATCH_EVENT, 1).await; + let (_raw, db) = db_with_showcase_tables().await; + fixture.control.register_named_group(TX_BATCH_GROUP).await; + let group = event_broker_sdk::ConsumerGroupId::from_gts(TX_BATCH_GROUP); + let topic = event_broker_sdk::TopicId::from_gts(TX_BATCH_TOPIC); + let committed_offsets = Arc::new(Mutex::new(Vec::new())); + let assertion_manager = LocalDbOffsetManager::new(db.clone(), Fallback::Earliest); + + let handle = ConsumerBuilder::new(fixture.broker.clone()) + .group(ConsumerGroupRef::existing(group)) + .topics([TX_BATCH_TOPIC]) + .batching(ConsumerBatching { + max_events: 8, + max_wait: Duration::from_millis(20), + }) + .offset_manager(LocalDbOffsetManager::new(db.clone(), Fallback::Earliest)) + .batch_handler(TxBatchProjector { + db: db.clone(), + committed_offsets: committed_offsets.clone(), + }) + .start() + .await + .expect("transactional batch consumer starts"); + + for subject in ["tx-batch-1", "tx-batch-2"] { + publish_json( + &fixture.broker, + &fixture.ctx, + TX_BATCH_TOPIC, + TX_BATCH_EVENT, + subject, + Some(0), + serde_json::json!({ "tx": "batch", "subject": subject }), + ) + .await; + } + + wait_until(|| !committed_offsets.lock().unwrap().is_empty()).await; + handle.stop().await.expect("consumer stops"); + + let committed = *committed_offsets.lock().unwrap().last().unwrap(); + assert_eq!( + assertion_manager + .load_position(&group, &topic, 0) + .await + .unwrap(), + ResolvedPosition::Exact(committed) + ); +} + +#[tokio::test] +async fn if_i_want_db_transactional_progress_i_write_business_rows_and_offset_together() { + let (_raw, db) = db_with_showcase_tables().await; + let manager = Arc::new(LocalDbOffsetManager::new(db.clone(), Fallback::Earliest)); + let group = event_broker_sdk::ConsumerGroupId::from_gts("showcase-db-tx"); + let topic = event_broker_sdk::TopicId::from_gts("showcase-db-tx-topic"); + let tx_manager = manager.clone(); + + db.transaction_ref(move |tx| { + Box::pin(async move { + // Application business writes should use secure repositories with + // this same `tx`; the offset save joins that transaction. + tx_manager + .commit_in_tx(tx, &group, &topic, 0, 41) + .await + .map_err(|err| toolkit_db::DbError::InvalidConfig(err.to_string()))?; + Ok(()) + }) + }) + .await + .expect("transaction commits"); + + assert_eq!( + manager.load_position(&group, &topic, 0).await.unwrap(), + ResolvedPosition::Exact(41) + ); +} + +#[tokio::test] +async fn if_the_business_transaction_rolls_back_the_offset_rolls_back_too() { + let (_raw, db) = db_with_showcase_tables().await; + let manager = Arc::new(LocalDbOffsetManager::new(db.clone(), Fallback::Earliest)); + let group = event_broker_sdk::ConsumerGroupId::from_gts("showcase-db-tx-rollback"); + let topic = event_broker_sdk::TopicId::from_gts("showcase-db-tx-rollback-topic"); + let tx_manager = manager.clone(); + + let result: Result<(), toolkit_db::DbError> = db + .transaction_ref(move |tx| { + Box::pin(async move { + tx_manager + .commit_in_tx(tx, &group, &topic, 0, 77) + .await + .map_err(|err| toolkit_db::DbError::InvalidConfig(err.to_string()))?; + Err(toolkit_db::DbError::InvalidConfig( + "force rollback".to_owned(), + )) + }) + }) + .await; + + assert!(result.is_err()); + assert_eq!( + manager.load_position(&group, &topic, 0).await.unwrap(), + ResolvedPosition::Earliest + ); +} + +#[tokio::test] +async fn if_i_want_transactional_dlq_i_write_the_record_and_offset_in_one_transaction() { + let (raw, db) = db_with_showcase_tables().await; + let manager = Arc::new(LocalDbOffsetManager::new(db.clone(), Fallback::Earliest)); + let group = event_broker_sdk::ConsumerGroupId::from_gts("showcase-db-dlq"); + let topic = event_broker_sdk::TopicId::from_gts("showcase-db-dlq-topic"); + let tx_manager = manager.clone(); + let event = raw_event_for_dead_letter(); + let record = DeadLetterRecord::builder(&event, "permanent validation failure") + .group_id(group) + .attempts(6) + .build(); + + db.transaction_ref(move |tx| { + Box::pin(async move { + insert_dead_letter(tx, &record).await?; + tx_manager + .commit_in_tx(tx, &group, &topic, event.partition, event.offset) + .await + .map_err(|err| toolkit_db::DbError::InvalidConfig(err.to_string()))?; + Ok(()) + }) + }) + .await + .expect("transaction commits"); + + assert_eq!(dead_letter_count(&raw).await, 1); + assert_eq!( + manager + .load_position(&group, &topic, event.partition) + .await + .unwrap(), + ResolvedPosition::Exact(event.offset) + ); +} + +#[tokio::test] +async fn if_the_transactional_dlq_rolls_back_neither_parking_nor_offset_is_durable() { + let (raw, db) = db_with_showcase_tables().await; + let manager = Arc::new(LocalDbOffsetManager::new(db.clone(), Fallback::Earliest)); + let group = event_broker_sdk::ConsumerGroupId::from_gts("showcase-db-dlq-rollback"); + let topic = event_broker_sdk::TopicId::from_gts("showcase-db-dlq-rollback-topic"); + let tx_manager = manager.clone(); + let event = raw_event_for_dead_letter(); + let record = DeadLetterRecord::builder(&event, "permanent validation failure") + .group_id(group) + .attempts(6) + .build(); + + let result: Result<(), toolkit_db::DbError> = db + .transaction_ref(move |tx| { + Box::pin(async move { + insert_dead_letter(tx, &record).await?; + tx_manager + .commit_in_tx(tx, &group, &topic, event.partition, event.offset) + .await + .map_err(|err| toolkit_db::DbError::InvalidConfig(err.to_string()))?; + Err(toolkit_db::DbError::InvalidConfig( + "force rollback".to_owned(), + )) + }) + }) + .await; + + assert!(result.is_err()); + assert_eq!(dead_letter_count(&raw).await, 0); + assert_eq!( + manager + .load_position(&group, &topic, event.partition) + .await + .unwrap(), + ResolvedPosition::Earliest + ); +} diff --git a/gears/system/event-broker/event-broker-sdk/tests/consumer/dlq/handler_owned.rs b/gears/system/event-broker/event-broker-sdk/tests/consumer/dlq/handler_owned.rs new file mode 100644 index 000000000..318735c6a --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/tests/consumer/dlq/handler_owned.rs @@ -0,0 +1,139 @@ +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use chrono::Utc; +use event_broker_sdk::dlq::{DeadLetterRecord, DeadLetterSink}; +use event_broker_sdk::{ + ConsumerBuilder, ConsumerError, ConsumerGroupRef, EventBrokerError, Fallback, HandlerOutcome, + InMemoryOffsetManager, RawEvent, SingleEventHandler, +}; +use uuid::Uuid; + +use crate::consumer::common::{publish_json, topic_fixture, wait_until}; + +const TOPIC: &str = "gts.cf.core.events.topic.v1~example.mock.showcase.dlq.v1"; +const EVENT_TYPE: &str = "gts.cf.core.events.event_type.v1~example.mock.showcase.dlq.v1"; + +#[derive(Clone, Default)] +struct RecordingDeadLetterSink { + records: Arc>>, + fail: bool, +} + +fn rejected_event() -> RawEvent { + RawEvent { + id: Uuid::new_v4(), + type_id: EVENT_TYPE.to_owned(), + topic: TOPIC.to_owned(), + tenant_id: Uuid::nil(), + subject: "dlq-1".to_owned(), + subject_type: "test".to_owned(), + partition_key: Some("dlq-1".to_owned()), + partition: 0, + sequence: 1, + offset: 1, + occurred_at: Utc::now(), + sequence_time: Utc::now(), + trace_parent: None, + data: serde_json::json!({ "reject": true }), + } +} + +#[async_trait] +impl DeadLetterSink for RecordingDeadLetterSink { + async fn park(&self, record: DeadLetterRecord) -> Result<(), ConsumerError> { + if self.fail { + return Err(EventBrokerError::Internal( + "dead-letter sink unavailable".to_owned(), + )); + } + self.records.lock().unwrap().push(record); + Ok(()) + } +} + +struct HandlerOwnedDlqPolicy { + sink: RecordingDeadLetterSink, +} + +impl HandlerOwnedDlqPolicy { + async fn park_permanent_failure( + &self, + event: &RawEvent, + attempts: u16, + ) -> Result<(), ConsumerError> { + let record = DeadLetterRecord::builder(event, "showcase reject") + .attempts(attempts) + .build(); + self.sink.park(record).await + } +} + +#[async_trait] +impl SingleEventHandler for HandlerOwnedDlqPolicy { + async fn handle( + &self, + event: RawEvent, + attempts: u16, + ) -> Result { + if event.data.get("reject").and_then(|value| value.as_bool()) == Some(true) { + self.park_permanent_failure(&event, attempts).await?; + return Ok(HandlerOutcome::Success); + } + + Ok(HandlerOutcome::Success) + } +} + +#[tokio::test] +async fn if_i_want_permanent_failures_parked_i_do_it_in_the_handler() { + let fixture = topic_fixture(TOPIC, EVENT_TYPE, 1).await; + let sink = RecordingDeadLetterSink::default(); + let records = sink.records.clone(); + + let handle = ConsumerBuilder::new(fixture.broker.clone()) + .group(ConsumerGroupRef::auto_anonymous("showcase-dlq")) + .topics([TOPIC]) + .offset_manager(InMemoryOffsetManager::new(Fallback::Earliest)) + .handler(HandlerOwnedDlqPolicy { sink }) + .start() + .await + .expect("consumer starts"); + + publish_json( + &fixture.broker, + &fixture.ctx, + TOPIC, + EVENT_TYPE, + "dlq-1", + None, + serde_json::json!({ "reject": true }), + ) + .await; + + wait_until(|| records.lock().unwrap().len() == 1).await; + handle.stop().await.expect("consumer stops"); + + let records = records.lock().unwrap(); + assert_eq!(records[0].reason, "showcase reject"); + assert_eq!(records[0].payload, serde_json::json!({ "reject": true })); + assert_eq!(records[0].topic, TOPIC); +} + +#[tokio::test] +async fn if_my_dead_letter_sink_fails_i_return_an_error_and_the_source_offset_is_not_successful() { + let sink = RecordingDeadLetterSink { + fail: true, + ..RecordingDeadLetterSink::default() + }; + let records = sink.records.clone(); + let handler = HandlerOwnedDlqPolicy { sink }; + + let err = handler + .park_permanent_failure(&rejected_event(), 6) + .await + .expect_err("failed parking must keep the handler from returning success"); + + assert!(err.to_string().contains("dead-letter sink unavailable")); + assert!(records.lock().unwrap().is_empty()); +} diff --git a/gears/system/event-broker/event-broker-sdk/tests/consumer/dlq/mod.rs b/gears/system/event-broker/event-broker-sdk/tests/consumer/dlq/mod.rs new file mode 100644 index 000000000..8ed721aa6 --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/tests/consumer/dlq/mod.rs @@ -0,0 +1,4 @@ +mod handler_owned; + +#[cfg(feature = "outbox")] +mod outbox_backed; diff --git a/gears/system/event-broker/event-broker-sdk/tests/consumer/dlq/outbox_backed.rs b/gears/system/event-broker/event-broker-sdk/tests/consumer/dlq/outbox_backed.rs new file mode 100644 index 000000000..c1a5ca0ee --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/tests/consumer/dlq/outbox_backed.rs @@ -0,0 +1,373 @@ +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use async_trait::async_trait; +use chrono::Utc; +use event_broker_sdk::consumer::LOCAL_DB_OFFSET_STORE_MIGRATION_SQL; +use event_broker_sdk::dlq::{ConsumerDlqOutbox, DeadLetterEnvelope, DeadLetterRecord}; +use event_broker_sdk::{ + CommitOffsetInTx, ConsumerGroupId, Fallback, LocalDbOffsetManager, OffsetStore, RawEvent, + ResolvedPosition, TopicId, +}; +use sea_orm::{ConnectionTrait, Database, Statement}; +use toolkit_db::outbox::{ + LeasedMessageHandler, MessageResult, Outbox, OutboxHandle, OutboxMessage, Partitions, +}; +use uuid::Uuid; + +use crate::consumer::common::wait_until; + +const DLQ_QUEUE: &str = "showcase-consumer-dlq"; +const DLQ_PARTITIONS: u32 = 4; +const TOPIC_GTS: &str = "gts.cf.core.events.topic.v1~example.showcase.outbox.dlq.v1"; +const EVENT_TYPE_GTS: &str = "gts.cf.core.events.event_type.v1~example.showcase.outbox.dlq.v1"; +const GROUP_GTS: &str = "gts.cf.core.events.consumer_group.v1~example.showcase.outbox.dlq.v1"; + +static DB_SEQ: AtomicU64 = AtomicU64::new(1); + +#[derive(Clone, Default)] +struct RecordingDlqProcessor { + envelopes: Arc>>, +} + +#[async_trait] +impl LeasedMessageHandler for RecordingDlqProcessor { + async fn handle(&self, msg: &OutboxMessage) -> MessageResult { + match DeadLetterEnvelope::from_slice(&msg.payload) { + Ok(envelope) => { + self.envelopes.lock().unwrap().push(envelope); + MessageResult::Ok + } + Err(err) => MessageResult::Reject(err.to_string()), + } + } +} + +struct DlqOutboxFixture { + raw: sea_orm::DatabaseConnection, + db: toolkit_db::Db, + handle: OutboxHandle, + envelopes: Arc>>, +} + +impl DlqOutboxFixture { + fn helper(&self) -> ConsumerDlqOutbox { + ConsumerDlqOutbox::builder(Arc::clone(self.handle.outbox())) + .queue(DLQ_QUEUE) + .partitions(DLQ_PARTITIONS) + .build() + } + + async fn stop(self) { + self.handle.stop().await; + drop(self.raw); + drop(self.db); + } +} + +async fn fixture() -> DlqOutboxFixture { + let seq = DB_SEQ.fetch_add(1, Ordering::Relaxed); + let dsn = format!("sqlite:file:evbk_showcase_outbox_dlq_{seq}?mode=memory&cache=shared"); + let raw = Database::connect(&dsn).await.expect("raw sqlite connect"); + let backend = raw.get_database_backend(); + raw.execute(Statement::from_string( + backend, + LOCAL_DB_OFFSET_STORE_MIGRATION_SQL.to_owned(), + )) + .await + .expect("offset table"); + + let db = toolkit_db::connect_db( + &dsn, + toolkit_db::ConnectOpts { + max_conns: Some(1), + ..toolkit_db::ConnectOpts::default() + }, + ) + .await + .expect("toolkit db"); + toolkit_db::migration_runner::run_migrations_for_testing( + &db, + toolkit_db::outbox::outbox_migrations(), + ) + .await + .expect("outbox migrations"); + + let processor = RecordingDlqProcessor::default(); + let envelopes = processor.envelopes.clone(); + let handle = Outbox::builder(db.clone()) + .queue(DLQ_QUEUE, Partitions::of(DLQ_PARTITIONS as u16)) + .leased(processor) + .start() + .await + .expect("outbox starts"); + + DlqOutboxFixture { + raw, + db, + handle, + envelopes, + } +} + +fn rejected_event(offset: i64) -> RawEvent { + RawEvent { + id: Uuid::new_v4(), + type_id: EVENT_TYPE_GTS.to_owned(), + topic: TOPIC_GTS.to_owned(), + tenant_id: Uuid::nil(), + subject: format!("dlq-order-{offset}"), + subject_type: "order".to_owned(), + partition_key: Some(format!("dlq-order-{offset}")), + partition: 0, + sequence: offset, + offset, + occurred_at: Utc::now(), + sequence_time: Utc::now(), + trace_parent: None, + data: serde_json::json!({ "reject": true, "offset": offset }), + } +} + +fn dead_letter(event: &RawEvent, group: ConsumerGroupId) -> DeadLetterRecord { + DeadLetterRecord::builder(event, "permanent validation failure") + .group_id(group) + .topic_id(TopicId::from_gts(&event.topic)) + .attempts(6) + .build() +} + +fn coordinates() -> (ConsumerGroupId, TopicId) { + ( + ConsumerGroupId::from_gts(GROUP_GTS), + TopicId::from_gts(TOPIC_GTS), + ) +} + +async fn load_position( + manager: &LocalDbOffsetManager, + group: &ConsumerGroupId, + topic: &TopicId, + partition: u32, +) -> ResolvedPosition { + manager + .load_position(group, topic, partition) + .await + .expect("load offset") +} + +async fn wait_for_envelope(envelopes: &Arc>>) -> DeadLetterEnvelope { + wait_until(|| !envelopes.lock().unwrap().is_empty()).await; + envelopes.lock().unwrap()[0].clone() +} + +#[tokio::test] +async fn if_i_want_a_single_queue_dlq_i_enqueue_to_the_consumer_dlq_outbox() { + let fixture = fixture().await; + let helper = fixture.helper(); + let (group, topic) = coordinates(); + let event = rejected_event(10); + let record = dead_letter(&event, group); + let conn = fixture.db.conn().expect("db conn"); + + helper + .enqueue(&conn, record) + .await + .expect("dead-letter record enqueued"); + fixture.handle.outbox().flush(); + + let envelope = wait_for_envelope(&fixture.envelopes).await; + assert_eq!(envelope.group_id, Some(group)); + assert_eq!(envelope.topic_id, Some(topic)); + assert_eq!(envelope.topic, TOPIC_GTS); + assert_eq!(envelope.subject, event.subject); + assert_eq!(envelope.subject_type, event.subject_type); + assert_eq!(envelope.payload, event.data); + assert_eq!(envelope.reason, "permanent validation failure"); + + fixture.stop().await; +} + +#[tokio::test] +async fn if_i_want_transactional_dlq_i_enqueue_and_commit_offset_in_the_same_tx() { + let fixture = fixture().await; + let helper = fixture.helper(); + let manager = Arc::new(LocalDbOffsetManager::new( + fixture.db.clone(), + Fallback::Earliest, + )); + let (group, topic) = coordinates(); + let event = rejected_event(20); + let record = dead_letter(&event, group); + let tx_manager = Arc::clone(&manager); + + fixture + .db + .transaction_ref(|tx| { + Box::pin(async move { + helper + .enqueue(tx, record) + .await + .map_err(|err| toolkit_db::DbError::InvalidConfig(err.to_string()))?; + tx_manager + .commit_in_tx(tx, &group, &topic, event.partition, event.offset) + .await + .map_err(|err| toolkit_db::DbError::InvalidConfig(err.to_string()))?; + Ok(()) + }) + }) + .await + .expect("transaction commits"); + fixture.handle.outbox().flush(); + + let envelope = wait_for_envelope(&fixture.envelopes).await; + assert_eq!(envelope.offset, event.offset); + assert_eq!( + load_position(&manager, &group, &topic, event.partition).await, + ResolvedPosition::Exact(event.offset) + ); + + fixture.stop().await; +} + +#[tokio::test] +async fn if_the_dlq_transaction_rolls_back_neither_handoff_nor_offset_is_durable() { + let fixture = fixture().await; + let helper = fixture.helper(); + let manager = Arc::new(LocalDbOffsetManager::new( + fixture.db.clone(), + Fallback::Earliest, + )); + let (group, topic) = coordinates(); + let event = rejected_event(30); + let record = dead_letter(&event, group); + let tx_manager = Arc::clone(&manager); + + let result: Result<(), toolkit_db::DbError> = fixture + .db + .transaction_ref(|tx| { + Box::pin(async move { + helper + .enqueue(tx, record) + .await + .map_err(|err| toolkit_db::DbError::InvalidConfig(err.to_string()))?; + tx_manager + .commit_in_tx(tx, &group, &topic, event.partition, event.offset) + .await + .map_err(|err| toolkit_db::DbError::InvalidConfig(err.to_string()))?; + Err(toolkit_db::DbError::InvalidConfig( + "force rollback".to_owned(), + )) + }) + }) + .await; + + assert!(result.is_err()); + fixture.handle.outbox().flush(); + tokio::time::sleep(Duration::from_millis(50)).await; + assert!(fixture.envelopes.lock().unwrap().is_empty()); + assert_eq!( + load_position(&manager, &group, &topic, event.partition).await, + ResolvedPosition::Earliest + ); + + fixture.stop().await; +} + +#[tokio::test] +async fn if_the_main_transaction_rolled_back_i_open_a_new_tx_for_dlq_and_offset_skip() { + let fixture = fixture().await; + let helper = fixture.helper(); + let manager = Arc::new(LocalDbOffsetManager::new( + fixture.db.clone(), + Fallback::Earliest, + )); + let (group, topic) = coordinates(); + let event = rejected_event(40); + let record = dead_letter(&event, group); + + let business_result: Result<(), toolkit_db::DbError> = fixture + .db + .transaction_ref(|_tx| { + Box::pin(async move { + Err(toolkit_db::DbError::InvalidConfig( + "business rollback".to_owned(), + )) + }) + }) + .await; + assert!(business_result.is_err()); + + let tx_manager = Arc::clone(&manager); + fixture + .db + .transaction_ref(|tx| { + Box::pin(async move { + helper + .enqueue(tx, record) + .await + .map_err(|err| toolkit_db::DbError::InvalidConfig(err.to_string()))?; + tx_manager + .commit_in_tx(tx, &group, &topic, event.partition, event.offset) + .await + .map_err(|err| toolkit_db::DbError::InvalidConfig(err.to_string()))?; + Ok(()) + }) + }) + .await + .expect("dlq transaction commits"); + fixture.handle.outbox().flush(); + + let envelope = wait_for_envelope(&fixture.envelopes).await; + assert_eq!(envelope.offset, event.offset); + assert_eq!( + load_position(&manager, &group, &topic, event.partition).await, + ResolvedPosition::Exact(event.offset) + ); + + fixture.stop().await; +} + +#[tokio::test] +async fn if_dlq_handoff_fails_i_do_not_commit_the_source_offset() { + let fixture = fixture().await; + let broken_helper = ConsumerDlqOutbox::builder(Arc::clone(fixture.handle.outbox())) + .queue("missing-dlq-queue") + .partitions(DLQ_PARTITIONS) + .build(); + let manager = Arc::new(LocalDbOffsetManager::new( + fixture.db.clone(), + Fallback::Earliest, + )); + let (group, topic) = coordinates(); + let event = rejected_event(50); + let record = dead_letter(&event, group); + let tx_manager = Arc::clone(&manager); + + let result: Result<(), toolkit_db::DbError> = fixture + .db + .transaction_ref(|tx| { + Box::pin(async move { + broken_helper + .enqueue(tx, record) + .await + .map_err(|err| toolkit_db::DbError::InvalidConfig(err.to_string()))?; + tx_manager + .commit_in_tx(tx, &group, &topic, event.partition, event.offset) + .await + .map_err(|err| toolkit_db::DbError::InvalidConfig(err.to_string()))?; + Ok(()) + }) + }) + .await; + + assert!(result.is_err()); + assert!(fixture.envelopes.lock().unwrap().is_empty()); + assert_eq!( + load_position(&manager, &group, &topic, event.partition).await, + ResolvedPosition::Earliest + ); + + fixture.stop().await; +} diff --git a/gears/system/event-broker/event-broker-sdk/tests/consumer/in_memory.rs b/gears/system/event-broker/event-broker-sdk/tests/consumer/in_memory.rs new file mode 100644 index 000000000..45fcb4ec6 --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/tests/consumer/in_memory.rs @@ -0,0 +1,65 @@ +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use event_broker_sdk::{ + ConsumerBuilder, ConsumerError, ConsumerGroupRef, EventTypeRef, Fallback, HandlerOutcome, + InMemoryOffsetManager, RawEvent, SingleEventHandler, SubscriptionInterest, TopicRef, +}; + +use super::common::{publish_json, topic_fixture, wait_until}; + +const TOPIC: &str = "gts.cf.core.events.topic.v1~example.mock.showcase.inmemory.v1"; +const EVENT_TYPE: &str = "gts.cf.core.events.event_type.v1~example.mock.showcase.inmemory.v1"; + +struct RecordingHandler { + subjects: Arc>>, +} + +#[async_trait] +impl SingleEventHandler for RecordingHandler { + async fn handle( + &self, + event: RawEvent, + _attempts: u16, + ) -> Result { + self.subjects.lock().unwrap().push(event.subject); + Ok(HandlerOutcome::Success) + } +} + +#[tokio::test] +async fn if_i_want_at_least_once_consumption_with_in_memory_offsets() { + let fixture = topic_fixture(TOPIC, EVENT_TYPE, 1).await; + let subjects = Arc::new(Mutex::new(Vec::new())); + + let handle = ConsumerBuilder::new(fixture.broker.clone()) + .group(ConsumerGroupRef::auto_anonymous("showcase-in-memory")) + .subscription_interests([SubscriptionInterest::builder() + .topic(TopicRef::gts(TOPIC)) + .types([EventTypeRef::gts(EVENT_TYPE)]) + .build() + .expect("topic-scoped interest")]) + .offset_manager(InMemoryOffsetManager::new(Fallback::Earliest)) + .handler(RecordingHandler { + subjects: subjects.clone(), + }) + .start() + .await + .expect("consumer starts"); + + publish_json( + &fixture.broker, + &fixture.ctx, + TOPIC, + EVENT_TYPE, + "order-1", + None, + serde_json::json!({ "ok": true }), + ) + .await; + + wait_until(|| subjects.lock().unwrap().len() == 1).await; + handle.stop().await.expect("consumer stops"); + + assert_eq!(subjects.lock().unwrap().as_slice(), ["order-1"]); +} diff --git a/gears/system/event-broker/event-broker-sdk/tests/consumer/offset_manager.rs b/gears/system/event-broker/event-broker-sdk/tests/consumer/offset_manager.rs new file mode 100644 index 000000000..f8974e104 --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/tests/consumer/offset_manager.rs @@ -0,0 +1,87 @@ +//! Unit tests for offset-store built-in implementations. +use event_broker_sdk::{ + CommitOffset, ConsumerGroupId, Fallback, InMemoryOffsetManager, OffsetStore, ResolvedPosition, + TopicId, +}; + +fn group() -> ConsumerGroupId { + ConsumerGroupId::new(uuid::Uuid::new_v4()) +} + +fn topic() -> TopicId { + TopicId::from_gts("gts.cf.core.events.topic.v1~example.orders.v1") +} + +#[tokio::test] +async fn in_memory_position_fresh_returns_fallback() { + let om = InMemoryOffsetManager::new(Fallback::Earliest); + let g = group(); + let t = topic(); + assert_eq!( + om.load_position(&g, &t, 0).await.unwrap(), + ResolvedPosition::Earliest + ); +} + +#[tokio::test] +async fn in_memory_position_returns_stored_value_verbatim() { + let om = InMemoryOffsetManager::new(Fallback::Latest); + let g = group(); + let t = topic(); + om.commit(&g, &t, 0, 42).await.unwrap(); + // Exact carries the last-processed offset verbatim (no +1 on the SDK side). + assert_eq!( + om.load_position(&g, &t, 0).await.unwrap(), + ResolvedPosition::Exact(42) + ); +} + +#[tokio::test] +async fn in_memory_commit_applies_max_semantics() { + let om = InMemoryOffsetManager::new(Fallback::Earliest); + let g = group(); + let t = topic(); + om.commit(&g, &t, 1, 100).await.unwrap(); + om.commit(&g, &t, 1, 50).await.unwrap(); + assert_eq!( + om.load_position(&g, &t, 1).await.unwrap(), + ResolvedPosition::Exact(100) + ); + om.commit(&g, &t, 1, 200).await.unwrap(); + assert_eq!( + om.load_position(&g, &t, 1).await.unwrap(), + ResolvedPosition::Exact(200) + ); +} + +#[tokio::test] +async fn in_memory_overrides_apply_only_when_no_committed_cursor() { + let om = InMemoryOffsetManager::new(Fallback::Latest).with_overrides([((topic(), 0), 17)]); + let g = group(); + let t = topic(); + + // Override applies when no committed cursor. + assert_eq!( + om.load_position(&g, &t, 0).await.unwrap(), + ResolvedPosition::Exact(17) + ); + + // Committed cursor wins over override. + om.commit(&g, &t, 0, 100).await.unwrap(); + assert_eq!( + om.load_position(&g, &t, 0).await.unwrap(), + ResolvedPosition::Exact(100) + ); +} + +#[tokio::test] +async fn in_memory_override_miss_falls_back_to_fallback() { + let om = InMemoryOffsetManager::new(Fallback::Latest).with_overrides([((topic(), 0), 17)]); + let g = group(); + let t = topic(); + // Partition not in overrides → falls back to Fallback::Latest. + assert_eq!( + om.load_position(&g, &t, 9).await.unwrap(), + ResolvedPosition::Latest + ); +} diff --git a/gears/system/event-broker/event-broker-sdk/tests/consumer/remote_calls.rs b/gears/system/event-broker/event-broker-sdk/tests/consumer/remote_calls.rs new file mode 100644 index 000000000..3d898979d --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/tests/consumer/remote_calls.rs @@ -0,0 +1,79 @@ +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use event_broker_sdk::{ + ConsumerBuilder, ConsumerError, ConsumerGroupRef, Fallback, HandlerOutcome, + InMemoryOffsetManager, RawEvent, SingleEventHandler, +}; + +use super::common::{publish_json, topic_fixture, wait_until}; + +const TOPIC: &str = "gts.cf.core.events.topic.v1~example.mock.showcase.remote.v1"; +const EVENT_TYPE: &str = "gts.cf.core.events.event_type.v1~example.mock.showcase.remote.v1"; + +type RemoteCall = (String, &'static str); +type RecordedRemoteCalls = Arc>>; + +#[derive(Clone)] +struct FakeRemoteClient { + token: &'static str, + calls: RecordedRemoteCalls, +} + +struct ForwardingHandler { + remote: FakeRemoteClient, +} + +#[async_trait] +impl SingleEventHandler for ForwardingHandler { + async fn handle( + &self, + event: RawEvent, + _attempts: u16, + ) -> Result { + self.remote + .calls + .lock() + .unwrap() + .push((event.subject, self.remote.token)); + Ok(HandlerOutcome::Success) + } +} + +#[tokio::test] +async fn if_i_need_remote_calls_the_handler_owns_its_client_and_auth() { + let fixture = topic_fixture(TOPIC, EVENT_TYPE, 1).await; + let calls = Arc::new(Mutex::new(Vec::new())); + let remote = FakeRemoteClient { + token: "service-token", + calls: calls.clone(), + }; + + let handle = ConsumerBuilder::new(fixture.broker.clone()) + .group(ConsumerGroupRef::auto_anonymous("showcase-remote-calls")) + .topics([TOPIC]) + .offset_manager(InMemoryOffsetManager::new(Fallback::Earliest)) + .handler(ForwardingHandler { remote }) + .start() + .await + .expect("consumer starts"); + + publish_json( + &fixture.broker, + &fixture.ctx, + TOPIC, + EVENT_TYPE, + "remote-1", + None, + serde_json::json!({ "forward": true }), + ) + .await; + + wait_until(|| calls.lock().unwrap().len() == 1).await; + handle.stop().await.expect("consumer stops"); + + assert_eq!( + calls.lock().unwrap().as_slice(), + &[("remote-1".to_owned(), "service-token")] + ); +} diff --git a/gears/system/event-broker/event-broker-sdk/tests/consumer/routed_handlers.rs b/gears/system/event-broker/event-broker-sdk/tests/consumer/routed_handlers.rs new file mode 100644 index 000000000..f0094b0ab --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/tests/consumer/routed_handlers.rs @@ -0,0 +1,85 @@ +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use event_broker_sdk::{ + ConsumerBuilder, ConsumerError, ConsumerGroupRef, EventTypeRef, Fallback, HandlerOutcome, + InMemoryOffsetManager, RawEvent, SingleEventHandler, TopicRef, +}; + +use super::common::{publish_json, topic_fixture, wait_until}; + +const TOPIC: &str = "gts.cf.core.events.topic.v1~example.mock.showcase.routed.v1"; +const CREATED: &str = "gts.cf.core.events.event_type.v1~example.mock.routed.created.v1"; +const UPDATED: &str = "gts.cf.core.events.event_type.v1~example.mock.routed.updated.v1"; + +struct NamedHandler { + name: &'static str, + calls: Arc>>, +} + +#[async_trait] +impl SingleEventHandler for NamedHandler { + async fn handle( + &self, + _event: RawEvent, + _attempts: u16, + ) -> Result { + self.calls.lock().unwrap().push(self.name); + Ok(HandlerOutcome::Success) + } +} + +#[tokio::test] +async fn if_i_need_topic_type_routing_i_can_register_specific_and_default_handlers() { + let fixture = topic_fixture(TOPIC, CREATED, 1).await; + fixture + .control + .register_event_type(TOPIC, UPDATED, serde_json::json!({ "type": "object" }), &[]) + .await; + + let calls = Arc::new(Mutex::new(Vec::new())); + let handle = ConsumerBuilder::new(fixture.broker.clone()) + .group(ConsumerGroupRef::auto_anonymous("showcase-routed")) + .topics([TOPIC]) + .offset_manager(InMemoryOffsetManager::new(Fallback::Earliest)) + .default_handler(NamedHandler { + name: "default", + calls: calls.clone(), + }) + .route() + .topic(TopicRef::gts(TOPIC)) + .event_type(EventTypeRef::gts(CREATED)) + .handler(NamedHandler { + name: "created", + calls: calls.clone(), + }) + .start() + .await + .expect("consumer starts"); + + publish_json( + &fixture.broker, + &fixture.ctx, + TOPIC, + CREATED, + "created-1", + None, + serde_json::json!({ "route": "created" }), + ) + .await; + publish_json( + &fixture.broker, + &fixture.ctx, + TOPIC, + UPDATED, + "updated-1", + None, + serde_json::json!({ "route": "default" }), + ) + .await; + + wait_until(|| calls.lock().unwrap().len() == 2).await; + handle.stop().await.expect("consumer stops"); + + assert_eq!(calls.lock().unwrap().as_slice(), ["created", "default"]); +} diff --git a/gears/system/event-broker/event-broker-sdk/tests/consumer/single_handler.rs b/gears/system/event-broker/event-broker-sdk/tests/consumer/single_handler.rs new file mode 100644 index 000000000..bbf245a08 --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/tests/consumer/single_handler.rs @@ -0,0 +1,110 @@ +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use event_broker_sdk::{ + ConsumerBuilder, ConsumerError, ConsumerGroupRef, Fallback, HandlerOutcome, + InMemoryOffsetManager, RawEvent, SingleEventHandler, +}; + +use super::common::{PublishJson, publish_json, topic_fixture, wait_until}; + +const TOPIC: &str = "gts.cf.core.events.topic.v1~example.mock.showcase.single.v1"; +const EVENT_TYPE: &str = "gts.cf.core.events.event_type.v1~example.mock.showcase.single.v1"; + +struct SingleEventProjector { + offsets: Arc>>, + partition_keys: Arc>>>, +} + +#[async_trait] +impl SingleEventHandler for SingleEventProjector { + async fn handle( + &self, + event: RawEvent, + _attempts: u16, + ) -> Result { + self.partition_keys + .lock() + .unwrap() + .push(event.partition_key.clone()); + self.offsets.lock().unwrap().push(event.offset); + Ok(HandlerOutcome::Success) + } +} + +#[tokio::test] +async fn if_i_want_a_simple_single_event_handler() { + let fixture = topic_fixture(TOPIC, EVENT_TYPE, 1).await; + let offsets = Arc::new(Mutex::new(Vec::new())); + let partition_keys = Arc::new(Mutex::new(Vec::new())); + + let handle = ConsumerBuilder::new(fixture.broker.clone()) + .group(ConsumerGroupRef::auto_anonymous("showcase-single")) + .topics([TOPIC]) + .offset_manager(InMemoryOffsetManager::new(Fallback::Earliest)) + .handler(SingleEventProjector { + offsets: offsets.clone(), + partition_keys: partition_keys.clone(), + }) + .start() + .await + .expect("consumer starts"); + + publish_json( + &fixture.broker, + &fixture.ctx, + TOPIC, + EVENT_TYPE, + "single-1", + None, + serde_json::json!({ "kind": "single" }), + ) + .await; + + wait_until(|| offsets.lock().unwrap().len() == 1).await; + handle.stop().await.expect("consumer stops"); + + assert_eq!(offsets.lock().unwrap().len(), 1); + assert_eq!(partition_keys.lock().unwrap()[0], None); +} + +#[tokio::test] +async fn if_i_publish_with_a_partition_key_the_handler_can_inspect_it() { + let fixture = topic_fixture(TOPIC, EVENT_TYPE, 1).await; + let offsets = Arc::new(Mutex::new(Vec::new())); + let partition_keys = Arc::new(Mutex::new(Vec::new())); + + let handle = ConsumerBuilder::new(fixture.broker.clone()) + .group(ConsumerGroupRef::auto_anonymous( + "showcase-single-partition-key", + )) + .topics([TOPIC]) + .offset_manager(InMemoryOffsetManager::new(Fallback::Earliest)) + .handler(SingleEventProjector { + offsets: offsets.clone(), + partition_keys: partition_keys.clone(), + }) + .start() + .await + .expect("consumer starts"); + + super::common::publish_json_with_partition_key(PublishJson { + broker: &fixture.broker, + ctx: &fixture.ctx, + topic: TOPIC, + event_type: EVENT_TYPE, + subject: "single-1", + partition_key: Some("tenant-a/order-1"), + partition: None, + data: serde_json::json!({ "kind": "single" }), + }) + .await; + + wait_until(|| offsets.lock().unwrap().len() == 1).await; + handle.stop().await.expect("consumer stops"); + + assert_eq!( + partition_keys.lock().unwrap()[0].as_deref(), + Some("tenant-a/order-1") + ); +} diff --git a/gears/system/event-broker/event-broker-sdk/tests/consumer/slow_consumer.rs b/gears/system/event-broker/event-broker-sdk/tests/consumer/slow_consumer.rs new file mode 100644 index 000000000..84ef7342f --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/tests/consumer/slow_consumer.rs @@ -0,0 +1,325 @@ +use std::collections::BTreeSet; +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use event_broker_sdk::mock::stubs::test_ctx_for_tenant; +use event_broker_sdk::mock::{MockBroker, MockBrokerHandle}; +use event_broker_sdk::{ + BatchHandlerOutcome, CommitOffset, ConnectionDropReason, ConsumerBuffering, ConsumerBuilder, + ConsumerError, ConsumerGroupId, ConsumerGroupRef, ConsumerHandler, ConsumerRuntimeEvent, + ConsumerRuntimeListener, EventBatch, EventBroker, Fallback, InMemoryOffsetManager, + OffsetManagerError, OffsetStore, ResolvedPosition, TopicId, +}; +use uuid::Uuid; + +use super::common::{TENANT, publish_json, wait_until}; + +const SLOW_TOPIC: &str = "gts.cf.core.events.topic.v1~example.mock.showcase.slow.v1"; +const SLOW_EVENT_TYPE: &str = "gts.cf.core.events.event_type.v1~example.mock.showcase.slow.v1"; +const FAST_TOPIC: &str = "gts.cf.core.events.topic.v1~example.mock.showcase.fast.v1"; +const FAST_EVENT_TYPE: &str = "gts.cf.core.events.event_type.v1~example.mock.showcase.fast.v1"; + +#[derive(Clone, Default)] +struct RecordingRuntimeListener { + events: Arc>>, +} + +#[async_trait] +impl ConsumerRuntimeListener for RecordingRuntimeListener { + async fn on_consumer_event(&self, event: &ConsumerRuntimeEvent) -> Result<(), ConsumerError> { + self.events.lock().unwrap().push(event.clone()); + Ok(()) + } +} + +#[derive(Clone)] +struct SequencedOffsetManager { + timeline: Arc>>, +} + +#[async_trait] +impl OffsetStore for SequencedOffsetManager { + async fn load_position( + &self, + _group: &ConsumerGroupId, + _topic: &TopicId, + _partition: u32, + ) -> Result { + self.timeline.lock().unwrap().push("load"); + Ok(ResolvedPosition::Earliest) + } +} + +#[async_trait] +impl CommitOffset for SequencedOffsetManager { + async fn commit( + &self, + _group: &ConsumerGroupId, + _topic: &TopicId, + _partition: u32, + _offset: i64, + ) -> Result<(), OffsetManagerError> { + Ok(()) + } +} + +struct SequencedBatchHandler { + timeline: Arc>>, +} + +#[async_trait] +impl ConsumerHandler for SequencedBatchHandler { + async fn handle_batch( + &self, + _batch: &EventBatch<'_>, + _attempts: u16, + ) -> Result { + self.timeline.lock().unwrap().push("handle"); + Ok(BatchHandlerOutcome::Success) + } +} + +struct RecordingBatchHandler { + subjects: Arc>>, +} + +#[async_trait] +impl ConsumerHandler for RecordingBatchHandler { + async fn handle_batch( + &self, + batch: &EventBatch<'_>, + _attempts: u16, + ) -> Result { + let chunk = batch.next_chunk(batch.len()); + self.subjects + .lock() + .unwrap() + .extend(chunk.iter().map(|event| event.subject.clone())); + Ok(chunk + .last() + .map(|event| BatchHandlerOutcome::AdvanceThrough { + offset: event.offset, + }) + .unwrap_or(BatchHandlerOutcome::Success)) + } +} + +async fn broker_with_slow_and_fast_topics() -> ( + Arc, + MockBrokerHandle, + toolkit_security::SecurityContext, +) { + let mock = MockBroker::new(); + let control = MockBrokerHandle::from_broker(&mock); + control.register_topic(SLOW_TOPIC, 2).await; + control + .register_event_type( + SLOW_TOPIC, + SLOW_EVENT_TYPE, + serde_json::json!({ "type": "object" }), + &[], + ) + .await; + control.register_topic(FAST_TOPIC, 1).await; + control + .register_event_type( + FAST_TOPIC, + FAST_EVENT_TYPE, + serde_json::json!({ "type": "object" }), + &[], + ) + .await; + control + .set_heartbeat_interval(std::time::Duration::from_millis(10)) + .await; + + ( + Arc::new(mock), + control, + test_ctx_for_tenant(Uuid::parse_str(TENANT).expect("tenant uuid")), + ) +} + +#[tokio::test] +async fn if_a_partition_is_slow_the_sdk_drops_drains_and_rejoins_from_offsets() { + let (broker, _control, ctx) = broker_with_slow_and_fast_topics().await; + let listener = RecordingRuntimeListener::default(); + let events = listener.events.clone(); + let timeline = Arc::new(Mutex::new(Vec::new())); + + let handle = ConsumerBuilder::new(broker.clone()) + .group(ConsumerGroupRef::auto_anonymous("showcase-slow-drop")) + .topics([SLOW_TOPIC]) + .buffering(ConsumerBuffering { + partition_capacity: 8, + high_watermark: 1, + low_watermark: 0, + }) + .register_listener(listener) + .offset_manager(SequencedOffsetManager { + timeline: timeline.clone(), + }) + .batch_handler(SequencedBatchHandler { + timeline: timeline.clone(), + }) + .start() + .await + .expect("consumer starts"); + + wait_until(|| handle.subscription_ids().len() == 1).await; + publish_json( + &broker, + &ctx, + SLOW_TOPIC, + SLOW_EVENT_TYPE, + "slow-0", + Some(0), + serde_json::json!({ "slow": true }), + ) + .await; + + wait_until(|| { + let observed = timeline.lock().unwrap(); + let Some(first_handle) = observed.iter().position(|entry| *entry == "handle") else { + return false; + }; + observed + .iter() + .skip(first_handle + 1) + .any(|entry| *entry == "load") + }) + .await; + handle.stop().await.expect("consumer stops"); + + let observed = timeline.lock().unwrap().clone(); + let first_load = observed + .iter() + .position(|entry| *entry == "load") + .expect("initial offset load"); + let first_handle = observed + .iter() + .position(|entry| *entry == "handle") + .expect("drained handler call"); + let rejoin_load = observed + .iter() + .enumerate() + .skip(first_handle + 1) + .find_map(|(idx, entry)| (*entry == "load").then_some(idx)) + .expect("offset load after rejoin"); + assert!(first_load < first_handle && first_handle < rejoin_load); + + let affected = events + .lock() + .unwrap() + .iter() + .find_map(|event| { + if let ConsumerRuntimeEvent::SubscriptionConnectionDropped { + reason: ConnectionDropReason::SlowConsumer { topic, .. }, + affected, + .. + } = event + { + (topic == SLOW_TOPIC).then(|| { + affected + .iter() + .map(|slot| (slot.topic.clone(), slot.partition)) + .collect::>() + }) + } else { + None + } + }) + .expect("slow-consumer connection drop event"); + assert_eq!( + affected, + BTreeSet::from([(SLOW_TOPIC.to_owned(), 0), (SLOW_TOPIC.to_owned(), 1)]) + ); +} + +#[tokio::test] +async fn if_a_topic_is_noisy_i_can_isolate_it_in_a_separate_consumer_handle() { + let (broker, _control, ctx) = broker_with_slow_and_fast_topics().await; + let slow_listener = RecordingRuntimeListener::default(); + let slow_events = slow_listener.events.clone(); + let slow_timeline = Arc::new(Mutex::new(Vec::new())); + let fast_subjects = Arc::new(Mutex::new(Vec::new())); + + let slow_handle = ConsumerBuilder::new(broker.clone()) + .group(ConsumerGroupRef::auto_anonymous("showcase-noisy-slow")) + .topics([SLOW_TOPIC]) + .buffering(ConsumerBuffering { + partition_capacity: 8, + high_watermark: 1, + low_watermark: 0, + }) + .register_listener(slow_listener) + .offset_manager(SequencedOffsetManager { + timeline: slow_timeline.clone(), + }) + .batch_handler(SequencedBatchHandler { + timeline: slow_timeline, + }) + .start() + .await + .expect("slow consumer starts"); + + let fast_handle = ConsumerBuilder::new(broker.clone()) + .group(ConsumerGroupRef::auto_anonymous("showcase-noisy-fast")) + .topics([FAST_TOPIC]) + .offset_manager(InMemoryOffsetManager::new(Fallback::Earliest)) + .batch_handler(RecordingBatchHandler { + subjects: fast_subjects.clone(), + }) + .start() + .await + .expect("fast consumer starts"); + + wait_until(|| { + slow_handle.subscription_ids().len() == 1 && fast_handle.subscription_ids().len() == 1 + }) + .await; + + publish_json( + &broker, + &ctx, + SLOW_TOPIC, + SLOW_EVENT_TYPE, + "slow-noisy", + Some(0), + serde_json::json!({ "slow": true }), + ) + .await; + publish_json( + &broker, + &ctx, + FAST_TOPIC, + FAST_EVENT_TYPE, + "fast-independent", + Some(0), + serde_json::json!({ "fast": true }), + ) + .await; + + wait_until(|| fast_subjects.lock().unwrap().as_slice() == ["fast-independent"]).await; + wait_until(|| { + slow_events.lock().unwrap().iter().any(|event| { + matches!( + event, + ConsumerRuntimeEvent::SubscriptionConnectionDropped { + reason: ConnectionDropReason::SlowConsumer { topic, .. }, + .. + } if topic == SLOW_TOPIC + ) + }) + }) + .await; + + slow_handle.stop().await.expect("slow consumer stops"); + fast_handle.stop().await.expect("fast consumer stops"); + + assert_eq!( + fast_subjects.lock().unwrap().as_slice(), + ["fast-independent"], + "a noisy topic in its own handle must not stop the independent topic handle" + ); +} diff --git a/gears/system/event-broker/event-broker-sdk/tests/producer.rs b/gears/system/event-broker/event-broker-sdk/tests/producer.rs new file mode 100644 index 000000000..3e55101dd --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/tests/producer.rs @@ -0,0 +1,8 @@ +#[path = "producer/builder.rs"] +mod builder; +#[cfg(feature = "test-util")] +#[path = "producer/direct.rs"] +mod direct; +#[cfg(all(feature = "db", feature = "outbox", feature = "test-util"))] +#[path = "producer/outbox.rs"] +mod outbox; diff --git a/gears/system/event-broker/event-broker-sdk/tests/producer/builder.rs b/gears/system/event-broker/event-broker-sdk/tests/producer/builder.rs new file mode 100644 index 000000000..95a857927 --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/tests/producer/builder.rs @@ -0,0 +1,105 @@ +use uuid::Uuid; + +use event_broker_sdk::{ + DirectDeduplication, EventBrokerError, ProducerIdentity, ProducerMode, ValidationTiming, +}; + +#[test] +fn producer_identity_requires_source() { + let err = ProducerIdentity::new().validate_pub_for_test().unwrap_err(); + assert!(matches!( + err, + EventBrokerError::InvalidProducerOptions { .. } + )); +} + +#[test] +fn producer_identity_accepts_source_and_agent() { + let identity = ProducerIdentity::new() + .source("order-service") + .client_agent("order-service/1.0"); + + assert_eq!(identity.source_ref(), "order-service"); + assert_eq!(identity.client_agent_ref(), Some("order-service/1.0")); +} + +#[test] +fn direct_deduplication_rejects_stateless_registration_modes() { + let pid = event_broker_sdk::ProducerId(Uuid::new_v4()); + + let register = DirectDeduplication::register_on_start(ProducerMode::Stateless); + let reuse = DirectDeduplication::reuse(ProducerMode::Stateless, pid); + + assert!(matches!( + register.validate_pub_for_test(), + Err(EventBrokerError::InvalidProducerOptions { .. }) + )); + assert!(matches!( + reuse.validate_pub_for_test(), + Err(EventBrokerError::InvalidProducerOptions { .. }) + )); +} + +#[test] +fn default_validation_is_eager() { + assert_eq!(ValidationTiming::default(), ValidationTiming::Eager); +} + +#[test] +fn typestate_builder_compile_failures_are_checked() { + let tests = trybuild::TestCases::new(); + tests.compile_fail("tests/trybuild/producer/missing_direct_broker.rs"); + + #[cfg(feature = "db")] + { + tests.compile_fail("tests/trybuild/producer/direct_rejects_db_dedup.rs"); + tests.compile_fail("tests/trybuild/producer/db_rejects_direct_dedup.rs"); + } +} + +trait ProducerIdentityTestExt { + fn validate_pub_for_test(&self) -> Result<(), EventBrokerError>; +} + +impl ProducerIdentityTestExt for ProducerIdentity { + fn validate_pub_for_test(&self) -> Result<(), EventBrokerError> { + if self.source_ref().trim().is_empty() { + Err(EventBrokerError::InvalidProducerOptions { + detail: "producer identity source is required".to_owned(), + instance: String::new(), + }) + } else { + Ok(()) + } + } +} + +trait DirectDeduplicationTestExt { + fn validate_pub_for_test(&self) -> Result<(), EventBrokerError>; +} + +impl DirectDeduplicationTestExt for DirectDeduplication { + fn validate_pub_for_test(&self) -> Result<(), EventBrokerError> { + match *self { + DirectDeduplication::Stateless + | DirectDeduplication::RegisterOnStart { + mode: ProducerMode::Monotonic | ProducerMode::Chained, + } + | DirectDeduplication::Reuse { + mode: ProducerMode::Monotonic | ProducerMode::Chained, + .. + } => Ok(()), + DirectDeduplication::RegisterOnStart { + mode: ProducerMode::Stateless, + } + | DirectDeduplication::Reuse { + mode: ProducerMode::Stateless, + .. + } => Err(EventBrokerError::InvalidProducerOptions { + detail: "registration-backed deduplication requires monotonic or chained mode" + .to_owned(), + instance: String::new(), + }), + } + } +} diff --git a/gears/system/event-broker/event-broker-sdk/tests/producer/direct.rs b/gears/system/event-broker/event-broker-sdk/tests/producer/direct.rs new file mode 100644 index 000000000..768c6768b --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/tests/producer/direct.rs @@ -0,0 +1,233 @@ +use std::borrow::Cow; +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use event_broker_sdk::api::EventBroker; +use event_broker_sdk::mock::MockBroker; +use event_broker_sdk::{ + DirectDeduplication, IngestOutcome, Producer, ProducerIdentity, ProducerMode, TypedEvent, +}; + +const TOPIC: &str = "gts.cf.core.events.topic.v1~example.sdk.producer.orders.v1"; +const EVENT_TYPE: &str = "gts.cf.core.events.event_type.v1~example.sdk.producer.created.v1"; +const SUBJECT_TYPE: &str = "gts.cf.core.events.subject.v1~example.sdk.producer.order.v1"; +const TENANT_PARTITION: u32 = 2; + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct OrderCreated { + order_id: Uuid, + total_cents: i64, +} + +impl TypedEvent for OrderCreated { + const TYPE_ID: &'static str = EVENT_TYPE; + const TOPIC: &'static str = TOPIC; + const SUBJECT_TYPE: &'static str = SUBJECT_TYPE; + const SOURCE: &'static str = "order-service"; + + fn subject(&self) -> Cow<'_, str> { + Cow::Owned(self.order_id.to_string()) + } + + fn tenant_id(&self) -> Option { + Some(test_tenant()) + } +} + +#[tokio::test] +async fn stateless_publish_omits_producer_cursor_state() { + let (broker, handle) = broker().await; + let producer = Producer::builder() + .broker(Arc::clone(&broker)) + .security_context(toolkit_security::SecurityContext::anonymous()) + .identity(ProducerIdentity::new().source("order-service")) + .deduplication(DirectDeduplication::stateless()) + .topics([TOPIC]) + .event_type_patterns(["gts.cf.core.events.event_type.v1~example.sdk.producer.*"]) + .prepare_all() + .await + .unwrap(); + + let outcome = producer.publish(order()).await.unwrap(); + + assert_eq!(outcome, IngestOutcome::Accepted); + assert_eq!(handle.stored(TOPIC, TENANT_PARTITION).await.len(), 1); +} + +#[tokio::test] +async fn stateless_publish_uses_tenant_when_partition_key_is_absent() { + let (broker, handle) = broker().await; + let producer = Producer::builder() + .broker(Arc::clone(&broker)) + .security_context(toolkit_security::SecurityContext::anonymous()) + .identity(ProducerIdentity::new().source("order-service")) + .deduplication(DirectDeduplication::stateless()) + .topics([TOPIC]) + .event_type_patterns(["gts.cf.core.events.event_type.v1~example.sdk.producer.*"]) + .prepare_all() + .await + .unwrap(); + let subject_routed = OrderCreated { + order_id: Uuid::parse_str("00000000-0000-0000-0000-000000000002").unwrap(), + total_cents: 10, + }; + producer.publish(subject_routed).await.unwrap(); + + assert_eq!(handle.stored(TOPIC, TENANT_PARTITION).await.len(), 1); +} + +#[tokio::test] +async fn register_on_start_chained_mints_id_and_publishes_first_sequence() { + let (broker, handle) = broker().await; + let producer = Producer::builder() + .broker(Arc::clone(&broker)) + .security_context(toolkit_security::SecurityContext::anonymous()) + .identity(ProducerIdentity::new().source("order-service")) + .deduplication(DirectDeduplication::register_on_start( + ProducerMode::Chained, + )) + .topics([TOPIC]) + .event_type_patterns(["gts.cf.core.events.event_type.v1~example.sdk.producer.*"]) + .prepare_all() + .await + .unwrap(); + + let producer_id = producer.producer_id().expect("producer id is minted"); + let outcome = producer.publish(order()).await.unwrap(); + + assert_eq!(outcome, IngestOutcome::Accepted); + let cursors = broker + .get_producer_cursors(&toolkit_security::SecurityContext::anonymous(), producer_id) + .await + .unwrap(); + assert_eq!(cursors[0].last_sequence, 0); + assert_eq!(handle.stored(TOPIC, TENANT_PARTITION).await.len(), 1); +} + +#[tokio::test] +async fn reuse_monotonic_primes_from_broker_cursor() { + let (broker, _handle) = broker().await; + let ctx = toolkit_security::SecurityContext::anonymous(); + let producer_id = broker + .register_producer(&ctx, ProducerMode::Monotonic, "test/1.0") + .await + .unwrap(); + let first = Producer::builder() + .broker(Arc::clone(&broker)) + .security_context(toolkit_security::SecurityContext::anonymous()) + .identity(ProducerIdentity::new().source("order-service")) + .deduplication(DirectDeduplication::reuse( + ProducerMode::Monotonic, + producer_id, + )) + .topics([TOPIC]) + .event_type_patterns(["gts.cf.core.events.event_type.v1~example.sdk.producer.*"]) + .prepare_all() + .await + .unwrap(); + first.publish(order()).await.unwrap(); + + let restarted = Producer::builder() + .broker(Arc::clone(&broker)) + .security_context(toolkit_security::SecurityContext::anonymous()) + .identity(ProducerIdentity::new().source("order-service")) + .deduplication(DirectDeduplication::reuse( + ProducerMode::Monotonic, + producer_id, + )) + .topics([TOPIC]) + .event_type_patterns(["gts.cf.core.events.event_type.v1~example.sdk.producer.*"]) + .prepare_all() + .await + .unwrap(); + restarted.publish(order()).await.unwrap(); + + let cursors = broker + .get_producer_cursors(&ctx, producer_id) + .await + .unwrap(); + assert_eq!(cursors[0].last_sequence, 1); +} + +#[tokio::test] +async fn persisted_publish_returns_persisted() { + let (broker, _handle) = broker().await; + let producer = Producer::builder() + .broker(Arc::clone(&broker)) + .security_context(toolkit_security::SecurityContext::anonymous()) + .identity(ProducerIdentity::new().source("order-service")) + .deduplication(DirectDeduplication::stateless()) + .topics([TOPIC]) + .event_type_patterns(["gts.cf.core.events.event_type.v1~example.sdk.producer.*"]) + .prepare_all() + .await + .unwrap(); + + let outcome = producer.publish_persisted(order()).await.unwrap(); + + assert_eq!(outcome, IngestOutcome::Persisted); +} + +#[tokio::test] +async fn batch_publish_routes_through_event_broker() { + let (broker, handle) = broker().await; + let producer = Producer::builder() + .broker(Arc::clone(&broker)) + .security_context(toolkit_security::SecurityContext::anonymous()) + .identity(ProducerIdentity::new().source("order-service")) + .deduplication(DirectDeduplication::stateless()) + .topics([TOPIC]) + .event_type_patterns(["gts.cf.core.events.event_type.v1~example.sdk.producer.*"]) + .prepare_all() + .await + .unwrap(); + + let outcomes = producer + .publish_batch(vec![order(), order()]) + .await + .unwrap(); + + assert_eq!( + outcomes, + vec![IngestOutcome::Accepted, IngestOutcome::Accepted] + ); + assert_eq!(handle.stored(TOPIC, TENANT_PARTITION).await.len(), 2); +} + +async fn broker() -> ( + Arc, + event_broker_sdk::mock::MockBrokerHandle, +) { + let mock = Arc::new(MockBroker::new()); + let handle = mock.handle(); + handle.register_topic(TOPIC, 4).await; + handle + .register_event_type( + TOPIC, + EVENT_TYPE, + serde_json::json!({ + "type": "object", + "required": ["order_id", "total_cents"], + "properties": { + "order_id": { "type": "string" }, + "total_cents": { "type": "integer" } + } + }), + &[SUBJECT_TYPE], + ) + .await; + (mock, handle) +} + +fn order() -> OrderCreated { + OrderCreated { + order_id: Uuid::new_v4(), + total_cents: 10, + } +} + +fn test_tenant() -> Uuid { + Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap() +} diff --git a/gears/system/event-broker/event-broker-sdk/tests/producer/outbox.rs b/gears/system/event-broker/event-broker-sdk/tests/producer/outbox.rs new file mode 100644 index 000000000..4bfa9dbbe --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/tests/producer/outbox.rs @@ -0,0 +1,870 @@ +use std::borrow::Cow; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; +use tokio::time::{Instant, sleep}; +use uuid::Uuid; + +use event_broker_sdk::ProducerId; +use event_broker_sdk::api::EventBroker; +use event_broker_sdk::error::EventBrokerError; +use event_broker_sdk::mock::{MockBroker, MockBrokerHandle}; +use event_broker_sdk::models::{Event, ProducerMeta, ResetScope}; +use event_broker_sdk::producer::IngestOutcome; +use event_broker_sdk::producer::UnknownProducerAction; +use event_broker_sdk::{ + DbDeduplication, DbProducer, MissingProducerRegistration, ProducerIdentity, ProducerMode, + TypedEvent, UnknownProducerRegistration, +}; + +const QUEUE: &str = "event-broker-producer"; +const TOPIC: &str = "gts.cf.core.events.topic.v1~example.sdk.outbox.orders.v1"; +const TOPIC2: &str = "gts.cf.core.events.topic.v1~example.sdk.outbox.billing.v1"; +const EVENT_TYPE: &str = "gts.cf.core.events.event_type.v1~example.sdk.outbox.created.v1"; +const EVENT_TYPE2: &str = "gts.cf.core.events.event_type.v1~example.sdk.outbox.charged.v1"; +const SUBJECT_TYPE: &str = "gts.cf.core.events.subject.v1~example.sdk.outbox.order.v1"; +const TENANT_PARTITION: u32 = 2; + +static DB_SEQ: AtomicU64 = AtomicU64::new(1); + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct OrderCreated { + order_id: Uuid, + total_cents: i64, + #[serde(skip)] + partition_key: Option, +} + +impl TypedEvent for OrderCreated { + const TYPE_ID: &'static str = EVENT_TYPE; + const TOPIC: &'static str = TOPIC; + const SUBJECT_TYPE: &'static str = SUBJECT_TYPE; + const SOURCE: &'static str = "order-service"; + + fn subject(&self) -> Cow<'_, str> { + Cow::Owned(self.order_id.to_string()) + } + + fn partition_key(&self) -> Option> { + self.partition_key.as_deref().map(Cow::Borrowed) + } + + fn tenant_id(&self) -> Option { + Some(test_tenant()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct BadOrderCreated { + order_id: Uuid, + total_cents: String, +} + +impl TypedEvent for BadOrderCreated { + const TYPE_ID: &'static str = EVENT_TYPE; + const TOPIC: &'static str = TOPIC; + const SUBJECT_TYPE: &'static str = SUBJECT_TYPE; + const SOURCE: &'static str = "order-service"; + + fn subject(&self) -> Cow<'_, str> { + Cow::Owned(self.order_id.to_string()) + } + + fn tenant_id(&self) -> Option { + Some(test_tenant()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct BillingCharged { + charge_id: Uuid, + total_cents: i64, +} + +impl TypedEvent for BillingCharged { + const TYPE_ID: &'static str = EVENT_TYPE2; + const TOPIC: &'static str = TOPIC2; + const SUBJECT_TYPE: &'static str = SUBJECT_TYPE; + const SOURCE: &'static str = "billing-service"; + + fn subject(&self) -> Cow<'_, str> { + Cow::Owned(self.charge_id.to_string()) + } + + fn tenant_id(&self) -> Option { + Some(test_tenant()) + } +} + +struct NoopProcessor; + +#[async_trait::async_trait] +impl toolkit_db::outbox::LeasedMessageHandler for NoopProcessor { + async fn handle( + &self, + _msg: &toolkit_db::outbox::OutboxMessage, + ) -> toolkit_db::outbox::MessageResult { + toolkit_db::outbox::MessageResult::Ok + } +} + +#[tokio::test] +async fn schema_validation_rejects_invalid_payload_before_enqueue() { + let (db, broker) = fixture().await; + let producer = stateless_producer(db.clone(), Arc::clone(&broker)).await; + + let err = producer + .outbox_envelope( + BadOrderCreated { + order_id: Uuid::new_v4(), + total_cents: "not-an-integer".to_owned(), + }, + 4, + ) + .await + .unwrap_err(); + + assert!(matches!(err, EventBrokerError::EventDataInvalid { .. })); +} + +#[tokio::test] +async fn lazy_validation_missing_schema_fails_without_broker_lookup() { + let db = db().await; + let broker = Arc::new(MockBroker::new()); + let producer = DbProducer::builder() + .broker(broker) + .db(db) + .security_context(toolkit_security::SecurityContext::anonymous()) + .identity(ProducerIdentity::new().source("order-service")) + .deduplication(DbDeduplication::stateless()) + .topics([TOPIC]) + .event_type_patterns(["gts.cf.core.events.event_type.v1~example.sdk.outbox.*"]) + .lazy_validation() + .build() + .await + .unwrap(); + + let err = producer.outbox_envelope(order(None), 4).await.unwrap_err(); + + assert!(matches!(err, EventBrokerError::SchemaNotPrepared { .. })); +} + +#[tokio::test] +async fn managed_missing_registration_can_fail_without_registering() { + let (db, broker) = fixture().await; + + let result = DbProducer::builder() + .broker(broker) + .db(db) + .security_context(toolkit_security::SecurityContext::anonymous()) + .identity(ProducerIdentity::new().source("order-service")) + .deduplication( + DbDeduplication::managed(ProducerMode::Chained) + .key("orders") + .on_missing(MissingProducerRegistration::Fail), + ) + .topics([TOPIC]) + .event_type_patterns([EVENT_TYPE]) + .prepare_all() + .await; + let err = match result { + Ok(_) => panic!("missing managed registration must fail"), + Err(err) => err, + }; + + assert!(matches!( + err, + EventBrokerError::InvalidProducerOptions { .. } + )); +} + +#[tokio::test] +async fn managed_client_agent_drift_fails_loudly() { + let (db, broker) = fixture().await; + let _first = managed_producer(db.clone(), Arc::clone(&broker)).await; + + let result = DbProducer::builder() + .broker(broker) + .db(db) + .security_context(toolkit_security::SecurityContext::anonymous()) + .identity( + ProducerIdentity::new() + .source("order-service") + .client_agent("order-service/2.0"), + ) + .deduplication( + DbDeduplication::managed(ProducerMode::Chained) + .key("orders") + .on_missing(MissingProducerRegistration::RegisterNew), + ) + .topics([TOPIC]) + .event_type_patterns([EVENT_TYPE]) + .prepare_all() + .await; + let err = match result { + Ok(_) => panic!("managed client-agent drift must fail"), + Err(err) => err, + }; + + assert!(matches!( + err, + EventBrokerError::InvalidProducerOptions { .. } + )); +} + +#[tokio::test] +async fn managed_registration_requires_producer_migrations() { + let (_, broker, _) = fixture_with_handle().await; + let db = db_without_producer_migrations().await; + + let result = DbProducer::builder() + .broker(broker) + .db(db) + .security_context(toolkit_security::SecurityContext::anonymous()) + .identity( + ProducerIdentity::new() + .source("order-service") + .client_agent("order-service/1.0"), + ) + .deduplication( + DbDeduplication::managed(ProducerMode::Chained) + .key("orders") + .on_missing(MissingProducerRegistration::RegisterNew), + ) + .topics([TOPIC]) + .event_type_patterns([EVENT_TYPE]) + .prepare_all() + .await; + let err = match result { + Ok(_) => panic!("managed registration must require producer migrations"), + Err(err) => err, + }; + + assert!( + err.to_string().contains("producer"), + "unexpected migration error: {err}" + ); +} + +#[tokio::test] +async fn unknown_producer_fail_policy_keeps_registration() { + let (db, broker) = fixture().await; + let producer = + managed_producer_with_unknown(db, broker, UnknownProducerRegistration::Fail).await; + let (_, before) = producer.outbox_envelope(order(None), 4).await.unwrap(); + let before = envelope_json(&before); + let producer_id = producer_id_from_envelope(&before); + + let action = producer.handle_unknown_producer(producer_id).await.unwrap(); + let (_, after) = producer.outbox_envelope(order(None), 4).await.unwrap(); + let after = envelope_json(&after); + + assert_eq!(action, UnknownProducerAction::Fail); + assert_eq!(before["producer_id"], after["producer_id"]); + assert_eq!(before["generation"], after["generation"]); +} + +#[tokio::test] +async fn unknown_producer_register_new_replaces_registration() { + let (db, broker) = fixture().await; + let producer = + managed_producer_with_unknown(db, broker, UnknownProducerRegistration::RegisterNew).await; + let (_, before) = producer.outbox_envelope(order(None), 4).await.unwrap(); + let before = envelope_json(&before); + let producer_id = producer_id_from_envelope(&before); + + let action = producer.handle_unknown_producer(producer_id).await.unwrap(); + let (_, after) = producer.outbox_envelope(order(None), 4).await.unwrap(); + let after = envelope_json(&after); + + assert_eq!(action, UnknownProducerAction::Rotated); + assert_ne!(before["producer_id"], after["producer_id"]); + assert_eq!(before["generation"], 1); + assert_eq!(after["generation"], 2); + + let action = producer.handle_unknown_producer(producer_id).await.unwrap(); + assert_eq!(action, UnknownProducerAction::AlreadyRotated); +} + +#[tokio::test] +async fn tenant_fallback_and_partition_key_input_drive_broker_partition() { + let (db, broker) = fixture().await; + let producer = stateless_producer(db, broker).await; + + let (_, tenant_envelope) = producer.outbox_envelope(order(None), 4).await.unwrap(); + let (_, keyed_envelope) = producer + .outbox_envelope(order(Some("explicit-key")), 4) + .await + .unwrap(); + let tenant = envelope_json(&tenant_envelope); + let keyed = envelope_json(&keyed_envelope); + + assert_eq!( + tenant["broker_partition"], + serde_json::json!(TENANT_PARTITION) + ); + assert_eq!(keyed["broker_partition"], serde_json::json!(3)); +} + +#[tokio::test] +async fn same_topic_partition_maps_to_same_outbox_partition() { + let (db, broker) = fixture().await; + let producer = stateless_producer(db, broker).await; + + let (left, _) = producer.outbox_envelope(order(None), 8).await.unwrap(); + let (right, _) = producer.outbox_envelope(order(None), 8).await.unwrap(); + + assert_eq!(left, right); +} + +#[tokio::test] +async fn producer_outbox_and_broker_topic_partition_counts_can_differ() { + let db = db().await; + let mock = Arc::new(MockBroker::new()); + let handle = mock.handle(); + handle.register_topic(TOPIC, 16).await; + handle.register_topic(TOPIC2, 4).await; + handle + .register_event_type(TOPIC, EVENT_TYPE, order_schema(), &[SUBJECT_TYPE]) + .await; + handle + .register_event_type(TOPIC2, EVENT_TYPE2, billing_schema(), &[SUBJECT_TYPE]) + .await; + let broker: Arc = mock; + let producer = stateless_producer(db, broker).await; + + let event = order(Some("explicit-key")); + let expected_broker_partition = 15; + let expected_outbox_partition = 0; + + let (outbox_partition, envelope) = producer.outbox_envelope(event, 4).await.unwrap(); + let envelope = envelope_json(&envelope); + + assert_eq!(outbox_partition, expected_outbox_partition); + assert_eq!( + envelope["broker_partition"], + serde_json::json!(expected_broker_partition) + ); + assert!(outbox_partition < 4); + assert!(expected_broker_partition < 16); +} + +#[tokio::test] +async fn one_queue_can_carry_multiple_topics() { + let (db, broker) = fixture().await; + let producer = DbProducer::builder() + .broker(broker) + .db(db) + .security_context(toolkit_security::SecurityContext::anonymous()) + .identity(ProducerIdentity::new().source("order-service")) + .deduplication(DbDeduplication::stateless()) + .topics([TOPIC, TOPIC2]) + .event_type_patterns(["gts.cf.core.events.event_type.v1~example.sdk.outbox.*"]) + .prepare_all() + .await + .unwrap(); + + let (orders_partition, orders) = producer.outbox_envelope(order(None), 4).await.unwrap(); + let (billing_partition, billing) = producer + .outbox_envelope( + BillingCharged { + charge_id: Uuid::new_v4(), + total_cents: 30, + }, + 4, + ) + .await + .unwrap(); + + assert!(orders_partition < 4); + assert!(billing_partition < 4); + assert_eq!(envelope_json(&orders)["topic"], TOPIC); + assert_eq!(envelope_json(&billing)["topic"], TOPIC2); +} + +#[tokio::test] +async fn managed_envelope_captures_registration_and_omits_final_chain_fields() { + let (db, broker) = fixture().await; + let producer = managed_producer(db, broker).await; + + let (_, envelope) = producer.outbox_envelope(order(None), 4).await.unwrap(); + let json = envelope_json(&envelope); + + assert_eq!(json["version"], 1); + assert_eq!(json["type"], EVENT_TYPE); + assert_eq!(json["producer_mode"], "chained"); + assert!(json["producer_id"].as_str().is_some()); + assert_eq!(json["generation"], 1); + assert!(json.get("previous").is_none()); + assert!(json.get("sequence").is_none()); + assert_eq!( + json["diagnostic_metadata"]["sdk_client_agent"], + "order-service/1.0" + ); + + let bytes = serde_json::to_vec(&envelope).unwrap(); + let round_tripped: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(round_tripped, json); +} + +#[tokio::test] +async fn rotation_affects_future_envelopes_only() { + let (db, broker) = fixture().await; + let mut producer = managed_producer(db, broker).await; + + let (_, before) = producer.outbox_envelope(order(None), 4).await.unwrap(); + producer.rotate_registration().await.unwrap(); + let (_, after) = producer.outbox_envelope(order(None), 4).await.unwrap(); + let before = envelope_json(&before); + let after = envelope_json(&after); + + assert_ne!(before["producer_id"], after["producer_id"]); + assert_eq!(before["generation"], 1); + assert_eq!(after["generation"], 2); +} + +#[tokio::test] +async fn reset_chain_preserves_registered_producer_id() { + let (db, broker) = fixture().await; + let producer = managed_producer(db, broker).await; + + let (_, before) = producer.outbox_envelope(order(None), 4).await.unwrap(); + producer.reset_chain(ResetScope::AllTopics).await.unwrap(); + let (_, after) = producer.outbox_envelope(order(None), 4).await.unwrap(); + + assert_eq!( + envelope_json(&before)["producer_id"], + envelope_json(&after)["producer_id"] + ); +} + +#[tokio::test] +async fn service_owned_lifecycle_registers_extra_queue_and_binds_producer_outbox() { + let (db, broker, _) = fixture_with_handle().await; + let producer = stateless_producer(db.clone(), broker).await; + let event_outbox = producer + .outbox_queue(QUEUE, toolkit_db::outbox::Partitions::of(4)) + .unwrap(); + let handle = event_outbox + .register(toolkit_db::outbox::Outbox::builder(db.clone())) + .queue("other-service-queue", toolkit_db::outbox::Partitions::of(2)) + .leased(NoopProcessor) + .start() + .await + .unwrap(); + let producer_outbox = event_outbox.bind(&handle); + let conn = db.conn().unwrap(); + + let id = producer_outbox.enqueue(&conn, order(None)).await.unwrap(); + + assert!(id.0 > 0); + handle.stop().await; +} + +#[tokio::test] +async fn convenience_start_drains_enqueued_event_to_broker() { + let (db, broker, handle) = fixture_with_handle().await; + let producer = stateless_producer(db.clone(), broker).await; + let event_outbox = producer + .outbox_queue(QUEUE, toolkit_db::outbox::Partitions::of(4)) + .unwrap(); + let producer_handle = event_outbox + .start(toolkit_db::outbox::Outbox::builder(db.clone())) + .await + .unwrap(); + let conn = db.conn().unwrap(); + + producer_handle + .outbox() + .enqueue(&conn, order(None)) + .await + .unwrap(); + wait_for_stored(&handle, TOPIC, TENANT_PARTITION, 1).await; + + producer_handle.stop().await; +} + +#[tokio::test] +async fn outbox_processor_treats_duplicate_as_ok() { + let (db, broker, handle) = fixture_with_handle().await; + let producer = managed_producer(db, Arc::clone(&broker)).await; + let envelope = managed_envelope_payload(&producer).await; + let json = envelope_json_from_bytes(&envelope); + let producer_id = producer_id_from_envelope(&json); + seed_chained_cursor(&broker, producer_id, 1).await; + + let result = producer.process_outbox_payload_for_test(envelope, 1).await; + + assert_message_ok(result); + assert_eq!(handle.stored(TOPIC, TENANT_PARTITION).await.len(), 1); +} + +#[tokio::test] +async fn outbox_processor_retries_transient_rate_limit() { + let (db, broker, handle) = fixture_with_handle().await; + let producer = stateless_producer(db, broker).await; + let (_, envelope) = producer.outbox_envelope(order(None), 4).await.unwrap(); + handle.set_publish_rate_limit(Some(0)).await; + + let result = producer + .process_outbox_payload_for_test(serde_json::to_vec(&envelope).unwrap(), 1) + .await; + + assert_message_retry(result); +} + +#[tokio::test] +async fn outbox_processor_rejects_malformed_payload() { + let (db, broker) = fixture().await; + let producer = stateless_producer(db, broker).await; + + let result = producer + .process_outbox_payload_for_test(b"not-json".to_vec(), 1) + .await; + + assert_message_reject(result, "decode producer envelope"); +} + +#[tokio::test] +async fn outbox_processor_recovers_chained_cursor_on_startup() { + let (db, broker) = fixture().await; + let producer = managed_producer(db, Arc::clone(&broker)).await; + let envelope = managed_envelope_payload(&producer).await; + let json = envelope_json_from_bytes(&envelope); + let producer_id = producer_id_from_envelope(&json); + seed_chained_cursor(&broker, producer_id, 2).await; + + let result = producer.process_outbox_payload_for_test(envelope, 4).await; + let cursors = broker + .get_producer_cursors(&toolkit_security::SecurityContext::anonymous(), producer_id) + .await + .unwrap(); + let cursor = cursors + .iter() + .find(|cursor| cursor.topic == TOPIC && cursor.partition == TENANT_PARTITION) + .unwrap(); + + assert_message_ok(result); + assert_eq!(cursor.last_sequence, 4); +} + +#[tokio::test] +async fn outbox_processor_recovers_chained_sequence_violation_by_refreshing_cursor() { + let (db, broker) = fixture().await; + let producer = managed_producer(db, Arc::clone(&broker)).await; + let envelope = managed_envelope_payload(&producer).await; + let json = envelope_json_from_bytes(&envelope); + let producer_id = producer_id_from_envelope(&json); + seed_chained_cursor(&broker, producer_id, 3).await; + + let result = producer + .process_outbox_payload_with_cursor_for_test(envelope, 4, Some(-1)) + .await; + let cursors = broker + .get_producer_cursors(&toolkit_security::SecurityContext::anonymous(), producer_id) + .await + .unwrap(); + let cursor = cursors + .iter() + .find(|cursor| cursor.topic == TOPIC && cursor.partition == TENANT_PARTITION) + .unwrap(); + + assert_message_ok(result); + assert_eq!(cursor.last_sequence, 4); +} + +#[tokio::test] +async fn outbox_processor_rotates_future_registration_when_broker_forgot_producer() { + let (db, broker, handle) = fixture_with_handle().await; + let producer = + managed_producer_with_unknown(db.clone(), broker, UnknownProducerRegistration::RegisterNew) + .await; + let (_, before) = producer.outbox_envelope(order(None), 4).await.unwrap(); + let before = envelope_json(&before); + let producer_id = producer_id_from_envelope(&before); + handle.forget_producer(producer_id).await; + + let event_outbox = producer + .outbox_queue(QUEUE, toolkit_db::outbox::Partitions::of(4)) + .unwrap(); + let producer_handle = event_outbox + .start(toolkit_db::outbox::Outbox::builder(db.clone())) + .await + .unwrap(); + let conn = db.conn().unwrap(); + + producer_handle + .outbox() + .enqueue(&conn, order(None)) + .await + .unwrap(); + let after = + wait_for_rotated_registration(&producer, before["producer_id"].as_str().unwrap()).await; + + assert_eq!(after["generation"], 2); + assert_ne!(before["producer_id"], after["producer_id"]); + producer_handle.stop().await; +} + +async fn fixture() -> (toolkit_db::Db, Arc) { + let (db, broker, _) = fixture_with_handle().await; + (db, broker) +} + +async fn fixture_with_handle() -> (toolkit_db::Db, Arc, MockBrokerHandle) { + let db = db().await; + let mock = Arc::new(MockBroker::new()); + let handle = mock.handle(); + handle.register_topic(TOPIC, 4).await; + handle.register_topic(TOPIC2, 4).await; + handle + .register_event_type(TOPIC, EVENT_TYPE, order_schema(), &[SUBJECT_TYPE]) + .await; + handle + .register_event_type(TOPIC2, EVENT_TYPE2, billing_schema(), &[SUBJECT_TYPE]) + .await; + (db, mock, handle) +} + +async fn db() -> toolkit_db::Db { + let seq = DB_SEQ.fetch_add(1, Ordering::Relaxed); + let dsn = format!("sqlite:file:evbk_producer_outbox_{seq}?mode=memory&cache=shared"); + let db = toolkit_db::connect_db( + &dsn, + toolkit_db::ConnectOpts { + max_conns: Some(1), + ..toolkit_db::ConnectOpts::default() + }, + ) + .await + .unwrap(); + let mut migrations = toolkit_db::outbox::outbox_migrations(); + migrations.extend(event_broker_sdk::producer_registration_migrations()); + toolkit_db::migration_runner::run_migrations_for_testing(&db, migrations) + .await + .unwrap(); + db +} + +async fn db_without_producer_migrations() -> toolkit_db::Db { + let seq = DB_SEQ.fetch_add(1, Ordering::Relaxed); + let dsn = format!("sqlite:file:evbk_producer_outbox_no_reg_{seq}?mode=memory&cache=shared"); + let db = toolkit_db::connect_db( + &dsn, + toolkit_db::ConnectOpts { + max_conns: Some(1), + ..toolkit_db::ConnectOpts::default() + }, + ) + .await + .unwrap(); + toolkit_db::migration_runner::run_migrations_for_testing( + &db, + toolkit_db::outbox::outbox_migrations(), + ) + .await + .unwrap(); + db +} + +async fn stateless_producer(db: toolkit_db::Db, broker: Arc) -> DbProducer { + DbProducer::builder() + .broker(broker) + .db(db) + .security_context(toolkit_security::SecurityContext::anonymous()) + .identity(ProducerIdentity::new().source("order-service")) + .deduplication(DbDeduplication::stateless()) + .topics([TOPIC]) + .event_type_patterns([EVENT_TYPE]) + .prepare_all() + .await + .unwrap() +} + +async fn managed_producer(db: toolkit_db::Db, broker: Arc) -> DbProducer { + managed_producer_with_unknown(db, broker, UnknownProducerRegistration::Fail).await +} + +async fn managed_producer_with_unknown( + db: toolkit_db::Db, + broker: Arc, + unknown: UnknownProducerRegistration, +) -> DbProducer { + DbProducer::builder() + .broker(broker) + .db(db) + .security_context(toolkit_security::SecurityContext::anonymous()) + .identity( + ProducerIdentity::new() + .source("order-service") + .client_agent("order-service/1.0"), + ) + .deduplication( + DbDeduplication::managed(ProducerMode::Chained) + .key("orders") + .on_missing(MissingProducerRegistration::RegisterNew) + .on_unknown(unknown), + ) + .topics([TOPIC]) + .event_type_patterns([EVENT_TYPE]) + .prepare_all() + .await + .unwrap() +} + +fn order_schema() -> serde_json::Value { + serde_json::json!({ + "type": "object", + "required": ["order_id", "total_cents"], + "properties": { + "order_id": { "type": "string" }, + "total_cents": { "type": "integer" } + } + }) +} + +fn billing_schema() -> serde_json::Value { + serde_json::json!({ + "type": "object", + "required": ["charge_id", "total_cents"], + "properties": { + "charge_id": { "type": "string" }, + "total_cents": { "type": "integer" } + } + }) +} + +fn order(partition_key: Option<&str>) -> OrderCreated { + OrderCreated { + order_id: Uuid::new_v4(), + total_cents: 10, + partition_key: partition_key.map(str::to_owned), + } +} + +fn envelope_json(envelope: &impl serde::Serialize) -> serde_json::Value { + serde_json::to_value(envelope).unwrap() +} + +fn envelope_json_from_bytes(envelope: &[u8]) -> serde_json::Value { + serde_json::from_slice(envelope).unwrap() +} + +fn producer_id_from_envelope(envelope: &serde_json::Value) -> ProducerId { + ProducerId(Uuid::parse_str(envelope["producer_id"].as_str().unwrap()).unwrap()) +} + +fn test_tenant() -> Uuid { + Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap() +} + +async fn wait_for_stored(handle: &MockBrokerHandle, topic: &str, partition: u32, count: usize) { + let deadline = Instant::now() + Duration::from_secs(2); + loop { + if handle.stored(topic, partition).await.len() >= count { + return; + } + assert!( + Instant::now() < deadline, + "timed out waiting for {count} stored events on {topic}:{partition}" + ); + sleep(Duration::from_millis(10)).await; + } +} + +async fn wait_for_rotated_registration( + producer: &DbProducer, + old_producer_id: &str, +) -> serde_json::Value { + let deadline = Instant::now() + Duration::from_secs(2); + loop { + let (_, envelope) = producer.outbox_envelope(order(None), 4).await.unwrap(); + let json = envelope_json(&envelope); + if json["producer_id"].as_str() != Some(old_producer_id) { + return json; + } + assert!( + Instant::now() < deadline, + "timed out waiting for producer registration rotation" + ); + sleep(Duration::from_millis(10)).await; + } +} + +async fn managed_envelope_payload(producer: &DbProducer) -> Vec { + let (_, envelope) = producer.outbox_envelope(order(None), 4).await.unwrap(); + serde_json::to_vec(&envelope).unwrap() +} + +async fn seed_chained_cursor( + broker: &Arc, + producer_id: ProducerId, + last_sequence: i64, +) { + let ctx = toolkit_security::SecurityContext::anonymous(); + for sequence in 1..=last_sequence { + let previous = sequence - 1; + let previous = if previous == 0 { -1 } else { previous }; + let outcome = broker + .publish( + &ctx, + &chained_event_for_sequence(producer_id, sequence, previous), + ) + .await + .unwrap(); + assert_eq!(outcome, IngestOutcome::Accepted); + } +} + +fn chained_event_for_sequence(producer_id: ProducerId, sequence: i64, previous: i64) -> Event { + Event { + id: Uuid::new_v4(), + type_id: EVENT_TYPE.to_owned(), + topic: TOPIC.to_owned(), + tenant_id: test_tenant(), + source: "order-service".to_owned(), + subject: Uuid::new_v4().to_string(), + subject_type: SUBJECT_TYPE.to_owned(), + partition_key: None, + occurred_at: chrono::Utc::now(), + trace_parent: None, + data: Some(serde_json::json!({ + "order_id": Uuid::new_v4(), + "total_cents": 10 + })), + partition: None, + sequence: None, + sequence_time: None, + offset: None, + offset_time: None, + meta: Some(ProducerMeta { + version: 1, + producer_id: Some(producer_id.0), + previous: Some(previous), + sequence: Some(sequence), + partition_hint: Some(TENANT_PARTITION), + }), + } +} + +fn assert_message_ok(result: toolkit_db::outbox::MessageResult) { + assert!(matches!(result, toolkit_db::outbox::MessageResult::Ok)); +} + +fn assert_message_retry(result: toolkit_db::outbox::MessageResult) { + assert!(matches!(result, toolkit_db::outbox::MessageResult::Retry)); +} + +fn assert_message_reject(result: toolkit_db::outbox::MessageResult, expected: &str) { + match result { + toolkit_db::outbox::MessageResult::Reject(reason) => { + assert!( + reason.contains(expected), + "expected reject reason to contain {expected:?}, got {reason:?}" + ); + } + other => panic!("expected reject, got {other:?}"), + } +} diff --git a/gears/system/event-broker/event-broker-sdk/tests/sdk.rs b/gears/system/event-broker/event-broker-sdk/tests/sdk.rs new file mode 100644 index 000000000..84463ded1 --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/tests/sdk.rs @@ -0,0 +1,5 @@ +mod sdk { + mod defaults; + mod errors; + mod typed_event; +} diff --git a/gears/system/event-broker/event-broker-sdk/tests/sdk/defaults.rs b/gears/system/event-broker/event-broker-sdk/tests/sdk/defaults.rs new file mode 100644 index 000000000..19df95489 --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/tests/sdk/defaults.rs @@ -0,0 +1,15 @@ +//! Unit tests for SDK-level defaults. + +use event_broker_sdk::EventBrokerSdk; + +#[test] +fn default_client_agent_is_shared_sdk_identifier() { + assert_eq!( + EventBrokerSdk::default_client_agent(), + EventBrokerSdk::DEFAULT_CLIENT_AGENT + ); + assert_eq!( + EventBrokerSdk::default_client_agent(), + concat!("event-broker-sdk/", env!("CARGO_PKG_VERSION")) + ); +} diff --git a/gears/system/event-broker/event-broker-sdk/tests/sdk/errors.rs b/gears/system/event-broker/event-broker-sdk/tests/sdk/errors.rs new file mode 100644 index 000000000..439bda4a1 --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/tests/sdk/errors.rs @@ -0,0 +1,940 @@ +//! Unit tests for the error model. + +use std::error::Error; + +use event_broker_sdk::error::{reasons, resources}; +use event_broker_sdk::{ + ConsumerError, ConsumerGroupId, EventBrokerError, OffsetManagerError, ProducerId, + StorageBackendError, SubscriptionId, +}; +use serde_json::{Value, json}; +use toolkit_canonical_errors::{CanonicalError, Problem}; +use uuid::Uuid; + +#[test] +fn display_interpolates_fields() { + let e = EventBrokerError::EventTypeNotDeclared { + type_id: "gts.cf.core.events.event_type.v1~example.foo.v1".into(), + detail: "d".into(), + instance: String::new(), + }; + assert!( + e.to_string() + .contains("gts.cf.core.events.event_type.v1~example.foo.v1") + ); + + let e = EventBrokerError::SequenceViolation { + expected_previous: 42, + detail: "d".into(), + instance: String::new(), + }; + assert!(e.to_string().contains("42")); + + let e = EventBrokerError::RateLimitExceeded { + retry_after_secs: 30, + detail: "d".into(), + instance: String::new(), + }; + assert!(e.to_string().contains("30")); +} + +#[test] +fn from_conversions() { + let storage_err = StorageBackendError::Internal("test".into()); + let broker_err = EventBrokerError::from(storage_err); + assert!(matches!(broker_err, EventBrokerError::StorageBackend(_))); + + let offset_err = OffsetManagerError::Internal("test".into()); + let broker_err = EventBrokerError::from(offset_err); + assert!(matches!(broker_err, EventBrokerError::OffsetManager(_))); +} + +#[test] +fn consumer_error_alias() { + let e: ConsumerError = EventBrokerError::Internal("test".into()); + assert!(!e.to_string().is_empty()); +} + +#[derive(Debug, thiserror::Error)] +#[error("db failed")] +struct DbFailure; + +#[test] +fn offset_manager_error_preserves_source_through_broker_error() { + let offset_err = OffsetManagerError::persist_failed("write offset", "upsert failed", DbFailure); + assert_eq!( + offset_err.source().map(ToString::to_string), + Some("db failed".to_owned()) + ); + + let broker_err = EventBrokerError::from(offset_err); + let source = broker_err + .source() + .expect("broker error should expose offset error"); + assert_eq!(source.to_string(), "persist failed: write offset"); + assert_eq!( + source.source().map(ToString::to_string), + Some("db failed".to_owned()) + ); +} + +fn fixed_uuid(n: u128) -> Uuid { + Uuid::from_u128(n) +} + +fn problem_json(err: impl Into) -> Value { + let canonical = err.into(); + let problem = Problem::from(canonical) + .with_instance("/v1/event-broker/test") + .with_trace_id("trace-123"); + serde_json::to_value(problem).expect("problem serializes") +} + +fn assert_problem(err: impl Into, expected: Value) { + assert_eq!(problem_json(err), expected); +} + +#[test] +fn top_level_event_broker_errors_have_full_canonical_representation() { + let group_id = ConsumerGroupId(fixed_uuid(1)); + let subscription_id = SubscriptionId(fixed_uuid(2)); + let producer_id = ProducerId(fixed_uuid(3)); + + let cases = [ + ( + EventBrokerError::InvalidProducerOptions { + detail: "source is required".into(), + instance: "ignored".into(), + }, + json!({ + "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.invalid_argument.v1~", + "title": "Invalid Argument", + "status": 400, + "detail": "Request validation failed", + "instance": "/v1/event-broker/test", + "trace_id": "trace-123", + "context": { + "field_violations": [{ + "field": "producer_options", + "description": "source is required", + "reason": reasons::INVALID_PRODUCER_OPTIONS + }], + "resource_type": resources::PRODUCER_OPTIONS + } + }), + ), + ( + EventBrokerError::InvalidConsumerOptions { + detail: "group is required".into(), + instance: "ignored".into(), + }, + json!({ + "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.invalid_argument.v1~", + "title": "Invalid Argument", + "status": 400, + "detail": "Request validation failed", + "instance": "/v1/event-broker/test", + "trace_id": "trace-123", + "context": { + "field_violations": [{ + "field": "consumer_options", + "description": "group is required", + "reason": reasons::INVALID_CONSUMER_OPTIONS + }], + "resource_type": resources::CONSUMER_OPTIONS + } + }), + ), + ( + EventBrokerError::EventTypeNotDeclared { + type_id: "gts.cf.core.events.event_type.v1~example.orders.created.v1".into(), + detail: "type is not declared".into(), + instance: "ignored".into(), + }, + json!({ + "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.invalid_argument.v1~", + "title": "Invalid Argument", + "status": 400, + "detail": "Request validation failed", + "instance": "/v1/event-broker/test", + "trace_id": "trace-123", + "context": { + "field_violations": [{ + "field": "event_type", + "description": "type is not declared", + "reason": reasons::EVENT_TYPE_NOT_DECLARED + }], + "resource_type": resources::EVENT_TYPE, + "resource_name": "gts.cf.core.events.event_type.v1~example.orders.created.v1" + } + }), + ), + ( + EventBrokerError::EventTypeUnknown { + type_id: "gts.cf.core.events.event_type.v1~example.orders.created.v1".into(), + detail: "event type not found".into(), + instance: "ignored".into(), + }, + json!({ + "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.not_found.v1~", + "title": "Not Found", + "status": 404, + "detail": "event type not found", + "instance": "/v1/event-broker/test", + "trace_id": "trace-123", + "context": { + "resource_type": resources::EVENT_TYPE, + "resource_name": "gts.cf.core.events.event_type.v1~example.orders.created.v1" + } + }), + ), + ( + EventBrokerError::TypeNotInDeclaredTopic { + type_id: "gts.cf.core.events.event_type.v1~example.orders.created.v1".into(), + expected_topic: "orders".into(), + detail: "type belongs to another topic".into(), + instance: "ignored".into(), + }, + json!({ + "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.failed_precondition.v1~", + "title": "Failed Precondition", + "status": 400, + "detail": "Operation precondition not met", + "instance": "/v1/event-broker/test", + "trace_id": "trace-123", + "context": { + "violations": [{ + "type": reasons::TYPE_NOT_IN_DECLARED_TOPIC, + "subject": "gts.cf.core.events.event_type.v1~example.orders.created.v1", + "description": "type belongs to another topic; expected topic orders" + }], + "resource_type": resources::EVENT_TYPE, + "resource_name": "gts.cf.core.events.event_type.v1~example.orders.created.v1" + } + }), + ), + ( + EventBrokerError::SchemaNotPrepared { + type_id: "gts.cf.core.events.event_type.v1~example.orders.created.v1".into(), + detail: "schema cache missing".into(), + instance: "ignored".into(), + }, + json!({ + "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.failed_precondition.v1~", + "title": "Failed Precondition", + "status": 400, + "detail": "Operation precondition not met", + "instance": "/v1/event-broker/test", + "trace_id": "trace-123", + "context": { + "violations": [{ + "type": reasons::SCHEMA_NOT_PREPARED, + "subject": "gts.cf.core.events.event_type.v1~example.orders.created.v1", + "description": "schema cache missing" + }], + "resource_type": resources::EVENT_TYPE, + "resource_name": "gts.cf.core.events.event_type.v1~example.orders.created.v1" + } + }), + ), + ( + EventBrokerError::InvalidEventField { + field: "subject", + detail: "must not be empty".into(), + instance: "ignored".into(), + }, + json!({ + "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.invalid_argument.v1~", + "title": "Invalid Argument", + "status": 400, + "detail": "Request validation failed", + "instance": "/v1/event-broker/test", + "trace_id": "trace-123", + "context": { + "field_violations": [{ + "field": "subject", + "description": "must not be empty", + "reason": reasons::INVALID_EVENT_FIELD + }], + "resource_type": resources::EVENT + } + }), + ), + ( + EventBrokerError::EventDataInvalid { + type_id: "gts.cf.core.events.event_type.v1~example.orders.created.v1".into(), + errors: vec!["/amount must be >= 0".into()], + detail: "payload invalid".into(), + instance: "ignored".into(), + }, + json!({ + "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.invalid_argument.v1~", + "title": "Invalid Argument", + "status": 400, + "detail": "Request validation failed", + "instance": "/v1/event-broker/test", + "trace_id": "trace-123", + "context": { + "field_violations": [{ + "field": "data", + "description": "payload invalid: /amount must be >= 0", + "reason": reasons::EVENT_DATA_INVALID + }], + "resource_type": resources::EVENT_TYPE, + "resource_name": "gts.cf.core.events.event_type.v1~example.orders.created.v1" + } + }), + ), + ( + EventBrokerError::TopicNotFound { + topic: "orders".into(), + detail: "topic not found".into(), + instance: "ignored".into(), + }, + json!({ + "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.not_found.v1~", + "title": "Not Found", + "status": 404, + "detail": "topic not found", + "instance": "/v1/event-broker/test", + "trace_id": "trace-123", + "context": { + "resource_type": resources::TOPIC, + "resource_name": "orders" + } + }), + ), + ( + EventBrokerError::ConsumerGroupNotFound { + group_id, + detail: "consumer group not found".into(), + instance: "ignored".into(), + }, + json!({ + "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.not_found.v1~", + "title": "Not Found", + "status": 404, + "detail": "consumer group not found", + "instance": "/v1/event-broker/test", + "trace_id": "trace-123", + "context": { + "resource_type": resources::CONSUMER_GROUP, + "resource_name": group_id.to_string() + } + }), + ), + ( + EventBrokerError::ConsumerGroupHasActiveMembers { + detail: "group has active members".into(), + instance: "ignored".into(), + }, + json!({ + "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.failed_precondition.v1~", + "title": "Failed Precondition", + "status": 400, + "detail": "Operation precondition not met", + "instance": "/v1/event-broker/test", + "trace_id": "trace-123", + "context": { + "violations": [{ + "type": reasons::CONSUMER_GROUP_HAS_ACTIVE_MEMBERS, + "subject": resources::CONSUMER_GROUP, + "description": "group has active members" + }], + "resource_type": resources::CONSUMER_GROUP + } + }), + ), + ( + EventBrokerError::SubscriptionNotFound { + id: subscription_id, + detail: "subscription not found".into(), + instance: "ignored".into(), + }, + json!({ + "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.not_found.v1~", + "title": "Not Found", + "status": 404, + "detail": "subscription not found", + "instance": "/v1/event-broker/test", + "trace_id": "trace-123", + "context": { + "resource_type": resources::SUBSCRIPTION, + "resource_name": subscription_id.to_string() + } + }), + ), + ( + EventBrokerError::Unauthorized { + detail: "tenant boundary violation".into(), + instance: "ignored".into(), + }, + json!({ + "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.permission_denied.v1~", + "title": "Permission Denied", + "status": 403, + "detail": "You do not have permission to perform this operation", + "instance": "/v1/event-broker/test", + "trace_id": "trace-123", + "context": { + "reason": reasons::UNAUTHORIZED, + "resource_type": resources::EVENT + } + }), + ), + ( + EventBrokerError::UnknownProducer { + producer_id, + detail: "producer not found".into(), + instance: "ignored".into(), + }, + json!({ + "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.not_found.v1~", + "title": "Not Found", + "status": 404, + "detail": "producer not found", + "instance": "/v1/event-broker/test", + "trace_id": "trace-123", + "context": { + "resource_type": resources::PRODUCER, + "resource_name": producer_id.0.to_string() + } + }), + ), + ( + EventBrokerError::SequenceViolation { + expected_previous: 41, + detail: "sequence mismatch".into(), + instance: "ignored".into(), + }, + json!({ + "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.failed_precondition.v1~", + "title": "Failed Precondition", + "status": 400, + "detail": "Operation precondition not met", + "instance": "/v1/event-broker/test", + "trace_id": "trace-123", + "context": { + "violations": [{ + "type": reasons::SEQUENCE_MISMATCH, + "subject": "producer_sequence", + "description": "sequence mismatch; expected previous 41" + }], + "resource_type": resources::PRODUCER + } + }), + ), + ( + EventBrokerError::RateLimitExceeded { + retry_after_secs: 30, + detail: "rate limit exceeded".into(), + instance: "ignored".into(), + }, + json!({ + "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.resource_exhausted.v1~", + "title": "Resource Exhausted", + "status": 429, + "detail": "rate limit exceeded", + "instance": "/v1/event-broker/test", + "trace_id": "trace-123", + "context": { + "violations": [{ + "subject": reasons::RATE_LIMIT, + "description": "rate limit exceeded", + "retry_after_seconds": 30 + }], + "resource_type": resources::EVENT + } + }), + ), + ( + EventBrokerError::GroupAtCapacity { + active: 4, + partitions: 4, + detail: "group at capacity".into(), + instance: "ignored".into(), + }, + json!({ + "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.resource_exhausted.v1~", + "title": "Resource Exhausted", + "status": 429, + "detail": "group at capacity", + "instance": "/v1/event-broker/test", + "trace_id": "trace-123", + "context": { + "violations": [{ + "subject": reasons::CONSUMER_GROUP_CAPACITY, + "description": "group at capacity; active=4; partitions=4" + }], + "resource_type": resources::CONSUMER_GROUP + } + }), + ), + ( + EventBrokerError::RateLimited { + retry_after_secs: 15, + detail: "publish rate limited".into(), + instance: "ignored".into(), + }, + json!({ + "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.resource_exhausted.v1~", + "title": "Resource Exhausted", + "status": 429, + "detail": "publish rate limited", + "instance": "/v1/event-broker/test", + "trace_id": "trace-123", + "context": { + "violations": [{ + "subject": reasons::RATE_LIMIT, + "description": "publish rate limited", + "retry_after_seconds": 15 + }], + "resource_type": resources::EVENT + } + }), + ), + ( + EventBrokerError::BatchTooLarge { + count: 101, + bytes: 2048, + max_count: 100, + max_bytes: 1024, + detail: "batch too large".into(), + instance: "ignored".into(), + }, + json!({ + "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.invalid_argument.v1~", + "title": "Invalid Argument", + "status": 400, + "detail": "Request validation failed", + "instance": "/v1/event-broker/test", + "trace_id": "trace-123", + "context": { + "field_violations": [ + { + "field": "batch.count", + "description": "batch too large; count=101; max_count=100", + "reason": reasons::BATCH_TOO_LARGE + }, + { + "field": "batch.bytes", + "description": "batch too large; bytes=2048; max_bytes=1024", + "reason": reasons::BATCH_TOO_LARGE + } + ], + "resource_type": resources::EVENT + } + }), + ), + ( + EventBrokerError::SubscriptionRecoveryExhausted { + attempts: 3, + detail: "rejoin failed".into(), + instance: "ignored".into(), + }, + json!({ + "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.service_unavailable.v1~", + "title": "Service Unavailable", + "status": 503, + "detail": "Service temporarily unavailable", + "instance": "/v1/event-broker/test", + "trace_id": "trace-123", + "context": {} + }), + ), + ( + EventBrokerError::InvalidInitialPosition { + topic: "orders".into(), + partition: 7, + requested: "before-retention".into(), + detail: "initial position out of range".into(), + instance: "ignored".into(), + }, + json!({ + "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.out_of_range.v1~", + "title": "Out of Range", + "status": 400, + "detail": "initial position out of range", + "instance": "/v1/event-broker/test", + "trace_id": "trace-123", + "context": { + "field_violations": [{ + "field": "initial_position", + "description": "initial position out of range; requested=before-retention", + "reason": "invalid_initial_position" + }], + "resource_type": resources::PARTITION, + "resource_name": "orders:7" + } + }), + ), + ( + EventBrokerError::PositionsNotSet { + unseeded: vec![("orders".into(), 0), ("orders".into(), 1)], + detail: "positions not seeded".into(), + instance: "ignored".into(), + }, + json!({ + "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.failed_precondition.v1~", + "title": "Failed Precondition", + "status": 400, + "detail": "Operation precondition not met", + "instance": "/v1/event-broker/test", + "trace_id": "trace-123", + "context": { + "violations": [ + { + "type": reasons::POSITIONS_NOT_SET, + "subject": "orders:0", + "description": "positions not seeded" + }, + { + "type": reasons::POSITIONS_NOT_SET, + "subject": "orders:1", + "description": "positions not seeded" + } + ], + "resource_type": resources::STREAM + } + }), + ), + ( + EventBrokerError::PartitionNotAssigned { + topic: "orders".into(), + partition: 2, + detail: "partition not assigned".into(), + instance: "ignored".into(), + }, + json!({ + "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.failed_precondition.v1~", + "title": "Failed Precondition", + "status": 400, + "detail": "Operation precondition not met", + "instance": "/v1/event-broker/test", + "trace_id": "trace-123", + "context": { + "violations": [{ + "type": reasons::PARTITION_NOT_ASSIGNED, + "subject": "orders:2", + "description": "partition not assigned" + }], + "resource_type": resources::PARTITION, + "resource_name": "orders:2" + } + }), + ), + ( + EventBrokerError::StreamingInProgress { + detail: "stream already open".into(), + instance: "ignored".into(), + }, + json!({ + "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.failed_precondition.v1~", + "title": "Failed Precondition", + "status": 400, + "detail": "Operation precondition not met", + "instance": "/v1/event-broker/test", + "trace_id": "trace-123", + "context": { + "violations": [{ + "type": reasons::STREAMING_IN_PROGRESS, + "subject": resources::STREAM, + "description": "stream already open" + }], + "resource_type": resources::STREAM + } + }), + ), + ( + EventBrokerError::Transport("tcp reset by peer".into()), + json!({ + "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.service_unavailable.v1~", + "title": "Service Unavailable", + "status": 503, + "detail": "Service temporarily unavailable", + "instance": "/v1/event-broker/test", + "trace_id": "trace-123", + "context": {} + }), + ), + ( + EventBrokerError::Internal("secret stack frame".into()), + json!({ + "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.internal.v1~", + "title": "Internal", + "status": 500, + "detail": "An internal error occurred. Please retry later.", + "instance": "/v1/event-broker/test", + "trace_id": "trace-123", + "context": {} + }), + ), + ]; + + for (err, expected) in cases { + assert_problem(err, expected); + } +} + +#[test] +fn nested_storage_backend_errors_have_full_canonical_representation() { + let cases = [ + ( + StorageBackendError::Unavailable { + reason: "database refused connection".into(), + detail: "backend unavailable".into(), + instance: "ignored".into(), + }, + json!({ + "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.service_unavailable.v1~", + "title": "Service Unavailable", + "status": 503, + "detail": "Service temporarily unavailable", + "instance": "/v1/event-broker/test", + "trace_id": "trace-123", + "context": {} + }), + ), + ( + StorageBackendError::InvalidConfig { + detail: "missing DSN".into(), + instance: "ignored".into(), + }, + json!({ + "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.invalid_argument.v1~", + "title": "Invalid Argument", + "status": 400, + "detail": "Request validation failed", + "instance": "/v1/event-broker/test", + "trace_id": "trace-123", + "context": { + "field_violations": [{ + "field": "backend_config", + "description": "missing DSN", + "reason": reasons::INVALID_BACKEND_CONFIG + }], + "resource_type": resources::STORAGE + } + }), + ), + ( + StorageBackendError::OffsetOutOfRange { + requested: 10, + oldest: 20, + detail: "offset too old".into(), + instance: "ignored".into(), + }, + json!({ + "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.out_of_range.v1~", + "title": "Out of Range", + "status": 400, + "detail": "offset too old", + "instance": "/v1/event-broker/test", + "trace_id": "trace-123", + "context": { + "field_violations": [{ + "field": "offset", + "description": "offset too old; requested=10; oldest=20", + "reason": "offset_out_of_range" + }], + "resource_type": resources::OFFSET + } + }), + ), + ( + StorageBackendError::PartitionNotFound { + detail: "partition missing".into(), + instance: "ignored".into(), + }, + json!({ + "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.not_found.v1~", + "title": "Not Found", + "status": 404, + "detail": "partition missing", + "instance": "/v1/event-broker/test", + "trace_id": "trace-123", + "context": { + "resource_type": resources::PARTITION, + "resource_name": resources::PARTITION + } + }), + ), + ( + StorageBackendError::PersistFailed { + reason: "unique index detail".into(), + detail: "persist failed".into(), + instance: "ignored".into(), + }, + json!({ + "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.service_unavailable.v1~", + "title": "Service Unavailable", + "status": 503, + "detail": "Service temporarily unavailable", + "instance": "/v1/event-broker/test", + "trace_id": "trace-123", + "context": {} + }), + ), + ( + StorageBackendError::ReadFailed { + reason: "driver timeout".into(), + detail: "read failed".into(), + instance: "ignored".into(), + }, + json!({ + "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.service_unavailable.v1~", + "title": "Service Unavailable", + "status": 503, + "detail": "Service temporarily unavailable", + "instance": "/v1/event-broker/test", + "trace_id": "trace-123", + "context": {} + }), + ), + ( + StorageBackendError::Internal("private storage invariant".into()), + json!({ + "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.internal.v1~", + "title": "Internal", + "status": 500, + "detail": "An internal error occurred. Please retry later.", + "instance": "/v1/event-broker/test", + "trace_id": "trace-123", + "context": {} + }), + ), + ]; + + for (err, expected) in cases { + assert_problem(err, expected); + } +} + +#[test] +fn nested_offset_manager_errors_have_full_canonical_representation() { + let cases = [ + ( + OffsetManagerError::InTxNotSupported { + detail: "store cannot join transaction".into(), + instance: "ignored".into(), + }, + json!({ + "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.failed_precondition.v1~", + "title": "Failed Precondition", + "status": 400, + "detail": "Operation precondition not met", + "instance": "/v1/event-broker/test", + "trace_id": "trace-123", + "context": { + "violations": [{ + "type": reasons::IN_TX_OFFSETS_NOT_SUPPORTED, + "subject": resources::OFFSET, + "description": "store cannot join transaction" + }], + "resource_type": resources::OFFSET + } + }), + ), + ( + OffsetManagerError::persist_failed( + "db write secret", + "offset persist failed", + DbFailure, + ), + json!({ + "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.service_unavailable.v1~", + "title": "Service Unavailable", + "status": 503, + "detail": "Service temporarily unavailable", + "instance": "/v1/event-broker/test", + "trace_id": "trace-123", + "context": {} + }), + ), + ( + OffsetManagerError::load_failed("db read secret", "offset load failed", DbFailure), + json!({ + "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.service_unavailable.v1~", + "title": "Service Unavailable", + "status": 503, + "detail": "Service temporarily unavailable", + "instance": "/v1/event-broker/test", + "trace_id": "trace-123", + "context": {} + }), + ), + ( + OffsetManagerError::Internal("private offset invariant".into()), + json!({ + "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.internal.v1~", + "title": "Internal", + "status": 500, + "detail": "An internal error occurred. Please retry later.", + "instance": "/v1/event-broker/test", + "trace_id": "trace-123", + "context": {} + }), + ), + ]; + + for (err, expected) in cases { + assert_problem(err, expected); + } +} + +#[test] +fn private_diagnostics_do_not_leak_to_production_problem() { + let json = problem_json(EventBrokerError::Internal( + "postgres://user:password@host/db stack frame".into(), + )); + let rendered = serde_json::to_string(&json).expect("json renders"); + + assert!(!rendered.contains("password")); + assert!(!rendered.contains("stack frame")); + assert_eq!( + json["detail"], + "An internal error occurred. Please retry later." + ); +} + +#[test] +fn failed_precondition_uses_canonical_status_without_local_override() { + let json = problem_json(EventBrokerError::StreamingInProgress { + detail: "already open".into(), + instance: "ignored".into(), + }); + + assert_eq!( + json["type"], + "gts://gts.cf.core.errors.err.v1~cf.core.err.failed_precondition.v1~" + ); + assert_eq!(json["status"], 400); +} + +#[test] +fn canonical_to_event_broker_projection_preserves_modeled_and_unmodeled_cases() { + let rate_limited = CanonicalError::from(EventBrokerError::RateLimitExceeded { + retry_after_secs: 25, + detail: "slow down".into(), + instance: String::new(), + }); + match EventBrokerError::from(rate_limited) { + EventBrokerError::RateLimitExceeded { + retry_after_secs: 25, + .. + } => {} + other => panic!("expected rate-limit projection, got {other:?}"), + } + + let not_found = CanonicalError::from(EventBrokerError::TopicNotFound { + topic: "orders".into(), + detail: "topic missing".into(), + instance: String::new(), + }); + match EventBrokerError::from(not_found) { + EventBrokerError::Other { canonical } => { + assert!(matches!(canonical, CanonicalError::NotFound { .. })); + } + other => panic!("expected catch-all projection, got {other:?}"), + } +} diff --git a/gears/system/event-broker/event-broker-sdk/tests/sdk/typed_event.rs b/gears/system/event-broker/event-broker-sdk/tests/sdk/typed_event.rs new file mode 100644 index 000000000..6365909b5 --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/tests/sdk/typed_event.rs @@ -0,0 +1,61 @@ +//! Unit tests for TypedEvent and EnvelopedEvent. + +use std::borrow::Cow; + +use chrono::Utc; +use event_broker_sdk::{EnvelopedEvent, TypedEvent}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +struct OrderCreated { + order_id: Uuid, + total_cents: i64, +} + +impl TypedEvent for OrderCreated { + const TYPE_ID: &'static str = "gts.cf.core.events.event.v1~example.orders.created.v1"; + const TOPIC: &'static str = "gts.cf.core.events.topic.v1~example.orders.v1"; + const SUBJECT_TYPE: &'static str = "gts.cf.core.events.subject.v1~example.order.v1"; + const SOURCE: &'static str = "order-service"; + + fn subject(&self) -> Cow<'_, str> { + Cow::Owned(self.order_id.to_string()) + } +} + +#[test] +fn typed_event_round_trip() { + let original = OrderCreated { + order_id: Uuid::new_v4(), + total_cents: 4299, + }; + let serialised = serde_json::to_vec(&original).unwrap(); + let restored: OrderCreated = serde_json::from_slice(&serialised).unwrap(); + assert_eq!(original, restored); +} + +#[test] +fn enveloped_event_deref() { + let id = Uuid::new_v4(); + let payload = OrderCreated { + order_id: id, + total_cents: 100, + }; + let now = Utc::now(); + let env = EnvelopedEvent { + payload: payload.clone(), + id: Uuid::new_v4(), + tenant_id: Uuid::new_v4(), + subject: id.to_string(), + partition: 3, + sequence: 42, + offset: 10, + occurred_at: now, + sequence_time: now, + trace_parent: None, + }; + assert_eq!(env.order_id, id); + assert_eq!(env.total_cents, 100); + assert_eq!(env.sequence, 42); +} diff --git a/gears/system/event-broker/event-broker-sdk/tests/trybuild/producer/db_rejects_direct_dedup.rs b/gears/system/event-broker/event-broker-sdk/tests/trybuild/producer/db_rejects_direct_dedup.rs new file mode 100644 index 000000000..dc4e072b6 --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/tests/trybuild/producer/db_rejects_direct_dedup.rs @@ -0,0 +1,5 @@ +use event_broker_sdk::{DbProducer, DirectDeduplication}; + +fn main() { + let _ = DbProducer::builder().deduplication(DirectDeduplication::stateless()); +} diff --git a/gears/system/event-broker/event-broker-sdk/tests/trybuild/producer/db_rejects_direct_dedup.stderr b/gears/system/event-broker/event-broker-sdk/tests/trybuild/producer/db_rejects_direct_dedup.stderr new file mode 100644 index 000000000..acfc6f08c --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/tests/trybuild/producer/db_rejects_direct_dedup.stderr @@ -0,0 +1,24 @@ +error[E0277]: the trait bound `DbDeduplication: From` is not satisfied + --> tests/trybuild/producer/db_rejects_direct_dedup.rs:4:49 + | +4 | let _ = DbProducer::builder().deduplication(DirectDeduplication::stateless()); + | ------------- ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `From` is not implemented for `DbDeduplication` + | | + | required by a bound introduced by this call + | +help: the trait `From` is not implemented for `DbDeduplication` + but trait `From` is implemented for it + --> src/producer/types.rs + | + | impl From for DbDeduplication { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + = help: for that trait implementation, expected `producer::types::ManagedDeduplicationBuilder`, found `DirectDeduplication` + = note: required for `DirectDeduplication` to implement `Into` +note: required by a bound in `DbProducerBuilder::::deduplication` + --> src/producer/db.rs + | + | pub fn deduplication( + | ------------- required by a bound in this associated function + | self, + | deduplication: impl Into, + | ^^^^^^^^^^^^^^^^^^^^^ required by this bound in `DbProducerBuilder::::deduplication` diff --git a/gears/system/event-broker/event-broker-sdk/tests/trybuild/producer/direct_rejects_db_dedup.rs b/gears/system/event-broker/event-broker-sdk/tests/trybuild/producer/direct_rejects_db_dedup.rs new file mode 100644 index 000000000..d0ce9cf0e --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/tests/trybuild/producer/direct_rejects_db_dedup.rs @@ -0,0 +1,6 @@ +use event_broker_sdk::{DbDeduplication, Producer, ProducerMode}; + +fn main() { + let _ = Producer::builder() + .deduplication(DbDeduplication::managed(ProducerMode::Chained).key("orders")); +} diff --git a/gears/system/event-broker/event-broker-sdk/tests/trybuild/producer/direct_rejects_db_dedup.stderr b/gears/system/event-broker/event-broker-sdk/tests/trybuild/producer/direct_rejects_db_dedup.stderr new file mode 100644 index 000000000..4bb052003 --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/tests/trybuild/producer/direct_rejects_db_dedup.stderr @@ -0,0 +1,13 @@ +error[E0308]: mismatched types + --> tests/trybuild/producer/direct_rejects_db_dedup.rs:5:24 + | +5 | .deduplication(DbDeduplication::managed(ProducerMode::Chained).key("orders")); + | ------------- ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected `DirectDeduplication`, found `ManagedDeduplicationBuilder` + | | + | arguments to this method are incorrect + | +note: method defined here + --> src/producer/direct.rs + | + | pub fn deduplication( + | ^^^^^^^^^^^^^ diff --git a/gears/system/event-broker/event-broker-sdk/tests/trybuild/producer/missing_direct_broker.rs b/gears/system/event-broker/event-broker-sdk/tests/trybuild/producer/missing_direct_broker.rs new file mode 100644 index 000000000..9f5e3fc1f --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/tests/trybuild/producer/missing_direct_broker.rs @@ -0,0 +1,11 @@ +use event_broker_sdk::{DirectDeduplication, Producer, ProducerIdentity}; + +fn main() { + let _ = Producer::builder() + .security_context(toolkit_security::SecurityContext::anonymous()) + .identity(ProducerIdentity::new().source("orders")) + .deduplication(DirectDeduplication::stateless()) + .topics(["orders"]) + .event_type_patterns(["orders.*"]) + .build(); +} diff --git a/gears/system/event-broker/event-broker-sdk/tests/trybuild/producer/missing_direct_broker.stderr b/gears/system/event-broker/event-broker-sdk/tests/trybuild/producer/missing_direct_broker.stderr new file mode 100644 index 000000000..2fb226c1b --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/tests/trybuild/producer/missing_direct_broker.stderr @@ -0,0 +1,16 @@ +error[E0599]: no method named `build` found for struct `ProducerBuilder` in the current scope + --> tests/trybuild/producer/missing_direct_broker.rs:10:10 + | + 4 | let _ = Producer::builder() + | _____________- + 5 | | .security_context(toolkit_security::SecurityContext::anonymous()) + 6 | | .identity(ProducerIdentity::new().source("orders")) + 7 | | .deduplication(DirectDeduplication::stateless()) + 8 | | .topics(["orders"]) + 9 | | .event_type_patterns(["orders.*"]) +10 | | .build(); + | | -^^^^^ method not found in `ProducerBuilder` + | |_________| + | + | + = note: the method was found for `ProducerBuilder` diff --git a/gears/system/event-broker/event-broker-sdk/tests/usage.rs b/gears/system/event-broker/event-broker-sdk/tests/usage.rs new file mode 100644 index 000000000..e6d9ac453 --- /dev/null +++ b/gears/system/event-broker/event-broker-sdk/tests/usage.rs @@ -0,0 +1,869 @@ +#![cfg(feature = "test-util")] + +//! Executable usage showcase for the event-broker SDK. +//! +//! Each test is a small service story. The important setup stays visible in the +//! test body so this file can be read as wiring guidance, not only as coverage. +//! +//! | Area | Shows | Core APIs | Proof | +//! | --- | --- | --- | --- | +//! | Mock setup | Register topic and event type | `MockBroker`, `MockBrokerHandle` | Topic/type can be used by SDK code | +//! | Direct producer | Publish typed event | `Producer`, `ProducerIdentity`, `DirectDeduplication` | Event is stored by mock | +//! | Persisted/batch producer | Persist-confirming and batch send | `publish_persisted`, `publish_batch` | Outcomes and stored events match | +//! | Chained producer | Broker-issued producer id | `ProducerMode::Chained` | Broker cursor advances | +//! | Consumer | In-memory offset delivery | `ConsumerBuilder`, `InMemoryOffsetManager` | Handler records event data | +//! | Routing | Topic/type dispatch | consumer routes and default handler | Correct handler receives each event | +//! | End-to-end | Producer -> mock -> consumer | producer and consumer together | Handler sees the produced subject | +//! | Outbox producer | Validate and drain durable enqueue | `DbProducer`, toolkit-db outbox | Enqueued row reaches mock | +//! | Multi-topic outbox | One queue for multiple topics | `DbProducer` topics + outbox queue | Both topics are delivered | + +use std::borrow::Cow; +#[cfg(all(feature = "db", feature = "outbox"))] +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use async_trait::async_trait; +use event_broker_sdk::mock::MockBroker; +#[cfg(all(feature = "db", feature = "outbox"))] +use event_broker_sdk::mock::MockBrokerHandle; +use event_broker_sdk::{ + ConsumerBuilder, ConsumerError, ConsumerGroupRef, DirectDeduplication, EventBroker, + EventTypeRef, Fallback, HandlerOutcome, InMemoryOffsetManager, IngestOutcome, Producer, + ProducerIdentity, ProducerMode, RawEvent, SingleEventHandler, SubscriptionInterest, TopicRef, + TypedEvent, +}; +#[cfg(all(feature = "db", feature = "outbox"))] +use event_broker_sdk::{DbDeduplication, DbProducer, EventBrokerError}; +use serde::{Deserialize, Serialize}; +use tokio::time::{Instant, sleep}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +type TestResult = Result>; + +const ORDERS_TOPIC: &str = "gts.cf.core.events.topic.v1~example.sdk.usage.orders.v1"; +#[cfg(all(feature = "db", feature = "outbox"))] +const BILLING_TOPIC: &str = "gts.cf.core.events.topic.v1~example.sdk.usage.billing.v1"; +const ORDER_CREATED: &str = "gts.cf.core.events.event_type.v1~example.sdk.usage.created.v1"; +const ORDER_UPDATED: &str = "gts.cf.core.events.event_type.v1~example.sdk.usage.updated.v1"; +const TENANT_PARTITION: u32 = 2; +#[cfg(all(feature = "db", feature = "outbox"))] +const BILLING_CHARGED: &str = "gts.cf.core.events.event_type.v1~example.sdk.usage.charged.v1"; +const ORDER_SUBJECT: &str = "gts.cf.core.events.subject.v1~example.sdk.usage.order.v1"; +#[cfg(all(feature = "db", feature = "outbox"))] +const BILLING_SUBJECT: &str = "gts.cf.core.events.subject.v1~example.sdk.usage.charge.v1"; +#[cfg(all(feature = "db", feature = "outbox"))] +const PRODUCER_QUEUE: &str = "event-broker-producer"; + +#[cfg(all(feature = "db", feature = "outbox"))] +static USAGE_DB_SEQ: AtomicU64 = AtomicU64::new(1); + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct OrderCreated { + order_id: Uuid, + total_cents: i64, +} + +impl TypedEvent for OrderCreated { + const TYPE_ID: &'static str = ORDER_CREATED; + const TOPIC: &'static str = ORDERS_TOPIC; + const SUBJECT_TYPE: &'static str = ORDER_SUBJECT; + const SOURCE: &'static str = "order-service"; + + fn subject(&self) -> Cow<'_, str> { + Cow::Owned(self.order_id.to_string()) + } + + fn tenant_id(&self) -> Option { + Some(test_tenant()) + } +} + +#[cfg(all(feature = "db", feature = "outbox"))] +#[derive(Debug, Clone, Serialize, Deserialize)] +struct InvalidOrderCreated { + order_id: Uuid, + total_cents: String, +} + +#[cfg(all(feature = "db", feature = "outbox"))] +impl TypedEvent for InvalidOrderCreated { + const TYPE_ID: &'static str = ORDER_CREATED; + const TOPIC: &'static str = ORDERS_TOPIC; + const SUBJECT_TYPE: &'static str = ORDER_SUBJECT; + const SOURCE: &'static str = "order-service"; + + fn subject(&self) -> Cow<'_, str> { + Cow::Owned(self.order_id.to_string()) + } + + fn tenant_id(&self) -> Option { + Some(test_tenant()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct OrderUpdated { + order_id: Uuid, + status: String, +} + +impl TypedEvent for OrderUpdated { + const TYPE_ID: &'static str = ORDER_UPDATED; + const TOPIC: &'static str = ORDERS_TOPIC; + const SUBJECT_TYPE: &'static str = ORDER_SUBJECT; + const SOURCE: &'static str = "order-service"; + + fn subject(&self) -> Cow<'_, str> { + Cow::Owned(self.order_id.to_string()) + } + + fn tenant_id(&self) -> Option { + Some(test_tenant()) + } +} + +#[cfg(all(feature = "db", feature = "outbox"))] +#[derive(Debug, Clone, Serialize, Deserialize)] +struct BillingCharged { + charge_id: Uuid, + total_cents: i64, +} + +#[cfg(all(feature = "db", feature = "outbox"))] +impl TypedEvent for BillingCharged { + const TYPE_ID: &'static str = BILLING_CHARGED; + const TOPIC: &'static str = BILLING_TOPIC; + const SUBJECT_TYPE: &'static str = BILLING_SUBJECT; + const SOURCE: &'static str = "billing-service"; + + fn subject(&self) -> Cow<'_, str> { + Cow::Owned(self.charge_id.to_string()) + } + + fn tenant_id(&self) -> Option { + Some(test_tenant()) + } +} + +struct RecordingHandler { + subjects: Arc>>, +} + +#[async_trait] +impl SingleEventHandler for RecordingHandler { + async fn handle( + &self, + event: RawEvent, + _attempts: u16, + ) -> Result { + self.subjects.lock().unwrap().push(event.subject); + Ok(HandlerOutcome::Success) + } +} + +struct NamedHandler { + name: &'static str, + calls: Arc>>, +} + +#[async_trait] +impl SingleEventHandler for NamedHandler { + async fn handle( + &self, + _event: RawEvent, + _attempts: u16, + ) -> Result { + self.calls.lock().unwrap().push(self.name); + Ok(HandlerOutcome::Success) + } +} + +/// A service can set up the mock broker contract before wiring producers or consumers. +/// +/// Preconditions: the mock starts empty. +/// Expected: the registered topic and event type are visible through the EventBroker API. +#[tokio::test] +async fn mock_registers_order_topic_and_event_type() -> TestResult { + let mock = Arc::new(MockBroker::new()); + let broker: Arc = mock.clone(); + let handle = mock.handle(); + let ctx = SecurityContext::anonymous(); + + handle.register_topic(ORDERS_TOPIC, 4).await; + handle + .register_event_type( + ORDERS_TOPIC, + ORDER_CREATED, + order_created_schema(), + &[ORDER_SUBJECT], + ) + .await; + + let topics = broker.list_topics(&ctx).await?; + let event_type = broker.get_event_type(&ctx, ORDER_CREATED).await?; + + assert!(topics.iter().any(|topic| topic.id == ORDERS_TOPIC)); + assert_eq!(event_type.id, ORDER_CREATED); + assert_eq!(event_type.topic, ORDERS_TOPIC); + + Ok(()) +} + +/// An order service can use the mock as its Event Broker and publish a typed event. +/// +/// Preconditions: the orders topic and schema are registered before `prepare_all`. +/// Expected: the event is accepted and appears in mock storage. +#[tokio::test] +async fn producer_stateless_publish_sends_typed_event() -> TestResult { + let mock = Arc::new(MockBroker::new()); + let broker: Arc = mock.clone(); + let handle = mock.handle(); + + handle.register_topic(ORDERS_TOPIC, 4).await; + handle + .register_event_type( + ORDERS_TOPIC, + ORDER_CREATED, + order_created_schema(), + &[ORDER_SUBJECT], + ) + .await; + + let producer = Producer::builder() + .broker(Arc::clone(&broker)) + .security_context(SecurityContext::anonymous()) + .identity( + ProducerIdentity::new() + .source("order-service") + .client_agent("order-service/1.0"), + ) + .deduplication(DirectDeduplication::stateless()) + .topics([ORDERS_TOPIC]) + .event_type_patterns(["gts.cf.core.events.event_type.v1~example.sdk.usage.*"]) + .prepare_all() + .await?; + + let outcome = producer.publish(order_created()).await?; + + assert_eq!(outcome, IngestOutcome::Accepted); + assert_eq!(handle.stored(ORDERS_TOPIC, TENANT_PARTITION).await.len(), 1); + + Ok(()) +} + +/// A producer can wait for broker-side persistence when the caller needs it. +/// +/// Preconditions: the producer uses the same direct setup as normal publishing. +/// Expected: `publish_persisted` returns `Persisted`. +#[tokio::test] +async fn producer_persisted_publish_waits_for_storage() -> TestResult { + let mock = Arc::new(MockBroker::new()); + let broker: Arc = mock.clone(); + let handle = mock.handle(); + + handle.register_topic(ORDERS_TOPIC, 4).await; + handle + .register_event_type( + ORDERS_TOPIC, + ORDER_CREATED, + order_created_schema(), + &[ORDER_SUBJECT], + ) + .await; + + let producer = Producer::builder() + .broker(Arc::clone(&broker)) + .security_context(SecurityContext::anonymous()) + .identity(ProducerIdentity::new().source("order-service")) + .deduplication(DirectDeduplication::stateless()) + .topics([ORDERS_TOPIC]) + .event_type_patterns([ORDER_CREATED]) + .prepare_all() + .await?; + + let outcome = producer.publish_persisted(order_created()).await?; + + assert_eq!(outcome, IngestOutcome::Persisted); + assert_eq!(handle.stored(ORDERS_TOPIC, TENANT_PARTITION).await.len(), 1); + + Ok(()) +} + +/// A producer can publish multiple typed events in one batch call. +/// +/// Preconditions: all events satisfy the prepared schema and configured topic. +/// Expected: each event is accepted and stored. +#[tokio::test] +async fn producer_batch_publish_sends_multiple_events() -> TestResult { + let mock = Arc::new(MockBroker::new()); + let broker: Arc = mock.clone(); + let handle = mock.handle(); + + handle.register_topic(ORDERS_TOPIC, 4).await; + handle + .register_event_type( + ORDERS_TOPIC, + ORDER_CREATED, + order_created_schema(), + &[ORDER_SUBJECT], + ) + .await; + + let producer = Producer::builder() + .broker(Arc::clone(&broker)) + .security_context(SecurityContext::anonymous()) + .identity(ProducerIdentity::new().source("order-service")) + .deduplication(DirectDeduplication::stateless()) + .topics([ORDERS_TOPIC]) + .event_type_patterns([ORDER_CREATED]) + .prepare_all() + .await?; + + let outcomes = producer + .publish_batch(vec![order_created(), order_created()]) + .await?; + + assert_eq!( + outcomes, + vec![IngestOutcome::Accepted, IngestOutcome::Accepted] + ); + assert_eq!(handle.stored(ORDERS_TOPIC, TENANT_PARTITION).await.len(), 2); + + Ok(()) +} + +/// Chained mode obtains a broker-issued producer id and advances broker cursor state. +/// +/// Preconditions: the producer opts into broker registration at startup. +/// Expected: the producer id comes from Event Broker and publishing advances the cursor. +#[tokio::test] +async fn producer_chained_mode_uses_broker_issued_id_and_sequences_events() -> TestResult { + let mock = Arc::new(MockBroker::new()); + let broker: Arc = mock.clone(); + let handle = mock.handle(); + let ctx = SecurityContext::anonymous(); + + handle.register_topic(ORDERS_TOPIC, 4).await; + handle + .register_event_type( + ORDERS_TOPIC, + ORDER_CREATED, + order_created_schema(), + &[ORDER_SUBJECT], + ) + .await; + + let producer = Producer::builder() + .broker(Arc::clone(&broker)) + .security_context(ctx.clone()) + .identity( + ProducerIdentity::new() + .source("order-service") + .client_agent("order-service/1.0"), + ) + .deduplication(DirectDeduplication::register_on_start( + ProducerMode::Chained, + )) + .topics([ORDERS_TOPIC]) + .event_type_patterns([ORDER_CREATED]) + .prepare_all() + .await?; + + let producer_id = producer.producer_id().expect("broker issued producer id"); + producer.publish(order_created()).await?; + + let cursors = broker.get_producer_cursors(&ctx, producer_id).await?; + let cursor = cursors + .iter() + .find(|cursor| cursor.topic == ORDERS_TOPIC && cursor.partition == TENANT_PARTITION) + .expect("cursor for orders tenant partition"); + + assert_eq!(cursor.last_sequence, 0); + assert_eq!(handle.stored(ORDERS_TOPIC, TENANT_PARTITION).await.len(), 1); + + Ok(()) +} + +/// A consumer can read matching events from the mock with in-memory offsets. +/// +/// Preconditions: the consumer subscribes to the orders topic and created event type. +/// Expected: the handler records the subject of the produced order. +#[tokio::test] +async fn consumer_with_in_memory_offsets_receives_event_from_mock() -> TestResult { + let mock = Arc::new(MockBroker::new()); + let broker: Arc = mock.clone(); + let handle = mock.handle(); + + handle.register_topic(ORDERS_TOPIC, 4).await; + handle + .register_event_type( + ORDERS_TOPIC, + ORDER_CREATED, + order_created_schema(), + &[ORDER_SUBJECT], + ) + .await; + handle + .set_heartbeat_interval(Duration::from_millis(10)) + .await; + + let subjects = Arc::new(Mutex::new(Vec::new())); + let consumer = ConsumerBuilder::new(Arc::clone(&broker)) + .group(ConsumerGroupRef::auto_anonymous("usage-in-memory")) + .subscription_interests([SubscriptionInterest::builder() + .topic(TopicRef::gts(ORDERS_TOPIC)) + .types([EventTypeRef::gts(ORDER_CREATED)]) + .build()?]) + .offset_manager(InMemoryOffsetManager::new(Fallback::Earliest)) + .handler(RecordingHandler { + subjects: Arc::clone(&subjects), + }) + .start() + .await?; + + let producer = Producer::builder() + .broker(Arc::clone(&broker)) + .security_context(SecurityContext::anonymous()) + .identity(ProducerIdentity::new().source("order-service")) + .deduplication(DirectDeduplication::stateless()) + .topics([ORDERS_TOPIC]) + .event_type_patterns([ORDER_CREATED]) + .prepare_all() + .await?; + + let order = order_created(); + let subject = order.order_id.to_string(); + producer.publish(order).await?; + + wait_until(|| subjects.lock().unwrap().contains(&subject)).await; + consumer.stop().await?; + + assert_eq!(subjects.lock().unwrap().as_slice(), [subject]); + + Ok(()) +} + +/// A consumer can route one event type to a specific handler and let another use default handling. +/// +/// Preconditions: the consumer subscribes to the orders topic and registers a route for created events. +/// Expected: created and updated events reach different handlers. +#[tokio::test] +async fn consumer_routes_events_by_topic_and_type() -> TestResult { + let mock = Arc::new(MockBroker::new()); + let broker: Arc = mock.clone(); + let handle = mock.handle(); + + handle.register_topic(ORDERS_TOPIC, 4).await; + handle + .register_event_type( + ORDERS_TOPIC, + ORDER_CREATED, + order_created_schema(), + &[ORDER_SUBJECT], + ) + .await; + handle + .register_event_type( + ORDERS_TOPIC, + ORDER_UPDATED, + order_updated_schema(), + &[ORDER_SUBJECT], + ) + .await; + handle + .set_heartbeat_interval(Duration::from_millis(10)) + .await; + + let calls = Arc::new(Mutex::new(Vec::new())); + let consumer = ConsumerBuilder::new(Arc::clone(&broker)) + .group(ConsumerGroupRef::auto_anonymous("usage-routed")) + .topics([ORDERS_TOPIC]) + .offset_manager(InMemoryOffsetManager::new(Fallback::Earliest)) + .default_handler(NamedHandler { + name: "default", + calls: Arc::clone(&calls), + }) + .route() + .topic(TopicRef::gts(ORDERS_TOPIC)) + .event_type(EventTypeRef::gts(ORDER_CREATED)) + .handler(NamedHandler { + name: "created", + calls: Arc::clone(&calls), + }) + .start() + .await?; + + let producer = Producer::builder() + .broker(Arc::clone(&broker)) + .security_context(SecurityContext::anonymous()) + .identity(ProducerIdentity::new().source("order-service")) + .deduplication(DirectDeduplication::stateless()) + .topics([ORDERS_TOPIC]) + .event_type_patterns(["gts.cf.core.events.event_type.v1~example.sdk.usage.*"]) + .prepare_all() + .await?; + + let order = order_created(); + producer.publish(order.clone()).await?; + producer + .publish(OrderUpdated { + order_id: order.order_id, + status: "paid".to_owned(), + }) + .await?; + + wait_until(|| calls.lock().unwrap().len() == 2).await; + consumer.stop().await?; + + assert_eq!(calls.lock().unwrap().as_slice(), ["created", "default"]); + + Ok(()) +} + +/// A produced event is visible to a consumer reading from the same mock broker. +/// +/// Preconditions: producer and consumer share the same `Arc`. +/// Expected: the consumer observes the exact subject produced by the order service. +#[tokio::test] +async fn producer_and_consumer_round_trip_order_created() -> TestResult { + let mock = Arc::new(MockBroker::new()); + let broker: Arc = mock.clone(); + let handle = mock.handle(); + + handle.register_topic(ORDERS_TOPIC, 4).await; + handle + .register_event_type( + ORDERS_TOPIC, + ORDER_CREATED, + order_created_schema(), + &[ORDER_SUBJECT], + ) + .await; + handle + .set_heartbeat_interval(Duration::from_millis(10)) + .await; + + let received = Arc::new(Mutex::new(Vec::new())); + let consumer = ConsumerBuilder::new(Arc::clone(&broker)) + .group(ConsumerGroupRef::auto_anonymous("usage-round-trip")) + .subscription_interests([SubscriptionInterest::builder() + .topic(TopicRef::gts(ORDERS_TOPIC)) + .types([EventTypeRef::gts(ORDER_CREATED)]) + .build()?]) + .offset_manager(InMemoryOffsetManager::new(Fallback::Earliest)) + .handler(RecordingHandler { + subjects: Arc::clone(&received), + }) + .start() + .await?; + + let producer = Producer::builder() + .broker(Arc::clone(&broker)) + .security_context(SecurityContext::anonymous()) + .identity(ProducerIdentity::new().source("order-service")) + .deduplication(DirectDeduplication::stateless()) + .topics([ORDERS_TOPIC]) + .event_type_patterns([ORDER_CREATED]) + .prepare_all() + .await?; + + let order = order_created(); + let subject = order.order_id.to_string(); + producer.publish(order).await?; + + wait_until(|| received.lock().unwrap().contains(&subject)).await; + consumer.stop().await?; + + assert_eq!(received.lock().unwrap().as_slice(), [subject]); + + Ok(()) +} + +/// A DB-aware producer rejects invalid payloads before a durable outbox row is written. +/// +/// Preconditions: the producer prepared the registered JSON schema before enqueue. +/// Expected: enqueue fails locally and the mock broker stays empty. +#[cfg(all(feature = "db", feature = "outbox"))] +#[tokio::test] +async fn outbox_producer_validates_before_enqueue() -> TestResult { + let db = sqlite_db_with_outbox_and_producer_migrations().await?; + let mock = Arc::new(MockBroker::new()); + let broker: Arc = mock.clone(); + let handle = mock.handle(); + + handle.register_topic(ORDERS_TOPIC, 4).await; + handle + .register_event_type( + ORDERS_TOPIC, + ORDER_CREATED, + order_created_schema(), + &[ORDER_SUBJECT], + ) + .await; + + let producer = DbProducer::builder() + .broker(Arc::clone(&broker)) + .db(db.clone()) + .security_context(SecurityContext::anonymous()) + .identity( + ProducerIdentity::new() + .source("order-service") + .client_agent("order-service/1.0"), + ) + .deduplication(DbDeduplication::stateless()) + .topics([ORDERS_TOPIC]) + .event_type_patterns([ORDER_CREATED]) + .prepare_all() + .await?; + + let event_outbox = + producer.outbox_queue(PRODUCER_QUEUE, toolkit_db::outbox::Partitions::of(4))?; + let producer_handle = event_outbox + .start(toolkit_db::outbox::Outbox::builder(db.clone())) + .await?; + let conn = db.conn()?; + + let err = producer_handle + .outbox() + .enqueue(&conn, invalid_order_created()) + .await + .expect_err("invalid event must fail before enqueue"); + + assert!(matches!(err, EventBrokerError::EventDataInvalid { .. })); + assert!( + handle + .stored(ORDERS_TOPIC, TENANT_PARTITION) + .await + .is_empty() + ); + producer_handle.stop().await; + + Ok(()) +} + +/// A running producer outbox processor drains a durable row into the mock broker. +/// +/// Preconditions: toolkit-db owns the outbox lifecycle; the producer binding supplies the queue. +/// Expected: the enqueued order is eventually stored by the mock broker. +#[cfg(all(feature = "db", feature = "outbox"))] +#[tokio::test] +async fn outbox_producer_drains_queue_to_mock_broker() -> TestResult { + let db = sqlite_db_with_outbox_and_producer_migrations().await?; + let mock = Arc::new(MockBroker::new()); + let broker: Arc = mock.clone(); + let handle = mock.handle(); + + handle.register_topic(ORDERS_TOPIC, 4).await; + handle + .register_event_type( + ORDERS_TOPIC, + ORDER_CREATED, + order_created_schema(), + &[ORDER_SUBJECT], + ) + .await; + + let producer = DbProducer::builder() + .broker(Arc::clone(&broker)) + .db(db.clone()) + .security_context(SecurityContext::anonymous()) + .identity( + ProducerIdentity::new() + .source("order-service") + .client_agent("order-service/1.0"), + ) + .deduplication(DbDeduplication::stateless()) + .topics([ORDERS_TOPIC]) + .event_type_patterns([ORDER_CREATED]) + .prepare_all() + .await?; + + let event_outbox = + producer.outbox_queue(PRODUCER_QUEUE, toolkit_db::outbox::Partitions::of(4))?; + let producer_handle = event_outbox + .start(toolkit_db::outbox::Outbox::builder(db.clone())) + .await?; + let conn = db.conn()?; + + producer_handle + .outbox() + .enqueue(&conn, order_created()) + .await?; + + wait_for_stored(&handle, ORDERS_TOPIC, TENANT_PARTITION, 1).await; + producer_handle.stop().await; + + Ok(()) +} + +/// One producer outbox queue can carry all topics configured on the `DbProducer`. +/// +/// Preconditions: orders and billing topics are both configured on the producer. +/// Expected: both enqueued events are delivered under their own topics. +#[cfg(all(feature = "db", feature = "outbox"))] +#[tokio::test] +async fn single_outbox_queue_can_carry_multiple_topics() -> TestResult { + let db = sqlite_db_with_outbox_and_producer_migrations().await?; + let mock = Arc::new(MockBroker::new()); + let broker: Arc = mock.clone(); + let handle = mock.handle(); + + handle.register_topic(ORDERS_TOPIC, 4).await; + handle.register_topic(BILLING_TOPIC, 4).await; + handle + .register_event_type( + ORDERS_TOPIC, + ORDER_CREATED, + order_created_schema(), + &[ORDER_SUBJECT], + ) + .await; + handle + .register_event_type( + BILLING_TOPIC, + BILLING_CHARGED, + billing_charged_schema(), + &[BILLING_SUBJECT], + ) + .await; + + let producer = DbProducer::builder() + .broker(Arc::clone(&broker)) + .db(db.clone()) + .security_context(SecurityContext::anonymous()) + .identity(ProducerIdentity::new().source("order-service")) + .deduplication(DbDeduplication::stateless()) + .topics([ORDERS_TOPIC, BILLING_TOPIC]) + .event_type_patterns(["gts.cf.core.events.event_type.v1~example.sdk.usage.*"]) + .prepare_all() + .await?; + + let event_outbox = + producer.outbox_queue(PRODUCER_QUEUE, toolkit_db::outbox::Partitions::of(4))?; + let producer_handle = event_outbox + .start(toolkit_db::outbox::Outbox::builder(db.clone())) + .await?; + let conn = db.conn()?; + + producer_handle + .outbox() + .enqueue(&conn, order_created()) + .await?; + producer_handle + .outbox() + .enqueue(&conn, billing_charged()) + .await?; + + wait_for_stored(&handle, ORDERS_TOPIC, TENANT_PARTITION, 1).await; + wait_for_stored(&handle, BILLING_TOPIC, TENANT_PARTITION, 1).await; + producer_handle.stop().await; + + Ok(()) +} + +fn order_created_schema() -> serde_json::Value { + serde_json::json!({ + "type": "object", + "required": ["order_id", "total_cents"], + "properties": { + "order_id": { "type": "string" }, + "total_cents": { "type": "integer" } + } + }) +} + +fn order_updated_schema() -> serde_json::Value { + serde_json::json!({ + "type": "object", + "required": ["order_id", "status"], + "properties": { + "order_id": { "type": "string" }, + "status": { "type": "string" } + } + }) +} + +#[cfg(all(feature = "db", feature = "outbox"))] +fn billing_charged_schema() -> serde_json::Value { + serde_json::json!({ + "type": "object", + "required": ["charge_id", "total_cents"], + "properties": { + "charge_id": { "type": "string" }, + "total_cents": { "type": "integer" } + } + }) +} + +fn order_created() -> OrderCreated { + OrderCreated { + order_id: Uuid::new_v4(), + total_cents: 1200, + } +} + +#[cfg(all(feature = "db", feature = "outbox"))] +fn invalid_order_created() -> InvalidOrderCreated { + InvalidOrderCreated { + order_id: Uuid::new_v4(), + total_cents: "not-an-integer".to_owned(), + } +} + +#[cfg(all(feature = "db", feature = "outbox"))] +fn billing_charged() -> BillingCharged { + BillingCharged { + charge_id: Uuid::new_v4(), + total_cents: 1200, + } +} + +fn test_tenant() -> Uuid { + Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap() +} + +async fn wait_until(mut predicate: impl FnMut() -> bool) { + let deadline = Instant::now() + Duration::from_secs(2); + loop { + if predicate() { + return; + } + assert!(Instant::now() < deadline, "condition was not observed"); + sleep(Duration::from_millis(10)).await; + } +} + +#[cfg(all(feature = "db", feature = "outbox"))] +async fn wait_for_stored(handle: &MockBrokerHandle, topic: &str, partition: u32, count: usize) { + let deadline = Instant::now() + Duration::from_secs(2); + loop { + if handle.stored(topic, partition).await.len() >= count { + return; + } + assert!( + Instant::now() < deadline, + "timed out waiting for {count} stored events on {topic}:{partition}" + ); + sleep(Duration::from_millis(10)).await; + } +} + +#[cfg(all(feature = "db", feature = "outbox"))] +async fn sqlite_db_with_outbox_and_producer_migrations() -> TestResult { + let seq = USAGE_DB_SEQ.fetch_add(1, Ordering::Relaxed); + let dsn = format!("sqlite:file:evbk_usage_showcase_{seq}?mode=memory&cache=shared"); + let db = toolkit_db::connect_db( + &dsn, + toolkit_db::ConnectOpts { + max_conns: Some(1), + ..toolkit_db::ConnectOpts::default() + }, + ) + .await?; + let mut migrations = toolkit_db::outbox::outbox_migrations(); + migrations.extend(event_broker_sdk::producer_registration_migrations()); + toolkit_db::migration_runner::run_migrations_for_testing(&db, migrations).await?; + Ok(db) +} diff --git a/gears/system/event-broker/scenarios/INDEX.md b/gears/system/event-broker/scenarios/INDEX.md new file mode 100644 index 000000000..9ce482e8f --- /dev/null +++ b/gears/system/event-broker/scenarios/INDEX.md @@ -0,0 +1,318 @@ +# Event Broker — Scenario Guide + +Practical companion to [DESIGN.md](../docs/DESIGN.md) and [openapi.yaml](../docs/openapi.yaml). Each scenario is a concrete HTTP exchange: a literal request, the expected response, and the side effects the broker must produce. + +Organized in two parts: + +1. **How To** — the integration journey and per-area reference with mechanism summaries. +2. **Guardrails** — rejections, negative cases, and error shapes. + +--- + +## 1. How To + +### Integration Journey + +To publish and consume events through the broker, follow these steps. Each step links to the scenario(s) with full coverage. + +#### Step 1 — Establish a consumer group + +Anonymous groups are broker-minted via REST. Named groups are registered via `types_registry` at startup — no REST call needed. + +- [Create anonymous group](consumer/groups/1.01-positive-create-anonymous-group.md) — `POST /v1/consumer_groups` → broker mints GTS id; distribute it to your consumer fleet out-of-band. +- [JOIN a named group](consumer/groups/1.08-positive-named-group-join.md) — no creation step; JOIN with the well-known GTS identifier directly. + +#### Step 2 — Publish events + +Producers submit typed events to a topic. Default is async (`202`); opt into sync persistence with `Sync-Wait`. + +- [Publish a single event (async)](producer/single/1.01-positive-publish-single-async.md) — `POST /v1/events` → `202 Accepted`. +- [Publish sync (wait=persisted)](producer/single/1.02-positive-publish-sync-wait-persisted.md) — `POST /v1/events` with `Sync-Wait` header → `201 Created`. + +#### Step 3 — JOIN a subscription + +A consumer instance JOINs the group with topic interests and receives its partition assignment. + +- [Cold JOIN, fresh group](consumer/subscriptions/1.01-positive-cold-join-fresh-group.md) — `POST /v1/subscriptions` → `201` with `assigned[]` + `topology_version`. + +#### Step 4 — SEEK the starting position + +Before streaming, the consumer declares where each assigned partition begins. **This step is required** — opening the stream without it returns `409 Failed Precondition`. + +**Path A — Consumer has a persistent store (service DB, browser IndexedDB, filesystem)** + +Read the last processed offset from your own store and SEEK with the exact integer: + +- [SEEK to exact offset](consumer/positions/1.03-positive-seek-exact-offset.md) — `POST /v1/subscriptions/{id}:seek` with integer offset from own DB. +- [SEEK to timestamp](consumer/positions/1.11-positive-seek-at-timestamp.md) — use `"at:"` sentinel for date-anchored readers; response returns resolved integer for persistence. +- [Full Path A journey](consumer/flows/1.03-flow-path-a-consumer-with-db.md) — end-to-end: JOIN → SEEK from DB → stream → persist offset → reconnect resumes correctly. + +**Path B — No persistent store (one-shot scripts, stateless readers)** + +SEEK with a sentinel; accept reprocessing on restart: + +- [SEEK to earliest](consumer/positions/1.01-positive-seek-earliest.md) — `"earliest"` → cursor = RF − 1 (= 0 on a fresh topic where RF = 1; always ≥ 0); broker emits from RF. +- [SEEK to latest](consumer/positions/1.02-positive-seek-latest.md) — `"latest"` → broker resolves to current HWM; only future events delivered. + +#### Step 5 — Stream events + +Open the long-lived multipart stream and consume frames as they arrive. + +- [Stream multipart frames](consumer/stream/1.01-positive-stream-multipart-frames.md) — `GET /v1/events:stream` → `200 multipart/mixed`; one event per part. +- [SSE event stream](consumer/stream/1.09-positive-sse-event-stream.md) — `GET /v1/events:sse` → `200 text/event-stream`; browser-native alternative. + +> **End-to-end**: [Publish → subscribe → consume](flows/1.01-flow-publish-subscribe-consume.md) composes all steps into one coupled transcript. + +--- + +### Parallel consumer group formation + +Multiple consumer instances share a group by JOINing with the same `consumer_group` identifier. The broker rebalances partitions across all active members automatically. + +**Anonymous group**: one instance calls `POST /v1/consumer_groups` and distributes the returned id out-of-band (shared DB row, ConfigMap, env var). All instances then JOIN with that id. + +**Named group**: no creation step. All instances JOIN with the well-known GTS identifier — no coordination needed. + +- [Second JOIN triggers rebalance](consumer/subscriptions/1.11-positive-second-join-triggers-rebalance.md) — 2nd member joins; `topology` frame splits 4 partitions 2+2. +- [Third JOIN triggers rebalance](consumer/subscriptions/1.12-positive-third-join-triggers-rebalance.md) — 3rd member joins; three-way partition split. + +--- + +### producer/ — Publish events + +#### producer/single/ — `POST /v1/events` (stateless) +- [positive-1.1 — Publish single (async)](producer/single/1.01-positive-publish-single-async.md) — `POST /v1/events` → `202 Accepted`; event enqueued in outbox. +- [positive-1.2 — Publish sync (wait=persisted)](producer/single/1.02-positive-publish-sync-wait-persisted.md) — `POST /v1/events` with `Sync-Wait` → `201 Created` after backend persist. +- [negative-1.5 — Read-only partition rejected](producer/single/1.05-negative-readonly-partition-rejected.md) — producer-supplied `partition` on publish → `400 Bad Request`; broker derives partition itself. + +#### producer/batch/ — `POST /v1/events:batch` +- [positive-1.1 — Publish batch](producer/batch/1.01-positive-publish-batch.md) — `POST /v1/events:batch` → `202 Accepted`; all-or-nothing per topic+partition. + +#### producer/flows/ — `POST /v1/producers`, `GET /cursors`, `POST :reset` +- [positive-1.1 — Register chained producer](producer/flows/1.01-positive-register-chained-producer.md) — `POST /v1/producers { mode: chained }` → `201` with `producer_id`. +- [positive-1.2 — Register monotonic producer](producer/flows/1.02-positive-register-monotonic-producer.md) — `POST /v1/producers { mode: monotonic }` → `201`. +- [positive-1.3 — Chained-mode sequence](producer/flows/1.03-positive-chained-mode-sequence.md) — `POST /v1/events` with `Producer-Id` header and `meta.previous/sequence`; broker deduplicates. +- [positive-1.4 — Idempotency key dedup](producer/flows/1.04-positive-idempotency-key-dedup.md) — duplicate event id returns `200` with original event; no second write. +- [positive-1.6 — Cursor recovery](producer/flows/1.06-positive-cursor-recovery.md) — `GET /v1/producers/{id}/cursors` → `[{topic, partition, last_sequence}]`; feeds next SEEK after desync. +- [positive-1.7 — Chain reset](producer/flows/1.07-positive-chain-reset.md) — `POST /v1/producers/{id}:reset` → `200`; chain state cleared, audited. + +--- + +### consumer/ — Consume events + +#### consumer/groups/ — `POST/GET/DELETE /v1/consumer_groups` +- [positive-1.1 — Create anonymous group](consumer/groups/1.01-positive-create-anonymous-group.md) — `POST /v1/consumer_groups` → `201` with broker-minted GTS id. +- [positive-1.2 — Get group by id](consumer/groups/1.02-positive-get-group-by-id.md) — `GET /v1/consumer_groups/{id}` → full group record. +- [positive-1.3 — List groups](consumer/groups/1.03-positive-list-groups.md) — `GET /v1/consumer_groups` → paged list of caller-visible groups. +- [positive-1.4 — Delete empty group](consumer/groups/1.04-positive-delete-empty-group.md) — `DELETE /v1/consumer_groups/{id}` → `204`; only when no active subscriptions. +- [positive-1.8 — Named group JOIN (no create step)](consumer/groups/1.08-positive-named-group-join.md) — JOIN with `types_registry`-provisioned identifier; broker validates `:consume` grant. + +#### consumer/subscriptions/ — `POST/GET/DELETE /v1/subscriptions` +- [positive-1.1 — Cold JOIN, fresh group](consumer/subscriptions/1.01-positive-cold-join-fresh-group.md) — `POST /v1/subscriptions` → `201` with `assigned[]` and `topology_version`. +- [positive-1.2 — Multi-topic interests](consumer/subscriptions/1.02-positive-join-multi-topic-interests.md) — JOIN with interests across two topics; partition assignment spans both. +- [positive-1.3 — Typed filter](consumer/subscriptions/1.03-positive-join-with-typed-filter.md) — `interest.filter { engine, expression }` applied per-member at delivery. +- [positive-1.4 — Multiple subscriptions (parallelism)](consumer/subscriptions/1.04-positive-parallelism-multiple-subscriptions.md) — second JOIN rebalances partitions 2+2. +- [positive-1.5 — Leave subscription](consumer/subscriptions/1.05-positive-leave-subscription.md) — `DELETE /v1/subscriptions/{id}` → `204`; triggers rebalance. +- [positive-1.9 — List subscriptions](consumer/subscriptions/1.09-positive-list-subscriptions.md) — `GET /v1/subscriptions` → paged list of active subscriptions. +- [positive-1.10 — Read subscription](consumer/subscriptions/1.10-positive-read-subscription.md) — `GET /v1/subscriptions/{id}` → current assignment and expiry. +- [positive-1.11 — Second JOIN triggers rebalance](consumer/subscriptions/1.11-positive-second-join-triggers-rebalance.md) — 2nd instance joins; partitions split 2+2; `topology` frame emitted. +- [positive-1.12 — Third JOIN triggers rebalance](consumer/subscriptions/1.12-positive-third-join-triggers-rebalance.md) — 3rd instance joins; three-way split; all streams updated. + +#### consumer/positions/ — `POST /v1/subscriptions/{id}:seek` (SEEK) +- [positive-1.1 — SEEK earliest](consumer/positions/1.01-positive-seek-earliest.md) — `"earliest"` → cursor = RF − 1 (= 0 on a fresh topic where RF = 1; always ≥ 0); broker emits from RF onwards. +- [positive-1.2 — SEEK latest](consumer/positions/1.02-positive-seek-latest.md) — `"latest"` → cursor set to HWM; only future events delivered. +- [positive-1.3 — SEEK exact offset](consumer/positions/1.03-positive-seek-exact-offset.md) — integer last-processed offset from own DB; broker emits from `offset + 1`. +- [positive-1.4 — Mixed sentinels and integers](consumer/positions/1.04-positive-mixed-sentinels.md) — partitions may mix `"earliest"`, `"latest"`, and exact offsets in one request. +- [positive-1.10 — Any value in retention range](consumer/positions/1.10-positive-seek-any-value-in-range.md) — any integer in `[RF−1, HWM]` is accepted. +- [positive-1.11 — SEEK at timestamp](consumer/positions/1.11-positive-seek-at-timestamp.md) — `"at:"` → resolves to first event at or after timestamp; response returns integer. +- [positive-1.12 — Timestamp before retention](consumer/positions/1.12-positive-seek-at-timestamp-before-retention.md) — timestamp before RF → clamps to RF. +- [positive-1.13 — Timestamp beyond HWM](consumer/positions/1.13-positive-seek-at-timestamp-beyond-hwm.md) — future timestamp → resolves to HWM (equivalent to `"latest"`). + +#### consumer/stream/ — `GET /v1/events:stream`, `GET /v1/events:sse` +- [positive-1.1 — Multipart frames](consumer/stream/1.01-positive-stream-multipart-frames.md) — `GET /v1/events:stream` → `200 multipart/mixed`; `event`, `heartbeat`, `advisory`, `topology` frame kinds. +- [positive-1.2 — Heartbeat cadence](consumer/stream/1.02-positive-stream-heartbeat-cadence.md) — idle stream emits `heartbeat` every 5 s; keeps connection alive through proxies. +- [positive-1.3 — Topology frame on rebalance](consumer/stream/1.03-positive-stream-topology-frame-on-rebalance.md) — mid-stream JOIN by another member triggers `topology` frame with new `assigned[]`. +- [positive-1.9 — SSE event stream](consumer/stream/1.09-positive-sse-event-stream.md) — `GET /v1/events:sse` → `200 text/event-stream`; same frame schema as multipart. + +#### consumer/flows/ — consumer-only end-to-end journeys +- [flow-1.1 — Two-consumer rebalance](consumer/flows/1.01-flow-two-consumer-rebalance.md) — full inline transcript: consumer A holds all partitions; B joins; rebalance; both stream. +- [flow-1.2 — PositionsNotSet recovery](consumer/flows/1.02-flow-positions-not-set-recovery.md) — SDK mis-SEEKs; broker returns `409`; SDK re-SEEKs and resumes. +- [flow-1.3 — Path A consumer with DB](consumer/flows/1.03-flow-path-a-consumer-with-db.md) — consumer reads own DB → SEEK exact offset → stream → persist offset → reconnect resumes from correct position. + +--- + +### topics/ — Topic introspection + +- [positive-1.1 — List topics](topics/1.01-positive-list-topics.md) — `GET /v1/topics` → paged list; `partitions` field exposes partition count. +- [positive-1.2 — List topic segments](topics/1.02-positive-list-topic-segments.md) — `GET /v1/topics/segments?topic=...&partition=...` → segment manifest with RF/HWM per segment. +- [positive-1.4 — List event types](topics/1.04-positive-list-event-types.md) — `GET /v1/event_types?topic=...` → paged list of event type registrations. + +--- + +### flows/ — Coupled producer + consumer journeys + +- [flow-1.1 — Publish → subscribe → consume](flows/1.01-flow-publish-subscribe-consume.md) — producer publishes 3 events; consumer creates group, JOINs, SEEKs, streams, processes. + +--- + +## 2. Guardrails + +### Auth & permissions + +- [negative-1.1 — Missing bearer token](auth/1.01-negative-missing-bearer-token.md) — no `Authorization` header → `401 Unauthenticated` on any endpoint. +- [negative-1.2 — Invalid bearer token](auth/1.02-negative-invalid-bearer-token.md) — expired or malformed token → `401 Unauthenticated`. +- [negative-1.3 — No produce permission](auth/1.03-negative-insufficient-permission-produce.md) — `POST /v1/events` without `topic:produce` → `403 Permission Denied`. +- [negative-1.4 — No consume permission](auth/1.04-negative-insufficient-permission-consume.md) — `POST /v1/subscriptions` without `topic:consume` → `403 Permission Denied`. +- [negative-1.5 — Cross-tenant anonymous group](auth/1.05-negative-cross-tenant-anonymous-group.md) — tenant B JOINs tenant A's anonymous group → `403 Permission Denied`. +- [negative-1.6 — Unauthorized topic JOIN](consumer/subscriptions/1.06-negative-join-unauthorized-topic.md) — interest references a topic the principal cannot consume → `403`. + +### Input validation + +- [negative-1.3 — Schema validation failure](producer/single/1.03-negative-schema-validation-failure.md) — `event.data` fails JSON Schema → `422 Invalid Argument`. +- [negative-1.5 — Read-only partition rejected](producer/single/1.05-negative-readonly-partition-rejected.md) — producer-supplied `partition` on publish → `400 Bad Request`; `partition` is consumer-facing/read-side only. +- [negative-1.2 — Mixed-partition batch](producer/batch/1.02-negative-mixed-partition-batch.md) — batch events span different partitions → `400 Invalid Argument`. +- [negative-1.3 — Batch too large](producer/batch/1.03-negative-batch-too-large.md) — over 100 events or 1 MiB → `400 Invalid Argument`. +- [negative-1.4 — Batch late validation failure](producer/batch/1.04-negative-batch-late-validation-failure.md) — later invalid event rejects whole batch → `400 Invalid Argument`. +- [negative-1.7 — Too many interests](consumer/subscriptions/1.07-negative-join-too-many-interests.md) — more than 64 interests in one JOIN → `400 Invalid Argument`. +- [negative-1.6 — Invalid client_agent](consumer/groups/1.06-negative-invalid-client-agent.md) — non-ASCII or oversized `client_agent` → `400 Invalid Argument`. +- [guardrail-1.7 — Stream requires multipart Accept](consumer/stream/1.07-guardrail-stream-accept-json-rejected.md) — `Accept: application/json` on `:stream` endpoint → `406 Invalid Argument`. +- [guardrail-1.8 — SSE from multipart endpoint](consumer/stream/1.08-guardrail-sse-from-stream-endpoint.md) — `Accept: text/event-stream` on `/events:stream` → `406`; use `/events:sse` instead. +- [negative-1.10 — Stream rejects timeout/collect params](consumer/stream/1.10-negative-stream-rejects-timeout-collect-params.md) — unsupported query params on `:stream` → `400 Invalid Argument`. + +### Seek / cursor errors + +- [negative-1.5 — Out-of-range offset](consumer/positions/1.05-negative-out-of-range-offset.md) — offset below RF−1 → `400 Invalid Argument`. +- [negative-1.6 — Offset above HWM](consumer/positions/1.06-negative-offset-above-hwm.md) — offset beyond HWM → `400 Invalid Argument`. +- [negative-1.7 — SEEK while streaming](consumer/positions/1.07-negative-seek-while-streaming.md) — any SEEK while `:stream` is open → `409 StreamingInProgress` (SEEK is pre-stream-only). +- [negative-1.9 — SEEK unassigned partition](consumer/positions/1.09-negative-seek-unassigned-partition.md) — SEEK references a partition not in `assigned[]` → `409 Failed Precondition`. + +### Stream errors + +- [negative-1.4 — PositionsNotSet](consumer/stream/1.04-negative-stream-positions-not-set.md) — stream opened without prior SEEK → `409 Failed Precondition`; `context.unseeded` lists affected partitions. +- [negative-1.5 — Unknown subscription](consumer/stream/1.05-negative-stream-unknown-subscription.md) — `subscription_id` not found or expired → `404 Not Found`. +- [negative-1.6 — Terminated subscription](consumer/stream/1.06-negative-stream-terminated-subscription.md) — delivery shard shutdown sends `410`; consumer re-JOINs. + +### Producer chain errors + +- [negative-1.5 — Chained sequence violation](producer/flows/1.05-negative-chained-sequence-violation.md) — `meta.previous` doesn't match broker's `last_sequence` → `412 Failed Precondition`; recover via `GET /v1/producers/{id}/cursors`. +- [negative-1.8 — Unknown producer](producer/flows/1.08-negative-unknown-producer.md) — `Producer-Id` not registered or reaped → `400 Invalid Argument`. + +### Consumer group errors + +- [negative-1.5 — Delete group with active members](consumer/groups/1.05-negative-delete-group-with-active-members.md) — `DELETE` while subscriptions exist → `409 Failed Precondition`. +- [negative-1.7 — Get unknown group](consumer/groups/1.07-negative-get-unknown-group.md) — `GET /v1/consumer_groups/{id}` for non-existent id → `404 Not Found`. +- [negative-1.8 — LEAVE unknown subscription](consumer/subscriptions/1.08-negative-leave-unknown-subscription.md) — `DELETE /v1/subscriptions/{id}` for expired/unknown id → `404 Not Found`. + +### Topics / segments errors + +- [negative-1.3 — Segments for unknown topic](topics/1.03-negative-segments-unknown-topic.md) — `GET /v1/topics/segments` with unregistered topic → `404 Not Found`. + +### Rate limiting + +- [negative-1.4 — Publish rate limited](producer/single/1.04-negative-rate-limited.md) — publish exceeds per-tenant quota → `429 Resource Exhausted` with `Retry-After`. + +### Error envelope reference + +- [1.01 — Problem Details envelope](errors/1.01-positive-problem-details-envelope.md) — canonical RFC-9457 + GTS shape; all broker errors use this format. +- [1.02 — 401 Unauthenticated](errors/1.02-negative-401-unauthenticated.md) +- [1.03 — 403 Permission Denied](errors/1.03-negative-403-unauthorized.md) +- [1.04 — 404 Not Found](errors/1.04-negative-404-not-found.md) +- [1.05 — 409 Failed Precondition](errors/1.05-negative-409-conflict.md) +- [1.06 — 412 Failed Precondition (sequence)](errors/1.06-negative-412-sequence-violation.md) +- [1.07 — 429 Resource Exhausted](errors/1.07-negative-429-rate-limited.md) +- [1.08 — 500 Internal](errors/1.08-negative-500-internal.md) + +--- + +## 3. Authoring Rules + +### Folder assignment + +Put a scenario in the folder that matches what a reader is trying to understand — not which endpoint it calls. + +| Question | Folder | +|---|---| +| "How do I publish an event?" | `producer/single/` or `producer/batch/` | +| "How does the idempotent producer protocol work?" | `producer/flows/` | +| "How do I create / manage a group?" | `consumer/groups/` | +| "How do I join / leave a subscription?" | `consumer/subscriptions/` | +| "How do I set or change my position?" | `consumer/positions/` | +| "How does the stream / SSE transport work?" | `consumer/stream/` | +| "What are the topic segment offsets?" | `topics/` | +| "What happens when auth fails?" | `auth/` | +| "What does a specific error code look like?" | `errors/` | +| "Show me a producer-only end-to-end journey" | `producer/flows/` | +| "Show me a consumer-only end-to-end journey" | `consumer/flows/` | +| "Show me publish + consume together" | `flows/` (top-level) | + +### Naming convention + +`{positive|negative|guardrail}-{area-number}.{seq}-{slug}.md` + +Numbers are relative to the sub-area folder (restart at 1.1 for each new folder). Slugs are kebab-case; describe the behavior, not the endpoint. + +### Cross-reference format + +Use relative paths from the scenario file. Example from `consumer/subscriptions/`: + +```markdown +[Create anonymous group](../groups/1.01-positive-create-anonymous-group.md) +``` + +### Flows placement + +| Journey involves | Use | +|---|---| +| Only producer-side exchanges | `producer/flows/` | +| Only consumer-side exchanges | `consumer/flows/` | +| Both producer and consumer in same transcript | `flows/` (top-level) | + +### Error format + +All error response bodies MUST use the canonical GTS + RFC-9457 shape: + +```json +{ + "type": "gts://gts.cf.core.errors.err.v1~cf.core.err..v1~", + "title": "", + "status": , + "detail": "", + "instance": "", + "context": { "" } +} +``` + +HTTP status → category mapping: + +| Status | Category | `title` | +|---|---|---| +| 400, 422 | `invalid_argument` | `"Invalid Argument"` | +| 401 | `unauthenticated` | `"Unauthenticated"` | +| 403 | `permission_denied` | `"Permission Denied"` | +| 404, 410 | `not_found` | `"Not Found"` | +| 409, 412 | `failed_precondition` | `"Failed Precondition"` | +| 429 | `resource_exhausted` | `"Resource Exhausted"` | +| 500 | `internal` | `"Internal"` | + +Domain-specific fields (e.g., `unseeded`, `expected_previous`, `valid_range`) go inside `context`, not at root level. + +### Side-effects predicate vocabulary + +| Kind | Form | +|---|---| +| State | `() is set to ` · `
() is absent` · `
() advances from to ` | +| Frame | `subscription next frame is with ` · `subscription emits within ` | +| Reply | `subsequent returns ` | +| Lifecycle | `subscription is reaped after ` · `consumer-group is deleted` | +| Metric / audit | `metric incremented by ` · `audit log entry created` | + +### Legend + +| Shorthand | Meaning | +|---|---| +| `PD` | RFC-9457 Problem Details (`application/problem+json`). Implied by any `4xx` / `5xx`. | +| `RF` | Partition retention floor — smallest offset still readable. | +| `HWM` | Partition high-water mark — offset of the next event to be admitted. | +| `MP` | Multipart frame on `:stream`. | +| `SSE` | Server-Sent Event frame on `:sse`. | +| `Cursor` | Runtime/session cursor value for a `(group, topic, partition)` assignment — last-processed offset; broker emits from `Cursor + 1`. | diff --git a/gears/system/event-broker/scenarios/auth/1.01-negative-missing-bearer-token.md b/gears/system/event-broker/scenarios/auth/1.01-negative-missing-bearer-token.md new file mode 100644 index 000000000..cea761ff3 --- /dev/null +++ b/gears/system/event-broker/scenarios/auth/1.01-negative-missing-bearer-token.md @@ -0,0 +1,34 @@ +# Request without Authorization header → 401 + +All event-broker endpoints require a valid Bearer token. A request with no `Authorization` header is rejected before any resource lookup. + +## Request + +```http +POST /v1/events HTTP/1.1 +Host: broker.example.com +Content-Type: application/json + +{ "id": "...", "type": "...", "topic": "...", "tenant_id": "...", "source": "x", "subject": "y", "subject_type": "...", "occurred_at": "2026-06-15T10:00:00Z", "data": {} } +``` + +## Expected response + +- `401 Unauthenticated` + +```json +{ + "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.unauthenticated.v1~", + "title": "Unauthenticated", + "status": 401, + "detail": "Authorization header is missing", + "instance": "/v1/events", + "trace_id": "", + "context": {} +} +``` + +## Side effects + +- No event is processed or stored. +- Applies identically to every event-broker endpoint (`GET /v1/topics`, `POST /v1/consumer_groups`, `POST /v1/subscriptions`, etc.). diff --git a/gears/system/event-broker/scenarios/auth/1.02-negative-invalid-bearer-token.md b/gears/system/event-broker/scenarios/auth/1.02-negative-invalid-bearer-token.md new file mode 100644 index 000000000..a89b17635 --- /dev/null +++ b/gears/system/event-broker/scenarios/auth/1.02-negative-invalid-bearer-token.md @@ -0,0 +1,32 @@ +# Expired or malformed token → 401 + +A request with an `Authorization: Bearer` header whose token is expired, malformed, or signed with an unknown key is rejected by `toolkit-security` before any event-broker logic runs. + +## Request + +```http +GET /v1/topics HTTP/1.1 +Host: broker.example.com +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.EXPIRED.signature +``` + +## Expected response + +- `401 Unauthenticated` + +```json +{ + "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.unauthenticated.v1~", + "title": "Unauthenticated", + "status": 401, + "detail": "Bearer token is expired or invalid", + "instance": "/v1/topics", + "trace_id": "", + "context": {} +} +``` + +## Side effects + +- No resource is accessed. +- The response body does not indicate whether the token was expired vs. malformed — both surface as the same `unauthenticated` type to prevent oracle attacks. diff --git a/gears/system/event-broker/scenarios/auth/1.03-negative-insufficient-permission-produce.md b/gears/system/event-broker/scenarios/auth/1.03-negative-insufficient-permission-produce.md new file mode 100644 index 000000000..fb49f9322 --- /dev/null +++ b/gears/system/event-broker/scenarios/auth/1.03-negative-insufficient-permission-produce.md @@ -0,0 +1,47 @@ +# Principal without topic:produce permission → 403 + +Publishing to a topic requires the `produce` action on that topic's GTS resource. A principal with a valid token but no `produce` grant is rejected before any ingest logic runs. + +## Request + +```http +POST /v1/events HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Content-Type: application/json + +{ + "id": "11111111-0000-0000-0000-000000000001", + "type": "gts.cf.core.events.event.v1~acme.orders.created.v1", + "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", + "tenant_id": "", + "source": "order-service", + "subject": "order-1", + "subject_type": "gts.cf.core.events.subject.v1~acme.order.v1", + "occurred_at": "2026-06-15T10:00:00Z", + "data": { "order_id": "order-1" } +} +``` + +## Expected response + +- `403 Permission Denied` + +```json +{ + "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.permission_denied.v1~", + "title": "Permission Denied", + "status": 403, + "detail": "principal lacks produce permission on topic gts.cf.core.events.topic.v1~acme.orders.v1", + "instance": "/v1/events", + "trace_id": "", + "context": { + "reason": "missing grant: gts.cf.core.events.topic.v1~acme.orders.v1:produce" + } +} +``` + +## Side effects + +- No event is stored or enqueued. +- The response does not reveal whether the topic exists, to prevent existence probing by unauthorized principals. diff --git a/gears/system/event-broker/scenarios/auth/1.04-negative-insufficient-permission-consume.md b/gears/system/event-broker/scenarios/auth/1.04-negative-insufficient-permission-consume.md new file mode 100644 index 000000000..4dedc7cc1 --- /dev/null +++ b/gears/system/event-broker/scenarios/auth/1.04-negative-insufficient-permission-consume.md @@ -0,0 +1,51 @@ +# Principal without topic:consume permission → 403 + +JOINing a subscription requires the `consume` action on the topic (and on each event type in the `interests[]`). A principal lacking `consume` is rejected at JOIN time — no subscription is created. + +## Setup + +- Group `{group_id}` exists (anonymous, owned by the caller's tenant). + +## Request + +```http +POST /v1/subscriptions HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Content-Type: application/json + +{ + "consumer_group": "{group_id}", + "client_agent": "report-runner/1.0.0", + "session_timeout": "PT30S", + "interests": [ + { + "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", + "tenant_id": "", + "types": ["gts.cf.core.events.event.v1~acme.orders.*"] + } + ] +} +``` + +## Expected response + +- `403 Permission Denied` + +```json +{ + "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.permission_denied.v1~", + "title": "Permission Denied", + "status": 403, + "detail": "principal lacks consume permission on topic gts.cf.core.events.topic.v1~acme.orders.v1", + "instance": "/v1/subscriptions", + "trace_id": "", + "context": { + "reason": "missing grant: gts.cf.core.events.topic.v1~acme.orders.v1:consume" + } +} +``` + +## Side effects + +- No subscription is created. No group state is modified. diff --git a/gears/system/event-broker/scenarios/auth/1.05-negative-cross-tenant-anonymous-group.md b/gears/system/event-broker/scenarios/auth/1.05-negative-cross-tenant-anonymous-group.md new file mode 100644 index 000000000..e9875e116 --- /dev/null +++ b/gears/system/event-broker/scenarios/auth/1.05-negative-cross-tenant-anonymous-group.md @@ -0,0 +1,54 @@ +# Tenant B cannot JOIN tenant A's anonymous consumer group → 403 + +Anonymous consumer groups are tenant-bound at creation: the creating tenant's principal is the owner. A different tenant trying to JOIN with the same group identifier is rejected — the broker validates `caller.tenant_id == group.owner_tenant_id`. + +## Setup + +- Tenant A created group `gts.cf.core.events.consumer_group.v1~{uuid}` via `POST /v1/consumer_groups`. The group is owned by tenant A. +- Tenant B somehow obtained the group's GTS identifier (e.g., it appeared in a log, was guessed, etc.). + +## Request (tenant B) + +```http +POST /v1/subscriptions HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Content-Type: application/json + +{ + "consumer_group": "gts.cf.core.events.consumer_group.v1~{uuid}", + "client_agent": "adversary/1.0.0", + "session_timeout": "PT30S", + "interests": [ + { + "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", + "tenant_id": "", + "types": ["gts.cf.core.events.event.v1~acme.orders.*"] + } + ] +} +``` + +## Expected response + +- `403 Permission Denied` + +```json +{ + "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.permission_denied.v1~", + "title": "Permission Denied", + "status": 403, + "detail": "consumer group is owned by a different tenant", + "instance": "/v1/subscriptions", + "trace_id": "", + "context": { + "reason": "anonymous groups are tenant-private; cross-tenant sharing requires a named group with explicit :consume grants" + } +} +``` + +## Side effects + +- No subscription is created. No group state is modified. +- Tenant A's group and any active subscriptions within it are unaffected. +- The error does not reveal tenant A's identity or any details of the group's contents. diff --git a/gears/system/event-broker/scenarios/consumer/flows/1.01-flow-two-consumer-rebalance.md b/gears/system/event-broker/scenarios/consumer/flows/1.01-flow-two-consumer-rebalance.md new file mode 100644 index 000000000..5cab80117 --- /dev/null +++ b/gears/system/event-broker/scenarios/consumer/flows/1.01-flow-two-consumer-rebalance.md @@ -0,0 +1,191 @@ +# Flow: two-consumer rebalance + +The full HTTP transcript of a group growing from one consumer to two. Consumer A owns all partitions and is streaming; consumer B JOINs, triggering a rebalance; A's open stream receives a `topology` frame; B SEEKs the partitions it gained and streams them. + +Every exchange is shown inline. This transcript is strictly broker-observable: it shows the calls each client makes and the broker's replies. Client-side policy for choosing SEEK positions after a rebalance is SDK behavior and is outside this scenario file. + +Topic `gts.cf.core.events.topic.v1~acme.orders.v1` has 4 partitions; group `{group_id}` already exists. + +--- + +## Exchange 1 — Consumer A JOINs (owns all 4 partitions) + +```http +POST /v1/subscriptions HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Content-Type: application/json + +{ + "consumer_group": "{group_id}", + "client_agent": "order-worker/1.4.0", + "interests": [ { "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", "tenant_id": "", "types": ["gts.cf.core.events.event.v1~acme.orders.*"] } ] +} +``` + +```http +HTTP/1.1 201 Created +Content-Type: application/json + +{ + "id": "", + "assigned": [ + { "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", "partition": 0 }, + { "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", "partition": 1 }, + { "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", "partition": 2 }, + { "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", "partition": 3 } + ], + "topology_version": 1 +} +``` + +## Exchange 2 — A SEEKs all four, then opens its stream + +```http +POST /v1/subscriptions/:seek HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Content-Type: application/json + +{ + "partition_positions": { + "gts.cf.core.events.topic.v1~acme.orders.v1:0": "earliest", + "gts.cf.core.events.topic.v1~acme.orders.v1:1": "earliest", + "gts.cf.core.events.topic.v1~acme.orders.v1:2": "earliest", + "gts.cf.core.events.topic.v1~acme.orders.v1:3": "earliest" + } +} +``` + +```http +HTTP/1.1 200 OK +Content-Type: application/json + +{ "partition_positions": { "gts.cf.core.events.topic.v1~acme.orders.v1:0": 99, "gts.cf.core.events.topic.v1~acme.orders.v1:1": -1, "gts.cf.core.events.topic.v1~acme.orders.v1:2": -1, "gts.cf.core.events.topic.v1~acme.orders.v1:3": -1 } } +``` + +```http +GET /v1/events:stream?subscription_id= HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Accept: multipart/mixed +``` + +```http +HTTP/1.1 200 OK +Content-Type: multipart/mixed; boundary=evbkA1 +Transfer-Encoding: chunked + +--evbkA1 +Content-Type: application/json + +{ "kind": "heartbeat" } +``` + +A's stream stays open from here on. + +## Exchange 3 — Consumer B JOINs the same group (triggers rebalance) + +```http +POST /v1/subscriptions HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Content-Type: application/json + +{ + "consumer_group": "{group_id}", + "client_agent": "order-worker/1.4.0", + "interests": [ { "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", "tenant_id": "", "types": ["gts.cf.core.events.event.v1~acme.orders.*"] } ] +} +``` + +```http +HTTP/1.1 201 Created +Content-Type: application/json + +{ + "id": "", + "assigned": [ + { "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", "partition": 2 }, + { "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", "partition": 3 } + ], + "topology_version": 2 +} +``` + +The broker rebalances to A=`[0,1]`, B=`[2,3]`. + +## Exchange 4 — A's open stream receives a topology frame + +On A's stream from Exchange 2, the next part is a `topology` frame (the stream is not closed): + +``` +--evbkA1 +Content-Type: application/json + +{ + "kind": "topology", + "topology_version": 2, + "assigned": [ + { "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", "partition": 0, "offset": 110, "last_examined": 110 }, + { "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", "partition": 1, "offset": 8, "last_examined": 8 } + ] +} +``` + +A keeps `0,1`; the broker has stopped delivering `2,3` to A. (Cursors for the continuing partitions `0,1` are unchanged.) + +## Exchange 5 — B SEEKs its gained partitions + +```http +POST /v1/subscriptions/:seek HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Content-Type: application/json + +{ + "partition_positions": { + "gts.cf.core.events.topic.v1~acme.orders.v1:2": "earliest", + "gts.cf.core.events.topic.v1~acme.orders.v1:3": "earliest" + } +} +``` + +```http +HTTP/1.1 200 OK +Content-Type: application/json + +{ "partition_positions": { "gts.cf.core.events.topic.v1~acme.orders.v1:2": -1, "gts.cf.core.events.topic.v1~acme.orders.v1:3": -1 } } +``` + +## Exchange 6 — B opens its stream + +```http +GET /v1/events:stream?subscription_id= HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Accept: multipart/mixed +``` + +```http +HTTP/1.1 200 OK +Content-Type: multipart/mixed; boundary=evbkB1 +Transfer-Encoding: chunked + +--evbkB1 +Content-Type: application/json + +{ "kind": "heartbeat" } +``` + +Had B opened its stream *before* Exchange 5, the broker would have replied `409 PositionsNotSet` for `2,3` (see [stream/negative-1.4](../stream/1.04-negative-stream-positions-not-set.md)). + +--- + +## Side effects (cumulative) + +- Group `{group_id}` topology_version advances `1 → 2`. +- `evbk_subscription().assigned` becomes `[0,1]`; `evbk_subscription().assigned` is `[2,3]`. +- Session cursor state for partitions `0,1` is unchanged across the rebalance; partitions `2,3` reflect B's SEEK from Exchange 5. +- No partition is delivered to both A and B simultaneously. +- At-least-once: any `2,3` events A had in flight when the rebalance hit are redelivered to B (consumers must be idempotent — DESIGN §2.1). diff --git a/gears/system/event-broker/scenarios/consumer/flows/1.02-flow-positions-not-set-recovery.md b/gears/system/event-broker/scenarios/consumer/flows/1.02-flow-positions-not-set-recovery.md new file mode 100644 index 000000000..864ec98a2 --- /dev/null +++ b/gears/system/event-broker/scenarios/consumer/flows/1.02-flow-positions-not-set-recovery.md @@ -0,0 +1,90 @@ +# Flow: PositionsNotSet recovery + +The full HTTP transcript of the recovery sequence when a client opens the stream before seeding a partition: the broker replies `409 PositionsNotSet`, the client SEEKs the listed partitions, and the retried stream succeeds. + +Every exchange is shown inline. This is the broker-observable recovery sequence. Client-side retry policy and retry budgets are SDK behavior and are outside this scenario file. + +Subscription `{sub_id}` has JOINed group `{group_id}` and is assigned `("acme.orders.v1", 0)`; no SEEK has happened yet. + +--- + +## Exchange 1 — Open the stream without seeding (rejected) + +```http +GET /v1/events:stream?subscription_id={sub_id} HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Accept: multipart/mixed +``` + +```http +HTTP/1.1 409 Conflict +Content-Type: application/problem+json + +{ + "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.failed_precondition.v1~", + "title": "Failed Precondition", + "status": 409, + "detail": "1 assigned partition(s) have no committed cursor", + "instance": "/v1/events:stream", + "context": { + "unseeded": [ + { "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", "partition": 0 } + ], + "recovery_hint": "Call POST /v1/subscriptions/{id}:seek for the unseeded partitions before re-opening the stream." + } +} +``` + +## Exchange 2 — SEEK the unseeded partitions + +The client SEEKs exactly the partitions named in `unseeded[]`: + +```http +POST /v1/subscriptions/{sub_id}:seek HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Content-Type: application/json + +{ + "partition_positions": { + "gts.cf.core.events.topic.v1~acme.orders.v1:0": "earliest" + } +} +``` + +```http +HTTP/1.1 200 OK +Content-Type: application/json + +{ "partition_positions": { "gts.cf.core.events.topic.v1~acme.orders.v1:0": 99 } } +``` + +## Exchange 3 — Retry the stream (succeeds) + +```http +GET /v1/events:stream?subscription_id={sub_id} HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Accept: multipart/mixed +``` + +```http +HTTP/1.1 200 OK +Content-Type: multipart/mixed; boundary=evbkR1 +Transfer-Encoding: chunked + +--evbkR1 +Content-Type: application/json + +{ "kind": "event", "event": { "offset": 100, "partition": 0, ... } } +``` + +--- + +## Side effects + +- After Exchange 1: no stream established, session cursor for `(group, "acme.orders.v1", 0)` still absent (the 409 changes nothing). +- After Exchange 2: session cursor for `(group, "acme.orders.v1", 0)` is set to `99`. +- After Exchange 3: `subsequent GET /v1/events:stream returns 200`; emission begins at offset `100`. +- `metric stream_positions_not_set incremented by 1` (the broker counts the rejected open). diff --git a/gears/system/event-broker/scenarios/consumer/flows/1.03-flow-path-a-consumer-with-db.md b/gears/system/event-broker/scenarios/consumer/flows/1.03-flow-path-a-consumer-with-db.md new file mode 100644 index 000000000..9e06b50a5 --- /dev/null +++ b/gears/system/event-broker/scenarios/consumer/flows/1.03-flow-path-a-consumer-with-db.md @@ -0,0 +1,136 @@ +# Flow: consumer with external DB (Path A) + +A consumer that persists processed offsets in its own database. On startup it reads the last committed offset per partition and SEEKs to that exact position. On reconnect after a crash, it resumes without reprocessing already-committed events. + +All six exchanges are shown inline. Topic: `acme.orders.v1` (4 partitions). Consumer DB stores `(topic, partition) → last_processed_offset`. + +--- + +## Exchange 1 — Create anonymous group (first startup only) + +```http +POST /v1/consumer_groups HTTP/1.1 +Authorization: Bearer +Content-Type: application/json + +{} +``` + +```http +HTTP/1.1 201 Created +Location: /v1/consumer_groups/gts.cf.core.events.consumer_group.v1~{uuid} + +{ "id": "gts.cf.core.events.consumer_group.v1~{uuid}", "kind": "anonymous" } +``` + +> Consumer persists `group_id` in its own DB or config. On restart, it reads this stored value — no new group creation. + +--- + +## Exchange 2 — JOIN (every startup and reconnect) + +```http +POST /v1/subscriptions HTTP/1.1 +Authorization: Bearer +Content-Type: application/json + +{ + "consumer_group": "gts.cf.core.events.consumer_group.v1~{uuid}", + "client_agent": "order-processor/1.0.0", + "session_timeout": "PT30S", + "interests": [ + { "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", "tenant_id": "", "types": ["gts.cf.core.events.event.v1~acme.orders.*"] } + ] +} +``` + +```http +HTTP/1.1 201 Created + +{ + "id": "{sub_id}", + "assigned": [ + { "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", "partition": 0 }, + { "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", "partition": 1 }, + { "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", "partition": 2 }, + { "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", "partition": 3 } + ], + "topology_version": 1 +} +``` + +--- + +## Exchange 3 — SEEK from own DB (every startup and reconnect) + +Consumer reads its DB: partition 0 → last offset 510, partition 1 → 304, partition 2 → 201, partition 3 → 0 (never processed → use `"earliest"`). + +```http +POST /v1/subscriptions/{sub_id}:seek HTTP/1.1 +Authorization: Bearer +Content-Type: application/json + +{ + "partition_positions": { + "gts.cf.core.events.topic.v1~acme.orders.v1:0": 510, + "gts.cf.core.events.topic.v1~acme.orders.v1:1": 304, + "gts.cf.core.events.topic.v1~acme.orders.v1:2": 201, + "gts.cf.core.events.topic.v1~acme.orders.v1:3": "earliest" + } +} +``` + +```http +HTTP/1.1 200 OK + +{ + "partition_positions": { + "gts.cf.core.events.topic.v1~acme.orders.v1:0": 510, + "gts.cf.core.events.topic.v1~acme.orders.v1:1": 304, + "gts.cf.core.events.topic.v1~acme.orders.v1:2": 201, + "gts.cf.core.events.topic.v1~acme.orders.v1:3": 499 + } +} +``` + +--- + +## Exchange 4 — Stream events + +```http +GET /v1/events:stream?subscription_id={sub_id} HTTP/1.1 +Authorization: Bearer +Accept: multipart/mixed +``` + +``` +HTTP/1.1 200 OK +Content-Type: multipart/mixed; boundary="broker-frame" +Transfer-Encoding: chunked + +--broker-frame +Content-Type: application/json +X-Frame-Kind: event + +{ "id": "...", "type": "gts...orders.created.v1", "topic": "...acme.orders.v1", "partition": 0, "sequence": 511, ... } + +--broker-frame +Content-Type: application/json +X-Frame-Kind: heartbeat + +{ "kind": "heartbeat", "at": "2026-06-15T10:00:05Z" } +``` + +--- + +## Exchange 5 — Consumer processes event and persists offset to own DB + +> Not an HTTP exchange — internal to consumer. Consumer writes `(acme.orders.v1, 0) → 511` to its DB **within the same transaction** as the business operation. This is the at-most-once / exactly-once boundary. + +--- + +## Exchange 6 — Reconnect after crash: same flow from Exchange 2 + +Consumer restarts. Reads `(acme.orders.v1, 0) → 511` from DB. JOINs, SEEKs with `511`. Broker delivers from offset 512. No reprocessing of offset 511. + +> **Key property**: the broker cursor is not the source of truth — the consumer DB is. The broker cursor is set anew on each reconnect from the consumer's own store. Idempotency is achieved by the consumer's DB transaction, not by the broker. diff --git a/gears/system/event-broker/scenarios/consumer/flows/1.04-flow-leave-triggers-gain-terminate.md b/gears/system/event-broker/scenarios/consumer/flows/1.04-flow-leave-triggers-gain-terminate.md new file mode 100644 index 000000000..b9df45c96 --- /dev/null +++ b/gears/system/event-broker/scenarios/consumer/flows/1.04-flow-leave-triggers-gain-terminate.md @@ -0,0 +1,119 @@ +# Flow: a leave makes a survivor gain — terminate + re-JOIN + +The inverse of [flow-1.1](1.01-flow-two-consumer-rebalance.md). Group `{group_id}` has A=`[0,1]` and B=`[2,3]`, both streaming `gts.cf.core.events.topic.v1~acme.orders.v1` (4 partitions). B leaves; the rebalance assigns B's partitions to A. Because A **gains** partitions and a gained partition is unseeded (SEEK is pre-stream-only), A's subscription **terminates** via a `control` frame with `code: "terminal"` and a graceful close; A re-JOINs, SEEKs, and reopens. Strictly broker-observable. + +--- + +## Exchange 1 — Consumer B LEAVEs + +```http +DELETE /v1/subscriptions/ HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +``` + +```http +HTTP/1.1 204 No Content +``` + +The broker rebalances the group: A is assigned all 4 partitions (`topology_version` `2 → 3`). + +## Exchange 2 — A's open stream receives a terminal control frame, then closes + +Because A's new assignment **grows** (`[0,1]` → `[0,1,2,3]`), the broker terminates A's subscription. The last frame on A's stream is a `control` frame with the complete final positions for A's current assignment; the broker then closes the connection gracefully. + +``` +--evbkA1 +Content-Type: application/json + +{ + "kind": "control", + "code": "terminal", + "reason": "rebalanced", + "positions": [ + { "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", "partition": 0, "offset": 110, "last_examined": 110 }, + { "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", "partition": 1, "offset": 8, "last_examined": 96 } + ] +} +--evbkA1-- +``` + +A commits these positions to its own offset store. (Partition `1` shows `last_examined = 96 > offset = 8` — a filter-saturated partition; A re-SEEKs `1` to `96` to skip the server-side-filtered events.) + +## Exchange 3 — A re-JOINs (new subscription) + +```http +POST /v1/subscriptions HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Content-Type: application/json + +{ + "consumer_group": "{group_id}", + "client_agent": "order-worker/1.4.0", + "interests": [ { "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", "tenant_id": "", "types": ["gts.cf.core.events.event.v1~acme.orders.*"] } ] +} +``` + +```http +HTTP/1.1 201 Created +Content-Type: application/json + +{ + "id": "", + "assigned": [ + { "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", "partition": 0 }, + { "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", "partition": 1 }, + { "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", "partition": 2 }, + { "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", "partition": 3 } + ], + "topology_version": 3 +} +``` + +A new `subscription_id` is issued; the group cursor for every partition survived the churn. + +## Exchange 4 — A SEEKs all four (committed positions for 0,1; group cursor for the gained 2,3), then reopens + +```http +POST /v1/subscriptions/:seek HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Content-Type: application/json + +{ + "partition_positions": [ + { "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", "partition": 0, "value": 110 }, + { "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", "partition": 1, "value": 96 }, + { "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", "partition": 2, "value": "earliest" }, + { "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", "partition": 3, "value": "earliest" } + ] +} +``` + +```http +HTTP/1.1 200 OK +``` + +A then opens `GET /v1/events:stream?subscription_id=` and receives the open-time `topology` baseline followed by events. + +## Exchange 5 — reuse of the old subscription_id is rejected + +```http +GET /v1/events:stream?subscription_id= HTTP/1.1 +``` + +```http +HTTP/1.1 410 Gone +``` + +`410 SubscriptionTerminated` — the old `subscription_id` terminated at Exchange 2. (Safety net for a consumer that missed the terminal control frame.) + +--- + +## Side effects (cumulative) + +- `topology_version` advances `2 → 3`. +- `` is removed (LEAVE); `` is terminated (gain); `` holds `[0,1,2,3]`. +- The group cursor for `0,1,2,3` survives; A resumes `2,3` from the preserved group cursor (or `earliest` if never consumed). +- No event on `2,3` is delivered to both B (before its LEAVE) and A (after re-JOIN) — A reaches `2,3` only after terminate → re-JOIN → SEEK → reopen, strictly after B stopped. diff --git a/gears/system/event-broker/scenarios/consumer/groups/1.01-positive-create-anonymous-group.md b/gears/system/event-broker/scenarios/consumer/groups/1.01-positive-create-anonymous-group.md new file mode 100644 index 000000000..87b03a9bb --- /dev/null +++ b/gears/system/event-broker/scenarios/consumer/groups/1.01-positive-create-anonymous-group.md @@ -0,0 +1,42 @@ +# Create an anonymous consumer group + +The broker mints a GTS consumer-group identifier server-side. The request body MUST NOT carry an `id`. + +## Request + +```http +POST /v1/consumer-groups HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Content-Type: application/json + +{ + "description": "order-fulfilment workers" +} +``` + +## Expected response + +- `201 Created` +- `Location` header: `/v1/consumer-groups/` +- Response body: + - `id` is a broker-minted GTS id matching `gts.cf.core.events.consumer_group.v1~` + - `kind` is `"anonymous"` + - `tenant_id` and `owner_principal_id` are taken from the caller's `SecurityContext` (not from the body) + +```json +{ + "id": "gts.cf.core.events.consumer_group.v1~7b2c1e9a-4f3d-4a2b-9c8e-1d2f3a4b5c6d", + "tenant_id": "", + "owner_principal_id": "", + "kind": "anonymous", + "description": "order-fulfilment workers", + "created_at": "2026-05-29T10:00:00Z" +} +``` + +## Side effects + +- `evbk_consumer_group({id})` is set to the returned record (`kind=anonymous`, `tenant_id` from ctx). +- `subsequent GET /v1/consumer-groups/{id}` returns `200` with the same record. +- `audit log entry consumer_group.created` created. diff --git a/gears/system/event-broker/scenarios/consumer/groups/1.02-positive-get-group-by-id.md b/gears/system/event-broker/scenarios/consumer/groups/1.02-positive-get-group-by-id.md new file mode 100644 index 000000000..9bf6f7488 --- /dev/null +++ b/gears/system/event-broker/scenarios/consumer/groups/1.02-positive-get-group-by-id.md @@ -0,0 +1,33 @@ +# Get a consumer group by id + +Read-only lookup of a registered group by its GTS id. + +## Setup + +- Run [Create an anonymous group](1.01-positive-create-anonymous-group.md). Group id available as `{group_id}`. + +## Request + +```http +GET /v1/consumer-groups/{group_id} HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +``` + +## Expected response + +- `200 OK` +- Body is the stored group record (id URL-decoded from the path). + +```json +{ + "id": "{group_id}", + "tenant_id": "", + "owner_principal_id": "", + "kind": "anonymous", + "description": "order-fulfilment workers", + "created_at": "2026-05-29T10:00:00Z" +} +``` + + diff --git a/gears/system/event-broker/scenarios/consumer/groups/1.03-positive-list-groups.md b/gears/system/event-broker/scenarios/consumer/groups/1.03-positive-list-groups.md new file mode 100644 index 000000000..d84a08cce --- /dev/null +++ b/gears/system/event-broker/scenarios/consumer/groups/1.03-positive-list-groups.md @@ -0,0 +1,31 @@ +# List consumer groups visible to the caller + +Read-only listing of groups in the caller's tenant. + +## Request + +```http +GET /v1/consumer-groups HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +``` + +## Expected response + +- `200 OK` +- Body is an array of group records the caller's principal may see (tenant-scoped). + +```json +[ + { + "id": "gts.cf.core.events.consumer_group.v1~7b2c1e9a-4f3d-4a2b-9c8e-1d2f3a4b5c6d", + "tenant_id": "", + "owner_principal_id": "", + "kind": "anonymous", + "description": "order-fulfilment workers", + "created_at": "2026-05-29T10:00:00Z" + } +] +``` + + diff --git a/gears/system/event-broker/scenarios/consumer/groups/1.04-positive-delete-empty-group.md b/gears/system/event-broker/scenarios/consumer/groups/1.04-positive-delete-empty-group.md new file mode 100644 index 000000000..d28796ddc --- /dev/null +++ b/gears/system/event-broker/scenarios/consumer/groups/1.04-positive-delete-empty-group.md @@ -0,0 +1,26 @@ +# Delete a consumer group with no active members + +Deletion is allowed only when the group has no active subscriptions (cache state empty for the group). + +## Setup + +- Run [Create an anonymous group](1.01-positive-create-anonymous-group.md). Group id `{group_id}`. +- No subscriptions reference `{group_id}`. + +## Request + +```http +DELETE /v1/consumer-groups/{group_id} HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +``` + +## Expected response + +- `204 No Content` + +## Side effects + +- `evbk_consumer_group({group_id})` is absent. +- `subsequent GET /v1/consumer-groups/{group_id}` returns `404 ConsumerGroupNotFound`. +- `audit log entry consumer_group.deleted` created. diff --git a/gears/system/event-broker/scenarios/consumer/groups/1.05-negative-delete-group-with-active-members.md b/gears/system/event-broker/scenarios/consumer/groups/1.05-negative-delete-group-with-active-members.md new file mode 100644 index 000000000..be2294e66 --- /dev/null +++ b/gears/system/event-broker/scenarios/consumer/groups/1.05-negative-delete-group-with-active-members.md @@ -0,0 +1,45 @@ +# Delete is rejected while the group has active members + +A group with at least one active subscription cannot be deleted. + +## Setup + +- Run [Create an anonymous group](1.01-positive-create-anonymous-group.md). Group id `{group_id}`. +- Run [Cold JOIN against a fresh group](../subscriptions/1.01-positive-cold-join-fresh-group.md) against `{group_id}` — one active subscription exists. + +## Request + +```http +DELETE /v1/consumer-groups/{group_id} HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +``` + +## Expected response + +- `409 Conflict` (`PD`) + +```json +{ + "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.failed_precondition.v1~", + "title": "Failed Precondition", + "status": 409, + "detail": "consumer group {group_id} has 1 active subscription(s)", + "instance": "/v1/consumer-groups/{group_id}", + "trace_id": "", + "context": { + "violations": [ + { + "type": "has_active_members", + "subject": "{group_id}", + "description": "consumer group has 1 active subscription(s)" + } + ] + } +} +``` + +## Side effects + +- `evbk_consumer_group({group_id})` is unchanged (still present). +- After the active subscription LEAVEs (or is reaped), a retried `DELETE` returns `204` (see [positive-1.4](1.04-positive-delete-empty-group.md)). diff --git a/gears/system/event-broker/scenarios/consumer/groups/1.06-negative-invalid-client-agent.md b/gears/system/event-broker/scenarios/consumer/groups/1.06-negative-invalid-client-agent.md new file mode 100644 index 000000000..b1d6816f0 --- /dev/null +++ b/gears/system/event-broker/scenarios/consumer/groups/1.06-negative-invalid-client-agent.md @@ -0,0 +1,47 @@ +# Reject a non-ASCII / over-length field on group create + +Text fields on the broker API are ASCII-only and length-bounded (e.g., `client_agent` follows the RFC 9110 User-Agent grammar, 1–256 bytes). A body carrying non-ASCII or control characters is rejected with `400 BadRequest`. + +## Request + +A `description` carrying bytes outside the printable-ASCII range (a non-ASCII multibyte character plus an embedded control byte, both rejected): + +```http +POST /v1/consumer-groups HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Content-Type: application/json + +{ + "description": "" +} +``` + +## Expected response + +- `400 Bad Request` (`PD`) +- Problem Details body identifies the offending field and the rule (ASCII / length). + +```json +{ + "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.invalid_argument.v1~", + "title": "Invalid Argument", + "status": 400, + "detail": "description contains non-ASCII or control characters", + "instance": "/v1/consumer-groups", + "trace_id": "", + "context": { + "field_violations": [ + { + "field": "description", + "description": "contains non-ASCII or control characters", + "reason": "ascii_only" + } + ] + } +} +``` + +## Side effects + +- No group is created (`evbk_consumer_group` gains no row). diff --git a/gears/system/event-broker/scenarios/consumer/groups/1.07-negative-get-unknown-group.md b/gears/system/event-broker/scenarios/consumer/groups/1.07-negative-get-unknown-group.md new file mode 100644 index 000000000..3190d1e84 --- /dev/null +++ b/gears/system/event-broker/scenarios/consumer/groups/1.07-negative-get-unknown-group.md @@ -0,0 +1,32 @@ +# Get an unknown consumer group + +Looking up an id that was never registered (or was deleted) returns `404`. + +## Request + +```http +GET /v1/consumer-groups/gts.cf.core.events.consumer_group.v1~00000000-0000-0000-0000-000000000000 HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +``` + +## Expected response + +- `404 Not Found` (`PD`) + +```json +{ + "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.not_found.v1~", + "title": "Not Found", + "status": 404, + "detail": "consumer group gts.cf.core.events.consumer_group.v1~00000000-0000-0000-0000-000000000000 not found", + "instance": "/v1/consumer-groups/gts.cf.core.events.consumer_group.v1~00000000-0000-0000-0000-000000000000", + "trace_id": "", + "context": { + "resource_type": "gts.cf.core.events.consumer_group.v1~", + "resource_name": "gts.cf.core.events.consumer_group.v1~00000000-0000-0000-0000-000000000000" + } +} +``` + + diff --git a/gears/system/event-broker/scenarios/consumer/groups/1.08-positive-named-group-join.md b/gears/system/event-broker/scenarios/consumer/groups/1.08-positive-named-group-join.md new file mode 100644 index 000000000..fa30593dc --- /dev/null +++ b/gears/system/event-broker/scenarios/consumer/groups/1.08-positive-named-group-join.md @@ -0,0 +1,61 @@ +# JOIN a named consumer group (no creation step) + +Named groups are pre-registered via `types_registry` at module startup. Any principal with a `consume` grant on the GTS identifier can JOIN directly — there is no `POST /v1/consumer-groups` step. This is the pattern for shared platform-level consumers and cross-module consumption. + +## Setup + +- Group `gts.cf.core.events.consumer_group.v1~vendor.order-processors.v1` is registered in `types_registry` by the `order-processor` module at startup. The row exists in `evbk_consumer_group` with `kind = named`. +- Caller holds `gts.cf.core.events.consumer_group.v1~vendor.order-processors.v1:consume` permission. +- Topic `gts.cf.core.events.topic.v1~acme.orders.v1` is registered with 4 partitions. + +## Request + +```http +POST /v1/subscriptions HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Content-Type: application/json + +{ + "consumer_group": "gts.cf.core.events.consumer_group.v1~vendor.order-processors.v1", + "client_agent": "order-processor/2.0.0 cf-event-broker-sdk/0.1.0", + "session_timeout": "PT60S", + "interests": [ + { + "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", + "tenant_id": "", + "types": ["gts.cf.core.events.event.v1~acme.orders.*"] + } + ] +} +``` + +## Expected response + +- `201 Created` + +```json +{ + "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "consumer_group": "gts.cf.core.events.consumer_group.v1~vendor.order-processors.v1", + "assigned": [ + { "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", "partition": 0 }, + { "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", "partition": 1 }, + { "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", "partition": 2 }, + { "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", "partition": 3 } + ], + "topology_version": 1, + "expires_at": "2026-06-15T10:01:00Z" +} +``` + +## Side effects + +- `evbk_subscription(aaaaaaaa-...)` created in cache. +- GroupState for `vendor.order-processors.v1` created in cache with `topology_version = 1`. +- A second instance JOINing with the same group identifier triggers a rebalance (see [positive-1.11](../subscriptions/1.11-positive-second-join-triggers-rebalance.md)). + +## Errors + +- `403 Permission Denied` — caller lacks `:consume` on this named group identifier. +- `404 Not Found` — group identifier not in `evbk_consumer_group` (was never registered via `types_registry`). diff --git a/gears/system/event-broker/scenarios/consumer/positions/1.01-positive-seek-earliest.md b/gears/system/event-broker/scenarios/consumer/positions/1.01-positive-seek-earliest.md new file mode 100644 index 000000000..22622b7ef --- /dev/null +++ b/gears/system/event-broker/scenarios/consumer/positions/1.01-positive-seek-earliest.md @@ -0,0 +1,44 @@ +# Pre-stream SEEK to earliest + +A freshly-JOINed consumer seeds its starting position with the `"earliest"` sentinel before opening the stream. The broker resolves the sentinel at admission to `RF - 1` so the subsequent stream begins at the retention floor. Because sequences start at 1 (see [ADR-0001](../../docs/ADR/0001-offset-semantics.md)), RF ≥ 1 always, so the resolved cursor is always ≥ 0. On a completely fresh topic (RF = 1), `"earliest"` resolves to cursor = 0. + +> Broker-observable SEEK wire contract for the `"earliest"` sentinel. + +## Setup + +- Run [Cold JOIN against a fresh group](../subscriptions/1.01-positive-cold-join-fresh-group.md). Subscription id available as `{sub_id}`; assigned partition `("acme.orders.v1", 0)`. +- Partition `("acme.orders.v1", 0)` has retention floor `RF = 100` (offsets `< 100` evicted). + +## Request + +```http +POST /v1/subscriptions/{sub_id}:seek HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Content-Type: application/json + +{ + "partition_positions": { + "gts.cf.core.events.topic.v1~acme.orders.v1:0": "earliest" + } +} +``` + +## Expected response + +- `200 OK` +- Response body returns the resolved integer cursor per partition (the sentinel is resolved server-side). + +```json +{ + "partition_positions": { + "gts.cf.core.events.topic.v1~acme.orders.v1:0": 99 + } +} +``` + +## Side effects + +- Session cursor for `(group, "acme.orders.v1", 0)` is set to `99` (`RF - 1`). +- `subsequent GET /v1/events:stream` for `{sub_id}` emits `("acme.orders.v1", 0)` events starting at offset `100` (`Cursor + 1`). +- The `("acme.orders.v1", 0)` partition is now seeded — it no longer contributes to a `409 PositionsNotSet`. diff --git a/gears/system/event-broker/scenarios/consumer/positions/1.02-positive-seek-latest.md b/gears/system/event-broker/scenarios/consumer/positions/1.02-positive-seek-latest.md new file mode 100644 index 000000000..369ea2a85 --- /dev/null +++ b/gears/system/event-broker/scenarios/consumer/positions/1.02-positive-seek-latest.md @@ -0,0 +1,43 @@ +# Pre-stream SEEK to latest + +A consumer seeds with the `"latest"` sentinel. The broker resolves it at admission to the current high-water mark, so the subsequent stream delivers only events admitted *after* this SEEK. + +> Broker-observable SEEK wire contract for the `"latest"` sentinel. + +## Setup + +- Run [Cold JOIN against a fresh group](../subscriptions/1.01-positive-cold-join-fresh-group.md). Subscription id `{sub_id}`; assigned `("acme.orders.v1", 0)`. +- Partition `("acme.orders.v1", 0)` has `HWM = 5000` at the moment of the SEEK. + +## Request + +```http +POST /v1/subscriptions/{sub_id}:seek HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Content-Type: application/json + +{ + "partition_positions": { + "gts.cf.core.events.topic.v1~acme.orders.v1:0": "latest" + } +} +``` + +## Expected response + +- `200 OK` + +```json +{ + "partition_positions": { + "gts.cf.core.events.topic.v1~acme.orders.v1:0": 5000 + } +} +``` + +## Side effects + +- Session cursor for `(group, "acme.orders.v1", 0)` is set to `5000` (the `HWM` at admission). +- `subsequent GET /v1/events:stream` emits only events with offset `> 5000` — everything already in the partition is skipped. +- `"latest"` resolution is a snapshot at admission time; events admitted concurrently with the SEEK may land just above or below `5000` per broker admission ordering (non-deterministic by design — "skip everything before now", not "skip up to offset N"). diff --git a/gears/system/event-broker/scenarios/consumer/positions/1.03-positive-seek-exact-offset.md b/gears/system/event-broker/scenarios/consumer/positions/1.03-positive-seek-exact-offset.md new file mode 100644 index 000000000..eaa694e9c --- /dev/null +++ b/gears/system/event-broker/scenarios/consumer/positions/1.03-positive-seek-exact-offset.md @@ -0,0 +1,43 @@ +# Pre-stream SEEK to an exact offset + +A consumer seeds with an explicit integer offset — the last-processed offset. This is the wire path a client with its own durable offset store uses on restart, and the replay/backfill path (start from a known offset). The integer is stored verbatim; the broker emits from `offset + 1`. + +> Broker-observable `POST .../positions` wire contract for an exact integer SEEK value. + +## Setup + +- Run [Cold JOIN against a fresh group](../subscriptions/1.01-positive-cold-join-fresh-group.md). Subscription id `{sub_id}`; assigned `("acme.orders.v1", 0)`. +- Partition `("acme.orders.v1", 0)` has `RF = 1`, `HWM = 5000` → valid range `[0, 5000]`. (Here the value `42` is the consumer's last-processed offset, e.g. read back from its local DB.) + +## Request + +```http +POST /v1/subscriptions/{sub_id}:seek HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Content-Type: application/json + +{ + "partition_positions": { + "gts.cf.core.events.topic.v1~acme.orders.v1:0": 42 + } +} +``` + +## Expected response + +- `200 OK` +- The integer is stored verbatim (no `+1` adjustment on the wire — the value already means "last processed"). + +```json +{ + "partition_positions": { + "gts.cf.core.events.topic.v1~acme.orders.v1:0": 42 + } +} +``` + +## Side effects + +- Session cursor for `(group, "acme.orders.v1", 0)` is set to `42`. +- `subsequent GET /v1/events:stream` emits `("acme.orders.v1", 0)` events starting at offset `43` (`Cursor + 1`). diff --git a/gears/system/event-broker/scenarios/consumer/positions/1.04-positive-mixed-sentinels.md b/gears/system/event-broker/scenarios/consumer/positions/1.04-positive-mixed-sentinels.md new file mode 100644 index 000000000..531d655c6 --- /dev/null +++ b/gears/system/event-broker/scenarios/consumer/positions/1.04-positive-mixed-sentinels.md @@ -0,0 +1,45 @@ +# SEEK with mixed integer and sentinel values + +One SEEK request may carry a different value kind per partition — an explicit integer for some, `"earliest"` / `"latest"` for others. All are resolved and stored. + +## Setup + +- Run [Cold JOIN against a fresh group](../subscriptions/1.01-positive-cold-join-fresh-group.md) → `{sub_id}`, assigned `("acme.orders.v1", 0..2)`. +- Partition `0`: `RF = 100`. Partition `2`: `HWM = 5000`. + +## Request + +```http +POST /v1/subscriptions/{sub_id}:seek HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Content-Type: application/json + +{ + "partition_positions": { + "gts.cf.core.events.topic.v1~acme.orders.v1:0": 42, + "gts.cf.core.events.topic.v1~acme.orders.v1:1": "earliest", + "gts.cf.core.events.topic.v1~acme.orders.v1:2": "latest" + } +} +``` + +## Expected response + +- `200 OK` +- Each value resolved independently: `42` verbatim; `"earliest"` → `RF - 1`; `"latest"` → current `HWM`. + +```json +{ + "partition_positions": { + "gts.cf.core.events.topic.v1~acme.orders.v1:0": 42, + "gts.cf.core.events.topic.v1~acme.orders.v1:1": 99, + "gts.cf.core.events.topic.v1~acme.orders.v1:2": 5000 + } +} +``` + +## Side effects + +- Session cursor for `(group, "acme.orders.v1", 0)` set to `42`, `..1` set to `99`, `..2` set to `5000`. +- Subsequent stream emits partition `0` from offset `43`, partition `1` from `100`, partition `2` only events `> 5000`. diff --git a/gears/system/event-broker/scenarios/consumer/positions/1.05-negative-out-of-range-offset.md b/gears/system/event-broker/scenarios/consumer/positions/1.05-negative-out-of-range-offset.md new file mode 100644 index 000000000..ed88d4954 --- /dev/null +++ b/gears/system/event-broker/scenarios/consumer/positions/1.05-negative-out-of-range-offset.md @@ -0,0 +1,54 @@ +# SEEK rejects an out-of-range offset + +An integer SEEK value is interpreted as the last-processed offset and must fall within `[RF - 1, HWM]`. A value below `RF - 1` is rejected with `400 InvalidInitialPosition`. + +> Broker-observable out-of-range rejection path for integer SEEK. + +## Setup + +- Run [Cold JOIN against a fresh group](../subscriptions/1.01-positive-cold-join-fresh-group.md). Subscription id `{sub_id}`; assigned `("acme.orders.v1", 0)`. +- Partition `("acme.orders.v1", 0)` has `RF = 100`, `HWM = 5000` → valid range `[99, 5000]`. + +## Request + +```http +POST /v1/subscriptions/{sub_id}:seek HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Content-Type: application/json + +{ + "partition_positions": { + "gts.cf.core.events.topic.v1~acme.orders.v1:0": 5 + } +} +``` + +## Expected response + +- `400 Bad Request` (`PD`) +- Problem Details body identifies the offending partition, the requested value, and the valid range. + +```json +{ + "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.invalid_argument.v1~", + "title": "Invalid Argument", + "status": 400, + "detail": "offset 5 for acme.orders.v1:0 is below the valid range [99, 5000]", + "instance": "/v1/subscriptions/{sub_id}:seek", + "trace_id": "", + "context": { + "field_violations": [ + { + "field": "partition_positions[gts.cf.core.events.topic.v1~acme.orders.v1:0]", + "description": "offset 5 is below the valid range [99, 5000]", + "reason": "below_retention_floor" + } + ] + } +} +``` + +## Side effects + +- Session cursor for `(group, "acme.orders.v1", 0)` is unchanged/absent; the rejected request applies no cursor changes (validation is per-request atomic; no partition in the body is applied if any is out of range). diff --git a/gears/system/event-broker/scenarios/consumer/positions/1.06-negative-offset-above-hwm.md b/gears/system/event-broker/scenarios/consumer/positions/1.06-negative-offset-above-hwm.md new file mode 100644 index 000000000..0da032794 --- /dev/null +++ b/gears/system/event-broker/scenarios/consumer/positions/1.06-negative-offset-above-hwm.md @@ -0,0 +1,51 @@ +# SEEK rejects an offset above the high-water mark + +The upper bound of the valid last-processed range is `HWM`. A value above it is rejected `400 InvalidInitialPosition` (it would claim to have processed events that don't exist yet). + +## Setup + +- Run [Cold JOIN against a fresh group](../subscriptions/1.01-positive-cold-join-fresh-group.md) → `{sub_id}`, assigned `("acme.orders.v1", 0)`. +- Partition `("acme.orders.v1", 0)`: `RF = 100`, `HWM = 5000` → valid range `[99, 5000]`. + +## Request + +```http +POST /v1/subscriptions/{sub_id}:seek HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Content-Type: application/json + +{ + "partition_positions": { + "gts.cf.core.events.topic.v1~acme.orders.v1:0": 100000 + } +} +``` + +## Expected response + +- `400 Bad Request` (`PD`) + +```json +{ + "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.invalid_argument.v1~", + "title": "Invalid Argument", + "status": 400, + "detail": "offset 100000 for acme.orders.v1:0 is above the valid range [99, 5000]", + "instance": "/v1/subscriptions/{sub_id}:seek", + "trace_id": "", + "context": { + "field_violations": [ + { + "field": "partition_positions[gts.cf.core.events.topic.v1~acme.orders.v1:0]", + "description": "offset 100000 is above the valid range [99, 5000]", + "reason": "above_high_water_mark" + } + ] + } +} +``` + +## Side effects + +- Session cursor for `(group, "acme.orders.v1", 0)` is unchanged/absent (the rejected request applies no cursor changes). diff --git a/gears/system/event-broker/scenarios/consumer/positions/1.07-negative-seek-while-streaming.md b/gears/system/event-broker/scenarios/consumer/positions/1.07-negative-seek-while-streaming.md new file mode 100644 index 000000000..34d29b7f2 --- /dev/null +++ b/gears/system/event-broker/scenarios/consumer/positions/1.07-negative-seek-while-streaming.md @@ -0,0 +1,54 @@ +# SEEK while a stream is open is rejected + +SEEK is a **pre-stream-only** operation. While a `:stream` is open against the subscription, the session cursor auto-advances with delivery, so SEEK is not permitted — **any** SEEK (forward or backward) is rejected with `409 StreamingInProgress`. The open stream is unaffected. (Backward repositioning for replay is valid *before* a stream is open — see [positive-1.10](1.10-positive-seek-any-value-in-range.md).) + +## Setup + +- Run [Cold JOIN against a fresh group](../subscriptions/1.01-positive-cold-join-fresh-group.md). Subscription id `{sub_id}`; assigned `("acme.orders.v1", 0)`. +- Run a pre-stream SEEK seeding cursor `500`. +- A [stream](../stream/1.01-positive-stream-multipart-frames.md) is currently open against `{sub_id}`. + +## Request + +```http +POST /v1/subscriptions/{sub_id}:seek HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Content-Type: application/json + +{ + "partition_positions": [ + { "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", "partition": 0, "value": 600 } + ] +} +``` + +## Expected response + +- `409 Conflict` (`PD`) +- Problem Details body reports `StreamingInProgress`; the cursor is unchanged. + +```json +{ + "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.failed_precondition.v1~", + "title": "Failed Precondition", + "status": 409, + "detail": "SEEK is not permitted while a stream is open on {sub_id}; close the stream first", + "instance": "/v1/subscriptions/{sub_id}:seek", + "trace_id": "", + "context": { + "violations": [ + { + "type": "streaming_in_progress", + "subject": "{sub_id}", + "description": "SEEK is pre-stream-only; the session cursor auto-advances with delivery while streaming" + } + ] + } +} +``` + +## Side effects + +- The session cursor for `("acme.orders.v1", 0)` is unchanged (the SEEK was rejected). +- The open stream continues uninterrupted. diff --git a/gears/system/event-broker/scenarios/consumer/positions/1.09-negative-seek-unassigned-partition.md b/gears/system/event-broker/scenarios/consumer/positions/1.09-negative-seek-unassigned-partition.md new file mode 100644 index 000000000..22a13aaa1 --- /dev/null +++ b/gears/system/event-broker/scenarios/consumer/positions/1.09-negative-seek-unassigned-partition.md @@ -0,0 +1,52 @@ +# SEEK rejects a partition not assigned to the subscription + +A SEEK referencing a `(topic, partition)` not currently assigned to this subscription is rejected `409 PartitionNotAssigned`. Validation is per-request atomic — if any partition in the body is unassigned, nothing is applied. The response carries the current assignment so the client can self-heal. + +## Setup + +- Run [Cold JOIN against a fresh group](../subscriptions/1.01-positive-cold-join-fresh-group.md) on a group already split with another member, so `{sub_id}` is assigned only `("acme.orders.v1", 0)` and `("acme.orders.v1", 1)` (partitions `2`, `3` belong to another subscription). + +## Request + +```http +POST /v1/subscriptions/{sub_id}:seek HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Content-Type: application/json + +{ + "partition_positions": { + "gts.cf.core.events.topic.v1~acme.orders.v1:0": "earliest", + "gts.cf.core.events.topic.v1~acme.orders.v1:2": "earliest" + } +} +``` + +## Expected response + +- `409 Conflict` (`PD`) +- Body carries the current `topology_version` and the subscription's actual assignment. + +```json +{ + "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.failed_precondition.v1~", + "title": "Failed Precondition", + "status": 409, + "detail": "acme.orders.v1:2 is not assigned to subscription {sub_id}", + "instance": "/v1/subscriptions/{sub_id}:seek", + "trace_id": "", + "context": { + "violations": [ + { + "type": "partition_not_assigned", + "subject": "gts.cf.core.events.topic.v1~acme.orders.v1:2", + "description": "partition is not assigned to subscription {sub_id} (topology_version=2)" + } + ] + } +} +``` + +## Side effects + +- Nothing is applied — neither partition `0` nor `2` is seeded (session cursor state is unchanged for both). Atomic per-request: the assigned partition `0` is NOT seeded despite being valid, because partition `2` made the request invalid. diff --git a/gears/system/event-broker/scenarios/consumer/positions/1.10-positive-seek-any-value-in-range.md b/gears/system/event-broker/scenarios/consumer/positions/1.10-positive-seek-any-value-in-range.md new file mode 100644 index 000000000..005f33b75 --- /dev/null +++ b/gears/system/event-broker/scenarios/consumer/positions/1.10-positive-seek-any-value-in-range.md @@ -0,0 +1,40 @@ +# Pre-stream SEEK permits any value in range (including backward) + +Before a stream is open against the subscription, SEEK is NOT subject to the forward-only rule — any value within `[RF - 1, HWM]` is accepted, including one below a previously-committed cursor. This is how a fresh owner repositions (replay/backfill) before streaming. (Once the stream is open, any SEEK is rejected — see [negative-1.7](1.07-negative-seek-while-streaming.md).) + +## Setup + +- Run [Cold JOIN against a fresh group](../subscriptions/1.01-positive-cold-join-fresh-group.md) → `{sub_id}`, assigned `("acme.orders.v1", 0)`. +- Partition `0`: `RF = 1`, `HWM = 5000` → valid range `[0, 5000]`. A prior committed cursor exists at `500` (e.g., from an earlier run of this group). +- **No stream is open** against `{sub_id}`. + +## Request + +```http +POST /v1/subscriptions/{sub_id}:seek HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Content-Type: application/json + +{ + "partition_positions": { + "gts.cf.core.events.topic.v1~acme.orders.v1:0": 100 + } +} +``` + +## Expected response + +- `200 OK` — `100 < 500` is accepted because no stream is open (forward-only does not apply pre-stream). + +```json +{ + "partition_positions": { "gts.cf.core.events.topic.v1~acme.orders.v1:0": 100 } +} +``` + +## Side effects + +- Session cursor for `(group, "acme.orders.v1", 0)` moves from `500` to `100` (backward reposition allowed pre-stream). +- A subsequent stream re-delivers from offset `101` onwards. +- The same request issued while a stream is open would return `409 SeekBackwardNotAllowed`. diff --git a/gears/system/event-broker/scenarios/consumer/positions/1.11-positive-seek-at-timestamp.md b/gears/system/event-broker/scenarios/consumer/positions/1.11-positive-seek-at-timestamp.md new file mode 100644 index 000000000..2cd9af565 --- /dev/null +++ b/gears/system/event-broker/scenarios/consumer/positions/1.11-positive-seek-at-timestamp.md @@ -0,0 +1,50 @@ +# SEEK to a timestamp — date-anchored start position + +A consumer that tracks progress by time (e.g., a reporting job that ran yesterday and wants to resume from where it left off in wall-clock time) uses the `"at:"` sentinel. The broker resolves the sentinel to the offset of the first event whose `occurred_at ≥ timestamp` and sets the cursor. + +The resolved integer offset is returned so the consumer can persist it for future re-seeks without repeating the timestamp resolution. + +## Setup + +- Run [Cold JOIN against a fresh group](../subscriptions/1.01-positive-cold-join-fresh-group.md). `{sub_id}` assigned partitions `0..3` of `acme.orders.v1`. +- Events exist on partition 0: offset 100 at `occurred_at: 2026-06-14T09:59:50Z`, offset 101 at `occurred_at: 2026-06-14T10:00:03Z`. + +## Request + +```http +POST /v1/subscriptions/{sub_id}:seek HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Content-Type: application/json + +{ + "partition_positions": { + "gts.cf.core.events.topic.v1~acme.orders.v1:0": "at:2026-06-14T10:00:00Z", + "gts.cf.core.events.topic.v1~acme.orders.v1:1": "at:2026-06-14T10:00:00Z", + "gts.cf.core.events.topic.v1~acme.orders.v1:2": "at:2026-06-14T10:00:00Z", + "gts.cf.core.events.topic.v1~acme.orders.v1:3": "at:2026-06-14T10:00:00Z" + } +} +``` + +## Expected response + +- `200 OK` — resolved integer offsets per partition + +```json +{ + "partition_positions": { + "gts.cf.core.events.topic.v1~acme.orders.v1:0": 100, + "gts.cf.core.events.topic.v1~acme.orders.v1:1": 4820, + "gts.cf.core.events.topic.v1~acme.orders.v1:2": 3104, + "gts.cf.core.events.topic.v1~acme.orders.v1:3": 2201 + } +} +``` + +Partition 0 resolves to offset 100 (the last event strictly before the timestamp), so the broker will emit from offset 101 (`occurred_at: 2026-06-14T10:00:03Z`) on the first stream open. + +## Side effects + +- Session cursor for each `(group, topic, partition)` is set to the resolved integer for that partition. +- Subsequent `GET /v1/events:stream` delivers events from `cursor + 1` on each partition. diff --git a/gears/system/event-broker/scenarios/consumer/positions/1.12-positive-seek-at-timestamp-before-retention.md b/gears/system/event-broker/scenarios/consumer/positions/1.12-positive-seek-at-timestamp-before-retention.md new file mode 100644 index 000000000..7d2dfbdc8 --- /dev/null +++ b/gears/system/event-broker/scenarios/consumer/positions/1.12-positive-seek-at-timestamp-before-retention.md @@ -0,0 +1,41 @@ +# SEEK at-timestamp before retention floor — resolves to RF + +When the requested timestamp predates the retention floor (RF), the broker clamps to the first available event rather than rejecting. The consumer receives all retained events from the oldest available offset onward. + +## Setup + +- Run [Cold JOIN against a fresh group](../subscriptions/1.01-positive-cold-join-fresh-group.md). `{sub_id}` assigned partition 0. +- Retention floor on partition 0 is offset 500 (`occurred_at: 2026-01-01T00:00:00Z`). Older events have been purged. + +## Request + +```http +POST /v1/subscriptions/{sub_id}:seek HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Content-Type: application/json + +{ + "partition_positions": { + "gts.cf.core.events.topic.v1~acme.orders.v1:0": "at:2025-06-01T00:00:00Z" + } +} +``` + +## Expected response + +- `200 OK` — resolves to RF (499 = RF - 1, so broker emits from 500) + +```json +{ + "partition_positions": { + "gts.cf.core.events.topic.v1~acme.orders.v1:0": 499 + } +} +``` + +## Side effects + +- Session cursor for `(group, acme.orders.v1, 0)` is set to `499`. +- First stream delivery begins at offset 500 — the oldest retained event. +- Behavior is identical to the `"earliest"` sentinel: the timestamp is resolved to the retention floor. diff --git a/gears/system/event-broker/scenarios/consumer/positions/1.13-positive-seek-at-timestamp-beyond-hwm.md b/gears/system/event-broker/scenarios/consumer/positions/1.13-positive-seek-at-timestamp-beyond-hwm.md new file mode 100644 index 000000000..2a35d61e7 --- /dev/null +++ b/gears/system/event-broker/scenarios/consumer/positions/1.13-positive-seek-at-timestamp-beyond-hwm.md @@ -0,0 +1,41 @@ +# SEEK at-timestamp beyond HWM — resolves to HWM + +When the requested timestamp is in the future or beyond the current high-water mark (HWM), the broker resolves to the HWM. The consumer will wait for new events admitted after the SEEK, equivalent to the `"latest"` sentinel. + +## Setup + +- Run [Cold JOIN against a fresh group](../subscriptions/1.01-positive-cold-join-fresh-group.md). `{sub_id}` assigned partition 0. +- HWM on partition 0 is offset 9999. No events exist with `occurred_at ≥ 2030-01-01`. + +## Request + +```http +POST /v1/subscriptions/{sub_id}:seek HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Content-Type: application/json + +{ + "partition_positions": { + "gts.cf.core.events.topic.v1~acme.orders.v1:0": "at:2030-01-01T00:00:00Z" + } +} +``` + +## Expected response + +- `200 OK` — resolves to current HWM + +```json +{ + "partition_positions": { + "gts.cf.core.events.topic.v1~acme.orders.v1:0": 9999 + } +} +``` + +## Side effects + +- Session cursor for `(group, acme.orders.v1, 0)` is set to `9999`. +- First stream delivery waits for event with offset ≥ 10000 — only future events are delivered. +- Behavior is identical to the `"latest"` sentinel. diff --git a/gears/system/event-broker/scenarios/consumer/stream/1.01-positive-stream-multipart-frames.md b/gears/system/event-broker/scenarios/consumer/stream/1.01-positive-stream-multipart-frames.md new file mode 100644 index 000000000..f0bc127e2 --- /dev/null +++ b/gears/system/event-broker/scenarios/consumer/stream/1.01-positive-stream-multipart-frames.md @@ -0,0 +1,51 @@ +# Stream multipart frames + +A seeded subscription opens the long-lived `multipart/mixed` stream. The broker emits one event per multipart part, in offset-monotonic order per `(topic, partition)`, interleaved with heartbeat frames on idle. + +## Setup + +- Run [Cold JOIN against a fresh group](../subscriptions/1.01-positive-cold-join-fresh-group.md). Subscription id `{sub_id}`; assigned `("acme.orders.v1", 0)`. +- Run [Pre-stream SEEK to earliest](../positions/1.01-positive-seek-earliest.md) — cursor seeded at `99`, so emission begins at offset `100`. +- Three events exist on `("acme.orders.v1", 0)` at offsets `100`, `101`, `102`. + +## Request + +```http +GET /v1/events:stream?subscription_id={sub_id} HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Accept: multipart/mixed +``` + +## Expected response + +- `200 OK` +- `Content-Type: multipart/mixed; boundary=<...>` +- `Transfer-Encoding: chunked` +- Each `MP` part has `Content-Type: application/json` and a top-level `kind` of `event` | `heartbeat` | `advisory` | `topology`. +- The first three `event` parts carry offsets `100`, `101`, `102` in strict order; each part carries exactly one event (no batching). + +``` +-- +Content-Type: application/json + +{ "kind": "event", "event": { "offset": 100, "partition": 0, "type": "...orders.created.v1", "data": { ... } } } +-- +Content-Type: application/json + +{ "kind": "event", "event": { "offset": 101, "partition": 0, ... } } +-- +Content-Type: application/json + +{ "kind": "event", "event": { "offset": 102, "partition": 0, ... } } +-- +Content-Type: application/json + +{ "kind": "heartbeat" } +``` + +## Side effects + +- `subscription {sub_id} next frame is event with offset 100` (offset-monotonic per partition). +- `subscription {sub_id} emits heartbeat within PT5S` once the backlog is drained (default heartbeat cadence). +- The session cursor auto-advances with delivery — as events are emitted, session cursor state for `(group, "acme.orders.v1", 0)` follows the stream. The consumer does not SEEK mid-stream to record progress (SEEK is pre-stream-only); durable progress is the consumer's own (ADR-0006). diff --git a/gears/system/event-broker/scenarios/consumer/stream/1.02-positive-stream-heartbeat-cadence.md b/gears/system/event-broker/scenarios/consumer/stream/1.02-positive-stream-heartbeat-cadence.md new file mode 100644 index 000000000..26e09f529 --- /dev/null +++ b/gears/system/event-broker/scenarios/consumer/stream/1.02-positive-stream-heartbeat-cadence.md @@ -0,0 +1,39 @@ +# Stream emits heartbeats on idle + +On a subscription with no matching events, the broker emits a `heartbeat` frame at the configured cadence (default 5 s) to keep HTTP intermediaries from cutting the idle connection. A busy subscription suppresses heartbeats (events reset the idle timer). + +## Setup + +- Run [Cold JOIN](../subscriptions/1.01-positive-cold-join-fresh-group.md) + [SEEK earliest](../positions/1.01-positive-seek-earliest.md) → `{sub_id}`, seeded, with no new events available. + +## Request + +```http +GET /v1/events:stream?subscription_id={sub_id} HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Accept: multipart/mixed +``` + +## Expected response + +- `200 OK multipart/mixed` +- On an idle subscription, a `heartbeat` `MP` frame arrives roughly every 5 s: + +``` +-- +Content-Type: application/json + +{ "kind": "heartbeat" } +-- +Content-Type: application/json + +{ "kind": "heartbeat" } +``` + +## Side effects + +- `subscription {sub_id} emits heartbeat within PT5S` while idle. +- Over a 20 s idle window, at least 3 `heartbeat` frames arrive (≥ ⌊20/5⌋ − 1). +- If an `event` frame is delivered, the idle timer resets and the next heartbeat is deferred by the full cadence. +- Heartbeats do not advance any cursor. diff --git a/gears/system/event-broker/scenarios/consumer/stream/1.03-positive-stream-topology-frame-on-rebalance.md b/gears/system/event-broker/scenarios/consumer/stream/1.03-positive-stream-topology-frame-on-rebalance.md new file mode 100644 index 000000000..2a18ffb38 --- /dev/null +++ b/gears/system/event-broker/scenarios/consumer/stream/1.03-positive-stream-topology-frame-on-rebalance.md @@ -0,0 +1,48 @@ +# Stream emits a topology frame on rebalance + +When a second consumer JOINs the same group, the broker rebalances partitions and notifies existing streams in-band via a `topology` frame carrying the new `topology_version` and the subscription's updated `assigned` list. The stream is NOT closed. + +> Broker-observable stream topology-frame behavior on rebalance. The new owner's subsequent SEEK calls are shown end-to-end in [consumer/flows/flow-1.1](../flows/1.01-flow-two-consumer-rebalance.md). Client-side policy for choosing re-SEEK positions is outside this scenario file. + +## Setup + +- Consumer A: group `{group_id}`, subscription `{sub_a}`, currently assigned all 4 partitions of `acme.orders.v1`, seeded and [streaming](./1.01-positive-stream-multipart-frames.md). +- Consumer B JOINs `{group_id}` with the same interest (per [subscriptions/positive-1.1](../subscriptions/1.01-positive-cold-join-fresh-group.md)), triggering a rebalance to a 2/2 split. + +## Request + +The triggering call is consumer B's JOIN on the same group (it causes the rebalance that A observes as a frame): + +```http +POST /v1/subscriptions HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Content-Type: application/json + +{ "consumer_group": "{group_id}", "client_agent": "order-worker/1.4.0", "interests": [ { "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", "tenant_id": "", "types": ["gts.cf.core.events.event.v1~acme.orders.*"] } ] } +``` + +## Expected behavior on A's open stream + +- A's stream receives a `topology` `MP` frame (the stream stays open): + +```json +{ + "kind": "topology", + "topology_version": 2, + "assigned": [ + { "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", "partition": 0, "offset": 42, "last_examined": 42 }, + { "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", "partition": 1, "offset": 17, "last_examined": 17 } + ] +} +``` + +- A keeps partitions `0`, `1`; partitions `2`, `3` move to B. + +## Side effects + +- Group `{group_id}` topology_version advances from `1` to `2`. +- `evbk_subscription({sub_a}).assigned` updates to `[0, 1]`; `evbk_subscription({sub_b}).assigned` is `[2, 3]`. +- `subscription {sub_a} next frame is topology with assigned=[0,1]` (no stream close). +- Partitions `2`, `3` are now owned by B and require B to SEEK before B's stream delivers them — B's `:stream` returns `409 PositionsNotSet` until B seeds them (see [stream/negative-1.4](./1.04-negative-stream-positions-not-set.md)). +- Cursors for the continuing partitions `0`, `1` are unchanged (only moved partitions are affected). diff --git a/gears/system/event-broker/scenarios/consumer/stream/1.04-negative-stream-positions-not-set.md b/gears/system/event-broker/scenarios/consumer/stream/1.04-negative-stream-positions-not-set.md new file mode 100644 index 000000000..215108014 --- /dev/null +++ b/gears/system/event-broker/scenarios/consumer/stream/1.04-negative-stream-positions-not-set.md @@ -0,0 +1,49 @@ +# Stream rejects when positions are not set + +Opening `:stream` before SEEKing a partition with no committed cursor returns `409 PositionsNotSet`. This is the defensive backstop that enforces "the consumer must declare its starting position explicitly". A well-behaved SDK SEEKs before streaming and never observes this on the happy path. + +> Broker-observable `409 PositionsNotSet` stream reply. The end-to-end recovery call sequence is [flows/flow-1.2](../flows/1.02-flow-positions-not-set-recovery.md). SDK recovery-loop policy is outside this scenario file. + +## Setup + +- Run [Cold JOIN against a fresh group](../subscriptions/1.01-positive-cold-join-fresh-group.md). Subscription id `{sub_id}`; assigned `("acme.orders.v1", 0..3)`. +- **No** SEEK has been performed — every assigned partition lacks a committed cursor. + +## Request + +```http +GET /v1/events:stream?subscription_id={sub_id} HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Accept: multipart/mixed +``` + +## Expected response + +- `409 Conflict` (`PD`) +- Body lists the unseeded `(topic, partition)` pairs and a recovery hint. + +```json +{ + "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.failed_precondition.v1~", + "title": "Failed Precondition", + "status": 409, + "detail": "4 assigned partition(s) have no committed cursor", + "instance": "/v1/events:stream", + "trace_id": "", + "context": { + "violations": [ + { "type": "cursor_missing", "subject": "gts.cf.core.events.topic.v1~acme.orders.v1:0", "description": "call POST /v1/subscriptions/{id}:seek before re-opening the stream" }, + { "type": "cursor_missing", "subject": "gts.cf.core.events.topic.v1~acme.orders.v1:1", "description": "call POST /v1/subscriptions/{id}:seek before re-opening the stream" }, + { "type": "cursor_missing", "subject": "gts.cf.core.events.topic.v1~acme.orders.v1:2", "description": "call POST /v1/subscriptions/{id}:seek before re-opening the stream" }, + { "type": "cursor_missing", "subject": "gts.cf.core.events.topic.v1~acme.orders.v1:3", "description": "call POST /v1/subscriptions/{id}:seek before re-opening the stream" } + ] + } +} +``` + +## Side effects + +- No stream is established; no frames are emitted. +- Session cursor state for the assigned partitions remains absent (the rejection changes no state). +- After the consumer SEEKs the listed partitions, `subsequent GET /v1/events:stream` returns `200` (see [stream/positive-1.1](./1.01-positive-stream-multipart-frames.md)). diff --git a/gears/system/event-broker/scenarios/consumer/stream/1.05-negative-stream-unknown-subscription.md b/gears/system/event-broker/scenarios/consumer/stream/1.05-negative-stream-unknown-subscription.md new file mode 100644 index 000000000..a4b2c3eff --- /dev/null +++ b/gears/system/event-broker/scenarios/consumer/stream/1.05-negative-stream-unknown-subscription.md @@ -0,0 +1,33 @@ +# Stream with an unknown subscription + +Opening `:stream` with a subscription id that never existed returns `404`. + +## Request + +```http +GET /v1/events:stream?subscription_id=99999999-8888-7777-6666-555555555555 HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Accept: multipart/mixed +``` + +## Expected response + +- `404 Not Found` (`PD`) + +```json +{ + "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.not_found.v1~", + "title": "Not Found", + "status": 404, + "detail": "subscription 99999999-8888-7777-6666-555555555555 not found", + "instance": "/v1/events:stream", + "trace_id": "", + "context": { + "resource_type": "gts.cf.core.events.subscription.v1~", + "resource_name": "99999999-8888-7777-6666-555555555555" + } +} +``` + + diff --git a/gears/system/event-broker/scenarios/consumer/stream/1.06-negative-stream-terminated-subscription.md b/gears/system/event-broker/scenarios/consumer/stream/1.06-negative-stream-terminated-subscription.md new file mode 100644 index 000000000..75f3b0c81 --- /dev/null +++ b/gears/system/event-broker/scenarios/consumer/stream/1.06-negative-stream-terminated-subscription.md @@ -0,0 +1,40 @@ +# Stream with a terminated (reaped) subscription + +Opening `:stream` with a subscription that existed but was reaped (session timeout) or LEFT returns `410`. The consumer must re-JOIN to continue. + +## Setup + +- Subscription `{sub_id}` existed but was reaped after its `session_timeout` elapsed with no poll/stream. + +## Request + +```http +GET /v1/events:stream?subscription_id={sub_id} HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Accept: multipart/mixed +``` + +## Expected response + +- `410 Gone` (`PD`) + +```json +{ + "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.not_found.v1~", + "title": "Not Found", + "status": 410, + "detail": "subscription {sub_id} was terminated (reaped or left); re-JOIN to continue", + "instance": "/v1/events:stream", + "trace_id": "", + "context": { + "resource_type": "gts.cf.core.events.subscription.v1~", + "resource_name": "{sub_id}" + } +} +``` + +## Side effects + +- No stream established. +- The group's committed cursors are unaffected — a fresh JOIN + SEEK resolving the committed cursor resumes where the reaped subscription left off. diff --git a/gears/system/event-broker/scenarios/consumer/stream/1.07-guardrail-stream-accept-json-rejected.md b/gears/system/event-broker/scenarios/consumer/stream/1.07-guardrail-stream-accept-json-rejected.md new file mode 100644 index 000000000..3d13c4ef6 --- /dev/null +++ b/gears/system/event-broker/scenarios/consumer/stream/1.07-guardrail-stream-accept-json-rejected.md @@ -0,0 +1,36 @@ +# Guardrail: `Accept: application/json` on :stream is rejected + +`:stream` serves only `multipart/mixed`. A client whose `Accept` header excludes it gets `406 Not Acceptable` with the supported types listed. (Guardrail: a caller reasonably expects content negotiation, so the boundary is documented explicitly.) + +## Setup + +- Run [Cold JOIN](../subscriptions/1.01-positive-cold-join-fresh-group.md) + [SEEK](../positions/1.01-positive-seek-earliest.md) → seeded `{sub_id}`. + +## Request + +```http +GET /v1/events:stream?subscription_id={sub_id} HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Accept: application/json +``` + +## Expected response + +- `406 Not Acceptable` (`PD`) +- Body lists the supported media types. + +```json +{ + "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.invalid_argument.v1~", + "title": "Invalid Argument", + "status": 406, + "detail": "this endpoint serves multipart/mixed only", + "instance": "/v1/events:stream", + "supported": ["multipart/mixed"] +} +``` + +## Side effects + +- No stream established. (`Accept: */*` or `Accept: multipart/mixed` would succeed.) diff --git a/gears/system/event-broker/scenarios/consumer/stream/1.08-guardrail-sse-from-stream-endpoint.md b/gears/system/event-broker/scenarios/consumer/stream/1.08-guardrail-sse-from-stream-endpoint.md new file mode 100644 index 000000000..f49fd9b6d --- /dev/null +++ b/gears/system/event-broker/scenarios/consumer/stream/1.08-guardrail-sse-from-stream-endpoint.md @@ -0,0 +1,36 @@ +# Guardrail: SSE is not served from :stream + +SSE (`text/event-stream`) is available only at the dedicated `:sse` endpoint. Requesting it from `:stream` returns `406` — the two transports are distinct paths, not content-negotiated variants of one endpoint. + +## Setup + +- Run [Cold JOIN](../subscriptions/1.01-positive-cold-join-fresh-group.md) + [SEEK](../positions/1.01-positive-seek-earliest.md) → seeded `{sub_id}`. + +## Request + +```http +GET /v1/events:stream?subscription_id={sub_id} HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Accept: text/event-stream +``` + +## Expected response + +- `406 Not Acceptable` (`PD`) +- Body lists `multipart/mixed` as the supported type and points to `:sse` for SSE. + +```json +{ + "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.invalid_argument.v1~", + "title": "Invalid Argument", + "status": 406, + "detail": "SSE is served only at /v1/events:sse; this endpoint serves multipart/mixed", + "instance": "/v1/events:stream", + "supported": ["multipart/mixed"] +} +``` + +## Side effects + +- No stream established. For SSE, the client must use [`GET /v1/events:sse`](./1.09-positive-sse-event-stream.md). diff --git a/gears/system/event-broker/scenarios/consumer/stream/1.09-positive-sse-event-stream.md b/gears/system/event-broker/scenarios/consumer/stream/1.09-positive-sse-event-stream.md new file mode 100644 index 000000000..5c6d06c0c --- /dev/null +++ b/gears/system/event-broker/scenarios/consumer/stream/1.09-positive-sse-event-stream.md @@ -0,0 +1,36 @@ +# SSE consumption via :sse + +The dedicated `:sse` endpoint serves `text/event-stream` for browser-native consumption. The same four frame kinds (`event` / `heartbeat` / `advisory` / `topology`) are carried via the SSE `event:` line, with the JSON payload on the `data:` line. + +## Setup + +- Run [Cold JOIN](../subscriptions/1.01-positive-cold-join-fresh-group.md) + [SEEK earliest](../positions/1.01-positive-seek-earliest.md) → seeded `{sub_id}`; one event available at offset `100`. + +## Request + +```http +GET /v1/events:sse?subscription_id={sub_id} HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Accept: text/event-stream +``` + +## Expected response + +- `200 OK` +- `Content-Type: text/event-stream` +- Frames as SSE events; `event:` carries the kind, `data:` the JSON payload. + +``` +event: event +data: { "offset": 100, "partition": 0, "subject": "order-100", "data": { "order_id": "order-100", "total_cents": 4299 } } + +event: heartbeat +data: {} +``` + +## Side effects + +- Same delivery semantics as `:stream` — one event per `event:` frame, offset-monotonic per `(topic, partition)`. +- Reconnect resume uses the committed cursor (acked via the positions endpoint), NOT SSE's `Last-Event-ID`. +- `:sse` is disabled by default in v1 and enabled via deployment config; when disabled it returns `404`. diff --git a/gears/system/event-broker/scenarios/consumer/stream/1.10-negative-stream-rejects-timeout-collect-params.md b/gears/system/event-broker/scenarios/consumer/stream/1.10-negative-stream-rejects-timeout-collect-params.md new file mode 100644 index 000000000..6c91205da --- /dev/null +++ b/gears/system/event-broker/scenarios/consumer/stream/1.10-negative-stream-rejects-timeout-collect-params.md @@ -0,0 +1,38 @@ +# Stream rejects legacy timeout / collect params + +The merged `:stream` endpoint takes only `subscription_id`. The pre-merge long-poll parameters `timeout` and `collect` no longer exist; supplying them is rejected `400` (or ignored — implementation choice; the OpenAPI spec defines neither). This scenario asserts the reject behavior. + +## Setup + +- Run [Cold JOIN](../subscriptions/1.01-positive-cold-join-fresh-group.md) + [SEEK](../positions/1.01-positive-seek-earliest.md) → seeded `{sub_id}`. + +## Request + +```http +GET /v1/events:stream?subscription_id={sub_id}&timeout=20&collect=50 HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Accept: multipart/mixed +``` + +## Expected response + +- `400 Bad Request` (`PD`) — unknown query parameters `timeout`, `collect`. + +```json +{ + "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.invalid_argument.v1~", + "title": "Invalid Argument", + "status": 400, + "detail": "unknown query parameters: timeout, collect — the stream endpoint accepts only subscription_id", + "instance": "/v1/events:stream", + "trace_id": "", + "context": { + "constraint": "unknown query parameters: timeout, collect — the stream endpoint accepts only subscription_id" + } +} +``` + +## Side effects + +- No stream established. (These params were removed by the `merge-poll-and-stream-endpoints` change; a streaming transport has no per-request timeout/collect window.) diff --git a/gears/system/event-broker/scenarios/consumer/stream/1.11-negative-streaming-in-progress.md b/gears/system/event-broker/scenarios/consumer/stream/1.11-negative-streaming-in-progress.md new file mode 100644 index 000000000..49bbc1d3a --- /dev/null +++ b/gears/system/event-broker/scenarios/consumer/stream/1.11-negative-streaming-in-progress.md @@ -0,0 +1,37 @@ +# Second stream (or any non-DELETE call) on a streaming subscription → 409 StreamingInProgress + +A `subscription_id` is single-thread: at most one `:stream` is open at a time, and while it is open no other call on that `subscription_id` is accepted except `DELETE` (the priority interrupt — see [positive-1.13](1.13-positive-delete-while-streaming.md)). A second `:stream`, a `:seek`, or a `GET` on the same `subscription_id` while a stream is open returns `409 StreamingInProgress`; the existing stream is unaffected. + +## Setup + +- Run [Cold JOIN](../subscriptions/1.01-positive-cold-join-fresh-group.md) → `{sub_id}`, SEEK every assigned partition. +- A [stream](1.01-positive-stream-multipart-frames.md) is open against `{sub_id}`. + +## Request (a second stream on the same subscription) + +```http +GET /v1/events:stream?subscription_id={sub_id} HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Accept: multipart/mixed +``` + +## Expected response + +- `409 Conflict` (`PD`) + +```json +{ + "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.failed_precondition.v1~", + "title": "Failed Precondition", + "status": 409, + "detail": "a stream is already open on {sub_id}", + "instance": "/v1/events:stream", + "trace_id": "", + "context": { "violations": [ { "type": "streaming_in_progress", "subject": "{sub_id}", "description": "one stream per subscription; DELETE is the only permitted concurrent call" } ] } +} +``` + +## Side effects + +- None. The first stream continues uninterrupted. (A `:seek` on the open stream returns the same `409 StreamingInProgress` — see [positions/negative-1.7](../positions/1.07-negative-seek-while-streaming.md).) diff --git a/gears/system/event-broker/scenarios/consumer/stream/1.13-positive-delete-while-streaming.md b/gears/system/event-broker/scenarios/consumer/stream/1.13-positive-delete-while-streaming.md new file mode 100644 index 000000000..f3d3ff3b8 --- /dev/null +++ b/gears/system/event-broker/scenarios/consumer/stream/1.13-positive-delete-while-streaming.md @@ -0,0 +1,27 @@ +# DELETE while streaming closes the connection immediately + +`DELETE /v1/subscriptions/{id}` is the one call permitted while a `:stream` is open — the priority interrupt. It terminates the stream connection immediately. The broker emits **no** `control` or `topology` frame before closing: the consumer initiated the teardown and knows why the connection ended, so no re-JOIN is implied. + +## Setup + +- Run [Cold JOIN](../subscriptions/1.01-positive-cold-join-fresh-group.md) → `{sub_id}`, SEEK every assigned partition. +- A [stream](1.01-positive-stream-multipart-frames.md) is open against `{sub_id}`. + +## Request + +```http +DELETE /v1/subscriptions/{sub_id} HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +``` + +## Expected behavior + +- `DELETE` returns `204 No Content`. +- The open `:stream` connection closes immediately, mid-frame-boundary; **no** `control`/`topology` frame precedes the close. +- A subsequent `GET /v1/events:stream?subscription_id={sub_id}` returns `404 SubscriptionNotFound` (the subscription was removed — contrast `410` for a *terminated* id from a rebalance gain). + +## Side effects + +- The subscription is removed and the group rebalances (its partitions are redistributed). +- The group cursor survives; no re-JOIN is implied for the deleting consumer. diff --git a/gears/system/event-broker/scenarios/consumer/stream/1.14-positive-control-progress-frame.md b/gears/system/event-broker/scenarios/consumer/stream/1.14-positive-control-progress-frame.md new file mode 100644 index 000000000..220570ed4 --- /dev/null +++ b/gears/system/event-broker/scenarios/consumer/stream/1.14-positive-control-progress-frame.md @@ -0,0 +1,32 @@ +# Filter-saturated stream emits a `control` progress frame + +When a subscription's filter rejects most events, the consumer receives few or no `event` frames while the broker's scan frontier (`last_examined`) advances far ahead of its delivered offset. The broker periodically emits a `control` frame with `code: "progress"` carrying the **sparse** subset of partitions whose `last_examined` has drifted — so the consumer can advance its offset store and, on a later reconnect, re-SEEK from the true frontier instead of re-scanning server-side-filtered events (R57). + +> Emission is conditional and settings-driven — it is NOT tied to the constant `heartbeat` cadence. The broker SHALL eventually emit one once the frontier drift on a partition exceeds a bounded amount, so even a fully-filtered subscription learns its frontier. + +## Setup + +- Run [Cold JOIN](../subscriptions/1.01-positive-cold-join-fresh-group.md) with a **narrow** filter that matches almost nothing (e.g. a type pattern no current event matches). `{sub_id}` assigned `("acme.orders.v1", 0)`, seeded and [streaming](1.01-positive-stream-multipart-frames.md). +- Producers append heavily to `("acme.orders.v1", 0)`; the broker scans them but the filter rejects them. + +## Expected behavior on the open stream + +- The consumer's delivered offset stays at `142` (no matching events), while a `control` `MP` frame arrives advancing the frontier: + +```json +{ + "kind": "control", + "code": "progress", + "positions": [ + { "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", "partition": 0, "offset": 142, "last_examined": 155 } + ] +} +``` + +- The stream stays open; the consumer records `last_examined = 155` in its offset store. + +## Side effects + +- The progress frame reports runtime `last_examined = 155` for `(group, "acme.orders.v1", 0)`. +- On a later reconnect the consumer re-SEEKs `0` to `155` (not `142`), so the broker does not re-scan `143..155`. +- The frame is sparse: only partitions whose `last_examined` drifted appear; steadily-matching partitions are omitted. diff --git a/gears/system/event-broker/scenarios/consumer/subscriptions/1.01-positive-cold-join-fresh-group.md b/gears/system/event-broker/scenarios/consumer/subscriptions/1.01-positive-cold-join-fresh-group.md new file mode 100644 index 000000000..a2b26702c --- /dev/null +++ b/gears/system/event-broker/scenarios/consumer/subscriptions/1.01-positive-cold-join-fresh-group.md @@ -0,0 +1,63 @@ +# Cold JOIN against a fresh group + +A consumer instance JOINs a consumer group with one topic-anchored interest. The broker creates group state, computes the assignment, and returns the subscription with its assigned partitions and the group's topology version. + +## Setup + +- Run [Create an anonymous group](../groups/1.01-positive-create-anonymous-group.md). Group id available as `{group_id}`. +- Topic `gts.cf.core.events.topic.v1~acme.orders.v1` is registered with 4 partitions. + +## Request + +```http +POST /v1/subscriptions HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Content-Type: application/json + +{ + "consumer_group": "{group_id}", + "client_agent": "order-worker/1.4.0 cf-event-broker-sdk/0.1.0", + "session_timeout": "PT30S", + "interests": [ + { + "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", + "tenant_id": "", + "types": ["gts.cf.core.events.event.v1~acme.orders.*"] + } + ] +} +``` + +## Expected response + +- `201 Created` +- Response body: + - `id` is a broker-minted subscription UUID + - `assigned[]` lists every `(topic, partition)` this subscription owns — for a fresh single-member group, all 4 partitions of the topic + - `topology_version` is the group's current version (incremented by this JOIN) + +```json +{ + "id": "11111111-2222-3333-4444-555555555555", + "consumer_group": "{group_id}", + "client_agent": "order-worker/1.4.0 cf-event-broker-sdk/0.1.0", + "session_timeout": "PT30S", + "interests": [ { "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", "tenant_id": "", "types": ["gts.cf.core.events.event.v1~acme.orders.*"] } ], + "assigned": [ + { "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", "partition": 0 }, + { "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", "partition": 1 }, + { "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", "partition": 2 }, + { "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", "partition": 3 } + ], + "topology_version": 1, + "expires_at": "2026-05-29T10:00:30Z" +} +``` + +## Side effects + +- `evbk_subscription({sub_id})` is set with the assignment and `topology_version=1`. +- Group `{group_id}` topology_version advances from `0` to `1`. +- `subscription {sub_id} is reaped after PT30S` if no subsequent poll/stream arrives. +- The subscription is NOT yet streamable: every assigned partition has no committed cursor, so a `GET /v1/events:stream` now would return `409 PositionsNotSet` (see [stream/negative-1.4](../stream/1.04-negative-stream-positions-not-set.md)). The consumer must SEEK first. diff --git a/gears/system/event-broker/scenarios/consumer/subscriptions/1.02-positive-join-multi-topic-interests.md b/gears/system/event-broker/scenarios/consumer/subscriptions/1.02-positive-join-multi-topic-interests.md new file mode 100644 index 000000000..f905a397c --- /dev/null +++ b/gears/system/event-broker/scenarios/consumer/subscriptions/1.02-positive-join-multi-topic-interests.md @@ -0,0 +1,49 @@ +# JOIN with interests across multiple topics + +A subscription may declare interests on more than one topic. The assignment spans `(topic, partition)` pairs across all declared topics. + +## Setup + +- Group `{group_id}` exists (per [groups/positive-1.1](../groups/1.01-positive-create-anonymous-group.md)). +- Topics `acme.orders.v1` (2 partitions) and `acme.shipments.v1` (2 partitions) are registered. + +## Request + +```http +POST /v1/subscriptions HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Content-Type: application/json + +{ + "consumer_group": "{group_id}", + "client_agent": "fulfilment-worker/2.0.0", + "interests": [ + { "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", "tenant_id": "", "types": ["gts.cf.core.events.event.v1~acme.orders.*"] }, + { "topic": "gts.cf.core.events.topic.v1~acme.shipments.v1", "tenant_id": "", "types": ["gts.cf.core.events.event.v1~acme.shipments.*"] } + ] +} +``` + +## Expected response + +- `201 Created` +- `assigned[]` spans both topics (sole member → all partitions of both). + +```json +{ + "id": "", + "assigned": [ + { "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", "partition": 0 }, + { "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", "partition": 1 }, + { "topic": "gts.cf.core.events.topic.v1~acme.shipments.v1", "partition": 0 }, + { "topic": "gts.cf.core.events.topic.v1~acme.shipments.v1", "partition": 1 } + ], + "topology_version": 1 +} +``` + +## Side effects + +- `evbk_subscription().assigned` holds all 4 `(topic, partition)` pairs. +- Each assigned partition is unseeded until SEEKed; opening `:stream` before SEEK returns `409 PositionsNotSet` listing all 4. diff --git a/gears/system/event-broker/scenarios/consumer/subscriptions/1.03-positive-join-with-typed-filter.md b/gears/system/event-broker/scenarios/consumer/subscriptions/1.03-positive-join-with-typed-filter.md new file mode 100644 index 000000000..d31f82764 --- /dev/null +++ b/gears/system/event-broker/scenarios/consumer/subscriptions/1.03-positive-join-with-typed-filter.md @@ -0,0 +1,51 @@ +# JOIN with a typed filter expression + +An interest may carry a paired `expression_type` + `expression` (both present or both absent). The broker compiles the expression at JOIN and stores it on the member; only matching events are delivered to this subscription. + +## Setup + +- Group `{group_id}` exists. Topic `acme.orders.v1` registered. + +## Request + +```http +POST /v1/subscriptions HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Content-Type: application/json + +{ + "consumer_group": "{group_id}", + "client_agent": "high-value-worker/1.0.0", + "interests": [ + { + "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", + "tenant_id": "", + "types": ["gts.cf.core.events.event.v1~acme.orders.created.v1"], + "expression_type": "gts.cf.core.events.filter.v1~cf.events.expression.cel.v1", + "expression": "event.data.total_cents > 100000" + } + ] +} +``` + +## Expected response + +- `201 Created` +- The subscription is created with the filter compiled and stored on the member. + +```json +{ + "id": "", + "assigned": [ + { "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", "partition": 0 }, + { "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", "partition": 1 } + ], + "topology_version": 1 +} +``` + +## Side effects + +- `evbk_subscription()` carries the compiled CEL filter for the interest. +- On a later stream, only events whose `data.total_cents > 100000` are delivered; non-matching events advance the broker's per-member `last_examined` without delivery (per DESIGN R57). diff --git a/gears/system/event-broker/scenarios/consumer/subscriptions/1.04-positive-parallelism-multiple-subscriptions.md b/gears/system/event-broker/scenarios/consumer/subscriptions/1.04-positive-parallelism-multiple-subscriptions.md new file mode 100644 index 000000000..2bbb71eb9 --- /dev/null +++ b/gears/system/event-broker/scenarios/consumer/subscriptions/1.04-positive-parallelism-multiple-subscriptions.md @@ -0,0 +1,45 @@ +# Parallelism: multiple subscriptions on one group + +Two JOINs against the same group split the topic's partitions between the resulting subscriptions. This is the broker-observable basis of consumer parallelism. (The full rebalance transcript including the `topology` frame is [flows/flow-1.1](../flows/1.01-flow-two-consumer-rebalance.md).) + +## Setup + +- Group `{group_id}` exists. Topic `acme.orders.v1` has 4 partitions. +- One subscription `{sub_a}` already JOINed (per [positive-1.1](./1.01-positive-cold-join-fresh-group.md)), owning all 4 partitions, topology_version `1`. + +## Request + +```http +POST /v1/subscriptions HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Content-Type: application/json + +{ + "consumer_group": "{group_id}", + "client_agent": "order-worker/1.4.0", + "interests": [ { "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", "tenant_id": "", "types": ["gts.cf.core.events.event.v1~acme.orders.*"] } ] +} +``` + +## Expected response + +- `201 Created` +- The new subscription `{sub_b}` is assigned a disjoint subset; the group rebalances to a 2/2 split. + +```json +{ + "id": "", + "assigned": [ + { "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", "partition": 2 }, + { "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", "partition": 3 } + ], + "topology_version": 2 +} +``` + +## Side effects + +- Group `{group_id}` topology_version advances `1 → 2`. +- `evbk_subscription({sub_a}).assigned` becomes `[0, 1]`; `evbk_subscription({sub_b}).assigned` is `[2, 3]` — disjoint, covering all 4 partitions exactly once. +- `{sub_a}`'s open stream (if any) receives a `topology` frame reflecting `[0, 1]` (see [stream/positive-1.3](../stream/1.03-positive-stream-topology-frame-on-rebalance.md)). diff --git a/gears/system/event-broker/scenarios/consumer/subscriptions/1.05-positive-leave-subscription.md b/gears/system/event-broker/scenarios/consumer/subscriptions/1.05-positive-leave-subscription.md new file mode 100644 index 000000000..a59a8e929 --- /dev/null +++ b/gears/system/event-broker/scenarios/consumer/subscriptions/1.05-positive-leave-subscription.md @@ -0,0 +1,26 @@ +# LEAVE a subscription + +A consumer terminates its subscription. The broker releases the subscription's partitions back to the group and rebalances any remaining members. + +## Setup + +- Run [Cold JOIN against a fresh group](./1.01-positive-cold-join-fresh-group.md) → `{sub_id}` on group `{group_id}`. + +## Request + +```http +DELETE /v1/subscriptions/{sub_id} HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +``` + +## Expected response + +- `204 No Content` + +## Side effects + +- `evbk_subscription({sub_id})` is absent. +- The partitions it held are released; if other members exist, the group's topology_version advances and survivors receive a `topology` frame. +- `subsequent GET /v1/events:stream?subscription_id={sub_id}` returns `404 SubscriptionNotFound`. +- LEAVE discards this subscription session; a future member must initialize its session cursor from consumer-owned progress or an explicit start position. diff --git a/gears/system/event-broker/scenarios/consumer/subscriptions/1.06-negative-join-unauthorized-topic.md b/gears/system/event-broker/scenarios/consumer/subscriptions/1.06-negative-join-unauthorized-topic.md new file mode 100644 index 000000000..5b5a9a17e --- /dev/null +++ b/gears/system/event-broker/scenarios/consumer/subscriptions/1.06-negative-join-unauthorized-topic.md @@ -0,0 +1,45 @@ +# JOIN rejected for an unauthorized topic + +A JOIN whose interest names a topic the calling principal cannot `consume` is rejected `403`. + +## Setup + +- Group `{group_id}` exists. Topic `acme.restricted.v1` is registered but the caller's principal lacks `consume` on it. + +## Request + +```http +POST /v1/subscriptions HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Content-Type: application/json + +{ + "consumer_group": "{group_id}", + "client_agent": "worker/1.0.0", + "interests": [ { "topic": "gts.cf.core.events.topic.v1~acme.restricted.v1", "tenant_id": "", "types": ["gts.cf.core.events.event.v1~acme.restricted.*"] } ] +} +``` + +## Expected response + +- `403 Forbidden` (`PD`) +- Wire-level code `TopicNotAuthorized`; the offending topic is in the body. + +```json +{ + "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.permission_denied.v1~", + "title": "Permission Denied", + "status": 403, + "detail": "principal lacks 'consume' on gts.cf.core.events.topic.v1~acme.restricted.v1", + "instance": "/v1/subscriptions", + "trace_id": "", + "context": { + "reason": "principal lacks 'consume' on gts.cf.core.events.topic.v1~acme.restricted.v1" + } +} +``` + +## Side effects + +- No subscription is created (`evbk_subscription` gains no row); group topology unchanged. diff --git a/gears/system/event-broker/scenarios/consumer/subscriptions/1.07-negative-join-too-many-interests.md b/gears/system/event-broker/scenarios/consumer/subscriptions/1.07-negative-join-too-many-interests.md new file mode 100644 index 000000000..ec446cc1a --- /dev/null +++ b/gears/system/event-broker/scenarios/consumer/subscriptions/1.07-negative-join-too-many-interests.md @@ -0,0 +1,49 @@ +# JOIN rejected for too many interests + +A subscription may declare at most 64 interests (`MAX_INTERESTS_PER_SUBSCRIPTION`). Exceeding the cap is rejected `400 TooManyInterests`. + +## Setup + +- Group `{group_id}` exists. + +## Request + +```http +POST /v1/subscriptions HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Content-Type: application/json + +{ + "consumer_group": "{group_id}", + "client_agent": "worker/1.0.0", + "interests": [ "...65 interest entries..." ] +} +``` + +(The `interests` array carries 65 entries; abbreviated here.) + +## Expected response + +- `400 Bad Request` (`PD`) +- Wire-level code `TooManyInterests`. + +```json +{ + "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.invalid_argument.v1~", + "title": "Invalid Argument", + "status": 400, + "detail": "interests count 65 exceeds the maximum of 64", + "instance": "/v1/subscriptions", + "trace_id": "", + "context": { + "constraint": "interests count 65 exceeds the maximum of 64" + } +} +``` + +## Side effects + +- No subscription is created. + +> Sibling caps share this validation envelope (same `400 PD`, different `title`): `TooManyTypes` (>32 per interest), `ExpressionTooLong` (>4096 bytes), `BadTypePattern` (wildcard violates GTS §10), `NoTypesMatched`, `TypeNotInTopic`, `UnknownFilterEngine`, `CompiledFilterTooLarge` (>64 KiB). Each is its own follow-up scenario; this file is the representative. diff --git a/gears/system/event-broker/scenarios/consumer/subscriptions/1.08-negative-leave-unknown-subscription.md b/gears/system/event-broker/scenarios/consumer/subscriptions/1.08-negative-leave-unknown-subscription.md new file mode 100644 index 000000000..0fc5767cd --- /dev/null +++ b/gears/system/event-broker/scenarios/consumer/subscriptions/1.08-negative-leave-unknown-subscription.md @@ -0,0 +1,32 @@ +# LEAVE an unknown subscription + +Terminating a subscription id that never existed (or was already reaped/deleted) returns `404`. + +## Request + +```http +DELETE /v1/subscriptions/99999999-8888-7777-6666-555555555555 HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +``` + +## Expected response + +- `404 Not Found` (`PD`) + +```json +{ + "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.not_found.v1~", + "title": "Not Found", + "status": 404, + "detail": "subscription 99999999-8888-7777-6666-555555555555 not found", + "instance": "/v1/subscriptions/99999999-8888-7777-6666-555555555555", + "trace_id": "", + "context": { + "resource_type": "gts.cf.core.events.subscription.v1~", + "resource_name": "99999999-8888-7777-6666-555555555555" + } +} +``` + + diff --git a/gears/system/event-broker/scenarios/consumer/subscriptions/1.09-positive-list-subscriptions.md b/gears/system/event-broker/scenarios/consumer/subscriptions/1.09-positive-list-subscriptions.md new file mode 100644 index 000000000..1b67701a9 --- /dev/null +++ b/gears/system/event-broker/scenarios/consumer/subscriptions/1.09-positive-list-subscriptions.md @@ -0,0 +1,42 @@ +# List active subscriptions + +Returns a paged list of subscriptions visible to the caller — subscriptions belonging to consumer groups the caller's principal can see. Useful for observability and debugging (e.g., "how many consumers are active for this group?"). + +## Request + +```http +GET /v1/subscriptions?limit=10 HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +``` + +## Expected response + +- `200 OK` + +```json +{ + "items": [ + { + "id": "11111111-2222-3333-4444-555555555555", + "consumer_group": "gts.cf.core.events.consumer_group.v1~{uuid}", + "client_agent": "order-worker/1.4.0 cf-event-broker-sdk/0.1.0", + "assigned": [ + { "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", "partition": 0 }, + { "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", "partition": 1 } + ], + "topology_version": 2, + "expires_at": "2026-06-15T10:00:30Z" + } + ], + "page_info": { + "next_cursor": null, + "limit": 10 + } +} +``` + +## Side effects + +- Read-only. No state changes. +- Returns only subscriptions for groups visible to the caller's `AccessScope`. Cross-tenant subscriptions are not visible. diff --git a/gears/system/event-broker/scenarios/consumer/subscriptions/1.10-positive-read-subscription.md b/gears/system/event-broker/scenarios/consumer/subscriptions/1.10-positive-read-subscription.md new file mode 100644 index 000000000..2fd666167 --- /dev/null +++ b/gears/system/event-broker/scenarios/consumer/subscriptions/1.10-positive-read-subscription.md @@ -0,0 +1,51 @@ +# Read a single subscription + +Returns the full record for one subscription by id — its current assignment, topology version, and expiry. Used by SDK recovery logic and observability tooling. + +## Setup + +- Run [Cold JOIN against a fresh group](./1.01-positive-cold-join-fresh-group.md). Subscription id `{sub_id}` available. + +## Request + +```http +GET /v1/subscriptions/{sub_id} HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +``` + +## Expected response + +- `200 OK` + +```json +{ + "id": "{sub_id}", + "consumer_group": "gts.cf.core.events.consumer_group.v1~{uuid}", + "client_agent": "order-worker/1.4.0 cf-event-broker-sdk/0.1.0", + "session_timeout": "PT30S", + "interests": [ + { + "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", + "tenant_id": "", + "types": ["gts.cf.core.events.event.v1~acme.orders.*"] + } + ], + "assigned": [ + { "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", "partition": 0 }, + { "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", "partition": 1 }, + { "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", "partition": 2 }, + { "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", "partition": 3 } + ], + "topology_version": 1, + "expires_at": "2026-06-15T10:00:30Z" +} +``` + +## Side effects + +- Read-only. TTL is NOT refreshed by a GET — only polls and seeks refresh `expires_at`. + +## Errors + +- `404 Not Found` — subscription expired or was deleted. diff --git a/gears/system/event-broker/scenarios/consumer/subscriptions/1.11-positive-second-join-triggers-rebalance.md b/gears/system/event-broker/scenarios/consumer/subscriptions/1.11-positive-second-join-triggers-rebalance.md new file mode 100644 index 000000000..c81978aa1 --- /dev/null +++ b/gears/system/event-broker/scenarios/consumer/subscriptions/1.11-positive-second-join-triggers-rebalance.md @@ -0,0 +1,55 @@ +# Second consumer JOINs — rebalance splits 4 partitions 2+2 + +When a second instance JOINs an existing consumer group, the broker rebalances. The new member is assigned half the partitions; the existing member's assignment is reduced and it receives a `topology` frame on its open stream. + +## Setup + +- Run [Cold JOIN against a fresh group](./1.01-positive-cold-join-fresh-group.md). `{sub_a}` holds all 4 partitions of `acme.orders.v1`. `{group_id}` distributed to instance B out-of-band. +- Instance A's stream is open; it receives the `topology` frame in-band on that open stream. + +## Request (instance B) + +```http +POST /v1/subscriptions HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Content-Type: application/json + +{ + "consumer_group": "{group_id}", + "client_agent": "order-worker/1.4.0 cf-event-broker-sdk/0.1.0", + "session_timeout": "PT30S", + "interests": [ + { + "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", + "tenant_id": "", + "types": ["gts.cf.core.events.event.v1~acme.orders.*"] + } + ] +} +``` + +## Expected response (instance B) + +- `201 Created` + +```json +{ + "id": "{sub_b}", + "consumer_group": "{group_id}", + "assigned": [ + { "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", "partition": 2 }, + { "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", "partition": 3 } + ], + "topology_version": 2, + "expires_at": "2026-06-15T10:00:30Z" +} +``` + +## Side effects + +- `evbk_subscription({sub_b})` created with partitions `[2, 3]`. +- `evbk_subscription({sub_a})` updated: assigned reduced to `[0, 1]`, `topology_version = 2`. +- Instance A's open stream receives a `topology` frame: + - `subscription {sub_a} next frame is topology with topology_version=2, assigned=[{topic, partition: 0}, {topic, partition: 1}]` +- Instance A drops in-flight events for partitions 2 and 3; instance B SEEKs and streams from the group cursor on those partitions. diff --git a/gears/system/event-broker/scenarios/consumer/subscriptions/1.12-positive-third-join-triggers-rebalance.md b/gears/system/event-broker/scenarios/consumer/subscriptions/1.12-positive-third-join-triggers-rebalance.md new file mode 100644 index 000000000..9721102af --- /dev/null +++ b/gears/system/event-broker/scenarios/consumer/subscriptions/1.12-positive-third-join-triggers-rebalance.md @@ -0,0 +1,53 @@ +# Third consumer JOINs — rebalance splits 4 partitions 1+2+1 or 2+1+1 + +When a third instance JOINs, the broker redistributes the 4 partitions across all three members. Each existing member whose assignment shrinks receives a non-terminal `topology` frame (its reduced assignment) and keeps streaming; it does not gain partitions, so it does not re-SEEK. The new member receives its assignment in the JOIN response and SEEKs before opening its stream. + +## Setup + +- Run [Second consumer JOINs](./1.11-positive-second-join-triggers-rebalance.md). `{sub_a}` holds `[0, 1]`, `{sub_b}` holds `[2, 3]`. Both streams open. + +## Request (instance C) + +```http +POST /v1/subscriptions HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Content-Type: application/json + +{ + "consumer_group": "{group_id}", + "client_agent": "order-worker/1.4.0 cf-event-broker-sdk/0.1.0", + "session_timeout": "PT30S", + "interests": [ + { + "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", + "tenant_id": "", + "types": ["gts.cf.core.events.event.v1~acme.orders.*"] + } + ] +} +``` + +## Expected response (instance C) + +- `201 Created` + +```json +{ + "id": "{sub_c}", + "consumer_group": "{group_id}", + "assigned": [ + { "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", "partition": 1 }, + { "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", "partition": 3 } + ], + "topology_version": 3, + "expires_at": "2026-06-15T10:00:30Z" +} +``` + +## Side effects + +- Three-way split: `{sub_a}` → `[0]`, `{sub_b}` → `[2]`, `{sub_c}` → `[1, 3]`. +- Both `{sub_a}` and `{sub_b}` receive a `topology` frame with their reduced assignments at `topology_version = 3`. +- Instance C SEEKs partitions 1 and 3 from the group cursor (or `"earliest"` / `"latest"` if no cursor exists) before streaming. +- Each partition is owned by exactly one subscription at all times — the single-consumer-per-partition invariant is preserved. diff --git a/gears/system/event-broker/scenarios/consumer/subscriptions/1.13-negative-join-group-at-capacity.md b/gears/system/event-broker/scenarios/consumer/subscriptions/1.13-negative-join-group-at-capacity.md new file mode 100644 index 000000000..2400f9dec --- /dev/null +++ b/gears/system/event-broker/scenarios/consumer/subscriptions/1.13-negative-join-group-at-capacity.md @@ -0,0 +1,47 @@ +# JOIN a group already at capacity → 429 GroupAtCapacity + +A subscription always holds at least one partition. When a consumer group already has as many members as the topic has partitions, a further JOIN that would receive zero partitions is refused with `429` and a `Retry-After` — the consumer is not admitted as a zero-partition standby. It retries the JOIN later; a slot frees when a member leaves. + +> The `429` shares its status with the per-tenant rate-limit refusal; the body `code` disambiguates (`GroupAtCapacity` vs `RateLimitExceeded`). The admission decision is owned by the consumer-subscription-lifecycle capability; this scenario records the broker-observable refusal the stream contract relies on. + +## Setup + +- Topic `gts.cf.core.events.topic.v1~acme.orders.v1` has 4 partitions. +- Group `{group_id}` already has 4 active members, each holding one partition. + +## Request (a 5th consumer) + +```http +POST /v1/subscriptions HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Content-Type: application/json + +{ + "consumer_group": "{group_id}", + "client_agent": "order-worker/1.4.0", + "interests": [ { "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", "tenant_id": "", "types": ["gts.cf.core.events.event.v1~acme.orders.*"] } ] +} +``` + +## Expected response + +- `429 Too Many Requests` +- `Retry-After: 5` + +```json +{ + "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.resource_exhausted.v1~", + "title": "Resource Exhausted", + "status": 429, + "detail": "group {group_id} has 4 members for 4 partitions; no partition slot available", + "instance": "/v1/subscriptions", + "trace_id": "", + "context": { "code": "GroupAtCapacity" } +} +``` + +## Side effects + +- No subscription is created (no zero-partition standby). +- The consumer retries the JOIN after `Retry-After`; when a member leaves, the next retry is admitted and assigned the freed partition. diff --git a/gears/system/event-broker/scenarios/errors/1.01-positive-problem-details-envelope.md b/gears/system/event-broker/scenarios/errors/1.01-positive-problem-details-envelope.md new file mode 100644 index 000000000..d9eb02f26 --- /dev/null +++ b/gears/system/event-broker/scenarios/errors/1.01-positive-problem-details-envelope.md @@ -0,0 +1,53 @@ +# RFC-9457 Problem Details envelope + +The canonical error envelope used by every `4xx` / `5xx` response. All error scenarios in other areas reference this shape rather than restating it. + +## Request + +Any request that triggers an error (here, a representative `404`): + +```http +GET /v1/consumer_groups/gts.cf.core.events.consumer_group.v1~deadbeef-0000-0000-0000-000000000000 HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +``` + +## Expected response + +- A `4xx`/`5xx` status with `Content-Type: application/problem+json`. +- Body is an RFC-9457 Problem Details object produced by `toolkit-canonical-errors`. Members: + - `type` — canonical GTS category URI: `gts://gts.cf.core.errors.err.v1~cf.core.err..v1~` + - `title` — category title (e.g. `"Not Found"`, `"Invalid Argument"`, `"Failed Precondition"`) + - `status` — HTTP status code, integer + - `detail` — human-readable, request-specific explanation + - `instance` — URI/path of the specific occurrence (middleware-injected) + - `trace_id` — distributed tracing correlation (middleware-injected) + - `context` — category-specific structured payload; always present (may be `{}`); domain identity expressed here via `resource_type`/`resource_name` or violation arrays + +```http +HTTP/1.1 404 Not Found +Content-Type: application/problem+json + +{ + "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.not_found.v1~", + "title": "Not Found", + "status": 404, + "detail": "consumer group gts.cf.core.events.consumer_group.v1~deadbeef-... not found", + "instance": "/v1/consumer_groups/gts.cf.core.events.consumer_group.v1~deadbeef-...", + "trace_id": "", + "context": { + "resource_type": "gts.cf.core.events.consumer_group.v1~", + "resource_name": "gts.cf.core.events.consumer_group.v1~deadbeef-..." + } +} +``` + +## Conventions + +- `type` is always a canonical category URI — never a domain-specific instance path. +- `title` is the canonical category title (`"Not Found"`, `"Invalid Argument"`, etc.), not a domain error slug. +- `context` shape varies by category; see `DESIGN.md §cpt-cf-evbk-interface-error-codes` for the full table. +- No internal implementation detail (stack traces, internal hostnames) appears in `detail` or `context`. +- The shorthand `PD` used throughout the scenarios means "a body of exactly this shape". + + diff --git a/gears/system/event-broker/scenarios/errors/1.02-negative-401-unauthenticated.md b/gears/system/event-broker/scenarios/errors/1.02-negative-401-unauthenticated.md new file mode 100644 index 000000000..3f9606191 --- /dev/null +++ b/gears/system/event-broker/scenarios/errors/1.02-negative-401-unauthenticated.md @@ -0,0 +1,33 @@ +# 401 — unauthenticated + +A request with a missing or invalid bearer token is rejected `401` before any resource logic runs. + +## Request + +```http +POST /v1/consumer_groups HTTP/1.1 +Host: broker.example.com +Content-Type: application/json + +{ "description": "no token" } +``` + +(No `Authorization` header.) + +## Expected response + +- `401 Unauthorized` (`PD`) + +```json +{ + "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.unauthenticated.v1~", + "title": "Unauthenticated", + "status": 401, + "detail": "missing or invalid bearer token", + "instance": "/v1/consumer_groups", + "trace_id": "", + "context": {} +} +``` + + diff --git a/gears/system/event-broker/scenarios/errors/1.03-negative-403-unauthorized.md b/gears/system/event-broker/scenarios/errors/1.03-negative-403-unauthorized.md new file mode 100644 index 000000000..aace50e2c --- /dev/null +++ b/gears/system/event-broker/scenarios/errors/1.03-negative-403-unauthorized.md @@ -0,0 +1,43 @@ +# 403 — authenticated but not authorized + +A valid token whose principal lacks the required permission is rejected `403`. The SDK rolls up the wire-level fine-grained codes (`TopicNotAuthorized`, `EventTypeNotAuthorized`, `TenantIdNotAuthorized`) into one `Unauthorized`; the HTTP layer keeps the specific `title`. + +## Request + +```http +POST /v1/subscriptions HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Content-Type: application/json + +{ + "consumer_group": "{group_id}", + "client_agent": "worker/1.0.0", + "interests": [ { "topic": "gts.cf.core.events.topic.v1~acme.restricted.v1", "tenant_id": "", "types": ["gts.cf.core.events.event.v1~acme.restricted.*"] } ] +} +``` + +## Expected response + +- `403 Forbidden` (`PD`) + +```json +{ + "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.permission_denied.v1~", + "title": "Permission Denied", + "status": 403, + "detail": "principal lacks 'consume' on gts.cf.core.events.topic.v1~acme.restricted.v1", + "instance": "/v1/subscriptions", + "trace_id": "", + "context": { + "reason": "principal lacks 'consume' on gts.cf.core.events.topic.v1~acme.restricted.v1" + } +} +``` + +## Notes + +- Sibling `403` conditions share this envelope; the offending resource is identified in `context.reason`, not as a root-level field. Producer-side `Forbidden` on publish follows the same shape. +- See the endpoint-specific instance: [consumer/subscriptions/negative-1.6](../consumer/subscriptions/1.06-negative-join-unauthorized-topic.md). + + diff --git a/gears/system/event-broker/scenarios/errors/1.04-negative-404-not-found.md b/gears/system/event-broker/scenarios/errors/1.04-negative-404-not-found.md new file mode 100644 index 000000000..d82ed33c1 --- /dev/null +++ b/gears/system/event-broker/scenarios/errors/1.04-negative-404-not-found.md @@ -0,0 +1,37 @@ +# 404 — resource not found + +A request referencing a resource that doesn't exist returns `404`. The `title` names the specific resource kind. + +## Request + +```http +GET /v1/events:stream?subscription_id=99999999-8888-7777-6666-555555555555 HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Accept: multipart/mixed +``` + +## Expected response + +- `404 Not Found` (`PD`) + +```json +{ + "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.not_found.v1~", + "title": "Not Found", + "status": 404, + "detail": "subscription 99999999-8888-7777-6666-555555555555 not found", + "instance": "/v1/events:stream", + "trace_id": "", + "context": { + "resource_type": "gts.cf.core.events.subscription.v1~", + "resource_name": "99999999-8888-7777-6666-555555555555" + } +} +``` + +## Notes + +All 404s share this canonical shape; the `context` carries `resource_type` and `resource_name` to identify what wasn't found. Sibling endpoint instances: [groups/negative-1.7](../consumer/groups/1.07-negative-get-unknown-group.md), [topics/negative-1.3](../topics/1.03-negative-segments-unknown-topic.md), [stream/negative-1.5](../consumer/stream/1.05-negative-stream-unknown-subscription.md). Endpoint-specific instances: [groups/negative-1.7](../consumer/groups/1.07-negative-get-unknown-group.md), [topics/negative-1.3](../topics/1.03-negative-segments-unknown-topic.md), [stream/negative-1.5](../consumer/stream/1.05-negative-stream-unknown-subscription.md). + + diff --git a/gears/system/event-broker/scenarios/errors/1.05-negative-409-conflict.md b/gears/system/event-broker/scenarios/errors/1.05-negative-409-conflict.md new file mode 100644 index 000000000..7b6932018 --- /dev/null +++ b/gears/system/event-broker/scenarios/errors/1.05-negative-409-conflict.md @@ -0,0 +1,51 @@ +# 400 — failed precondition state conflict + +A request that conflicts with current broker state and maps to canonical `FailedPrecondition` returns the canonical status `400`. Several distinct conditions share the category; the structured `context.violations` values disambiguate them. + +## Request + +Representative `FailedPrecondition` trigger (opening a stream whose assigned partitions have no committed cursor): + +```http +GET /v1/events:stream?subscription_id={sub_id} HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Accept: multipart/mixed +``` + +(Opening a stream whose assigned partitions have no committed cursor.) + +## Expected response + +- `400 Bad Request` (`PD`) + +```json +{ + "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.failed_precondition.v1~", + "title": "Failed Precondition", + "status": 400, + "detail": "1 assigned partition(s) have no committed cursor", + "instance": "/v1/events:stream", + "trace_id": "", + "context": { + "violations": [ + { + "type": "cursor_missing", + "subject": "gts.cf.core.events.topic.v1~acme.orders.v1:0", + "description": "no committed cursor; call POST /v1/subscriptions/{id}:seek before opening the stream" + } + ] + } +} +``` + +## The `FailedPrecondition` family + +| Condition | `context.violations[*].type` | Endpoint-specific scenario | +|---|---|---| +| Assigned partition has no cursor on stream-open | `cursor_missing` | [stream/negative-1.4](../consumer/stream/1.04-negative-stream-positions-not-set.md) | +| SEEK (or a second stream / any non-DELETE call) while a stream is open | `streaming_in_progress` | [positions/negative-1.7](../consumer/positions/1.07-negative-seek-while-streaming.md) | +| SEEK references an unassigned partition | `partition_not_assigned` | [positions/negative-1.9](../consumer/positions/1.09-negative-seek-unassigned-partition.md) | +| DELETE group with active subscriptions | `has_active_members` | [groups/negative-1.5](../consumer/groups/1.05-negative-delete-group-with-active-members.md) | + + diff --git a/gears/system/event-broker/scenarios/errors/1.06-negative-412-sequence-violation.md b/gears/system/event-broker/scenarios/errors/1.06-negative-412-sequence-violation.md new file mode 100644 index 000000000..eb0b50440 --- /dev/null +++ b/gears/system/event-broker/scenarios/errors/1.06-negative-412-sequence-violation.md @@ -0,0 +1,56 @@ +# 400 — sequence violation + +The only producer-protocol error that escapes to callers. A chained-mode publish whose `meta.previous` doesn't match the broker's `last_sequence` maps to canonical `FailedPrecondition` and is rejected with canonical status `400`, with the broker's current value for resync. + +## Request + +```http +POST /v1/events HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Content-Type: application/json + +{ + "id": "c0000000-0000-0000-0000-000000000099", + "type": "gts.cf.core.events.event.v1~acme.orders.created.v1", + "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", + "tenant_id": "", + "source": "order-service", + "subject": "order-chain", + "subject_type": "gts.cf.core.events.subject.v1~acme.order.v1", + "occurred_at": "2026-05-29T10:08:00Z", + "data": { "order_id": "order-chain", "total_cents": 700 }, + "meta": { "version": 1, "producer_id": "{producer_id}", "previous": 3, "sequence": 4 } +} +``` + +## Expected response + +- `400 Bad Request` (`PD`) +- The broker's current `last_sequence` is in `context.violations[0].description` for resync. + +```json +{ + "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.failed_precondition.v1~", + "title": "Failed Precondition", + "status": 400, + "detail": "meta.previous=3 does not match broker last_sequence=7", + "instance": "/v1/events", + "trace_id": "", + "context": { + "violations": [ + { + "type": "sequence_mismatch", + "subject": "(producer)", + "description": "expected_previous=7" + } + ] + } +} +``` + +## Notes + +Endpoint-specific instance: [flows/negative-1.5](../producer/flows/1.05-negative-chained-sequence-violation.md). For `:batch`, a violation on any event rejects the whole batch atomically. + + diff --git a/gears/system/event-broker/scenarios/errors/1.07-negative-429-rate-limited.md b/gears/system/event-broker/scenarios/errors/1.07-negative-429-rate-limited.md new file mode 100644 index 000000000..6d900087d --- /dev/null +++ b/gears/system/event-broker/scenarios/errors/1.07-negative-429-rate-limited.md @@ -0,0 +1,49 @@ +# 429 — rate limited + +When a tenant exceeds a rate cap (publish quota, or the per-tenant JOIN cap), the broker returns `429` with a `Retry-After` header and `retry_after_secs` in the body. + +## Request + +```http +POST /v1/events HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Content-Type: application/json + +{ "id": "f0000000-0000-0000-0000-000000000001", "type": "gts.cf.core.events.event.v1~acme.orders.created.v1", "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", "tenant_id": "", "source": "svc", "subject": "o", "subject_type": "gts.cf.core.events.subject.v1~acme.order.v1", "occurred_at": "2026-05-29T10:11:00Z", "data": { "order_id": "o", "total_cents": 1 } } +``` + +## Expected response + +- `429 Too Many Requests` (`PD`) +- `Retry-After: 30` header; retry guidance in `context.violations[0].retry_after_seconds`. + +```http +HTTP/1.1 429 Too Many Requests +Retry-After: 30 +Content-Type: application/problem+json + +{ + "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.resource_exhausted.v1~", + "title": "Resource Exhausted", + "status": 429, + "detail": "tenant publish quota exceeded; retry after 30s", + "instance": "/v1/events", + "trace_id": "", + "context": { + "violations": [ + { + "subject": "publish-quota", + "description": "tenant publish quota exceeded", + "retry_after_seconds": 30 + } + ] + } +} +``` + +## Notes + +Also fires on `POST /v1/subscriptions` (per-tenant JOIN rate cap, default 60/min). Endpoint-specific instance: [single/negative-1.4](../producer/single/1.04-negative-rate-limited.md). + + diff --git a/gears/system/event-broker/scenarios/errors/1.08-negative-500-internal.md b/gears/system/event-broker/scenarios/errors/1.08-negative-500-internal.md new file mode 100644 index 000000000..c8a4eef9c --- /dev/null +++ b/gears/system/event-broker/scenarios/errors/1.08-negative-500-internal.md @@ -0,0 +1,40 @@ +# 500 — internal error + +An unexpected broker invariant failure returns `500` with a Problem Details body that leaks NO internal detail (no stack trace, no internal hostnames). The `instance` correlates to the failed request for log lookup. + +## Request + +Any request that trips an internal invariant (illustrative — not a contract a client can deliberately trigger): + +```http +POST /v1/events HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Content-Type: application/json + +{ "id": "...", "type": "gts.cf.core.events.event.v1~acme.orders.created.v1", "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", "tenant_id": "", "source": "svc", "subject": "o", "subject_type": "gts.cf.core.events.subject.v1~acme.order.v1", "occurred_at": "2026-05-29T10:12:00Z", "data": { "order_id": "o", "total_cents": 1 } } +``` + +## Expected response + +- `500 Internal Server Error` (`PD`) +- Generic `detail`; no internals exposed. + +```json +{ + "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.internal.v1~", + "title": "Internal", + "status": 500, + "detail": "an internal error occurred; the request was not processed", + "instance": "/v1/events#req-7c9f2a", + "trace_id": "", + "context": {} +} +``` + +## Notes + +- `instance` carries a correlation token (`#req-...`) operators can grep in logs; the actual failure detail lives only in server-side logs. +- A `PartitionHashMismatch` (a CI-bug indicator, per the SDK error model) surfaces here as `500`, not as a distinct caller-facing code. + + diff --git a/gears/system/event-broker/scenarios/flows/1.01-flow-publish-subscribe-consume.md b/gears/system/event-broker/scenarios/flows/1.01-flow-publish-subscribe-consume.md new file mode 100644 index 000000000..8582e4934 --- /dev/null +++ b/gears/system/event-broker/scenarios/flows/1.01-flow-publish-subscribe-consume.md @@ -0,0 +1,271 @@ +# Flow: publish → subscribe → consume → ack + +The canonical end-to-end journey, shown as the **complete sequence of HTTP interactions** between clients and the broker. A producer publishes three events; a consumer establishes a group, JOINs, SEEKs, streams the events in order, and acks; a fresh re-JOIN resumes past the acked offset. + +Every request and response is shown inline. All three events hash to partition `0` of `gts.cf.core.events.topic.v1~acme.orders.v1` (4 partitions, `RF = 100`). + +--- + +## Exchange 1 — Publish event #1 (→ offset 100) + +```http +POST /v1/events HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Content-Type: application/json + +{ + "id": "a1b2c3d4-0000-0000-0000-000000000001", + "type": "gts.cf.core.events.event.v1~acme.orders.created.v1", + "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", + "tenant_id": "", + "source": "order-service", + "subject": "order-100", + "subject_type": "gts.cf.core.events.subject.v1~acme.order.v1", + "occurred_at": "2026-05-29T10:00:00Z", + "data": { "order_id": "order-100", "total_cents": 4299 } +} +``` + +```http +HTTP/1.1 202 Accepted +``` + +## Exchange 2 — Publish event #2 (→ offset 101) + +```http +POST /v1/events HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Content-Type: application/json + +{ + "id": "a1b2c3d4-0000-0000-0000-000000000002", + "type": "gts.cf.core.events.event.v1~acme.orders.created.v1", + "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", + "tenant_id": "", + "source": "order-service", + "subject": "order-101", + "subject_type": "gts.cf.core.events.subject.v1~acme.order.v1", + "occurred_at": "2026-05-29T10:00:01Z", + "data": { "order_id": "order-101", "total_cents": 1599 } +} +``` + +```http +HTTP/1.1 202 Accepted +``` + +## Exchange 3 — Publish event #3 (→ offset 102) + +```http +POST /v1/events HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Content-Type: application/json + +{ + "id": "a1b2c3d4-0000-0000-0000-000000000003", + "type": "gts.cf.core.events.event.v1~acme.orders.created.v1", + "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", + "tenant_id": "", + "source": "order-service", + "subject": "order-102", + "subject_type": "gts.cf.core.events.subject.v1~acme.order.v1", + "occurred_at": "2026-05-29T10:00:02Z", + "data": { "order_id": "order-102", "total_cents": 8800 } +} +``` + +```http +HTTP/1.1 202 Accepted +``` + +## Exchange 4 — Create the consumer group + +```http +POST /v1/consumer_groups HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Content-Type: application/json + +{ "description": "order-fulfilment workers" } +``` + +```http +HTTP/1.1 201 Created +Location: /v1/consumer_groups/gts.cf.core.events.consumer_group.v1~7b2c1e9a-4f3d-4a2b-9c8e-1d2f3a4b5c6d +Content-Type: application/json + +{ + "id": "gts.cf.core.events.consumer_group.v1~7b2c1e9a-4f3d-4a2b-9c8e-1d2f3a4b5c6d", + "tenant_id": "", + "owner_principal_id": "", + "kind": "anonymous", + "description": "order-fulfilment workers", + "created_at": "2026-05-29T10:00:03Z" +} +``` + +The minted id is used as `{group_id}` below. + +## Exchange 5 — JOIN a subscription + +```http +POST /v1/subscriptions HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Content-Type: application/json + +{ + "consumer_group": "{group_id}", + "client_agent": "order-worker/1.4.0 cf-event-broker-sdk/0.1.0", + "session_timeout": "PT30S", + "interests": [ + { + "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", + "tenant_id": "", + "types": ["gts.cf.core.events.event.v1~acme.orders.*"] + } + ] +} +``` + +```http +HTTP/1.1 201 Created +Content-Type: application/json + +{ + "id": "11111111-2222-3333-4444-555555555555", + "consumer_group": "{group_id}", + "assigned": [ + { "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", "partition": 0 }, + { "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", "partition": 1 }, + { "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", "partition": 2 }, + { "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", "partition": 3 } + ], + "topology_version": 1, + "expires_at": "2026-05-29T10:00:33Z" +} +``` + +The subscription id is used as `{sub_id}` below. + +## Exchange 6 — SEEK all assigned partitions (earliest) + +A single SEEK seeds every assigned partition. Partitions `0` has `RF = 100`; `1`/`2`/`3` are empty so their floors resolve accordingly. + +```http +POST /v1/subscriptions/{sub_id}:seek HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Content-Type: application/json + +{ + "partition_positions": { + "gts.cf.core.events.topic.v1~acme.orders.v1:0": "earliest", + "gts.cf.core.events.topic.v1~acme.orders.v1:1": "earliest", + "gts.cf.core.events.topic.v1~acme.orders.v1:2": "earliest", + "gts.cf.core.events.topic.v1~acme.orders.v1:3": "earliest" + } +} +``` + +```http +HTTP/1.1 200 OK +Content-Type: application/json + +{ + "partition_positions": { + "gts.cf.core.events.topic.v1~acme.orders.v1:0": 99, + "gts.cf.core.events.topic.v1~acme.orders.v1:1": -1, + "gts.cf.core.events.topic.v1~acme.orders.v1:2": -1, + "gts.cf.core.events.topic.v1~acme.orders.v1:3": -1 + } +} +``` + +## Exchange 7 — Open the stream + +```http +GET /v1/events:stream?subscription_id={sub_id} HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Accept: multipart/mixed +``` + +```http +HTTP/1.1 200 OK +Content-Type: multipart/mixed; boundary=evbk7f3a +Transfer-Encoding: chunked + +--evbk7f3a +Content-Type: application/json + +{ "kind": "event", "event": { "offset": 100, "partition": 0, "subject": "order-100", "type": "gts.cf.core.events.event.v1~acme.orders.created.v1", "data": { "order_id": "order-100", "total_cents": 4299 } } } +--evbk7f3a +Content-Type: application/json + +{ "kind": "event", "event": { "offset": 101, "partition": 0, "subject": "order-101", "data": { "order_id": "order-101", "total_cents": 1599 } } } +--evbk7f3a +Content-Type: application/json + +{ "kind": "event", "event": { "offset": 102, "partition": 0, "subject": "order-102", "data": { "order_id": "order-102", "total_cents": 8800 } } } +--evbk7f3a +Content-Type: application/json + +{ "kind": "heartbeat" } +``` + +Events arrive on partition `0` in strict offset order `100 → 101 → 102`; the other partitions are empty, so only heartbeats follow. + +## Exchange 8 — Ack through offset 102 + +```http +POST /v1/subscriptions/{sub_id}:seek HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Content-Type: application/json + +{ "partition_positions": { "gts.cf.core.events.topic.v1~acme.orders.v1:0": 102 } } +``` + +```http +HTTP/1.1 200 OK +Content-Type: application/json + +{ "partition_positions": { "gts.cf.core.events.topic.v1~acme.orders.v1:0": 102 } } +``` + +## Exchange 9 — Re-JOIN resumes past the ack + +A new subscription on the same group, SEEKing `0` to its committed cursor, resumes at offset `103` — no redelivery of `100`–`102`. (LEAVE of `{sub_id}` omitted for brevity; the group cursor is what carries resumption.) + +```http +GET /v1/events:stream?subscription_id={sub_id_2} HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Accept: multipart/mixed +``` + +```http +HTTP/1.1 200 OK +Content-Type: multipart/mixed; boundary=evbk88b1 +Transfer-Encoding: chunked + +--evbk88b1 +Content-Type: application/json + +{ "kind": "heartbeat" } +``` + +No `event` frames — offsets `100`–`102` were acked, and nothing newer exists yet. + +--- + +## Side effects (cumulative) + +- `evbk_event` on `("acme.orders.v1", 0)` holds the three events at offsets `100`, `101`, `102`. +- Session cursor for `({group_id}, "acme.orders.v1", 0)` advances `99 -> 102` across Exchanges 6 and 8. +- Session cursor for `({group_id}, "acme.orders.v1", 1|2|3)` is initialized to `0` (empty partitions with RF = 1, seeded earliest -> cursor = RF - 1 = 0). +- `metric events_delivered incremented by 3` for `{group_id}`. diff --git a/gears/system/event-broker/scenarios/producer/batch/1.01-positive-publish-batch.md b/gears/system/event-broker/scenarios/producer/batch/1.01-positive-publish-batch.md new file mode 100644 index 000000000..f938c86d6 --- /dev/null +++ b/gears/system/event-broker/scenarios/producer/batch/1.01-positive-publish-batch.md @@ -0,0 +1,52 @@ +# Publish a batch (atomic per topic) + +`POST /v1/events:batch` submits up to 100 events for a single topic, all-or-nothing. + +## Setup + +- Topic `acme.orders.v1` and event type `acme.orders.created.v1` registered. + +## Request + +```http +POST /v1/events:batch HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Content-Type: application/json + +{ + "events": [ + { + "id": "b0000000-0000-0000-0000-000000000001", + "type": "gts.cf.core.events.event.v1~acme.orders.created.v1", + "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", + "tenant_id": "", + "source": "order-service", + "subject": "order-A", + "subject_type": "gts.cf.core.events.subject.v1~acme.order.v1", + "occurred_at": "2026-05-29T10:06:00Z", + "data": { "order_id": "order-A", "total_cents": 100 } + }, + { + "id": "b0000000-0000-0000-0000-000000000002", + "type": "gts.cf.core.events.event.v1~acme.orders.created.v1", + "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", + "tenant_id": "", + "source": "order-service", + "subject": "order-B", + "subject_type": "gts.cf.core.events.subject.v1~acme.order.v1", + "occurred_at": "2026-05-29T10:06:01Z", + "data": { "order_id": "order-B", "total_cents": 250 } + } + ] +} +``` + +## Expected response + +- `202 Accepted` — both events durably enqueued. + +## Side effects + +- Both events enqueued in the per-topic ingest outbox; partitions computed per-event from each `subject`. +- All-or-nothing: if any event in the batch fails validation/dedup, the whole batch is rejected (see [flows/negative-1.5](../flows/1.05-negative-chained-sequence-violation.md), [batch/negative-1.2](./1.02-negative-mixed-partition-batch.md)). diff --git a/gears/system/event-broker/scenarios/producer/batch/1.02-negative-mixed-partition-batch.md b/gears/system/event-broker/scenarios/producer/batch/1.02-negative-mixed-partition-batch.md new file mode 100644 index 000000000..5a68cb228 --- /dev/null +++ b/gears/system/event-broker/scenarios/producer/batch/1.02-negative-mixed-partition-batch.md @@ -0,0 +1,59 @@ +# Batch rejected for mixing topics + +`POST /v1/events:batch` is atomic per topic — all events in one batch must target the same topic. A batch mixing topics is rejected `400`. + +## Setup + +- Topics `acme.orders.v1` and `acme.shipments.v1` both registered. + +## Request + +```http +POST /v1/events:batch HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Content-Type: application/json + +{ + "events": [ + { + "id": "e0000000-0000-0000-0000-000000000001", + "type": "gts.cf.core.events.event.v1~acme.orders.created.v1", + "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", + "tenant_id": "", "source": "svc", "subject": "o-1", + "subject_type": "gts.cf.core.events.subject.v1~acme.order.v1", + "occurred_at": "2026-05-29T10:10:00Z", "data": { "order_id": "o-1", "total_cents": 1 } + }, + { + "id": "e0000000-0000-0000-0000-000000000002", + "type": "gts.cf.core.events.event.v1~acme.shipments.dispatched.v1", + "topic": "gts.cf.core.events.topic.v1~acme.shipments.v1", + "tenant_id": "", "source": "svc", "subject": "s-1", + "subject_type": "gts.cf.core.events.subject.v1~acme.shipment.v1", + "occurred_at": "2026-05-29T10:10:01Z", "data": { "shipment_id": "s-1" } + } + ] +} +``` + +## Expected response + +- `400 Bad Request` (`PD`) + +```json +{ + "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.invalid_argument.v1~", + "title": "Invalid Argument", + "status": 400, + "detail": "batch mixes topics: acme.orders.v1, acme.shipments.v1 — a batch is atomic per topic", + "instance": "/v1/events:batch", + "trace_id": "", + "context": { + "constraint": "batch mixes topics; a batch is atomic per topic" + } +} +``` + +## Side effects + +- No event from the batch is admitted (all-or-nothing). diff --git a/gears/system/event-broker/scenarios/producer/batch/1.03-negative-batch-too-large.md b/gears/system/event-broker/scenarios/producer/batch/1.03-negative-batch-too-large.md new file mode 100644 index 000000000..465b4a705 --- /dev/null +++ b/gears/system/event-broker/scenarios/producer/batch/1.03-negative-batch-too-large.md @@ -0,0 +1,44 @@ +# Batch rejected for exceeding the size cap + +A `:batch` may carry at most 100 events. A larger batch is rejected `413`. + +## Setup + +- Topic `acme.orders.v1` registered. + +## Request + +```http +POST /v1/events:batch HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Content-Type: application/json + +{ + "events": [ "...101 event objects..." ] +} +``` + +(The `events` array carries 101 entries; abbreviated here.) + +## Expected response + +- `413 Content Too Large` (`PD`) + +```json +{ + "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.invalid_argument.v1~", + "title": "Invalid Argument", + "status": 413, + "detail": "batch carries 101 events; maximum is 100", + "instance": "/v1/events:batch", + "trace_id": "", + "context": { + "constraint": "batch carries 101 events; maximum is 100" + } +} +``` + +## Side effects + +- No event from the batch is admitted. diff --git a/gears/system/event-broker/scenarios/producer/batch/1.04-negative-batch-late-validation-failure.md b/gears/system/event-broker/scenarios/producer/batch/1.04-negative-batch-late-validation-failure.md new file mode 100644 index 000000000..42a7c7169 --- /dev/null +++ b/gears/system/event-broker/scenarios/producer/batch/1.04-negative-batch-late-validation-failure.md @@ -0,0 +1,78 @@ +# Batch rejected when a later event fails validation + +`POST /v1/events:batch` is all-or-nothing even when the first event would be accepted on its own. If a later event fails schema validation, producer checks, or deduplication, the whole batch is rejected. + +## Setup + +- Topic `acme.orders.v1` and event type `acme.orders.created.v1` registered. +- Event type `acme.orders.created.v1` has a `data_schema` requiring `order_id` (string) and `total_cents` (integer). + +## Request + +The first event is valid. The second event targets the same topic and partition input but is missing `total_cents`. + +```http +POST /v1/events:batch HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Content-Type: application/json + +{ + "events": [ + { + "id": "e0000000-0000-0000-0000-000000000101", + "type": "gts.cf.core.events.event.v1~acme.orders.created.v1", + "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", + "tenant_id": "", + "source": "order-service", + "subject": "order-atomic", + "subject_type": "gts.cf.core.events.subject.v1~acme.order.v1", + "partition_key": "order-atomic", + "occurred_at": "2026-05-29T10:11:00Z", + "data": { "order_id": "order-atomic", "total_cents": 100 } + }, + { + "id": "e0000000-0000-0000-0000-000000000102", + "type": "gts.cf.core.events.event.v1~acme.orders.created.v1", + "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", + "tenant_id": "", + "source": "order-service", + "subject": "order-atomic", + "subject_type": "gts.cf.core.events.subject.v1~acme.order.v1", + "partition_key": "order-atomic", + "occurred_at": "2026-05-29T10:11:01Z", + "data": { "order_id": "order-atomic" } + } + ] +} +``` + +## Expected response + +- `400 Bad Request` (`PD`) +- Body carries the validator's diagnostics for the second event. + +```json +{ + "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.invalid_argument.v1~", + "title": "Invalid Argument", + "status": 400, + "detail": "payload failed schema validation for gts.cf.core.events.event.v1~acme.orders.created.v1", + "instance": "/v1/events:batch", + "trace_id": "", + "context": { + "field_violations": [ + { + "field": "(payload)", + "description": "missing required field: total_cents", + "reason": "schema_validation" + } + ] + } +} +``` + +## Side effects + +- No event from the batch is admitted. +- The valid first event is not appended or made visible to consumers. diff --git a/gears/system/event-broker/scenarios/producer/flows/1.01-positive-register-chained-producer.md b/gears/system/event-broker/scenarios/producer/flows/1.01-positive-register-chained-producer.md new file mode 100644 index 000000000..d9c375ec0 --- /dev/null +++ b/gears/system/event-broker/scenarios/producer/flows/1.01-positive-register-chained-producer.md @@ -0,0 +1,35 @@ +# Register a chained-mode producer + +A producer that wants exactly-once ingest-side deduplication registers with the broker to obtain a `producer_id` bound to its principal. Subsequent publishes supply this id in the `Producer-Id` header and include a `meta` block with chained sequence fields. + +## Request + +```http +POST /v1/producers HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Content-Type: application/json + +{ + "mode": "chained" +} +``` + +## Expected response + +- `201 Created` +- Body: + +```json +{ + "producer_id": "550e8400-e29b-41d4-a716-446655440000", + "mode": "chained", + "created_at": "2026-06-15T10:00:00Z" +} +``` + +## Side effects + +- `evbk_producer(550e8400-...)` row created with `owner_principal_id` from `SecurityContext`, `mode = chained`, `last_seen_at = now()`. +- Subsequent `POST /v1/events` with `Producer-Id: 550e8400-...` from any other principal returns `403 Permission Denied`. +- The producer row is reaped by the Reaper worker after the platform-wide producer-registration TTL (default `P30D`) of inactivity. diff --git a/gears/system/event-broker/scenarios/producer/flows/1.02-positive-register-monotonic-producer.md b/gears/system/event-broker/scenarios/producer/flows/1.02-positive-register-monotonic-producer.md new file mode 100644 index 000000000..6753fa581 --- /dev/null +++ b/gears/system/event-broker/scenarios/producer/flows/1.02-positive-register-monotonic-producer.md @@ -0,0 +1,33 @@ +# Register a monotonic-mode producer + +Monotonic mode is simpler than chained: the producer supplies a monotonically increasing `meta.sequence` per `(producer_id, topic, partition)` but no `meta.previous` link. The broker deduplicates on `(producer_id, topic, partition, sequence)` without requiring a contiguous chain. + +## Request + +```http +POST /v1/producers HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Content-Type: application/json + +{ + "mode": "monotonic" +} +``` + +## Expected response + +- `201 Created` + +```json +{ + "producer_id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "mode": "monotonic", + "created_at": "2026-06-15T10:00:00Z" +} +``` + +## Side effects + +- `evbk_producer(aaaaaaaa-...)` row created with `mode = monotonic`. +- Publishes omitting `meta.previous` are accepted; publishes that include `meta.previous` are rejected with `400 Invalid Argument` (wrong shape for monotonic mode). diff --git a/gears/system/event-broker/scenarios/producer/flows/1.03-positive-chained-mode-sequence.md b/gears/system/event-broker/scenarios/producer/flows/1.03-positive-chained-mode-sequence.md new file mode 100644 index 000000000..16d3b5ba0 --- /dev/null +++ b/gears/system/event-broker/scenarios/producer/flows/1.03-positive-chained-mode-sequence.md @@ -0,0 +1,40 @@ +# Chained-mode publish advances the producer chain + +A producer registered in chained mode supplies `meta.producer_id`, `meta.previous`, and `meta.sequence`. The broker validates `meta.previous` against its stored `last_sequence` for `(producer_id, topic, partition)`; on match, the event is admitted and `last_sequence` advances. + +## Setup + +- Producer `{producer_id}` is registered in chained mode. Its `last_sequence` for `(producer_id, "acme.orders.v1", 0)` is currently `7`. +- The event's `subject` hashes to partition `0`. + +## Request + +```http +POST /v1/events HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Content-Type: application/json + +{ + "id": "c0000000-0000-0000-0000-000000000008", + "type": "gts.cf.core.events.event.v1~acme.orders.created.v1", + "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", + "tenant_id": "", + "source": "order-service", + "subject": "order-chain", + "subject_type": "gts.cf.core.events.subject.v1~acme.order.v1", + "occurred_at": "2026-05-29T10:07:00Z", + "data": { "order_id": "order-chain", "total_cents": 700 }, + "meta": { "version": 1, "producer_id": "{producer_id}", "previous": 7, "sequence": 8 } +} +``` + +## Expected response + +- `202 Accepted` — `meta.previous` (7) matches the broker's `last_sequence`; the event is admitted. + +## Side effects + +- The producer chain's `last_sequence` for `({producer_id}, "acme.orders.v1", 0)` advances from `7` to `8`. +- `meta` is stripped on read — consumers never see `producer_id` / `previous` / `sequence`. +- A re-send of the same `meta.previous=7` (duplicate) is deduped, not double-admitted. diff --git a/gears/system/event-broker/scenarios/producer/flows/1.04-positive-idempotency-key-dedup.md b/gears/system/event-broker/scenarios/producer/flows/1.04-positive-idempotency-key-dedup.md new file mode 100644 index 000000000..fa47c214a --- /dev/null +++ b/gears/system/event-broker/scenarios/producer/flows/1.04-positive-idempotency-key-dedup.md @@ -0,0 +1,41 @@ +# Idempotency: re-publishing a deduped event + +Re-submitting an event the broker has already admitted (same idempotency identity — here the client-provided event `id` within the chained producer's dedup window) is accepted but not re-admitted: no new offset is assigned. + +## Setup + +- Event `c0000000-0000-0000-0000-000000000008` was already admitted (per [positive-1.3](./1.03-positive-chained-mode-sequence.md), chained `meta.previous=7, sequence=8`). + +## Request + +The identical event is sent again (same `id`, same `meta.previous=7, sequence=8`): + +```http +POST /v1/events HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Content-Type: application/json + +{ + "id": "c0000000-0000-0000-0000-000000000008", + "type": "gts.cf.core.events.event.v1~acme.orders.created.v1", + "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", + "tenant_id": "", + "source": "order-service", + "subject": "order-chain", + "subject_type": "gts.cf.core.events.subject.v1~acme.order.v1", + "occurred_at": "2026-05-29T10:07:00Z", + "data": { "order_id": "order-chain", "total_cents": 700 }, + "meta": { "version": 1, "producer_id": "{producer_id}", "previous": 7, "sequence": 8 } +} +``` + +## Expected response + +- `202 Accepted` — idempotent; the broker recognizes the duplicate. + +## Side effects + +- No new event is appended; `evbk_event` on `("acme.orders.v1", 0)` is unchanged. +- The producer chain's `last_sequence` stays at `8` (already advanced by the first admit; the duplicate does not advance it again). +- Consumers receive the event exactly once on their stream. diff --git a/gears/system/event-broker/scenarios/producer/flows/1.05-negative-chained-sequence-violation.md b/gears/system/event-broker/scenarios/producer/flows/1.05-negative-chained-sequence-violation.md new file mode 100644 index 000000000..dcc17d7b4 --- /dev/null +++ b/gears/system/event-broker/scenarios/producer/flows/1.05-negative-chained-sequence-violation.md @@ -0,0 +1,61 @@ +# Chained-mode sequence violation + +When `meta.previous` does not match the broker's stored `last_sequence` for `(producer_id, topic, partition)`, the publish is rejected `412 SequenceViolation`. The response carries the broker's current `last_sequence` so the producer can resync. + +## Setup + +- Producer `{producer_id}` registered in chained mode; `last_sequence` for `({producer_id}, "acme.orders.v1", 0)` is `7`. + +## Request + +The producer sends `meta.previous = 3` (stale — should be `7`): + +```http +POST /v1/events HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Content-Type: application/json + +{ + "id": "c0000000-0000-0000-0000-000000000099", + "type": "gts.cf.core.events.event.v1~acme.orders.created.v1", + "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", + "tenant_id": "", + "source": "order-service", + "subject": "order-chain", + "subject_type": "gts.cf.core.events.subject.v1~acme.order.v1", + "occurred_at": "2026-05-29T10:08:00Z", + "data": { "order_id": "order-chain", "total_cents": 700 }, + "meta": { "version": 1, "producer_id": "{producer_id}", "previous": 3, "sequence": 4 } +} +``` + +## Expected response + +- `412 Precondition Failed` (`PD`) +- Body includes the broker's current `last_sequence` for resync. + +```json +{ + "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.failed_precondition.v1~", + "title": "Failed Precondition", + "status": 412, + "detail": "meta.previous=3 does not match broker last_sequence=7 for ({producer_id}, acme.orders.v1, 0)", + "instance": "/v1/events", + "trace_id": "", + "context": { + "violations": [ + { + "type": "sequence_mismatch", + "subject": "(producer)", + "description": "expected_previous=7" + } + ] + } +} +``` + +## Side effects + +- No event is admitted; the producer chain's `last_sequence` stays at `7`. +- For `:batch`, a sequence violation on any event rejects the whole batch atomically (`412`). diff --git a/gears/system/event-broker/scenarios/producer/flows/1.06-positive-cursor-recovery.md b/gears/system/event-broker/scenarios/producer/flows/1.06-positive-cursor-recovery.md new file mode 100644 index 000000000..27a982a02 --- /dev/null +++ b/gears/system/event-broker/scenarios/producer/flows/1.06-positive-cursor-recovery.md @@ -0,0 +1,42 @@ +# Producer cursor recovery after desync + +When a chained producer loses its local chain state (DB restore, crash, restart without persistent state), it reads the broker's last known sequence per `(topic, partition)` and uses the returned offsets to re-seed its local counter before publishing again. + +## Setup + +- Run [Register a chained producer](./1.01-positive-register-chained-producer.md). `{producer_id}` available. +- At least one event has been published to `gts.cf.core.events.topic.v1~acme.orders.v1` (partition 0, sequence 7) and `gts.cf.core.events.topic.v1~acme.orders.v1` (partition 1, sequence 3). + +## Request + +```http +GET /v1/producers/{producer_id}/cursors HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +``` + +## Expected response + +- `200 OK` + +```json +{ + "producer_id": "{producer_id}", + "cursors": [ + { "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", "partition": 0, "last_sequence": 7 }, + { "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", "partition": 1, "last_sequence": 3 } + ] +} +``` + +## Side effects + +- No state changes. Read-only endpoint. +- The producer uses `last_sequence` values to set `meta.previous` in the next publish per `(topic, partition)`: + - Next publish to partition 0: `meta.previous = 7`, `meta.sequence = 8`. + - Next publish to partition 1: `meta.previous = 3`, `meta.sequence = 4`. + +## Errors + +- `403 Permission Denied` — caller's principal does not match `producer_id`'s registered owner. +- `404 Not Found` — producer not registered (or reaped). diff --git a/gears/system/event-broker/scenarios/producer/flows/1.07-positive-chain-reset.md b/gears/system/event-broker/scenarios/producer/flows/1.07-positive-chain-reset.md new file mode 100644 index 000000000..d8dddc800 --- /dev/null +++ b/gears/system/event-broker/scenarios/producer/flows/1.07-positive-chain-reset.md @@ -0,0 +1,42 @@ +# Operator-driven chain reset + +When a producer's chain state is unrecoverable (e.g., storage corruption, irrecoverable sequence gap), an operator resets the chain for a specific `(topic, partition)` while preserving the `producer_id`. The next publish to that partition starts a fresh chain from sequence 1. + +## Setup + +- Run [Register a chained producer](./1.01-positive-register-chained-producer.md). `{producer_id}` available. +- `evbk_producer_state({producer_id}, acme.orders.v1, 0)` exists with `last_sequence = 42`. + +## Request + +```http +POST /v1/producers/{producer_id}:reset HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Content-Type: application/json + +{ + "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", + "partition": 0 +} +``` + +## Expected response + +- `200 OK` + +```json +{ + "producer_id": "{producer_id}", + "reset_at": "2026-06-15T10:00:00Z", + "scope": { "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", "partition": 0 } +} +``` + +## Side effects + +- `evbk_producer_state({producer_id}, acme.orders.v1, 0)` is deleted (or reset to `last_sequence = 0`). +- `evbk_producer({producer_id}).last_seen_at` is updated. +- An audit log entry is created: `producer_chain_reset` with principal, `producer_id`, topic, partition, timestamp. +- Next publish to partition 0 with `meta.previous = 0, meta.sequence = 1` is accepted. +- Omitting `topic`/`partition` from the body resets **all** state rows for `{producer_id}`. diff --git a/gears/system/event-broker/scenarios/producer/flows/1.08-negative-unknown-producer.md b/gears/system/event-broker/scenarios/producer/flows/1.08-negative-unknown-producer.md new file mode 100644 index 000000000..0a14477ec --- /dev/null +++ b/gears/system/event-broker/scenarios/producer/flows/1.08-negative-unknown-producer.md @@ -0,0 +1,55 @@ +# Publish with unknown or expired Producer-Id + +A producer whose registration has been reaped (expired after `P30D` of inactivity) or was never registered sends a publish with a `Producer-Id` header. The broker rejects the request. + +## Request + +```http +POST /v1/events HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Content-Type: application/json +Producer-Id: deadbeef-0000-0000-0000-000000000000 + +{ + "id": "11111111-0000-0000-0000-000000000001", + "type": "gts.cf.core.events.event.v1~acme.orders.created.v1", + "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", + "tenant_id": "", + "source": "order-service", + "subject": "order-1", + "subject_type": "gts.cf.core.events.subject.v1~acme.order.v1", + "occurred_at": "2026-06-15T10:00:00Z", + "data": { "order_id": "order-1" }, + "meta": { + "producer_id": "deadbeef-0000-0000-0000-000000000000", + "sequence": 1, + "previous": 0 + } +} +``` + +## Expected response + +- `400 Invalid Argument` + +```json +{ + "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.invalid_argument.v1~", + "title": "Invalid Argument", + "status": 400, + "detail": "Producer-Id deadbeef-0000-0000-0000-000000000000 is not registered", + "instance": "/v1/events", + "trace_id": "", + "context": { + "field_violations": [ + { "field": "Producer-Id", "description": "producer not registered or registration has expired", "reason": "unknown_producer" } + ] + } +} +``` + +## Side effects + +- No event is written or enqueued. +- Recovery: call `POST /v1/producers` to obtain a fresh `producer_id` and restart the chain from sequence 1. diff --git a/gears/system/event-broker/scenarios/producer/flows/1.09-flow-chained-producer-desync-recovery.md b/gears/system/event-broker/scenarios/producer/flows/1.09-flow-chained-producer-desync-recovery.md new file mode 100644 index 000000000..51bc33608 --- /dev/null +++ b/gears/system/event-broker/scenarios/producer/flows/1.09-flow-chained-producer-desync-recovery.md @@ -0,0 +1,121 @@ +# Flow: chained producer desync recovery + +A chained producer loses its local sequence state (DB restore, crash). The first publish after recovery fails with `412 SequenceViolation`. The producer reads the broker's cursor, reconciles its local counter, and successfully republishes. + +Three exchanges shown inline. Topic: `acme.orders.v1`, partition 0. Broker's last accepted sequence for this producer on this partition: 7. + +--- + +## Exchange 1 — Publish with stale sequence → 412 + +After a restart, the producer's local DB was restored from a backup and thinks `last_sequence = 3`. It sends the next event with `meta.previous = 3, meta.sequence = 4` — but the broker has already accepted through sequence 7. + +```http +POST /v1/events HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Content-Type: application/json +Producer-Id: 550e8400-e29b-41d4-a716-446655440000 + +{ + "id": "aaaaaaaa-0000-0000-0000-000000000004", + "type": "gts.cf.core.events.event.v1~acme.orders.created.v1", + "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", + "tenant_id": "", + "source": "order-service", + "subject": "order-4", + "subject_type": "gts.cf.core.events.subject.v1~acme.order.v1", + "occurred_at": "2026-06-15T10:00:00Z", + "data": { "order_id": "order-4" }, + "meta": { + "producer_id": "550e8400-e29b-41d4-a716-446655440000", + "sequence": 4, + "previous": 3 + } +} +``` + +```http +HTTP/1.1 412 Precondition Failed +Content-Type: application/problem+json + +{ + "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.failed_precondition.v1~", + "title": "Failed Precondition", + "status": 412, + "detail": "chain broken on (550e8400-..., acme.orders.v1, 0): expected previous=7, got previous=3", + "instance": "/v1/events", + "context": { + "expected_previous": 7 + } +} +``` + +--- + +## Exchange 2 — Read broker cursor → reconcile local state + +The producer calls `GET /v1/producers/{id}/cursors` to learn the broker's authoritative `last_sequence` per `(topic, partition)`. + +```http +GET /v1/producers/550e8400-e29b-41d4-a716-446655440000/cursors HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +``` + +```http +HTTP/1.1 200 OK +Content-Type: application/json + +{ + "producer_id": "550e8400-e29b-41d4-a716-446655440000", + "cursors": [ + { "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", "partition": 0, "last_sequence": 7 } + ] +} +``` + +> Producer updates its local DB: `last_sequence(acme.orders.v1, 0) = 7`. Next publish must use `meta.previous = 7, meta.sequence = 8`. + +--- + +## Exchange 3 — Republish with correct sequence → 202 + +```http +POST /v1/events HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Content-Type: application/json +Producer-Id: 550e8400-e29b-41d4-a716-446655440000 + +{ + "id": "aaaaaaaa-0000-0000-0000-000000000008", + "type": "gts.cf.core.events.event.v1~acme.orders.created.v1", + "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", + "tenant_id": "", + "source": "order-service", + "subject": "order-8", + "subject_type": "gts.cf.core.events.subject.v1~acme.order.v1", + "occurred_at": "2026-06-15T10:00:01Z", + "data": { "order_id": "order-8" }, + "meta": { + "producer_id": "550e8400-e29b-41d4-a716-446655440000", + "sequence": 8, + "previous": 7 + } +} +``` + +```http +HTTP/1.1 202 Accepted +Content-Type: application/json + +{ + "id": "aaaaaaaa-0000-0000-0000-000000000008", + "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", + "partition": 0, + "accepted_at": "2026-06-15T10:00:01Z" +} +``` + +> `evbk_producer_state(550e8400-..., acme.orders.v1, 0).last_sequence` advances to `8`. Chain is healthy; the producer continues normally from sequence 9. diff --git a/gears/system/event-broker/scenarios/producer/single/1.01-positive-publish-single-async.md b/gears/system/event-broker/scenarios/producer/single/1.01-positive-publish-single-async.md new file mode 100644 index 000000000..0645fe927 --- /dev/null +++ b/gears/system/event-broker/scenarios/producer/single/1.01-positive-publish-single-async.md @@ -0,0 +1,42 @@ +# Publish a single event (async, default) + +A producer enqueues one event into the per-topic ingest outbox. The default response is `202 Accepted` once durably enqueued; the offset is assigned asynchronously by the storage backend and is never returned inline. + +## Setup + +- Topic `gts.cf.core.events.topic.v1~acme.orders.v1` is registered. +- Event type `gts.cf.core.events.event.v1~acme.orders.created.v1` is registered with a `data_schema` requiring `order_id` and `total_cents`. + +## Request + +```http +POST /v1/events HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Content-Type: application/json + +{ + "id": "a1b2c3d4-0000-0000-0000-000000000001", + "type": "gts.cf.core.events.event.v1~acme.orders.created.v1", + "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", + "tenant_id": "", + "source": "order-service", + "subject": "order-42", + "subject_type": "gts.cf.core.events.subject.v1~acme.order.v1", + "occurred_at": "2026-05-29T10:00:00Z", + "data": { "order_id": "order-42", "total_cents": 4299 } +} +``` + +## Expected response + +- `202 Accepted` +- No `partition` / `sequence` / `offset` in the response. `partition` is broker-stamped read-side data for consumers, not producer input. + +## Side effects + +- The event is durably enqueued in the per-topic ingest outbox. +- The broker topic partition is computed at ingest from `partition_key` when present, otherwise `tenant_id`; this request has no `partition_key`, so `tenant_id` is the partition input — see [ADR/0002](../../docs/ADR/0002-partition-selection.md). +- `evbk_event` for the resolved `(topic, partition)` gains the event with broker-stamped `partition` and server-assigned `sequence` once the backend persists it. +- `subsequent GET /v1/events:stream` for a subscription assigned that partition, seeded at or before this event's offset, emits the event as one `MP` part including the broker-stamped `partition`. +- Supplying a read-only field (`partition`, `sequence`, `sequence_time`) on publish returns `400 BadRequest` — see [single/negative-1.5](./1.05-negative-readonly-partition-rejected.md) for the `partition` case. diff --git a/gears/system/event-broker/scenarios/producer/single/1.02-positive-publish-sync-wait-persisted.md b/gears/system/event-broker/scenarios/producer/single/1.02-positive-publish-sync-wait-persisted.md new file mode 100644 index 000000000..eb0da0e28 --- /dev/null +++ b/gears/system/event-broker/scenarios/producer/single/1.02-positive-publish-sync-wait-persisted.md @@ -0,0 +1,39 @@ +# Publish synchronously (wait for backend persistence) + +Opting into synchronous persistence makes the broker hold the response until the storage backend has durably persisted the event, returning `201` instead of the default `202`. + +## Setup + +- Topic `acme.orders.v1` and event type `acme.orders.created.v1` registered. + +## Request + +```http +POST /v1/events?wait=persisted HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Content-Type: application/json + +{ + "id": "a1b2c3d4-0000-0000-0000-0000000000a2", + "type": "gts.cf.core.events.event.v1~acme.orders.created.v1", + "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", + "tenant_id": "", + "source": "order-service", + "subject": "order-900", + "subject_type": "gts.cf.core.events.subject.v1~acme.order.v1", + "occurred_at": "2026-05-29T10:05:00Z", + "data": { "order_id": "order-900", "total_cents": 200 } +} +``` + +(Equivalent to sending the header `Sync-Wait: true`.) + +## Expected response + +- `201 Created` — the event is persisted to the backend before the response returns. +- Offset/partition/sequence are still NOT returned inline (assigned by the backend; surfaced only on read). + +## Side effects + +- `evbk_event` on the resolved `(topic, partition)` holds the event durably before the `201` is sent (vs. `202` which only guarantees outbox enqueue). diff --git a/gears/system/event-broker/scenarios/producer/single/1.03-negative-schema-validation-failure.md b/gears/system/event-broker/scenarios/producer/single/1.03-negative-schema-validation-failure.md new file mode 100644 index 000000000..0d0e13235 --- /dev/null +++ b/gears/system/event-broker/scenarios/producer/single/1.03-negative-schema-validation-failure.md @@ -0,0 +1,61 @@ +# Schema validation failure on publish + +The event `data` is validated against the event type's `data_schema` at ingest; a payload that fails is rejected `400`. Server-stamped read-only fields are rejected on publish; `partition` has its own focused scenario in [single/negative-1.5](./1.05-negative-readonly-partition-rejected.md). + +## Setup + +- Event type `acme.orders.created.v1` has a `data_schema` requiring `order_id` (string) and `total_cents` (integer). + +## Request + +`data` is missing the required `total_cents`: + +```http +POST /v1/events HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Content-Type: application/json + +{ + "id": "d0000000-0000-0000-0000-000000000001", + "type": "gts.cf.core.events.event.v1~acme.orders.created.v1", + "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", + "tenant_id": "", + "source": "order-service", + "subject": "order-bad", + "subject_type": "gts.cf.core.events.subject.v1~acme.order.v1", + "occurred_at": "2026-05-29T10:09:00Z", + "data": { "order_id": "order-bad" } +} +``` + +## Expected response + +- `400 Bad Request` (`PD`) +- Body carries the validator's diagnostics. + +```json +{ + "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.invalid_argument.v1~", + "title": "Invalid Argument", + "status": 400, + "detail": "payload failed schema validation for gts.cf.core.events.event.v1~acme.orders.created.v1", + "instance": "/v1/events", + "trace_id": "", + "context": { + "field_violations": [ + { + "field": "(payload)", + "description": "missing required field: total_cents", + "reason": "schema_validation" + } + ] + } +} +``` + +## Side effects + +- No event is admitted. + +> Related `400` shapes share this envelope (different `title`): `InvalidPartition` / `InvalidTraceParent` (field-format), and `BadRequest` when a producer supplies a `readOnly` field. See [single/negative-1.5](./1.05-negative-readonly-partition-rejected.md) for the top-level `partition` case. diff --git a/gears/system/event-broker/scenarios/producer/single/1.04-negative-rate-limited.md b/gears/system/event-broker/scenarios/producer/single/1.04-negative-rate-limited.md new file mode 100644 index 000000000..ade5517be --- /dev/null +++ b/gears/system/event-broker/scenarios/producer/single/1.04-negative-rate-limited.md @@ -0,0 +1,58 @@ +# Publish rejected by the per-tenant rate cap + +When a tenant exceeds its publish quota, the broker rejects with `429 RateLimitExceeded` and a `Retry-After` header. + +## Setup + +- The caller's tenant has exhausted its publish quota for the current window. + +## Request + +```http +POST /v1/events HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Content-Type: application/json + +{ + "id": "f0000000-0000-0000-0000-000000000001", + "type": "gts.cf.core.events.event.v1~acme.orders.created.v1", + "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", + "tenant_id": "", + "source": "order-service", + "subject": "order-throttled", + "subject_type": "gts.cf.core.events.subject.v1~acme.order.v1", + "occurred_at": "2026-05-29T10:11:00Z", + "data": { "order_id": "order-throttled", "total_cents": 50 } +} +``` + +## Expected response + +- `429 Too Many Requests` (`PD`) +- `Retry-After: 30` header. + +```json +{ + "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.resource_exhausted.v1~", + "title": "Resource Exhausted", + "status": 429, + "detail": "tenant publish quota exceeded; retry after 30s", + "instance": "/v1/events", + "trace_id": "", + "context": { + "violations": [ + { + "subject": "publish-quota", + "description": "tenant publish quota exceeded", + "retry_after_seconds": 30 + } + ] + } +} +``` + +## Side effects + +- No event is admitted. +- `metric publish_rate_limited incremented by 1` for the tenant. diff --git a/gears/system/event-broker/scenarios/producer/single/1.05-negative-readonly-partition-rejected.md b/gears/system/event-broker/scenarios/producer/single/1.05-negative-readonly-partition-rejected.md new file mode 100644 index 000000000..d27cc8185 --- /dev/null +++ b/gears/system/event-broker/scenarios/producer/single/1.05-negative-readonly-partition-rejected.md @@ -0,0 +1,63 @@ +# Publish rejects producer-supplied partition + +The top-level event `partition` field is broker-stamped read-side data. Producers choose the partition input with `partition_key`; they do not supply the final broker topic partition. A publish request that includes `partition` is rejected and the supplied value is not used for ingestion routing. + +## Setup + +- Topic `gts.cf.core.events.topic.v1~acme.orders.v1` is registered with 4 partitions. +- Event type `gts.cf.core.events.event.v1~acme.orders.created.v1` is registered with a `data_schema` requiring `order_id` and `total_cents`. + +## Request + +```http +POST /v1/events HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +Content-Type: application/json + +{ + "id": "e0000000-0000-0000-0000-000000000001", + "type": "gts.cf.core.events.event.v1~acme.orders.created.v1", + "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", + "tenant_id": "", + "source": "order-service", + "subject": "order-42", + "subject_type": "gts.cf.core.events.subject.v1~acme.order.v1", + "partition_key": "customer-42", + "partition": 3, + "occurred_at": "2026-05-29T10:10:00Z", + "data": { "order_id": "order-42", "total_cents": 4299 } +} +``` + +## Expected response + +- `400 Bad Request` (`PD`) +- Body identifies `partition` as a read-only field that cannot be supplied on publish. + +```json +{ + "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.invalid_argument.v1~", + "title": "Invalid Argument", + "status": 400, + "detail": "publish body contains read-only field: partition", + "instance": "/v1/events", + "trace_id": "", + "context": { + "field_violations": [ + { + "field": "partition", + "description": "partition is broker-stamped and read-only on publish", + "reason": "read_only_field" + } + ] + } +} +``` + +## Side effects + +- No event is admitted. +- The supplied `partition` value is not used for ingestion routing. +- A valid retry omits `partition`; the broker derives the final topic partition from `partition_key` when present, otherwise `tenant_id`. +- Consumers see `partition` only after the broker accepts and stamps the read-side event. diff --git a/gears/system/event-broker/scenarios/topics/1.01-positive-list-topics.md b/gears/system/event-broker/scenarios/topics/1.01-positive-list-topics.md new file mode 100644 index 000000000..7701cd61f --- /dev/null +++ b/gears/system/event-broker/scenarios/topics/1.01-positive-list-topics.md @@ -0,0 +1,31 @@ +# List topics visible to the caller + +Read-only introspection. Returns the topics the caller's principal is authorized to see in the current tenant. + +## Request + +```http +GET /v1/topics HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +``` + +## Expected response + +- `200 OK` +- Body is an array of topic records; only topics the principal may `consume` or otherwise see are included. + +```json +[ + { + "id": "gts.cf.core.events.topic.v1~acme.orders.v1", + "partitions": 4 + }, + { + "id": "gts.cf.core.events.topic.v1~acme.shipments.v1", + "partitions": 2 + } +] +``` + + diff --git a/gears/system/event-broker/scenarios/topics/1.02-positive-list-topic-segments.md b/gears/system/event-broker/scenarios/topics/1.02-positive-list-topic-segments.md new file mode 100644 index 000000000..0f8b03cac --- /dev/null +++ b/gears/system/event-broker/scenarios/topics/1.02-positive-list-topic-segments.md @@ -0,0 +1,32 @@ +# List storage segments for a (topic, partition) + +Read-only introspection of the storage backend's segment manifest for a specific `(topic, partition)`. Segment shapes are backend-specific; consumers treat `segments[]` entries as opaque except the documented envelope. + +## Request + +```http +GET /v1/topics/segments?topic=gts.cf.core.events.topic.v1~acme.orders.v1&partition=0 HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +``` + +## Expected response + +- `200 OK` + +```json +{ + "topic": "gts.cf.core.events.topic.v1~acme.orders.v1", + "partition": 0, + "start_sequence": 100, + "end_sequence": 4999, + "start_time": "2026-05-20T00:00:00Z", + "end_time": "2026-05-29T10:00:00Z", + "segments": [ + { "id": "seg-0001", "start_sequence": 100, "end_sequence": 2000 }, + { "id": "seg-0002", "start_sequence": 2001, "end_sequence": 4999 } + ] +} +``` + + diff --git a/gears/system/event-broker/scenarios/topics/1.03-negative-segments-unknown-topic.md b/gears/system/event-broker/scenarios/topics/1.03-negative-segments-unknown-topic.md new file mode 100644 index 000000000..72790e64e --- /dev/null +++ b/gears/system/event-broker/scenarios/topics/1.03-negative-segments-unknown-topic.md @@ -0,0 +1,32 @@ +# Segments for an unknown topic + +Requesting the segment manifest for a topic that isn't registered returns `404`. + +## Request + +```http +GET /v1/topics/segments?topic=gts.cf.core.events.topic.v1~acme.nonexistent.v1&partition=0 HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +``` + +## Expected response + +- `404 Not Found` (`PD`) + +```json +{ + "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.not_found.v1~", + "title": "Not Found", + "status": 404, + "detail": "topic gts.cf.core.events.topic.v1~acme.nonexistent.v1 not found", + "instance": "/v1/topics/segments", + "trace_id": "", + "context": { + "resource_type": "gts.cf.core.events.topic.v1~", + "resource_name": "gts.cf.core.events.topic.v1~acme.nonexistent.v1" + } +} +``` + + diff --git a/gears/system/event-broker/scenarios/topics/1.04-positive-list-event-types.md b/gears/system/event-broker/scenarios/topics/1.04-positive-list-event-types.md new file mode 100644 index 000000000..396abeb1b --- /dev/null +++ b/gears/system/event-broker/scenarios/topics/1.04-positive-list-event-types.md @@ -0,0 +1,45 @@ +# List event types + +Returns a paged list of event types registered in `types_registry`. Producers use this to discover which event types are available on a given topic; consumers use it to understand the data schemas they'll receive. + +## Request + +```http +GET /v1/event_types?topic=gts.cf.core.events.topic.v1~acme.orders.v1&limit=20 HTTP/1.1 +Host: broker.example.com +Authorization: Bearer +``` + +## Expected response + +- `200 OK` + +```json +{ + "items": [ + { + "id": "gts.cf.core.events.event.v1~acme.orders.created.v1", + "topic_id": "gts.cf.core.events.topic.v1~acme.orders.v1", + "description": "An order was placed", + "allowed_subject_types": ["gts.cf.core.events.subject.v1~acme.order.v1"], + "created_at": "2026-01-01T00:00:00Z" + }, + { + "id": "gts.cf.core.events.event.v1~acme.orders.cancelled.v1", + "topic_id": "gts.cf.core.events.topic.v1~acme.orders.v1", + "description": "An order was cancelled", + "allowed_subject_types": ["gts.cf.core.events.subject.v1~acme.order.v1"], + "created_at": "2026-01-01T00:00:00Z" + } + ], + "page_info": { + "next_cursor": null, + "limit": 20 + } +} +``` + +## Side effects + +- Read-only. No state changes. +- `data_schema` is omitted from the list response to keep payloads manageable; retrieve full schema via the GTS registry if needed. diff --git a/libs/toolkit-stable-hash/Cargo.toml b/libs/toolkit-stable-hash/Cargo.toml new file mode 100644 index 000000000..3008b9c2a --- /dev/null +++ b/libs/toolkit-stable-hash/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "cf-gears-toolkit-stable-hash" +version = "0.1.0" +edition.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +rust-version.workspace = true +description = "Stable non-cryptographic hash implementations for CF/Gears" +readme = "README.md" +keywords = ["constructorfabric", "cf-gears", "hashing", "murmur3"] +categories = ["algorithms"] +metadata.docs.rs.all-features = true + +[lib] +name = "toolkit_stable_hash" + +[lints] +workspace = true diff --git a/libs/toolkit-stable-hash/README.md b/libs/toolkit-stable-hash/README.md new file mode 100644 index 000000000..a765d8d12 --- /dev/null +++ b/libs/toolkit-stable-hash/README.md @@ -0,0 +1,24 @@ +# ToolKit Stable Hash + +Stable non-cryptographic hash implementations used by CF/Gears compatibility +contracts. + +## API + +```rust +use toolkit_stable_hash::murmur3_x86_32; + +assert_eq!(murmur3_x86_32(b"hello", 0), 0x248b_fa47); +``` + +`murmur3_x86_32` is single-pass, allocation-free, and linear in the input +length. Callers must bound untrusted input sizes. + +## Security + +MurmurHash3 is not cryptographic. Do not use it for passwords, signatures, +integrity checks, secrets, or attacker-controlled routing keys. + +## License + +Licensed under Apache-2.0. diff --git a/libs/toolkit-stable-hash/src/lib.rs b/libs/toolkit-stable-hash/src/lib.rs new file mode 100644 index 000000000..3b6fc1952 --- /dev/null +++ b/libs/toolkit-stable-hash/src/lib.rs @@ -0,0 +1,12 @@ +#![cfg_attr(coverage_nightly, feature(coverage_attribute))] +#![forbid(unsafe_code)] +//! Stable non-cryptographic hash implementations for compatibility contracts. +//! +//! Algorithms in this crate keep their output stable across compatible +//! releases. They are not suitable for security-sensitive hashing. + +mod murmur3; +pub use murmur3::murmur3_x86_32; + +#[cfg(test)] +mod murmur3_tests; diff --git a/libs/toolkit-stable-hash/src/murmur3.rs b/libs/toolkit-stable-hash/src/murmur3.rs new file mode 100644 index 000000000..df47ef6ab --- /dev/null +++ b/libs/toolkit-stable-hash/src/murmur3.rs @@ -0,0 +1,69 @@ +/// Computes `MurmurHash3` x86 32-bit over `bytes` with the supplied seed. +/// +/// This stable compatibility function is non-cryptographic. Do not use it for +/// passwords, signatures, integrity checks, secrets, or attacker-controlled +/// routing keys. +/// +/// # Performance +/// +/// The implementation is single-pass, allocation-free, and `O(n)` in the +/// input length. Callers at trust boundaries must bound untrusted input sizes. +#[must_use] +pub fn murmur3_x86_32(bytes: &[u8], seed: u32) -> u32 { + const C1: u32 = 0xcc9e_2d51; + const C2: u32 = 0x1b87_3593; + + let mut hash = seed; + let mut blocks = bytes.chunks_exact(4); + + for bytes in &mut blocks { + let mut block = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]); + block = block.wrapping_mul(C1); + block = block.rotate_left(15); + block = block.wrapping_mul(C2); + hash ^= block; + hash = hash.rotate_left(13); + hash = hash.wrapping_mul(5).wrapping_add(0xe654_6b64); + } + + let tail = blocks.remainder(); + let mut block = 0_u32; + match tail.len() { + 3 => { + block ^= u32::from(tail[2]) << 16; + block ^= u32::from(tail[1]) << 8; + block ^= u32::from(tail[0]); + block = block.wrapping_mul(C1); + block = block.rotate_left(15); + block = block.wrapping_mul(C2); + hash ^= block; + } + 2 => { + block ^= u32::from(tail[1]) << 8; + block ^= u32::from(tail[0]); + block = block.wrapping_mul(C1); + block = block.rotate_left(15); + block = block.wrapping_mul(C2); + hash ^= block; + } + 1 => { + block ^= u32::from(tail[0]); + block = block.wrapping_mul(C1); + block = block.rotate_left(15); + block = block.wrapping_mul(C2); + hash ^= block; + } + _ => {} + } + + // MurmurHash3 x86-32 defines a 32-bit length field. + #[allow(clippy::cast_possible_truncation)] + let length = bytes.len() as u32; + hash ^= length; + hash ^= hash >> 16; + hash = hash.wrapping_mul(0x85eb_ca6b); + hash ^= hash >> 13; + hash = hash.wrapping_mul(0xc2b2_ae35); + hash ^= hash >> 16; + hash +} diff --git a/libs/toolkit-stable-hash/src/murmur3_tests.rs b/libs/toolkit-stable-hash/src/murmur3_tests.rs new file mode 100644 index 000000000..bac6a13c0 --- /dev/null +++ b/libs/toolkit-stable-hash/src/murmur3_tests.rs @@ -0,0 +1,19 @@ +use super::murmur3_x86_32; + +#[test] +fn compatibility_vectors_are_pinned() { + assert_eq!(murmur3_x86_32(b"", 0), 0x0000_0000); + assert_eq!(murmur3_x86_32(b"a", 0), 0x3c25_69b2); + assert_eq!(murmur3_x86_32(b"ab", 0), 0x9bbf_d75f); + assert_eq!(murmur3_x86_32(b"abc", 0), 0xb3dd_93fa); + assert_eq!(murmur3_x86_32(b"abcd", 0), 0x43ed_676a); + assert_eq!(murmur3_x86_32(b"hello", 0), 0x248b_fa47); + assert_eq!(murmur3_x86_32(b"hello", 42), 0xe2db_d2e1); +} + +#[test] +fn repeated_hashing_is_deterministic() { + for _ in 0..100 { + assert_eq!(murmur3_x86_32(b"repeat-test-subject", 7), 0xf095_aab5); + } +}