From 9f1d54908d4fa97c7d2657c9ceb3c40da2bf86d2 Mon Sep 17 00:00:00 2001 From: visortelle Date: Sat, 1 Aug 2026 23:24:55 +0300 Subject: [PATCH 1/4] Consumer-session start-from overhaul, reorderable tables, e2e hardening Start-from / cross-topic merge: - Replace decline/NACK at the merge's memory cap with per-stream consumer pause/resume (count and byte watermarks). Nothing is handed back to the broker mid-skip, so a stream can no longer overtake itself and the cap nack-storm is gone. Broker redeliveries are deduplicated by append-position watermark and never double-spend the skip budget. - Finish the skip on the budget's last claim (no N+1st head), release the session-wide ordering lock once the merge settles, use a monotonic stall clock granting a fresh window on resume, and route cross-target drop acks through the origin listener. - Latest-n: anchor non-contributing topics at their inspected tail so concurrent appends survive; 30s wall-clock resolution budget; refuse read-compacted targets and chunked topics; re-verify anchors against retention before any seek; UI/server cap parity (10M), pinned on both sides. - Refuse counted skips over overlapping enabled targets; keep and document latest-n's per-view duplicate contract. - Degradation is user-visible: StartFromProgress carries degraded + abandoned_streams, the first degraded frame bypasses the reporting interval, and the session shows a sticky best-effort banner. - Merge internals: O(log K) head heaps and incremental counters, per-target receiver-queue budget, 1000-topic admission guard, one shared maintenance thread for stall sweeps. Tables / UX: - Draggable column reorder, persisted per table in localStorage, for the shared Table and the consumer-session message table (header and rows follow one keyed order; sticky columns stay pinned). - Shared Table: pinFirst for summary rows, compact size variant, and a fix for TableVirtuoso mounting before its scroll parent existed (tables rendered blank until a window resize). - Topic Positions: rebuilt on the shared Table with an All-topics aggregate row, Behind / entries read / entries left columns, consumption-first default order, wider topic column, first tab and selected by default; polling is gated by the open panel + tab, and the capture toggle is removed - opening the tab is the request. - Start From defaults to Earliest message (Latest on non-persistent topics, which retain nothing). Tests: full server (747), jest (453) and e2e suites green, including the new CsFlowControlSpec - consumer.pause under an armed listener and the give-up/degradation pipeline against a real broker - plus column-reorder and refusal-path coverage. --- .github/workflows/ci.yml | 2 + .gitignore | 7 + AGENTS.md | 4 +- docs/configuration-reference.md | 4 +- e2e/README.md | 235 +++- e2e/build.sbt | 5 + e2e/scripts/fresh-data-dir.sh | 89 ++ e2e/scripts/run-dekaf.sh | 27 + e2e/scripts/stack-down.sh | 11 + .../consumersession/ConsumerSessionPage.scala | 116 +- .../features/consumersession/ToolsPanel.scala | 23 + .../features/library/LibrarySidebar.scala | 11 +- .../main/scala/harness/PulsarFixtures.scala | 200 ++- .../ConsumerSessionConfigSpec.scala | 30 +- .../CsApproximatePartitionedSpec.scala | 248 ++++ .../consumersession/CsConsoleSpec.scala | 40 +- .../CsDeliveryControlsSpec.scala | 102 ++ .../consumersession/CsDetailsSpec.scala | 87 ++ .../consumersession/CsExportSpec.scala | 127 +- .../consumersession/CsFlowControlSpec.scala | 170 +++ .../consumersession/CsLifecycleSpec.scala | 103 ++ .../CsProjectionColoringSpec.scala | 57 +- .../CsStartFromMatrixSpec.scala | 307 +++++ .../CsStartFromOutcomesSpec.scala | 614 +++++++++ .../consumersession/CsTableSpec.scala | 35 + .../consumersession/CsTopicKindsSpec.scala | 223 +++ .../CsTopicPositionsSpec.scala | 140 ++ .../consumersession/StartFromSupport.scala | 162 +++ .../features/library/LibraryNotesSpec.scala | 62 + .../scala/harness/BatchingFixtureSpec.scala | 170 +++ .../test/scala/harness/StackScriptsSpec.scala | 204 +++ .../test/scala/harness/SuiteFactsSpec.scala | 152 ++ .../test/scala/routes/ResilienceSpec.scala | 52 +- .../scala/routes/SubscriptionActionSpec.scala | 19 +- e2e/src/test/scala/routes/TableSpec.scala | 31 + .../test/scala/routes/TopicActionsSpec.scala | 69 +- flake.lock | 17 + flake.nix | 14 +- proto/buf.lock | 2 - proto/buf.yaml | 7 - .../teal/pulsar/ui/api/v1/consumer.proto | 195 ++- .../pulsar/ui/library/v1/managed_items.proto | 2 + .../ui/library/v1/resource_matchers.proto | 9 + .../scala/brokers/BrokersServiceImpl.scala | 70 +- .../brokerstats/BrokerStatsServiceImpl.scala | 4 +- .../ChildrencountServiceImpl.scala | 4 +- .../scala/clusters/ClustersServiceImpl.scala | 54 +- .../src/main/scala/config/mergeConfigs.scala | 2 + .../scala/consumer/ConsumerServiceImpl.scala | 412 ++++-- .../session_runner/ConsumerListener.scala | 395 +++++- .../ConsumerSessionContext.scala | 38 + .../ConsumerSessionContextPool.scala | 34 + .../ConsumerSessionRunner.scala | 608 +++++++- .../ConsumerSessionTargetRunner.scala | 286 ++-- .../ConsumerSessionTargetStats.scala | 14 +- .../session_runner/StartFromDiscard.scala | 131 ++ .../session_runner/buildAllOrRelease.scala | 30 + .../session_runner/buildConsumer.scala | 33 +- .../session_runner/deliveryRateLimiter.scala | 284 ++++ .../session_runner/globalStartFrom.scala | 820 +++++++++++ .../session_runner/handleStartFrom.scala | 1218 +++++++++++++++-- .../session_runner/messageConverters.scala | 10 +- .../session_runner/startFromLookups.scala | 92 ++ .../session_runner/topicPositions.scala | 216 +++ .../topic_selector/MultiTopicSelector.scala | 11 +- .../start_from/ApproximateDataPosition.scala | 29 + .../start_from/ApproximateTimePosition.scala | 24 + .../start_from/ConsumerSessionStartFrom.scala | 31 +- server/src/main/scala/library/Library.scala | 63 +- .../scala/library/LibraryServiceImpl.scala | 44 +- .../ManagedConsumerSessionStartFrom.scala | 25 +- .../ManagedRelativeDateTime.scala | 11 + .../library/resourceMatchersConversions.scala | 35 +- .../scala/metrics/MetricsServiceImpl.scala | 12 +- .../namespace/NamespaceServiceImpl.scala | 80 +- .../NamespacePoliciesServiceImpl.scala | 438 +++--- .../scala/producer/ProducerServiceImpl.scala | 202 ++- .../main/scala/pulsar_auth/PulsarAuth.scala | 64 +- .../scala/pulsar_auth/PulsarAuthRoutes.scala | 72 +- .../pulsar_auth/PulsarAuthServiceImpl.scala | 4 +- .../main/scala/schema/SchemaServiceImpl.scala | 30 +- .../main/scala/tenant/TenantServiceImpl.scala | 22 +- .../main/scala/topic/TopicServiceImpl.scala | 88 +- .../TopicPoliciesServiceImpl.scala | 278 ++-- .../test/scala/config/mergeConfigsTest.scala | 222 +++ .../consumer/consumerServiceDeleteTest.scala | 150 ++ .../consumerServiceLifecycleTest.scala | 334 +++++ .../consumer/consumerServiceResumeTest.scala | 213 +++ .../consumerServiceTopicPositionsTest.scala | 116 ++ .../test/scala/consumer/convertersTest.scala | 294 ++-- .../message_filter/JsMessageFilterTest.scala | 15 +- .../BasicMessageFilterTest.scala | 20 +- .../approximateDataPositionTest.scala | 163 +++ .../approximateTimePositionTest.scala | 245 ++++ .../session_runner/batchSizeTest.scala | 58 + .../buildConsumerBackoffTest.scala | 77 ++ .../consumerSessionRunnerTest.scala | 108 ++ .../session_runner/deliveryBudgetTest.scala | 199 +++ .../deliveryRateLimiterTest.scala | 280 ++++ .../deliveryRateLimiterWiringTest.scala | 314 +++++ .../session_runner/globalStartFromTest.scala | 1098 +++++++++++++++ .../session_runner/handleStartFromTest.scala | 142 ++ .../session_runner/latestNLiveCheckMain.scala | 134 ++ .../listenerGateAndBudgetTest.scala | 301 ++++ .../mergeDeliveryFailureTest.scala | 159 +++ .../messageConvertersTest.scala | Bin 0 -> 6602 bytes .../messageIdStartFromTest.scala | 143 ++ .../nonPersistentTopicsTest.scala | 130 ++ .../sessionContextConcurrencyTest.scala | 424 ++++++ .../sessionOutputSerializationTest.scala | 446 ++++++ .../sessionResourceSafetyTest.scala | 427 ++++++ .../startFromBrokerFailureTest.scala | 259 ++++ .../startFromCountValidationTest.scala | 255 ++++ .../startFromDiscardOnceTest.scala | 230 ++++ .../session_runner/startFromDiscardTest.scala | 211 +++ .../startFromOrderingTest.scala | 401 ++++++ .../startFromProgressTest.scala | 411 ++++++ .../session_runner/topicPositionsTest.scala | 170 +++ .../multiTopicSelectorTest.scala | 67 + .../start_from/startFromConversionsTest.scala | 121 ++ .../scala/conversions/primitiveConvTest.scala | 72 +- .../library/libraryConcurrencyTest.scala | 89 ++ .../test/scala/library/libraryScanTest.scala | 279 ++++ .../library/libraryServiceDeleteTest.scala | 120 ++ .../library/managedItemsConversionsTest.scala | 94 ++ .../resourceMatchersConversionsTest.scala | 214 +++ .../scala/producer/ProducerRegistryTest.scala | 242 ++++ .../scala/producer/ProducerSendTest.scala | 324 +++++ .../test/scala/producer/awaitSendsTest.scala | 168 +++ .../test/scala/producer/jsonToValueTest.scala | 502 +++++++ .../pulsar_auth/pulsarAuthCookieTest.scala | 224 +++ .../pulsarAuthRoutesHttpTest.scala | 210 +++ .../schema/protobufnative/compilerTest.scala | 42 +- .../scala/server/grpc/statusCodeTest.scala | 69 + ui/build.js | 19 +- ui/components/TopicPage/TopicPage.test.tsx | 125 ++ ui/components/TopicPage/TopicPage.tsx | 5 +- .../app/contexts/Notifications.test.tsx | 28 + ui/components/app/contexts/Notifications.tsx | 17 +- .../app/pulsar-auth/Editor/Editor.test.tsx | 123 ++ .../app/pulsar-auth/Editor/Editor.tsx | 24 +- ui/components/conversions/conversions.spec.ts | 75 + ui/components/conversions/conversions.tsx | 21 +- ui/components/local-storage-keys.ts | 28 +- ui/components/ui/CodeEditor/CodeEditor.tsx | 9 + .../ui/ConsumerSession/Console/Console.tsx | 32 +- .../Console/Producer/lib/lib.spec.ts | 34 + .../Console/Producer/lib/lib.ts | 11 +- .../TopicPositions/TopicPositions.module.css | 42 + .../TopicPositions/TopicPositions.test.tsx | 158 +++ .../Console/TopicPositions/TopicPositions.tsx | 252 ++++ .../TopicPositions/topic-positions.spec.ts | 315 +++++ .../Console/TopicPositions/topic-positions.ts | 244 ++++ .../ConsumerSession.lifecycle.test.tsx | 962 +++++++++++++ .../ConsumerSession.module.css | 14 + .../ConsumerSession/ConsumerSession.test.ts | 362 +++++ .../ui/ConsumerSession/ConsumerSession.tsx | 749 +++++++--- .../ui/ConsumerSession/Message/Message.tsx | 207 +-- .../ConsumerSession/Message/fields.test.tsx | 64 + .../ui/ConsumerSession/Message/fields.tsx | 6 +- .../TestOpStringMatchesRegexInput.test.tsx | 50 + .../NumDisplayItemsInput.tsx | 67 + .../SessionConfiguration.module.css | 6 + .../SessionConfiguration.test.tsx | 282 ++++ .../SessionConfiguration.tsx | 91 +- .../SessionTargetInput/SessionTargetInput.tsx | 1 + .../ApproximateFractionInput.tsx | 91 ++ .../StartFromInput/MessageCountInput.tsx | 72 + .../StartFromInput/StartFromInput.module.css | 49 + .../StartFromInput/StartFromInput.test.tsx | 881 ++++++++++++ .../StartFromInput/StartFromInput.tsx | 216 ++- .../StartFromInput/approximate-fraction.ts | 52 + .../StartFromInput/message-count.ts | 55 + .../StartFromInput/message-id.ts | 27 + .../target-topics-persistency.spec.ts | 170 +++ .../target-topics-persistency.ts | 136 ++ .../SessionConfiguration/display-items.ts | 52 + .../StartFromProgress.module.css | 20 + .../StartFromProgress.test.tsx | 55 + .../StartFromProgress/StartFromProgress.tsx | 61 + ui/components/ui/ConsumerSession/Th.tsx | 15 +- .../MessagesExporter.test.tsx | 73 + .../MessagesExporter/MessagesExporter.tsx | 4 + .../Toolbar/Toolbar.module.css | 26 + .../ConsumerSession/Toolbar/Toolbar.test.tsx | 92 ++ .../Toolbar/Toolbar.throttle.test.tsx | 102 ++ .../ui/ConsumerSession/Toolbar/Toolbar.tsx | 101 +- .../conversions/conversions.spec.ts | 73 +- .../conversions/conversions.ts | 63 +- .../ui/ConsumerSession/keyboard.spec.ts | 148 ++ ui/components/ui/ConsumerSession/keyboard.ts | 36 +- .../ui/ConsumerSession/message-columns.ts | 42 + ui/components/ui/ConsumerSession/sort.test.ts | 60 + ui/components/ui/ConsumerSession/sort.ts | 4 +- ui/components/ui/ConsumerSession/types.ts | 12 + ui/components/ui/Input/Input.module.css | 6 + ui/components/ui/Input/Input.test.tsx | 163 +++ ui/components/ui/Input/Input.tsx | 25 +- .../StringFilterInput.test.tsx | 57 + .../LibraryBrowser/default-library-items.ts | 7 +- .../OverwriteExistingItemDialog.test.tsx | 156 +++ .../OverwriteExistingItemDialog.tsx | 12 +- .../SaveItemDialog/SaveItemDialog.test.tsx | 192 +++ .../dialogs/SaveItemDialog/SaveItemDialog.tsx | 13 +- .../model/resolved-items-conversions.ts | 9 +- .../start-from-approximate-positions.spec.ts | 137 ++ .../user-managed-items-conversions-pb.spec.ts | 28 + .../user-managed-items-conversions-pb.ts | 28 +- .../model/user-managed-items.ts | 8 +- .../RelativeDateTimePicker.module.css | 6 + .../RelativeDateTimePicker.test.tsx | 163 +++ .../RelativeDateTimePicker.tsx | 43 +- .../relative-date-time.ts | 35 + ui/components/ui/Select/Select.tsx | 6 +- ui/components/ui/Table/Table.module.css | 22 +- ui/components/ui/Table/Table.tsx | 106 +- .../ui/resizable/useColumnOrder.spec.ts | 60 + ui/components/ui/resizable/useColumnOrder.ts | Bin 0 -> 2826 bytes ui/jest.config.js | 4 +- 219 files changed, 28307 insertions(+), 1678 deletions(-) create mode 100755 e2e/scripts/fresh-data-dir.sh create mode 100644 e2e/src/test/scala/features/consumersession/CsApproximatePartitionedSpec.scala create mode 100644 e2e/src/test/scala/features/consumersession/CsDeliveryControlsSpec.scala create mode 100644 e2e/src/test/scala/features/consumersession/CsFlowControlSpec.scala create mode 100644 e2e/src/test/scala/features/consumersession/CsStartFromMatrixSpec.scala create mode 100644 e2e/src/test/scala/features/consumersession/CsStartFromOutcomesSpec.scala create mode 100644 e2e/src/test/scala/features/consumersession/CsTopicKindsSpec.scala create mode 100644 e2e/src/test/scala/features/consumersession/CsTopicPositionsSpec.scala create mode 100644 e2e/src/test/scala/features/consumersession/StartFromSupport.scala create mode 100644 e2e/src/test/scala/harness/BatchingFixtureSpec.scala create mode 100644 e2e/src/test/scala/harness/StackScriptsSpec.scala create mode 100644 e2e/src/test/scala/harness/SuiteFactsSpec.scala delete mode 100644 proto/buf.lock delete mode 100644 proto/buf.yaml create mode 100644 server/src/main/scala/consumer/session_runner/StartFromDiscard.scala create mode 100644 server/src/main/scala/consumer/session_runner/buildAllOrRelease.scala create mode 100644 server/src/main/scala/consumer/session_runner/deliveryRateLimiter.scala create mode 100644 server/src/main/scala/consumer/session_runner/globalStartFrom.scala create mode 100644 server/src/main/scala/consumer/session_runner/startFromLookups.scala create mode 100644 server/src/main/scala/consumer/session_runner/topicPositions.scala create mode 100644 server/src/main/scala/consumer/start_from/ApproximateDataPosition.scala create mode 100644 server/src/main/scala/consumer/start_from/ApproximateTimePosition.scala create mode 100644 server/src/test/scala/config/mergeConfigsTest.scala create mode 100644 server/src/test/scala/consumer/consumerServiceDeleteTest.scala create mode 100644 server/src/test/scala/consumer/consumerServiceLifecycleTest.scala create mode 100644 server/src/test/scala/consumer/consumerServiceResumeTest.scala create mode 100644 server/src/test/scala/consumer/consumerServiceTopicPositionsTest.scala create mode 100644 server/src/test/scala/consumer/session_runner/approximateDataPositionTest.scala create mode 100644 server/src/test/scala/consumer/session_runner/approximateTimePositionTest.scala create mode 100644 server/src/test/scala/consumer/session_runner/batchSizeTest.scala create mode 100644 server/src/test/scala/consumer/session_runner/buildConsumerBackoffTest.scala create mode 100644 server/src/test/scala/consumer/session_runner/consumerSessionRunnerTest.scala create mode 100644 server/src/test/scala/consumer/session_runner/deliveryBudgetTest.scala create mode 100644 server/src/test/scala/consumer/session_runner/deliveryRateLimiterTest.scala create mode 100644 server/src/test/scala/consumer/session_runner/deliveryRateLimiterWiringTest.scala create mode 100644 server/src/test/scala/consumer/session_runner/globalStartFromTest.scala create mode 100644 server/src/test/scala/consumer/session_runner/handleStartFromTest.scala create mode 100644 server/src/test/scala/consumer/session_runner/latestNLiveCheckMain.scala create mode 100644 server/src/test/scala/consumer/session_runner/listenerGateAndBudgetTest.scala create mode 100644 server/src/test/scala/consumer/session_runner/mergeDeliveryFailureTest.scala create mode 100644 server/src/test/scala/consumer/session_runner/messageConvertersTest.scala create mode 100644 server/src/test/scala/consumer/session_runner/messageIdStartFromTest.scala create mode 100644 server/src/test/scala/consumer/session_runner/nonPersistentTopicsTest.scala create mode 100644 server/src/test/scala/consumer/session_runner/sessionContextConcurrencyTest.scala create mode 100644 server/src/test/scala/consumer/session_runner/sessionOutputSerializationTest.scala create mode 100644 server/src/test/scala/consumer/session_runner/sessionResourceSafetyTest.scala create mode 100644 server/src/test/scala/consumer/session_runner/startFromBrokerFailureTest.scala create mode 100644 server/src/test/scala/consumer/session_runner/startFromCountValidationTest.scala create mode 100644 server/src/test/scala/consumer/session_runner/startFromDiscardOnceTest.scala create mode 100644 server/src/test/scala/consumer/session_runner/startFromDiscardTest.scala create mode 100644 server/src/test/scala/consumer/session_runner/startFromOrderingTest.scala create mode 100644 server/src/test/scala/consumer/session_runner/startFromProgressTest.scala create mode 100644 server/src/test/scala/consumer/session_runner/topicPositionsTest.scala create mode 100644 server/src/test/scala/consumer/session_target/topic_selector/multiTopicSelectorTest.scala create mode 100644 server/src/test/scala/consumer/start_from/startFromConversionsTest.scala create mode 100644 server/src/test/scala/library/libraryConcurrencyTest.scala create mode 100644 server/src/test/scala/library/libraryScanTest.scala create mode 100644 server/src/test/scala/library/libraryServiceDeleteTest.scala create mode 100644 server/src/test/scala/library/managedItemsConversionsTest.scala create mode 100644 server/src/test/scala/library/resourceMatchersConversionsTest.scala create mode 100644 server/src/test/scala/producer/ProducerRegistryTest.scala create mode 100644 server/src/test/scala/producer/ProducerSendTest.scala create mode 100644 server/src/test/scala/producer/awaitSendsTest.scala create mode 100644 server/src/test/scala/producer/jsonToValueTest.scala create mode 100644 server/src/test/scala/pulsar_auth/pulsarAuthCookieTest.scala create mode 100644 server/src/test/scala/pulsar_auth/pulsarAuthRoutesHttpTest.scala create mode 100644 server/src/test/scala/server/grpc/statusCodeTest.scala create mode 100644 ui/components/TopicPage/TopicPage.test.tsx create mode 100644 ui/components/app/contexts/Notifications.test.tsx create mode 100644 ui/components/app/pulsar-auth/Editor/Editor.test.tsx create mode 100644 ui/components/conversions/conversions.spec.ts create mode 100644 ui/components/ui/ConsumerSession/Console/TopicPositions/TopicPositions.module.css create mode 100644 ui/components/ui/ConsumerSession/Console/TopicPositions/TopicPositions.test.tsx create mode 100644 ui/components/ui/ConsumerSession/Console/TopicPositions/TopicPositions.tsx create mode 100644 ui/components/ui/ConsumerSession/Console/TopicPositions/topic-positions.spec.ts create mode 100644 ui/components/ui/ConsumerSession/Console/TopicPositions/topic-positions.ts create mode 100644 ui/components/ui/ConsumerSession/ConsumerSession.lifecycle.test.tsx create mode 100644 ui/components/ui/ConsumerSession/ConsumerSession.test.ts create mode 100644 ui/components/ui/ConsumerSession/Message/fields.test.tsx create mode 100644 ui/components/ui/ConsumerSession/SessionConfiguration/FilterChainEditor/FilterEditor/BasicFilterEditor/BasicMessageFilterOpInput/AnyTestOpInput/TestOpStringMatchesRegexInput/TestOpStringMatchesRegexInput.test.tsx create mode 100644 ui/components/ui/ConsumerSession/SessionConfiguration/NumDisplayItemsInput.tsx create mode 100644 ui/components/ui/ConsumerSession/SessionConfiguration/SessionConfiguration.test.tsx create mode 100644 ui/components/ui/ConsumerSession/SessionConfiguration/StartFromInput/ApproximateFractionInput.tsx create mode 100644 ui/components/ui/ConsumerSession/SessionConfiguration/StartFromInput/MessageCountInput.tsx create mode 100644 ui/components/ui/ConsumerSession/SessionConfiguration/StartFromInput/StartFromInput.test.tsx create mode 100644 ui/components/ui/ConsumerSession/SessionConfiguration/StartFromInput/approximate-fraction.ts create mode 100644 ui/components/ui/ConsumerSession/SessionConfiguration/StartFromInput/message-count.ts create mode 100644 ui/components/ui/ConsumerSession/SessionConfiguration/StartFromInput/message-id.ts create mode 100644 ui/components/ui/ConsumerSession/SessionConfiguration/StartFromInput/target-topics-persistency.spec.ts create mode 100644 ui/components/ui/ConsumerSession/SessionConfiguration/StartFromInput/target-topics-persistency.ts create mode 100644 ui/components/ui/ConsumerSession/SessionConfiguration/display-items.ts create mode 100644 ui/components/ui/ConsumerSession/StartFromProgress/StartFromProgress.module.css create mode 100644 ui/components/ui/ConsumerSession/StartFromProgress/StartFromProgress.test.tsx create mode 100644 ui/components/ui/ConsumerSession/StartFromProgress/StartFromProgress.tsx create mode 100644 ui/components/ui/ConsumerSession/Toolbar/ExportMessagesButton/MessagesExporter/MessagesExporter.test.tsx create mode 100644 ui/components/ui/ConsumerSession/Toolbar/Toolbar.test.tsx create mode 100644 ui/components/ui/ConsumerSession/Toolbar/Toolbar.throttle.test.tsx create mode 100644 ui/components/ui/ConsumerSession/keyboard.spec.ts create mode 100644 ui/components/ui/ConsumerSession/sort.test.ts create mode 100644 ui/components/ui/Input/Input.test.tsx create mode 100644 ui/components/ui/Input/StringFilterInput/StringFilterInput.test.tsx create mode 100644 ui/components/ui/LibraryBrowser/dialogs/OverwriteExistingItemDialog/OverwriteExistingItemDialog.test.tsx create mode 100644 ui/components/ui/LibraryBrowser/dialogs/SaveItemDialog/SaveItemDialog.test.tsx create mode 100644 ui/components/ui/LibraryBrowser/model/start-from-approximate-positions.spec.ts create mode 100644 ui/components/ui/LibraryBrowser/model/user-managed-items-conversions-pb.spec.ts create mode 100644 ui/components/ui/RelativeDateTimePicker/RelativeDateTimePicker.test.tsx create mode 100644 ui/components/ui/RelativeDateTimePicker/relative-date-time.ts create mode 100644 ui/components/ui/resizable/useColumnOrder.spec.ts create mode 100644 ui/components/ui/resizable/useColumnOrder.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c00507c96..17387a1a5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,8 @@ jobs: PULSAR_ADMIN_PORT: 28080 PULSAR_BROKER_PORT: 26650 DEKAF_PORT: 28090 + # Isolate the Library's data dir per run so items don't accumulate and skew counts. + DEKAF_FRESH_DATA: 1 steps: - uses: actions/checkout@v4 with: diff --git a/.gitignore b/.gitignore index 11fbfeb84..1a304e33e 100644 --- a/.gitignore +++ b/.gitignore @@ -118,3 +118,10 @@ result # Docker **/slim.*.json + +# macOS +.DS_Store + +# Local design assets (~24MB of PNG/GIF concepts) - not part of the product build. +# Anchored to the repo root so it does not also swallow any nested `design/` dir elsewhere. +/design/ diff --git a/AGENTS.md b/AGENTS.md index 27acdb557..fdb4dc883 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,7 @@ Dekaf is an open-source UI for Apache Pulsar. It's a single deployable binary th - A **Scala 3 / ZIO** backend exposing a gRPC API (`server/`) - An embedded **Envoy proxy** that translates browser gRPC-Web ↔ native gRPC -The UI and server communicate over **Protobuf / gRPC-Web**. Proto definitions live in `proto/` and are the source of truth for the API contract — generated code is committed into `ui/grpc-web/` and `server/src/main/scala/pb/`. +The UI and server communicate over **Protobuf / gRPC-Web**. Proto definitions live in `proto/` and are the source of truth for the API contract — generated code lands in `ui/grpc-web/` and `server/src/main/scala/pb/`, which are gitignored and regenerated by `cd proto && make build`. ## Development environment @@ -69,7 +69,7 @@ Dekaf stores saved sessions and other user artifacts as "managed items" on disk ## Conventions & notes - The backend is intentionally **straightforward Scala** — avoid heavy FP / type-level acrobatics (per `CONTRIBUTING.md`). -- After changing any `.proto`, you **must** run `cd proto && make build` and rebuild both sides; the generated code is committed. +- After changing any `.proto`, you **must** run `cd proto && make build` and rebuild both sides. The generated output is gitignored, so a clean checkout has no `pb/` or `grpc-web/` until you run it. - Generated directories (`ui/grpc-web/`, `server/src/main/scala/pb/`) should not be hand-edited. - `demoapp/` is a sample producer app used by the quick-start docker-compose to populate demo data. - `desktop/` contains an Electron wrapper; `helm/` and `deployment/` are for k8s; `docker/` holds image builds and the quick-start compose file. diff --git a/docs/configuration-reference.md b/docs/configuration-reference.md index c9c1604a4..aff6f155a 100644 --- a/docs/configuration-reference.md +++ b/docs/configuration-reference.md @@ -76,8 +76,8 @@ Also set the appropriate cookie settings. |Field |Description | |--- |--- | -|cookieSecure | `true` or `false`. Set it to `true` if you use the `https` protocol. | -|cookieSameSite | `true` or `false`. Set it to `true` if you use the `https` protocol. | +|cookieSecure | `true` or `false`. Set it to `true` if you use the `https` protocol. Adds the `Secure` attribute, so the browser only sends the cookie over HTTPS. | +|cookieSameSite | `lax`, `strict` or `none` (case-insensitive). Controls the cookie's `SameSite` attribute, which tells the browser whether to send the cookie on cross-site requests - the built-in CSRF protection. Leave it unset to use the browser default. `none` additionally requires `cookieSecure: true`, because browsers reject `SameSite=None` on a non-`Secure` cookie; if you set it without `cookieSecure`, the attribute is omitted and a warning is logged. An unrecognised value is also omitted with a warning. | ### Default Pulsar Auth diff --git a/e2e/README.md b/e2e/README.md index 342126e31..e11e935ec 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -64,17 +64,42 @@ sbt "testOnly routes.NavigationTreeSpec" # one spec sbt "testOnly *CsFiltersSpec -- -z CS-10" # a single test by name substring (-z) ``` -### The `KnownBug` mechanism (lane currently EMPTY - all bugs fixed 2026-07-19) +### The `KnownBug` mechanism (lane currently EMPTY) While an app bug is open, its regression test asserts the **correct** behavior, is tagged `KnownBug`, and stays **red on purpose** - excluded from `sbt test` (`Test / test / testOptions` in `build.sbt`) so the normal run is green. When the bug is fixed, the test is **untagged** and joins the green lane -as an ordinary regression. All 19 catalogued bugs were fixed on 2026-07-19, so the -`knownbugs/*Spec` tests now run green in the normal lane (6 remain `ignore`d - fixed app-side but not -driveable from this harness; rationale inline, and see §6). The tag + exclusion stay wired for the next bug: +as an ordinary regression. All 19 originally catalogued bugs were fixed on 2026-07-19, so the +`knownbugs/*Spec` tests now run green in the normal lane. The last remaining tagged test, +`CsTopicKindsSpec` CS-TK-6, was **untagged on 2026-07-25** when both counting Start-From modes became +GLOBAL and its `all.drop(2)` expectation became the contract rather than more than it (§6, +"Start-From: batching, and the entry-vs-message trap"). No test carries the tag today, so the bug +lane runs **0 tests** - that is the healthy state, not a broken filter. + +Nothing in this suite is `ignore`d or `pending` either. The bugs that are fixed app-side but not +driveable from Playwright have no e2e test *at all* - they are covered in the jest / server tiers +instead (§6), with an inline pointer where they would have lived. (This paragraph used to claim six +`ignore`d tests; `rg '\bignore\s*\(' e2e/src/test` has found none for some time.) + +Two topic-policy specs are the one exception, and an honest one: `TopicPolicySpec` (TOP-8/9) and +`TopicPolicyBreadthSpec` (TOP-13/14) `assume(...)` on the broker's `topicLevelPoliciesEnabled` +setting, so on any given stack the branch that does not match the broker RUNTIME-cancels - a canceled +test is neither a pass nor a failure. On the dev stack (policies ON) TOP-8 always cancels. That is +config-gated coverage rather than a hidden red test, so it is named here and pinned: the census below +counts every `assume(...)` and fails if one appears OUTSIDE those two specs, which is the one way the +same mechanism could quietly drop a test from the green run. + +All of these are pinned by `harness.SuiteFactsSpec` against the marker below - the `ignored`, +`pending` and `known-bug` counts, the `assume` count and its location, and that build.sbt excludes +exactly the one `KnownBug` tag from `sbt test` (`excluded-tags`) - so none of the claims can drift +again. + + + +The tag and its exclusion stay wired for the next bug: ```bash -sbt "testOnly * -- -n KnownBug" # the bug lane - currently runs nothing (no open bugs) +sbt "testOnly * -- -n KnownBug" # the bug lane - red by design; currently empty (0 tests) sbt test # green lane, includes the fixed-bug regressions ``` @@ -172,7 +197,9 @@ e2e/src │ ├── library/ LibrarySidebar, LibrarySaveDialog, LibraryBrowser │ └── consumersession/ ConsumerSessionPage, FilterPanel, TargetSelector, ExportModal, ToolsPanel └── test/scala - ├── harness/ DekafSuite (base trait: fresh BrowserContext + trace + fixtures per test) + ├── harness/ DekafSuite (base trait: fresh BrowserContext + trace + fixtures per test), + │ BatchingFixtureSpec (the broker facts under Start-From), StackScriptsSpec + │ (scripts/), SuiteFactsSpec (this README's countable claims) ├── smoke/ routes/ instance/ navigation, chrome, and per-page specs ├── primitives/ cross-cutting form primitives (X-1/2/3) ├── features/ library/ + consumersession/ + producer/ specs @@ -189,20 +216,58 @@ e2e/src Honest backlog - none block the green lane; each is a place the suite proves less than it might. **Infrastructure** -- **Playwright browsers aren't pinned/cached** - a clean CI runner downloads Chromium on first use - (the CI `e2e` job installs it each run). -- **Library state accumulates.** `run-dekaf.sh` doesn't set `DEKAF_DATA_DIR`, so items land in - `server/data/library` and survive runs. Tests are context-scoped so they still pass, but - instance-scoped items (e.g. LIB-16's note) leak and counts drift. Isolating the data dir also needs - `js/dist/libs.js` + `proto/` seeded into it (see `run-dekaf.sh`). - **No independent Library oracle.** LIB CRUD arranges *and* verifies through the same UI path, so a shared serialization/render defect could pass both. A generated `LibraryService` gRPC stub would fix - this (and let the `ignore`d BUG-8/9/17 regressions drive the server directly). + this - the one remaining piece of test infrastructure worth building. + +*Resolved:* Playwright browsers are no longer downloaded at all - they come pre-patched from the nix +store, version-locked to the Java client via the `nixpkgs-playwright` flake input, so a clean runner +needs neither a download nor sudo. And `run-dekaf.sh` now honours **`DEKAF_FRESH_DATA=1`**: it seeds a +throwaway data dir with `js/` + `proto/` and points `DEKAF_DATA_DIR` at it, which CI sets - so Library +items no longer accumulate across runs. Local dev keeps its persistent library by default. + +That throwaway tree used to be a `mktemp -d`, and **leaked on every CI build**: `run-dekaf.sh` ends in +`exec sbt run`, so the process that created it is replaced by the server, and CI then kills the whole +process tree - no trap or shutdown hook in the server's own lifetime can fire, and the random name +left nothing findable afterwards. The path now comes from **`scripts/fresh-data-dir.sh`** (one +deterministic directory **per stack** under `$RUNNER_TEMP`, falling back to `$TMPDIR`): +`run-dekaf.sh` clears the previous run's tree before seeding, and **`stack-down.sh`** - the teardown +step CI already runs with `if: always()` - removes it. + +*Per stack* is the second half of the fix and arrived after the first: a single deterministic name is +shared by every stack on the box, and since startup `rm -rf`s it and teardown `rm -rf`s it again, two +stacks side by side - a second Dekaf on its own port, which is exactly how you run two - destroyed +each other's **live** data dir. The identity is derived rather than configured, from the two +variables that already distinguish the stacks and are already in both scripts' environment: +`DEKAF_PORT` and `PULSAR_CONTAINER_NAME`. Teardown must therefore be run with the same values the +stack came up with (CI sets `DEKAF_PORT` at job level, so every step of the job agrees); getting it +wrong now leaks a tree instead of destroying a live one, and `clean` prints any tree it deliberately +left behind so that leak is visible. `harness.StackScriptsSpec` (STACK-1..4) pins the determinism, +the removal, that two identities cannot collide - cleaning one leaves the other's seeded data byte +for byte - and that both scripts still go through the shared path. + +Two more fixes that had no permanent regression now have one, in both cases because the failing +condition does not occur on a healthy local stack and had to be **created**: + +- **LIB-22** delays `ListLibraryItems` from inside the page (a wrapped `XMLHttpRequest.send`, not a + Playwright route handler - a Java handler that sleeps blocks the driver's own dispatch loop and + would stall the test's `isVisible` call too, hiding the race). It asserts the Notes panel really is + unsettled and that `LibrarySidebar.createNote` still works. Reverting `createNote` to its + pre-fix `isVisible` branch makes it time out on `lib-new-note`, which is the original symptom. +- **CS-33** blocks `cdn.jsdelivr.net` outright and asserts a Monaco editor still mounts, that the + files came from `/ui/static/dist/vs/`, and that nothing reached for the CDN. Blocking rather than + merely observing is the point: on a runner with internet, a regression would silently succeed + through the CDN and prove nothing about the offline case. *Still unproven:* the loader path is + built against `document.baseURI` so that it survives a non-root `DEKAF_PUBLIC_BASE_URL` / + `basePath`, and this stack serves Dekaf at the root - where the base-relative and origin-rooted + forms are the same string. Covering that needs a second Dekaf started on a sub-path. **Assertions thinner than the feature they name** (acknowledged, not defects) -- **CS-16/17** don't assert the full counter/state machine incl. broker-side consumer presence; - **CS-23** asserts the details panel opens, not its tab contents; **CS-28** asserts formats + `.zip` - entry indices, not exact exported values. +- **CS-23** asserts the details panel opens, not its tab contents. (CS-16/17 used to sit here for + broker-side consumer presence; the lifecycle specs now assert broker consumer counts on stop.) (**CS-28** used to sit here too; + it now parses the exported `.zip` and compares exact `(index, key, value, topic)` records in order, + so only the fields the broker owns - message id, publish/event time, size, producer name - are + left unpinned.) - **TOP-3/4, SUB-3** assert success toasts rather than a polled state change; **RES-2** supplies a missing id, not a malformed persisted config; **LIB-18** proves the `?id=` URL loads, not that the exact saved config restored. @@ -212,6 +277,136 @@ Honest backlog - none block the green lane; each is a place the suite proves les split + clear-backlog but not unload/unload-all; TOP-7 doesn't verify Earliest/Latest cursor semantics; Producer properties/event-time have testIds but no dedicated test yet. +**Start-From: batching, and the entry-vs-message trap** (bug FIXED 2026-07-25; coverage now green) + +A Pulsar broker addresses its log by **entry**, and the Java producer packs many messages into one +entry by default. `PulsarAdmin.examineMessage` - which "Skip first n messages" and "Latest n +messages" used to be built on - therefore counts entries, not messages. Every fixture the suite had +produced *one message per entry* (a blocking `send` closes a one-message batch every time), so entry +positions and message positions always coincided and the difference was invisible: "skip the first +5" actually skipped 8 (or 50), and nothing caught it. + +`PulsarFixtures` now offers `produceBatched` / `produceUnbatched` / `numberOfEntries` / +`readAllMessages` / `messageIdHex`. **`produceBatched` throws** if the messages did not actually +share entries - an unbatched "batched" fixture would silently recreate the blind spot it exists to +close. `harness.BatchingFixtureSpec` (BATCH-1..4) pins the broker facts underneath: batching really +happens, `examineMessage` is entry-addressed, past the end it *clamps* on `"earliest"` but *throws* +on `"latest"`, and an empty partition cannot answer at all. + +Outcome coverage per mode lives in `CsStartFromOutcomesSpec` (CS-SF-1..19, persistent +non-partitioned, batched and unbatched), `CsStartFromMatrixSpec` (skip-n / latest-n x batched / +unbatched x the whole `TopicKind` matrix, plus the spread-across-partitions cases) and +`CsApproximatePartitionedSpec` (CS-SF-20/21, the two approximate modes on a partitioned topic). The +contracts asserted, as implemented: + +- **Skip first n** drops n messages of the session's merged stream and delivers the rest - n in + TOTAL, across every physical topic. The merge takes each partition in its own append order and + compares publish times across the partitions' current heads, so on same-clock producers this is + the n globally-earliest; where producer clocks disagree, which n can shift while the count stays + exact. On a single ordered log it is exactly "start at message n + 1". `n = 0` shows everything. +- **Latest n** delivers **exactly n in total**, not n per partition, under the same head-comparison + ordering. + (Until 2026-07-25 it was resolved per physical topic, so "latest 2" on a 3-partition topic returned + six; CS-SFM-4 is the regression against that.) `n = 0` shows nothing and streams only what arrives + after play. One deliberate edge: if two ENABLED TARGETS select the same topic, each target delivers + its own counted set through its own subscription - see the duplicate-target contract note in + `handleStartFrom.scala`. +- **About % through the data** is by ENTRY, not by message - it has to resolve in constant time at + any topic size - so 50% of twelve messages lands on m-07 written one per entry and on m-05 written + four per entry. CS-SF-11/12 assert exactly that difference, deriving the expectation from the + broker's entry count. 0% is Earliest, 100% is Latest, and a percentage outside 0-100 is refused + without reaching the session - **CS-SF-14 presses Play with the rejected text still on screen**, so + what it pins is that the invalid value never reaches the session (the run comes out at the last + valid one), not merely that correcting the field afterwards works. +- **About % through the time range** interpolates between the first and last PUBLISH TIMES instead + and seeks to the resulting instant, so the same 50% lands somewhere else whenever messages did not + arrive evenly. CS-SF-16 pins the proportionality (25% and 75% of a 12-second range, each falling in + the middle of a gap); **CS-SF-17 asks one topic the same "50%" with both modes and asserts the two + different answers** - two old messages plus a burst of ten, where half the time is back in the + empty stretch while half the messages are inside the burst. Its endpoints are deliberately NOT the + data mode's: 0% is Earliest but **100% is the last message itself** (CS-SF-18), because the time + range ends at that message rather than past it. CS-SF-19 presses Play on the rejected value the + same way CS-SF-14 does. +- **The two approximate modes diverge on a PARTITIONED topic**, and that is where their definitions + actually differ: the data mode resolves **every physical topic independently** (each partition + leaves `floor(fraction x its own entry count)` entries behind) while the time mode groups the + partitions by logical topic, pools `min(first publish time)` .. `max(last publish time)` across the + group, and seeks **every** partition to that one instant - so an idle partition cannot drag the + cutoff backwards and a late-starting one gets no range of its own. `CsApproximatePartitionedSpec` + CS-SF-20/21 arrange one topic skewed on both axes at once (partitions holding 8 / 4 / 2 / **0** + messages over three different stretches of a 12-second range, each pooled endpoint owned by exactly + one partition) and assert the exact set for each mode, having first asserted that the two modes - + and the mistake each is exposed to - really do give different answers on that arrangement. + The **empty** partition is part of the arrangement, not an accident: `examineMessage` does not + report an empty topic as an empty range, it *fails* (BATCH-4), so an empty partition reaches the + server as an error it has to tell apart from an operational one, and both modes then have to skip + it - counted as time zero it would drag the pooled start back to 1970 and turn 50% into Earliest. + Both tests also gate on the session holding flow permits on **every** partition, the empty one + included, so a server that quietly dropped it would fail rather than answer the same. The pure + arithmetic stays in `server/src/test/scala/consumer/session_runner/approximateTimePositionTest.scala`. +- **Non-persistent targets** cannot honour any history mode, and the selector now says so: all eight + are rendered `disabled` with a note (`cs-start-from-non-persistent-note`), leaving only "Latest + message". CS-SFM-2 and CS-TK-5 assert the exact disabled set rather than watching a + permitted-but-meaningless selection behave. +- **Skip progress** (`cs-start-from-progress`) is deliberately silent at or below 1,000,000 messages + to skip. CS-SF-15 asks for a 2,000,000 skip on a 12-message topic - `messagesToSkip` is what was + asked for, not what exists - which is the one way to drive the server -> gRPC -> panel join that + neither the jest nor the server tests can reach. It requires `data-cs-skipped` to be **strictly + positive**: the server reports as soon as the discard claims its first message, so a zero would be + a frame the UI could have rendered from its own initial state rather than proof of a real callback. + +Honest limits: the **non-persistent** quadrants cannot prove batching at all (no managed ledger, so +no `numberOfEntries`). The single-log cells still funnel their payload through **one** partition - +not because the global contract needs it, but because that is where "the first n" is a plain slice of +the payload and the cell stays readable. + +`CsTopicKindsSpec` **CS-TK-6** was **untagged on 2026-07-25**: its `all.drop(2)` expectation is a +statement about a global order, which is exactly what Skip-N now promises. Verified green six runs +running. `CsStartFromMatrixSpec` CS-SFM-3/4 assert the same two contracts on a payload genuinely +spread over every partition, with the expectation derived from the **broker's own publish times** +(`globalOrder`) rather than from the produce order - and the arrangement spaces its publishes so that +no two messages in different partitions share a millisecond, which that helper asserts rather than +assumes. + +CS-SFM-3/4 configure **no message filter, value projection or coloring rule** on purpose, so a +failure there is about start-from and nothing else. + +What used to sit next door was a *concurrent-entry* hazard: a session gets **one** GraalVM JS context +(`ConsumerSessionContextPool` pins the pool to size 1) while a partitioned topic gives each partition +its own listener thread. GraalJS lets a context move between threads but not be entered by two at +once ("Multi threaded access ... is not allowed for language(s) js"), and nothing serialized them. +**That is now fixed**: `ConsumerSessionContext.exclusively` takes a reentrant lock and +`ConsumerSessionContextPool.withNextContext` leases it for a **whole message** - the filter chain, +the coloring rules, the projections and the `getStdout` drain all run inside one lease, so neither +the exception nor the subtler outcome (one message judged against another's `setCurrentMessage` +global) can happen. The browser console goes through the same lease. + +Both of the races this section used to list next to it are **fixed**: + +- *ordering* - `ConsumerListener.received` now resolves **and processes** inside + `startFromOrdering.inOrder`, so a vector reaches the target handler in the order the merge chose; +- *concurrent observer entry* - start-from progress no longer calls `StreamObserver.onNext` itself. + Every write goes through `ConsumerSessionRunner.sendResponse`, which holds `sendLock`. + +The two hazards this section used to file as still-open - about the ORDER and TERMINATION of what the +observer is handed rather than about entering it - are **fixed** as well, and pinned at the server +tier in `server/src/test/scala/consumer/session_runner/sessionOutputSerializationTest.scala` (suite +"progress never goes backwards, and nothing follows the end of the stream"): + +- `sendResponse` now **builds** its response - start-from progress included - **inside** `sendLock`, + in the same critical section as the `onNext` and behind an `if !streamCompleted` check, so two + threads can no longer snapshot an older, incomplete progress frame and send it after a newer + complete one. Pinned by "AN OLDER PROGRESS FRAME CANNOT OVERTAKE A NEWER COMPLETE ONE". +- `stop()` now calls `observer.onCompleted()` **inside** `sendLock`, behind a sticky `streamCompleted` + terminal gate, so nothing is written after completion and completion never interleaves with an + `onNext` still in flight. Pinned by "NO RESPONSE REACHES THE CLIENT AFTER THE STREAM HAS BEEN + COMPLETED" and "THE STREAM IS NOT COMPLETED WHILE A RESPONSE IS STILL BEING WRITTEN". + +Because those are unit-pinned at the server tier, this suite does not reproduce them: a browser +cannot see which of two frames the server built first, and the window is a few instructions wide. +Probed 2026-07-25 at 400 messages over 3 partitions, with and without a JS filter: no +multi-threaded-access error appeared then, and none has appeared in a full run since. + **Open product/config questions** (not test gaps) - **SUB-6**: Delete-Subscription's guard is the **topic FQN**, not the subscription name - the test encodes today's behavior; confirm it's intended. @@ -221,9 +416,11 @@ Honest backlog - none block the green lane; each is a place the suite proves les **Regression coverage - the `KnownBug` lane** (catalogued bugs fixed; see §3). Twelve run green as ordinary regressions in `knownbugs/*Spec` (BUG-1,3,4,5,6,10,12,13,14,15,18 + -BUG-19→NAV-14). Six are fixed app-side but not driveable from Playwright and stay `ignore`d with inline -rationale - covered instead by **jest** component tests (BUG-2/7: `KeyValueEditor` / -`AvailableInContextsButton`) and **server** unit tests (BUG-8/9/17: `LibraryBugRegressionsTest`). +BUG-19→NAV-14). Five are fixed app-side but not driveable from Playwright and have **no e2e test at +all** - not an `ignore`d one; the suite carries none (§3). They are covered in another tier instead: +**jest** component tests (BUG-2/7: `KeyValueEditor` / `AvailableInContextsButton`) and **server** +unit tests (BUG-8/9/17: `LibraryBugRegressionsTest`), with a pointer at the foot of +`MoreKnownBugsSpec` where they would otherwise have lived. **Two were reclassified as intended design** after owner review: **BUG-11** - auto-refresh is deliberately ONE global toggle ("we either want to refresh any table, or not"); a `MoreKnownBugsSpec` test now pins the global-shared semantics. **BUG-16** - per-connection library-storage scoping was diff --git a/e2e/build.sbt b/e2e/build.sbt index 6b4cbcc39..a92446bd1 100644 --- a/e2e/build.sbt +++ b/e2e/build.sbt @@ -51,6 +51,11 @@ lazy val root = project // Test framework "org.scalatest" %% "scalatest" % scalatestVersion % Test, + // Parsing what the app EXPORTS (CS-28 reads the downloaded .zip's JSON back as records rather + // than substring-searching it). Already on the classpath via pulsar-client-admin-original; + // declared here, at that same version, because a test asserting on parsed JSON should not + // depend on which JSON library the Pulsar client happens to pull in. + "com.fasterxml.jackson.core" % "jackson-databind" % "2.14.2" % Test, // Renders ScalaTest's `-h` HTML report (see testOptions above); required on the classpath. "com.vladsch.flexmark" % "flexmark-all" % "0.64.8" % Test, ), diff --git a/e2e/scripts/fresh-data-dir.sh b/e2e/scripts/fresh-data-dir.sh new file mode 100755 index 000000000..91c715649 --- /dev/null +++ b/e2e/scripts/fresh-data-dir.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# +# The throwaway DEKAF_DATA_DIR used when DEKAF_FRESH_DATA=1 (see run-dekaf.sh). +# +# It lives here, in one script with two verbs, because two different scripts need to agree on it and +# they never run together: run-dekaf.sh creates it and then `exec`s the server, so the process that +# made the directory is replaced by one that has no idea it is temporary. CI later kills that whole +# process tree, so nothing the server could have registered - a trap, an atexit - ever runs either. +# The path was `mktemp -d`, i.e. a fresh unguessable name per run, which made the tree impossible to +# find afterwards and left one behind on the self-hosted runner every single build. +# +# A DETERMINISTIC path fixes both halves: the run before it can be cleared away up front, and the +# teardown step (stack-down.sh, which CI runs with `if: always()`) knows exactly what to remove +# without having been told. +# +# Deterministic is not enough on its own, though, and the first version of this script stopped +# there: ONE path shared by every stack on the box, `rm -rf`'d at startup by run-dekaf.sh and again +# at teardown by stack-down.sh. Two stacks running side by side - a second Dekaf on its own port, +# with or without a Pulsar container of its own, which is how you run two - would therefore delete +# each other's LIVE data dir. So the path also carries a STACK IDENTITY. +# +# That identity is derived, not configured: it is exactly the pair of variables that already +# distinguishes one stack from another and is already in the environment of BOTH ends - the Dekaf +# port run-dekaf.sh serves on, and the Pulsar container name stack-down.sh removes. Nothing has to +# be passed along or remembered; each end computes the same answer from what it was given anyway. +# +# fresh-data-dir.sh path print the directory (no side effects) +# fresh-data-dir.sh clean remove it if it exists +# +# NOTE for teardown: `clean` removes THIS stack's tree only, so it has to run with the same +# DEKAF_PORT / PULSAR_CONTAINER_NAME the stack was brought up with (CI sets DEKAF_PORT at job level, +# so every step of the job agrees). Getting that wrong now leaks a tree instead of destroying a live +# one, and `clean` names the trees it left behind so the leak is visible rather than silent. +# +# $RUNNER_TEMP is the GitHub-runner-scoped temp dir - already per-job and wiped by the runner - so it +# is the right home on CI; locally it falls back to $TMPDIR. +set -euo pipefail + +fresh_data_base() { + local base="${RUNNER_TEMP:-${TMPDIR:-/tmp}}" + echo "${base%/}/dekaf-e2e-fresh-data" +} + +# Which stack this is. Sanitized to [A-Za-z0-9._-] so a container name containing a slash cannot +# push the tree outside the base dir, and so an identity cannot collide with a different one that +# literally lacks the offending character: `tr -c` REPLACES each disallowed character with `_` +# rather than dropping it, so the character still leaves a mark (`a/b` -> `a_b`, not `ab`). +stack_id() { + printf '%s-%s' "${DEKAF_PORT:-8090}" "${PULSAR_CONTAINER_NAME:-dekaf-e2e-pulsar}" | tr -c 'A-Za-z0-9._-' '_' +} + +fresh_data_dir() { + echo "$(fresh_data_base)-$(stack_id)" +} + +case "${1:-path}" in + path) + fresh_data_dir + ;; + clean) + dir="$(fresh_data_dir)" + if [ -d "$dir" ]; then + rm -rf "$dir" + echo "Removed the e2e fresh data dir: $dir" + else + echo "No e2e fresh data dir to remove ($dir)." + fi + # Anything left belongs to a DIFFERENT stack identity and is deliberately not touched. Naming + # it is the whole mitigation for the one hazard per-stack paths introduce: a teardown run + # without the DEKAF_PORT / PULSAR_CONTAINER_NAME its stack used now leaks a tree, which is the + # failure mode the deterministic path was introduced to end. Better loud than invisible. + # The bare `$prefix` (no identity) is matched too: it is where the pre-identity version of this + # script put ITS tree, and a checkout old enough to have created one would otherwise leave it + # orphaned and invisible. Listing is all that happens to it - deleting a path that every stack + # once shared is the bug this identity exists to fix. + prefix="$(fresh_data_base)" + shopt -s nullglob + others=("$prefix"*) + shopt -u nullglob + if [ "${#others[@]}" -gt 0 ]; then + echo "Other stacks' e2e data dirs left untouched (each is removed by its own stack's teardown):" + printf ' %s\n' "${others[@]}" + fi + ;; + *) + echo "usage: $(basename "$0") [path|clean]" >&2 + exit 2 + ;; +esac diff --git a/e2e/scripts/run-dekaf.sh b/e2e/scripts/run-dekaf.sh index 63c99b65b..ea5c0a8b7 100755 --- a/e2e/scripts/run-dekaf.sh +++ b/e2e/scripts/run-dekaf.sh @@ -38,5 +38,32 @@ fi echo "Building UI bundle (ui/)..." (cd "$repo/ui" && npm run build) +# --- Optional per-run data isolation (DEKAF_FRESH_DATA=1) ----------------------------------------- +# Without this the Library writes into $repo/server/data/library and accumulates forever, so +# instance-scoped items (e.g. LIB-16's note) leak between runs and item counts drift. Opt-in +# rather than default because a local dev session usually WANTS its library to persist. +# A bare empty dir will not boot: ConsumerSessionContext reads js/dist/libs.js from the data dir, +# and the schema tooling reads proto/ - so seed both from the repo copy (built just above). +# +# The path is DETERMINISTIC (scripts/fresh-data-dir.sh) rather than `mktemp -d`. This script ends in +# `exec sbt run`, so the process that created the tree is gone by the time anyone could clean it up, +# and CI kills the resulting process tree outright - no trap here would ever fire. A known path is +# what lets the previous run's tree be cleared below and the current one be removed by the teardown +# step (stack-down.sh). With `mktemp -d` every build left one behind on the self-hosted runner. +# +# It is also PER STACK: the `rm -rf` below is why one shared path was dangerous - a second Dekaf on +# its own port would have wiped this one's live data dir on startup, and its teardown would have +# wiped it again. fresh-data-dir.sh derives the identity from DEKAF_PORT (exported above) and +# PULSAR_CONTAINER_NAME, both of which this script's environment already carries. +if [ "${DEKAF_FRESH_DATA:-}" = "1" ]; then + fresh_data="$("$here/fresh-data-dir.sh" path)" + rm -rf "$fresh_data" + mkdir -p "$fresh_data/library" + cp -R "$repo/server/data/js" "$fresh_data/js" + cp -R "$repo/server/data/proto" "$fresh_data/proto" + export DEKAF_DATA_DIR="$fresh_data" + echo "Using a fresh data dir (DEKAF_FRESH_DATA=1): $fresh_data" +fi + echo "Starting Dekaf on :${DEKAF_PORT} → admin :${ADMIN_PORT}, broker :${BROKER_PORT} ..." cd "$repo/server" && exec sbt run diff --git a/e2e/scripts/stack-down.sh b/e2e/scripts/stack-down.sh index 5626b834f..7ef61ff7b 100755 --- a/e2e/scripts/stack-down.sh +++ b/e2e/scripts/stack-down.sh @@ -1,9 +1,20 @@ #!/usr/bin/env bash # Tear down the local Pulsar standalone started by stack-up.sh. set -euo pipefail +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" NAME="${PULSAR_CONTAINER_NAME:-dekaf-e2e-pulsar}" if docker rm -f "$NAME" >/dev/null 2>&1; then echo "Stopped and removed '$NAME'." else echo "No '$NAME' container running." fi + +# The DEKAF_FRESH_DATA tree, if run-dekaf.sh made one. Nothing else can: that script `exec`s the +# server, and CI kills the process tree, so no trap or atexit in the server's own lifetime ever runs. +# This is the teardown hook CI already calls with `if: always()`, which is why the removal lives here. +# +# THIS stack's tree only - the path carries a stack identity derived from DEKAF_PORT and +# PULSAR_CONTAINER_NAME (the same variable that named the container above), so tearing one stack +# down cannot delete a concurrent stack's live data. Run this with the same values the stack was +# brought up with; `clean` prints any tree it deliberately left behind. +"$here/fresh-data-dir.sh" clean diff --git a/e2e/src/main/scala/features/consumersession/ConsumerSessionPage.scala b/e2e/src/main/scala/features/consumersession/ConsumerSessionPage.scala index 159a0a5f8..186ce8975 100644 --- a/e2e/src/main/scala/features/consumersession/ConsumerSessionPage.scala +++ b/e2e/src/main/scala/features/consumersession/ConsumerSessionPage.scala @@ -23,6 +23,30 @@ final case class ConsumerSessionPage(page: Page): val startFromN: Locator = page.getByTestId("cs-start-from-n") val startFromMessageId: Locator = page.getByTestId("cs-start-from-message-id") + /** The Start-From "additional controls" block - whatever the selected mode reveals below the + * dropdown (the n input, the message-id input, the datetime picker, the relative picker). + * + * Anchored on the CSS-module class PREFIX rather than a `testId`: the datetime and relative + * pickers are third-party/shared components with no instrumentation of their own, and `ui/` is + * out of scope for this change. The `-module__AdditionalControls` prefix is stable across builds; + * only the trailing content hash moves. Scoping is REQUIRED, not cosmetic - the always-mounted + * Producer console renders its own datetime picker, so a page-wide `input[name='year']` matches + * two different controls. */ + val startFromAdditional: Locator = page.locator("[class*='StartFromInput-module__AdditionalControls']") + + // --- The two approximate modes + persistency advice --- + // Both render the SAME percent control, so each carries its own test-id prefix; sharing one would + // let a test drive "% through the data" while asserting "% through the time range". + val startFromDataFraction: Locator = page.getByTestId("cs-start-from-data-fraction") + val startFromDataFractionSlider: Locator = page.getByTestId("cs-start-from-data-fraction-slider") + val startFromDataFractionError: Locator = page.getByTestId("cs-start-from-data-fraction-error") + val startFromTimeFraction: Locator = page.getByTestId("cs-start-from-time-fraction") + val startFromTimeFractionSlider: Locator = page.getByTestId("cs-start-from-time-fraction-slider") + val startFromTimeFractionError: Locator = page.getByTestId("cs-start-from-time-fraction-error") + val startFromNonPersistentNote: Locator = page.getByTestId("cs-start-from-non-persistent-note") + val startFromMixedPersistencyNote: Locator = page.getByTestId("cs-start-from-mixed-persistency-note") + val startFromProgress: Locator = page.getByTestId("cs-start-from-progress") + // --- Advanced reveal (CS-15) --- val advancedToggle: Locator = page.getByTestId("cs-advanced-toggle") @@ -99,15 +123,76 @@ final case class ConsumerSessionPage(page: Page): // --- Navigation --- def openForTopic(tenant: String, namespace: String, topic: String): Unit = - page.navigate(s"/tenants/$tenant/namespaces/$namespace/topics/persistent/$topic/consumer-session") + openForTopic(tenant, namespace, topic, persistency = "persistent") + + /** `persistency` is the route segment: "persistent" or "non-persistent". */ + def openForTopic(tenant: String, namespace: String, topic: String, persistency: String): Unit = + page.navigate(s"/tenants/$tenant/namespaces/$namespace/topics/$persistency/$topic/consumer-session") /** Namespace-level mount (no current topic) - drives CS-7. */ def openForNamespace(tenant: String, namespace: String): Unit = page.navigate(s"/tenants/$tenant/namespaces/$namespace/consumer-session") + /** Switch target 1's topic selector to "Specific Topic(s)" and enter the given FQNs. */ + def setTargetTopicsSpecific(topicFqns: Seq[String]): Unit = + page.getByTestId("cs-target-mode").first().selectOption(new SelectOption().setValue("multi-topic-selector")) + topicFqns.foreach { fqn => + val input = page.getByTestId("cs-target-fqn-input").first() + input.fill(fqn) + input.press("Enter") + } + + /** Toggle target 1's read-compacted consumption mode. */ + def toggleTargetCompacted(): Unit = page.getByTestId("cs-target-compacted").first().click() + + val startFromDegradedBanner: Locator = page.getByTestId("cs-start-from-degraded") + def setStartFrom(label: String): Unit = startFromSelect.selectOption(new SelectOption().setLabel(label)) + /** Every Start-From option as (visible label, is it selectable). A mode the selected topics cannot + * honour is rendered `disabled` rather than hidden, so the list is a stable catalog and the flag + * is what varies - which is why both halves are read here rather than just the labels. */ + def startFromOptions: List[(String, Boolean)] = + startFromSelect.locator("option").all().asScala.toList + .map(o => o.innerText().trim -> (o.getAttribute("disabled") == null)) + + def startFromLabels: List[String] = startFromOptions.map(_._1) + def disabledStartFromLabels: List[String] = startFromOptions.filterNot(_._2).map(_._1) + + /** Start From = "About % through the data", set to `percent` of the messages a topic still holds + * (0-100; the model stores the fraction). */ + def setStartFromDataPercent(percent: String): Unit = startFromDataFraction.fill(percent) + + /** Start From = "About % through the time range", set to `percent` of the time a topic still + * covers (0-100; the model stores the fraction). */ + def setStartFromTimePercent(percent: String): Unit = startFromTimeFraction.fill(percent) + + /** Start From = "Specific time", set to `at` in the BROWSER's local zone (same machine as the + * test JVM, so `LocalDateTime.ofInstant(i, ZoneId.systemDefault)` is the right conversion). + * + * The picker is second-granular and rebuilds its Date from ALL six inputs on every change, so + * the fields are filled in coarse-to-fine order and the final `second` write is what commits the + * complete tuple. That last write is nudged through a different value first: React suppresses an + * onChange when the input's value is unchanged, which would otherwise leave the picker holding + * whatever it was seeded with (`new Date()`) whenever the target second happened to match. */ + def setStartFromDateTime(at: java.time.LocalDateTime): Unit = + def part(name: String, value: Int): Unit = + startFromAdditional.locator(s"input[name='$name']").fill(value.toString) + part("year", at.getYear) + part("month", at.getMonthValue) + part("day", at.getDayOfMonth) + part("hour24", at.getHour) + part("minute", at.getMinute) + part("second", if at.getSecond == 0 then 1 else 0) + part("second", at.getSecond) + + /** Start From = "Relative time ago", e.g. `setStartFromRelative(10, "second")`. `unit` is the + * option value: second | minute | hour | day | week | month | year. */ + def setStartFromRelative(value: Int, unit: String): Unit = + startFromAdditional.locator("input[type='number']").fill(value.toString) + startFromAdditional.locator("select").selectOption(new SelectOption().setValue(unit)) + def revealAdvanced(): Unit = advancedToggle.click() def setDeserializer(label: String): Unit = deserializerSelect.selectOption(new SelectOption().setLabel(label)) def addTarget(): Unit = addTargetButton.click() @@ -133,6 +218,20 @@ final case class ConsumerSessionPage(page: Page): playButton.click() def stop(): Unit = stopButton.click() + + // --- the browser-wide delivery controls (localStorage-backed, in the toolbar) --- + val rateLimitInput: Locator = page.getByTestId("cs-rate-limit") + val pauseAfterInput: Locator = page.getByTestId("cs-pause-after") + + /** Commit a rate limit (msgs/second, 0 clears). The input is draft-committed on Enter/blur. */ + def setRateLimit(n: Int): Unit = + rateLimitInput.fill(if n > 0 then n.toString else "") + rateLimitInput.press("Enter") + + /** Commit an auto-pause threshold (messages loaded, 0 clears). */ + def setPauseAfter(n: Int): Unit = + pauseAfterInput.fill(if n > 0 then n.toString else "") + pauseAfterInput.press("Enter") def clickFirstMessage(): Unit = messages.first().click() def searchInResults(t: String): Unit = searchInput.fill(t) // Force-click: the button's own "Toggle additional tools" tooltip can overlay it and intercept a normal click. @@ -150,9 +249,20 @@ final case class ConsumerSessionPage(page: Page): assertThat(messages).hasCount(n, new LocatorAssertions.HasCountOptions().setTimeout(timeoutMs)) /** Wait until the toolbar reports `n` messages loaded. Use this instead of `waitMessages` when n is - * larger than a viewport - the message table is virtualized, so DOM rows != loaded messages. */ + * larger than a viewport - the message table is virtualized, so DOM rows != loaded messages. + * + * The counter renders through `numeral(n).format('0,0')`, so the expected text must carry the + * same thousands separator - `hasText("2000")` against a counter showing "2,000" waited out its + * whole timeout on a session that had in fact finished. */ def awaitLoaded(n: Int, timeoutMs: Double = 30000): Unit = - assertThat(loaded).hasText(n.toString, new LocatorAssertions.HasTextOptions().setTimeout(timeoutMs)) + val expected = java.text.NumberFormat.getIntegerInstance(java.util.Locale.US).format(n.toLong) + assertThat(loaded).hasText(expected, new LocatorAssertions.HasTextOptions().setTimeout(timeoutMs)) + + /** The toolbar's loaded counter as a number, right now. Digits only: it is rendered through + * `numeral(n).format('0,0')`, so a four-figure count carries a thousands separator. */ + def loadedCount: Int = + val digits = loaded.innerText().replaceAll("[^0-9]", "") + digits.toIntOption.getOrElse(throw new AssertionError(s"the loaded counter does not read as a number: '${loaded.innerText()}'")) // --- lifecycle triggers (CS-18) --- def wheelUpOverTable(): Unit = diff --git a/e2e/src/main/scala/features/consumersession/ToolsPanel.scala b/e2e/src/main/scala/features/consumersession/ToolsPanel.scala index cf91e5e17..674a9e566 100644 --- a/e2e/src/main/scala/features/consumersession/ToolsPanel.scala +++ b/e2e/src/main/scala/features/consumersession/ToolsPanel.scala @@ -16,3 +16,26 @@ final case class ToolsPanel(page: Page): val replLogs: Locator = page.getByTestId("cs-repl-logs") val logs: Locator = page.getByTestId("cs-logs") + + // --- Topic Positions (the per-topic debug view) --- + val topicPositionsTab: Locator = page.getByTestId("console-tab-topic-positions") + val topicPositionsTable: Locator = page.getByTestId("topic-positions-table") + val topicPositionsNotStarted: Locator = page.getByTestId("topic-positions-not-started") + val topicPositionsError: Locator = page.getByTestId("topic-positions-error") + + /** A row of the (shared-Table-backed) positions table, matched by its topic cell. */ + def topicPositionsRow(topicFqn: String): Locator = + page.locator("[data-testid='topic-positions'] tbody tr").filter(new Locator.FilterOptions().setHasText(topicFqn)) + + /** The row's cells, in DEFAULT header order (consumption first, then the topic's endpoints): + * 0 topic, 1 entries read (x / y), 2 entries left, 3 % of entries, 4 behind, + * 5 % of time range, 6 first message, 7 first published, 8 last message, + * 9 last published, 10 message under cursor. + */ + def topicPositionsCells(topicFqn: String): Vector[String] = + import scala.jdk.CollectionConverters.* + topicPositionsRow(topicFqn).locator("td").allTextContents().asScala.toVector + + /** Click a sortable header of the positions table by its column key. */ + def topicPositionsSortBy(columnKey: String): Unit = + page.locator(s"[data-testid='topic-positions'] [data-testid='table-th'][data-column-key='$columnKey']").click() diff --git a/e2e/src/main/scala/features/library/LibrarySidebar.scala b/e2e/src/main/scala/features/library/LibrarySidebar.scala index d0fdb763a..bc187d9fc 100644 --- a/e2e/src/main/scala/features/library/LibrarySidebar.scala +++ b/e2e/src/main/scala/features/library/LibrarySidebar.scala @@ -24,8 +24,17 @@ final case class LibrarySidebar(page: Page): def openConsumerSessionsSubtab(): Unit = consumerSessionsSubtab.click() def openAllItemsSubtab(): Unit = allItemsSubtab.click() - /** From the Notes tab: create a note (first-note button when empty, else the "+" new-note button). */ + /** From the Notes tab: create a note (first-note button when empty, else the "+" new-note button). + * + * The panel renders a "Loading..." placeholder until its first `ListLibraryItems` resolves, so + * until then NEITHER button exists. `isVisible` does not wait, so branching on it while that + * fetch is still in flight takes the else-branch and then burns the whole timeout on + * `lib-new-note` - a button that can never appear for a topic with no notes. Waiting for either + * button first is the missing readiness precondition: it makes the branch read a settled panel + * rather than whichever render happened to be on screen. */ def createNote(): Unit = + createFirstNoteButton.or(newNoteButton).first() + .waitFor(new Locator.WaitForOptions().setTimeout(15000)) if createFirstNoteButton.isVisible then createFirstNoteButton.click() else newNoteButton.click() diff --git a/e2e/src/main/scala/harness/PulsarFixtures.scala b/e2e/src/main/scala/harness/PulsarFixtures.scala index 66661ee32..139d442df 100644 --- a/e2e/src/main/scala/harness/PulsarFixtures.scala +++ b/e2e/src/main/scala/harness/PulsarFixtures.scala @@ -2,9 +2,10 @@ package harness import net.datafaker.Faker import org.apache.pulsar.client.admin.PulsarAdmin -import org.apache.pulsar.client.api.{PulsarClient, Schema} +import org.apache.pulsar.client.api.{Message as PulsarMessage, MessageId as PulsarMessageId, PulsarClient, Schema} import org.apache.pulsar.common.policies.data.{ClusterData, ResourceGroup, TenantInfo} +import java.util.concurrent.TimeUnit import scala.collection.mutable import scala.jdk.CollectionConverters.* @@ -99,6 +100,44 @@ class PulsarFixtures: admin.topics().createNonPartitionedTopic(fqn) fqn + /** One quadrant of the topic matrix the consumer session must handle. + * + * NOTE ON NON-PERSISTENT: a non-persistent topic keeps nothing on disk - messages published + * while no consumer is attached are dropped forever. So the "pre-produce, then start from + * Earliest" shape is meaningless there; only produce-AFTER-play can be asserted. `retains` + * encodes that so specs can branch on capability instead of hard-coding topic names. + */ + case class TopicKind(persistent: Boolean, partitions: Int): + def scheme: String = if persistent then "persistent" else "non-persistent" + def isPartitioned: Boolean = partitions > 0 + def retains: Boolean = persistent + def label: String = + s"${if persistent then "persistent" else "non-persistent"}/${if isPartitioned then s"partitioned($partitions)" else "non-partitioned"}" + + object TopicKind: + val PersistentNonPartitioned = TopicKind(persistent = true, partitions = 0) + val PersistentPartitioned = TopicKind(persistent = true, partitions = 3) + val NonPersistentNonPartitioned = TopicKind(persistent = false, partitions = 0) + val NonPersistentPartitioned = TopicKind(persistent = false, partitions = 3) + /** The full matrix the consumer session is expected to support. */ + val all: List[TopicKind] = + List(PersistentNonPartitioned, PersistentPartitioned, NonPersistentNonPartitioned, NonPersistentPartitioned) + + /** Create a topic of the given kind in an existing namespace; returns its FQN. */ + def createTopicOfKind(tenant: String, namespace: String, kind: TopicKind): String = + val topic = unique("topic") + val fqn = s"${kind.scheme}://$tenant/$namespace/$topic" + if kind.isPartitioned then admin.topics().createPartitionedTopic(fqn, kind.partitions) + else admin.topics().createNonPartitionedTopic(fqn) + fqn + + /** Fresh tenant → namespace → topic of the given kind; returns (tenant, namespace, shortTopic, fqn). */ + def freshTopicPartsOfKind(kind: TopicKind): (String, String, String, String) = + val t = createTenant() + val ns = createNamespace(t) + val fqn = createTopicOfKind(t, ns, kind) + (t, ns, fqn.substring(fqn.lastIndexOf('/') + 1), fqn) + /** Convenience: fresh tenant → namespace → topic, returns the topic FQN. */ def freshTopic(): String = val t = createTenant() @@ -119,6 +158,165 @@ class PulsarFixtures: try (1 to n).foreach(i => producer.send(s"msg-$i")) finally producer.close() + /** Bulk produce with ASYNC sends (batched entries, ~100x faster than the blocking loop) - for + * tests that need tens of thousands of messages as scenery, not as per-entry fixtures. */ + def produceStringsFast(topicFqn: String, n: Int): Unit = + val producer = client.newProducer(Schema.STRING).topic(topicFqn).create() + try + (1 to n).foreach(i => producer.sendAsync(s"msg-$i")) + producer.flush() + finally producer.close() + + /** Force-delete a topic out from under its consumers: everything it held, recorded ends + * included, silently stops being deliverable - the retention/trim race the start-from give-up + * window exists for, made absolute and reproducible. (Auto-creation may resurrect the NAME as + * an empty topic; the old ledger never comes back, which is the point.) */ + def forceDeleteTopic(topicFqn: String): Unit = + admin.topics.delete(topicFqn, true) + + // --------------------------------------------------------------------------------------------- + // Batching + // + // A Pulsar broker addresses its log by ENTRY, not by message, and the Java producer batches by + // default - so an ordinary application writes many messages per entry. Everything the suite + // produced before this section went out one-message-per-entry (a blocking `send` per message + // closes each batch immediately), which meant no test could ever observe an entry-vs-message + // confusion. `PulsarAdmin.examineMessage` - what the "skip first n" / "latest n" start-from modes + // are built on - counts ENTRIES, so that gap hid a real defect. These helpers make both shapes + // explicit and provable. + // --------------------------------------------------------------------------------------------- + + /** Broker-side entry count of a NON-partitioned topic (a `-partition-K` FQN counts as one). + * This is the number `examineMessage` indexes into, so `numberOfEntries < messages produced` is + * exactly the condition under which entry-addressing and message-addressing diverge. + * Persistent topics only - a non-persistent topic has no managed ledger and 405s. */ + def numberOfEntries(nonPartitionedTopicFqn: String): Long = + admin.topics().getInternalStats(nonPartitionedTopicFqn).numberOfEntries + + /** How many partitions a topic has, or 0 when it is not partitioned. */ + def partitionCount(topicFqn: String): Int = + admin.topics().getPartitionedTopicMetadata(topicFqn).partitions + + /** Total broker entries behind a topic FQN, summed across partitions when it names a partitioned + * topic - the parent of a partitioned topic has no managed ledger of its own, so asking it for + * internal stats 404s. Partitions that are not materialized yet contribute nothing. */ + def totalEntries(topicFqn: String): Long = + partitionCount(topicFqn) match + case 0 => numberOfEntries(topicFqn) + case n => + (0 until n).map { i => + try numberOfEntries(s"$topicFqn-partition-$i") + catch case _: Throwable => 0L + }.sum + + /** The broker entries added to `topicFqn` since `before`, once the admin counter has SETTLED - two + * reads a short quiescence gap apart that agree. + * + * Every producer ack is already held (and flushed) when this is called, so the writes are durable + * and the counter can only LAG, never grow anew: this is not readiness polling, it is measuring + * the admin counter catch up. The gap matters on a PARTITIONED topic, where the entries land + * across partitions and the counter climbs in STEPS - a single non-zero reading can catch it + * mid-climb and UNDERCOUNT, and an undercount sits below `values.size` even for genuinely + * unbatched output, which is exactly the fixture the batching guard exists to reject. */ + private def settledEntryDelta(topicFqn: String, before: Long): Long = + Eventually.eventually(timeoutMs = 15000, intervalMs = 250) { + val d1 = totalEntries(topicFqn) - before + assert(d1 >= 1L, s"no entries visible yet on $topicFqn") + Thread.sleep(400) // quiescence gap, NOT a readiness wait: every ack is held, so the counter only lags + val d2 = totalEntries(topicFqn) - before + assert(d1 == d2, s"the broker entry counter is still rising on $topicFqn: $d1 then $d2 - reading it mid-climb would undercount") + d2 + } + + /** Produce `values` with batching DISABLED: one message per broker entry, guaranteed. + * The baseline half of every batched/unbatched pair. */ + def produceUnbatched(topicFqn: String, values: Seq[String]): Unit = + val producer = client.newProducer(Schema.STRING).topic(topicFqn).enableBatching(false).create() + try values.foreach(producer.send) + finally producer.close() + + /** Produce `values` so that consecutive groups of `messagesPerBatch` genuinely SHARE one broker + * entry - what the Java client does by default in any real application. + * + * The three settings are all load-bearing: `enableBatching` alone changes nothing if each + * message is sent with a blocking `send` (that flushes a one-message batch every time), so the + * sends must be async and only then flushed; and the publish delay has to be long enough that + * `batchingMaxMessages` - not a timer - is what closes a batch, otherwise a slow box silently + * degrades to singletons. + * + * On a persistent topic the outcome is VERIFIED against `numberOfEntries` and an unbatched + * result THROWS. That guard is the point: a "batched" fixture that quietly produced one-message + * entries would recreate the exact blind spot batched coverage exists to close, and every test + * built on it would keep passing while proving nothing. */ + def produceBatched(topicFqn: String, values: Seq[String], messagesPerBatch: Int): Unit = + require(messagesPerBatch >= 2, s"messagesPerBatch must be >= 2 to batch anything, got $messagesPerBatch") + val verifiable = topicFqn.startsWith("persistent://") // a non-persistent topic has no ledger to count + val isPartitioned = verifiable && partitionCount(topicFqn) > 0 + val before = if verifiable then totalEntries(topicFqn) else -1L + val producer = client + .newProducer(Schema.STRING) + .topic(topicFqn) + .enableBatching(true) + .batchingMaxMessages(messagesPerBatch) + .batchingMaxBytes(4 * 1024 * 1024) // never the binding limit for these short payloads + .batchingMaxPublishDelay(60, TimeUnit.SECONDS) // never the binding limit either: size or flush closes a batch + .create() + try + val acks = values.map(v => producer.sendAsync(v)) + producer.flush() // closes the trailing partial batch + acks.foreach(_.get(60, TimeUnit.SECONDS)) // and every send really landed + finally producer.close() + if verifiable then + val expected = math.ceil(values.size.toDouble / messagesPerBatch).toLong + // The managed-ledger counter is read back through the admin API, so allow it a moment to + // reflect the writes we already hold producer acks for. On a single log the final count is + // known (`expected`), so waiting for exactly that and no more is both sufficient and precise. + // On a PARTITIONED topic the final count is router-dependent and unknown, so the counter must + // be read once it has SETTLED - a `>= 1` reading can catch it mid-climb and undercount, and an + // undercount would pass the batching guard below on genuinely unbatched output. + val added = + if isPartitioned then settledEntryDelta(topicFqn, before) + else + Eventually.eventually(timeoutMs = 10000, intervalMs = 200) { + val d = totalEntries(topicFqn) - before + assert(d >= expected, s"only $d entries visible yet for ${values.size} messages on $topicFqn") + d + } + // THE guard: if the messages did not actually share entries there is no point running any + // batched test on top, because it would be indistinguishable from the unbatched one. + assert( + added < values.size, + s"produceBatched did NOT batch: ${values.size} messages at $messagesPerBatch per batch became " + + s"$added broker entries on $topicFqn - one per message. An unbatched 'batched' fixture proves " + + "nothing; fix the producer settings, do not relax this." + ) + // On a single log the split is fully determined. A partitioned topic batches per partition and + // the router decides where each batch lands, so only the inequality above is guaranteed there. + if !isPartitioned then + assert(added == expected, s"expected exactly $expected entries for ${values.size} messages at $messagesPerBatch per batch, got $added") + + /** Every message currently retained on a NON-partitioned topic, oldest first, read with a + * non-durable Reader (no cursor left behind). The oracle for the id- and time-addressed + * start-from modes: only the broker knows the real message ids and publish times. */ + def readAllMessages(nonPartitionedTopicFqn: String): Vector[PulsarMessage[String]] = + val reader = client + .newReader(Schema.STRING) + .topic(nonPartitionedTopicFqn) + .startMessageId(PulsarMessageId.earliest) + .create() + try + val buf = Vector.newBuilder[PulsarMessage[String]] + while reader.hasMessageAvailable do + val m = reader.readNext(10, TimeUnit.SECONDS) + if m != null then buf += m + buf.result() + finally reader.close() + + /** A message id in the space-separated hex the Start-From "Message with specific ID" input takes + * (`hexStringToByteArray` in ui/components/conversions), e.g. "08 c3 03 10 cd 04 20 00 30 01". */ + def messageIdHex(messageId: PulsarMessageId): String = + messageId.toByteArray.map(b => f"${b & 0xff}%02x").mkString(" ") + /** Best-effort teardown of everything this fixture created. Runs after each test. * Failures are logged (not thrown - teardown must not fail a test) so leaks aren't silent. * Two deliberate softenings (both bit on CI): diff --git a/e2e/src/test/scala/features/consumersession/ConsumerSessionConfigSpec.scala b/e2e/src/test/scala/features/consumersession/ConsumerSessionConfigSpec.scala index 2ee1c70c3..84dd35a08 100644 --- a/e2e/src/test/scala/features/consumersession/ConsumerSessionConfigSpec.scala +++ b/e2e/src/test/scala/features/consumersession/ConsumerSessionConfigSpec.scala @@ -8,12 +8,32 @@ import scala.jdk.CollectionConverters.* class ConsumerSessionConfigSpec extends DekafSuite: private def count(n: Int) = new LocatorAssertions.HasCountOptions().setTimeout(20000) - test("CS-1: Start-From offers all 7 options") { + /** The whole Start-From catalog. Named rather than counted: a count is satisfied by a renamed or + * swapped mode just as happily as by the right one, which is how this assertion previously + * survived a new mode being added without saying anything about it. */ + private val allStartFromModes = List( + "Earliest message", + "Latest message", + "Message with specific ID", + "Specific time", + "Relative time ago", + "Skip first n messages", + "Latest n messages", + // Two approximate modes, not one: "about half way in" is either half the MESSAGES or half the + // TIME, and a single control could not say which. Named in full here because a count would be + // satisfied by one mode renamed to the other's label. + "About % through the data", + "About % through the time range" + ) + + test("CS-1: Start-From offers every mode, all of them selectable on a persistent topic") { val (t, ns, topic) = fixtures.freshTopicParts() - ConsumerSessionPage(page).openForTopic(t, ns, topic) - val opts = page.getByTestId("cs-start-from").locator("option").allTextContents().asScala.toList - assert(opts.size == 7, s"got: $opts") - assert(opts.contains("Earliest message") && opts.contains("Latest message") && opts.contains("Message with specific ID")) + val cs = ConsumerSessionPage(page) + cs.openForTopic(t, ns, topic) + assert(cs.startFromLabels == allStartFromModes, s"got: ${cs.startFromLabels}") + // A persistent topic keeps history, so nothing is greyed out - the counterpart on a + // non-persistent topic is CsStartFromMatrixSpec CS-SFM-2. + assert(cs.disabledStartFromLabels.isEmpty, s"unexpectedly disabled on a persistent topic: ${cs.disabledStartFromLabels}") } test("CS-17: Stop clears the loaded messages") { diff --git a/e2e/src/test/scala/features/consumersession/CsApproximatePartitionedSpec.scala b/e2e/src/test/scala/features/consumersession/CsApproximatePartitionedSpec.scala new file mode 100644 index 000000000..e9815883b --- /dev/null +++ b/e2e/src/test/scala/features/consumersession/CsApproximatePartitionedSpec.scala @@ -0,0 +1,248 @@ +package features.consumersession + +import org.apache.pulsar.client.api.Message as PulsarMessage + +/** The two APPROXIMATE Start-From modes on a PARTITIONED topic - the shape in which their + * definitions actually differ from one another. + * + * `CsStartFromOutcomesSpec` covers both modes on a persistent NON-partitioned topic, where each + * mode sees exactly one physical log; on that shape "resolve every log independently" and "pool one + * answer across the logs" are the same sentence, so neither rule is under test. The production + * wiring (`handleStartFrom`, the two `Approximate*` branches) differs precisely here: + * + * - **% through the DATA** resolves EVERY PHYSICAL TOPIC INDEPENDENTLY: each partition leaves + * `floor(fraction x its own entry count)` entries behind, so a partition holding two entries + * and one holding eight start at different depths. + * - **% through the TIME RANGE** groups the physical topics by their LOGICAL topic, pools + * `min(first publish time)` .. `max(last publish time)` across the group, and seeks EVERY + * partition to that ONE instant - so a partition that went idle early cannot drag the cutoff + * backwards, and a partition that only started late is not given a range of its own. + * + * ONE arrangement drives both, and it is deliberately skewed on BOTH axes at once - the partitions + * hold 8 / 4 / 2 / 0 messages and cover three different stretches of the range: + * + * {{{ + * t = 0s t = 4s t = 8s t = 12s + * p0 a-01..a-04 a-05..a-08 8 messages, the middle of the range + * p1 b-01..b-04 4 messages, then idle for 12s + * p2 c-01 c-02 2 messages, only the late half + * p3 EMPTY - materialized, never written + * }}} + * + * p3 IS THE ONE PARTITION THAT HOLDS NOTHING, and it is here rather than in a unit test because the + * "empty" answer is a fact about the BROKER, not about the arithmetic. `PulsarAdmin.examineMessage` + * does not report an empty topic as an empty range - it FAILS (pinned at the broker level by + * `harness.BatchingFixtureSpec` BATCH-4), so an empty partition reaches the server as an error that + * it has to tell apart from an operational one. Both modes then have to SKIP it: counting it as + * time zero would drag the pooled range's start back to 1970 and put every interior fraction before + * the real data, i.e. turn 50% into Earliest. The pure arithmetic for that rule lives in + * `server/.../consumer/session_runner/approximateTimePositionTest`; what only a real broker can + * show is that the failure is classified as "this partition holds nothing" and not as "the lookup + * broke", and that the session still starts. + * + * An empty partition is arrangeable here for the same reason the skew is: the schedule addresses + * the PHYSICAL partitions directly and simply never names p3. (This spec's doc used to say the + * opposite - that it could not be arranged through the parent's router - which was true of the + * router and irrelevant, since nothing here goes through it.) + * + * The pooled endpoints are each owned by exactly ONE partition, which is what makes the pooling + * rule falsifiable rather than merely satisfied: `min(first)` is p1's t=0 and `max(last)` is p2's + * t=12, so taking the max of the firsts, or the min of the lasts, or skipping an idle partition, + * every one of them moves the cutoff somewhere else. At 50% it lands at t=6s, in the middle of the + * only four-second gap in the arrangement, two seconds clear of the nearest message on either side. + * + * The two modes therefore answer differently, and each differs from the mistake it is exposed to. + * Both facts are asserted BEFORE the UI is driven, so a run can never pass by the answers having + * quietly converged: + * + * - data 50% -> per partition: 4 of 8, 2 of 4 and 1 of 2 entries left behind, which keeps p1's + * later half. A single cut through the POOLED stream would have kept a-04 and c-01 instead. + * - time 50% -> one cutoff at t=6s for EVERY partition: none of p1 (idle since t=0, six seconds + * before the cutoff) and BOTH of p2 (on its own 8s..12s range, 50% would be t=10s and would + * drop c-01). p3 contributes no endpoint at all; counted as time zero it would drag the pooled + * start to 1970 and hand back the whole topic. + * + * Every expectation is derived from the broker - real entry counts and real publish times - rather + * than written down, for the same reason `CsStartFromOutcomesSpec` does it: a hard-coded set would + * encode one particular arrangement as "the" answer instead of the rule. + */ +class CsApproximatePartitionedSpec extends StartFromSupport: + + /** FOUR partitions, spelled out rather than taken from `TopicKind.PersistentPartitioned` (three), + * because the fourth is the empty one and is part of the arrangement. */ + private val kind = fixtures.TopicKind(persistent = true, partitions = 4) + + /** The partition deliberately left holding nothing. */ + private val EmptyPartition = 3 + + /** Real wall clock between the arrangement's groups. Both modes are asserted at 50%, whose cutoff + * lands in the middle of the second gap, so this is also the safety margin: 4s puts the nearest + * message two seconds away and no produce round trip can move one across it. */ + private val GroupGapMs = 4000L + + /** What each partition is given, group by group: the index is which 4s slot it is published in, + * the map is partition -> values. */ + private val schedule: Seq[Map[Int, Seq[String]]] = Seq( + Map(1 -> Seq("b-01", "b-02", "b-03", "b-04")), + Map(0 -> Seq("a-01", "a-02", "a-03", "a-04")), + Map(0 -> Seq("a-05", "a-06", "a-07", "a-08"), 2 -> Seq("c-01")), + Map(2 -> Seq("c-02")) + ) + + /** Publish the schedule straight to the partitions, unbatched (one message per broker ENTRY, so + * the data mode's entry arithmetic and a message count coincide and the expectation stays + * readable), with a real gap between groups. + * + * Addressing the partitions directly is the only way to control the skew: the parent topic's + * router decides where a message lands, and neither "p1 gets four messages and then goes idle" + * nor "p2 only exists in the late half" can be arranged through it. The gaps are ARRANGEMENT, not + * readiness waits - the only way to give a topic a time range is to publish across one. */ + private def arrange(fqn: String): Unit = + var groupStartedAt = System.currentTimeMillis() + schedule.zipWithIndex.foreach { (group, index) => + if index > 0 then + awaitClockGap(groupStartedAt, GroupGapMs) + groupStartedAt = System.currentTimeMillis() + group.toSeq.sortBy(_._1).foreach((partition, values) => fixtures.produceUnbatched(s"$fqn-partition-$partition", values)) + } + + /** Assert the arrangement landed as the class doc describes it, and hand back what each partition + * really holds, oldest first - the oracle both expectations are derived from. A drifted + * arrangement must fail HERE, naming the arrangement, rather than surfacing later as an + * unexplained set mismatch. */ + private def arrangedContents(fqn: String): Vector[Vector[PulsarMessage[String]]] = + // Reading every partition also MATERIALIZES the empty one: `createPartitionedTopic` writes + // metadata, and a partition nothing ever touched may not exist as a topic at all. The reader + // creates it, so the session meets the case this spec is about - a partition the broker knows + // and reports as holding nothing - rather than a missing topic, which is a different failure. + val perPartition = (0 until kind.partitions).toVector.map(p => fixtures.readAllMessages(s"$fqn-partition-$p")) + val arranged = schedule.flatMap(_.toSeq).groupBy(_._1).view.mapValues(_.flatMap(_._2)).toMap + (0 until kind.partitions).foreach { p => + val expected = arranged.getOrElse(p, Seq.empty) + assert(perPartition(p).map(_.getValue) == expected, s"partition $p holds ${perPartition(p).map(_.getValue)}, arranged $expected") + assert( + perPartition(p).map(_.getPublishTime) == perPartition(p).map(_.getPublishTime).sorted, + s"partition $p is not in publish-time order: ${perPartition(p).map(m => m.getValue -> m.getPublishTime)}" + ) + } + // EXACTLY ONE empty partition, and it is the one the arrangement names. Asserted through the + // broker's own entry count as well: "materialized and holding nothing" is the arranged state, + // and a partition that does not exist would fail here, naming itself. + val empties = perPartition.zipWithIndex.filter(_._1.isEmpty).map(_._2) + assert(empties == Vector(EmptyPartition), s"expected partition $EmptyPartition and only it to be empty, empty were: $empties") + val emptyEntries = fixtures.numberOfEntries(s"$fqn-partition-$EmptyPartition") + assert(emptyEntries == 0L, s"partition $EmptyPartition was supposed to hold nothing, the broker reports $emptyEntries entries") + + // The two skews the whole spec rests on, over the partitions that hold something: they must hold + // DIFFERENT numbers of messages, and must cover DIFFERENT stretches of the time range. + val nonEmpty = perPartition.filter(_.nonEmpty) + assert(nonEmpty.map(_.size).distinct.size == nonEmpty.size, s"the counts are not skewed: ${perPartition.map(_.size)}") + val spans = nonEmpty.map(msgs => msgs.head.getPublishTime -> msgs.last.getPublishTime) + assert(spans.distinct.size == nonEmpty.size, s"the time spans are not skewed: $spans") + // ... and exactly one partition owns each pooled endpoint, or the pooling rule is not falsifiable. + assert(spans.count(_._1 == spans.map(_._1).min) == 1, s"the pooled EARLIEST is not owned by one partition: $spans") + assert(spans.count(_._2 == spans.map(_._2).max) == 1, s"the pooled LATEST is not owned by one partition: $spans") + perPartition + + /** Where the data mode starts on ONE partition: leave `floor(fraction x entries)` entries behind. */ + private def dataAnswer(perPartition: Vector[Vector[PulsarMessage[String]]], entriesOf: Int => Long): Vector[String] = + perPartition.zipWithIndex.flatMap { case (msgs, p) => + val entries = entriesOf(p) + assert(entries == msgs.size, s"partition $p: $entries entries for ${msgs.size} unbatched messages") + msgs.drop(math.floor(0.5 * entries).toInt).map(_.getValue) + } + + // ------------------------------------------------------------------------------------------- + + test("CS-SF-20: % through the data resolves EACH PARTITION on its own entry count") { + val (t, ns, topic, fqn) = fixtures.freshTopicPartsOfKind(kind) + arrange(fqn) + val perPartition = arrangedContents(fqn) + + // The contract, applied per physical topic. The entry count comes from the broker even though + // the fixture is unbatched - the mode is defined over ENTRIES, and reading it back is what keeps + // that visible (and asserts the fixture really did write one message per entry). + val expected = dataAnswer(perPartition, p => fixtures.numberOfEntries(s"$fqn-partition-$p")) + assert( + expected.toSet == Set("a-05", "a-06", "a-07", "a-08", "b-03", "b-04", "c-02"), + s"the arrangement no longer produces the documented per-partition answer: $expected" + ) + + // The mistake this cell exists to catch: one cut through the pooled stream instead of one cut + // per partition. Same number of messages, different messages - so only an exact SET separates + // them, and they must genuinely differ or this test proves nothing. + val pooled = perPartition.flatten.sortBy(_.getPublishTime).map(_.getValue).takeRight(expected.size) + assert(pooled.toSet != expected.toSet, s"the pooled answer coincides with the per-partition one: $pooled") + + val cs = ConsumerSessionPage(page) + cs.openForTopic(t, ns, topic, kind.scheme) + cs.setStartFrom("About % through the data") + cs.setStartFromDataPercent("50") + cs.play() + cs.assertState("running") + // EVERY partition, the empty one included, is really part of this session. Without this the + // empty partition would be decorative: a server that dropped it from the target list, or never + // subscribed to it because it could not resolve a position for it, would deliver exactly the + // same messages and pass. + awaitConsumersFlowing(fqn, kind) + assertLoadedExactlyWithCounter(cs, expected) + } + + test("CS-SF-21: % through the time range pools ONE cutoff across the partitions") { + val (t, ns, topic, fqn) = fixtures.freshTopicPartsOfKind(kind) + arrange(fqn) + val perPartition = arrangedContents(fqn) + + // The contract: min over the partitions' FIRST publish times .. max over their LAST, one + // interpolated instant, every partition seeked to it. Over the partitions that HOLD something - + // an empty one has no first and no last, and is skipped rather than counted as time zero. + val nonEmpty = perPartition.filter(_.nonEmpty) + val earliest = nonEmpty.map(_.head.getPublishTime).min + val latest = nonEmpty.map(_.last.getPublishTime).max + val cutoff = earliest + math.floor(0.5 * (latest - earliest)).toLong + val all = perPartition.flatten + // The cutoff has to fall in a GAP, or a produce round trip decides the outcome instead of the rule. + assert( + all.forall(m => math.abs(m.getPublishTime - cutoff) > 1000), + s"the cutoff $cutoff is within a second of a message: ${all.map(m => m.getValue -> m.getPublishTime)}" + ) + val expected = all.filter(_.getPublishTime >= cutoff).map(_.getValue) + assert( + expected.toSet == Set("a-05", "a-06", "a-07", "a-08", "c-01", "c-02"), + s"the arrangement no longer produces the documented pooled answer: $expected" + ) + + // The mistake this cell exists to catch: a range per PARTITION instead of one per logical topic. + // p1 went idle at t=0 and the pooled cutoff leaves all of it behind; on a range of its own its + // later half comes back. p2 started at t=8 and the pooled cutoff keeps both of its messages; on + // a range of its own 50% is t=10 and c-01 is dropped. + val perPartitionAnswer = nonEmpty.flatMap { msgs => + val first = msgs.head.getPublishTime + val last = msgs.last.getPublishTime + if last <= first then msgs.map(_.getValue) // a partition occupying one instant resolves to Earliest + else msgs.filter(_.getPublishTime >= first + math.floor(0.5 * (last - first)).toLong).map(_.getValue) + } + assert(perPartitionAnswer.toSet != expected.toSet, s"a per-partition range gives the same answer here: $perPartitionAnswer") + // And the sibling mode must not coincide either, or a session that ran the wrong one would pass. + val byData = dataAnswer(perPartition, p => perPartition(p).size.toLong) + assert(byData.toSet != expected.toSet, s"the data mode gives the same answer here: $byData") + // The mistake the EMPTY partition exposes: counting it as time zero rather than skipping it. + // Its "first publish time" would be the epoch, so the pooled range would start in 1970 and 50% + // of it lands decades before this topic existed - i.e. the whole topic comes back. Derived, not + // asserted as a hunch: this is the position such an implementation would seek to. + val emptyAsTimeZeroCutoff = math.floor(0.5 * latest).toLong + val emptyAsTimeZeroAnswer = all.filter(_.getPublishTime >= emptyAsTimeZeroCutoff).map(_.getValue) + assert( + emptyAsTimeZeroAnswer.toSet != expected.toSet, + s"counting the empty partition as time zero gives the same answer here, so this arrangement cannot catch it: $emptyAsTimeZeroAnswer" + ) + + val cs = ConsumerSessionPage(page) + cs.openForTopic(t, ns, topic, kind.scheme) + cs.setStartFrom("About % through the time range") + cs.setStartFromTimePercent("50") + cs.play() + cs.assertState("running") + awaitConsumersFlowing(fqn, kind) // including the empty partition - see CS-SF-20 + assertLoadedExactlyWithCounter(cs, expected) + } diff --git a/e2e/src/test/scala/features/consumersession/CsConsoleSpec.scala b/e2e/src/test/scala/features/consumersession/CsConsoleSpec.scala index f147b4d10..c47de8fc9 100644 --- a/e2e/src/test/scala/features/consumersession/CsConsoleSpec.scala +++ b/e2e/src/test/scala/features/consumersession/CsConsoleSpec.scala @@ -3,6 +3,8 @@ package features.consumersession import harness.DekafSuite import com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat import com.microsoft.playwright.assertions.LocatorAssertions +import java.util.regex.Pattern +import scala.collection.mutable class CsConsoleSpec extends DekafSuite: @@ -17,7 +19,9 @@ class CsConsoleSpec extends DekafSuite: assertThat(tools.replTab).isVisible() assertThat(tools.logsTab).isVisible() - assertThat(tools.produceSend).isVisible() // Produce is the default active tab + // Topic Positions is the default active tab - the "where am I?" view leads. + assertThat(tools.topicPositionsNotStarted).isVisible() + tools.produceTab.click(); assertThat(tools.produceSend).isVisible() tools.replTab.click(); assertThat(tools.replRun).isVisible() tools.logsTab.click(); assertThat(page.getByText("logDebug")).isVisible() } @@ -50,6 +54,40 @@ class CsConsoleSpec extends DekafSuite: assertThat(tools.replClear).isDisabled() } + // Monaco is NOT part of the JS bundle: `@monaco-editor/react` fetches it at runtime through its + // own AMD loader, whose default base is `cdn.jsdelivr.net`. Every code editor in Dekaf therefore + // used to depend on the public internet - seconds of third-party network before the first editor + // appeared, and no editor at all offline, air-gapped, or behind a proxy that blocks the CDN. + // `ui/build.js` now copies `monaco-editor/min/vs` next to the bundle and `CodeEditor.tsx` points + // the loader at that same-origin path. + // + // Nothing pinned it, because a runner with working internet cannot tell "served by Dekaf" from + // "downloaded from jsdelivr" - both mount an editor. Blocking the CDN outright is what makes the + // difference observable, and makes this a permanent regression rather than a property of the + // network the run happened to have. + test("CS-33: a code editor mounts with the public CDN blocked, from Dekaf's own origin") { + val requested = mutable.ListBuffer.empty[String] + page.onRequest(r => requested += r.url()) + // Not merely observed - REFUSED. On a runner with internet a regression would silently succeed + // through the CDN, and the assertion below would pass while the offline case stayed broken. + page.route(Pattern.compile(".*jsdelivr.*"), _.abort()) + + val (t, ns, topic) = fixtures.freshTopicParts() + val cs = ConsumerSessionPage(page) + cs.openForTopic(t, ns, topic) + cs.openTools() + ToolsPanel(page).produceTab.click() // Topic Positions is the default tab now // the Produce tab is default-active and mounts a CodeEditor for the value + + val editor = page.getByTestId("produce-value").locator(".monaco-editor").first() + assertThat(editor).isVisible(new LocatorAssertions.IsVisibleOptions().setTimeout(30000)) + + // The editor rendering is necessary but not sufficient: assert WHERE Monaco came from. + val fromDekaf = requested.toList.filter(_.contains("/ui/static/dist/vs/")) + assert(fromDekaf.nonEmpty, s"Monaco was not fetched from Dekaf's own origin; requests: ${requested.toList}") + val fromCdn = requested.toList.filter(_.contains("jsdelivr")) + assert(fromCdn.isEmpty, s"the editor still reaches for the public CDN: $fromCdn") + } + test("CS-32: Context Logs render (empty-state placeholder for logDebug output)") { val (t, ns, topic) = fixtures.freshTopicParts() val cs = ConsumerSessionPage(page) diff --git a/e2e/src/test/scala/features/consumersession/CsDeliveryControlsSpec.scala b/e2e/src/test/scala/features/consumersession/CsDeliveryControlsSpec.scala new file mode 100644 index 000000000..9c71ef163 --- /dev/null +++ b/e2e/src/test/scala/features/consumersession/CsDeliveryControlsSpec.scala @@ -0,0 +1,102 @@ +package features.consumersession + +import harness.DekafSuite + +/** The two browser-wide delivery controls in the toolbar: "msg/s limit" and "pause after". + * + * Both live in localStorage and ride the session's requests - the rate on each Resume, the + * auto-pause purely client-side - so neither is part of the session's saved definition. What only + * an end-to-end test can see: that the number typed into the toolbar actually SLOWS a real + * session against a real broker, and that the auto-pause lands the session in `paused` near the + * threshold rather than merely somewhere. + * + * THE RATE IS A BAND, THE BUDGET IS EXACT - deliberately different assertions. A rate of 100/s + * starts with a full one-second burst (by design: the first screenful paints at once) and the UI + * flushes every 250ms, so a fixed observation window can only assert a corridor. "Pause after n" + * carries a server-side delivery budget, so its count is asserted with EQUALITY: exactly n, then + * exactly n more. The offline arithmetic lives in deliveryRateLimiterTest and deliveryBudgetTest; + * what this spec pins is that the whole path is wired against a real broker. + */ +class CsDeliveryControlsSpec extends DekafSuite: + + /** Every test leaves the browser-wide settings OFF, so no later spec inherits a throttle. */ + private def clearControls(cs: ConsumerSessionPage): Unit = + cs.setRateLimit(0) + cs.setPauseAfter(0) + + test("DC-1: the rate limit slows a real session, and clearing it restores full speed") { + val (t, ns, topic) = fixtures.freshTopicParts() + fixtures.produceStrings(s"persistent://$t/$ns/$topic", 2000) + + val cs = ConsumerSessionPage(page) + cs.openForTopic(t, ns, topic) + try + cs.setRateLimit(100) + cs.setStartFrom("Earliest message") + cs.play() + + // ~4s at 100/s: the burst (100) plus ~400 paced, with generous slack for the stack under + // load. The line that matters is the ceiling: WELL under the 2000 an unlimited session + // loads in this window (DC-1's second half proves that below, on the same topic). + page.waitForTimeout(4000) + val limited = cs.loadedCount + assert(cs.state == "running", s"expected a throttled session to still be running, got '${cs.state}'") + assert(limited >= 100, s"the first second's burst should have painted at least the rate, got $limited") + // The ideal is ~500 (the 100 burst + ~4s at 100/s). 800 tolerates stack jitter but convicts + // a limiter running 2x fast or worse; the old 1500 ceiling waved a 3x-broken limiter through. + assert(limited <= 800, s"a 100/s limit should not have loaded $limited messages in ~4s") + + // Clear the limit and restart: the same topic must now load completely FASTER than the + // throttled run's theoretical minimum ever could - a still-active 100/s limiter needs ~19s + // for the remaining 1900, so finishing inside 12s proves the limit is genuinely off. + cs.stop() + cs.setRateLimit(0) + cs.play() + cs.awaitLoaded(2000, timeoutMs = 12000) + finally clearControls(cs) + } + + test("DC-2: pause-after lands the session in `paused` near the threshold, and Play re-arms it") { + val (t, ns, topic) = fixtures.freshTopicParts() + fixtures.produceStrings(s"persistent://$t/$ns/$topic", 2000) + + val cs = ConsumerSessionPage(page) + cs.openForTopic(t, ns, topic) + try + // The rate limit keeps the overshoot small: at 200/s, the chunks that land while the pause + // RPC is in flight are tens of messages, not the rest of the topic. + cs.setRateLimit(200) + cs.setPauseAfter(300) + cs.setStartFrom("Earliest message") + cs.play() + + // EXACTLY 300, not a band: the server-side delivery budget stops the stream at the + // message that spends the last unit, so no chunk latency and no rate-limit burst can + // overshoot it. The client's pause lands after; the count is already settled. + cs.assertState("paused", timeoutMs = 30000) + val firstStop = cs.loadedCount + assert(firstStop == 300, s"'pause after 300' must load exactly 300, got $firstStop") + + // Play again: both halves re-arm - the client threshold at "current + n", the server budget + // at n more - so Play behaves as "give me exactly 300 more". + cs.play() + cs.assertState("paused", timeoutMs = 30000) + val secondStop = cs.loadedCount + assert(secondStop == 600, s"the re-armed budget must land at exactly 600, got $secondStop") + finally clearControls(cs) + } + + test("DC-3: both controls are REMEMBERED across a reload - they belong to the browser") { + val (t, ns, topic) = fixtures.freshTopicParts() + val cs = ConsumerSessionPage(page) + cs.openForTopic(t, ns, topic) + try + cs.setRateLimit(123) + cs.setPauseAfter(456) + + page.reload() + val reloaded = ConsumerSessionPage(page) + assert(reloaded.rateLimitInput.inputValue() == "123", s"rate limit lost on reload: '${reloaded.rateLimitInput.inputValue()}'") + assert(reloaded.pauseAfterInput.inputValue() == "456", s"pause-after lost on reload: '${reloaded.pauseAfterInput.inputValue()}'") + finally clearControls(ConsumerSessionPage(page)) + } diff --git a/e2e/src/test/scala/features/consumersession/CsDetailsSpec.scala b/e2e/src/test/scala/features/consumersession/CsDetailsSpec.scala index 06794228e..c63431d2d 100644 --- a/e2e/src/test/scala/features/consumersession/CsDetailsSpec.scala +++ b/e2e/src/test/scala/features/consumersession/CsDetailsSpec.scala @@ -2,9 +2,17 @@ package features.consumersession import harness.DekafSuite import com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat +import com.microsoft.playwright.assertions.LocatorAssertions +import org.apache.pulsar.client.api.{Schema, SubscriptionInitialPosition} +import java.util.concurrent.TimeUnit import java.util.regex.Pattern +import scala.jdk.CollectionConverters.* class CsDetailsSpec extends DekafSuite: + private def hasText = new LocatorAssertions.HasTextOptions().setTimeout(20000) + private def containsText = new LocatorAssertions.ContainsTextOptions().setTimeout(20000) + private def count(n: Int) = new LocatorAssertions.HasCountOptions().setTimeout(20000) + private def loadedPaused(n: Int): ConsumerSessionPage = val (t, ns, topic) = fixtures.freshTopicParts() fixtures.produceStrings(s"persistent://$t/$ns/$topic", n) @@ -17,6 +25,85 @@ class CsDetailsSpec extends DekafSuite: cs.assertState("paused") cs + // CS-23 used to assert only that the panel OPENS - a panel rendering an empty shell, or another + // message entirely, passed. It now pins the panel's CONTENT, tab by tab, against what the broker + // actually stored (read back with the Pulsar client) rather than against the UI's own table view. + test("CS-23: MessageDetails shows the published key, value, metadata and properties") { + val (t, ns, topic) = fixtures.freshTopicParts() + val fqn = s"persistent://$t/$ns/$topic" + + val key = "cs23-key" + val value = "cs23-value-payload" // no spaces: survives Monaco's tokenized rendering verbatim + val producerName = fixtures.unique("cs23-producer") + val properties = Map("cs23-prop-a" -> "alpha", "cs23-prop-b" -> "beta") + + val producer = client.newProducer(Schema.STRING).producerName(producerName).topic(fqn).create() + try + val builder = producer.newMessage().key(key).value(value) + properties.foreach { case (k, v) => builder.property(k, v) } + builder.send() + finally producer.close() + + // GROUND TRUTH: read the message back off the broker. Everything below is asserted against + // THIS, so a UI that renders its own stale copy (or the wrong message) can't pass. + val oracleConsumer = client.newConsumer(Schema.STRING) + .topic(fqn) + .subscriptionName(fixtures.unique("cs23-oracle")) + .subscriptionInitialPosition(SubscriptionInitialPosition.Earliest) + .subscribe() + val stored = + try + val m = oracleConsumer.receive(15, TimeUnit.SECONDS) + assert(m != null, "the oracle consumer received no message") + (m.getKey, m.getValue, m.getProducerName, m.getProperties.asScala.toMap) + finally oracleConsumer.close() + val (storedKey, storedValue, storedProducer, storedProps) = stored + assert( + stored == (key, value, producerName, properties), + s"the broker stored something else than we published: $stored" + ) + + val cs = ConsumerSessionPage(page) + cs.openForTopic(t, ns, topic) + cs.setStartFrom("Earliest message") + cs.play() + cs.waitMessages(1) + cs.pauseFromToolbar() + cs.assertState("paused") + + cs.clickFirstMessage() + assertThat(cs.messageDetails).isVisible() + + // --- Value tab (the default one) --- + // A STRING key/value crosses the wire JSON-encoded (messageConverters: `msg.getKey.asJson`), + // so the panel renders it JSON-quoted - assert that exact rendering, not a substring. + assertThat(cs.messageDetails.getByTestId("cs-cell-key")).hasText(s"\"$storedKey\"", hasText) + // The value itself is shown in a Monaco viewer (JsonView), so assert its rendered text. + assertThat(cs.messageDetails.locator(".monaco-editor")).containsText(storedValue, containsText) + + // --- Metadata tab --- + cs.messageDetails.getByTestId("cs-details-tab-metadata").click() + assertThat(cs.messageDetails.getByTestId("cs-cell-topic")).hasText(fqn, hasText) + assertThat(cs.messageDetails.getByTestId("cs-cell-key")).hasText(s"\"$storedKey\"", hasText) + // The producer name is broker-reported, and we pinned it on the producer - so it is a real + // metadata round-trip, not a value the UI could have echoed from the row it was clicked on. + assertThat(cs.messageDetails).containsText(storedProducer, containsText) + + // --- Properties tab --- + val propertiesTab = cs.messageDetails.getByTestId("cs-details-tab-properties") + assertThat(propertiesTab).hasText(s"Properties ${storedProps.size}", hasText) // count in the tab title + propertiesTab.click() + // Only the active tab is mounted, so these are exactly the read-only property key/value inputs. + val propertyInputs = cs.messageDetails.locator("input") + assertThat(propertyInputs).hasCount(storedProps.size * 2, count(storedProps.size * 2)) + val shown = propertyInputs.all().asScala.toList + .map(_.inputValue()) + .grouped(2) + .map(pair => pair.head -> pair.last) + .toMap + assert(shown == storedProps, s"the Properties tab showed $shown, the broker has $storedProps") + } + test("CS-24: MessageDetails closes only via its close button") { val cs = loadedPaused(3) cs.clickFirstMessage() diff --git a/e2e/src/test/scala/features/consumersession/CsExportSpec.scala b/e2e/src/test/scala/features/consumersession/CsExportSpec.scala index 154232e0f..6afe62b03 100644 --- a/e2e/src/test/scala/features/consumersession/CsExportSpec.scala +++ b/e2e/src/test/scala/features/consumersession/CsExportSpec.scala @@ -1,6 +1,7 @@ package features.consumersession import harness.DekafSuite +import com.fasterxml.jackson.databind.{JsonNode, ObjectMapper} import com.microsoft.playwright.Download import com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat import org.apache.pulsar.client.api.Schema @@ -9,11 +10,72 @@ import scala.jdk.CollectionConverters.* class CsExportSpec extends DekafSuite: /** Export runs JSON.parse(message.value) - values MUST be valid JSON, else the export throws. */ - private def produceJson(fqn: String, n: Int): Unit = + private def produceKeyedJson(fqn: String, keyed: Seq[(String, String)]): Unit = val p = client.newProducer(Schema.STRING).topic(fqn).create() - try (0 until n).foreach(i => p.send(s"""{"n":$i}""")) + try keyed.foreach((k, v) => p.newMessage().key(k).value(v).send()) finally p.close() + /** A JSON string literal for `s` - the exact encoding both the server (circe `noSpaces`) and the + * exporter (`JSON.stringify`) emit, so expectations can be built from the produced values. */ + private def jsonQuoted(s: String): String = + "\"" + s.replace("\\", "\\\\").replace("\"", "\\\"") + "\"" + + /** Every non-directory entry of a downloaded .zip, as name -> UTF-8 text. */ + private def zipEntries(download: Download): Map[String, String] = + val zipPath = java.nio.file.Files.createTempFile("cs-export", ".zip") + download.saveAs(zipPath) + fixtures.onCleanup(() => java.nio.file.Files.deleteIfExists(zipPath)) + val zip = new java.util.zip.ZipFile(zipPath.toFile) + try + zip.entries().asScala + .filterNot(_.isDirectory) + .map(e => e.getName -> new String(zip.getInputStream(e).readAllBytes(), java.nio.charset.StandardCharsets.UTF_8)) + .toMap + finally zip.close() + + /** ONE exported message, reduced to the fields a test can predict. Everything else in the + * descriptor is the broker's (message id, publish/event times, size, producer name, sequence id) + * and is deliberately not pinned. */ + private case class Exported(index: Int, key: String, value: String, topic: String) + + private val mapper = new ObjectMapper() + + /** Parse an exported "message per array entry" file into records. + * + * PARSING is the point. The previous version searched the concatenated file text for each index, + * each key and each value INDEPENDENTLY - which is satisfied by an export that paired message 1's + * key with message 4's value, or wrote every field of every message into one record, as long as + * all the substrings appeared somewhere. Records make the association itself assertable. */ + private def exportedMessages(fileText: String): List[Exported] = + val root = mapper.readTree(fileText) + assert(root.isArray, s"the exported file is not a JSON array:\n$fileText") + root.elements().asScala.toList.map { node => + def field(name: String): JsonNode = + val v = node.get(name) + assert(v != null && !v.isNull, s"exported message has no '$name':\n${node.toString}") + v + // Each of these also pins the ENCODING LEVEL, which the recorded values below depend on: a + // string field that stopped being a JSON string, or an index that became one, fails here + // rather than quietly comparing something else. + def jsonString(name: String): String = + val v = field(name) + assert(v.isTextual, s"'$name' was expected to be a JSON string, got ${v.toString}") + v.asText + val index = field("index") + assert(index.isInt, s"'index' is not a JSON number: ${index.toString}") + Exported( + index = index.intValue, + // Both of these arrive at the browser JSON-ENCODED, and the exporter treats them + // differently: `value` is JSON.parse'd once (so it comes back out as the produced text), + // `key` is not (so it comes back out still carrying its own quotes). Pin what is really + // written rather than what one might assume - `asText` peels exactly one JSON string level + // off each, and `isTextual` above is what stops that from hiding a change. + key = jsonString("key"), + value = jsonString("value"), + topic = jsonString("topic") + ) + } + private def loadedPaused(t: String, ns: String, topic: String, n: Int): ConsumerSessionPage = val cs = ConsumerSessionPage(page) cs.openForTopic(t, ns, topic) @@ -24,10 +86,21 @@ class CsExportSpec extends DekafSuite: cs.assertState("paused") cs - test("CS-28: Export modal offers 4 formats and downloads a .zip containing the messages") { + // Previously this asserted 4 formats + that indices 1..5 appeared somewhere in the .zip - an + // exporter that dropped every value, or exported the wrong topic's messages, still passed. It now + // asserts the exported BYTES: exact content for the value-only format, and the parsed RECORDS - + // (index, key, value, topic) per message, in order - for the default full-descriptor format. + // + // Records rather than substrings because the fields are what a broken exporter mixes up. Every key + // and every value is DISTINCT and their positions differ (key i is the i-th key, value i the i-th + // value), so a record-for-record comparison fails on a swap that a "does the file contain this + // string" search cannot see at all. + test("CS-28: Export modal offers 4 formats and the .zip carries exactly the produced messages") { val (t, ns, topic) = fixtures.freshTopicParts() val fqn = s"persistent://$t/$ns/$topic" - produceJson(fqn, 5) + val keys = (1 to 5).map(i => s"cs28-key-$i").toList + val values = (1 to 5).map(i => s"""{"n":$i}""").toList + produceKeyedJson(fqn, keys.zip(values)) val cs = loadedPaused(t, ns, topic, 5) cs.exportOpen.click() @@ -36,25 +109,39 @@ class CsExportSpec extends DekafSuite: assert(modal.formatOptionCount == 4, s"expected 4 formats, got ${modal.formatOptionCount}") assertThat(modal.fieldRows.first()).isVisible() // field-config list present (reorder disabled - see NOTES) + // --- "value per array entry": the file is nothing but the values, so pin it EXACTLY - a + // dropped, duplicated, reordered or mangled message all fail here. The messages sort by + // publishTime ascending (stable), i.e. production order. + modal.selectFormat("json-value-per-entry") + val valueEntries = zipEntries(page.waitForDownload(() => modal.runButton.click())) + assert(valueEntries.size == 1, s"expected one exported file, got ${valueEntries.keys.toList}") + val expectedValuesJson = values.map(jsonQuoted).mkString("[", ",", "]") + assert( + valueEntries.head._2 == expectedValuesJson, + s"exported values were:\n${valueEntries.head._2}\nexpected:\n$expectedValuesJson" + ) + + // --- default "message per array entry": full descriptors. Timestamps and message ids are + // non-deterministic, so the exported file is PARSED and compared record for record on the + // fields that are: index, key, value and topic. + modal.selectFormat("json-message-per-entry") val download: Download = page.waitForDownload(() => modal.runButton.click()) assert(download.suggestedFilename().endsWith(".zip"), download.suggestedFilename()) + val entries = zipEntries(download) + // One file, and its NAME is the index range it holds: the exporter chunks by size and names each + // chunk `-.json`, inside a timestamped export folder. + assert(entries.size == 1, s"expected one exported file, got ${entries.keys.toList}") + val (entryName, entryText) = entries.head + assert(entryName.endsWith("/1-5.json"), s"unexpected exported file name: $entryName") - val zipPath = java.nio.file.Files.createTempFile("cs-export", ".zip") - download.saveAs(zipPath) - fixtures.onCleanup(() => java.nio.file.Files.deleteIfExists(zipPath)) - - val zip = new java.util.zip.ZipFile(zipPath.toFile) - try - val text = zip.entries().asScala - .filterNot(_.isDirectory) - .map(e => new String(zip.getInputStream(e).readAllBytes(), java.nio.charset.StandardCharsets.UTF_8)) - .mkString("\n") - assert(text.nonEmpty, "empty export") - // The default format exports the full message descriptor; the raw value is JSON-escaped inside - // a string field, so assert the (unescaped) per-message index 1..5 is present. - (1 to 5).foreach(i => - assert(text.contains(s"\"index\":$i"), s"missing message index $i in export")) - finally zip.close() + val exported = exportedMessages(entryText) + val expected = keys.zip(values).zipWithIndex.map { case ((k, v), i) => + Exported(index = i + 1, key = jsonQuoted(k), value = v, topic = fqn) + } + assert( + exported == expected, + s"the exported messages are not the produced ones.\n exported: $exported\n expected: $expected" + ) } test("CS-29: export config persists across reopen") { diff --git a/e2e/src/test/scala/features/consumersession/CsFlowControlSpec.scala b/e2e/src/test/scala/features/consumersession/CsFlowControlSpec.scala new file mode 100644 index 000000000..0ef5874dd --- /dev/null +++ b/e2e/src/test/scala/features/consumersession/CsFlowControlSpec.scala @@ -0,0 +1,170 @@ +package features.consumersession + +import harness.DekafSuite +import com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat +import com.microsoft.playwright.assertions.LocatorAssertions + +/** The start-from flow-control and guard behaviors only a REAL broker can prove. + * + * The merge's watermark arithmetic, the give-up window and the guards are all pinned by the + * server suite with injected clocks and plain values. What no unit test can reach is the LAST + * HOP of each: `consumer.pause()` issued from inside an armed MessageListener callback while the + * broker keeps dispatching (CS-FC-1), the give-up degradation travelling broker -> merge -> + * progress frame -> STICKY banner (CS-FC-2), and the creation-time refusals surfacing as a + * user-visible error rather than a silent wrong session (CS-FC-3/4). + * + * WHY NOT A DISPATCH-RATE THROTTLE: the obvious way to starve one stream is a namespace + * dispatch rate, and it was tried first - this standalone broker does not enforce it (verified + * with the broker's own CLI consumer sailing through a 1-msg/10s policy), with or without the + * `dispatchThrottlingOnNonBacklogConsumerEnabled` / `preciseDispatcherFlowControl` flags. The + * scenarios below use levers that are deterministic here instead: a LARGE OLD BACKLOG whose + * drop phase takes real seconds (pause pressure without any silence), and a FORCE-DELETE of a + * topic while the session is paused (absolute silence - a deleted ledger cannot deliver its + * recorded end, whatever any cursor believes; admin cursor jumps proved unreliable on + * NonDurable subscriptions). + */ +class CsFlowControlSpec extends DekafSuite: + private def vis(ms: Int) = new LocatorAssertions.IsVisibleOptions().setTimeout(ms.toDouble) + + test("CS-FC-1: the per-stream watermark engages under the armed listener, and the exact count survives") { + // B: 30,000 OLD messages. A: 3,000 NEW ones. Global skip of 31,995 must drop every B message + // FIRST (they are globally oldest), which takes real seconds - and in that window all of A + // arrives and can do nothing but QUEUE, sailing past the per-stream watermark (1,000), so + // A's consumer is paused from inside its own listener callback and resumed as the queue + // drains, cycling until the budget lands. What this pins end to end: pausing a consumer + // mid-listener neither deadlocks the session (a hang fails the await) nor loses or + // double-counts anything (the final count is EXACT: 31,995 dropped of 33,000 leaves 1,005, + // independent of every timing). What it deliberately does not claim: that a silently + // non-pausing consumer.pause() would be detected - that would only weaken the memory bound, + // which no black-box assertion can see; the hook INVOCATION is pinned by the ordering-layer + // unit suite. + val (tA, nsA, topicA) = fixtures.freshTopicParts() + val fqnA = s"persistent://$tA/$nsA/$topicA" + val (tB, nsB, topicB) = fixtures.freshTopicParts() + val fqnB = s"persistent://$tB/$nsB/$topicB" + fixtures.produceStringsFast(fqnB, 30000) // first, so B is the globally-oldest block + fixtures.produceStrings(fqnA, 3000) + + val cs = ConsumerSessionPage(page) + cs.openForTopic(tA, nsA, topicA) + cs.setTargetTopicsSpecific(Seq(fqnA, fqnB)) + cs.setStartFrom("Skip first n messages") + cs.startFromN.fill("31995") + cs.play() + cs.assertState("running") + + cs.awaitLoaded(1005, timeoutMs = 120000) + page.waitForTimeout(1500) // anything past 1,005 would arrive right behind it + assert(cs.loadedCount == 1005, s"exactly 1005 must remain after skipping 31995 of 33000, got ${cs.loadedCount}") + + // Slow positioning is not degraded positioning: every stream kept speaking. + assertThat(cs.startFromDegradedBanner).not().isVisible() + } + + test("CS-FC-2: a stream whose recorded end stops being deliverable degrades VISIBLY after the give-up window") { + // The retention/trim race, reproduced without racing anything: B holds 200,000 messages, far + // more than the drop phase can consume before the session is PAUSED moments after it starts. + // With every consumer frozen, B's subscription cursor is jumped past its whole backlog - the + // recorded end silently stops being deliverable - and the session resumes into at most a + // prefetched tail followed by permanent silence on a still-waited stream. The give-up window + // (30s, granted FRESH at resume - paused time proves nothing) expires, the merge abandons B, + // and the session must SAY SO: the sticky best-effort banner, carried on the very frames the + // give-up drain emits. The budget (150,000) is deliberately unspendable, so the banner - not + // delivery - is the observable outcome, exactly like a real over-sized skip against a topic + // that retention trimmed mid-positioning. + val (tA, nsA, topicA) = fixtures.freshTopicParts() + val fqnA = s"persistent://$tA/$nsA/$topicA" + val (tB, nsB, topicB) = fixtures.freshTopicParts() + val fqnB = s"persistent://$tB/$nsB/$topicB" + // A MILLION, deliberately: the merge's drop phase runs at up to a few hundred thousand + // messages per second, so anything smaller can be fully consumed - recorded end delivered, + // stream no longer waited - before a UI-timed pause can possibly land. At this size the + // pause is guaranteed to catch B mid-backlog, whatever the machine's speed. + fixtures.produceStringsFast(fqnB, 1000000) + fixtures.produceStrings(fqnA, 100) + + val cs = ConsumerSessionPage(page) + cs.openForTopic(tA, nsA, topicA) + cs.setTargetTopicsSpecific(Seq(fqnA, fqnB)) + cs.setStartFrom("Skip first n messages") + cs.startFromN.fill("600000") + cs.play() + cs.assertState("running") + + // Freeze the world, then make the backlog unreachable for good. The pre-pause second drops + // at most a couple hundred thousand of B's million (harmless - the budget stays unspendable + // and B stays mid-backlog, so it is still WAITED on), and force-deleting B destroys the + // rest, recorded end included. Auto-creation may resurrect the NAME as an empty topic; the + // old ledger never comes back, which is exactly the trimmed-partition condition. + cs.pauseFromToolbar() + cs.assertState("paused") + fixtures.forceDeleteTopic(fqnB) + cs.play() + cs.assertState("running") + + // Give-up at ~30s of post-resume silence (+ the 2s sweep cadence). + assertThat(cs.startFromDegradedBanner).isVisible(vis(75000)) + val banner = cs.startFromDegradedBanner.textContent() + assert(banner.contains("Best effort"), s"unexpected banner text: '$banner'") + assert(banner.contains("1 stream"), s"the banner should count the abandoned streams, got: '$banner'") + + // Nothing was delivered - the budget is unspendable by design - and the session is still + // alive and positioning, not crashed: degradation is a disclosure, never a failure. + assert(cs.loadedCount == 0, s"an unspendable budget must deliver nothing, got ${cs.loadedCount}") + assert(cs.state == "running", s"a degraded session keeps running, got '${cs.state}'") + + // Stop EXPLICITLY: this session's B-consumer is reconnect-looping against a deleted topic, + // and leaving it running turns the server log into a firehose for the rest of the suite. + cs.stop() + } + + test("CS-FC-3: skip-n REFUSES overlapping targets with a visible reason - and per-view modes still work") { + // Two enabled targets, both "Current Topic": a counted skip cannot mean anything predictable + // over duplicated subscriptions (one shared budget, two copies of every message), so Play + // must fail with the reason on screen. The SAME session with a plain mode then works, and + // shows each target's own copy - the per-view contract, pinned end to end. + val (t, ns, topic) = fixtures.freshTopicParts() + fixtures.produceStrings(s"persistent://$t/$ns/$topic", 3) + + val cs = ConsumerSessionPage(page) + cs.openForTopic(t, ns, topic) + cs.addTarget() + cs.setStartFrom("Skip first n messages") + cs.startFromN.fill("1") + cs.play() + + val refusal = page.getByText( + java.util.regex.Pattern.compile("two enabled targets select the same topic", java.util.regex.Pattern.CASE_INSENSITIVE) + ).first() + assertThat(refusal).isVisible(vis(15000)) + + // The refusal is MODE-specific: Earliest on the same two targets delivers each target's own + // counted set - three messages, two views, six rows. + cs.setStartFrom("Earliest message") + cs.play() + cs.awaitLoaded(6) + } + + test("CS-FC-4: latest-n REFUSES a read-compacted target with a visible reason - Latest message still works") { + // Latest-n counts STORED entries; a compacted read shows one message per key. The walk + // cannot see the compacted view, so the session must refuse rather than promise a count it + // cannot keep. 'Latest message' needs no counting and must keep working on the same target. + val (t, ns, topic) = fixtures.freshTopicParts() + fixtures.produceStrings(s"persistent://$t/$ns/$topic", 3) + + val cs = ConsumerSessionPage(page) + cs.openForTopic(t, ns, topic) + cs.toggleTargetCompacted() + cs.setStartFrom("Latest n messages") + cs.startFromN.fill("2") + cs.play() + + val refusal = page.getByText( + java.util.regex.Pattern.compile("read compacted", java.util.regex.Pattern.CASE_INSENSITIVE) + ).first() + assertThat(refusal).isVisible(vis(15000)) + + cs.setStartFrom("Latest message") + cs.play() + cs.assertState("running") + } diff --git a/e2e/src/test/scala/features/consumersession/CsLifecycleSpec.scala b/e2e/src/test/scala/features/consumersession/CsLifecycleSpec.scala index 57f9707aa..968a38738 100644 --- a/e2e/src/test/scala/features/consumersession/CsLifecycleSpec.scala +++ b/e2e/src/test/scala/features/consumersession/CsLifecycleSpec.scala @@ -1,10 +1,46 @@ package features.consumersession import harness.DekafSuite +import harness.Eventually.eventually import com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat import com.microsoft.playwright.assertions.LocatorAssertions +import com.microsoft.playwright.Locator +import org.apache.pulsar.client.api.Schema +import scala.jdk.CollectionConverters.* class CsLifecycleSpec extends DekafSuite: + private def hasText = new LocatorAssertions.HasTextOptions().setTimeout(20000) + private def count(n: Int) = new LocatorAssertions.HasCountOptions().setTimeout(20000) + + /** The toolbar's "processed" counter (the server's `numMessageProcessed` carried on the last + * streamed message). `ConsumerSessionPage` only exposes `loaded`, so address it directly. */ + private def processed: Locator = page.getByTestId("cs-processed") + + private def produce(fqn: String, values: Seq[String]): Unit = + val p = client.newProducer(Schema.STRING).topic(fqn).create() + try values.foreach(p.send) finally p.close() + + /** BROKER ORACLE: how many of Dekaf's OWN consumers are attached to `fqn` right now. + * + * The Consumer Session mints `__dekaf_` (ConsumerSession.tsx) and `buildConsumer` reuses + * it as the consumer AND subscription name, subscribing NON-DURABLY - so a `__dekaf_`-prefixed + * subscription in the topic stats can only exist while a live UI-driven consumer is attached, and + * the broker drops it as soon as that consumer detaches. Prefix-scoping (rather than counting any + * consumer) keeps a stray subscription from another test or a leftover reader out of the count. */ + private def dekafConsumerCount(fqn: String): Int = + val subs = admin.topics().getStats(fqn).getSubscriptions + subs.keySet().asScala.toList + .filter(_.startsWith("__dekaf_")) + .map(name => subs.get(name).getConsumers.size) + .sum + + private def awaitDekafConsumers(fqn: String, n: Int): Unit = + eventually(timeoutMs = 30000, intervalMs = 300) { + // The topic can momentarily 404 while the broker unloads/GCs it; treat that as "not yet". + val got = try dekafConsumerCount(fqn) catch case _: Throwable => -1 + assert(got == n, s"expected $n Dekaf consumer(s) attached to $fqn, got $got") + } + private def topicWith(n: Int): (String, String, String) = val (t, ns, topic) = fixtures.freshTopicParts() fixtures.produceStrings(s"persistent://$t/$ns/$topic", n) @@ -20,6 +56,73 @@ class CsLifecycleSpec extends DekafSuite: cs.assertState("running") cs + // CS-16/17 previously proved only "rows appeared" / "rows disappeared" - a UI that rendered a + // cached list would pass both. These two assert the thing the names claim: a REAL broker-side + // consumer for the lifetime of the session, and counters that track produced messages. + + test("CS-16: a running session holds a real broker-side consumer and its counters advance with production") { + val (t, ns, topic) = fixtures.freshTopicParts() + val fqn = s"persistent://$t/$ns/$topic" + fixtures.produceStrings(fqn, 5) // "msg-1".."msg-5" + + val cs = ConsumerSessionPage(page) + cs.openForTopic(t, ns, topic) + cs.setStartFrom("Earliest message") + cs.play() + + // The table is virtualized, so assert the COUNTERS, not DOM rows. + cs.awaitLoaded(5) + cs.assertState("running") + assertThat(processed).hasText("5", hasText) + + // Broker-side proof that the session is actually consuming, not replaying a client-side cache. + awaitDekafConsumers(fqn, 1) + + // Counters must ADVANCE with newly produced messages. "live-sentinel" is produced LAST on a + // single-partition topic, so its arrival means every earlier value was already delivered - the + // set assertion below is then exact, never a transient count. + produce(fqn, Seq("live-1", "live-2", "live-sentinel")) + cs.awaitLoaded(8) + assertThat(processed).hasText("8", hasText) + + val expected = (1 to 5).map(i => s"msg-$i").toList ++ List("live-1", "live-2", "live-sentinel") + val values = eventually() { + val vs = cs.columnValues("value") + assert(vs.contains("live-sentinel"), s"sentinel not rendered yet: $vs") + vs + } + assert(values == expected, s"loaded values were: $values (expected exactly $expected)") + + // Still attached after the second wave - the consumer lives for the whole running session. + assert(dekafConsumerCount(fqn) == 1, s"the session's broker consumer vanished while running on $fqn") + } + + test("CS-17: Stop detaches the broker-side consumer, clears the messages and resets the counters") { + val (t, ns, topic) = fixtures.freshTopicParts() + val fqn = s"persistent://$t/$ns/$topic" + fixtures.produceStrings(fqn, 5) + + val cs = ConsumerSessionPage(page) + cs.openForTopic(t, ns, topic) + cs.setStartFrom("Earliest message") + cs.play() + cs.awaitLoaded(5) + assertThat(processed).hasText("5", hasText) + awaitDekafConsumers(fqn, 1) + + cs.stop() + + // Client side: the session is torn down to a fresh one - no rows, both counters back to zero. + assertThat(cs.messages).hasCount(0, count(0)) + cs.assertState("new") + assertThat(cs.loaded).hasText("0", hasText) + assertThat(processed).hasText("0", hasText) + + // Broker side: the consumer is really gone (a non-durable subscription disappears with it), so + // Stop releases the broker resource instead of leaking a consumer per stopped session. + awaitDekafConsumers(fqn, 0) + } + test("CS-18: wheel-scroll (up) on a running session transitions to paused") { val cs = runningSession(5) cs.wheelUpOverTable() diff --git a/e2e/src/test/scala/features/consumersession/CsProjectionColoringSpec.scala b/e2e/src/test/scala/features/consumersession/CsProjectionColoringSpec.scala index fe04cba17..22e018d68 100644 --- a/e2e/src/test/scala/features/consumersession/CsProjectionColoringSpec.scala +++ b/e2e/src/test/scala/features/consumersession/CsProjectionColoringSpec.scala @@ -3,7 +3,9 @@ package features.consumersession import harness.DekafSuite import com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat import com.microsoft.playwright.assertions.LocatorAssertions +import com.microsoft.playwright.options.SelectOption import org.apache.pulsar.client.api.Schema +import scala.jdk.CollectionConverters.* class CsProjectionColoringSpec extends DekafSuite: private def count(n: Int) = new LocatorAssertions.HasCountOptions().setTimeout(20000) @@ -15,22 +17,52 @@ class CsProjectionColoringSpec extends DekafSuite: val p = client.newProducer(Schema.STRING).topic(fqn).create() try values.foreach(p.send) finally p.close() - test("CS-12 (P0): a projection adds a column whose header equals the projection label") { + private def produceKeyed(fqn: String, keyed: Seq[(String, String)]): Unit = + val p = client.newProducer(Schema.STRING).topic(fqn).create() + try keyed.foreach((k, v) => p.newMessage().key(k).value(v).send()) finally p.close() + + /** The rendered PROJECTION cells, in row order. + * + * A projection `` carries no test-id of its own (the projections are a variable-length run of + * columns), but its position is fixed by `Message.tsx`: the projections sit immediately before + * the instrumented value cell. Addressing them relative to that anchor reads the real projected + * output without asking `ui/` for new instrumentation. */ + private def projectionCells(cs: ConsumerSessionPage): List[String] = + page.locator("[data-testid='cs-message-value']").locator("xpath=preceding-sibling::td[1]") + .allInnerTexts().asScala.toList.map(_.trim) + + test("CS-12 (P0): a projection adds a column whose header is its label and whose cells hold the projected value") { val (t, ns, topic) = fixtures.freshTopicParts() val fqn = s"persistent://$t/$ns/$topic" - produce(fqn, (1 to 3).map(i => s"m-$i")) + // Distinct key and value per message, so a projection OF THE KEY cannot be satisfied by + // rendering the value column twice - which is what an assertion on the header alone allowed. + produceKeyed(fqn, (1 to 3).map(i => s"k-$i" -> s"m-$i")) val cs = ConsumerSessionPage(page) cs.openForTopic(t, ns, topic) cs.revealAdvanced() cs.addProjection() cs.projectionLabel.first().fill("MyCol") + // Project the message KEY rather than the default (the whole value). The target select has no + // test-id; its option values are the discriminator, and are unique to this control on the page. + cs.sessionProjections.locator("select:has(option[value='BasicMessageFilterKeyTarget'])") + .first().selectOption(new SelectOption().setValue("BasicMessageFilterKeyTarget")) cs.setStartFrom("Earliest message") cs.play() assertThat(cs.messages).hasCount(3, count(3)) assertThat(cs.projectionColumnHeader).hasCount(1, count(1)) assertThat(cs.projectionColumnHeader).containsText("MyCol", contains) + + // THE assertion: the projected VALUES, exactly, row by row - the server really evaluated the + // projection and the results really landed in the right rows. The header is generated entirely + // from local config and would render identically for a projection that computed nothing. + assert( + projectionCells(cs) == List("\"k-1\"", "\"k-2\"", "\"k-3\""), + s"the projection column holds ${projectionCells(cs)}" + ) + // ... and it is the KEY, not a second copy of the value column beside it. + assert(cs.columnValues("value") == List("m-1", "m-2", "m-3"), s"the value column holds ${cs.columnValues("value")}") } test("CS-13: coloring modal picks a swatch, applies it, and a matching row is colored") { @@ -86,23 +118,32 @@ class CsProjectionColoringSpec extends DekafSuite: test("CS-14: changing the deserializer changes how values decode") { val (t, ns, topic) = fixtures.freshTopicParts() val fqn = s"persistent://$t/$ns/$topic" - produce(fqn, Seq("\"apple\"")) // raw bytes are the JSON string "apple" (with quotes) + // The raw bytes on the wire are the seven characters "apple" - a JSON string INCLUDING its + // quotes. That is what makes the two deserializers distinguishable: one more decode step + // removes exactly one layer of quoting. + produce(fqn, Seq("\"apple\"")) val cs = ConsumerSessionPage(page) cs.openForTopic(t, ns, topic) cs.setStartFrom("Earliest message") cs.play() assertThat(cs.messages).hasCount(1, count(1)) - val schemaRendered = cs.firstValueCell.innerText() // topic STRING schema -> "apple" (quoted) + // Topic STRING schema: the bytes decode to the 7-character string `"apple"`, which the value + // column then renders as JSON - so the quotes are escaped and a second pair is added. + // `columnValues` strips the outer rendering pair, leaving the decoded string itself. + assert(cs.columnValues("value") == List("\\\"apple\\\""), s"schema rendering was ${cs.columnValues("value")}") cs.stop() // back to config view cs.setDeserializer("Treat raw bytes as JSON") cs.play() assertThat(cs.messages).hasCount(1, count(1)) - val jsonRendered = cs.firstValueCell.innerText() // JSON parse -> apple (unquoted) - - assert(schemaRendered != jsonRendered, - s"deserializer change should alter value rendering: schema='$schemaRendered' json='$jsonRendered'") + // Raw bytes as JSON: the same bytes are PARSED, so the value is the string `apple` with no + // quotes of its own, rendered with one pair which `columnValues` strips. + // + // Both renderings are pinned exactly, not merely asserted to differ: "they differ" is satisfied + // by any two wrong answers - a decode that dropped a character, or an error placeholder in + // either leg - which is what this test used to accept. + assert(cs.columnValues("value") == List("apple"), s"JSON rendering was ${cs.columnValues("value")}") } test("CS-15: the Advanced reveal is one-way (once revealed, stays)") { diff --git a/e2e/src/test/scala/features/consumersession/CsStartFromMatrixSpec.scala b/e2e/src/test/scala/features/consumersession/CsStartFromMatrixSpec.scala new file mode 100644 index 000000000..46de5be78 --- /dev/null +++ b/e2e/src/test/scala/features/consumersession/CsStartFromMatrixSpec.scala @@ -0,0 +1,307 @@ +package features.consumersession + +import com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat +import com.microsoft.playwright.assertions.LocatorAssertions +import org.apache.pulsar.client.api.{Message as PulsarMessage, Schema} + +/** The two COUNTING Start-From modes - "Skip first n messages" and "Latest n messages" - crossed + * with both ways an application writes to Pulsar (batched / unbatched) and with the whole topic + * matrix ({persistent, non-persistent} x {partitioned, non-partitioned}). + * + * Why the batching axis: until 2026-07-25 both modes were implemented on + * `PulsarAdmin.examineMessage`, which addresses ENTRIES. The Java producer packs many messages into + * one entry by default, so in any ordinary application "the 6th entry" and "the 6th message" are + * different things - and every fixture the suite had produced exactly one message per entry, which + * made the two indistinguishable. See `harness.BatchingFixtureSpec` for the broker-level proof of + * both facts, and `CsStartFromOutcomesSpec` for the same two modes examined in isolation. + * + * The contracts asserted here, as implemented. BOTH counting modes are GLOBAL in COUNT - n in + * total across the whole session, never n per partition - and the merge takes each partition in + * its own APPEND order, comparing publish times only across the partitions' current heads. On + * same-clock producers that is the publish-time answer; where producer clocks disagree, WHICH n + * can differ from a strict global-publish-time sort (a buried out-of-order timestamp is not dug + * out), while the count stays exactly n. globalStartFromTest pins both sides of that line. + * - **Skip first n** drops n and delivers everything else. On a single ordered log that is + * exactly "start at message n + 1"; across partitions it is the n oldest as the merge sees + * them, whichever partitions they came from. + * - **Latest n** delivers EXACTLY n in total. It used to be resolved per physical topic, so + * "latest 2" on a 3-partition topic returned six; CS-SFM-4 is the regression against that. + * + * The cells generated from the topic matrix still funnel their payload through ONE partition, where + * the two contracts coincide with "the first / last n of that log" - which is what makes their + * expectation a plain slice of the payload. The genuinely-spread cases, where a per-topic answer + * and the global one differ, are CS-SFM-3/4 at the bottom of this spec. + * + * NON-PERSISTENT quadrants get a different assertion, not a weaker one. Such a topic retains + * nothing - anything published while no consumer is attached is dropped by the broker forever, and + * PulsarAdmin will not examine one at all - so there is no history to count into. The app now says + * so up front: both counting modes are rendered DISABLED with a note, and the server rejects them + * for an all-non-persistent session rather than degrading into a silent "from now". Those cells + * assert the refusal, plus that the live tail still works and none of the pre-produced messages + * come back. + */ +class CsStartFromMatrixSpec extends StartFromSupport: + + /** 12 messages -> 3 entries when batched at 4/entry: entry positions and message positions are + * far enough apart that no off-by-a-factor can be mistaken for a correct answer. `m-12` is the + * sentinel and is in both expectations below. */ + private val payload = (1 to 12).map(i => f"m-$i%02d") + + /** A counting mode: the dropdown label, and the messages it must leave showing. */ + private case class Counting(id: String, uiLabel: String, n: String, expected: Seq[String]) + private val counting = List( + Counting("skip-first-n", "Skip first n messages", "5", payload.drop(5)), // m-06 .. m-12 + Counting("latest-n", "Latest n messages", "5", payload.takeRight(5)) // m-08 .. m-12 + ) + + /** The single ordered log the expectation is stated against. + * + * A partitioned topic is fed through ONE partition on purpose: "the first n messages" is only + * defined on a totally ordered log, and a partitioned topic has none. This still drives the + * partitioned code path end to end - the session subscribes to the parent, so every partition is + * expanded, subscribed and seeked - it only makes the expectation unambiguous. The genuinely + * spread-across-partitions cases are CS-SFM-3/4 below. */ + private def logOf(fqn: String, kind: fixtures.TopicKind): String = + if kind.isPartitioned then s"$fqn-partition-0" else fqn + + fixtures.TopicKind.all.foreach { kind => + if kind.retains then + Produce.values.foreach { produce => + counting.foreach { c => + val cell = s"[${kind.label}] [${produce.label}] [${c.id}]" + // The batched cells are the regression: before 2026-07-25 both modes counted broker + // ENTRIES, and with these 12 messages in 3 entries "skip 5" started at m-09 (it skipped + // eight) while "latest 5" showed all twelve. Both mechanisms are pinned in BATCH-2. + test(s"CS-SFM-1 $cell: shows exactly the expected MESSAGES") { + val (t, ns, topic, fqn) = fixtures.freshTopicPartsOfKind(kind) + produceAs(produce, logOf(fqn, kind), payload) + + val cs = ConsumerSessionPage(page) + cs.openForTopic(t, ns, topic, kind.scheme) + cs.setStartFrom(c.uiLabel) + cs.startFromN.fill(c.n) + cs.play() + cs.assertState("running") + assertLoadedExactlyWithCounter(cs, c.expected) + } + } + } + else + // ONE test per non-persistent kind. The batched/unbatched axis is decorative here: a + // non-persistent topic retains nothing, so the pre-produced payload is dropped by the broker + // regardless of how it was batched (and `produceBatched` cannot even verify itself without a + // managed ledger). BOTH counting modes must be refused, so they are asserted together rather + // than across four byte-identical (produce x counting) cells that only differ in which label + // they happen to check. + test(s"CS-SFM-2 [${kind.label}]: no history to count - both counting modes refused, the live stream is exact") { + val (t, ns, topic, fqn) = fixtures.freshTopicPartsOfKind(kind) + produceAs(Produce.Unbatched, fqn, payload) // no consumer attached -> the broker drops these forever + + val cs = ConsumerSessionPage(page) + cs.openForTopic(t, ns, topic, kind.scheme) + // The counting modes cannot mean anything here, and are no longer offered as if they could: + // each is rendered disabled and a note says why. Asserting the refusal is stronger than + // asserting that a permitted-but-meaningless selection happened to behave. + counting.foreach { c => + assert( + cs.disabledStartFromLabels.contains(c.uiLabel), + s"'${c.uiLabel}' is still selectable on a non-persistent topic; disabled: ${cs.disabledStartFromLabels}" + ) + } + assertThat(cs.startFromNonPersistentNote).isVisible(new LocatorAssertions.IsVisibleOptions().setTimeout(20000)) + + // What the topic CAN do still has to work, and the pre-produced messages must stay gone. + cs.setStartFrom("Latest message") + cs.play() + cs.assertState("running") + awaitConsumersFlowing(fqn, kind) + // Prove the stream is live BEFORE asserting the negative - an empty table only means + // "nothing was retained" once we know a message would have shown up. + awaitSessionStreaming(cs, fqn) + + val live = Seq("live-1", "live-2", "live-last") + produceAs(Produce.Unbatched, fqn, live) + assertLoadedExactly(cs, live) + val rendered = cs.columnValues("value") + assert( + !rendered.exists(_.startsWith("m-")), + s"a non-persistent topic answered a counting Start-From with retained history: $rendered" + ) + } + } + + // ------------------------------------------------------------------------------------------- + // Messages spread across ALL partitions - where a per-topic answer and the global one diverge + // + // Everything above funnels its payload through one partition, where "the first n" and "the last + // n" of that single log ARE the global answer. These two put part of the payload on every + // partition, which is the only shape in which a per-partition implementation and the global + // contract disagree: "skip n" has to pick the n oldest across independent consumers, and + // "latest n" has to return n messages rather than n per partition. + // + // Deliberately NO message filter, value projection or coloring rule is configured here, so a + // failure here is about start-from and nothing else. + // + // The CONCURRENT-ENTRY hazard that used to live next door is fixed: a session still gets ONE + // GraalVM JS context (`ConsumerSessionContextPool` pins the pool to size 1) while a partitioned + // topic gives each partition its own listener thread, but `ConsumerSessionContext.exclusively` now + // leases that context for a WHOLE message, so two threads can no longer be inside it at once + // ("Multi threaded access ... is not allowed for language(s) js") nor interleave a + // `setCurrentMessage` with another message's chain. + // + // The ORDERING race that used to be described here is fixed as well: `ConsumerListener.received` + // now resolves AND processes inside `startFromOrdering.inOrder`, so the vector the merge chose is + // handed to the target handler under the very lock that chose it. It is no longer possible for two + // listener threads to resolve in one order and deliver in another. + // + // The OUTPUT stream's ORDER and TERMINATION hazards this comment used to file as still-open are now + // fixed too, and this arrangement does not exercise them either. Every write goes through + // `ConsumerSessionRunner.sendResponse`, which holds `sendLock`, and: + // - `sendResponse` now BUILDS the response, start-from progress and all, INSIDE that lock (behind + // an `if !streamCompleted` check), so an older incomplete frame can no longer overtake a newer + // complete one; + // - `stop()` now calls `observer.onCompleted()` INSIDE the lock behind a sticky `streamCompleted` + // terminal gate, so nothing is written after completion and completion never interleaves with an + // in-flight `onNext`. + // Both are pinned at the server tier by `sessionOutputSerializationTest` (suite "progress never goes + // backwards, and nothing follows the end of the stream"); see e2e/README.md §6. + // ------------------------------------------------------------------------------------------- + + private val spreadKind = fixtures.TopicKind.PersistentPartitioned + + /** Gap left between two publishes by `spread`. + * + * Both counting modes are defined over the merged stream ordered by PUBLISH TIME, so an + * expectation of the form "the globally-earliest n" is only well defined while no two messages + * share a millisecond. Enforced by ARRANGEMENT (and checked in `globalOrder`) rather than by + * re-implementing the server's tie-break here, which would make the test agree with a broken + * tie-break instead of catching it. */ + private val PublishGapMs = 20L + + /** What each partition actually holds, oldest first, read back from the broker - the ORACLE both + * multi-partition expectations are derived from. The router's starting partition is chosen at + * random, so hard-coding the split would be wrong; only the broker knows it. */ + private def partitionContents(fqn: String): Vector[Vector[PulsarMessage[String]]] = + (0 until spreadKind.partitions).toVector.map(p => fixtures.readAllMessages(s"$fqn-partition-$p")) + + /** The merged stream in the GLOBAL publish-time order both counting modes are defined over, + * derived entirely from the broker - never from the payload names. + * + * A stable sort by publish time is a total order only while no two messages sharing a publish + * time sit in DIFFERENT partitions; `spread` is what makes that true and this asserts it rather + * than assuming it. Equal times do survive inside a producer BATCH - one entry, one publish time, + * every message in it - but a batch lives in a single partition and is read back in produce + * order, which the stable sort preserves. */ + private def globalOrder(perPartition: Vector[Vector[PulsarMessage[String]]]): Vector[String] = + val flat = perPartition.zipWithIndex.flatMap { case (msgs, p) => msgs.map(m => (p, m)) } + flat.groupBy { case (_, m) => m.getPublishTime }.foreach { case (publishTime, group) => + val partitions = group.map { case (p, _) => p }.distinct + assert( + partitions.size == 1, + s"publish time $publishTime is shared by partitions $partitions, so 'the globally-earliest n' is " + + s"not decidable from publish time alone: ${group.map { case (_, m) => m.getValue }}" + ) + } + flat.sortBy { case (_, m) => m.getPublishTime }.map { case (_, m) => m.getValue } + + /** Put `values` on the partitioned topic so that EVERY partition ends up holding some of it, and + * so that publish times never collide across partitions. + * + * Unbatched goes through the parent and lets the default round-robin router spread it - ordinary + * produce traffic - one message at a time. Batched cannot go that way: with batching on, the + * round-robin router only switches partition every `batchingPartitionSwitchFrequencyByPublishDelay` + * x publish delay, so a burst lands entirely on ONE partition (observed - the whole payload on + * partition 1). Addressing the partitions directly is the only way to get batched entries onto + * more than one of them, and the gap then goes BETWEEN batches, a batch being one entry with one + * publish time shared by everything in it. + * + * The gaps are ARRANGEMENT, not readiness waits: the only way to put messages at distinct known + * instants is to publish them at distinct instants. */ + private def spread(produce: Produce, fqn: String, values: Seq[String]): Unit = produce match + case Produce.Unbatched => + // One producer for the whole payload, so the round-robin router really rotates through the + // partitions instead of restarting from a fresh random one per message. + val producer = client.newProducer(Schema.STRING).topic(fqn).enableBatching(false).create() + try + values.foreach { value => + val sentAt = System.currentTimeMillis() + producer.send(value) + awaitClockGap(sentAt, PublishGapMs) + } + finally producer.close() + case Produce.Batched => + val perPartition = math.ceil(values.size.toDouble / spreadKind.partitions).toInt + values.grouped(perPartition).zipWithIndex.foreach { case (chunk, i) => + val sentAt = System.currentTimeMillis() + fixtures.produceBatched(s"$fqn-partition-$i", chunk, MessagesPerBatch) + awaitClockGap(sentAt, PublishGapMs) + } + + Produce.values.foreach { produce => + + test(s"CS-SFM-3 [${spreadKind.label}] [${produce.label}] [skip-first-n]: drops the GLOBALLY-EARLIEST n across every partition") { + val skip = 3 + val (t, ns, topic, fqn) = fixtures.freshTopicPartsOfKind(spreadKind) + spread(produce, fqn, payload) + val perPartition = partitionContents(fqn) + assert( + perPartition.flatten.map(_.getValue).toSet == payload.toSet, + s"the arrangement did not land: ${perPartition.map(_.map(_.getValue))}" + ) + // Every partition has to hold some of the payload, or the session never merges anything and + // the global contract is indistinguishable from the single-log one. + assert( + perPartition.forall(_.nonEmpty), + s"this test needs every partition to hold part of the payload, got ${perPartition.map(_.size)}" + ) + + // Not payload.drop(skip): WHICH messages are globally oldest is a fact about publish times + // that only the broker knows - the router decides where each message lands, and a batch shares + // one publish time across everything in it. + val expected = globalOrder(perPartition).drop(skip) + + val cs = ConsumerSessionPage(page) + cs.openForTopic(t, ns, topic, spreadKind.scheme) + cs.setStartFrom("Skip first n messages") + cs.startFromN.fill(skip.toString) + cs.play() + cs.assertState("running") + // An exact SET, not a count. Dropping any other three messages also leaves nine, so a count + // would pass for an implementation that dropped the first three to ARRIVE rather than the + // three oldest - which is precisely what the per-topic implementation did. + assertLoadedExactlyWithCounter(cs, expected) + } + + test(s"CS-SFM-4 [${spreadKind.label}] [${produce.label}] [latest-n]: shows exactly the globally-latest n, not n per partition") { + val n = 2 + val (t, ns, topic, fqn) = fixtures.freshTopicPartsOfKind(spreadKind) + spread(produce, fqn, payload) + val perPartition = partitionContents(fqn) + assert( + perPartition.flatten.map(_.getValue).toSet == payload.toSet, + s"the arrangement did not land: ${perPartition.map(_.map(_.getValue))}" + ) + + // "Latest n" is GLOBAL: exactly the n newest messages of the merged stream, however they are + // distributed. Derived from the broker's own publish times, not from the message names. + val expected = globalOrder(perPartition).takeRight(n) + assert(expected.size == n, s"the globally-latest $n should be $n messages, got $expected") + + // THE regression. "Latest n" used to be resolved per physical topic, so this arrangement + // answered with the last n of EACH partition - up to six. Asserting here that the two answers + // really do differ is what stops the test also passing on the old behavior. + val perTopicAnswer = perPartition.filter(_.nonEmpty).flatMap(_.takeRight(n)).map(_.getValue) + assert( + perTopicAnswer.size > n, + s"this only pins the GLOBAL contract while the per-partition answer is larger than $n: $perTopicAnswer" + ) + + val cs = ConsumerSessionPage(page) + cs.openForTopic(t, ns, topic, spreadKind.scheme) + cs.setStartFrom("Latest n messages") + cs.startFromN.fill(n.toString) + cs.play() + cs.assertState("running") + assertLoadedExactlyWithCounter(cs, expected) + } + } diff --git a/e2e/src/test/scala/features/consumersession/CsStartFromOutcomesSpec.scala b/e2e/src/test/scala/features/consumersession/CsStartFromOutcomesSpec.scala new file mode 100644 index 000000000..67e6a0dcd --- /dev/null +++ b/e2e/src/test/scala/features/consumersession/CsStartFromOutcomesSpec.scala @@ -0,0 +1,614 @@ +package features.consumersession + +import com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat +import com.microsoft.playwright.assertions.LocatorAssertions + +import java.time.{Instant, LocalDateTime, ZoneId} + +/** Start-From OUTCOME coverage: for each mode, the exact set of messages the session ends up + * showing. + * + * Before this spec only Earliest and Latest had outcome coverage (`CsStartFromSpec` CS-2/CS-3); + * the five addressed modes - skip first n, latest n, message id, specific time, relative time - + * had none at all. Everything here runs on a PERSISTENT NON-PARTITIONED topic, which is the + * single-topic fast path in `handleStartFrom` (a message-id seek); `CsStartFromMatrixSpec` crosses + * the same two counting modes with the rest of the topic matrix. + * + * The two APPROXIMATE modes are both covered below - "% through the data" (CS-SF-11..14) and "% + * through the time range" (CS-SF-16..19) - including CS-SF-17, where one topic is asked the same + * "50%" by both and answers differently. Being non-partitioned, these do not exercise what the two + * modes do across several physical topics - the data mode resolving each independently, the time + * mode pooling one min/max range over them: that is `CsApproximatePartitionedSpec` (CS-SF-20/21), + * with the pure arithmetic in `server/.../approximateTimePositionTest`. + * + * Each test asserts an exact SET, never a row count: "skip the first 5" and "skip the first 50" + * both produce *a* count, and a virtualized table reaches a transient count for almost any bug. + * The last produced message doubles as the sentinel - it is in every expectation here, so the set + * cannot be satisfied until the whole stream has been observed. + * + * Batching is the axis this spec exists for. `PulsarAdmin.examineMessage`, which the two counting + * modes are built on, addresses ENTRIES; the Java producer packs many messages into one entry by + * default; and every fixture the suite had produced one message per entry, so entry positions and + * message positions always coincided and the two could never be told apart. See + * `harness.BatchingFixtureSpec` for the broker-level proof of both facts. + */ +class CsStartFromOutcomesSpec extends StartFromSupport: + + /** 12 messages -> 3 entries when batched at 4/entry, so an entry position and a message position + * can never be mistaken for one another. `m-12` is the sentinel. */ + private val payload = (1 to 12).map(i => f"m-$i%02d") + + private def openOn(fqn: String, t: String, ns: String, topic: String): ConsumerSessionPage = + val cs = ConsumerSessionPage(page) + cs.openForTopic(t, ns, topic) + cs + + // ------------------------------------------------------------------------------------------- + // Skip first n (NthMessageAfterEarliest) + // ------------------------------------------------------------------------------------------- + + test("CS-SF-1: Skip first n (unbatched) shows every message except the first n") { + val (t, ns, topic) = fixtures.freshTopicParts() + val fqn = s"persistent://$t/$ns/$topic" + fixtures.produceUnbatched(fqn, payload) + + val cs = openOn(fqn, t, ns, topic) + cs.setStartFrom("Skip first n messages") + cs.startFromN.fill("5") + cs.play() + cs.assertState("running") + assertLoadedExactlyWithCounter(cs, payload.drop(5)) // m-06 .. m-12 + } + + // The regression this whole spec exists for. Until 2026-07-25 the mode was implemented as an + // entry-addressed `examineMessage("earliest", n + 1)` seek, and BATCH-2 pins why that cannot work: + // that call answers with the first message of the n-th ENTRY and, past the last entry, silently + // returns the last entry instead of failing. With these 12 messages in 3 entries, "skip 5" asked + // for position 6, got entry 3, and started at m-09 - it skipped EIGHT. Nothing caught it because + // every fixture in the suite produced one message per entry. + test("CS-SF-2: Skip first n (batched) skips n MESSAGES, not n entries") { + val (t, ns, topic) = fixtures.freshTopicParts() + val fqn = s"persistent://$t/$ns/$topic" + fixtures.produceBatched(fqn, payload, MessagesPerBatch) // 12 messages -> 3 entries + + val cs = openOn(fqn, t, ns, topic) + cs.setStartFrom("Skip first n messages") + cs.startFromN.fill("5") + cs.play() + cs.assertState("running") + assertLoadedExactlyWithCounter(cs, payload.drop(5)) // m-06 .. m-12 + } + + // ------------------------------------------------------------------------------------------- + // Latest n (NthMessageBeforeLatest) + // ------------------------------------------------------------------------------------------- + + test("CS-SF-3: Latest n (unbatched) shows exactly the last n messages") { + val (t, ns, topic) = fixtures.freshTopicParts() + val fqn = s"persistent://$t/$ns/$topic" + fixtures.produceUnbatched(fqn, payload) + + val cs = openOn(fqn, t, ns, topic) + cs.setStartFrom("Latest n messages") + cs.startFromN.fill("5") + cs.play() + cs.assertState("running") + assertLoadedExactlyWithCounter(cs, payload.takeRight(5)) // m-08 .. m-12 + } + + // The counterpart regression, and the old failure shape differed from CS-SF-2's: counting entries + // BACK from the end does not clamp - `examineMessage("latest", n)` past the first entry THROWS + // (BATCH-2). The seek swallowed that and fell back to Earliest, so "show me the latest 5" showed + // all twelve. A row count would have called that "5 or more" and moved on; the exact set will not. + test("CS-SF-4: Latest n (batched) shows the last n MESSAGES, not the last n entries") { + val (t, ns, topic) = fixtures.freshTopicParts() + val fqn = s"persistent://$t/$ns/$topic" + fixtures.produceBatched(fqn, payload, MessagesPerBatch) // 12 messages -> 3 entries + + val cs = openOn(fqn, t, ns, topic) + cs.setStartFrom("Latest n messages") + cs.startFromN.fill("5") + cs.play() + cs.assertState("running") + assertLoadedExactlyWithCounter(cs, payload.takeRight(5)) // m-08 .. m-12 + } + + // ------------------------------------------------------------------------------------------- + // The n = 0 boundary - the two modes are deliberately asymmetric there + // ------------------------------------------------------------------------------------------- + + test("CS-SF-9: Skip first n with n = 0 skips nothing") { + val (t, ns, topic) = fixtures.freshTopicParts() + val fqn = s"persistent://$t/$ns/$topic" + fixtures.produceBatched(fqn, payload, MessagesPerBatch) + + val cs = openOn(fqn, t, ns, topic) + cs.setStartFrom("Skip first n messages") + cs.startFromN.fill("0") + cs.play() + cs.assertState("running") + assertLoadedExactlyWithCounter(cs, payload) // "skip zero" is Earliest + } + + test("CS-SF-10: Latest n with n = 0 shows nothing, and still streams what arrives after play") { + val (t, ns, topic) = fixtures.freshTopicParts() + val fqn = s"persistent://$t/$ns/$topic" + fixtures.produceBatched(fqn, payload, MessagesPerBatch) + + val cs = openOn(fqn, t, ns, topic) + cs.setStartFrom("Latest n messages") + cs.startFromN.fill("0") + cs.play() + cs.assertState("running") + // "the last zero messages" is Latest: none of the history, and the empty state is the proof. + assertThat(cs.awaitingText).isVisible(new LocatorAssertions.IsVisibleOptions().setTimeout(20000)) + assert(cs.columnValues("value").isEmpty, s"n = 0 loaded history: ${cs.columnValues("value")}") + + // An empty table alone would also be what a dead session looks like, so prove it is live. + val live = Seq("live-1", "live-2", "live-last") + fixtures.produceUnbatched(fqn, live) + assertLoadedExactlyWithCounter(cs, live) + } + + // The one join neither the jest tests nor the server tests can reach: the server really populating + // `start_from_progress`, travelling over gRPC, and arriving in the rendered panel. Both ends are + // covered in isolation; this is the wire between them. + // + // The panel is deliberately silent at or below 1,000,000 messages to skip, so the only way to see + // it is to ask for a skip larger than that - hence a number with no relation to the 12 messages + // actually on the topic. That is also what makes the test cheap: nothing has to be produced to + // reach the threshold, because `messagesToSkip` is what was ASKED for, not what exists. + test("CS-SF-15: a very large skip reports its progress from the server into the rendered panel") { + val (t, ns, topic) = fixtures.freshTopicParts() + val fqn = s"persistent://$t/$ns/$topic" + fixtures.produceUnbatched(fqn, payload) + + val cs = openOn(fqn, t, ns, topic) + cs.setStartFrom("Skip first n messages") + cs.startFromN.fill("2000000") + cs.play() + cs.assertState("running") + + // The raw counts, not the prose. `skipped` must be strictly POSITIVE, and that is the whole + // point of the test: the server reports as soon as the discard claims its first message, so a + // positive count is the only thing that separates a real progress callback - listener -> gRPC + // -> panel - from a zero-state frame the UI could have rendered from its own initial state. + // Accepting `>= 0` (which this test used to do) accepted exactly that empty frame. + val skipped = harness.Eventually.eventually(timeoutMs = 30000, intervalMs = 400) { + assert(cs.startFromProgress.count() > 0, "the progress panel never appeared") + val reported = cs.startFromProgress.getAttribute("data-cs-skipped").toLong + assert(reported > 0, s"the panel is still at its zero state: skipped = $reported") + reported + } + val toSkip = cs.startFromProgress.getAttribute("data-cs-to-skip") + assert(toSkip == "2000000", s"to-skip was $toSkip") + // Only 12 messages exist, so a skip of two million can never claim more than those twelve, can + // never finish, and can never deliver anything. + assert(skipped <= payload.size, s"skipped $skipped of a ${payload.size}-message topic") + assert(cs.columnValues("value").isEmpty, s"a skip of 2,000,000 delivered messages: ${cs.columnValues("value")}") + } + + // ------------------------------------------------------------------------------------------- + // About % through the DATA (ApproximateDataPosition) + // + // Deliberately ENTRY-addressed (it has to resolve in constant time at any topic size), so the + // expectation is derived from the broker's real entry count rather than from the message count - + // the same percentage lands somewhere different on batched and unbatched data, and that is the + // documented contract, not a defect. Asserting it from the oracle is what keeps these two tests + // honest: hard-coding message positions would quietly encode the unbatched case as "the" answer. + // ------------------------------------------------------------------------------------------- + + /** Where `percent` of the retained backlog starts, in messages, given how the payload was written. + * `floor(fraction * entries)` entries are left behind, and these arrangements put a uniform + * number of messages in each entry. */ + /** The messages expected after positioning `percent` through the DATA of `fqn`. + * + * `messages` MUST be the values actually produced to `fqn`, in order. It defaults to the shared + * `payload` because most cells use exactly that - but CS-SF-17 arranges its own set, and the + * default silently gave a confident answer about a topic it had never looked at. + * + * The guard compares against `messages` rather than `payload` for the same reason: with `payload` + * it passed by COINCIDENCE, both arrangements happening to hold 12 messages, so the mistake + * surfaced as a baffling set mismatch instead of naming the real problem. + */ + private def expectedFromPercent(fqn: String, percent: Int, messagesPerEntry: Int, messages: Seq[String] = payload): Seq[String] = + val entries = fixtures.numberOfEntries(fqn) + assert(entries == messages.size / messagesPerEntry, s"unexpected arrangement: $entries entries for ${messages.size} messages") + // The broker is the oracle for WHICH messages, not just how many. The count check above is too + // weak on its own: CS-SF-17 arranged 12 messages of its own while `messages` defaulted to a + // DIFFERENT 12, so the count matched and the helper confidently described the wrong topic. + val onTopic = fixtures.readAllMessages(fqn).map(_.getValue) + assert(onTopic == messages, s"`messages` does not match $fqn: expected $messages, topic holds $onTopic") + val entriesLeftBehind = math.floor(percent / 100.0 * entries).toInt + messages.drop(entriesLeftBehind * messagesPerEntry) + + test("CS-SF-11: % through the data (unbatched) starts the given percentage into the backlog") { + val (t, ns, topic) = fixtures.freshTopicParts() + val fqn = s"persistent://$t/$ns/$topic" + fixtures.produceUnbatched(fqn, payload) // 12 entries, 1 message each + + val expected = expectedFromPercent(fqn, 50, messagesPerEntry = 1) // m-07 .. m-12 + val cs = openOn(fqn, t, ns, topic) + cs.setStartFrom("About % through the data") + cs.setStartFromDataPercent("50") + cs.play() + cs.assertState("running") + assertLoadedExactlyWithCounter(cs, expected) + } + + test("CS-SF-12: % through the data (batched) is proportional to ENTRIES, not to messages") { + val (t, ns, topic) = fixtures.freshTopicParts() + val fqn = s"persistent://$t/$ns/$topic" + fixtures.produceBatched(fqn, payload, MessagesPerBatch) // 3 entries, 4 messages each + + // 50% of 3 entries leaves 1 entry behind, so this starts at m-05 - NOT at m-07, which is where + // the same 50% lands on the same 12 messages written one per entry (CS-SF-11). + val expected = expectedFromPercent(fqn, 50, messagesPerEntry = MessagesPerBatch) + assert(expected == payload.drop(MessagesPerBatch), s"the entry-addressed expectation moved: $expected") + + val cs = openOn(fqn, t, ns, topic) + cs.setStartFrom("About % through the data") + cs.setStartFromDataPercent("50") + cs.play() + cs.assertState("running") + assertLoadedExactlyWithCounter(cs, expected) + } + + test("CS-SF-13: % through the data endpoints - 0% is Earliest, 100% is Latest") { + val (t, ns, topic) = fixtures.freshTopicParts() + val fqn = s"persistent://$t/$ns/$topic" + fixtures.produceUnbatched(fqn, payload) + + val cs = openOn(fqn, t, ns, topic) + cs.setStartFrom("About % through the data") + cs.setStartFromDataPercent("0") + cs.play() + cs.assertState("running") + assertLoadedExactlyWithCounter(cs, payload) + + // 100% is the other endpoint: past the last retained message, i.e. the live tail. + val (t2, ns2, topic2) = fixtures.freshTopicParts() + val fqn2 = s"persistent://$t2/$ns2/$topic2" + fixtures.produceUnbatched(fqn2, payload) + + cs.openForTopic(t2, ns2, topic2) + cs.setStartFrom("About % through the data") + cs.setStartFromDataPercent("100") + cs.play() + cs.assertState("running") + assertThat(cs.awaitingText).isVisible(new LocatorAssertions.IsVisibleOptions().setTimeout(20000)) + assert(cs.columnValues("value").isEmpty, s"100% loaded history: ${cs.columnValues("value")}") + + val live = Seq("live-1", "live-2", "live-last") + fixtures.produceUnbatched(fqn2, live) + assertLoadedExactlyWithCounter(cs, live) + } + + test("CS-SF-14: % through the data rejects a percentage outside 0-100 without changing the session") { + val (t, ns, topic) = fixtures.freshTopicParts() + val fqn = s"persistent://$t/$ns/$topic" + fixtures.produceUnbatched(fqn, payload) + + val cs = openOn(fqn, t, ns, topic) + cs.setStartFrom("About % through the data") + cs.setStartFromDataPercent("150") // the model stores a fraction in [0, 1]; 150% has no meaning + assertThat(cs.startFromDataFractionError).isVisible(new LocatorAssertions.IsVisibleOptions().setTimeout(10000)) + + // Play with the rejected text STILL ON SCREEN. This is the whole test: the invalid value must + // never reach the session, so the run has to come out at the last VALID value - the 50% default. + // Correcting the field to 50 first (which this test used to do) only proved that validation + // recovers, and a session that had silently accepted 150% would have passed it unchanged. + // + // NOTE ON THE PRECONDITION: today the toolbar leaves Play ENABLED while the fraction is + // invalid, so "does not change the session" has to mean "runs at the last valid value". If the + // app is later changed to propagate validity and DISABLE Play (an open product question - an + // even stronger way to honour the same contract), this assertion is the one to rewrite: drop + // the play and assert the disabled button instead. It is asserted rather than assumed so that + // change surfaces here, with this sentence attached, instead of as a mystery timeout. + assert(cs.playButton.isEnabled, "Play is disabled while the fraction is invalid - see the note above") + cs.play() + cs.assertState("running") + assertLoadedExactlyWithCounter(cs, expectedFromPercent(fqn, 50, messagesPerEntry = 1)) + + // ... and the field is not simply inert: a VALID change on the same control does move the + // session. Without this leg the assertion above would also hold for a mode that ignored the + // fraction entirely and always started half way in. + cs.openForTopic(t, ns, topic) + cs.setStartFrom("About % through the data") + cs.setStartFromDataPercent("0") + cs.play() + cs.assertState("running") + assertLoadedExactlyWithCounter(cs, payload) + } + + // ------------------------------------------------------------------------------------------- + // Message with specific ID (MessageId) + // ------------------------------------------------------------------------------------------- + + /** The broker's own message ids are the oracle - the UI takes the serialized id as hex. */ + private def messageIdHexOf(fqn: String, value: String): String = + val msgs = fixtures.readAllMessages(fqn) + val m = msgs.find(_.getValue == value).getOrElse(fail(s"$value is not on $fqn: ${msgs.map(_.getValue)}")) + fixtures.messageIdHex(m.getMessageId) + + test("CS-SF-5: Message with specific ID starts AT that message and shows the rest") { + val (t, ns, topic) = fixtures.freshTopicParts() + val fqn = s"persistent://$t/$ns/$topic" + fixtures.produceUnbatched(fqn, payload) + + val cs = openOn(fqn, t, ns, topic) + cs.setStartFrom("Message with specific ID") + cs.startFromMessageId.fill(messageIdHexOf(fqn, "m-04")) + cs.play() + cs.assertState("running") + // Inclusive: the addressed message is where the session starts, so it is shown too. + assertLoadedExactlyWithCounter(cs, payload.drop(3)) // m-04 .. m-12 + } + + test("CS-SF-5b: Message with specific ID resolves a message inside a BATCH") { + // A batched message id carries a batch index; the id-addressed path has to honour it and start + // mid-entry rather than at the entry's first message. + val (t, ns, topic) = fixtures.freshTopicParts() + val fqn = s"persistent://$t/$ns/$topic" + fixtures.produceBatched(fqn, payload, MessagesPerBatch) // m-05..m-08 share the 2nd entry + + val cs = openOn(fqn, t, ns, topic) + cs.setStartFrom("Message with specific ID") + cs.startFromMessageId.fill(messageIdHexOf(fqn, "m-06")) // index 1 within its entry + cs.play() + cs.assertState("running") + assertLoadedExactlyWithCounter(cs, payload.drop(5)) // m-06 .. m-12 + } + + // ------------------------------------------------------------------------------------------- + // Specific time (DateTime) and Relative time ago (RelativeDateTime) + // ------------------------------------------------------------------------------------------- + + private val older = (1 to 4).map(i => f"old-$i%02d") + private val newer = (1 to 4).map(i => f"new-$i%02d") // "new-04" is the sentinel + + /** Produce `older`, leave a real gap on the wall clock, produce `newer`, and return the broker's + * publish times keyed by value - the oracle both time-addressed modes are asserted against. */ + private def produceStraddling(fqn: String, gapMs: Long): Map[String, Long] = + fixtures.produceUnbatched(fqn, older) + awaitClockGap(System.currentTimeMillis(), gapMs) + fixtures.produceUnbatched(fqn, newer) + val times = fixtures.readAllMessages(fqn).map(m => m.getValue -> m.getPublishTime).toMap + assert( + (older ++ newer).forall(times.contains), + s"the arrangement did not land on the broker: ${times.keys.toList.sorted}" + ) + assert( + older.map(times).max < newer.map(times).min, + s"the two groups did not straddle the gap: old=${older.map(times)} new=${newer.map(times)}" + ) + times + + test("CS-SF-6: Specific time shows only messages published at or after that instant") { + val (t, ns, topic) = fixtures.freshTopicParts() + val fqn = s"persistent://$t/$ns/$topic" + // 2s: the picker is second-granular, so the two groups have to sit in different seconds. + val times = produceStraddling(fqn, gapMs = 2000) + // The start of the second the first new message landed in: strictly after every old message + // (the gap guarantees it) and at or before every new one. + val cutoffMs = newer.map(times).min / 1000 * 1000 + assert(cutoffMs > older.map(times).max, s"cutoff $cutoffMs does not separate the groups: $times") + + val cs = openOn(fqn, t, ns, topic) + cs.setStartFrom("Specific time") + cs.setStartFromDateTime(LocalDateTime.ofInstant(Instant.ofEpochMilli(cutoffMs), ZoneId.systemDefault)) + cs.play() + cs.assertState("running") + assertLoadedExactlyWithCounter(cs, newer) + } + + test("CS-SF-7: Relative time ago with a window that covers everything shows the whole topic") { + val (t, ns, topic) = fixtures.freshTopicParts() + val fqn = s"persistent://$t/$ns/$topic" + fixtures.produceUnbatched(fqn, payload) + + val cs = openOn(fqn, t, ns, topic) + cs.setStartFrom("Relative time ago") + cs.setStartFromRelative(1, "hour") // everything here was published seconds ago + cs.play() + cs.assertState("running") + assertLoadedExactlyWithCounter(cs, payload) + } + + test("CS-SF-8: Relative time ago with a narrow window excludes the older messages") { + val (t, ns, topic) = fixtures.freshTopicParts() + val fqn = s"persistent://$t/$ns/$topic" + // The window is resolved server-side at seek time, so the arrangement has to leave room for the + // round trip on both sides: with a 20s gap and a 10s window the cutoff lands ~10s after the old + // group and ~10s before the new one, wherever in that span the seek actually happens. + produceStraddling(fqn, gapMs = 20000) + + val cs = openOn(fqn, t, ns, topic) + cs.setStartFrom("Relative time ago") + cs.setStartFromRelative(10, "second") + cs.play() + cs.assertState("running") + assertLoadedExactlyWithCounter(cs, newer) + } + + // ------------------------------------------------------------------------------------------- + // About % through the TIME RANGE (ApproximateTimePosition) + // + // The sibling of "% through the data" above, and the reason both exist: "about half way in" is two + // different questions. Where the data mode counts stored entries, this one interpolates between + // the first and last PUBLISH TIMES and seeks to the instant that falls out - so the same 50% lands + // somewhere else entirely on a topic whose messages did not arrive evenly. CS-SF-17 is that + // contrast, asserted on one topic with both modes. + // + // Every expectation here is derived from the broker's own publish times rather than written down, + // for the same reason the entry-addressed ones are: hard-coding a set would encode one particular + // arrangement as "the" answer. The arrangements leave SECONDS of margin between the cutoff and the + // nearest message, so a produce round trip cannot move a message across it. + // ------------------------------------------------------------------------------------------- + + /** Publish `groups` in order with `gapMs` of real wall clock between the start of each, and answer + * with the broker's publish time for every value produced. + * + * This is ARRANGEMENT: the only way to give a topic a time range is to publish across one. The + * gaps are also the test's safety margin - each group is asserted to sit strictly after the one + * before it, so a cutoff computed to fall inside a gap cannot accidentally land on a message. */ + private def produceSpacedGroups(fqn: String, groups: Seq[Seq[String]], gapMs: Long): Map[String, Long] = + var groupStartedAt = System.currentTimeMillis() + groups.zipWithIndex.foreach { (group, index) => + if index > 0 then + awaitClockGap(groupStartedAt, gapMs) + groupStartedAt = System.currentTimeMillis() + fixtures.produceUnbatched(fqn, group) + } + + val times = fixtures.readAllMessages(fqn).map(m => m.getValue -> m.getPublishTime).toMap + assert(groups.flatten.forall(times.contains), s"the arrangement did not land on the broker: ${times.keys.toList.sorted}") + groups.map(_.map(times)).sliding(2).foreach { + case Seq(before, after) => assert(before.max < after.min, s"the groups do not straddle their gap: $before then $after") + case _ => () + } + times + + /** The exact messages `percent` of the topic's TIME RANGE must deliver, computed from the broker's + * publish times: the cutoff is `first + floor(percent/100 * (last - first))` and everything + * published at or after it is shown. */ + private def expectedFromTimePercent(times: Map[String, Long], percent: Int): Seq[String] = + val first = times.values.min + val last = times.values.max + val cutoffMs = first + math.floor(percent / 100.0 * (last - first)).toLong + times.filter((_, publishedAt) => publishedAt >= cutoffMs).keys.toSeq.sorted + + test("CS-SF-16: % through the time range lands proportionally through the ELAPSED TIME") { + val (t, ns, topic) = fixtures.freshTopicParts() + val fqn = s"persistent://$t/$ns/$topic" + // Three groups six seconds apart, so the range is ~12s and both fractions below fall in the + // middle of a gap - three seconds from the nearest message on either side. + val early = Seq("t-01", "t-02") + val middle = Seq("t-03", "t-04") + val late = Seq("t-05", "t-06") + val times = produceSpacedGroups(fqn, Seq(early, middle, late), gapMs = 6000) + + // 25% of a 12s range is ~3s in: past the early group, well short of the middle one. + val quarter = expectedFromTimePercent(times, 25) + assert(quarter.toSet == (middle ++ late).toSet, s"25% of the time range resolved to $quarter") + + val cs = openOn(fqn, t, ns, topic) + cs.setStartFrom("About % through the time range") + cs.setStartFromTimePercent("25") + cs.play() + cs.assertState("running") + assertLoadedExactlyWithCounter(cs, quarter) + + // 75% is ~9s in: past the middle group, short of the late one. A mode that ignored the fraction + // and always seeked to one end would satisfy one of these two assertions but never both. + val threeQuarters = expectedFromTimePercent(times, 75) + assert(threeQuarters.toSet == late.toSet, s"75% of the time range resolved to $threeQuarters") + + cs.openForTopic(t, ns, topic) + cs.setStartFrom("About % through the time range") + cs.setStartFromTimePercent("75") + cs.play() + cs.assertState("running") + assertLoadedExactlyWithCounter(cs, threeQuarters) + } + + test("CS-SF-17: at the same 50%, the time mode and the data mode land in different places") { + // THE motivating case, and the whole reason one mode became two. Two messages long ago and then + // a burst of ten: half the TIME is back in the empty stretch, while half the MESSAGES is inside + // the burst. One control could not have meant both. + val (t, ns, topic) = fixtures.freshTopicParts() + val fqn = s"persistent://$t/$ns/$topic" + val old = Seq("old-1", "old-2") + val burst = (1 to 10).map(i => f"b-$i%02d") + val times = produceSpacedGroups(fqn, Seq(old, burst), gapMs = 10000) + + // Half of a ~10s range is ~5s in - five seconds after the old pair and five before the burst. + val byTime = expectedFromTimePercent(times, 50) + assert(byTime.toSet == burst.toSet, s"50% of the time range resolved to $byTime") + + val cs = openOn(fqn, t, ns, topic) + cs.setStartFrom("About % through the time range") + cs.setStartFromTimePercent("50") + cs.play() + cs.assertState("running") + assertLoadedExactlyWithCounter(cs, byTime) + + // The same 50%, counted over the 12 entries instead: six are left behind, so it starts on the + // seventh message overall - the fifth of the burst. + val byData = expectedFromPercent(fqn, 50, messagesPerEntry = 1, messages = old ++ burst) + assert(byData.toSet == burst.drop(4).toSet, s"50% of the data resolved to $byData") + assert(byData.size < byTime.size, s"the two modes must not coincide here: data=$byData time=$byTime") + + cs.openForTopic(t, ns, topic) + cs.setStartFrom("About % through the data") + cs.setStartFromDataPercent("50") + cs.play() + cs.assertState("running") + assertLoadedExactlyWithCounter(cs, byData) + } + + test("CS-SF-18: % through the time range endpoints - 0% is everything, 100% is the LAST message") { + // Deliberately unlike the data mode, whose 100% means "past the end" and shows nothing: the + // time range ends AT the last message, so 100% shows it. The sentinel is produced alone after a + // gap so that "the last message" is exactly one message - a timestamp seek cannot separate + // messages that share a millisecond. + val (t, ns, topic) = fixtures.freshTopicParts() + val fqn = s"persistent://$t/$ns/$topic" + val bulk = (1 to 6).map(i => f"e-$i%02d") + val sentinel = Seq("e-last") + val times = produceSpacedGroups(fqn, Seq(bulk, sentinel), gapMs = 4000) + + val cs = openOn(fqn, t, ns, topic) + cs.setStartFrom("About % through the time range") + cs.setStartFromTimePercent("0") + cs.play() + cs.assertState("running") + assertLoadedExactlyWithCounter(cs, bulk ++ sentinel) + + val atTheEnd = expectedFromTimePercent(times, 100) + assert(atTheEnd == sentinel, s"100% of the time range resolved to $atTheEnd") + + cs.openForTopic(t, ns, topic) + cs.setStartFrom("About % through the time range") + cs.setStartFromTimePercent("100") + cs.play() + cs.assertState("running") + assertLoadedExactlyWithCounter(cs, sentinel) + } + + test("CS-SF-19: % through the time range rejects a percentage outside 0-100 without changing the session") { + val (t, ns, topic) = fixtures.freshTopicParts() + val fqn = s"persistent://$t/$ns/$topic" + val early = Seq("t-01", "t-02") + val late = Seq("t-03", "t-04") + val times = produceSpacedGroups(fqn, Seq(early, late), gapMs = 6000) + + val cs = openOn(fqn, t, ns, topic) + cs.setStartFrom("About % through the time range") + cs.setStartFromTimePercent("150") // the model stores a fraction in [0, 1]; 150% has no meaning + // The TIME mode's own error, not the data mode's: the two render the same control, so this is + // also the check that they are separately addressable in the real DOM. + assertThat(cs.startFromTimeFractionError).isVisible(new LocatorAssertions.IsVisibleOptions().setTimeout(10000)) + assertThat(cs.startFromDataFraction).hasCount(0) + + // Play with the rejected text STILL ON SCREEN - the same claim CS-SF-14 makes for the data + // mode, and for the same reason: correcting the field first proves validation recovery, not + // that an invalid value cannot reach the session. + assert(cs.playButton.isEnabled, "Play is disabled while the fraction is invalid - see the note in CS-SF-14") + cs.play() + cs.assertState("running") + assertLoadedExactlyWithCounter(cs, expectedFromTimePercent(times, 50)) + + // ... and the control is not inert: a valid change does move the session. + val atTheEnd = expectedFromTimePercent(times, 100) + assert( + atTheEnd.toSet != expectedFromTimePercent(times, 50).toSet, + s"100% and 50% resolve to the same set here, so the second leg proves nothing: $atTheEnd" + ) + cs.openForTopic(t, ns, topic) + cs.setStartFrom("About % through the time range") + cs.setStartFromTimePercent("100") + cs.play() + cs.assertState("running") + assertLoadedExactlyWithCounter(cs, atTheEnd) + } diff --git a/e2e/src/test/scala/features/consumersession/CsTableSpec.scala b/e2e/src/test/scala/features/consumersession/CsTableSpec.scala index 992c63c95..a88756d58 100644 --- a/e2e/src/test/scala/features/consumersession/CsTableSpec.scala +++ b/e2e/src/test/scala/features/consumersession/CsTableSpec.scala @@ -1,6 +1,7 @@ package features.consumersession import harness.DekafSuite +import scala.jdk.CollectionConverters.* import org.apache.pulsar.client.api.Schema class CsTableSpec extends DekafSuite: @@ -111,3 +112,37 @@ class CsTableSpec extends DekafSuite: assert(math.abs(domAfterReload - domAfterResize) <= 3, s"restored column rendered at ${domAfterReload}px, expected ~${domAfterResize}px (persisted width not re-applied)") } + + test("CS-TBL-REORDER: a message column dragged onto another lands before it and is REMEMBERED") { + val (t, ns, topic) = fixtures.freshTopicParts() + fixtures.produceStrings(s"persistent://$t/$ns/$topic", 3) + + val cs = ConsumerSessionPage(page) + cs.openForTopic(t, ns, topic) + cs.setStartFrom("Earliest message") + cs.play() + cs.awaitLoaded(3) + + def headerKeys(): List[String] = + page.locator("[data-testid^='cs-th-']").all().asScala.toList + .map(_.getAttribute("data-testid").stripPrefix("cs-th-")) + + // `.all()` does not wait; the header commits a frame after the first rows do. + page.getByTestId("cs-th-topic").waitFor() + val before = headerKeys() + assert(before.indexOf("topic") > before.indexOf("value"), s"unexpected default order: $before") + + // Drag TOPIC onto VALUE: topic must land immediately before value, rows following the header. + page.getByTestId("cs-th-topic").dragTo(page.getByTestId("cs-th-value")) + val after = headerKeys() + assert( + after.indexOf("topic") == after.indexOf("value") - 1, + s"dragged column should sit immediately before its target, got $after" + ) + // The sticky pair stays put in front. + assert(after.take(2) == List("index", "publishTime"), s"sticky pair must stay first, got ${after.take(2)}") + + val stored = page.evaluate("() => localStorage.getItem('table:consumer-session-messages:column-order') || ''").toString + assert(stored.contains("topic"), s"expected a persisted message-column order, got '$stored'") + } + diff --git a/e2e/src/test/scala/features/consumersession/CsTopicKindsSpec.scala b/e2e/src/test/scala/features/consumersession/CsTopicKindsSpec.scala new file mode 100644 index 000000000..440f9268c --- /dev/null +++ b/e2e/src/test/scala/features/consumersession/CsTopicKindsSpec.scala @@ -0,0 +1,223 @@ +package features.consumersession + +import com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat +import com.microsoft.playwright.assertions.LocatorAssertions + +/** The Consumer Session across the whole topic matrix - {persistent, non-persistent} x + * {partitioned, non-partitioned}. Every test is generated from `TopicKind.all`, so all four + * quadrants run and a failure names its own quadrant. + * + * Why the matrix matters: `consumer/session_runner/handleStartFrom.scala` behaves differently per + * quadrant - a partitioned topic is expanded into its partitions and each gets its own consumer and + * its own seek, while a non-persistent one has no history to seek into at all - and the suite's + * only start-from coverage lived on persistent non-partitioned topics; the other three quadrants + * had none. + * + * Three broker facts shape the assertions here: + * - a NON-PERSISTENT topic retains nothing. Whatever is published while no consumer is attached + * is dropped forever, so "pre-produce, then Start From = Earliest" is meaningless there; only + * produce-AFTER-play is assertable. `kind.retains` gates which shape a quadrant gets. + * - producing to a PARTITIONED topic round-robins, and the session runs one consumer per + * partition, so the merged view has no total order. Every assertion below is on a SET. + * - a partitioned topic's partitions are what the session actually subscribes to, so + * "the session is attached" has to be checked per partition (see `awaitConsumersFlowing`). + * + * The arrangement and assertion helpers come from `StartFromSupport`, which this spec used to + * carry near-identical private copies of. The shared ones are stronger in the way that matters + * here: `assertLoadedExactlyWithCounter` also pins the toolbar's `cs-loaded` counter and requires + * it to STAY there, which the local copy could not - it compared the currently rendered rows of a + * VIRTUALIZED table, so a message loaded off-screen, or one that arrived just after the first + * matching poll, passed. + */ +class CsTopicKindsSpec extends StartFromSupport: + private def vis = new LocatorAssertions.IsVisibleOptions().setTimeout(30000) + + private def produce(fqn: String, values: Seq[String]): Unit = fixtures.produceUnbatched(fqn, values) + + /** Messages published BEFORE the session exists. Every test that arranges them also asserts none + * of them came back, so the prefix is what a stray one is recognized by. */ + private val OldPrefix = "old-" + + /** What the toolbar counter already stood at before a test's payload was produced. + * + * Zero on the retaining quadrants - nothing is loaded there until the payload is. The + * non-persistent ones first have to prove the stream is live by getting a handshake row rendered, + * and handshakes count towards `cs-loaded` too, so their baseline is whatever the counter settled + * at once the handshakes stopped arriving. */ + private def streamingBaseline(cs: ConsumerSessionPage, fqn: String, kind: fixtures.TopicKind): Int = + if kind.retains then 0 + else + awaitSessionStreaming(cs, fqn) + settledLoaded(cs) + + fixtures.TopicKind.all.foreach { kind => + + test(s"CS-TK-1 [${kind.label}]: the session mounts on the topic and starts") { + val (t, ns, topic, _) = fixtures.freshTopicPartsOfKind(kind) + val cs = ConsumerSessionPage(page) + cs.openForTopic(t, ns, topic, kind.scheme) + + // The configuration view comes first - the message table only mounts once a message exists. + assertThat(cs.startFromSelect).isVisible(vis) + assertThat(cs.playButton).isVisible(vis) + assert(cs.state == "new", s"unexpected initial session state: ${cs.state}") + + cs.play() + // 'running' is set only when CreateConsumer returns OK, which makes it a real server-side + // gate: the target resolved this topic kind, the consumers subscribed, and the start-from + // seek did not throw. A failure here is a broken quadrant, not a slow one. + cs.assertState("running") + assertThat(cs.awaitingText).isVisible(vis) + } + + test(s"CS-TK-2 [${kind.label}]: Start From = Latest loads exactly the messages produced after play") { + val (t, ns, topic, fqn) = fixtures.freshTopicPartsOfKind(kind) + // Sentinels published BEFORE the session exists. Without them this test could not tell + // "Latest" from "Earliest" at all: on a topic that was EMPTY at play time both modes deliver + // exactly the rows produced afterwards, so an implementation that ignored the selected mode + // passed. With them, the retaining quadrants have history on disk that only Latest keeps out - + // and it is the same history CS-TK-3 proves Earliest does deliver, so the two tests now + // disagree about the same topic contents rather than agreeing by construction. + // (On a non-persistent topic the broker drops these anyway - CS-TK-5 is where that is the + // point; here they are simply harmless.) + val old = (1 to 3).map(i => s"${OldPrefix}tk-$i") + produce(fqn, old) + + val cs = ConsumerSessionPage(page) + cs.openForTopic(t, ns, topic, kind.scheme) + cs.setStartFrom("Latest message") + cs.play() + cs.assertState("running") + awaitConsumersFlowing(fqn, kind) + val base = streamingBaseline(cs, fqn, kind) + + val payload = Seq("tk-1", "tk-2", "tk-3", "tk-last") // "tk-last" is produced last + produce(fqn, payload) + assertLoadedExactlyWithCounter(cs, payload, base) + val rendered = cs.columnValues("value") + assert( + !rendered.exists(_.startsWith(OldPrefix)), + s"Start From = Latest delivered a message published before the session started: $rendered" + ) + cs.waitHeader() // the real message table rendered, not just the empty state + } + + if kind.retains then + test(s"CS-TK-3 [${kind.label}]: Start From = Earliest loads the whole pre-produced set") { + val (t, ns, topic, fqn) = fixtures.freshTopicPartsOfKind(kind) + val pre = Seq("pre-1", "pre-2", "pre-3", "pre-last") + produce(fqn, pre) // round-robins across partitions when the kind is partitioned + + val cs = ConsumerSessionPage(page) + cs.openForTopic(t, ns, topic, kind.scheme) + cs.setStartFrom("Earliest message") + cs.play() + cs.assertState("running") + assertLoadedExactlyWithCounter(cs, pre) + } + + test(s"CS-TK-4 [${kind.label}]: Start From = Skip first n skips exactly the first n messages") { + val (t, ns, topic, fqn) = fixtures.freshTopicPartsOfKind(kind) + // The partitioned kind is fed through ONE partition deliberately: "the first n messages" is + // only well defined on a single ordered log, and a partitioned topic has no total order. + // This still drives the partitioned branch end to end - the topic is expanded into its + // partitions and every partition gets its own consumer and its own seek - it just makes the + // expectation unambiguous. + val log = if kind.isPartitioned then s"$fqn-partition-0" else fqn + val all = (1 to 6).map(i => s"m-$i") + produce(log, all) + + val cs = ConsumerSessionPage(page) + cs.openForTopic(t, ns, topic, kind.scheme) + cs.setStartFrom("Skip first n messages") + cs.startFromN.fill("2") + cs.play() + cs.assertState("running") + // The control is labelled "Skip first n messages", so n = 2 out of 6 must leave m-3..m-6. + assertLoadedExactlyWithCounter(cs, all.drop(2)) + } + + if kind.isPartitioned then + // UNTAGGED on 2026-07-25 - green in the normal lane. This was `KnownBug` twice over: first + // for a real defect (skipping across partitions dropped nothing at all), then because the + // expectation below - `all.drop(2)` - is a statement about a GLOBAL order that the + // then-current contract did not promise. Skip-N was resolved per partition, so it dropped + // two arbitrary messages and runs disagreed about which (m-1 + m-6 in one, m-4 + m-5 in + // another). + // + // Skip-N is now GLOBAL: it drops the n oldest messages of the merged stream by publish time, + // whichever partitions they came from. The payload below is produced through the parent one + // blocking send at a time, so publish order is m-1 .. m-6 and the two globally-oldest are + // m-1 and m-2 - exactly what `all.drop(2)` says. + // + // The SET is the assertion, not the count: dropping any other two also leaves four. + // + // The one residual assumption is that six blocking sends land in six distinct milliseconds - + // if two shared one, the global order between their partitions would be decided by the + // ordering key's tie-break rather than by produce order. A blocking send costs a broker round + // trip, and this ran green six times running, so it is stated rather than defended here. + // `CsStartFromMatrixSpec` CS-SFM-3 covers the same contract without the assumption: it spaces + // its publishes and derives the expectation from the broker's own publish times. + test(s"CS-TK-6 [${kind.label}]: Skip first n works when messages are spread across ALL partitions") { + // CS-TK-4 deliberately funnels every message through partition-0 to make "first n" + // unambiguous - which also means it never exercises the real multi-partition path. Here + // the default RoundRobinPartition router spreads 6 messages over 3 partitions (2 each), + // which is what ordinary produce traffic looks like. + val (t, ns, topic, fqn) = fixtures.freshTopicPartsOfKind(kind) + val all = (1 to 6).map(i => s"m-$i") + produce(fqn, all) // parent topic -> round-robin across the 3 partitions + + val cs = ConsumerSessionPage(page) + cs.openForTopic(t, ns, topic, kind.scheme) + cs.setStartFrom("Skip first n messages") + cs.startFromN.fill("2") + cs.play() + cs.assertState("running") + // Skipping 2 of 6 must leave 4 messages, whichever partitions they came from. + assertLoadedExactlyWithCounter(cs, all.drop(2)) + } + else + test(s"CS-TK-5 [${kind.label}]: nothing is retained - every history mode is refused, the live session still streams") { + val (t, ns, topic, fqn) = fixtures.freshTopicPartsOfKind(kind) + val dropped = (1 to 4).map(i => s"${OldPrefix}$i") + produce(fqn, dropped) // no consumer attached -> the broker drops these forever + + val cs = ConsumerSessionPage(page) + cs.openForTopic(t, ns, topic, kind.scheme) + // "Nothing is retained" is now enforced in the selector, not just observable afterwards: + // every history-based mode is disabled here, INCLUDING "Earliest message" - which used to be + // the misleading one, since with nothing retained it quietly behaved as "from now". + // Asserting the disabled set is what this test's title has always claimed to prove. + assert( + cs.disabledStartFromLabels.toSet == Set( + "Earliest message", + "Message with specific ID", + "Specific time", + "Relative time ago", + "Skip first n messages", + "Latest n messages", + // Both approximate modes need a history to be a proportion OF: one asks the broker for + // the topic's entry count, the other for its first and last publish times, and + // examineMessage answers neither on a non-persistent topic. + "About % through the data", + "About % through the time range" + ), + s"disabled modes on a non-persistent topic were: ${cs.disabledStartFromLabels}" + ) + assertThat(cs.startFromNonPersistentNote).isVisible(vis) + + cs.setStartFrom("Latest message") // the only position a non-persistent topic has + cs.play() + cs.assertState("running") + awaitConsumersFlowing(fqn, kind) + // Prove the session is really streaming BEFORE asserting the negative: an empty table only + // means "nothing was retained" once we know a message would have shown up. + val base = streamingBaseline(cs, fqn, kind) + + val live = Seq("new-1", "new-2", "new-last") + produce(fqn, live) + assertLoadedExactlyWithCounter(cs, live, base) + val rendered = cs.columnValues("value") + assert(!rendered.exists(_.startsWith(OldPrefix)), s"a non-persistent topic retained a pre-produced message: $rendered") + } + } diff --git a/e2e/src/test/scala/features/consumersession/CsTopicPositionsSpec.scala b/e2e/src/test/scala/features/consumersession/CsTopicPositionsSpec.scala new file mode 100644 index 000000000..2a8f394e8 --- /dev/null +++ b/e2e/src/test/scala/features/consumersession/CsTopicPositionsSpec.scala @@ -0,0 +1,140 @@ +package features.consumersession + +import harness.DekafSuite +import com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat + +/** The Topic Positions tab: the per-topic debug view in the Tools panel. + * + * WHAT ONLY AN END-TO-END TEST CAN SEE HERE. The server suite pins the arithmetic and the cursor + * bookkeeping against plain values; the jest suite pins the row model against a hand-built + * protobuf. Neither can tell whether the numbers a REAL broker produces survive the whole path - + * whether the entry counts add up to what was actually published, and whether a figure the server + * genuinely does not know arrives as a blank cell rather than as a confident 0%. + * + * POLLING IS GATED BY THE TAB ITSELF: opening it is the request, and only the tab on screen pays + * the per-partition broker cost (the jest suite pins the hidden-tab half). The session records + * its read position unconditionally either way, so the tab shows the full truth whenever it is + * opened - there is nothing to remember to turn on first. + */ +class CsTopicPositionsSpec extends DekafSuite: + + test("CS-TP-1: before the session is started the tab says so - no table, no raw server error") { + // The tab is reachable the moment the page loads, so opening it before Play is the ORDINARY + // case. Nothing exists to ask about yet: no poll is armed (the jest suite pins the zero-RPC + // half), so no FAILED_PRECONDITION can reach the screen and the generated session name cannot + // leak at the one moment the user has done nothing wrong. + val (t, ns, topic) = fixtures.freshTopicParts() + fixtures.produceStrings(s"persistent://$t/$ns/$topic", 10) + + val cs = ConsumerSessionPage(page) + cs.openForTopic(t, ns, topic) + cs.openTools() + + val tools = ToolsPanel(page) + assertThat(tools.topicPositionsTab).isVisible() + tools.topicPositionsTab.click() + + assertThat(tools.topicPositionsNotStarted).isVisible() + assertThat(tools.topicPositionsTable).not().isVisible() + assertThat(tools.topicPositionsError).not().isVisible() + // The internal session name must not reach the screen. + assertThat(page.getByText("__dekaf_")).not().isVisible() + } + + test("CS-TP-3: a running session reports each partition, and the entry counts ADD UP") { + // The arithmetic check that only a real broker can settle: three partitions, 30 messages, and + // the per-partition entry totals must sum to exactly what was published. + val (t, ns, topic, topicFqn) = fixtures.freshTopicPartsOfKind(fixtures.TopicKind.PersistentPartitioned) + fixtures.produceStrings(topicFqn, 30) + + val cs = ConsumerSessionPage(page) + cs.openForTopic(t, ns, topic) + cs.setStartFrom("Earliest message") + cs.play() + cs.awaitLoaded(30) + cs.openTools() + + val tools = ToolsPanel(page) + tools.topicPositionsTab.click() + + val partitions = (0 until 3).map(i => s"$topicFqn-partition-$i") + partitions.foreach(p => assertThat(tools.topicPositionsRow(p)).isVisible()) + + // WAIT for a settled frame first: the table refreshes once a second, and the frame on screen + // when the tab opens can legally predate the last messages read - parsing its '-' cells would + // test the refresh cycle's phase, not the arithmetic. + assertThat(tools.topicPositionsRow("All topics")).containsText("30 / 30") + + // "ordinal / total" in the ENTRIES READ column (cell 1 - consumption columns lead the table); + // the totals are what must reconcile. + val totals = partitions.map { p => + val cells = tools.topicPositionsCells(p) + cells(1).split('/').last.trim.replace(",", "").toInt + } + assert( + totals.sum == 30, + s"per-partition entry totals ${totals.mkString(" + ")} = ${totals.sum}, but 30 messages were published to $topicFqn" + ) + + // The ALL TOPICS row sums what the per-partition rows show - the whole session, one line. + val aggregate = tools.topicPositionsCells("All topics") + assert(aggregate(1).replace(",", "").contains("30 / 30"), s"aggregate entries read should be 30 / 30, got '${aggregate(1)}'") + assert(aggregate(2).trim == "0", s"aggregate entries left should be 0, got '${aggregate(2)}'") + } + + test("CS-TP-4: a figure the server does not know is BLANK, not 0%") { + // The distinction the whole view rests on. Starting at LATEST on a topic nobody is producing to + // means the session has read nothing, so it has no position - while the topic itself still has + // a first and a last message. Rendering the unknown as 0% would claim the session sits at the + // BEGINNING of the topic when it is in fact parked at the end. + val (t, ns, topic) = fixtures.freshTopicParts() + val topicFqn = s"persistent://$t/$ns/$topic" + fixtures.produceStrings(topicFqn, 10) + + val cs = ConsumerSessionPage(page) + cs.openForTopic(t, ns, topic) + cs.setStartFrom("Latest message") + cs.play() + cs.openTools() + + val tools = ToolsPanel(page) + tools.topicPositionsTab.click() + assertThat(tools.topicPositionsRow(topicFqn)).isVisible() + + val cells = tools.topicPositionsCells(topicFqn) + // The topic's own endpoints ARE known. + assert(cells(6).trim.nonEmpty && cells(6).trim != "-", s"first message id should be known, got '${cells(6)}'") + assert(cells(9).trim.nonEmpty && cells(9).trim != "-", s"last published should be known, got '${cells(9)}'") + // The session's position is not - including the lag clock, which without a cursor is a guess. + assert(cells(10).trim == "-", s"cursor id should be blank before anything is read, got '${cells(10)}'") + assert(cells(4).trim == "-", s"'behind' should be blank without a cursor, got '${cells(4)}'") + assert(cells(5).trim == "-", s"% of time range should be blank, got '${cells(5)}'") + assert(cells(3).trim == "-", s"% of entries should be blank, got '${cells(3)}'") + } + + test("CS-TP-6: sorting is live, and the ALL TOPICS row stays pinned on top through it") { + val (t, ns, topic, topicFqn) = fixtures.freshTopicPartsOfKind(fixtures.TopicKind.PersistentPartitioned) + fixtures.produceStrings(topicFqn, 30) + + val cs = ConsumerSessionPage(page) + cs.openForTopic(t, ns, topic) + cs.setStartFrom("Earliest message") + cs.play() + cs.awaitLoaded(30) + cs.openTools() + + val tools = ToolsPanel(page) + tools.topicPositionsTab.click() + assertThat(tools.topicPositionsRow("All topics")).isVisible() + + // The table opens sorted by topic ASCENDING (its defaultSort), so ONE click flips it to + // descending: partition-2 must lead the real rows, and the aggregate must not move - it + // summarizes the table, it does not compete with it. Descending is the interesting direction: + // the Table implements it by reversing the sorted array, which is exactly the operation that + // would flip a naive comparator-based pin to the bottom. + tools.topicPositionsSortBy("topic") + val rows = page.locator("[data-testid='topic-positions'] tbody tr").allTextContents() + assert(rows.size() >= 4, s"expected the aggregate plus 3 partitions, got ${rows.size()}") + assert(rows.get(0).contains("All topics"), s"the aggregate must stay pinned first, got '${rows.get(0).take(60)}'") + assert(rows.get(1).contains("partition-2"), s"desc sort by topic should lead with partition-2, got '${rows.get(1).take(80)}'") + } diff --git a/e2e/src/test/scala/features/consumersession/StartFromSupport.scala b/e2e/src/test/scala/features/consumersession/StartFromSupport.scala new file mode 100644 index 000000000..518942f28 --- /dev/null +++ b/e2e/src/test/scala/features/consumersession/StartFromSupport.scala @@ -0,0 +1,162 @@ +package features.consumersession + +import harness.DekafSuite +import harness.Eventually.eventually +import org.apache.pulsar.common.policies.data.TopicStats + +import scala.jdk.CollectionConverters.* + +/** Shared arrangement + assertion helpers for the Start-From outcome specs + * (`CsStartFromOutcomesSpec`, `CsStartFromMatrixSpec`). + * + * The one assertion that matters: every test states the EXACT set of message values the session + * must end up showing. Counting rows cannot express "skip first 5" - a run that skipped 50 and one + * that skipped 5 both reach *some* count, and a transient count is reached by almost anything. + */ +/** How a test's data is written to the broker. Both shapes are ordinary application behavior; only + * the second one was ever exercised before, which is why entry-vs-message defects in Start-From + * went unseen. */ +enum Produce(val label: String): + /** Several messages share ONE broker entry - the Java producer's default behavior. */ + case Batched extends Produce("batched") + /** One message per broker entry. */ + case Unbatched extends Produce("unbatched") + +trait StartFromSupport extends DekafSuite: + + /** Messages per entry for `Produce.Batched`. Chosen so the test payloads below land in a handful + * of entries, i.e. entry positions and message positions are far apart and cannot coincide. */ + val MessagesPerBatch = 4 + + def produceAs(mode: Produce, fqn: String, values: Seq[String]): Unit = mode match + case Produce.Batched => fixtures.produceBatched(fqn, values, MessagesPerBatch) + case Produce.Unbatched => fixtures.produceUnbatched(fqn, values) + + /** Handshake rows: produced only to prove the live stream is flowing, and excluded from every + * payload assertion by this prefix. */ + val HandshakePrefix = "handshake-" + + /** The session must show EXACTLY `expected`, no more and no less. + * + * Set-based because a partitioned topic merges several consumers and has no total order. Waiting + * for the whole set (rather than a count) is what makes it non-transient: a missing message keeps + * polling until the deadline, and an extra one can never satisfy the equality. + * + * What it does NOT prove is that nothing else was loaded: the message table is virtualized, so + * off-screen rows are not in `columnValues` at all, and a message arriving after the first + * matching poll is never looked at. Prefer [[assertLoadedExactlyWithCounter]], which closes both; + * this bare form is for the quadrants where the counter cannot be predicted. */ + def assertLoadedExactly(cs: ConsumerSessionPage, expected: Seq[String]): Unit = + val rendered = eventually(timeoutMs = 45000, intervalMs = 400) { + val all = cs.columnValues("value") + val payload = all.filterNot(_.startsWith(HandshakePrefix)) + assert( + payload.toSet == expected.toSet, + s"the session shows ${payload.size} message(s): $payload\n expected exactly ${expected.size}: $expected" + + s"\n missing: ${expected.toSet.diff(payload.toSet)}\n unexpected: ${payload.toSet.diff(expected.toSet)}" + ) + payload + } + assert(rendered.size == expected.size, s"a message is rendered more than once: $rendered") + + /** Same, plus the toolbar's loaded counter - two things the rendered rows alone cannot say. + * + * The message table is VIRTUALIZED, so a set built from DOM rows cannot rule out messages loaded + * OFF-SCREEN; and the set assertion above is satisfied by the first poll that sees the right + * rows, so a message arriving AFTER that - the shape a too-wide start-from produces - would slip + * past it. The counter answers the first, and requiring the counter to STAY put answers the + * second. + * + * `base` is what the counter already stood at before `expected` was produced. It is 0 whenever no + * handshake rows are in play; a quadrant that needs them takes its baseline from + * [[settledLoaded]], because how many handshakes survived is not knowable up front (the early + * ones are dropped by design) yet all of them count towards `cs-loaded`. */ + def assertLoadedExactlyWithCounter(cs: ConsumerSessionPage, expected: Seq[String], base: Int = 0): Unit = + assertLoadedExactly(cs, expected) + val total = base + expected.size + cs.awaitLoaded(total) + val settled = settledLoaded(cs) + assert( + settled == total, + s"the session loaded $settled message(s) once the counter stopped moving, expected $total " + + s"(${expected.size} expected + $base already loaded before them). Rendered: ${cs.columnValues("value")}" + ) + + /** The loaded counter once it has STOPPED MOVING, i.e. two reads `quietMs` apart that agree. + * + * The window is quiescence, never readiness - nothing here is waiting for the app to catch up + * with a request, it is waiting for the app to prove it has nothing more to deliver. That is the + * only observable form "and no further message arrives" can take: a counter that has reached the + * right value tells you nothing about the message still in flight behind it. + * + * LIMIT, stated so it is not mistaken for airtight: the quiet window is finite (~1.2s), so an + * over-delivery arriving LATER than it - most plausibly a message the session left un-acked being + * redelivered on the broker's ack timeout, tens of seconds out - would land after this returns and + * still pass a "shows EXACTLY n" spec. The window is deliberately NOT widened to cover that: it is + * on the hot path of every counting cell, and a redelivery interval is far too long to wait per + * test. What actually forbids the over-delivery is pinned at the SERVER tier, where it is cheap and + * deterministic - the session acks every delivered message exactly once and a failing progress push + * cannot cost a skipped message its ack (`sessionOutputSerializationTest`, suite "a failing progress + * push must not cost a skipped message"), and the start-from discard is applied exactly once and is + * re-armed by neither a redelivery nor a resume (`startFromDiscardOnceTest`). This helper is the + * UI-level cross-check on top of that, not the primary guard against duplication. */ + def settledLoaded(cs: ConsumerSessionPage, quietMs: Int = 1200): Int = + eventually(timeoutMs = 30000, intervalMs = 200) { + val before = cs.loadedCount + page.waitForTimeout(quietMs) + val after = cs.loadedCount + assert(before == after, s"the loaded counter is still moving: $before then $after") + after + } + + /** Available-permit counts per consumer, keyed by the non-partitioned topic each is attached to. + * `availablePermits > 0` is the signal that the client has actually issued flow permits. */ + private def sessionConsumerPermits(fqn: String, kind: fixtures.TopicKind): Map[String, List[Int]] = + def permitsOf(st: TopicStats): List[Int] = + st.getSubscriptions.values().asScala.toList + .flatMap(sub => sub.getConsumers.asScala.toList.map(_.getAvailablePermits)) + if kind.isPartitioned then + admin.topics().getPartitionedStats(fqn, true).getPartitions.asScala.toMap.map { case (p, st) => p -> permitsOf(st) } + else Map(fqn -> permitsOf(admin.topics().getStats(fqn))) + + /** Wait until the session is consuming EVERY part of the topic - one attachment for a + * non-partitioned topic, one per partition otherwise. */ + def awaitConsumersFlowing(fqn: String, kind: fixtures.TopicKind): Unit = + val expected = math.max(kind.partitions, 1) + eventually(timeoutMs = 30000, intervalMs = 400) { + val permits = + try sessionConsumerPermits(fqn, kind) + catch case _: Throwable => Map.empty[String, List[Int]] // topic not materialized yet + assert( + permits.size == expected && permits.values.forall(_.exists(_ > 0)), + s"the session is not consuming every part of $fqn: expected $expected attachment(s) with permits > 0, got $permits" + ) + } + + /** Produce handshake messages until the session RENDERS one, i.e. until the whole path + * (broker -> consumer -> listener -> gRPC stream -> table) is provably live. + * + * Needed on NON-PERSISTENT topics: nothing is retained there, so anything published before the + * session's message handler is installed is acked, discarded and unrecoverable. A rendered row is + * the only observable proof that window has closed. The retries are why the prefix is filtered + * out of payload assertions. */ + def awaitSessionStreaming(cs: ConsumerSessionPage, fqn: String): Unit = + var attempt = 0 + eventually(timeoutMs = 60000, intervalMs = 1200) { + attempt += 1 + fixtures.produceUnbatched(fqn, Seq(s"$HandshakePrefix$attempt")) + val rendered = cs.columnValues("value") + assert(rendered.exists(_.startsWith(HandshakePrefix)), s"the session has not rendered a handshake message yet: $rendered") + } + + /** Block until `gapMs` has elapsed since `sinceMs`. + * + * This is ARRANGEMENT, not a readiness wait: the time-addressed Start-From modes can only be + * asserted if the test data straddles a known instant, and the only way to put messages on + * either side of one is to publish them at different times. Expressed as a polled predicate so + * it cannot silently become "sleep and hope the app caught up". */ + def awaitClockGap(sinceMs: Long, gapMs: Long): Unit = + eventually(timeoutMs = gapMs + 30000, intervalMs = 200) { + val elapsed = System.currentTimeMillis() - sinceMs + assert(elapsed >= gapMs, s"only ${elapsed}ms of the required ${gapMs}ms gap has elapsed") + } diff --git a/e2e/src/test/scala/features/library/LibraryNotesSpec.scala b/e2e/src/test/scala/features/library/LibraryNotesSpec.scala index c2229fe52..981917fdd 100644 --- a/e2e/src/test/scala/features/library/LibraryNotesSpec.scala +++ b/e2e/src/test/scala/features/library/LibraryNotesSpec.scala @@ -45,6 +45,68 @@ class LibraryNotesSpec extends DekafSuite: assertThat(lib.createFirstNoteButton).isVisible() // back to empty state } + /** Hold every `ListLibraryItems` response back by `delayMs`, from inside the browser. + * + * ARRANGEMENT, not a readiness wait: the panel's loading state is only observable while its first + * fetch is genuinely in flight, and on a local stack that window is a few milliseconds wide - far + * too narrow for a test to land in reliably. Widening it deterministically is the only way to + * make the state a test can be written against. + * + * Done by wrapping `XMLHttpRequest.send` in the page (grpc-web's transport) rather than with + * Playwright's `page.route`: a Java route handler that sleeps blocks the driver's dispatch loop, + * which would ALSO stall the test's own `isVisible` call and hide the very race being reproduced. + * A `setTimeout` in the page delays exactly one request and nothing else. The delay is bounded + * and small - the point is to be reliably slower than a click, not to test a timeout. */ + private def delayListLibraryItems(delayMs: Int): Unit = + context.addInitScript( + s"""(() => { + | const marker = 'LibraryService/ListLibraryItems'; + | const openOrig = XMLHttpRequest.prototype.open; + | const sendOrig = XMLHttpRequest.prototype.send; + | XMLHttpRequest.prototype.open = function (method, url) { + | this.__delayUrl = String(url); + | return openOrig.apply(this, arguments); + | }; + | XMLHttpRequest.prototype.send = function () { + | const args = arguments; + | if (this.__delayUrl && this.__delayUrl.indexOf(marker) !== -1) { + | window.__delayedListLibraryItems = (window.__delayedListLibraryItems || 0) + 1; + | setTimeout(() => sendOrig.apply(this, args), $delayMs); + | return; + | } + | return sendOrig.apply(this, args); + | }; + |})();""".stripMargin + ) + + // The regression for the Notes panel's readiness precondition. Until it was fixed, `createNote` + // asked `createFirstNoteButton.isVisible` - a question with no wait attached - while the panel was + // still rendering "Loading...". Neither button exists then, so the answer was `false`, the else + // branch clicked `lib-new-note`, and that button can never appear on a topic with no notes: the + // test burned its whole timeout and failed on an app that was working perfectly. + // + // Nothing pinned that, because ordinary tests never delay `ListLibraryItems` and the panel settles + // in milliseconds on a local stack - the flake needed a slow or loaded machine to appear at all. + test("LIB-22: a note can be created while the first ListLibraryItems is still in flight") { + delayListLibraryItems(3000) + val (t, ns, topic) = openTopicOverview() + val lib = LibrarySidebar(page) + lib.openNotesTab() + + // The precondition this test exists for: the panel really is in its unsettled state, with + // NEITHER button on screen. Asserted rather than assumed - if the delay ever stopped taking + // effect the test below would still pass, and would silently stop covering anything. + assert(!lib.createFirstNoteButton.isVisible, "the panel had already settled - the response delay did not take effect") + assert(!lib.newNoteButton.isVisible, "the panel had already settled - the response delay did not take effect") + + lib.createNote() + assertThat(lib.noteTab("Note 1")).hasCount(1, new LocatorAssertions.HasCountOptions().setTimeout(20000)) + + // ... and the delay applied to the real request, not to some other call that happened to match. + val delayed = page.evaluate("() => window.__delayedListLibraryItems || 0").asInstanceOf[Number].intValue + assert(delayed > 0, "no ListLibraryItems request was delayed") + } + // NOTE: instance-scope leg is NOT parallel-safe and leaks one instance note (no LibraryService teardown). // Run in a serial lane. See packet NOTES. test("LIB-16: the '⭐️ Updates' pseudo-note appears only on the Instance scope") { diff --git a/e2e/src/test/scala/harness/BatchingFixtureSpec.scala b/e2e/src/test/scala/harness/BatchingFixtureSpec.scala new file mode 100644 index 000000000..f10906746 --- /dev/null +++ b/e2e/src/test/scala/harness/BatchingFixtureSpec.scala @@ -0,0 +1,170 @@ +package harness + +import harness.Eventually.eventually + +import java.nio.charset.StandardCharsets.UTF_8 +import scala.jdk.CollectionConverters.* + +/** Broker-level spec for the batching fixtures - no UI, PulsarAdmin is both the arrange and the + * assert side. + * + * Two things are pinned here, and everything the batched start-from coverage claims rests on them: + * + * 1. `fixtures.produceBatched` really does put several messages into ONE broker entry, and + * `fixtures.produceUnbatched` really does put exactly one message in each. Without this the + * "batched" half of the matrix would be indistinguishable from the unbatched half and would + * silently prove nothing - the very blind spot that coverage exists to close. + * + * 2. `PulsarAdmin.examineMessage` addresses ENTRIES, not messages, and past the end of the log it + * answers with the wrong entry ("earliest") or throws ("latest") rather than saying so. That is + * the mechanism by which "skip the first 5 messages" silently skipped fifty while the two + * counting Start-From modes were built on it. They no longer are - the seek was rewritten on + * 2026-07-25 to count delivered MESSAGES - so this is now a pin on the primitive rather than on + * the app: it records why entry-addressing cannot implement a message-count contract, so + * nobody rebuilds those modes on it. + */ +class BatchingFixtureSpec extends DekafSuite: + + private def valueOf(m: org.apache.pulsar.client.api.Message[Array[Byte]]): String = + new String(m.getData, UTF_8) + + /** `numberOfEntries` is read back through the admin API; give it a beat to catch up with writes + * we already hold producer acks for. */ + private def awaitEntries(fqn: String, atLeast: Long): Long = + eventually(timeoutMs = 10000, intervalMs = 200) { + val n = fixtures.numberOfEntries(fqn) + assert(n >= atLeast, s"only $n entrie(s) visible on $fqn yet (want >= $atLeast)") + n + } + + test("BATCH-1: produceBatched packs many messages into few entries; produceUnbatched does not") { + val (t, ns, _) = fixtures.freshTopicParts() + + // --- batched: 100 messages, 50 per batch -> 2 entries ------------------------------------- + val batchedFqn = fixtures.createTopic(t, ns) + val values = (1 to 100).map(i => f"m-$i%03d") + fixtures.produceBatched(batchedFqn, values, messagesPerBatch = 50) + + val batchedEntries = awaitEntries(batchedFqn, 1) + assert(batchedEntries == 2, s"100 messages at 50/batch should be 2 broker entries, got $batchedEntries") + // The messages themselves must all be there - "few entries" must not mean "lost messages". + val read = fixtures.readAllMessages(batchedFqn).map(_.getValue) + assert(read == values.toVector, s"batched produce lost or reordered messages: got ${read.size} - $read") + + // --- unbatched: one entry per message ------------------------------------------------------ + val unbatchedFqn = fixtures.createTopic(t, ns) + fixtures.produceUnbatched(unbatchedFqn, values) + val unbatchedEntries = awaitEntries(unbatchedFqn, 100) + assert(unbatchedEntries == 100, s"unbatched produce should be 1 entry per message, got $unbatchedEntries for 100") + + // --- and the shape every pre-existing fixture produced -------------------------------------- + // `produceStrings` leaves the client default (batching ON) but sends with a BLOCKING send, which + // closes a one-message batch every time. That is why the suite could never see an entry-vs- + // message confusion before: every fixture in it produced entry-per-message data. + val legacyFqn = fixtures.createTopic(t, ns) + fixtures.produceStrings(legacyFqn, 100) + val legacyEntries = awaitEntries(legacyFqn, 100) + assert(legacyEntries == 100, s"blocking send should still be 1 entry per message, got $legacyEntries") + } + + test("BATCH-2: examineMessage is ENTRY-addressed; past the end it clamps (earliest) or fails (latest)") { + val (t, ns, _) = fixtures.freshTopicParts() + val fqn = fixtures.createTopic(t, ns) + val values = (1 to 100).map(i => f"m-$i%03d") + fixtures.produceBatched(fqn, values, messagesPerBatch = 50) // -> entry 1 = m-001.., entry 2 = m-051.. + assert(awaitEntries(fqn, 2) == 2) + + def examine(position: String, n: Long): String = + valueOf(admin.topics().examineMessage(fqn, position, n)) + + // Position 1 and 2 are the two ENTRIES - each answers with the FIRST message of that entry, not + // with the 1st and 2nd messages of the topic. + assert(examine("earliest", 1) == "m-001", s"entry 1 answered ${examine("earliest", 1)}") + assert(examine("earliest", 2) == "m-051", s"entry 2 answered ${examine("earliest", 2)} - if this is m-002, examineMessage is message-addressed") + + // Everything past the last entry answers with the last entry instead of failing. This is the + // whole defect mechanism for "skip first n": the mode asks for position n+1, gets entry 2, and + // silently skips 50 messages. + for n <- Seq(3L, 50L, 100L) do + assert( + examine("earliest", n) == "m-051", + s"examineMessage(earliest, $n) answered ${examine("earliest", n)}; expected the last entry's first message (m-051). " + + "Message-addressing would have answered m-003/m-050/m-100." + ) + + // Counting back from the end is entry-addressed too... + assert(examine("latest", 1) == "m-051", s"latest,1 answered ${examine("latest", 1)}") + assert(examine("latest", 2) == "m-001", s"latest,2 answered ${examine("latest", 2)}") + + // ...but past the end it does NOT clamp - it fails outright (ManagedLedgerException surfaced as + // an admin 500). Note the asymmetry with "earliest" above: a caller that only tested one + // direction would conclude examineMessage either always clamps or always fails, and both + // conclusions are wrong. + // + // This asymmetry is load-bearing in BOTH directions of the rewrite. It is why the old "Latest n + // messages" seek fell back to EARLIEST and showed every message there is: it caught the failure + // and could not tell "past the start of the log" from "the broker refused". And it is what the + // current `resolveLatestN` MERGED backward walk uses as its per-topic end-of-log signal - one + // cursor per topic steps back entry by entry while the merge accumulates message counts to n, + // and a cursor only leaves the merge when this failure (classified by `isEmptyLogAnswer`, so a + // genuine broker error still aborts instead) says its log has no older entry. Nothing may be + // built on "earliest" instead: that side clamps silently. + for n <- Seq(3L, 50L, 100L) do + val thrown = intercept[org.apache.pulsar.client.admin.PulsarAdminException] { + admin.topics().examineMessage(fqn, "latest", n) // must NOT return a message + } + assert( + Option(thrown.getMessage).exists(_.contains("Incorrect parameter input")), + s"examineMessage(latest, $n) failed for an unexpected reason: ${thrown.getMessage}" + ) + } + + test("BATCH-4: on a partitioned topic only the partitions holding data can answer examineMessage") { + // Why a per-partition search cannot be made to answer a global "n-th message" question: a + // partition holding no data cannot answer at all, so any algorithm that polls every partition + // sees candidates only from the ones that happen to hold data. This also pins that a partition + // of a partitioned topic is entry-addressed exactly like any other topic. + val kind = fixtures.TopicKind.PersistentPartitioned + val (t, ns, _, fqn) = fixtures.freshTopicPartsOfKind(kind) + val values = (1 to 12).map(i => f"m-$i%02d") + fixtures.produceBatched(s"$fqn-partition-0", values, 4) // -> 3 entries, all on partition 0 + + // Materialize every partition the way a session subscribing to the parent does. + val consumer = client.newConsumer().topic(fqn).subscriptionName("batch-4-probe").subscribe() + try + val partitions = admin.topics().getList(s"$t/$ns").asScala.toList.sorted + assert(partitions.size == 3, s"expected 3 materialized partitions, got $partitions") + + val loaded = partitions.filter(_.endsWith("-partition-0")) + val empty = partitions.filterNot(_.endsWith("-partition-0")) + + // The loaded partition answers entry-wise, and clamps past the end just like BATCH-2. + assert(valueOf(admin.topics().examineMessage(loaded.head, "earliest", 6)) == "m-09") + + // The empty ones cannot answer at all - so a multi-partition search only ever sees candidates + // from partitions that happen to hold data. + empty.foreach { p => + val thrown = intercept[org.apache.pulsar.client.admin.PulsarAdminException] { + admin.topics().examineMessage(p, "earliest", 6) + } + assert( + Option(thrown.getMessage).exists(_.contains("total message is zero")), + s"$p failed for an unexpected reason: ${thrown.getMessage}" + ) + } + finally consumer.close() + } + + test("BATCH-3: on unbatched data examineMessage positions coincide with message numbers") { + // The control for BATCH-2: with one message per entry, entry-addressing and message-addressing + // are the same thing - which is exactly why unbatched fixtures could never expose the defect. + val (t, ns, _) = fixtures.freshTopicParts() + val fqn = fixtures.createTopic(t, ns) + val values = (1 to 10).map(i => f"m-$i%03d") + fixtures.produceUnbatched(fqn, values) + assert(awaitEntries(fqn, 10) == 10) + + for n <- 1 to 10 do + val got = valueOf(admin.topics().examineMessage(fqn, "earliest", n.toLong)) + assert(got == f"m-$n%03d", s"examineMessage(earliest, $n) answered $got") + } diff --git a/e2e/src/test/scala/harness/StackScriptsSpec.scala b/e2e/src/test/scala/harness/StackScriptsSpec.scala new file mode 100644 index 000000000..d3e38587e --- /dev/null +++ b/e2e/src/test/scala/harness/StackScriptsSpec.scala @@ -0,0 +1,204 @@ +package harness + +import org.scalatest.funsuite.AnyFunSuite + +import java.nio.file.{Files, Path, Paths} +import scala.jdk.CollectionConverters.* + +/** The shell scripts that bring the stack up and take it down - the one part of the harness that no + * UI test can reach, because a test cannot run them without destroying the stack it is running on. + * + * Scoped tightly to what is testable in isolation: `scripts/fresh-data-dir.sh`, which exists so + * that `run-dekaf.sh` and `stack-down.sh` agree on the throwaway `DEKAF_DATA_DIR` path - one per + * STACK, derived from the same `DEKAF_PORT` / `PULSAR_CONTAINER_NAME` both ends already read. + * + * Why it needs pinning at all: `run-dekaf.sh` ends in `exec sbt run`, so the process that creates + * the tree is replaced by the server, and CI then kills that whole process tree - no trap, atexit + * or JVM shutdown hook in the server's own lifetime can ever fire. Cleanup therefore has to come + * from OUTSIDE, from a step that knows the path without being told, and the previous `mktemp -d` + * made that impossible: a fresh unguessable name every build, one abandoned tree per build, forever, + * on a self-hosted runner nobody wipes. + * + * No `DekafSuite` here on purpose - these need no browser, no Pulsar and no Dekaf. + */ +class StackScriptsSpec extends AnyFunSuite: + + /** The scripts dir. sbt forks tests with the project base (`e2e/`) as the working directory; the + * repo-root fallback keeps the spec runnable from an IDE that chose differently. */ + private val scripts: Path = + Seq(Paths.get("scripts"), Paths.get("e2e/scripts")) + .find(p => Files.isDirectory(p)) + .getOrElse(fail(s"cannot locate e2e/scripts from ${Paths.get("").toAbsolutePath}")) + + private def run(script: String, args: Seq[String], env: Map[String, String]): (Int, String) = + val pb = new ProcessBuilder((Seq("bash", scripts.resolve(script).toString) ++ args).asJava) + pb.redirectErrorStream(true) + env.foreach((k, v) => pb.environment().put(k, v)) + val process = pb.start() + val out = new String(process.getInputStream.readAllBytes(), "UTF-8") + (process.waitFor(), out.trim) + + /** The environment of one stack: its runner temp plus the two variables that identify it. Both are + * already read by the scripts on either end (`run-dekaf.sh` serves on `DEKAF_PORT`, + * `stack-down.sh` removes `PULSAR_CONTAINER_NAME`), which is what lets startup and teardown agree + * on a path without anything being passed between them. */ + private def stackEnv(runnerTemp: Path, dekafPort: String, container: String): Map[String, String] = + Map("RUNNER_TEMP" -> runnerTemp.toString, "DEKAF_PORT" -> dekafPort, "PULSAR_CONTAINER_NAME" -> container) + + private def freshDataDirWith(env: Map[String, String]): String = + val (code, out) = run("fresh-data-dir.sh", Seq("path"), env) + assert(code == 0, s"fresh-data-dir.sh path exited $code: $out") + out + + private def freshDataDir(runnerTemp: Path): String = + freshDataDirWith(Map("RUNNER_TEMP" -> runnerTemp.toString)) + + test("STACK-1: the fresh data dir is a DETERMINISTIC path under $RUNNER_TEMP") { + val runnerTemp = Files.createTempDirectory("stack-1-runner-temp") + try + // Deterministic: asking twice gives the same answer. This is the whole property - `mktemp -d` + // answered differently every time, which is what made the tree unfindable afterwards. + val first = freshDataDir(runnerTemp) + val second = freshDataDir(runnerTemp) + assert(first == second, s"the path is not stable across invocations: $first then $second") + + // ... and it lives under the runner-scoped temp dir it was given, not somewhere of its own + // choosing. A path that ignored RUNNER_TEMP would be stable AND still outlive the job. + assert( + first.startsWith(runnerTemp.toString + "/"), + s"$first is not under the RUNNER_TEMP it was given ($runnerTemp)" + ) + + // A different runner temp really moves it - so the value is read, not merely mentioned. + val elsewhere = Files.createTempDirectory("stack-1-other-runner-temp") + try assert(freshDataDir(elsewhere) != first, s"the path ignores RUNNER_TEMP: $first for both $runnerTemp and $elsewhere") + finally Files.deleteIfExists(elsewhere) + finally Files.deleteIfExists(runnerTemp) + } + + test("STACK-2: `clean` removes the data tree, and succeeds when there is nothing to remove") { + val runnerTemp = Files.createTempDirectory("stack-2-runner-temp") + try + val dir = Paths.get(freshDataDir(runnerTemp)) + // Seed something shaped like what run-dekaf.sh puts there - nested, non-empty, so a `rmdir` + // or a single-file delete would not be enough. + Files.createDirectories(dir.resolve("library")) + Files.createDirectories(dir.resolve("js/dist")) + Files.writeString(dir.resolve("js/dist/libs.js"), "// seeded by STACK-2") + assert(Files.isDirectory(dir), s"the fixture did not create $dir") + + val (code, out) = run("fresh-data-dir.sh", Seq("clean"), Map("RUNNER_TEMP" -> runnerTemp.toString)) + assert(code == 0, s"clean exited $code: $out") + assert(!Files.exists(dir), s"$dir survived the clean: $out") + + // Teardown runs with `if: always()`, including on runs that never created the tree, so a + // second clean must not fail the job. + val (againCode, againOut) = run("fresh-data-dir.sh", Seq("clean"), Map("RUNNER_TEMP" -> runnerTemp.toString)) + assert(againCode == 0, s"a second clean exited $againCode: $againOut") + finally Files.deleteIfExists(runnerTemp) + } + + /** Recursive rm for this spec's fixtures - the trees it seeds are nested, and one half of STACK-4 + * is deliberately NOT removed by the script under test. */ + private def deleteTree(dir: Path): Unit = + if Files.exists(dir) then + Files.walk(dir).sorted(java.util.Comparator.reverseOrder()).iterator().asScala.foreach(Files.deleteIfExists) + + test("STACK-4: each stack gets its OWN tree, and tearing one down leaves the other's live data alone") { + // The defect this pins: the path used to be one constant name per machine, while run-dekaf.sh + // `rm -rf`s it at startup and stack-down.sh `rm -rf`s it at teardown. Two stacks side by side - + // which is exactly how a second Dekaf is run against the same box - therefore destroyed each + // other's LIVE data dir, and nothing in STACK-1/2 could notice: determinism and removal are both + // still true of a path that every stack shares. + val runnerTemp = Files.createTempDirectory("stack-4-runner-temp") + try + val a = stackEnv(runnerTemp, dekafPort = "8090", container = "dekaf-e2e-pulsar") + // Differs from `a` only by PORT: two Dekafs against ONE Pulsar container is a real shape (this + // repo runs one on :8090 and one on :8091), and it is the case a container-only identity misses. + val bSamePulsar = stackEnv(runnerTemp, dekafPort = "8091", container = "dekaf-e2e-pulsar") + // ... and differs only by CONTAINER, the case a port-only identity misses. + val cSamePort = stackEnv(runnerTemp, dekafPort = "8090", container = "dekaf-e2e-pulsar-2") + + val stacks = List("a" -> a, "b(same pulsar, other port)" -> bSamePulsar, "c(same port, other pulsar)" -> cSamePort) + val paths = stacks.map((name, env) => name -> freshDataDirWith(env)) + assert( + paths.map(_._2).distinct.size == stacks.size, + s"two distinct stacks resolved to the SAME data dir, so one would delete the other's live data: $paths" + ) + // Still deterministic PER STACK - asking twice must agree, or teardown could not find the tree + // startup made, which is the whole reason this script exists (STACK-1 checks the same property + // for one stack; an identity built from a timestamp or a PID would pass that and fail here). + stacks.zip(paths).foreach { case ((name, env), (_, path)) => + val again = freshDataDirWith(env) + assert(again == path, s"stack $name's path is not stable across invocations: $path then $again") + } + + // Now the real proof: two live trees, tear ONE down, and the other must survive byte for byte. + val dirA = Paths.get(paths.head._2) + val dirB = Paths.get(paths(1)._2) + def seed(dir: Path, marker: String): Unit = + Files.createDirectories(dir.resolve("library")) + Files.writeString(dir.resolve("library/item.json"), marker) + seed(dirA, "stack A's library item") + seed(dirB, "stack B's library item") + + val (code, out) = run("fresh-data-dir.sh", Seq("clean"), a) + assert(code == 0, s"clean exited $code: $out") + assert(!Files.exists(dirA), s"stack A's own tree survived its own teardown: $out") + assert(Files.isRegularFile(dirB.resolve("library/item.json")), s"tearing stack A down deleted stack B's data dir ($dirB): $out") + assert( + Files.readString(dirB.resolve("library/item.json")) == "stack B's library item", + s"tearing stack A down rewrote stack B's live data: ${Files.readString(dirB.resolve("library/item.json"))}" + ) + // The one hazard per-stack paths introduce is the opposite of the old one: a teardown run + // without its stack's variables now LEAKS a tree rather than destroying a live one. `clean` + // has to name what it left, or that leak is as invisible as the `mktemp -d` one was. + assert(out.contains(dirB.toString), s"clean did not report the tree it left behind ($dirB):\n$out") + + // Symmetric: B's own teardown removes B. (A one-way property would be satisfied by a script + // that simply never removed anything but its first argument's tree.) + val (bCode, bOut) = run("fresh-data-dir.sh", Seq("clean"), bSamePulsar) + assert(bCode == 0, s"clean exited $bCode: $bOut") + assert(!Files.exists(dirB), s"stack B's own tree survived its own teardown: $bOut") + finally deleteTree(runnerTemp) + } + + test("STACK-3: run-dekaf.sh and stack-down.sh both go through the shared path") { + // STATIC on purpose. The behaviour above is executed; this pins the WIRING, which cannot be: + // `run-dekaf.sh` installs npm dependencies, builds the UI and ends in `exec sbt run`, and + // `stack-down.sh` deletes the Pulsar container the rest of this suite is running against. The + // check is here rather than nowhere because the deterministic path is worthless if either end + // stops using it - and each of them would still pass its own tests. + def source(name: String): String = Files.readString(scripts.resolve(name)) + + val runDekaf = source("run-dekaf.sh") + assert( + runDekaf.contains("""fresh_data="$("$here/fresh-data-dir.sh" path)""""), + "run-dekaf.sh no longer takes its DEKAF_FRESH_DATA directory from fresh-data-dir.sh" + ) + // Comments are excluded deliberately: the script EXPLAINS why it is no longer on mktemp, and a + // whole-file search would match that sentence and never be able to fail for the real reason. + val runDekafCode = runDekaf.linesIterator.filterNot(_.trim.startsWith("#")).mkString("\n") + assert( + !runDekafCode.contains("mktemp"), + "run-dekaf.sh is back on mktemp - the tree becomes unfindable and leaks on every CI run" + ) + assert( + runDekaf.contains("""export DEKAF_DATA_DIR="$fresh_data""""), + "run-dekaf.sh no longer points DEKAF_DATA_DIR at that directory" + ) + assert( + source("stack-down.sh").contains("""fresh-data-dir.sh" clean"""), + "stack-down.sh no longer removes the fresh data dir - CI's teardown step is the only thing that can" + ) + + // Cheap correctness gate on all three: an edit that broke the syntax would otherwise only + // surface on CI, where these run once each and nothing else exercises them. + Seq("fresh-data-dir.sh", "run-dekaf.sh", "stack-down.sh").foreach { name => + val pb = new ProcessBuilder(Seq("bash", "-n", scripts.resolve(name).toString).asJava) + pb.redirectErrorStream(true) + val process = pb.start() + val out = new String(process.getInputStream.readAllBytes(), "UTF-8") + assert(process.waitFor() == 0, s"$name does not parse: $out") + } + } diff --git a/e2e/src/test/scala/harness/SuiteFactsSpec.scala b/e2e/src/test/scala/harness/SuiteFactsSpec.scala new file mode 100644 index 000000000..46dd99c9a --- /dev/null +++ b/e2e/src/test/scala/harness/SuiteFactsSpec.scala @@ -0,0 +1,152 @@ +package harness + +import org.scalatest.funsuite.AnyFunSuite + +import java.nio.file.{Files, Path, Paths} +import scala.jdk.CollectionConverters.* + +/** The README's countable claims about this suite, checked against the suite. + * + * `e2e/README.md` is the catalog, and several of its statements are facts about the source rather + * than prose: how many tests are `ignore`d, how many are `pending`, how many carry the `KnownBug` + * tag, how many `assume(...)` (and where), and how many tags `build.sbt` excludes from the green + * lane. Every one of those is a way coverage can be dropped from `sbt test` WITHOUT the run going + * red, so every one is pinned here against a machine-readable marker the README carries: + * + * {{{ + * + * }}} + * + * The point is not to correct a sentence once but to make the numbers derivable, so the next edit + * that adds a lane has to say so in the README or fail here. The lanes and why each hides coverage: + * + * - `ignore(...)` - a test that never runs and reports neither pass nor fail; + * - `pending` / `pendingUntilFixed` - reported yellow, i.e. neither passed nor failed; + * - `test("name", KnownBug)` (or `taggedAs KnownBug`) - excluded from `sbt test` by build.sbt. + * `known-bug=0` is also the standing rule that the bug lane stays EMPTY: the tag exists for a bug + * that is genuinely open, and tagging a red test to get a run green is the misuse it invites; + * - `assume(...)` - RUNTIME-cancels the test when its predicate is false, so on the wrong stack it + * just vanishes from the run, neither failing nor reported as ignored. Legitimate for the two + * topic-policy specs, which adapt to the broker's `topicLevelPoliciesEnabled` and therefore + * always cancel one branch - but ONLY there, which is why both the count and the location are + * pinned; + * - a second `-l ` exclusion in build.sbt - drops a whole tag from `sbt test` the same way + * KnownBug is dropped, without any test looking ignored or tagged. + * + * This spec's OWN source is excluded from the scan: it holds every pattern below as a string + * literal (e.g. the bare word `pending`), so scanning it would count the patterns themselves. It + * declares no lane of its own, so nothing is lost by skipping it. + */ +class SuiteFactsSpec extends AnyFunSuite: + + private val e2eRoot: Path = + Seq(Paths.get("."), Paths.get("e2e")) + .find(p => Files.isRegularFile(p.resolve("README.md")) && Files.isDirectory(p.resolve("src/test/scala"))) + .getOrElse(fail(s"cannot locate the e2e project from ${Paths.get("").toAbsolutePath}")) + + /** Drop comment lines from a source file: this spec's prose - and the README-quoting comments in + * the specs - would otherwise count as occurrences and a check could never fail for the real + * reason. */ + private def stripComments(source: String): String = + source.linesIterator + .filterNot(line => { val t = line.trim; t.startsWith("//") || t.startsWith("*") || t.startsWith("/*") }) + .mkString("\n") + + /** Every spec source except THIS one, with comment lines dropped. See the class doc for why this + * file is excluded. */ + private lazy val specs: List[(Path, String)] = + val root = e2eRoot.resolve("src/test/scala") + Files.walk(root).iterator().asScala + .filter(p => Files.isRegularFile(p) && p.toString.endsWith(".scala")) + .filterNot(_.getFileName.toString == "SuiteFactsSpec.scala") + .toList.sortBy(_.toString) + .map(p => p -> stripComments(Files.readString(p))) + + private lazy val readme: String = Files.readString(e2eRoot.resolve("README.md")) + + /** The declared counts, parsed out of the README's marker. */ + private lazy val declared: Map[String, Int] = + val body = """""".r.findFirstMatchIn(readme).map(_.group(1)).getOrElse( + fail("e2e/README.md has no `` marker - see §3") + ) + """(\S+)=(\d+)""".r.findAllMatchIn(body).map(m => m.group(1) -> m.group(2).toInt).toMap + + private def countIn(pattern: String): List[(Path, Int)] = + val re = pattern.r + specs.map((path, code) => path -> re.findAllMatchIn(code).size).filter(_._2 > 0) + + private def total(counts: List[(Path, Int)]): Int = counts.map(_._2).sum + + private def declaredEquals(key: String, counts: List[(Path, Int)], noun: String): Unit = + assert( + declared.get(key).contains(total(counts)), + s"e2e/README.md declares $key=${declared.get(key)} but the suite has ${total(counts)} $noun" + + s"${if counts.isEmpty then "" else s" in ${counts.map(_._1)}"}. Update BOTH the marker in §3 and " + + "the prose in §3/§6 - a change in this count is a change in what coverage a reader can rely on." + ) + + test("FACTS-1: the README's `ignored` count is the number of ignored tests") { + // Bare `ignore(` only: a ScalaTest ignored test is called as a statement, so anything reached + // through a receiver (`x.ignore(...)`) is a different method and not what the README counts. + declaredEquals("ignored", countIn("""(^|[^A-Za-z0-9_.`"])ignore\s*\("""), "ignored test(s)") + } + + test("FACTS-2: the README's `known-bug` count is the number of tagged tests, and the lane is EMPTY") { + // Both ways ScalaTest can carry a tag: the FunSuite tag argument this suite uses, + // `test("name", KnownBug) { ... }`, and the `taggedAs` form the other styles use - the latter + // with OR without parentheses (`taggedAs KnownBug` is a legal infix call), because either would + // take a test out of the green lane. Matching the usage rather than the bare identifier keeps an + // `import harness.KnownBug` from counting as a test. + val counts = + countIn("""(?s)\btest\s*\(\s*"(?:[^"\\]|\\.)*"\s*,\s*KnownBug""") ++ countIn("""taggedAs\s*\(?\s*KnownBug""") + declaredEquals("known-bug", counts, "tagged test(s)") + assert( + total(counts) == 0, + s"the KnownBug lane is meant to be empty - a tag is for a bug that is genuinely OPEN, never a way to " + + s"take a red test out of the green run. Tagged: ${counts.map(_._1)}" + ) + } + + test("FACTS-3: nothing in the suite is `pending`") { + // A `pending` (or `pendingUntilFixed`) test is reported neither passed nor failed - yellow, not + // red - so it removes coverage as surely as an ignored one while a `sbt test` run still goes + // green. The README owns that count. + declaredEquals("pending", countIn("""\bpending(?:UntilFixed)?\b"""), "`pending` marker(s)") + } + + /** The only specs allowed to carry an `assume(...)`. Both adapt to whether the broker has + * `topicLevelPoliciesEnabled`, so exactly one branch runs per stack and the other runtime-cancels + * - legitimate config-gated coverage, but cancellation all the same, so it lives here and nowhere + * else. */ + private val assumeAllowedFiles = Set("TopicPolicySpec.scala", "TopicPolicyBreadthSpec.scala") + + test("FACTS-4: `assume(...)` cancellation is confined to the known policy specs and counted") { + // Bare `assume(` (statement form), guarded against a receiver call the same way `ignore` is. + val counts = countIn("""(^|[^A-Za-z0-9_.`"])assume\s*\(""") + val stray = counts.filterNot((path, _) => assumeAllowedFiles.contains(path.getFileName.toString)) + assert( + stray.isEmpty, + s"`assume(...)` outside the known policy specs $assumeAllowedFiles: ${stray.map(_._1)}. assume " + + "RUNTIME-cancels a test - on the wrong broker config it vanishes from the run, neither failing " + + "nor reported as ignored. If this is deliberate it belongs beside a documented config axis (like " + + "topicLevelPoliciesEnabled), not as a way to quiet a red test." + ) + declaredEquals("assume", counts, "`assume(...)` call(s)") + } + + test("FACTS-5: build.sbt excludes exactly the KnownBug tag from the green lane, nothing else") { + // A `-l ` in build.sbt drops that whole tag from `sbt test`. Exactly one is expected + // (KnownBug); a second is a way to remove tests from the green run without ignoring or tagging + // them - the very lane this census closes. + val code = stripComments(Files.readString(e2eRoot.resolve("build.sbt"))) + val excluded = "\"-l\"\\s*,\\s*\"([^\"]+)\"".r.findAllMatchIn(code).map(_.group(1)).toList + assert( + declared.get("excluded-tags").contains(excluded.size), + s"e2e/README.md declares excluded-tags=${declared.get("excluded-tags")} but build.sbt has " + + s"${excluded.size} `-l ` exclusion(s): $excluded. Update the marker in §3." + ) + assert( + excluded.toSet == Set("KnownBug"), + s"build.sbt's green lane excludes $excluded; only KnownBug may be excluded from `sbt test`." + ) + } diff --git a/e2e/src/test/scala/routes/ResilienceSpec.scala b/e2e/src/test/scala/routes/ResilienceSpec.scala index 121d2fde9..4534c47f1 100644 --- a/e2e/src/test/scala/routes/ResilienceSpec.scala +++ b/e2e/src/test/scala/routes/ResilienceSpec.scala @@ -1,14 +1,17 @@ package routes import harness.DekafSuite +import harness.Eventually.eventually import features.consumersession.ConsumerSessionPage +import features.library.LibrarySidebar import com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat import com.microsoft.playwright.assertions.LocatorAssertions import com.microsoft.playwright.options.AriaRole import com.microsoft.playwright.Page.GetByRoleOptions -/** RES-1/2/3 - negative / resilience: bogus routes 404, a bad saved-session id degrades gracefully, - * and the unguarded non-persistent details route is documented. */ +/** RES-1/2/3 - negative / resilience: bogus routes 404, a saved-session id that is missing or + * points at a malformed persisted config degrades gracefully, and the unguarded non-persistent + * details route is documented. */ class ResilienceSpec extends DekafSuite: private def goHome = page.getByRole(AriaRole.BUTTON, new GetByRoleOptions().setName("Go Home")) @@ -24,7 +27,7 @@ class ResilienceSpec extends DekafSuite: assertThat(goHome).isVisible(new LocatorAssertions.IsVisibleOptions().setTimeout(15000)) } - test("RES-2: opening a consumer session with a bad saved-session id degrades gracefully") { + test("RES-2: opening a consumer session with a MISSING saved-session id degrades gracefully") { val (t, ns, topic) = fixtures.freshTopicParts() // A non-existent managed session id must not crash the page - it falls back to a fresh session. page.navigate(s"/tenants/$t/namespaces/$ns/topics/persistent/$topic/consumer-session?id=does-not-exist-xyz") @@ -33,6 +36,49 @@ class ResilienceSpec extends DekafSuite: assertThat(cs.playButton).isVisible(new LocatorAssertions.IsVisibleOptions().setTimeout(15000)) } + // A `?id=` pointing at a persisted library item whose stored content is NOT a consumer-session + // config (here a message-filter, saved through the app's own Library) used to take the WHOLE app + // down: the editor reached for `spec.targets` on the foreign spec and threw "TypeError: Cannot + // read properties of undefined (reading 'map')" during render, and with no error boundary React + // unmounted everything - document.body rendered EMPTY. The session configuration editor now checks + // the stored shape and reports it, and the session subtree sits behind an error boundary, so this + // degrades like the missing-id case above (untagged from KnownBug per e2e/README.md §6). + test("RES-2: opening a consumer session whose persisted config is MALFORMED degrades gracefully") { + val (t, ns, topic) = fixtures.freshTopicParts() + val overviewUrl = s"/tenants/$t/namespaces/$ns/topics/persistent/$topic/overview" + page.navigate(overviewUrl) + + // Arrange through the app's own persistence path: save a library item whose content is + // structurally invalid FOR THIS ROUTE, then read its real id out of the app's item editor. + val lib = LibrarySidebar(page) + lib.openLibraryTab() + lib.createItemNamed("message-filter", "malformed-session") + page.navigate(overviewUrl) // reload so the library search re-fetches (see LIB-8/12) + lib.openLibraryTab() + lib.browseType("message-filter").editItem("malformed-session") + val itemId = eventually() { + val editorText = page.getByTestId("lib-save-dialog").innerText() + // The editor renders `ID: `, and a non-breaking space is not `\s` - skip any + // non-hex separator instead. + raw"ID:[^0-9a-fA-F]*([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})".r + .findFirstMatchIn(editorText) + .map(_.group(1)) + .getOrElse(throw new AssertionError(s"no item id in the library item editor: $editorText")) + } + + page.navigate(s"/tenants/$t/namespaces/$ns/topics/persistent/$topic/consumer-session?id=$itemId") + + // The app must survive a malformed persisted config: its chrome still renders (the document is + // not blank) ... + assertThat(page.getByTestId("breadcrumbs")).isVisible(new LocatorAssertions.IsVisibleOptions().setTimeout(15000)) + // ... and the route shows either a usable session or a visible error - never an empty shell. + assertThat( + ConsumerSessionPage(page).playButton + .or(page.getByText(java.util.regex.Pattern.compile("Unable to fetch item", java.util.regex.Pattern.CASE_INSENSITIVE))) + .first() + ).isVisible(new LocatorAssertions.IsVisibleOptions().setTimeout(15000)) + } + test("RES-3: the unguarded non-persistent /details route renders without a blank crash") { val t = fixtures.createTenant() val ns = fixtures.createNamespace(t) diff --git a/e2e/src/test/scala/routes/SubscriptionActionSpec.scala b/e2e/src/test/scala/routes/SubscriptionActionSpec.scala index 9c1fa5f39..efe8b4144 100644 --- a/e2e/src/test/scala/routes/SubscriptionActionSpec.scala +++ b/e2e/src/test/scala/routes/SubscriptionActionSpec.scala @@ -1,6 +1,7 @@ package routes import harness.DekafSuite +import harness.Eventually.eventually import ui.ConfirmationDialog import com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat import com.microsoft.playwright.options.SelectOption @@ -60,23 +61,31 @@ class SubscriptionActionSpec extends DekafSuite: assert(awaitBacklog(fqn, sub, _ == 0L) == 0L) } - test("SUB-3: expire messages older than a duration runs without error (non-partitioned)") { + test("SUB-3: expire messages older than a duration clears the backlog (non-partitioned)") { val (t, ns, topic) = fixtures.freshTopicParts() val fqn = s"persistent://$t/$ns/$topic" produceCapturing(fqn, 3) + val producedAt = System.currentTimeMillis() val sub = fixtures.unique("sub") admin.topics().createSubscription(fqn, sub, MessageId.earliest) + assert(awaitBacklog(fqn, sub, _ == 3L) == 3L) // a REAL backlog to expire page.navigate(overviewUrl(t, ns, topic, sub)) page.getByTestId("expire-subscription-messages-button").click() page.getByTestId("expire-target-select").selectOption(new SelectOption().setValue("expire-time-in-seconds")) - page.getByTestId("expire-duration").locator("input").first().fill("5") // any >0 enables Confirm + page.getByTestId("expire-duration").locator("input").first().fill("1") // seconds (DurationInput default unit) + // Expiry compares publish time against `now - 1s`, so "older than 1s" is a real precondition of + // the assertion below - poll for it (page load usually covers it) instead of sleeping blind. + eventually(timeoutMs = 10000, intervalMs = 200) { + assert(System.currentTimeMillis() - producedAt > 2000) + } ConfirmationDialog(page).confirm(guard = Some("CONFIRM")) - // Oracle for the time-based path: the action runs without error. - // (Deterministic backlog effect is asserted on the by-ID leg above - see NOTES.) assertThat(page.getByText("Messages were successfully expired")).isVisible() - assert(admin.topics().getSubscriptions(fqn).asScala.contains(sub)) // admin cross-check: sub intact + // The oracle - a toast only proves a request was fired; this proves the backlog actually drained + // while the subscription itself survived. + assert(awaitBacklog(fqn, sub, _ == 0L) == 0L) + assert(admin.topics().getSubscriptions(fqn).asScala.contains(sub)) } test("SUB-3: expire by message ID is disabled for a partitioned topic") { diff --git a/e2e/src/test/scala/routes/TableSpec.scala b/e2e/src/test/scala/routes/TableSpec.scala index a6b5ba9fa..de6db2d48 100644 --- a/e2e/src/test/scala/routes/TableSpec.scala +++ b/e2e/src/test/scala/routes/TableSpec.scala @@ -118,3 +118,34 @@ class TableSpec extends DekafSuite: assert(math.abs(domAfterReload - domAfterResize) <= 3, s"restored column rendered at ${domAfterReload}px, expected ~${domAfterResize}px (persisted width not re-applied)") } + + test("NAV-6: a column dragged onto another lands BEFORE it, persists, and survives a reload") { + openTenantsTable() + + def columnKeys(): List[String] = + page.locator("[data-testid='table-th']").all().asScala.toList.map(_.getAttribute("data-column-key")) + + val before = columnKeys() + assert(before.indexOf("allowedClusters") > before.indexOf("namespacesCount"), s"unexpected default order: $before") + + // Drag 'allowedClusters' onto 'namespacesCount': it must land immediately before it. The + // sticky first column (tenantName) is not draggable and must stay first throughout. + page.locator("[data-testid='table-th'][data-column-key='allowedClusters']") + .dragTo(page.locator("[data-testid='table-th'][data-column-key='namespacesCount']")) + + val after = columnKeys() + assert(after.head == "tenantName", s"the sticky column must stay first, got $after") + assert( + after.indexOf("allowedClusters") == after.indexOf("namespacesCount") - 1, + s"dragged column should sit immediately before its target, got $after" + ) + + // Persisted like the widths: the order is in localStorage and survives a reload. + val stored = page.evaluate("() => localStorage.getItem('table:tenants-table:column-order') || ''").toString + assert(stored.contains("allowedClusters"), s"expected a persisted column order, got '$stored'") + page.reload() + assertThat(page.getByTestId("table-counter")).isVisible(vis(15000)) + val reloaded = columnKeys() + assert(reloaded == after, s"the order must survive a reload: before=$after after=$reloaded") + } + diff --git a/e2e/src/test/scala/routes/TopicActionsSpec.scala b/e2e/src/test/scala/routes/TopicActionsSpec.scala index 6fdd6a037..ba7af6870 100644 --- a/e2e/src/test/scala/routes/TopicActionsSpec.scala +++ b/e2e/src/test/scala/routes/TopicActionsSpec.scala @@ -1,12 +1,13 @@ package routes import harness.DekafSuite +import harness.Eventually import ui.ConfirmationDialog import com.microsoft.playwright.Page.GetByRoleOptions import com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat import com.microsoft.playwright.assertions.LocatorAssertions import com.microsoft.playwright.options.AriaRole -import org.apache.pulsar.client.api.Schema +import org.apache.pulsar.client.api.{MessageId, Schema} import java.util.regex.Pattern import scala.jdk.CollectionConverters.* @@ -25,6 +26,26 @@ class TopicActionsSpec extends DekafSuite: ok } + /** Produce `n` NON-batched messages (1 message == 1 entry => deterministic backlog/entry counts). */ + private def produce(fqn: String, n: Int): Unit = { + val p = client.newProducer(Schema.STRING).topic(fqn).enableBatching(false).create() + try (1 to n).foreach(i => p.send(s"msg-$i")) + finally p.close() + } + + private def backlogOf(fqn: String, sub: String): Long = { + val subs = admin.topics().getStats(fqn).getSubscriptions + if subs.containsKey(sub) then subs.get(sub).getMsgBacklog else -1L + } + + /** Expiry compares each message's publish time against `now - expireTimeInSeconds`, so + * "the messages are older than the threshold" is a real precondition of the expire assertion. + * Poll for it (page load usually covers it already) instead of sleeping blind. */ + private def awaitOlderThan(producedAt: Long, ageMs: Long): Unit = + Eventually.eventually(timeoutMs = 10000, intervalMs = 200) { + assert(System.currentTimeMillis() - producedAt > ageMs) + } + private def partitionedTopic(count: Int): (String, String, String, String) = { val t = fixtures.createTenant() val ns = fixtures.createNamespace(t) @@ -77,31 +98,67 @@ class TopicActionsSpec extends DekafSuite: }) } - test("TOP-3: expire messages on all subscriptions") { + test("TOP-3: expire messages on all subscriptions clears every subscription's backlog") { val (t, ns, topic) = fixtures.freshTopicParts() + val fqn = s"persistent://$t/$ns/$topic" + + // Arrange a REAL backlog on two subscriptions - "all subscriptions" is only proven by more + // than one - then act through the UI and poll the admin oracle for the state change. + val subA = fixtures.unique("suba") + val subB = fixtures.unique("subb") + admin.topics().createSubscription(fqn, subA, MessageId.earliest) + admin.topics().createSubscription(fqn, subB, MessageId.earliest) + produce(fqn, 3) + val producedAt = System.currentTimeMillis() + Eventually.eventually() { + assert(backlogOf(fqn, subA) == 3L) + assert(backlogOf(fqn, subB) == 3L) + } + page.navigate(s"/tenants/$t/namespaces/$ns/topics/persistent/$topic/overview") page.getByTestId("expire-topic-messages-button").click() - // Confirm stays disabled until duration > 0 (ExpireAllSubscriptions.tsx:93). + // Confirm stays disabled until duration > 0 (ExpireAllSubscriptions.tsx:93). DurationInput's + // default unit is seconds, so "1" == expireTimeInSeconds 1. page.getByRole(AriaRole.SPINBUTTON).fill("1") + awaitOlderThan(producedAt, 2000) // No force checkbox on this dialog - force must stay false. ConfirmationDialog(page).confirm(guard = Some("CONFIRM")) - // Empty-backlog expire is a server no-op → assert the success toast + no error toast. assertThat(page.getByText("Messages were successfully expired")).isVisible(visible(15000)) + // The oracle - a toast only proves a request was fired; this proves the backlog actually drained. + Eventually.eventually() { + assert(backlogOf(fqn, subA) == 0L) + assert(backlogOf(fqn, subB) == 0L) + } } - test("TOP-4: unload a topic") { + test("TOP-4: unload a topic reloads its managed ledger (data preserved)") { val (t, ns, topic) = fixtures.freshTopicParts() val fqn = s"persistent://$t/$ns/$topic" + + // Arrange state that only survives ONE load: entriesAddedCounter lives on the ManagedLedger + // INSTANCE, so it counts this load's 3 writes and restarts at 0 once the topic is closed and + // re-opened (the next admin read re-loads it). Re-opening also appends a fresh ledger. + produce(fqn, 3) + Eventually.eventually() { assert(admin.topics().getInternalStats(fqn).entriesAddedCounter == 3L) } + val ledgersBefore = admin.topics().getInternalStats(fqn).ledgers.size() + page.navigate(s"/tenants/$t/namespaces/$ns/topics/persistent/$topic/overview") page.getByTestId("topic-page-unload-button").click() ConfirmationDialog(page).confirm(guard = Some(fqn)) // guard = topic FQN, no force - // Unload is transient: assert success toast + topic still present. assertThat(page.getByText(Pattern.compile("has been successfully unloaded"))).isVisible(visible(15000)) + // The oracle: the managed ledger really was closed and re-opened. + Eventually.eventually() { + val stats = admin.topics().getInternalStats(fqn) + assert(stats.entriesAddedCounter == 0L) // per-load counter restarted + assert(stats.ledgers.size() > ledgersBefore) // re-open created a new ledger + } + // Unload, not delete: the topic and its 3 entries survive. + assert(admin.topics().getInternalStats(fqn).numberOfEntries == 3L) assert(admin.topics().getList(s"$t/$ns").asScala.exists(_.contains(topic))) } diff --git a/flake.lock b/flake.lock index 636a4d734..ac4035583 100644 --- a/flake.lock +++ b/flake.lock @@ -49,6 +49,22 @@ "type": "indirect" } }, + "nixpkgs-buf": { + "locked": { + "lastModified": 1784796856, + "narHash": "sha256-wWFrV5/Qbm+lyt5x20E/bSbfJiGKMo4RCxZV8cl/WZI=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "e2587caef70cea85dd97d7daab492899902dbf5d", + "type": "github" + }, + "original": { + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "e2587caef70cea85dd97d7daab492899902dbf5d", + "type": "github" + } + }, "nixpkgs-playwright": { "locked": { "lastModified": 1735160951, @@ -70,6 +86,7 @@ "flake-compat": "flake-compat", "flake-utils": "flake-utils", "nixpkgs": "nixpkgs", + "nixpkgs-buf": "nixpkgs-buf", "nixpkgs-playwright": "nixpkgs-playwright" } }, diff --git a/flake.nix b/flake.nix index 0205b84d6..2077b3f4a 100644 --- a/flake.nix +++ b/flake.nix @@ -10,6 +10,14 @@ nixpkgs-playwright = { url = "github:NixOS/nixpkgs/c792c60b8a97daa7efe41a6e4954497ae410e0c1"; }; + # Codegen: the buf in the main nixpkgs lock is 1.30.0, whose darwin binary has no + # LC_UUID load command and so cannot launch at all on macOS 15+ ("dyld: missing + # LC_UUID load command"), breaking `cd proto && make build` on every recent Mac. + # Pinned separately rather than bumping the main lock, which would churn the whole + # toolchain (JVM, sbt, node, envoy) for a single tool. + nixpkgs-buf = { + url = "github:NixOS/nixpkgs/e2587caef70cea85dd97d7daab492899902dbf5d"; + }; flake-compat = { url = "github:edolstra/flake-compat"; flake = false; @@ -22,6 +30,7 @@ { self , nixpkgs , nixpkgs-playwright + , nixpkgs-buf , flake-compat , flake-utils , @@ -59,6 +68,9 @@ playwrightBrowsers = (import nixpkgs-playwright { inherit system; }).playwright-driver.browsers; + # See the nixpkgs-buf input: the main lock's buf cannot launch on modern macOS. + buf = (import nixpkgs-buf { inherit system; }).buf; + runtimeLibraryPath = lib.makeLibraryPath ([ pkgs.zlib ]); pulsar-ui-dev = pkgs.mkShell { @@ -90,7 +102,7 @@ pkgs.maven pkgs.protobuf3_20 - pkgs.buf + buf protoc-gen-grpc-web protoc-gen-scala diff --git a/proto/buf.lock b/proto/buf.lock deleted file mode 100644 index c91b5810c..000000000 --- a/proto/buf.lock +++ /dev/null @@ -1,2 +0,0 @@ -# Generated by buf. DO NOT EDIT. -version: v1 diff --git a/proto/buf.yaml b/proto/buf.yaml deleted file mode 100644 index 1a5194568..000000000 --- a/proto/buf.yaml +++ /dev/null @@ -1,7 +0,0 @@ -version: v1 -breaking: - use: - - FILE -lint: - use: - - DEFAULT diff --git a/proto/proto/tools/teal/pulsar/ui/api/v1/consumer.proto b/proto/proto/tools/teal/pulsar/ui/api/v1/consumer.proto index 528be6a7a..a34c0cf2e 100644 --- a/proto/proto/tools/teal/pulsar/ui/api/v1/consumer.proto +++ b/proto/proto/tools/teal/pulsar/ui/api/v1/consumer.proto @@ -119,6 +119,64 @@ message RelativeDateTime { bool is_rounded_to_unit_start = 3; } +// Start approximately the given distance through the DATA a topic still holds - +// "about % through the data". +// +// Pulsar can address a position by ENTRY ordinal or by timestamp in constant/log +// time at any topic size, but it has no message-ordinal index unless the operator +// enables brokerEntryMetadataInterceptors (empty by default). So "60% of the way +// in" is resolvable instantly on a billion-message topic, while "skip the first +// 600 million messages" is not - the latter has to count. +// +// This mode therefore advertises an APPROXIMATE position rather than an exact +// message count: the approximation lives in the contract instead of being hidden +// behind it. +// +// Resolved PER PHYSICAL TOPIC: every topic of the session, and every partition of +// a partitioned topic, is positioned against its own backlog. +// +// The counterpart to ApproximateTimePosition, and the distinction is not +// cosmetic. On a topic where 99% of the messages arrived in the last hour of a +// 30-day retention, "50%" of the DATA lands inside that last hour, while 50% of +// the TIME RANGE lands 15 days back. One message used to answer both questions, +// which made it impossible to name or to reason about. +message ApproximateDataPosition { + // Outside [0.0, 1.0] is rejected. BOTH ENDPOINTS ARE EXACT and neither is an + // entry ordinal: + // + // 0.0 = `MessageId.earliest` - the earliest retained message, exactly as the + // "Earliest message" mode. + // 1.0 = `MessageId.latest` - PAST the last retained message, exactly as the + // "Latest message" mode: nothing retained is shown at all, only what is + // published from now on. It is NOT "the latest retained message"; that + // reading cost a reviewer a round trip, so it is spelled out here. + // + // Interior fractions are resolved against ENTRY ordinals, so the position is + // proportional to stored entries rather than to message count; the two diverge + // as far as batch sizes varied over the topic's lifetime. + double fraction = 1; +} + +// Start approximately the given distance through the TIME RANGE a topic still +// covers - "about % through the time range". +// +// Resolved PER LOGICAL TOPIC, not per partition: `earliest` is the MINIMUM first- +// message publish time across the topic's partitions, `latest` the MAXIMUM last- +// message publish time across them, and the cutoff is +// `earliest + fraction * (latest - earliest)` (floored to a whole millisecond). +// Every partition is then seeked to that one instant. +// +// Taking min/max across partitions rather than a per-partition quantile is +// deliberate: it gives clean endpoints by construction, is monotonic in the +// fraction, and is independent of the partition count. It also avoids the defect +// ApproximateDataPosition has to live with, where one idle partition drags a +// quantile-based cutoff backwards. +message ApproximateTimePosition { + // 0.0 = earliest retained message, 1.0 = the last message. Outside [0.0, 1.0] + // is rejected. Both endpoints are exact rather than interpolated. + double fraction = 1; +} + message ConsumerSessionStartFrom { oneof start_from { EarliestMessage start_from_earliest_message = 1; @@ -128,6 +186,8 @@ message ConsumerSessionStartFrom { MessageId start_from_message_id = 3; DateTime start_from_date_time = 4; RelativeDateTime start_from_relative_date_time = 5; + ApproximateDataPosition start_from_approximate_data_position = 8; + ApproximateTimePosition start_from_approximate_time_position = 9; } } @@ -543,9 +603,59 @@ message ResumeRequest { string consumer_name = 1; bool include_consumer_stats = 2; bool is_debug = 3; -} -message ConsumerStats {} + // Cap on how many messages per second this session DELIVERS to the client, applied from this + // resume onward. 0 means unlimited; negative is refused outright. + // + // PER REQUEST, NOT PER SESSION CONFIG, like the two flags above it, and deliberately so: the + // value belongs to the BROWSER doing the watching (it lives in localStorage), so it must never + // travel into a saved library item and follow the session to another person's screen. + // + // The limit shapes steady-state delivery only. Start-from positioning - the counted skip of a + // "skip first n" or the retained-history walk of a "latest n" - is never slowed by it: those + // messages were never going to be shown, and slowing the seek would only delay the first visible + // message. The count is per SESSION, not per partition, so the number means what it says + // regardless of how many partitions the selector matched. + int64 max_messages_per_second = 4; + + // Deliver AT MOST this many messages on this stream, then deliver nothing more until the next + // resume. 0 means no budget; negative is refused. + // + // The count is of messages LOADED - the ones that passed every filter and went on the wire, the + // number the toolbar's "loaded" counter shows - not of messages processed. A session whose + // filters drop most of what they read may well process hundreds to load ten; the budget lets it, + // and stops the STREAM at exactly ten. + // + // Enforced at the delivery drain, EXACTLY: the message that spends the last unit is the last one + // sent, whatever was mid-batch behind it stays queued - unacknowledged, undelivered - for the + // next resume. A client-side "pause after n" can only ever be approximate (a whole chunk lands + // before the client can react, and the first chunk under a rate limit is the full one-second + // burst); this is the server-side half that makes the number mean itself. + int64 max_messages_to_deliver = 5; +} + +// Progress of a start-from position that has to be resolved by counting messages +// rather than by seeking. Only NthMessageAfterEarliest needs this: skipping N +// messages exactly costs O(N) because Pulsar stores no message-ordinal index, so +// a large N takes real time and the UI must be able to say so. +message StartFromProgress { + int64 messages_skipped = 1; + int64 messages_to_skip = 2; + // True once the requested position has been reached and normal delivery began. + bool complete = 3; + // True once the position was resolved BEST-EFFORT rather than exactly: a stream the merge was + // waiting on stayed silent past the stall window and was abandoned (named below). The COUNT is + // still exact; WHICH messages were dropped may differ from the exact answer. Sticky for the + // session - a degradation that happened does not un-happen. + bool degraded = 4; + // The streams ("consumer@topic") the resolution gave up waiting for. + repeated string abandoned_streams = 5; +} + +message ConsumerStats { + // Absent unless the session's start-from is still being resolved. + StartFromProgress start_from_progress = 1; +} message ResumeResponse { google.rpc.Status status = 1; @@ -579,6 +689,83 @@ message ResolveTopicSelectorResponse { repeated string topic_fqns = 2; } +// One physical topic's endpoints and how far this session has read through them. +// +// A DEBUG VIEW, polled on demand - never pushed with the message stream. Filling one +// row costs three admin round trips (first entry, last entry, internal stats), so a +// session over a wide selector costs three per PARTITION every refresh. That is why +// the client asks for this rather than receiving it, and why the asking is off by +// default. +// +// EVERY FIELD IS OPTIONAL BECAUSE EVERY LOOKUP CAN DECLINE. A topic Pulsar refuses to +// examine at all (non-persistent: 405) answers with `unavailable_reason` set and the +// endpoints absent - which is different from an EMPTY topic, where the lookups +// succeed and there is genuinely nothing to report. Absent means "not known", never +// "zero". +message TopicPosition { + // The physical topic - a partition of a partitioned topic, not the parent, since + // `examineMessage` refuses the parent outright. + string topic_fqn = 1; + + // The oldest message the topic still holds. Absent on an empty topic, and on one + // whose first entry aged out between the two lookups. + google.protobuf.BytesValue first_message_id = 2; + google.protobuf.Int64Value first_publish_time = 3; + + // The newest message the topic holds. Absent on an empty topic: `examineMessage` + // THROWS for "latest" there rather than answering, where "earliest" clamps. + google.protobuf.BytesValue last_message_id = 4; + google.protobuf.Int64Value last_publish_time = 5; + + // The newest message this session has PROCESSED from this topic - which is the read + // position, not the last row on screen. A session filter can drop almost everything + // it reads, so the last DISPLAYED message can lag this by an arbitrary amount and + // would make both progress figures below read far too low. + // + // Absent until the session has processed something from this topic. + google.protobuf.BytesValue cursor_message_id = 6; + google.protobuf.Int64Value cursor_publish_time = 7; + + // Where the cursor sits in the topic's TIME range: 0.0 at the first message's + // publish time, 1.0 at the last's. Absent when there is no cursor yet, and on a + // topic that occupies a single instant (first == last), which has no interior to + // place anything in. + google.protobuf.DoubleValue cursor_time_fraction = 8; + + // The PROPORTION OF RETAINED ENTRIES THE SESSION HAS CONSUMED: cursor ordinal + // over retained count. Sitting ON the first of N entries is 1/N - one entry + // consumed - and on the last it is 1.0; a topic retaining a single entry is + // therefore 1.0 the moment that entry is read. There is no 0.0 with a cursor + // present: a cursor exists only once something was consumed. + // + // ENTRIES, NOT MESSAGES. A batched entry holds many messages, so this tracks + // message count only as closely as batch sizes stayed uniform - the same + // approximation ApproximateDataPosition documents. It is named for what it measures. + google.protobuf.DoubleValue cursor_entry_fraction = 9; + + // Entries the topic still retains, as `getInternalStats` reports it. + google.protobuf.Int64Value retained_entries = 10; + + // The cursor's 1-based ordinal among the retained entries, which is the numerator of + // `cursor_entry_fraction`. Shown so a reader can see the arithmetic rather than + // trust a percentage. + google.protobuf.Int64Value cursor_entry_ordinal = 11; + + // Why this row is blank, when it is. Set for a topic the broker refuses to examine + // (a non-persistent topic cannot answer either lookup) and for a lookup that failed + // outright. An EMPTY topic is NOT a reason - it answers, with nothing in it. + google.protobuf.StringValue unavailable_reason = 12; +} + +message GetTopicPositionsRequest { + string consumer_name = 1; +} + +message GetTopicPositionsResponse { + google.rpc.Status status = 1; + repeated TopicPosition positions = 2; +} + service ConsumerService { rpc CreateConsumer(CreateConsumerRequest) returns (CreateConsumerResponse); rpc DeleteConsumer(DeleteConsumerRequest) returns (DeleteConsumerResponse); @@ -586,4 +773,8 @@ service ConsumerService { rpc Pause(PauseRequest) returns (PauseResponse); rpc RunCode(RunCodeRequest) returns (RunCodeResponse); rpc ResolveTopicSelector(ResolveTopicSelectorRequest) returns (ResolveTopicSelectorResponse); + + // Poll the per-topic debug view. See [[TopicPosition]] for why this is polled rather + // than pushed. + rpc GetTopicPositions(GetTopicPositionsRequest) returns (GetTopicPositionsResponse); } diff --git a/proto/proto/tools/teal/pulsar/ui/library/v1/managed_items.proto b/proto/proto/tools/teal/pulsar/ui/library/v1/managed_items.proto index c431f3483..b6037f8ea 100644 --- a/proto/proto/tools/teal/pulsar/ui/library/v1/managed_items.proto +++ b/proto/proto/tools/teal/pulsar/ui/library/v1/managed_items.proto @@ -95,6 +95,8 @@ message ManagedConsumerSessionStartFromSpec { ManagedMessageIdValOrRef start_from_message_id = 3; ManagedDateTimeValOrRef start_from_date_time = 4; ManagedRelativeDateTimeValOrRef start_from_relative_date_time = 5; + tools.teal.pulsar.ui.api.v1.ApproximateDataPosition start_from_approximate_data_position = 8; + tools.teal.pulsar.ui.api.v1.ApproximateTimePosition start_from_approximate_time_position = 9; } } diff --git a/proto/proto/tools/teal/pulsar/ui/library/v1/resource_matchers.proto b/proto/proto/tools/teal/pulsar/ui/library/v1/resource_matchers.proto index 60b436f06..95c4b2fa2 100644 --- a/proto/proto/tools/teal/pulsar/ui/library/v1/resource_matchers.proto +++ b/proto/proto/tools/teal/pulsar/ui/library/v1/resource_matchers.proto @@ -24,6 +24,15 @@ message ExactNamespaceMatcher { message AllNamespaceMatcher { TenantMatcher tenant = 1; + + // NOT IMPLEMENTED - setting this field is REJECTED with INVALID_ARGUMENT. It is not ignored: + // ignoring it returned a matcher covering EVERY namespace of the matching tenant to a caller that + // had asked for a subset, which is a silent widening of access scope. + // + // Unimplemented deliberately, not by oversight: matchers are tested against other matchers rather + // than against a concrete namespace, so an AllNamespaceMatcher tested against another + // AllNamespaceMatcher would have to decide whether one regex subsumes another, which is + // undecidable in general. Leave it unset; narrow with ExactNamespaceMatcher instead. string namespace_regex = 2; } diff --git a/server/src/main/scala/brokers/BrokersServiceImpl.scala b/server/src/main/scala/brokers/BrokersServiceImpl.scala index c443cb7bc..a6609ec11 100644 --- a/server/src/main/scala/brokers/BrokersServiceImpl.scala +++ b/server/src/main/scala/brokers/BrokersServiceImpl.scala @@ -25,13 +25,13 @@ class BrokersServiceImpl extends pb.BrokersServiceGrpc.BrokersService { val config = adminClient.brokers.getAllDynamicConfigurations.asScala.toMap Future.successful( GetAllDynamicConfigurationsResponse( - status = Some(Status(code = Code.OK.index)), + status = Some(Status(code = Code.OK.value)), config ) ) } catch { case err => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(GetAllDynamicConfigurationsResponse(status = Some(status))) } @@ -42,13 +42,13 @@ class BrokersServiceImpl extends pb.BrokersServiceGrpc.BrokersService { val names = adminClient.brokers.getDynamicConfigurationNames.asScala.toList Future.successful( GetDynamicConfigurationNamesResponse( - status = Some(Status(code = Code.OK.index)), + status = Some(Status(code = Code.OK.value)), names ) ) } catch { case err => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(GetDynamicConfigurationNamesResponse(status = Some(status))) } @@ -66,13 +66,13 @@ class BrokersServiceImpl extends pb.BrokersServiceGrpc.BrokersService { Future.successful( GetInternalConfigurationDataResponse( - status = Some(Status(code = Code.OK.index)), + status = Some(Status(code = Code.OK.value)), config = Some(config) ) ) } catch { case err => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(GetInternalConfigurationDataResponse(status = Some(status))) } @@ -83,13 +83,13 @@ class BrokersServiceImpl extends pb.BrokersServiceGrpc.BrokersService { val config = adminClient.brokers.getRuntimeConfigurations.asScala.toMap Future.successful( GetRuntimeConfigurationsResponse( - status = Some(Status(code = Code.OK.index)), + status = Some(Status(code = Code.OK.value)), config ) ) } catch { case err => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(GetRuntimeConfigurationsResponse(status = Some(status))) } @@ -100,11 +100,11 @@ class BrokersServiceImpl extends pb.BrokersServiceGrpc.BrokersService { try { adminClient.brokers.updateDynamicConfiguration(request.name, request.value) Future.successful( - UpdateDynamicConfigurationResponse(status = Some(Status(code = Code.OK.index))) + UpdateDynamicConfigurationResponse(status = Some(Status(code = Code.OK.value))) ) } catch { case err => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(UpdateDynamicConfigurationResponse(status = Some(status))) } @@ -115,11 +115,11 @@ class BrokersServiceImpl extends pb.BrokersServiceGrpc.BrokersService { try { adminClient.brokers.deleteDynamicConfiguration(request.name) Future.successful( - DeleteDynamicConfigurationResponse(status = Some(Status(code = Code.OK.index))) + DeleteDynamicConfigurationResponse(status = Some(Status(code = Code.OK.value))) ) } catch { case err => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(DeleteDynamicConfigurationResponse(status = Some(status))) } @@ -130,11 +130,11 @@ class BrokersServiceImpl extends pb.BrokersServiceGrpc.BrokersService { try { adminClient.brokers.healthcheck(TopicVersion.V2) Future.successful( - HealthCheckResponse(status = Some(Status(code = Code.OK.index)), isOk = true) + HealthCheckResponse(status = Some(Status(code = Code.OK.value)), isOk = true) ) } catch { case err => - val status = Status(code = Code.OK.index, message = err.getMessage) + val status = Status(code = Code.OK.value, message = err.getMessage) Future.successful(HealthCheckResponse(status = Some(status), isOk = false)) } @@ -146,7 +146,7 @@ class BrokersServiceImpl extends pb.BrokersServiceGrpc.BrokersService { val resourceFqn = request.resourceFqn def failWithMessage(message: String): Future[CheckResourceExistsResponse] = - val status = Status(code = Code.FAILED_PRECONDITION.index, message = message) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = message) Future.successful(CheckResourceExistsResponse(status = Some(status), isExists = false)) val isResourceExists = request.resource match @@ -166,7 +166,7 @@ class BrokersServiceImpl extends pb.BrokersServiceGrpc.BrokersService { return failWithMessage("Resource type should be specified") Future.successful( - CheckResourceExistsResponse(status = Some(Status(code = Code.OK.index)), isExists = isResourceExists) + CheckResourceExistsResponse(status = Some(Status(code = Code.OK.value)), isExists = isResourceExists) ) override def backlogQuotaCheck(request: BacklogQuotaCheckRequest): Future[BacklogQuotaCheckResponse] = @@ -176,11 +176,11 @@ class BrokersServiceImpl extends pb.BrokersServiceGrpc.BrokersService { try { adminClient.brokers.backlogQuotaCheck() Future.successful( - BacklogQuotaCheckResponse(status = Some(Status(code = Code.OK.index)), isOk = true) + BacklogQuotaCheckResponse(status = Some(Status(code = Code.OK.value)), isOk = true) ) } catch { case err => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(BacklogQuotaCheckResponse(status = Some(status), isOk = false)) } override def getResourceGroupsList(request: GetResourceGroupsListRequest): Future[GetResourceGroupsListResponse] = @@ -191,13 +191,13 @@ class BrokersServiceImpl extends pb.BrokersServiceGrpc.BrokersService { Future.successful( GetResourceGroupsListResponse( - status = Some(Status(code = Code.OK.index)), + status = Some(Status(code = Code.OK.value)), resourceGroups ) ) } catch { case err => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(GetResourceGroupsListResponse(status = Some(status))) } override def getResourceGroups(request: GetResourceGroupsRequest): Future[GetResourceGroupsResponse] = @@ -220,13 +220,13 @@ class BrokersServiceImpl extends pb.BrokersServiceGrpc.BrokersService { Future.successful( GetResourceGroupsResponse( - status = Some(Status(code = Code.OK.index)), + status = Some(Status(code = Code.OK.value)), resourceGroups ) ) } catch { case err => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(GetResourceGroupsResponse(status = Some(status))) } @@ -246,13 +246,13 @@ class BrokersServiceImpl extends pb.BrokersServiceGrpc.BrokersService { Future.successful( pb.GetResourceGroupResponse( - status = Some(Status(code = Code.OK.index)), + status = Some(Status(code = Code.OK.value)), resourceGroup = Some(resourceGroup) ) ) } catch { case err => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.GetResourceGroupResponse(status = Some(status))) } @@ -271,13 +271,13 @@ class BrokersServiceImpl extends pb.BrokersServiceGrpc.BrokersService { rg.publishRateInMsgs.foreach(n => resourceGroup.setPublishRateInMsgs(n)) adminClient.resourcegroups.createResourceGroup(rg.name, resourceGroup) - Future.successful(CreateResourceGroupResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(CreateResourceGroupResponse(status = Some(Status(code = Code.OK.value)))) case None => - val status = Status(code = Code.INVALID_ARGUMENT.index, message = "Resource group should be specified") + val status = Status(code = Code.INVALID_ARGUMENT.value, message = "Resource group should be specified") Future.successful(CreateResourceGroupResponse(status = Some(status))) } catch { case err => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(CreateResourceGroupResponse(status = Some(status))) } @@ -288,11 +288,11 @@ class BrokersServiceImpl extends pb.BrokersServiceGrpc.BrokersService { try { adminClient.resourcegroups.deleteResourceGroup(request.name) Future.successful( - DeleteResourceGroupResponse(status = Some(Status(code = Code.OK.index))) + DeleteResourceGroupResponse(status = Some(Status(code = Code.OK.value))) ) } catch { case err => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(DeleteResourceGroupResponse(status = Some(status))) } @@ -311,13 +311,13 @@ class BrokersServiceImpl extends pb.BrokersServiceGrpc.BrokersService { rg.publishRateInMsgs.foreach(n => resourceGroup.setPublishRateInMsgs(n)) adminClient.resourcegroups.updateResourceGroup(rg.name, resourceGroup) - Future.successful(pb.UpdateResourceGroupResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(pb.UpdateResourceGroupResponse(status = Some(Status(code = Code.OK.value)))) case None => - val status = Status(code = Code.INVALID_ARGUMENT.index, message = "Resource group should be specified") + val status = Status(code = Code.INVALID_ARGUMENT.value, message = "Resource group should be specified") Future.successful(pb.UpdateResourceGroupResponse(status = Some(status))) } catch { case err => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.UpdateResourceGroupResponse(status = Some(status))) } @@ -329,15 +329,15 @@ class BrokersServiceImpl extends pb.BrokersServiceGrpc.BrokersService { Option(adminClient.brokers.getVersion) match case Some(version) => Future.successful(pb.GetVersionResponse( - status = Some(Status(code = Code.OK.index)), + status = Some(Status(code = Code.OK.value)), version )) case None => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = "Something went wrong.") + val status = Status(code = Code.FAILED_PRECONDITION.value, message = "Something went wrong.") Future.successful(pb.GetVersionResponse(status = Some(status))) } catch { case err => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.GetVersionResponse(status = Some(status))) } } diff --git a/server/src/main/scala/brokerstats/BrokerStatsServiceImpl.scala b/server/src/main/scala/brokerstats/BrokerStatsServiceImpl.scala index 82fd86271..00f0bec6e 100644 --- a/server/src/main/scala/brokerstats/BrokerStatsServiceImpl.scala +++ b/server/src/main/scala/brokerstats/BrokerStatsServiceImpl.scala @@ -20,10 +20,10 @@ class BrokerStatsServiceImpl extends pb.BrokerStatsServiceGrpc.BrokerStatsServic try { val statsJson = adminClient.brokerStats.getMetrics Future.successful( - pb.GetBrokerStatsJsonResponse(status = Some(Status(code = Code.OK.index)), statsJson) + pb.GetBrokerStatsJsonResponse(status = Some(Status(code = Code.OK.value)), statsJson) ) } catch { case err => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.GetBrokerStatsJsonResponse(status = Some(status))) } diff --git a/server/src/main/scala/childrencount/ChildrencountServiceImpl.scala b/server/src/main/scala/childrencount/ChildrencountServiceImpl.scala index 5e68de5ff..a3217fdb3 100644 --- a/server/src/main/scala/childrencount/ChildrencountServiceImpl.scala +++ b/server/src/main/scala/childrencount/ChildrencountServiceImpl.scala @@ -46,7 +46,7 @@ class TenantServiceImpl extends pb.ChildrenCountServiceGrpc.ChildrenCountService given ExecutionContext = ExecutionContext.global val allResults = Await.result(Future.sequence(allFutures), Duration(1, TimeUnit.MINUTES)) - val status: Status = Status(code = Code.OK.index) + val status: Status = Status(code = Code.OK.value) Future.successful( pb.GetChildrenCountResponse( status = Some(status), @@ -59,6 +59,6 @@ class TenantServiceImpl extends pb.ChildrenCountServiceGrpc.ChildrenCountService ) } catch { case err => - val status: Status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status: Status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.GetChildrenCountResponse(status = Some(status))) } diff --git a/server/src/main/scala/clusters/ClustersServiceImpl.scala b/server/src/main/scala/clusters/ClustersServiceImpl.scala index 52b820d4c..0796a08ec 100644 --- a/server/src/main/scala/clusters/ClustersServiceImpl.scala +++ b/server/src/main/scala/clusters/ClustersServiceImpl.scala @@ -21,11 +21,11 @@ class ClustersServiceImpl extends ClustersServiceGrpc.ClustersService: try val clusters = adminClient.clusters.getClusters - val status: Status = Status(code = Code.OK.index) + val status: Status = Status(code = Code.OK.value) Future.successful(GetClustersResponse(status = Some(status), clusters = clusters.asScala.toList)) catch case err => - val status: Status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status: Status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(GetClustersResponse(status = Some(status))) override def getCluster(request: GetClusterRequest): Future[GetClusterResponse] = @@ -35,12 +35,12 @@ class ClustersServiceImpl extends ClustersServiceGrpc.ClustersService: try adminClient.clusters.getCluster(request.cluster) catch case err => - val status: Status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status: Status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) return Future.successful(GetClusterResponse(status = Some(status))) val clusterDataPb = conversions.clusterDataToPb(clusterData) - val status: Status = Status(code = Code.OK.index) + val status: Status = Status(code = Code.OK.value) Future.successful(GetClusterResponse(status = Some(status), clusterData = Some(clusterDataPb))) override def createCluster(request: CreateClusterRequest): Future[CreateClusterResponse] = @@ -49,17 +49,17 @@ class ClustersServiceImpl extends ClustersServiceGrpc.ClustersService: try val clusterData = request.clusterData match case None => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = "Cluster data is empty") + val status = Status(code = Code.FAILED_PRECONDITION.value, message = "Cluster data is empty") return Future.successful(CreateClusterResponse(status = Some(status))) case Some(cd) => conversions.clusterDataFromPb(cd) adminClient.clusters.createCluster(request.cluster, clusterData) - val status: Status = Status(code = Code.OK.index) + val status: Status = Status(code = Code.OK.value) Future.successful(CreateClusterResponse(status = Some(status))) catch case err => - val status: Status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status: Status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(CreateClusterResponse(status = Some(status))) override def deleteCluster(request: DeleteClusterRequest): Future[DeleteClusterResponse] = @@ -68,11 +68,11 @@ class ClustersServiceImpl extends ClustersServiceGrpc.ClustersService: try adminClient.clusters.deleteCluster(request.cluster) - val status: Status = Status(code = Code.OK.index) + val status: Status = Status(code = Code.OK.value) Future.successful(DeleteClusterResponse(status = Some(status))) catch case err => - val status: Status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status: Status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(DeleteClusterResponse(status = Some(status))) override def getFailureDomains(request: GetFailureDomainsRequest): Future[GetFailureDomainsResponse] = @@ -81,11 +81,11 @@ class ClustersServiceImpl extends ClustersServiceGrpc.ClustersService: try val failureDomains = adminClient.clusters.getFailureDomains(request.cluster) val failureDomainsPb = failureDomains.asScala.view.mapValues(conversions.failureDomainToPb).toMap - val status: Status = Status(code = Code.OK.index) + val status: Status = Status(code = Code.OK.value) Future.successful(GetFailureDomainsResponse(status = Some(status), domains = failureDomainsPb)) catch case err => - val status: Status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status: Status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(GetFailureDomainsResponse(status = Some(status))) override def createFailureDomain(request: CreateFailureDomainRequest): Future[CreateFailureDomainResponse] = @@ -101,11 +101,11 @@ class ClustersServiceImpl extends ClustersServiceGrpc.ClustersService: request.domainName, failureDomain ) - val status: Status = Status(code = Code.OK.index) + val status: Status = Status(code = Code.OK.value) Future.successful(CreateFailureDomainResponse(status = Some(status))) catch case err => - val status: Status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status: Status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(CreateFailureDomainResponse(status = Some(status))) override def deleteFailureDomain(request: DeleteFailureDomainRequest): Future[DeleteFailureDomainResponse] = @@ -113,11 +113,11 @@ class ClustersServiceImpl extends ClustersServiceGrpc.ClustersService: try adminClient.clusters.deleteFailureDomain(request.cluster, request.domainName) - val status: Status = Status(code = Code.OK.index) + val status: Status = Status(code = Code.OK.value) Future.successful(DeleteFailureDomainResponse(status = Some(status))) catch case err => - val status: Status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status: Status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(DeleteFailureDomainResponse(status = Some(status))) override def updateFailureDomain(request: UpdateFailureDomainRequest): Future[UpdateFailureDomainResponse] = @@ -132,11 +132,11 @@ class ClustersServiceImpl extends ClustersServiceGrpc.ClustersService: request.domainName, failureDomain ) - val status: Status = Status(code = Code.OK.index) + val status: Status = Status(code = Code.OK.value) Future.successful(UpdateFailureDomainResponse(status = Some(status))) catch case err => - val status: Status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status: Status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(UpdateFailureDomainResponse(status = Some(status))) override def createNamespaceIsolationPolicy(request: CreateNamespaceIsolationPolicyRequest): Future[CreateNamespaceIsolationPolicyResponse] = @@ -151,11 +151,11 @@ class ClustersServiceImpl extends ClustersServiceGrpc.ClustersService: request.policyName, policy ) - val status: Status = Status(code = Code.OK.index) + val status: Status = Status(code = Code.OK.value) Future.successful(CreateNamespaceIsolationPolicyResponse(status = Some(status))) catch case err => - val status: Status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status: Status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(CreateNamespaceIsolationPolicyResponse(status = Some(status))) override def deleteNamespaceIsolationPolicy(request: DeleteNamespaceIsolationPolicyRequest): Future[DeleteNamespaceIsolationPolicyResponse] = @@ -163,11 +163,11 @@ class ClustersServiceImpl extends ClustersServiceGrpc.ClustersService: try adminClient.clusters.deleteNamespaceIsolationPolicy(request.cluster, request.policyName) - val status: Status = Status(code = Code.OK.index) + val status: Status = Status(code = Code.OK.value) Future.successful(DeleteNamespaceIsolationPolicyResponse(status = Some(status))) catch case err => - val status: Status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status: Status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(DeleteNamespaceIsolationPolicyResponse(status = Some(status))) override def getNamespaceIsolationPolicy(request: GetNamespaceIsolationPolicyRequest): Future[GetNamespaceIsolationPolicyResponse] = @@ -176,11 +176,11 @@ class ClustersServiceImpl extends ClustersServiceGrpc.ClustersService: try val policy = adminClient.clusters.getNamespaceIsolationPolicy(request.cluster, request.policyName) val namespaceIsolationDataPb = conversions.namespaceIsolationDataToPb(policy) - val status: Status = Status(code = Code.OK.index) + val status: Status = Status(code = Code.OK.value) Future.successful(GetNamespaceIsolationPolicyResponse(status = Some(status), namespaceIsolationData = Some(namespaceIsolationDataPb))) catch case err => - val status: Status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status: Status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(GetNamespaceIsolationPolicyResponse(status = Some(status))) override def updateNamespaceIsolationPolicy(request: UpdateNamespaceIsolationPolicyRequest): Future[UpdateNamespaceIsolationPolicyResponse] = @@ -195,11 +195,11 @@ class ClustersServiceImpl extends ClustersServiceGrpc.ClustersService: request.policyName, policy ) - val status: Status = Status(code = Code.OK.index) + val status: Status = Status(code = Code.OK.value) Future.successful(UpdateNamespaceIsolationPolicyResponse(status = Some(status))) catch case err => - val status: Status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status: Status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(UpdateNamespaceIsolationPolicyResponse(status = Some(status))) override def getBrokersWithNamespaceIsolationPolicy( @@ -210,9 +210,9 @@ class ClustersServiceImpl extends ClustersServiceGrpc.ClustersService: try val brokers = adminClient.clusters.getBrokersWithNamespaceIsolationPolicy(request.cluster) val brokersPb = brokers.asScala.toList.map(conversions.brokerNamespaceIsolationDataToPb) - val status: Status = Status(code = Code.OK.index) + val status: Status = Status(code = Code.OK.value) Future.successful(GetBrokersWithNamespaceIsolationPolicyResponse(status = Some(status), brokers = brokersPb)) catch case err => - val status: Status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status: Status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(GetBrokersWithNamespaceIsolationPolicyResponse(status = Some(status))) diff --git a/server/src/main/scala/config/mergeConfigs.scala b/server/src/main/scala/config/mergeConfigs.scala index e6c408bf9..f17ed4e3e 100644 --- a/server/src/main/scala/config/mergeConfigs.scala +++ b/server/src/main/scala/config/mergeConfigs.scala @@ -10,6 +10,8 @@ def mergeConfigs(lowPriority: Config, highPriority: Config): Config = protocol = highPriority.protocol.orElse(lowPriority.protocol), tlsCertificateFilePath = highPriority.tlsCertificateFilePath.orElse(lowPriority.tlsCertificateFilePath), tlsKeyFilePath = highPriority.tlsKeyFilePath.orElse(lowPriority.tlsKeyFilePath), + cookieSecure = highPriority.cookieSecure.orElse(lowPriority.cookieSecure), + cookieSameSite = highPriority.cookieSameSite.orElse(lowPriority.cookieSameSite), pulsarName = highPriority.pulsarName.orElse(lowPriority.pulsarName), pulsarColor = highPriority.pulsarColor.orElse(lowPriority.pulsarColor), pulsarListenerName = highPriority.pulsarListenerName.orElse(lowPriority.pulsarListenerName), diff --git a/server/src/main/scala/consumer/ConsumerServiceImpl.scala b/server/src/main/scala/consumer/ConsumerServiceImpl.scala index 5c89fe8d2..64309b5b8 100644 --- a/server/src/main/scala/consumer/ConsumerServiceImpl.scala +++ b/server/src/main/scala/consumer/ConsumerServiceImpl.scala @@ -12,6 +12,8 @@ import com.tools.teal.pulsar.ui.api.v1.consumer.{ CreateConsumerResponse, DeleteConsumerRequest, DeleteConsumerResponse, + GetTopicPositionsRequest, + GetTopicPositionsResponse, PauseRequest, PauseResponse, ResolveTopicSelectorRequest, @@ -23,71 +25,205 @@ import com.tools.teal.pulsar.ui.api.v1.consumer.{ } import com.typesafe.scalalogging.Logger import _root_.consumer.session_target.topic_selector.TopicSelector -import consumer.session_runner.ConsumerSessionRunner +import consumer.session_runner.{ + ConsumerSessionRunner, + LedgerSpan, + LogEndpoint, + TopicCursor, + TopicPositionInputs, + TopicPositionRow, + brokerAnswer, + buildTopicPositionRow, + entryIdOf, + furthestCursors, + storeConsumerSession, + topicPositionToPb +} +import org.apache.pulsar.client.admin.PulsarAdmin import java.util.concurrent.ConcurrentHashMap import scala.concurrent.Future +import scala.jdk.CollectionConverters.* import scala.jdk.OptionConverters.* import scala.util.{Failure, Success, Try} type ConsumerSessionName = String -class ConsumerServiceImpl extends ConsumerServiceGrpc.ConsumerService: +object ConsumerServiceImpl: + /** The production session builder. The Pulsar clients come from the gRPC request context, which + * is why this is a function rather than a direct call: a test can drive the lifecycle through + * the real RPC surface without a broker or a request context. */ + def makeFromRequestContext(sessionName: ConsumerSessionName, sessionConfig: ConsumerSessionConfig): ConsumerSessionRunner = + ConsumerSessionRunner.make( + sessionName = sessionName, + pulsarClient = RequestContext.pulsarClient.get(), + adminClient = RequestContext.pulsarAdmin.get(), + sessionConfig = sessionConfig + ) + + +/** @param consumerSessions + * the live sessions this service owns. A constructor parameter (with the production default) + * only so a test can put a real session behind an RPC without a broker - the resume/delete paths + * touch nothing but the runner and the observer. + * @param makeSession + * how a session is built. Injectable for the same reason: the lifecycle ORDERING - that a + * predecessor is stopped before its replacement subscribes, and that two creates under one name + * never overlap - is a property of this class and has to be testable without a broker. + */ +class ConsumerServiceImpl( + private val consumerSessions: ConcurrentHashMap[ConsumerSessionName, ConsumerSessionRunner] = + new ConcurrentHashMap[ConsumerSessionName, ConsumerSessionRunner](), + private val makeSession: (ConsumerSessionName, ConsumerSessionConfig) => ConsumerSessionRunner = + ConsumerServiceImpl.makeFromRequestContext +) extends ConsumerServiceGrpc.ConsumerService: private val logger: Logger = Logger(getClass.getName) - private val consumerSessions: ConcurrentHashMap[ConsumerSessionName, ConsumerSessionRunner] = new ConcurrentHashMap[ConsumerSessionName, ConsumerSessionRunner]() + + /** Lifecycle operations on ONE session name run one at a time. + * + * Every target subscribes as `${sessionName}-${targetIndex}`, non-durable and EXCLUSIVE, so two + * runners under one name cannot coexist on the broker at all - the second to subscribe is + * refused. Create and delete therefore have to be single operations rather than a read, some + * broker work, and a write - and resume joins them, because it must not wire a fresh observer + * into a runner a racing delete is stopping. + * + * ONE LOCK PER NAME, not a striped array. The lock is held across the predecessor's stop and + * the replacement's ENTIRE build - subscribing every consumer, seeking it, and for a Latest-N + * start-from a backward walk costing one admin lookup per entry - which is unbounded broker + * work, not one round trip. Striping made two UNRELATED names serialize whenever their hashes + * collided (1 stripe in 64), so one slow create could stall another session's create, delete + * and resume for as long as that broker work took. A map entry is a bare Object of a few dozen + * bytes against names the client chooses; the entries are REF-COUNTED away by + * [[withLifecycleLock]] below, so the map holds only the names something is actively locking + * right now - not every name any client has ever sent. + */ + private val lifecycleLocks = new ConcurrentHashMap[ConsumerSessionName, (Object, java.util.concurrent.atomic.AtomicInteger)]() + + /** How many distinct names currently hold or wait for a lifecycle lock - a test's window into + * the map's lifetime, and nothing else's. */ + private[consumer] def lifecycleLockCount: Int = lifecycleLocks.size + + /** Run `body` holding this name's lifecycle lock. REF-COUNTED: the entry exists only while + * someone holds or waits for it, and the last one out removes it. The names arrive on RPC + * input, so the previous keep-forever map let any client grow the heap without bound by + * naming sessions that never existed; plain eviction was no answer either, because evicting + * an entry a thread was WAITING on hands the next caller a different lock for the same name + * and the exclusion silently vanishes. The count is what makes removal safe: it only happens + * when nobody is inside and nobody is queued. + */ + private def withLifecycleLock[T](sessionName: ConsumerSessionName)(body: => T): T = + val entry = lifecycleLocks.compute( + sessionName, + (_, existing) => + if existing == null then (Object(), java.util.concurrent.atomic.AtomicInteger(1)) + else + existing._2.incrementAndGet() + existing + ) + try entry._1.synchronized(body) + finally + lifecycleLocks.compute( + sessionName, + (_, current) => + if current == null then null + else if current._2.decrementAndGet() == 0 then null + else current + ) override def resume(request: ResumeRequest, responseObserver: io.grpc.stub.StreamObserver[ResumeResponse]): Unit = val sessionName = request.consumerName logger.info(s"Resuming consumer session: $sessionName") - val consumerSession = Option(consumerSessions.get(sessionName)) match - case Some(consumerSession) => consumerSession - case _ => - val msg = s"No such consumer consumer session: $sessionName" - logger.warn(msg) - - val status: Status = Status(code = Code.FAILED_PRECONDITION.index, message = msg) - val res = ResumeResponse(status = Some(status)) - responseObserver.onNext(res) - responseObserver.onCompleted() - return - - try { - consumerSession.resume(grpcResponseObserver = responseObserver, isDebug = request.isDebug) - } catch { - case err: Throwable => - val status: Status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) - val res = ResumeResponse(status = Some(status)) - responseObserver.onNext(res) - responseObserver.onCompleted() - return - } + // For the cases where the observer was never wired into anything: nothing else can be + // writing to it, so answering it directly is safe here and only here. + def answerAndClose(message: String, code: Code = Code.FAILED_PRECONDITION): Unit = + logger.warn(message) + responseObserver.onNext(ResumeResponse(status = Some(Status(code = code.value, message = message)))) + responseObserver.onCompleted() - val status: Status = Status(code = Code.OK.index) - Future.successful(ResumeResponse(status = Some(status))) + // The same trust boundary the start-from counts cross: a nonsense number is refused loudly + // here, never clamped into a guess about what the client meant. 0 is the documented + // "unlimited" / "no budget", so only genuinely negative values are nonsense. + if request.maxMessagesPerSecond < 0 then + answerAndClose( + s"max_messages_per_second must not be negative, got ${request.maxMessagesPerSecond}. 0 means unlimited.", + Code.INVALID_ARGUMENT + ) + return + if request.maxMessagesToDeliver < 0 then + answerAndClose( + s"max_messages_to_deliver must not be negative, got ${request.maxMessagesToDeliver}. 0 means no delivery budget.", + Code.INVALID_ARGUMENT + ) + return + + // Serialized with create and delete under the same name. Resume used to take no lock at + // all, so it could read the runner while a delete was stopping it and wire the fresh + // observer into a stream the stop was about to complete - the play stream then hung + // silently, every later send swallowed by the terminal gate, with nothing telling the + // client why. + withLifecycleLock(sessionName) { + Option(consumerSessions.get(sessionName)) match + case None => + answerAndClose(s"No such consumer consumer session: $sessionName") + case Some(consumerSession) if consumerSession.isStreamCompleted => + // Stopped - deleted, replaced, or failed - but still reachable. The terminal + // gate is sticky, so wiring the observer in would swallow every response; + // the client's remedy is to create the session again. + answerAndClose(s"Consumer session $sessionName is closed and cannot be resumed. Create the session again.") + case Some(consumerSession) => + try + // BOTH request flags, not just the debug one: `include_consumer_stats` is + // the client saying whether it can handle consumer stats at all - including + // the message-less progress frames a skip in flight pushes - and it used to + // be read and then ignored. + consumerSession.resume( + grpcResponseObserver = responseObserver, + isDebug = request.isDebug, + includeConsumerStats = request.includeConsumerStats, + maxMessagesPerSecond = request.maxMessagesPerSecond, + maxMessagesToDeliver = request.maxMessagesToDeliver + ) + catch + case err: Throwable => + // THROUGH THE RUNNER, never straight to the observer: a late target's + // throw leaves the earlier targets' listeners live and pushing into + // this same observer, so the status frame and the completion must go + // through the runner's send lock and set its terminal flag. Written + // directly they interleaved with a push, and every push after the + // onCompleted landed in a completed stream. + consumerSession.failAndComplete(Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage)) + // And then STOP THE INTAKE: the targets resumed before the throw have + // open gates and running consumers, and with the stream terminal their + // output is suppressed - they were consuming and ACKNOWLEDGING messages + // nobody would ever see, until the session was recreated. Pausing + // closes the gates (buffered messages are handed back, not swallowed) + // and stops the consumers; best-effort, because the failed target may + // be in any state. + Try(consumerSession.pause()) + () + } override def pause(request: PauseRequest): Future[PauseResponse] = val sessionName = request.consumerName logger.info(s"Pausing consumer session $sessionName") - val consumerSession = Option(consumerSessions.get(sessionName)) match - case Some(consumerSession) => consumerSession - case _ => - val msg = s"No such consumer consumer session: $sessionName" - logger.warn(msg) - - val status: Status = Status(code = Code.FAILED_PRECONDITION.index, message = msg) - return Future.successful(PauseResponse(status = Some(status))) - - try { - consumerSession.pause() - } catch { - case err: Throwable => - val status: Status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) - return Future.successful(PauseResponse(status = Some(status))) + // Serialized with create, delete and resume under the same name. Pause used to take no + // lock, so on a multi-target session it could interleave TARGET BY TARGET with a resume - + // both answering OK while half the targets finished paused and half running, with the + // limiter believing whichever call it heard last. The browser makes that race ordinary: a + // quick hidden-then-visible tab abandons its pending pause and immediately resumes. + val status: Status = withLifecycleLock(sessionName) { + Option(consumerSessions.get(sessionName)) match + case None => + val msg = s"No such consumer consumer session: $sessionName" + logger.warn(msg) + Status(code = Code.FAILED_PRECONDITION.value, message = msg) + case Some(consumerSession) => + Try(consumerSession.pause()) match + case Success(_) => Status(code = Code.OK.value) + case Failure(err) => Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) } - - val status: Status = Status(code = Code.OK.index) Future.successful(PauseResponse(status = Some(status))) override def createConsumer(request: CreateConsumerRequest): Future[CreateConsumerResponse] = @@ -95,59 +231,89 @@ class ConsumerServiceImpl extends ConsumerServiceGrpc.ConsumerService: val sessionName = request.consumerName logger.info(s"Creating consumer session. $sessionName") - val pulsarClient = RequestContext.pulsarClient.get() - val adminClient = RequestContext.pulsarAdmin.get() + val sessionConfig = ConsumerSessionConfig.fromPb(request.consumerSessionConfig.get) - val consumerSession = ConsumerSessionRunner.make( - sessionName = sessionName, - pulsarClient = pulsarClient, - adminClient = adminClient, - sessionConfig = ConsumerSessionConfig.fromPb(request.consumerSessionConfig.get) - ) + withLifecycleLock(sessionName) { + // STOP THE PREDECESSOR FIRST. The replacement subscribes under the very same + // exclusive, non-durable subscription, so on the same topic a still-live + // predecessor refuses it - and the failure arrived long before the atomic map swap + // that was supposed to release it. The browser re-creates a session whenever its + // configuration changes, so this was the ordinary path, not a corner. + // + // Removing before building also means a build that FAILS leaves the name empty + // rather than leaving the old session running behind a client that has moved on: + // the user asked for this name to hold something else, and is told it does not. + Option(consumerSessions.remove(sessionName)).foreach(previous => + Try(previous.stop()).failed.foreach(err => + logger.warn(s"The consumer session being replaced under $sessionName could not be fully released. ${err.getMessage}") + ) + ) - consumerSessions.put(sessionName, consumerSession) + // Still not a bare `put`: nothing but this lock stands between two creates, and a + // predecessor that somehow survives one must be stopped rather than abandoned. + storeConsumerSession(consumerSessions, sessionName, makeSession(sessionName, sessionConfig)) + } } match case Success(_) => - val status: Status = Status(code = Code.OK.index) + val status: Status = Status(code = Code.OK.value) Future.successful(CreateConsumerResponse(status = Some(status))) case Failure(err) => - val status: Status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status: Status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(CreateConsumerResponse(status = Some(status))) override def deleteConsumer(request: DeleteConsumerRequest): Future[DeleteConsumerResponse] = val sessionName = request.consumerName logger.info(s"Deleting consumer session: $sessionName") - val consumerSession = Option(consumerSessions.get(sessionName)) match - case Some(consumerSession) => consumerSession - case _ => - val msg = s"No such consumer session: $sessionName" - logger.warn(msg) - val status: Status = Status(code = Code.FAILED_PRECONDITION.index, message = msg) - return Future.successful(DeleteConsumerResponse(status = Some(status))) - - try { - consumerSession.stop() - consumerSessions.remove(sessionName) - } catch { - case err: Throwable => - val status: Status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) - return Future.successful(DeleteConsumerResponse(status = Some(status))) + // Serialized against creates under the same name: reading the runner, stopping it (a broker + // round trip) and removing the entry have to be one operation. + val stopped = withLifecycleLock(sessionName) { + Option(consumerSessions.get(sessionName)) match + case None => + val msg = s"No such consumer session: $sessionName" + logger.warn(msg) + Left(msg) + case Some(consumerSession) => + val outcome = Try(consumerSession.stop()) + + // The handle goes WHATEVER stopping did. `stop` releases everything it can + // before it reports, so keeping the entry after a partial failure would leave a + // session that nothing can reach and nothing can retry - and the previous order + // (remove only on success) meant a broker that refused one unsubscribe made the + // session name permanently undeletable. + // + // COMPARE-AND-REMOVE, not a bare remove: only the session this call actually + // stopped may be unhooked. An unconditional remove would silently drop a + // replacement installed meanwhile, leaving it running and unreachable. + consumerSessions.remove(sessionName, consumerSession) + Right(outcome) } - val status: Status = Status(code = Code.OK.index) - Future.successful(DeleteConsumerResponse(status = Some(status))) + val outcome = stopped match + case Left(msg) => + return Future.successful(DeleteConsumerResponse(status = Some(Status(code = Code.FAILED_PRECONDITION.value, message = msg)))) + case Right(outcome) => outcome + + outcome match + case Success(_) => + Future.successful(DeleteConsumerResponse(status = Some(Status(code = Code.OK.value)))) + case Failure(err) => + logger.warn(s"Consumer session $sessionName was removed but could not be fully released. ${err.getMessage}") + Future.successful(DeleteConsumerResponse(status = Some(Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage)))) override def runCode(request: RunCodeRequest): Future[RunCodeResponse] = val consumerSession = Option(consumerSessions.get(request.consumerName)) match case Some(consumerSession) => consumerSession case _ => - val status: Status = Status(code = Code.FAILED_PRECONDITION.index, message = s"Consumer isn't found: ${request.consumerName}") + val status: Status = Status(code = Code.FAILED_PRECONDITION.value, message = s"Consumer isn't found: ${request.consumerName}") return Future.successful(RunCodeResponse(status = Some(status))) - val result = consumerSession.sessionContextPool.getContext(0).runCode(request.code) + // Leased, not grabbed: this runs on a gRPC thread while the session's listener threads are + // using the SAME context, so a raw handle here answered the user's expression with + // "[ERROR] Multi threaded access requested by thread ..." whenever the two overlapped. + val result = consumerSession.sessionContextPool.withContext(0)(_.runCode(request.code)) - val status: Status = Status(code = Code.OK.index) + val status: Status = Status(code = Code.OK.value) val response = RunCodeResponse(status = Some(status), result = Some(result)) Future.successful(response) @@ -159,9 +325,101 @@ class ConsumerServiceImpl extends ConsumerServiceGrpc.ConsumerService: topicSelector.getNonPartitionedTopics(adminClient) } match case Success(topicFqns) => - val status: Status = Status(code = Code.OK.index) + val status: Status = Status(code = Code.OK.value) val response = ResolveTopicSelectorResponse(status = Some(status), topicFqns = topicFqns) Future.successful(response) case Failure(err) => - val status: Status = Status(code = Code.INVALID_ARGUMENT.index, message = err.getMessage) + val status: Status = Status(code = Code.INVALID_ARGUMENT.value, message = err.getMessage) Future.successful(ResolveTopicSelectorResponse(status = Some(status))) + + /** Read one physical topic's endpoints and entry count, and pair them with how far the session + * has read. + * + * THREE ADMIN ROUND TRIPS, each O(1) in the size of the topic: the first entry, the last entry, + * and the internal stats. That per-topic cost is the whole reason this is polled on demand + * behind a client-side switch instead of riding along with the message stream - a session over + * a hundred partitions pays it a hundred times per refresh. + * + * EVERY LOOKUP IS ALLOWED TO DECLINE, and the two ways it can decline mean different things: + * + * - `brokerAnswer` returns `None` for "the broker says this log is empty" - `examineMessage` + * THROWS for "latest" on an empty topic where "earliest" answers 412 - and that is not an + * error. The row simply has no endpoints. + * - A topic Pulsar refuses to examine at all answers with a REASON. A non-persistent topic is + * the ordinary case (405): it retains nothing to examine. Distinguishing the two is why + * `unavailable_reason` exists rather than leaving the client to guess from blank cells. + */ + private def gatherTopicPosition( + adminClient: PulsarAdmin, + topicFqn: String, + cursor: Option[TopicCursor] + ): TopicPositionRow = + Try { + val first = brokerAnswer("examining the first entry", topicFqn)( + adminClient.topics.examineMessage(topicFqn, "earliest", 1) + ).map(message => LogEndpoint(entryIdOf(message.getMessageId), message.getPublishTime)) + + val last = brokerAnswer("examining the last entry", topicFqn)( + adminClient.topics.examineMessage(topicFqn, "latest", 1) + ).map(message => LogEndpoint(entryIdOf(message.getMessageId), message.getPublishTime)) + + val stats = adminClient.topics.getInternalStats(topicFqn) + val ledgers = stats.ledgers.asScala.toVector.map(info => LedgerSpan(info.ledgerId, info.entries)) + + TopicPositionInputs( + topicFqn = topicFqn, + first = first, + last = last, + cursor = cursor, + ledgers = ledgers, + currentLedgerEntries = stats.currentLedgerEntries, + retainedEntries = stats.numberOfEntries, + unavailableReason = None + ) + } match + case Success(inputs) => buildTopicPositionRow(inputs) + case Failure(err) => + // Report the topic with a reason rather than failing the whole call: one + // non-persistent topic in a multi-topic session must not blank the other rows. + logger.debug(s"Topic positions unavailable for $topicFqn. ${err.getMessage}") + buildTopicPositionRow( + TopicPositionInputs( + topicFqn = topicFqn, + first = None, + last = None, + cursor = cursor, + ledgers = Vector.empty, + currentLedgerEntries = 0, + retainedEntries = 0, + unavailableReason = Some(Option(err.getMessage).getOrElse(err.getClass.getSimpleName)) + ) + ) + + override def getTopicPositions(request: GetTopicPositionsRequest): Future[GetTopicPositionsResponse] = + val sessionName = request.consumerName + + // The session lookup comes FIRST, and the admin client is fetched only once there is + // something to ask about. A client polling this tab before the session has been started is + // the ordinary case, not an error path, and resolving the request context up here made even + // that answer impossible to produce - or to test - without a broker behind it. + Option(consumerSessions.get(sessionName)) match + case None => + val status: Status = Status(code = Code.FAILED_PRECONDITION.value, message = s"No such consumer session: $sessionName") + Future.successful(GetTopicPositionsResponse(status = Some(status))) + case Some(consumerSession) => + val adminClient = RequestContext.pulsarAdmin.get() + + // One row per PHYSICAL topic, which is what the session actually subscribes to and + // the only thing `examineMessage` will answer for - it refuses a partitioned parent + // outright (405). + val cursors = furthestCursors(consumerSession.targets.values.map(_.consumerListener.cursors)) + val topicFqns = consumerSession.targets.values.flatMap(_.consumers.keys).toVector.distinct.sorted + + Try(topicFqns.map(topicFqn => gatherTopicPosition(adminClient, topicFqn, cursors.get(topicFqn)))) match + case Success(rows) => + val status: Status = Status(code = Code.OK.value) + Future.successful(GetTopicPositionsResponse(status = Some(status), positions = rows.map(topicPositionToPb))) + case Failure(err) => + logger.warn(s"Could not read topic positions for $sessionName. ${err.getMessage}") + val status: Status = Status(code = Code.UNKNOWN.value, message = err.getMessage) + Future.successful(GetTopicPositionsResponse(status = Some(status))) diff --git a/server/src/main/scala/consumer/session_runner/ConsumerListener.scala b/server/src/main/scala/consumer/session_runner/ConsumerListener.scala index 8d9a403cf..317e65b7a 100644 --- a/server/src/main/scala/consumer/session_runner/ConsumerListener.scala +++ b/server/src/main/scala/consumer/session_runner/ConsumerListener.scala @@ -1,27 +1,402 @@ package consumer.session_runner import com.typesafe.scalalogging.Logger -import org.apache.pulsar.client.api.MessageListener +import org.apache.pulsar.client.api.{MessageListener, MessageId as PulsarMessageId} + +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicBoolean +import scala.jdk.CollectionConverters.* +import scala.util.{Failure, Success, Try} + +object ConsumerListener: + /** What to do with a message the broker just delivered. */ + enum Action: + /** The session is paused - hand it back so it is redelivered on resume. */ + case Reject + + /** Consumed by the start-from discard - acknowledge it, but show it to nobody. */ + case Drop + + case Deliver class ConsumerListener(val targetMessageHandler: ConsumerSessionTargetMessageHandler) extends MessageListener[Array[Byte]] { val logger: Logger = Logger(getClass.getName) // https://levelup.gitconnected.com/graceful-shutdown-of-pulsar-queue-consumers-in-java-and-spring-boot-f93645a92b2b - private var isAcceptingNewMessages: Boolean = true + // + // STARTS CLOSED, and that is load-bearing. `handleStartFrom` RESUMES every consumer before it + // seeks them, so the broker starts delivering while the session is still being built - before + // the start-from counters and the global ordering layer are armed, and while the target's + // message handler is still the no-op it was constructed with. Accepting there consumed those + // messages and ACKNOWLEDGED them into nothing, so a session whose set-up did any broker round + // trip (the backward entry walk, or reading each topic's last message id) could swallow its + // whole backlog and then deliver nothing at all. Rejecting instead hands them straight back. + // + // ATOMIC, and that is load-bearing too. This gate is READ from one Pulsar listener thread per + // physical topic and WRITTEN from the gRPC threads that pause and resume the session. As a + // plain `var Boolean` there was no happens-before between the two at all, so a listener thread + // was entitled to go on seeing "accepting" indefinitely after the user had paused - delivering + // into a paused session, and spending its start-from budget while doing so. Nothing here needs + // compare-and-set; what it needs is the visibility. + private val acceptingNewMessages = AtomicBoolean(false) + + /** Armed ONCE by `ConsumerSessionRunner.make`, after the start-from seek and before anything is + * resumed. Nothing on the pause/resume path may reassign it: re-arming would skip a fresh + * batch of messages every time the user hits play. + */ + var startFromDiscard: StartFromDiscard = StartFromDiscard.none + + /** The session-wide layer that puts the delivered streams into their GLOBAL order, for the two + * start-from modes that are defined over the merged stream rather than over one log. + * + * Armed ONCE alongside [[startFromDiscard]], and the SAME instance on every target of the + * session - the counting is over the whole session, so a per-target layer would count each + * target separately. Nothing on the pause/resume path may reassign it, for the same reason. + */ + var startFromOrdering: StartFromOrdering[HeldMessage] = StartFromOrdering.passThrough + + /** The counter start-from progress is read off - the USER'S skip, and nothing else. + * + * A global skip merge owns its own budget - it has to, since it decides the drops itself - and + * that budget is what the client should be shown. Otherwise it is this listener's own counter, + * but ONLY when that counter is a user skip: a "latest n" arms a per-topic counter to drop the + * over-fetch its seek could not avoid, and showing that as progress told a client that asked + * for the last 5 messages it was "skipping 95". See [[StartFromDiscard.reportsProgress]]. + */ + def progressDiscard: StartFromDiscard = + if effectiveDiscard.reportsProgress then effectiveDiscard else StartFromDiscard.none + + /** The counter actually doing the dropping, whoever owns it: the merge when it decides the drops + * itself, this listener otherwise. + * + * Deliberately WIDER than [[progressDiscard]]. This is the mechanism - what diagnostics and + * tests ask "how many messages are still to be dropped" - and it must answer for a latest-n + * seek correction too. What the CLIENT is shown is the narrower one. + */ + def effectiveDiscard: StartFromDiscard = startFromOrdering.progressDiscard.getOrElse(startFromDiscard) + + /** How far this listener has read into each physical topic it consumes, for the "Topic Positions" + * debug view. Read out of band by [[cursors]] on a gRPC thread while listener threads write it, + * hence concurrent. + * + * RECORDED WHERE MESSAGES ARE ACKNOWLEDGED - on a Drop as well as a Deliver - because both mean + * the session consumed the message. The alternative, letting the client derive it from the rows + * on screen, is wrong by however much the session filters out: a filter passing 1% of a topic + * would show a session that had read to the end as barely started. + * + * A HIGH-WATER MARK. `negativeAcknowledge` and the merge's cap both put messages back for + * redelivery, so an OLDER message can legitimately arrive after a newer one has been counted; + * letting the cursor walk backwards there would make the view flicker between two positions + * neither of which is wrong. Only advancing keeps it monotonic, which is what "how far has this + * read" means. + */ + private val cursorByTopic = ConcurrentHashMap[String, TopicCursor]() + + /** Record that `topicFqn` has been consumed as far as this message. Advances only - see + * [[cursorByTopic]]. + */ + def recordCursor(topicFqn: String, messageId: PulsarMessageId, publishTime: Long): Unit = + cursorByTopic.merge( + topicFqn, + TopicCursor(messageId, publishTime), + (existing, incoming) => if incoming.messageId.compareTo(existing.messageId) > 0 then incoming else existing + ) + () + + /** A snapshot of the read position per topic. */ + def cursors: Map[String, TopicCursor] = cursorByTopic.asScala.toMap + + /** Whether the pause gate is open. The permit-hold arbitration reads this so the delivery + * pacer's backpressure can never resume a consumer the USER paused. */ + def isAcceptingNewMessages: Boolean = acceptingNewMessages.get + + /** The session's delivery rate limiter, or None until a resume installs one. SHARED - every + * listener of the session holds the same instance, because the limit is per session, not per + * topic. Rewired on resume BEFORE the gate opens, like the message handler: the gate's + * volatile write is what publishes it to the listener threads. + */ + @volatile var deliveryRateLimiter: Option[DeliveryRateLimiter[HeldMessage]] = None + + /** Deliver one resolved message NOW, on the calling thread: hand it to the target's message + * handler, acknowledge it, and advance the read position. + * + * SELF-CONTAINED FAILURE HANDLING, because it has two callers with different surroundings: the + * resolved-batch loop (whose own catch would also cover it) and the rate limiter's drain + * thread (which has nothing above it). A failed delivery is handed back for redelivery - the + * same contract the loop pinned in round 3 - and costs only itself. + */ + def deliverNow(held: HeldMessage): Unit = + try + held.listener.targetMessageHandler.onNext(held.message) + acknowledge(held.consumer, held.message) + held.listener.recordCursor( + held.consumer.getTopic, + held.message.getMessageId, + held.message.getPublishTime + ) + catch + case err: Throwable => + logger.warn(s"Handing a message back for redelivery: delivering it failed. ${err.getMessage}") + Try(held.consumer.negativeAcknowledge(held.message)) + () + + /** Route one Deliver outcome: through the session's rate limiter when one is installed, + * straight through otherwise. The limiter's offer is an enqueue - it never blocks the calling + * listener thread and never blocks the ordering lock the merge path holds around it. */ + def deliver(held: HeldMessage): Unit = deliveryRateLimiter match + case Some(limiter) => limiter.offer(held) + case None => deliverNow(held) + + /** Called once per message the discard swallows, so a skip in flight can be reported to the + * client - a dropped message reaches nothing else, so this is the only signal there is. + * + * A no-op until a client resumes the session, and rewired by + * `ConsumerSessionTargetRunner.resume` on every play. Unlike [[startFromDiscard]] there is + * nothing to preserve across a pause: it carries no state. + */ + var onStartFromDiscardProgress: () => Unit = () => () + + /** Report a drop to the client, and NEVER let that reporting cost the message. + * + * The push ends in `StreamObserver.onNext`, which throws whenever the client's call has been + * cancelled. It used to be called between claiming the discard budget and acknowledging the + * message: the throw propagated out of `received`, so the budget had been spent on a message + * that was never acknowledged - the broker redelivered it, the spent budget let it through, + * and the session showed a message the user had asked to skip. Fewer than n unique messages + * were dropped, and nothing said so. + * + * Progress is a UI nicety. Losing it costs a progress bar; losing a message costs correctness. + */ + private def reportDiscardProgress(): Unit = + try onStartFromDiscardProgress() + catch + case err: Throwable => + logger.warn(s"Failed to report start-from progress; the skip itself is unaffected. ${err.getMessage}") def stopAcceptingNewMessages(): Unit = - this.isAcceptingNewMessages = false + acceptingNewMessages.set(false) def startAcceptingNewMessages(): Unit = - this.isAcceptingNewMessages = true + acceptingNewMessages.set(true) + + /** The whole decision, split out of `received` so it is testable without a broker - a paused + * session must NOT consume the discard (the message is coming back), and the discard must + * survive any number of pause/resume cycles. + * + * `canAcknowledge` is whether the consumer can still answer the broker for this message. A + * message that CANNOT be acknowledged must not be decided at all: claiming the discard for it + * spends a unit of the user's "skip the first n" on a message the broker will simply redeliver, + * and the redelivery then meets an empty budget and is SHOWN - so the session skips fewer than + * n unique messages while reporting that the skip completed. Handing it straight back costs + * one redelivery and nothing else. + * + * Reporting the drop happens HERE rather than in `received`, next to the claim that caused it: + * the claim is already the mutation this method performs, and keeping the two together is what + * makes "a rejected message does not count as skipped" testable without a broker. It goes + * through [[reportDiscardProgress]], so a failing report cannot abort the acknowledgment that + * has to follow the claim. + */ + def decide(topicFqn: NonPartitionedTopicFqn, canAcknowledge: Boolean): ConsumerListener.Action = + if !acceptingNewMessages.get then ConsumerListener.Action.Reject + else if !canAcknowledge then ConsumerListener.Action.Reject + else if startFromDiscard.claim(topicFqn) then + reportDiscardProgress() + ConsumerListener.Action.Drop + else ConsumerListener.Action.Deliver + + /** Messages this session DECIDED - dropped or delivered - whose acknowledgment the broker did + * not take. The decision stands; only the paperwork failed. The redelivery is met at the very + * top of [[received]]: acknowledged again and otherwise ignored, never re-decided. + * + * THIS SET IS WHAT MAKES THE COUNTED MODES EXACT UNDER A FAILED ACK. The alternatives were + * both wrong in a way three review rounds circled: REFUNDING the budget kept the count right + * but let the budget be spent on a DIFFERENT message, so the redelivered original was shown - + * "skip the first n" skipped some other n. NOT refunding kept the set right until the + * redelivery arrived after the budget closed and was shown anyway - n was simply wrong. The + * decision-stands-retry-the-ack rule keeps both: the unit stays spent on exactly the message + * it was claimed for, and that message can never reappear, however late the broker redelivers + * it. A DELIVERED message whose ack failed gets the same treatment, which also stops its + * redelivery from being shown twice. + * + * Bounded by the number of failed acknowledgments (rare - a disconnect race), and each entry + * leaves the moment its redelivery is finalized. + */ + private val awaitingAckRetry = ConcurrentHashMap.newKeySet[PulsarMessageId]() + + /** Test-only window: how many decided messages still owe the broker an acknowledgment. */ + private[session_runner] def awaitingAckRetryCount: Int = awaitingAckRetry.size + + /** Acknowledge a DECIDED message - dropped or delivered, merge-path or local. On failure the + * decision stands: the id is remembered, the message handed back, and the redelivery is + * acknowledged-and-ignored at the top of [[received]]. See [[awaitingAckRetry]] for why this + * replaced both the refund and the log-and-hope paths. + */ + private def acknowledge(consumer: org.apache.pulsar.client.api.Consumer[Array[Byte]], msg: org.apache.pulsar.client.api.Message[Array[Byte]]): Unit = + def retryOnRedelivery(reason: String): Unit = + logger.warn( + s"A decided message's acknowledgment failed; its decision stands and the redelivery will only be acknowledged. Consumer: ${consumer.getConsumerName}. $reason" + ) + awaitingAckRetry.add(msg.getMessageId) + Try(consumer.negativeAcknowledge(msg)) + () + + if !consumer.isConnected then retryOnRedelivery(s"Consumer ${consumer.getConsumerName} is not connected.") + else + Try(consumer.acknowledgeAsync(msg)) match + case Success(acknowledged) => + acknowledged.whenComplete((_, err) => if err != null then retryOnRedelivery(err.getMessage)) + () + case Failure(err) => retryOnRedelivery(err.getMessage) + + /** Acknowledge a message the start-from swallowed. The budget unit STAYS SPENT whatever the + * acknowledgment does: it was claimed for exactly this message, and [[awaitingAckRetry]] + * guarantees the message cannot come back as anything but paperwork. Refunding here used to + * let the redelivered original be shown while the refunded unit was spent on a different + * message - an exact COUNT of the wrong SET. + */ + private[session_runner] def acknowledgeDrop( + consumer: org.apache.pulsar.client.api.Consumer[Array[Byte]], + msg: org.apache.pulsar.client.api.Message[Array[Byte]] + ): Unit = acknowledge(consumer, msg) override def received(consumer: org.apache.pulsar.client.api.Consumer[Array[Byte]], msg: org.apache.pulsar.client.api.Message[Array[Byte]]): Unit = - if !isAcceptingNewMessages then - consumer.negativeAcknowledge(msg) - return; + // A redelivery of a message this session already DECIDED - its ack failed, nothing else. + // Finalize the paperwork and show it to nobody: not the gate (a pause cannot un-decide + // it), not the discard (its unit was spent on this very message), not the merge (it was + // already cut). This check is what keeps skip-n and latest-n exact across ack failures. + if awaitingAckRetry.remove(msg.getMessageId) then + acknowledge(consumer, msg) + else decide(consumer.getTopic, canAcknowledge = consumer.isConnected) match + case ConsumerListener.Action.Reject => + consumer.negativeAcknowledge(msg) + + case ConsumerListener.Action.Drop => + logger.debug(s"Listener discarded a message for the start-from position. Consumer: ${consumer.getConsumerName}") + acknowledgeDrop(consumer, msg) + // The read-position contract counts DROPS as read - the merge path records them, + // and this local path used to skip it, so a single-stream skip-n that consumed a + // whole backlog showed Topic Positions with no cursor at all. + recordCursor(consumer.getTopic, msg.getMessageId, msg.getPublishTime) + + case ConsumerListener.Action.Deliver => + // The offer AND everything it resolved run under the session's ordering lock. The + // lock used to be released with `offer`, and what `offer` answers with is a BATCH + // that still has to be handled - so another listener thread could resolve a later + // batch and process it first, and the session's stateful filters, coloring rules + // and value projections then saw the messages in a different order than the merge + // had just decided on. A pass-through session takes no lock at all. + startFromOrdering.inOrder { + // How the merge's flow control reaches THIS stream's consumer. Registered on + // first delivery - a stream that never delivers has nothing to pause - and a + // no-op for a pass-through layer. + startFromOrdering.registerStreamPauseHooks( + startFromStreamId(consumer.getConsumerName, consumer.getTopic), + pause = () => { Try(consumer.pause()); () }, + resume = () => { Try(consumer.resume()); () } + ) + // What comes back is what the OFFER resolved, which is often not the message + // just offered and may belong to another topic - and therefore to another + // target's listener, which is why each held message carries its own. + val resolved = startFromOrdering.offer( + consumerName = consumer.getConsumerName, + topicFqn = consumer.getTopic, + publishTime = msg.getPublishTime, + messageId = msg.getMessageId, + payload = HeldMessage(consumer, msg, this) + ) + handleResolved(resolved) + // The batch that spends the budget's last unit is handled just above, still + // under the lock; only AFTER it may later messages take the lock-free path. + startFromOrdering.settleIfDone() + startFromOrdering.reconcileFlowControl() + } + + /** The stall watchdog's entry: give up on a stream silent past the window even when NO further + * offer will ever arrive to trigger the offer-driven check - the last held backlog message has + * nobody behind it to speak. Same lock, same handling as an offer's resolutions. + */ + def sweepStartFromStall(): Unit = + startFromOrdering.inOrder { + val abandonedBefore = startFromOrdering.abandonedStreams.size + val resolved = startFromOrdering.sweepStalled() + if resolved.nonEmpty then handleResolved(resolved) + // A sweep's give-up can spend the budget's last unit too (drain on abandonment), and + // its drains change what flow control wants paused. + startFromOrdering.settleIfDone() + startFromOrdering.reconcileFlowControl() + // A give-up changed the ANSWER (degraded, abandoned stream names) even when it + // resolved no messages - and it may never be followed by another drop. Push the + // disclosure itself; the runner's gate lets the first degraded frame through + // whatever the reporting interval says. + if startFromOrdering.abandonedStreams.size > abandonedBefore then reportDiscardProgress() + } + + /** The streams the start-from resolution gave up waiting for - the session's degradation + * record, surfaced through the progress API. */ + def startFromAbandonedStreams: Vector[String] = startFromOrdering.abandonedStreams - logger.debug(s"Listener received a message. Consumer: ${consumer.getConsumerName}") - targetMessageHandler.onNext(msg) + /** Called by the runner on RESUME, before it re-arms the sweep: paused time must not count + * against a silent stream, and the resume just woke every consumer wholesale, so the + * flow-control bookkeeping has to match that reality too. */ + def resetStartFromStallClock(): Unit = startFromOrdering.inOrder { + startFromOrdering.resetStallClock() + startFromOrdering.resetAppliedFlowControl() + } - if consumer.isConnected then consumer.acknowledgeAsync(msg) + /** Act on what the ordering layer resolved - deliver, drop, or hand back each message. Shared + * by the offer path and the stall watchdog, and containment is per message: see the comment + * inside. Callers hold the ordering lock. + */ + private[session_runner] def handleResolved(resolved: Vector[(HeldMessage, StartFromOutcome)]): Unit = + val ordersProgress = startFromOrdering.progressDiscard.isDefined + resolved.foreach { (held, outcome) => + // Each pair was already DEQUEUED from the merge, so it exists nowhere else. A + // throw here - a cancelled client makes the delivery push throw - used to + // escape the whole loop, abandoning every pair after it: neither delivered, + // acknowledged, nor handed back, and with no ackTimeout on a NonDurable + // subscription the broker never redelivered them while the runner lived. + // Contain per message and hand a failed one back so the broker redelivers it; + // the progress push below is separately shielded (see reportDiscardProgress). + try + outcome match + case StartFromOutcome.Drop => + // Acknowledge FIRST: a progress push is best-effort, and a + // message must never be counted as dropped without being + // acknowledged. Through the ORIGIN listener, like every other + // per-message action here: a failed ack is remembered in + // awaitingAckRetry, and the broker redelivers to the listener + // the consumer belongs to - remembering it HERE (the listener + // that happened to process the batch) let the redelivery + // arrive at a listener that had never heard of it, which + // re-decided it and, after the cut, DELIVERED the very message + // the user asked to skip. + held.listener.acknowledgeDrop(held.consumer, held.message) + // A dropped message was still READ - the start-from discard + // consumed it - so the debug view's read position must count it. + // Omitting it would park the cursor at the start of the topic for + // the whole of a skip-n, which is the one time it is interesting. + held.listener.recordCursor( + held.consumer.getTopic, + held.message.getMessageId, + held.message.getPublishTime + ) + // Only when the ordering layer is doing the counting. A latest-n + // retain drops most of what it sees and counts none of it, and + // reporting those would put a frame on the wire per dropped message. + if ordersProgress then held.listener.reportDiscardProgress() + case StartFromOutcome.Deliver => + // Through the rate limiter when one is installed. The offer is + // an instant enqueue, so holding the ordering lock across it + // costs nothing - and the DROPS around it stay unlimited, + // which is what keeps a counted skip positioning at full + // speed under any limit. + held.listener.deliver(held) + catch + case err: Throwable => + logger.warn( + s"Handing a start-from-ordered message back for redelivery: delivering it failed. ${err.getMessage}" + ) + Try(held.consumer.negativeAcknowledge(held.message)) + () + } } diff --git a/server/src/main/scala/consumer/session_runner/ConsumerSessionContext.scala b/server/src/main/scala/consumer/session_runner/ConsumerSessionContext.scala index 87feb1764..f6a20554b 100644 --- a/server/src/main/scala/consumer/session_runner/ConsumerSessionContext.scala +++ b/server/src/main/scala/consumer/session_runner/ConsumerSessionContext.scala @@ -28,6 +28,33 @@ case class ConsumerSessionContextConfig( ) class ConsumerSessionContext(config: ConsumerSessionContextConfig): + /** Nobody enters this context without holding this. + * + * A GraalVM context may MIGRATE between threads but may NOT be entered by two at once - the + * loser gets "Multi threaded access requested by thread ... but is not allowed for + * language(s) js". A consumer session hands this ONE context to every partition of every + * target, and Pulsar delivers each partition on its own listener thread, so they collide. + * + * It guards more than that exception, and the more that it guards is the harder half: + * `setCurrentMessage` writes the message under test into a GLOBAL JS variable, and the filter + * chain, the coloring rules and the value projections all read it back out afterwards - each + * one a SEPARATE entry into the context, with a gap in between. `getStdout` likewise drains + * and RESETS a buffer shared by all of them. Held for the whole of one message those hand-offs + * cannot cross; held per JS call they still could, and that outcome throws nothing at all - it + * just judges one message by another message's contents. + * + * Reentrant, so a lease may nest inside a lease without deadlocking. + */ + private val lock = new java.util.concurrent.locks.ReentrantLock() + + /** Run `use` with this context entered by nobody else. Prefer leasing through + * `ConsumerSessionContextPool.withNextContext`, which keeps the choice of context and the + * exclusion over it together. */ + def exclusively[A](use: => A): A = + lock.lock() + try use + finally lock.unlock() + val context: Context = Context .newBuilder("js") .engine(config.engine) @@ -60,6 +87,17 @@ class ConsumerSessionContext(config: ConsumerSessionContextConfig): """.stripMargin ) + /** Release the JS context this session held. + * + * `close(true)` rather than `close()`: the plain form REFUSES while any thread is inside the + * context, and a session is stopped from a gRPC thread while its listener threads may still be + * mid-message. The cancelling form is the only one that can be relied on to actually free it. + * + * Nothing closed this at all, so every consumer session ever created leaked a Graal context + * (and, through the pool, an engine) for the life of the process. + */ + def close(): Unit = context.close(true) + def getStdout: String = val logs = config.stdout.toString config.stdout match diff --git a/server/src/main/scala/consumer/session_runner/ConsumerSessionContextPool.scala b/server/src/main/scala/consumer/session_runner/ConsumerSessionContextPool.scala index 6145d5103..0672a4166 100644 --- a/server/src/main/scala/consumer/session_runner/ConsumerSessionContextPool.scala +++ b/server/src/main/scala/consumer/session_runner/ConsumerSessionContextPool.scala @@ -31,3 +31,37 @@ case class ConsumerSessionContextPool(isDebug: Boolean = false): contextPool(key) def getContext(key: Int): ConsumerSessionContext = contextPool(key) + + /** Lease the next context for the WHOLE of `use`, with no other thread inside it meanwhile. + * + * Every caller that runs off a delivery thread - the per-message handler, the browser console - + * must come through here rather than through `getNextContext`/`getContext`: the pool is one + * context shared by every partition listener of the session, and a raw handle carries no + * exclusion. See `ConsumerSessionContext.exclusively` for what that exclusion is protecting. + */ + def withNextContext[A](use: ConsumerSessionContext => A): A = + val sessionContext = getNextContext + sessionContext.exclusively(use(sessionContext)) + + def withContext[A](key: Int)(use: ConsumerSessionContext => A): A = + val sessionContext = getContext(key) + sessionContext.exclusively(use(sessionContext)) + + /** Release every context and the engine behind them, when the session that owns this pool stops, + * and ANSWER WITH WHAT COULD NOT BE RELEASED. + * + * Best-effort per item, so one context that will not close cannot strand the rest or the + * engine. Idempotent: closing an already-closed Graal context or engine is a no-op, and + * stopping a session twice is an ordinary thing for a client to do. + * + * It used to swallow every failure and answer `Unit`, and the caller discarded that too - so a + * context still executing on some thread stayed open, holding its heap, while `deleteConsumer` + * told the client the session had been released. The caller aggregates these into the same + * failure it reports for consumers. + */ + def close(): Vector[String] = + val contextFailures = contextPool.toVector.flatMap { (key, sessionContext) => + scala.util.Try(sessionContext.close()).failed.toOption.map(err => s"JS context $key: ${err.getMessage}") + } + val engineFailure = scala.util.Try(engine.close(true)).failed.toOption.map(err => s"JS engine: ${err.getMessage}") + contextFailures ++ engineFailure.toVector diff --git a/server/src/main/scala/consumer/session_runner/ConsumerSessionRunner.scala b/server/src/main/scala/consumer/session_runner/ConsumerSessionRunner.scala index 9cbae16ff..62998dd18 100644 --- a/server/src/main/scala/consumer/session_runner/ConsumerSessionRunner.scala +++ b/server/src/main/scala/consumer/session_runner/ConsumerSessionRunner.scala @@ -2,9 +2,14 @@ package consumer.session_runner import com.tools.teal.pulsar.ui.api.v1.consumer as consumerPb import consumer.session_config.ConsumerSessionConfig +import consumer.session_target.ConsumerSessionTarget import org.apache.pulsar.client.admin.PulsarAdmin import org.apache.pulsar.client.api.PulsarClient +import scala.util.{Failure, Success, Try} + +import java.util.concurrent.{Executors, ScheduledExecutorService, TimeUnit} +import java.util.concurrent.atomic.AtomicLong import java.io.ByteArrayOutputStream import com.google.rpc.code.Code import com.google.rpc.status.Status @@ -15,23 +20,365 @@ import boundary.break type ConsumerSessionTargetIndex = Int +/** The single shape every ResumeResponse takes, so a message response and a progress-only push + * cannot drift apart. + * + * `startFromProgress` rides along on EVERY response, which is how the client learns that a skip + * finished: the completing state arrives with the first message that actually gets delivered. + * `None` means the session's start-from needed no counting at all, and the client is told nothing + * rather than told zero. + */ +def resumeResponse( + messages: Seq[consumerPb.Message], + errors: Vector[String], + startFromProgress: Option[consumerPb.StartFromProgress] +): consumerPb.ResumeResponse = + val status = errors.size match + case 0 => Status(code = Code.OK.value) + case _ => Status(code = Code.UNKNOWN.value, message = errors.mkString("\n\n")) + + consumerPb.ResumeResponse( + messages = messages, + status = Some(status), + consumerStats = startFromProgress.map(progress => consumerPb.ConsumerStats(startFromProgress = Some(progress))) + ) + case class ConsumerSessionRunner( sessionName: String, sessionConfig: ConsumerSessionConfig, sessionContextPool: ConsumerSessionContextPool, var grpcResponseObserver: Option[io.grpc.stub.StreamObserver[consumerPb.ResumeResponse]], var schemasByTopic: SchemasByTopic, - var targets: Map[ConsumerSessionTargetIndex, ConsumerSessionTargetRunner], - var numMessageProcessed: Long = 0, - var numMessageSent: Long = 0 + var targets: Map[ConsumerSessionTargetIndex, ConsumerSessionTargetRunner] ) { - def incrementNumMessageProcessed(): Unit = numMessageProcessed = numMessageProcessed + 1 + /** Session-wide counters, stamped onto EVERY `pb.Message` the browser receives. + * + * Atomic, and held in the body rather than as constructor params, because + * `incrementNumMessageProcessed` is called from the target message handler BEFORE the + * per-message context lease - i.e. concurrently, one listener thread per partition. As plain + * `var Long`s these lost read-modify-write updates, so a partitioned session under-reported how + * many messages it had processed and shipped that wrong number to the UI. + * + * `numMessageSent` happens to be incremented inside the lease today and so is already + * serialized; it is atomic too so the guarantee does not silently depend on that call staying + * where it is. + */ + private val numMessageProcessedCounter = AtomicLong(0) + private val numMessageSentCounter = AtomicLong(0) + + /** The per-resume delivery budget: how many more messages may be LOADED before the drain stops. + * Long.MaxValue when no budget is armed - the decrement below runs unconditionally, and from + * MaxValue it cannot reach zero in a session's lifetime. Reset by every resume. + */ + private val remainingToDeliver = AtomicLong(Long.MaxValue) + + def numMessageProcessed: Long = numMessageProcessedCounter.get + def numMessageSent: Long = numMessageSentCounter.get + + def incrementNumMessageProcessed(): Unit = numMessageProcessedCounter.incrementAndGet() + + /** Messages the start-from discard has still to drop before this session shows anything. + * + * Exposed so a test can assert "exactly n were skipped" instead of inferring it from what came + * out. `distinct` is identity-based (StartFromDiscard defines no equals), which is what a + * SharedTotal plan needs: every target holds the SAME counter and it must be counted once. + * + * Reads the EFFECTIVE counter, not the reportable one: this is the mechanism, and a latest-n + * seek correction still has messages to drop even though the client is deliberately not shown + * a progress bar for it. + */ + def remainingStartFromDiscard: Long = + targets.values.map(_.consumerListener.effectiveDiscard).toVector.distinct.map(_.remaining).sum + + /** How far the start-from skip has got, or `None` when there is no skip to do. + * + * Absent - not zero-and-complete - for every mode that seeks exactly (earliest, latest, a + * date/time, a message id, an approximate position): those reach their position with the seek + * itself, and a progress report for them would be an invention. + * + * `distinct` is identity-based, which is what a shared counter needs: every target of a + * "skip first n" session holds the SAME counter, and counting it once per target would report + * n times the number of targets. On a partitioned "skip first n" that counter belongs to the + * global merge rather than to the listener, which is what `progressDiscard` resolves - the + * merge decides the drops, so it is the only thing that knows how many are left. + */ + def startFromProgress: Option[consumerPb.StartFromProgress] = + val discards = targets.values.map(_.consumerListener.progressDiscard).toVector.distinct + val toSkip = discards.map(_.total).sum + + Option.when(toSkip > 0) { + val left = discards.map(_.remaining).sum + // The degradation record rides on every progress frame: a stream the merge abandoned + // means the position is best-effort, and the client keeps saying so for the session's + // life. One listener suffices - the ordering layer is session-wide. + val abandoned = targets.values.headOption.map(_.consumerListener.startFromAbandonedStreams).getOrElse(Vector.empty) + consumerPb.StartFromProgress( + messagesSkipped = toSkip - left, + messagesToSkip = toSkip, + complete = left <= 0, + degraded = abandoned.nonEmpty, + abandonedStreams = abandoned + ) + } + + /** Whether the client that resumed this session asked for consumer stats + * (`ResumeRequest.include_consumer_stats`). + * + * It used to be read off the request and then dropped, so every client received the stats - + * including the MESSAGE-LESS progress frames a skip in flight pushes, which a client that + * asked for no stats has no reason to expect. Set on every resume, like the debug flag. + */ + var includeConsumerStats: Boolean = true + + /** What may actually go on the wire: nothing at all unless the client asked for it. */ + private def reportableStartFromProgress: Option[consumerPb.StartFromProgress] = + if includeConsumerStats then startFromProgress else None + + /** Whether a counted start-from is still resolving: the discard has messages left to drop, or + * the global merge is holding messages while it decides. The rate limiter's permit hold is + * refused during this window - a consumer whose permits a THROTTLE paused looks exactly like + * the silent stream the merge gives up on after 30 seconds, and that give-up silently changes + * a skip's result. Delivers still queue during the window (they are the few at the boundary), + * so the user-visible rate stays exact; only the broker-side backpressure waits. + */ + private def startFromResolutionActive: Boolean = + remainingStartFromDiscard > 0 || targets.values.exists(_.consumerListener.startFromOrdering.heldCount > 0) + + /** Applies or lifts the rate limiter's permit hold on every target. Each target refuses under + * a closed gate, so a user pause always outranks the limiter's backpressure. + * + * ANSWERS whether every target applied it. `Consumer.pause()` can throw (a consumer mid-close + * during a shutdown race), and swallowing that used to let the limiter latch `permitsHeld` + * with nothing actually paused - the broker kept filling the queue and no retry ever came, + * because the flag said the work was done. Every target is still ATTEMPTED (one broken + * consumer must not shield the others), and the aggregate verdict lets the caller keep the + * flag honest. + */ + private val permitLogger = com.typesafe.scalalogging.Logger(getClass.getName) + + private def setPermitHold(hold: Boolean): Boolean = + targets.values.toVector + .map { target => + Try(target.setPermitHold(hold)) match + case Success(_) => true + case Failure(err) => + permitLogger.warn( + s"Could not ${if hold then "hold" else "release"} permits on target ${target.targetIndex}: ${err.getMessage}" + ) + false + } + .forall(identity) + + /** The single timer thread behind the session's delivery rate limiter, created on first use so + * a session that never throttles never owns one. Only the drain schedule runs here - the + * drain itself leases the session's JS context and takes the send lock, which is exactly the + * work the listener threads do today. + */ + private var rateLimiterExecutor: Option[ScheduledExecutorService] = None + + private def timerExecutor(): ScheduledExecutorService = synchronized { + rateLimiterExecutor.getOrElse { + val created = Executors.newSingleThreadScheduledExecutor(runnable => { + val thread = Thread(runnable, s"delivery-rate-limit-$sessionName") + thread.setDaemon(true) + thread + }) + rateLimiterExecutor = Some(created) + created + } + } + + private def scheduleRateLimiterTick(delayMs: Long, task: Runnable): Unit = + // A tick scheduled while stop() is shutting the executor down is a delivery that no longer + // matters; dropping it is the correct outcome, not an error. + Try(timerExecutor().schedule(task, delayMs, TimeUnit.MILLISECONDS)) + () + + /** The merge's stall bound is TIME-driven only through this watchdog: the in-band check runs on + * offers, and after the last held backlog message no offer may ever come again - a + * retention-trimmed partition then held the merge forever with the give-up window long + * expired. Armed on resume while a counted start-from is unresolved, disarmed the moment it + * resolves (the task cancels itself), on pause, and on stop. + */ + private var stallSweepTask: Option[java.util.concurrent.ScheduledFuture[?]] = None + + private def armStallSweep(): Unit = synchronized { + if startFromResolutionActive && stallSweepTask.forall(task => task.isDone || task.isCancelled) then + stallSweepTask = Try( + ConsumerSessionRunner.maintenanceScheduler.scheduleWithFixedDelay( + () => + if !startFromResolutionActive then cancelStallSweep() + else + Try(targets.values.headOption.foreach(_.consumerListener.sweepStartFromStall())).failed + .foreach(err => permitLogger.warn(s"The start-from stall sweep failed; it will run again. ${err.getMessage}")), + startFromStallSweepPeriodMs, + startFromStallSweepPeriodMs, + TimeUnit.MILLISECONDS + ) + ).toOption + } + + private def cancelStallSweep(): Unit = synchronized { + stallSweepTask.foreach(_.cancel(false)) + stallSweepTask = None + } + + /** The session's delivery rate limiter. ONE per session however many targets and partitions the + * selector matched, so the configured number means "per second, total" - the only reading a + * user can act on. Rate 0 (the default) short-circuits to the unlimited path. + */ + val deliveryRateLimiter: DeliveryRateLimiter[HeldMessage] = DeliveryRateLimiter[HeldMessage]( + core = DeliveryRateLimiterCore[HeldMessage](nowMs = () => System.nanoTime() / 1_000_000L), + schedule = scheduleRateLimiterTick, + process = held => held.listener.deliverNow(held), + holdPermits = () => + // Both refusals hand back `false`, and the limiter retries on the next crossing offer: + // suppression until the counted start-from resolves, and a target whose consumer threw. + !startFromResolutionActive && setPermitHold(true), + releasePermits = () => + // A failed release is only logged: the flag is already cleared, so the next crossing + // re-pauses whatever is still pausable, and a consumer that cannot resume is one the + // next user resume (or stop) deals with anyway. + setPermitHold(false) + () + ) + + /** Hand ONE response to the client, with wherever the start-from skip has got to attached. + * + * Every response leaves through here - a delivered message, a count-only placeholder, a + * progress push - so the client cannot receive one that forgot the stats. That matters for the + * completing state in particular: the client clears its progress panel when `complete` is true + * or when the field is absent, so a delivered message that dropped the stats would leave a + * "skipping..." panel on screen for the rest of the session. + * + * SERIALIZED, AND THE RESPONSE IS BUILT INSIDE THE SERIALIZED SECTION. `io.grpc.stub + * .StreamObserver` is not thread-safe and this is entered from every Pulsar listener thread of + * the session - one per physical topic - as well as from the gRPC thread that resumed it. + * Progress pushes made that concrete: several partitions claiming a discard at once each called + * `onNext` directly, concurrently, on one observer. + * + * Taking the lock only around `onNext` was not enough. The response - INCLUDING its start-from + * counters - was built first, so two threads could snapshot the counters in one order and send + * in the other: an older, still-incomplete progress frame could be written after a newer, + * complete one. The client clears its progress panel when it sees `complete`, so the stale + * frame behind it reopened a "skipping..." panel that stayed for the rest of the session. The + * snapshot and the write now happen under one lock, which is the only way the two can agree. + * + * NOTHING IS WRITTEN AFTER THE STREAM HAS BEEN COMPLETED. See [[stop]]. + */ + def sendResponse( + observer: io.grpc.stub.StreamObserver[consumerPb.ResumeResponse], + messages: Seq[consumerPb.Message], + errors: Vector[String] + ): Unit = + sendLock.synchronized { + if !streamCompleted then observer.onNext(resumeResponse(messages, errors, reportableStartFromProgress)) + } + + /** The one thing every write to this session's response stream goes through - including the + * terminal `onCompleted`, which is what makes "no response after the end" enforceable. */ + private val sendLock = new Object + + /** Whether the client's stream has been ended. Read and written only under [[sendLock]], so + * every listener thread sees it as soon as the thread that ended the stream let go. + * + * STICKY, deliberately. A session is only stopped on its way out - deleted, or replaced by a + * new session under the same name - and the runner is discarded immediately afterwards. A + * resume that raced the stop would otherwise start writing into a completed gRPC stream, which + * throws; the client's own remedy is to create a session, not to revive this one. + */ + private var streamCompleted: Boolean = false + + /** Whether this runner's response stream has been ENDED - by [[stop]], or by + * [[failAndComplete]]. Sticky, like the flag it reads: a terminal runner can never speak + * again, so `ConsumerServiceImpl.resume` must answer a fresh observer itself rather than wire + * it into one - every send would be swallowed by the gate and the play stream would hang + * silent. + */ + def isStreamCompleted: Boolean = sendLock.synchronized(streamCompleted) + + /** End the client's stream with a non-OK `status`, through the same lock and terminal flag as + * every other write to it. + * + * This exists for the failure paths that used to write to the observer DIRECTLY: a late + * target throwing out of a resume leaves the earlier targets' listeners live and pushing into + * the same observer, so a status frame written outside [[sendLock]] could interleave with a + * push (`StreamObserver` is not thread-safe), and an `onCompleted` that set no terminal flag + * let every later push land in a completed stream. Both sends are best-effort - the client's + * call may already be dead - but the flag, which is what stops the pushing, is set regardless. + */ + def failAndComplete(status: Status): Unit = + sendLock.synchronized { + if !streamCompleted then + streamCompleted = true + grpcResponseObserver.foreach { observer => + Try(observer.onNext(consumerPb.ResumeResponse(status = Some(status)))) + Try(observer.onCompleted()) + } + grpcResponseObserver = None + } def resume( grpcResponseObserver: io.grpc.stub.StreamObserver[consumerPb.ResumeResponse], - isDebug: Boolean + isDebug: Boolean, + includeConsumerStats: Boolean = true, + maxMessagesPerSecond: Long = 0, + maxMessagesToDeliver: Long = 0 ): Unit = - this.grpcResponseObserver = Some(grpcResponseObserver) + // Under the send lock like every other touch of the observer field: it is read by + // [[sendResponse]], [[failAndComplete]] and [[stop]] on other threads, and an unserialized + // write here could interleave with a stop completing the PREVIOUS observer. + sendLock.synchronized { this.grpcResponseObserver = Some(grpcResponseObserver) } + this.includeConsumerStats = includeConsumerStats + + // Both delivery controls are per RESUME, like the two flags above them - they belong to + // the browser that pressed Play, not to the session's saved definition. Configured before + // the targets resume below, so the gate's volatile write publishes the limiter to the + // listener threads along with everything else this resume rewired. The user's resume also + // resumed every consumer regardless of any permit hold the limiter had placed, so the + // limiter is told to forget it - its next watermark crossing re-asserts the hold instead + // of believing a stale flag. + // + // The delivery budget counts LOADED messages at the send site below; it needs the queue + // (forceQueue) so the drain can stop the line BETWEEN messages - the budget's whole + // contract is that the message spending the last unit is the last one sent. + remainingToDeliver.set(if maxMessagesToDeliver > 0 then maxMessagesToDeliver else Long.MaxValue) + deliveryRateLimiter.core.setRate(math.max(0, maxMessagesPerSecond)) + deliveryRateLimiter.core.setForceQueue(maxMessagesToDeliver > 0) + deliveryRateLimiter.onConsumersExternallyResumed() + targets.values.foreach(_.consumerListener.deliveryRateLimiter = Some(deliveryRateLimiter)) + // The drain is re-armed at the BOTTOM of this method, after every target has rewired its + // handlers onto this resume's observer. Re-arming here scheduled a zero-delay tick that + // could run a queued tail through the PREVIOUS play's handler closure - sending messages + // to the cancelled observer, acknowledging them, and charging them to THIS resume's + // delivery budget. The messages were consumed and never seen. + + // A message the start-from discard swallowed reaches nobody: the listener drops it before the + // message handler, so nothing on the path below fires while a skip is in flight. Without + // this push a session skipping millions of messages looks hung - it delivers nothing and + // says nothing. Runs on the Pulsar listener threads, one per consumer. + var degradationReported = false + def reportStartFromDiscardProgress(): Unit = + reportableStartFromProgress.foreach { progress => + // The FIRST degraded frame bypasses the interval gate. A give-up may resolve only + // a handful of drops - or none at all - and a session that then goes quiet would + // never cross another interval boundary: the disclosure would sit in the server + // forever, which is the exact silent failure the flag exists to prevent. + val mustDiscloseDegradation = progress.degraded && !degradationReported + if mustDiscloseDegradation + || shouldReportStartFromProgress(progress.messagesSkipped, progress.messagesToSkip, startFromProgressReportInterval) + then + if mustDiscloseDegradation then + // Log-worthy in its own right: this is the moment the session's answer + // became best-effort, and the one frame the client's banner hangs on. + permitLogger.info( + s"Disclosing start-from degradation to the client: abandoned ${progress.abandonedStreams.mkString(", ")}" + ) + if progress.degraded then degradationReported = true + // Message-less on purpose: nothing was delivered, only the counters moved. The + // client reads the stats before it looks for a trailing message. + sendResponse(grpcResponseObserver, Seq.empty, Vector.empty) + } def onNext( messageFromTarget: Option[ConsumerSessionMessage], @@ -40,17 +387,7 @@ case class ConsumerSessionRunner( errors: Vector[String] ): Unit = boundary: def createAndSendResponse(messages: Seq[consumerPb.Message], additionalErrors: Vector[String] = Vector.empty): Unit = - val allErrors = errors ++ additionalErrors - - val status = allErrors.size match - case 0 => Status(code = Code.OK.index) - case _ => Status(code = Code.UNKNOWN.index, message = allErrors.mkString("\n\n")) - - val response = consumerPb.ResumeResponse( - messages = messages, - status = Some(status) - ) - grpcResponseObserver.onNext(response) + sendResponse(grpcResponseObserver, messages, errors ++ additionalErrors) boundary.break(()) messageFromTarget match @@ -59,7 +396,10 @@ case class ConsumerSessionRunner( numMessageProcessed = numMessageProcessed, numMessageSent = numMessageSent ) - createAndSendResponse(Seq(emptyMsgPb), errors) + // NOT `errors` again: createAndSendResponse already appends the captured + // `errors` to every response, so passing them here too doubled every + // target-filter debug error the client saw. + createAndSendResponse(Seq(emptyMsgPb)) case Some(msg) => val messageFilterChainResult = sessionContext.testMessageFilterChain( @@ -102,7 +442,12 @@ case class ConsumerSessionRunner( messageFilterChainErrors ++ coloringRuleChainErrors else Vector.empty - numMessageSent = numMessageSent + 1 + numMessageSentCounter.incrementAndGet() + // The delivery budget is spent HERE - on loaded messages, after every filter - + // and the send that spends the last unit stops the drain before it returns, so + // whatever sits behind it in the batch is requeued instead of delivered. With + // no budget armed the counter starts at Long.MaxValue and this never fires. + if remainingToDeliver.decrementAndGet() == 0 then deliveryRateLimiter.pauseDraining() val messageToSendPb = msg.messagePb .withSessionContextStateJson(sessionContext.getState) @@ -118,27 +463,159 @@ case class ConsumerSessionRunner( targets.values.foreach(_.resume( onNext = onNext, isDebug = isDebug, - incrementNumMessageProcessed = incrementNumMessageProcessed + incrementNumMessageProcessed = incrementNumMessageProcessed, + onStartFromDiscardProgress = reportStartFromDiscardProgress )) + + // LAST, deliberately: only now does every listener's handler point at THIS observer, so a + // queued tail from the previous play drains into the resume that asked for it. See the + // comment where the limiter is configured above. + deliveryRateLimiter.resumeDraining() + // A pause held every source, so paused time proves nothing about a stream's health: hand + // the silent-stream clock a fresh window instead of letting the first sweep after resume + // abandon a stream that was merely held along with everything else. + targets.values.headOption.foreach(_.consumerListener.resetStartFromStallClock()) + armStallSweep() def pause(): Unit = targets.values.foreach(_.pause()) + // A paused session delivers NOTHING, including from the limiter's backlog: draining stops + // and the queue waits, unacknowledged, for the next resume. + deliveryRateLimiter.pauseDraining() + cancelStallSweep() + + /** Release everything this session owns: its consumers and their subscriptions, its GraalVM + * contexts, and the client's response stream. + * + * EVERY step runs even if an earlier one failed, and the failures are aggregated into one + * exception at the end. It used to swallow unsubscribe failures entirely and close nothing at + * all, so `deleteConsumer` removed the only handle to the session and answered OK while the + * subscription stayed on the broker, the consumers stayed connected, the Graal context stayed + * open and the browser kept a stream that was never completed. + * + * Idempotent: each target clears its consumer map, closing a Graal context twice is a no-op, + * and the observer is forgotten once completed. + * + * ENDING THE STREAM IS ITSELF A WRITE TO IT, so it goes through [[sendLock]] like every other + * one. `onCompleted` used to be called outside that lock with no terminal flag at all, so it + * could interleave with an `onNext` still in flight on a listener thread - and any push after + * it wrote to a stream that had already ended. + */ def stop(): Unit = - pause() - targets.values.foreach(_.stop()) + Try(pause()) + // The limiter's backlog dies with the session: the messages were never acknowledged, and + // the NonDurable subscriptions being released below take any redelivery question with + // them. Clearing promptly is about freeing the payloads, not about correctness. + Try(deliveryRateLimiter.stop()) + synchronized { rateLimiterExecutor }.foreach(executor => Try(executor.shutdownNow())) + // An UNEXPECTED throw out of a target's own stop used to become an empty failure vector, so + // whatever it failed to release was reported as released. It is a failure like any other. + val targetFailures = targets.values.toVector.flatMap { target => + Try(target.stop()) match + case Success(failures) => failures + case Failure(err) => Vector(s"target ${target.targetIndex}: ${err.getMessage}") + } + // The pool's own failures count too: a JS context that will not close holds its heap for the + // life of the process, and this used to be discarded twice over - swallowed inside `close` + // and then discarded again here. + val poolFailures = Try(sessionContextPool.close()) match + case Success(failures) => failures + case Failure(err) => Vector(s"JS context pool: ${err.getMessage}") + val failures = targetFailures ++ poolFailures + sendLock.synchronized { + if !streamCompleted then + streamCompleted = true + grpcResponseObserver.foreach(observer => Try(observer.onCompleted())) + grpcResponseObserver = None + } + if failures.nonEmpty then + throw new RuntimeException(s"Consumer session $sessionName could not be fully released. ${failures.mkString("; ")}") } +/** The logger [[storeConsumerSession]] reports through - a top-level function needs its own name + * for the log line to be attributable (and for a test to listen on). */ +private val storeConsumerSessionLogger = com.typesafe.scalalogging.Logger("consumer.session_runner.storeConsumerSession") + +/** Store a freshly built session under its name, STOPPING whatever it replaced. + * + * Creating a session under a name that already existed simply overwrote the entry, and the old + * runner's consumers went on holding their subscriptions and delivering messages into a session + * nothing could reach - for the life of the process. The browser re-creates a session on an + * ordinary configuration change, so this was the common path, not a corner. + * + * The replacement goes in FIRST and unconditionally: a predecessor that cannot be released must + * not make its name permanently unusable, so the failure is LOGGED HERE rather than propagated - + * it used to be discarded outright, while this comment claimed the caller logged it, so a + * predecessor that failed to release vanished without a trace in exactly the situation an + * operator needs one. `ConcurrentHashMap.put` is atomic, so two concurrent creates leave exactly + * one session stored and the other stopped. + */ +def storeConsumerSession( + sessions: java.util.concurrent.ConcurrentHashMap[String, ConsumerSessionRunner], + sessionName: String, + session: ConsumerSessionRunner +): Unit = + Option(sessions.put(sessionName, session)).foreach(replaced => + Try(replaced.stop()).failed.foreach(err => + storeConsumerSessionLogger.warn( + s"The consumer session being replaced under $sessionName could not be fully released. ${err.getMessage}" + ) + ) + ) + () + object ConsumerSessionRunner: + + private def isReadCompactedTarget(targetConfig: consumer.session_target.ConsumerSessionTarget): Boolean = + targetConfig.consumptionMode.mode match + case _: consumer.session_target.consumption_mode.modes.ReadCompactedConsumptionMode => true + case _ => false + /** ONE daemon thread for every session's periodic upkeep (the start-from stall sweeps). Each + * armed sweep is a tiny check every couple of seconds, and it self-cancels the moment its + * skip resolves - a thread per session outlived its one job by the whole session lifetime. + * The rare give-up DRAIN does run session work here (delivering what a silent stream held + * back), which is accepted: it fires at most once per abandoned stream. The delivery rate + * limiter keeps its per-session timer - its ticks do real per-message work under the + * session's own locks and must not serialize sessions against each other. + */ + private[session_runner] lazy val maintenanceScheduler: ScheduledExecutorService = + Executors.newSingleThreadScheduledExecutor(runnable => { + val thread = Thread(runnable, "consumer-session-maintenance") + thread.setDaemon(true) + thread + }) + /** @param sessionContextPool + * the session's GraalVM engine and JS contexts. A parameter (with the production default) + * only so a test can prove the pool is RELEASED when construction fails - it is created + * before anything else and so is the first thing that can be leaked. + */ def make( pulsarClient: PulsarClient, adminClient: PulsarAdmin, sessionName: String, - sessionConfig: ConsumerSessionConfig + sessionConfig: ConsumerSessionConfig, + sessionContextPool: ConsumerSessionContextPool = ConsumerSessionContextPool() ): ConsumerSessionRunner = - val sessionContextPool = ConsumerSessionContextPool() + var targets: Map[ConsumerSessionTargetIndex, ConsumerSessionTargetRunner] = Map.empty + + // EVERYTHING THIS SESSION OWNS IS RELEASED IF ANY OF IT FAILS, and the pool is inside that + // guard from the very first step. It used to be built before the guard existed, so a target + // that failed to build released the targets built before it (`buildAllOrRelease`) and left + // the GraalVM engine and its JS contexts open with nothing holding a handle to them - a + // whole engine leaked per failed create, and the browser retries a failed create. + def releasingSession[A](build: => A): A = + try build + catch + case err: Throwable => + targets.values.foreach(target => Try(target.stop())) + Try(sessionContextPool.close()) + throw err - var targets = sessionConfig.targets - .filter(_.isEnabled) - .zipWithIndex.map { case (targetConfig, i) => + // ALL OR NOTHING, at the target level as well as inside each target: a session builds one + // runner per enabled target, and a plain `map` left every target built before a failing one + // subscribed and unreachable. + targets = releasingSession(buildAllOrRelease[(ConsumerSessionTarget, Int), (Int, ConsumerSessionTargetRunner)]( + inputs = sessionConfig.targets.filter(_.isEnabled).zipWithIndex, + build = (targetConfig, i) => i -> ConsumerSessionTargetRunner.make( sessionName = sessionName, targetIndex = i, @@ -148,10 +625,32 @@ object ConsumerSessionRunner: sessionContextPool = sessionContextPool, targetConfig = targetConfig ) - }.toMap + , + release = (_, target) => target.stop() + ).toMap) - val nonPartitionedTopicFqns = targets.values.flatMap(_.nonPartitionedTopicFqns).toVector - val schemasByTopic = getSchemasByTopic(adminClient, nonPartitionedTopicFqns) + // A session with nothing to consume from used to be accepted: `make` returned a runner with + // an empty consumer map and ConsumerServiceImpl.createConsumer answered Code.OK, so the UI + // showed a session in state `running` that could never deliver a message and never said + // why. Reject it here, before any seeking, so the client gets a real non-OK status. + if targets.isEmpty then + Try(sessionContextPool.close()) + throw new IllegalArgumentException( + s"Consumer session $sessionName has no enabled targets." + ) + + val emptyTargetIndexes = targets.collect { case (targetIndex, target) if target.consumers.isEmpty => targetIndex }.toVector.sorted + if emptyTargetIndexes.nonEmpty then + releasingSession(throw new IllegalArgumentException( + s"Consumer session $sessionName has enabled targets that resolved to no topics: ${emptyTargetIndexes.mkString(", ")}." + )) + + // DISTINCT: two enabled targets may legitimately select the same topic, and each keeps its + // own consumer on it. What this vector feeds - schema lookup, the non-persistent rejection, + // the Message-ID lookup and the single-topic fast path - all ask ABOUT a topic rather than + // consume from it, and asking twice about one topic broke the Message-ID mode outright. + val nonPartitionedTopicFqns = targets.values.flatMap(_.nonPartitionedTopicFqns).toVector.distinct + val schemasByTopic = releasingSession(getSchemasByTopic(adminClient, nonPartitionedTopicFqns)) targets = targets.map { case (targetIndex, target) => targetIndex -> target.copy(schemasByTopic = schemasByTopic) @@ -159,14 +658,51 @@ object ConsumerSessionRunner: val consumers = targets.values.flatMap(_.consumers).map(_._2).toVector - handleStartFrom( - startFrom = sessionConfig.startFrom, - consumers = consumers, - adminClient = adminClient, - pulsarClient = pulsarClient, - nonPartitionedTopicFqns = nonPartitionedTopicFqns + val startFromPlan = releasingSession { + // Target-aware validations, before any broker round trip: a session that cannot mean + // what it promises is refused while refusing is still cheap. + val readCompactedTargetIndexes = targets.toVector + .collect { case (targetIndex, target) if isReadCompactedTarget(target.targetConfig) => targetIndex } + .sorted + latestNReadCompactedRejectionReason(sessionConfig.startFrom, readCompactedTargetIndexes) + .foreach(reason => throw new IllegalArgumentException(reason)) + skipOverlapRejectionReason(sessionConfig.startFrom, targets.values.toVector.map(_.nonPartitionedTopicFqns)) + .foreach(reason => throw new IllegalArgumentException(reason)) + handleStartFrom( + startFrom = sessionConfig.startFrom, + consumers = consumers, + adminClient = adminClient, + pulsarClient = pulsarClient, + nonPartitionedTopicFqns = nonPartitionedTopicFqns + ) + } + + // Arm the start-from discard ONCE, here: the seek has happened and no consumer has been + // resumed yet. `resume` deliberately knows nothing about it - re-arming on every play would + // skip a fresh batch of messages each time the session is paused and resumed. + // + // A SharedTotal plan is ONE counter over the merged stream, so every target must get the + // SAME instance; a PerTopic plan gets a fresh counter per target, because two targets may + // select the same topic and each has its own consumer to correct. + val sharedDiscard = startFromPlan.discard match + case StartFromDiscardPlan.SharedTotal(n) => StartFromDiscard.shared(n) + case _ => StartFromDiscard.none + + // The global ordering layer is ONE object for the whole session for the same reason a + // SharedTotal counter is: "the globally-first n" and "the globally-last n" are counted over + // every target's stream at once, so a layer per target would answer n per target. + val ordering = StartFromOrdering.make[HeldMessage]( + startFromPlan.ordering, + // The byte half of the merge's memory watermarks: what one held message costs. + payloadBytesOf = held => scala.util.Try(Option(held.message.getData).map(_.length.toLong).getOrElse(0L)).getOrElse(0L) ) + targets.values.foreach { target => + target.consumerListener.startFromDiscard = + StartFromDiscard.forTarget(startFromPlan.discard, sharedDiscard, target.nonPartitionedTopicFqns) + target.consumerListener.startFromOrdering = ordering + } + ConsumerSessionRunner( sessionName = sessionName, sessionConfig = sessionConfig, diff --git a/server/src/main/scala/consumer/session_runner/ConsumerSessionTargetRunner.scala b/server/src/main/scala/consumer/session_runner/ConsumerSessionTargetRunner.scala index c5e792a2d..800e7c5ef 100644 --- a/server/src/main/scala/consumer/session_runner/ConsumerSessionTargetRunner.scala +++ b/server/src/main/scala/consumer/session_runner/ConsumerSessionTargetRunner.scala @@ -14,6 +14,7 @@ import org.apache.pulsar.client.api.Message import scala.util.boundary import boundary.break import scala.util.{Failure, Success, Try} +import java.util.concurrent.atomic.AtomicLong type NonPartitionedTopicFqn = String @@ -35,103 +36,186 @@ case class ConsumerSessionTargetRunner( errors: Vector[String] ) => Unit, isDebug: Boolean, - incrementNumMessageProcessed: () => Unit + incrementNumMessageProcessed: () => Unit, + onStartFromDiscardProgress: () => Unit ): Unit = val listener = consumerListener val targetMessageHandler = listener.targetMessageHandler + // Rewired on every play, like the message handler above it, so a skip still in flight + // reports to whichever client is listening NOW. The discard counter itself is NOT touched + // here - it is armed once, at session creation. + listener.onStartFromDiscardProgress = onStartFromDiscardProgress + targetMessageHandler.onNext = (msg: Message[Array[Byte]]) => - boundary: - stats.messageProcessed += 1 - incrementNumMessageProcessed() + // Both counters are atomic because these two lines run OUTSIDE the per-message context + // lease below, on one listener thread per partition. Kept here rather than moved inside + // the lease so that a message failing to deserialize still counts as processed. + stats.messageProcessed.incrementAndGet() + incrementNumMessageProcessed() - val sessionContext = sessionContextPool.getNextContext - val consumerSessionMessage = converters.serializeMessage(schemasByTopic, msg, targetConfig.messageValueDeserializer) - val messageJson = consumerSessionMessage.messageAsJsonOmittingValue - val messageValueToJsonResult = consumerSessionMessage.messageValueAsJson + // Deliberately OUTSIDE the lease below. Deserialization is a pure function of the + // message, the session's schemas and the configured deserializer - it touches no JS - + // and it is the expensive part of handling a message. Holding the session's single JS + // context across it would make every partition decode in single file for no reason. + val consumerSessionMessage = converters.serializeMessage(schemasByTopic, msg, targetConfig.messageValueDeserializer) + val messageJson = consumerSessionMessage.messageAsJsonOmittingValue + val messageValueToJsonResult = consumerSessionMessage.messageValueAsJson - sessionContext.setCurrentMessage(messageJson, messageValueToJsonResult) + // ONE lease for the WHOLE message, not one per JS call - and it deliberately spans the + // `onNext` callback, because the session-level filter chain, coloring rules, value + // projections and `getState` all run in there, off the SAME current message this thread + // just set. Pulsar delivers each partition on its own listener thread and they all share + // this context; a lease per call would still let another partition's message overwrite + // `globalThis.__dekaf_currentMessage` midway through this one. + sessionContextPool.withNextContext { sessionContext => + boundary: + sessionContext.setCurrentMessage(messageJson, messageValueToJsonResult) - val messageFilterChainResult: ChainTestResult = sessionContext.testMessageFilterChain( - targetConfig.messageFilterChain - ) + val messageFilterChainResult: ChainTestResult = sessionContext.testMessageFilterChain( + targetConfig.messageFilterChain + ) + + val messageFilterChainErrors = messageFilterChainResult.results.flatMap(r => r.error) + + if !messageFilterChainResult.isOk then + onNext( + msg = None, + sessionContext = sessionContext, + stats = stats, + errors = if isDebug then messageFilterChainErrors else Vector.empty + ) + boundary.break() + + val coloringRuleChainResult: Vector[ChainTestResult] = if targetConfig.coloringRuleChain.isEnabled then + targetConfig.coloringRuleChain.coloringRules + .filter(_.isEnabled) + .map(cr => sessionContext.testMessageFilterChain(cr.messageFilterChain)) + else + Vector.empty - val messageFilterChainErrors = messageFilterChainResult.results.flatMap(r => r.error) + val valueProjectionListResult: Vector[ValueProjectionResult] = if targetConfig.valueProjectionList.isEnabled then + targetConfig.valueProjectionList.projections + .filter(_.isEnabled) + .map(_.project(sessionContext.context)) + else + Vector.empty + + var msgToSend = if messageFilterChainResult.isOk then + Some(consumerSessionMessage) + else + None + + val errors: Vector[String] = + if isDebug then + val serializationErrors = messageValueToJsonResult match + case Left(err) => Vector(err.getMessage) + case _ => Vector.empty + val coloringRuleChainErrors = coloringRuleChainResult.flatMap(r => r.results.flatMap(r2 => r2.error)) + serializationErrors ++ messageFilterChainErrors ++ coloringRuleChainErrors + else Vector.empty + + msgToSend = msgToSend.map(m => + m.copy( + messagePb = m.messagePb + .withSessionTargetIndex(targetIndex) + .withDebugStdout(sessionContext.getStdout) + .withSessionTargetMessageFilterChainTestResult(ChainTestResult.toPb(messageFilterChainResult)) + .withSessionTargetColorRuleChainTestResults(coloringRuleChainResult.map(ChainTestResult.toPb)) + .withSessionTargetValueProjectionListResult(valueProjectionListResult.map(ValueProjectionResult.toPb)) + ) + ) - if !messageFilterChainResult.isOk then onNext( - msg = None, + msg = msgToSend, sessionContext = sessionContext, stats = stats, - errors = if isDebug then messageFilterChainErrors else Vector.empty - ) - boundary.break() - - val coloringRuleChainResult: Vector[ChainTestResult] = if targetConfig.coloringRuleChain.isEnabled then - targetConfig.coloringRuleChain.coloringRules - .filter(_.isEnabled) - .map(cr => sessionContext.testMessageFilterChain(cr.messageFilterChain)) - else - Vector.empty - - val valueProjectionListResult: Vector[ValueProjectionResult] = if targetConfig.valueProjectionList.isEnabled then - targetConfig.valueProjectionList.projections - .filter(_.isEnabled) - .map(_.project(sessionContext.context)) - else - Vector.empty - - var msgToSend = if messageFilterChainResult.isOk then - Some(consumerSessionMessage) - else - None - - val errors: Vector[String] = - if isDebug then - val serializationErrors = messageValueToJsonResult match - case Left(err) => Vector(err.getMessage) - case _ => Vector.empty - val coloringRuleChainErrors = coloringRuleChainResult.flatMap(r => r.results.flatMap(r2 => r2.error)) - serializationErrors ++ messageFilterChainErrors ++ coloringRuleChainErrors - else Vector.empty - - msgToSend = msgToSend.map(m => - m.copy( - messagePb = m.messagePb - .withSessionTargetIndex(targetIndex) - .withDebugStdout(sessionContext.getStdout) - .withSessionTargetMessageFilterChainTestResult(ChainTestResult.toPb(messageFilterChainResult)) - .withSessionTargetColorRuleChainTestResults(coloringRuleChainResult.map(ChainTestResult.toPb)) - .withSessionTargetValueProjectionListResult(valueProjectionListResult.map(ValueProjectionResult.toPb)) + errors = errors ) - ) + } - onNext( - msg = msgToSend, - sessionContext = sessionContext, - stats = stats, - errors = errors - ) + permitLock.synchronized { + listener.startAcceptingNewMessages() + consumers.foreach((_, consumer) => consumer.resume()) + } - listener.startAcceptingNewMessages() - consumers.foreach((_, consumer) => consumer.resume()) + /** Serializes every touch of the consumers' pause/resume state: the user's pause and resume, + * and the delivery pacer's permit holds, which arrive on other threads. Two writers taking + * turns unserialized could interleave a pacer resume into the middle of a user pause and leave + * the broker delivering into a session the user just stopped. + * + * LOCK ORDER: this lock nests under nothing of the pacer's - the pacer invokes its callbacks + * outside its own lock precisely so this one stays a leaf. + */ + private val permitLock = Object() - def pause(): Unit = - consumers.foreach((_, consumer) => consumer.pause()) + /** The delivery pacer's half of flow control: stop asking the broker for more while the paced + * backlog is over its watermark, without touching the gate. + * + * DELIBERATELY NOT [[pause]]. The user's pause closes the gate first, so everything already + * prefetched is rejected and handed back for redelivery - correct for "stop showing me + * things", and exactly wrong for a throttle, which wants the prefetched tail to drain through + * the queue instead of cycling as nacks. Holding permits leaves the gate open: what has + * arrived flows on, and only the ASKING stops. + * + * Refused outright while the gate is shut - the user's pause outranks the pacer, and a hold + * "released" onto a paused session must not resume its consumers. + */ + def setPermitHold(hold: Boolean): Unit = permitLock.synchronized { + if consumerListener.isAcceptingNewMessages then + consumers.foreach((_, consumer) => if hold then consumer.pause() else consumer.resume()) + } + + /** GATE FIRST, CONSUMERS SECOND, and the order is the point. + * + * `Consumer.pause` only stops the client asking the broker for more permits; whatever the + * client has already received is still handed to the listener afterwards. Pausing first and + * closing the gate second therefore left a window in which every buffered callback was + * delivered into a session the user had just paused - and spent its start-from budget doing so. + * Closing the gate first makes that window empty: everything already buffered is rejected and + * handed back for redelivery on resume, which is exactly what a paused session promises. + * + * `resume` is deliberately the mirror image: it opens the gate and only then resumes the + * consumers, so nothing is ever delivered while the gate is shut. + */ + def pause(): Unit = permitLock.synchronized { consumerListener.stopAcceptingNewMessages() + consumers.foreach((_, consumer) => consumer.pause()) + } - def stop(): Unit = - consumers.foreach((_, consumer) => - Try { - consumer.unsubscribe() - } match - case Success(_) => () - case Failure(err) => println(s"Failed to stop consumer session target. ${err.getMessage}") - ) + /** Release this target's consumers, and answer with what could not be released. + * + * ANSWERS rather than throws: a session has other targets to release, and one broker that will + * not delete a subscription must not strand every consumer after it. The caller aggregates. + * + * `close` follows `unsubscribe` WHATEVER the unsubscribe did, and that is the important part: + * unsubscribing deletes the subscription on the broker, while closing releases the consumer, + * its connection and its listener thread here. Only the first was ever done, so every session + * that was stopped leaked its consumers - and a failed unsubscribe leaked them while reporting + * success to the client. + * + * BOTH failures are reported. The close was wrapped in a bare `Try` whose result was thrown + * away, so a consumer that refused to close - still connected, still holding its listener + * thread - was invisible: `deleteConsumer` answered OK and nothing said the consumer was still + * there. + */ + def stop(): Vector[String] = + val failures = consumers.toVector.flatMap { (topicFqn, consumer) => + val unsubscribed = Try(consumer.unsubscribe()).failed.toOption.map(err => s"$topicFqn: could not unsubscribe. ${err.getMessage}") + val closed = Try(consumer.close()).failed.toOption.map(err => s"$topicFqn: could not close the consumer. ${err.getMessage}") + unsubscribed.toVector ++ closed.toVector + } + consumers = Map.empty + failures } object ConsumerSessionTargetRunner: val logger: Logger = Logger(getClass.getName) + + /** The most physical topics one target may resolve to. Generous - a session at this size is + * already hard to use interactively - but a bound: past it, setup time, per-topic consumers + * and the counted modes' per-message costs stop being an interactive workload at all. */ + val maxTopicsPerTarget: Int = 1_000 def make( sessionName: String, @@ -144,24 +228,44 @@ object ConsumerSessionTargetRunner: ): ConsumerSessionTargetRunner = Try { val nonPartitionedTopicFqns = targetConfig.topicSelector.getNonPartitionedTopics(adminClient = adminClient) + // A hard admission guard, checked BEFORE any consumer exists. A namespaced-regex + // selector can match a whole namespace; past this many physical topics the session + // would spend its life subscribing (one consumer, subscription and receiver queue per + // topic) and every counted mode's per-message work scales with the count - refusing + // loudly beats grinding into a session nobody can use. + if nonPartitionedTopicFqns.size > maxTopicsPerTarget then + throw new RuntimeException( + s"Target $targetIndex resolves to ${nonPartitionedTopicFqns.size} physical topics, more than the $maxTopicsPerTarget " + + "one session can handle. Narrow the topic selector (a tighter regex, or specific topics) or split the work " + + "across several sessions." + ) + val receiverQueueSize = receiverQueueSizeFor(nonPartitionedTopicFqns.size) val listener = ConsumerListener(ConsumerSessionTargetMessageHandler(onNext = _ => ())) - val consumers: Map[NonPartitionedTopicFqn, Consumer[Array[Byte]]] = nonPartitionedTopicFqns.map { topicFqn => - val consumerName = s"$sessionName-$targetIndex" - - buildConsumer( - pulsarClient = pulsarClient, - consumerName = consumerName, - topicsToConsume = Vector(topicFqn), - listener = listener, - targetConfig = targetConfig - ) match - case Right(consumerBuilder) => - val consumer = consumerBuilder.subscribe() - topicFqn -> consumer - case Left(err) => - throw new RuntimeException(s"Failed to build consumer for topic $topicFqn. $err") - }.toMap + // ALL OR NOTHING. Subscribing in a plain `map` meant a topic that failed part-way + // through left every consumer created before it subscribed and running, with nothing + // holding a handle to close them: the partly-built runner is never returned. + val consumers: Map[NonPartitionedTopicFqn, Consumer[Array[Byte]]] = buildAllOrRelease[NonPartitionedTopicFqn, (NonPartitionedTopicFqn, Consumer[Array[Byte]])]( + inputs = nonPartitionedTopicFqns, + build = topicFqn => + val consumerName = s"$sessionName-$targetIndex" + + buildConsumer( + pulsarClient = pulsarClient, + consumerName = consumerName, + topicsToConsume = Vector(topicFqn), + listener = listener, + targetConfig = targetConfig, + receiverQueueSize = receiverQueueSize + ) match + case Right(consumerBuilder) => + val consumer = consumerBuilder.subscribe() + topicFqn -> consumer + case Left(err) => + throw new RuntimeException(s"Failed to build consumer for topic $topicFqn. $err") + , + release = (_, consumer) => consumer.close() + ).toMap ConsumerSessionTargetRunner( targetIndex = targetIndex, @@ -172,7 +276,7 @@ object ConsumerSessionTargetRunner: consumerListener = listener, schemasByTopic = schemasByTopic, stats = ConsumerSessionTargetStats( - messageProcessed = 0 + messageProcessed = AtomicLong(0) ) ) } match { diff --git a/server/src/main/scala/consumer/session_runner/ConsumerSessionTargetStats.scala b/server/src/main/scala/consumer/session_runner/ConsumerSessionTargetStats.scala index 702cf5190..3ff213975 100644 --- a/server/src/main/scala/consumer/session_runner/ConsumerSessionTargetStats.scala +++ b/server/src/main/scala/consumer/session_runner/ConsumerSessionTargetStats.scala @@ -1,5 +1,17 @@ package consumer.session_runner +import java.util.concurrent.atomic.AtomicLong + +/** Per-target counters. + * + * ATOMIC deliberately: Pulsar delivers each partition of a partitioned topic on its own listener + * thread, and this is incremented from the message handler BEFORE the per-message context lease, so + * a plain `var Long` loses read-modify-write updates and a partitioned session under-counts. + * + * The increment stays OUTSIDE the lease on purpose - moving it inside would also move it after + * `converters.serializeMessage`, changing whether a message that fails to deserialize still counts + * as processed. Making the counter atomic fixes the race without touching that ordering. + */ case class ConsumerSessionTargetStats( - var messageProcessed: Long + messageProcessed: AtomicLong ) diff --git a/server/src/main/scala/consumer/session_runner/StartFromDiscard.scala b/server/src/main/scala/consumer/session_runner/StartFromDiscard.scala new file mode 100644 index 000000000..b2d6c57af --- /dev/null +++ b/server/src/main/scala/consumer/session_runner/StartFromDiscard.scala @@ -0,0 +1,131 @@ +package consumer.session_runner + +import java.util.concurrent.atomic.AtomicLong + +/** What a start-from seek could NOT achieve on its own, expressed as messages to drop from the head + * of the delivered stream. + * + * A seek can only ever land on an ENTRY boundary: `PulsarAdmin.examineMessage` - the only primitive + * that addresses a position without reading the whole log - counts ENTRIES, a batching producer + * (the Java client default) puts many messages into one entry, and batch-index positions such as + * `1696:1:25` are rejected by the broker ("must be in format: ledgerId:entryId"). Landing on an + * exact MESSAGE therefore means seeking to the entry that contains it and dropping the messages + * that precede it inside that entry. There is no other exact mechanism. + */ +/** How many messages a discard swallows between progress reports. + * + * "Skip first n" is deliberately uncapped, so n can be in the millions; one gRPC frame per skipped + * message would be millions of frames for a progress bar. Coarse enough to be a trickle, fine + * enough that a skip big enough for the UI to bother showing (it surfaces above 1,000,000) still + * moves visibly. + */ +val startFromProgressReportInterval: Long = 10_000 + +/** Whether the discard should tell the client about the message it has just swallowed, `skipped` + * being the running count INCLUDING that one. + * + * The first is always reported, so the client learns the total as soon as the skip starts rather + * than one interval later. The last is always reported, so the run is seen to complete. In between + * only every `reportEvery`-th. + * + * `skipped >= total` rather than `==`: the counters are claimed from one listener thread per + * physical topic, and a thread that reads the running count a moment late must still report the + * end rather than sail past it. + * + * APPROXIMATE BY DESIGN. Under contention two threads can read the same count, or step over an + * interval boundary between them, so an intermediate tick may be reported twice or missed. Both are + * harmless for a progress indicator, and the end is not: every thread that reads a spent counter + * reports it. + */ +def shouldReportStartFromProgress(skipped: Long, total: Long, reportEvery: Long): Boolean = + if total <= 0 || skipped <= 0 then false + else skipped == 1 || skipped >= total || (reportEvery > 0 && skipped % reportEvery == 0) + +enum StartFromDiscardPlan: + /** The seek landed exactly - deliver everything from it. */ + case Nothing + + /** One counter for the WHOLE session: drop the first `n` messages of the merged delivered + * stream, whichever topic or partition each came from. */ + case SharedTotal(n: Long) + + /** An independent counter per physical topic: drop the first `counts(topic)` messages that + * topic delivers. */ + case PerTopic(counts: Map[NonPartitionedTopicFqn, Long]) + +/** The live counters behind a [[StartFromDiscardPlan]]. + * + * Armed ONCE, after the seek and before any consumer is resumed, and never re-armed: pausing and + * resuming a session must not skip a second batch of messages. + */ +final class StartFromDiscard private ( + private val shared: Option[AtomicLong], + private val perTopic: Map[NonPartitionedTopicFqn, AtomicLong] +): + /** Claims a delivered message for the discard. `true` means DROP it (and count it as dropped), + * `false` means deliver it. Called from the Pulsar client's listener threads - one per consumer + * - so the counters have to be atomic. */ + def claim(topicFqn: NonPartitionedTopicFqn): Boolean = + shared.orElse(perTopic.get(topicFqn)) match + case Some(counter) => counter.getAndUpdate(left => if left > 0 then left - 1 else 0) > 0 + case None => false + + /** Give a claimed drop back, because the message it was claimed for could not be acknowledged. + * + * A claim COSTS A MESSAGE - the message is acknowledged into nothing and nobody sees it - so a + * claim that no acknowledgment ever matched is a message Pulsar will redeliver against an + * already-spent budget, and the redelivery is then shown. Refunding keeps the invariant + * "units spent == messages actually dropped": the redelivery claims the unit again. + * + * Never above what was armed, because a refund only ever follows a claim. + */ + def refund(topicFqn: NonPartitionedTopicFqn): Unit = + shared.orElse(perTopic.get(topicFqn)).foreach(_.incrementAndGet()) + () + + /** Messages still to be dropped. Exposed so tests can assert "exactly n were skipped" rather + * than inferring it from what came out. */ + def remaining: Long = shared.map(_.get).getOrElse(perTopic.values.map(_.get).sum) + + /** Whether this counter is the USER'S skip, and so something to report progress for. + * + * Only the session-wide counter is: it exists because the user asked to skip the first n + * messages, which is O(n) and can take a long time with nothing to show for it. A PER-TOPIC + * counter is the opposite - it is the internal correction that makes "the latest n" exact, + * because a seek can only land on an entry boundary and each topic therefore over-fetches. + * Reporting that told a client asking for the last 5 messages that it was "skipping 95". + * + * The proto says the same thing: "Only NthMessageAfterEarliest needs this". + */ + def reportsProgress: Boolean = shared.isDefined + + /** How many messages this discard was ARMED with - what the user asked to skip. + * + * Captured once, at construction: read off the live counters it would shrink to zero as the + * skip progressed, and the client would be told the total was however much was left. + */ + val total: Long = shared.map(_.get).getOrElse(perTopic.values.map(_.get).sum) + +object StartFromDiscard: + /** Drops nothing, ever. A single immutable instance - it holds no counters to share. */ + val none: StartFromDiscard = new StartFromDiscard(None, Map.empty) + + def shared(n: Long): StartFromDiscard = new StartFromDiscard(Some(new AtomicLong(n max 0)), Map.empty) + + def perTopic(counts: Map[NonPartitionedTopicFqn, Long]): StartFromDiscard = + new StartFromDiscard(None, counts.map((topicFqn, n) => topicFqn -> new AtomicLong(n max 0))) + + /** Arms the counters for one consumer session target. `shared` must be the SAME instance for + * every target of a session, so that a [[StartFromDiscardPlan.SharedTotal]] really is one + * counter over the merged stream. */ + def forTarget( + plan: StartFromDiscardPlan, + shared: StartFromDiscard, + topicFqns: Vector[NonPartitionedTopicFqn] + ): StartFromDiscard = + plan match + case StartFromDiscardPlan.Nothing => none + case StartFromDiscardPlan.SharedTotal(_) => shared + // A fresh counter per target: two targets may select the SAME topic, and each has its + // own consumer on it that has to drop its own overshoot. + case StartFromDiscardPlan.PerTopic(counts) => perTopic(counts.view.filterKeys(topicFqns.toSet).toMap) diff --git a/server/src/main/scala/consumer/session_runner/buildAllOrRelease.scala b/server/src/main/scala/consumer/session_runner/buildAllOrRelease.scala new file mode 100644 index 000000000..5c44374e1 --- /dev/null +++ b/server/src/main/scala/consumer/session_runner/buildAllOrRelease.scala @@ -0,0 +1,30 @@ +package consumer.session_runner + +import scala.util.Try + +/** Build one resource per input, releasing everything ALREADY BUILT if any later one fails. + * + * A consumer session is built out of resources the broker holds on its behalf: one Pulsar consumer + * per physical topic, one target runner per enabled target. They were created in a plain `map`, so + * a failure part-way through - a topic that vanished, a broker that refused the subscription, + * anything at all - propagated out and left every consumer created before it subscribed, running, + * and unreachable: the partly-built runner was never returned, so nothing had a handle to close. + * The session appeared to fail while its consumers went on holding subscriptions until the process + * ended. + * + * The original failure is what propagates. A release that fails on the way out is swallowed + * DELIBERATELY: it is a second failure while handling the first, and reporting it instead would + * replace the cause with a consequence. + * + * PURE: `build` and `release` are plain functions, so the partial-failure path is driven with + * lambdas and no broker. + */ +def buildAllOrRelease[A, R](inputs: Vector[A], build: A => R, release: R => Unit): Vector[R] = + val built = Vector.newBuilder[R] + try + inputs.foreach(input => built += build(input)) + built.result() + catch + case err: Throwable => + built.result().foreach(resource => Try(release(resource))) + throw err diff --git a/server/src/main/scala/consumer/session_runner/buildConsumer.scala b/server/src/main/scala/consumer/session_runner/buildConsumer.scala index 578000efd..bb00e41d9 100644 --- a/server/src/main/scala/consumer/session_runner/buildConsumer.scala +++ b/server/src/main/scala/consumer/session_runner/buildConsumer.scala @@ -3,15 +3,33 @@ package consumer.session_runner import consumer.session_target.ConsumerSessionTarget import consumer.session_target.consumption_mode.modes.ReadCompactedConsumptionMode import org.apache.pulsar.client.api.* +import org.apache.pulsar.client.impl.MultiplierRedeliveryBackoff import scala.jdk.CollectionConverters.* +/** The client-side prefetch budget one TARGET may spend across all its topic consumers, in + * messages. A flat 2000-per-consumer receiver queue scaled the session's prefetch memory with + * the topic count - a 1000-topic regex selector configured ~2 million prefetched messages + * (payloads included) before anything was even shown. The budget divides across the target's + * consumers instead, floored so a huge selector still makes progress and capped at the old + * per-consumer value so small sessions keep their throughput. */ +val receiverQueueBudgetPerTarget: Int = 20_000 +val receiverQueueMin: Int = 50 +val receiverQueueMax: Int = 2_000 + +/** The per-consumer receiver queue for a target consuming `topicCount` topics: the budget divided + * evenly, clamped to [[receiverQueueMin]]..[[receiverQueueMax]]. */ +def receiverQueueSizeFor(topicCount: Int): Int = + val even = receiverQueueBudgetPerTarget / (topicCount max 1) + even.max(receiverQueueMin).min(receiverQueueMax) + def buildConsumer( pulsarClient: PulsarClient, consumerName: String, topicsToConsume: Vector[String], listener: MessageListener[Array[Byte]], - targetConfig: ConsumerSessionTarget + targetConfig: ConsumerSessionTarget, + receiverQueueSize: Int = receiverQueueMax ): Either[String, ConsumerBuilder[Array[Byte]]] = val isReadCompacted = targetConfig.consumptionMode.mode match case _: ReadCompactedConsumptionMode => true @@ -19,12 +37,21 @@ def buildConsumer( val consumer = pulsarClient.newConsumer .consumerName(consumerName) - .receiverQueueSize(2000) + .receiverQueueSize(receiverQueueSize) .autoUpdatePartitions(true) .maxPendingChunkedMessage(2) .autoAckOldestChunkedMessageOnQueueFull(true) .expireTimeOfIncompleteChunkedMessage(1, java.util.concurrent.TimeUnit.MINUTES) - .negativeAckRedeliveryDelay(0, java.util.concurrent.TimeUnit.SECONDS) + // DECAYING redelivery, not a flat delay. The old `negativeAckRedeliveryDelay(0, SECONDS)` + // is floored to 100ms by the client, which turned every sustained nack source into a + // ten-per-second redelivery hammer with no exit - and nacks are routine here: a paused + // session nacks everything it receives, and a delivery that throws mid-batch is handed + // back the same way. The first redelivery still comes at 100ms - pause/resume feels as + // immediate as before - while a message bounced over and over backs off toward a 10s + // ceiling, so a sustained source decays instead of hammering. (The start-from merge used + // to be the loudest source, nacking whatever it declined at its memory cap; it now PAUSES + // hot consumers instead and declines nothing.) + .negativeAckRedeliveryBackoff(MultiplierRedeliveryBackoff.builder.minDelayMs(100).maxDelayMs(10_000).build) .messageListener(listener) .startMessageIdInclusive() .subscriptionInitialPosition(SubscriptionInitialPosition.Earliest) diff --git a/server/src/main/scala/consumer/session_runner/deliveryRateLimiter.scala b/server/src/main/scala/consumer/session_runner/deliveryRateLimiter.scala new file mode 100644 index 000000000..341051f4d --- /dev/null +++ b/server/src/main/scala/consumer/session_runner/deliveryRateLimiter.scala @@ -0,0 +1,284 @@ +package consumer.session_runner + +import com.typesafe.scalalogging.Logger + +import java.util.concurrent.atomic.AtomicBoolean +import scala.collection.mutable.ArrayDeque +import scala.util.Try + +/** The delivery rate limiter: a token bucket and a FIFO queue between "the session decided to show + * this message" and "the browser was sent it". + * + * WHERE IT SITS, AND WHY EXACTLY THERE. The limiter is fed only with DELIVER outcomes - after the + * start-from discard and the global merge have decided a message is going to be shown. Drops fly + * past it at full speed, so a counted skip or a latest-n walk positions as fast as the broker can + * serve it; the limit shapes what the user actually watches, which is the only thing a + * messages-per-second number can honestly mean to them. Feeding it any earlier would slow the + * positioning the user is waiting on; any later (at the gRPC send) would leave the expensive part + * - deserialization and the GraalVM filter chain - running at firehose rate with the limit + * protecting nothing but the tab. + * + * WHY A QUEUE AND NOT THE BROKER'S PERMITS. `Consumer.pause()` only stops asking the broker for + * more; everything already prefetched (up to receiverQueueSize per partition) is still handed to + * the listener afterwards - this codebase measured that, see ConsumerSessionTargetRunner.pause. + * Pacing with permits alone therefore bursts by thousands. Here the burst lands in the queue + * instead of the browser, and the drain releases EXACTLY the configured rate; the permits are + * used for what they are good at - bounding how much piles up - via the high/low watermarks. + * + * WHY NOTHING EVER BLOCKS OR NACKS HERE. Offering is an enqueue under a lock held for + * nanoseconds, so a Pulsar listener thread is never parked and the global merge - whose offers + * run under the session's ordering lock - is never starved into its silent-stream give-up. + * Rejecting instead of queueing would nack, and a sustained rate limit built on nacks is the + * ~100ms redelivery storm the round-3 review put a bound on, recreated deliberately. + * + * ORDER IS THE QUEUE'S ORDER, ALWAYS. Merge-ordered sessions enqueue under the ordering lock, so + * the queue holds the merge's decision order; the single drainer releases FIFO; and while a drain + * is in flight or a backlog exists, nothing is processed inline - `drainInProgress` closes the + * window in which a later message could overtake a queued one into the stateful session filters. + * + * The core is deliberately free of threads and clocks: the wrapper below owns scheduling and side + * effects, this class owns every decision, and the tests drive it with a hand-cranked clock. + */ +final class DeliveryRateLimiterCore[A](nowMs: () => Long): + /** Messages per second; 0 means unlimited (offers process inline whenever no backlog exists). */ + private var ratePerSecond: Long = 0 + + /** The bucket. Capacity is one second's worth (= ratePerSecond), and it STARTS FULL on every + * rate change: the first screenful after Play appears at once, and the cap shapes what follows. + * A session limited to 100/s that begins with "latest 50" paints all 50 immediately - the user + * asked for exactly those - and then trickles. + */ + private var tokens: Double = 0 + private var lastRefillAtMs: Long = nowMs() + + private val queue = ArrayDeque.empty[A] + + /** True from the moment a drain is scheduled until [[beginDrain]] hands its batch out, so a + * second timer is never armed for the same backlog. */ + private var drainScheduled = false + + /** True between [[beginDrain]] and [[finishDrain]], i.e. while the wrapper is processing a + * batch OUTSIDE this lock. Inline processing is refused while it is set - see the class note + * on ordering. */ + private var drainInProgress = false + + def setRate(newRatePerSecond: Long): Unit = synchronized { + ratePerSecond = newRatePerSecond + tokens = newRatePerSecond.toDouble + lastRefillAtMs = nowMs() + } + + /** True while a DELIVERY BUDGET is armed. The budget is counted downstream (at the send, where + * "loaded" is decided), but it needs every message to pass through the QUEUE: the inline + * unlimited shortcut hands the message to processing on the calling thread, past any chance + * of stopping the batch behind the one that spent the last unit. + */ + private var forceQueue: Boolean = false + + def setForceQueue(force: Boolean): Unit = synchronized { forceQueue = force } + + def rate: Long = synchronized(ratePerSecond) + def queuedCount: Int = synchronized(queue.size) + + /** Put back what a drain took but did not process - the tail behind a delivery budget that ran + * out mid-batch. FRONT of the queue, order intact, and the tokens they were charged handed + * back: they were never delivered, and the next resume must find them exactly where they were. + */ + def requeueFront(items: Vector[A]): Unit = synchronized { + queue.prependAll(items) + if ratePerSecond > 0 then tokens = math.min(ratePerSecond.toDouble, tokens + items.size) + } + + /** What [[offer]] told the caller to do. `processNow` and `scheduleDrainAfterMs` are mutually + * exclusive; `queuedCount` is the size AFTER this offer, for the caller's watermark check. */ + final case class OfferOutcome(processNow: Boolean, queuedCount: Int, scheduleDrainAfterMs: Option[Long]) + + def offer(a: A): OfferOutcome = synchronized { + // Unlimited and nothing queued and nobody mid-drain: the message may go straight through on + // the calling thread - byte-for-byte the unlimited path. Any backlog forces the queue, so + // a flush after a rate change cannot be overtaken - and so does an armed delivery budget, + // which has to be able to stop the line BETWEEN messages. + if ratePerSecond == 0 && !forceQueue && queue.isEmpty && !drainInProgress then OfferOutcome(processNow = true, 0, None) + else + queue.append(a) + val schedule = if !drainScheduled && !drainInProgress then + drainScheduled = true + Some(nextDrainDelayMs) + else None + OfferOutcome(processNow = false, queue.size, schedule) + } + + /** Take the batch this tick has earned. Refills the bucket from the elapsed clock, spends it, + * and caps the batch so one tick never monopolizes the drainer thread. */ + def beginDrain(): Vector[A] = synchronized { + drainScheduled = false + drainInProgress = true + refill() + val earned = + if ratePerSecond == 0 then queue.size + else math.min(tokens.toLong, queue.size.toLong).toInt + val take = math.min(earned, deliveryRateLimitMaxDrainBatch) + if ratePerSecond > 0 then tokens -= take + Vector.fill(take)(queue.removeHead()) + } + + /** The batch is processed; decide what happens next. `rescheduleAfterMs` is set while a backlog + * remains, sized by how long the next token takes to arrive. */ + final case class FinishOutcome(queuedCount: Int, rescheduleAfterMs: Option[Long]) + + def finishDrain(): FinishOutcome = synchronized { + drainInProgress = false + val reschedule = if queue.nonEmpty then + drainScheduled = true + Some(nextDrainDelayMs) + else None + FinishOutcome(queue.size, reschedule) + } + + /** A tick fired while draining was paused: the timer is consumed, so the flag it represents + * must be handed back or no future offer would ever arm another. */ + def cancelScheduledDrain(): Unit = synchronized { drainScheduled = false } + + /** Re-arm after a pause ended. Answers the delay to schedule, or None when there is nothing + * queued or a timer is already armed. */ + def rearmDrain(): Option[Long] = synchronized { + if queue.nonEmpty && !drainScheduled && !drainInProgress then + drainScheduled = true + Some(nextDrainDelayMs) + else None + } + + /** Empty the queue - the session is stopping and these deliveries are moot. */ + def clear(): Unit = synchronized { + queue.clear() + drainScheduled = false + } + + private def refill(): Unit = + val now = nowMs() + if ratePerSecond > 0 then + val earned = (now - lastRefillAtMs).max(0).toDouble / 1000.0 * ratePerSecond + tokens = math.min(ratePerSecond.toDouble, tokens + earned) + lastRefillAtMs = now + + /** How long until the next token is worth waking up for. Zero when a whole token is already + * banked; otherwise the exact wait, floored so a high rate coalesces into batches instead of + * waking per message. The floor costs no throughput - tokens accrue while asleep and the next + * batch is larger by exactly the wait. + */ + private def nextDrainDelayMs: Long = + refill() + if ratePerSecond == 0 || tokens >= 1.0 then 0L + else + val exact = math.ceil((1.0 - tokens) * 1000.0 / ratePerSecond.toDouble).toLong + math.max(exact, deliveryRateLimitMinRescheduleDelayMs) + +/** One drain never processes more than this, so a fat bucket (high rate or a long sleep) cannot + * hold the drainer - and with it the session's single JS context - for an unbounded stretch. The + * remainder reschedules at delay zero. */ +val deliveryRateLimitMaxDrainBatch = 500 + +/** The floor under drain wake-ups. At 1000/s the exact next-token wait is 1ms; waking per token + * would burn a thread on timers, so waits are coalesced and the batch grows to match. */ +val deliveryRateLimitMinRescheduleDelayMs = 25L + +/** Backlog size above which the session stops ASKING the broker for more - `Consumer.pause()`, the + * permits half of flow control. One receiverQueueSize (2000), because that is the burst a pause + * cannot prevent anyway: whatever was prefetched still arrives after it. */ +val deliveryRateLimitHoldPermitsAboveQueued = 2000 + +/** Backlog size below which the permits are released again. The gap to the hold mark is the + * hysteresis that keeps a hovering queue from flapping pause/resume at the broker. */ +val deliveryRateLimitReleasePermitsBelowQueued = 500 + +/** The impure shell around [[DeliveryRateLimiterCore]]: it owns the timer, runs the processing, and + * turns the queue's watermarks into consumer permit holds. + * + * THE CALLBACKS RUN OUTSIDE THE CORE'S LOCK, every one of them. `process` leases the session's JS + * context and takes the send lock; `holdPermits`/`releasePermits` take the targets' permit locks. + * Holding the limiter's own lock across any of those would nest it under locks it must never meet. + * + * PERMIT ARBITRATION. `holdPermits` answers whether it actually paused anything - the runner + * refuses while start-from counting is still in flight (a throttled-quiet stream must not look + * like a SILENT one to the merge's give-up) - and the held flag is only set when it really did. + * A refused hold retries on the next offer, so backpressure engages the moment the refusal's + * reason has passed. [[onConsumersExternallyResumed]] resets the flag when a user resume has + * overridden the hold underneath us, so the next crossing re-asserts it instead of believing it + * still holds. + */ +final class DeliveryRateLimiter[A]( + val core: DeliveryRateLimiterCore[A], + schedule: (Long, Runnable) => Unit, + process: A => Unit, + holdPermits: () => Boolean, + releasePermits: () => Unit +): + private val logger: Logger = Logger(getClass.getName) + + private val permitsHeld = AtomicBoolean(false) + + /** Set while the USER has the session paused: ticks fall through and nothing is processed, so + * a paused session delivers nothing - the queue simply waits, unacknowledged, for resume. */ + private val drainingPaused = AtomicBoolean(false) + + def offer(a: A): Unit = + val outcome = core.offer(a) + if outcome.processNow then runProtected(a) + else + if outcome.queuedCount >= deliveryRateLimitHoldPermitsAboveQueued && !permitsHeld.get then + // CAS first so two crossing offers cannot double-pause; un-set on refusal so the + // next offer retries once the refusal's reason (start-from still counting) is gone. + if permitsHeld.compareAndSet(false, true) then + if !holdPermits() then permitsHeld.set(false) + outcome.scheduleDrainAfterMs.foreach(scheduleTick) + + def pauseDraining(): Unit = drainingPaused.set(true) + + def resumeDraining(): Unit = + drainingPaused.set(false) + core.rearmDrain().foreach(scheduleTick) + + /** The user resumed the session, which resumed every consumer regardless of any hold this limiter + * had placed. Believe the world, not the flag. */ + def onConsumersExternallyResumed(): Unit = permitsHeld.set(false) + + def stop(): Unit = + drainingPaused.set(true) + core.clear() + + def queuedCount: Int = core.queuedCount + + private def scheduleTick(delayMs: Long): Unit = schedule(delayMs, () => tick()) + + private def tick(): Unit = + if drainingPaused.get then + // The timer this tick consumed must not stay recorded as armed, or the backlog would + // never get another one. Resume re-arms explicitly. + core.cancelScheduledDrain() + () + else + val batch = core.beginDrain() + // Checked BETWEEN items, because the delivery budget stops the line from INSIDE a + // delivery: the send that spends the last unit flips the flag before it returns, and + // everything behind it in this batch must go back - untouched, unacknowledged, order + // intact - to the front of the queue for the next resume. + var processed = 0 + while processed < batch.size && !drainingPaused.get do + runProtected(batch(processed)) + processed += 1 + if processed < batch.size then core.requeueFront(batch.drop(processed)) + val fin = core.finishDrain() + if fin.queuedCount <= deliveryRateLimitReleasePermitsBelowQueued && permitsHeld.get then + if permitsHeld.compareAndSet(true, false) then releasePermits() + fin.rescheduleAfterMs.foreach { delay => + // A drain stopped by the budget leaves its backlog armed in the core; consuming + // the reschedule without scheduling would strand it, so the armed flag is handed + // back for the next resumeDraining to re-arm. + if drainingPaused.get then core.cancelScheduledDrain() else scheduleTick(delay) + } + + /** One failing delivery must cost that delivery alone - the drain has a whole batch behind it, + * and [[ConsumerListener.deliverNow]] already nacks its own failures. This is the same + * per-message containment the resolved-batch loop learned in round 3, applied to the drainer. */ + private def runProtected(a: A): Unit = + Try(process(a)).failed.foreach(err => logger.warn(s"A rate-limited delivery failed and was skipped. ${err.getMessage}")) diff --git a/server/src/main/scala/consumer/session_runner/globalStartFrom.scala b/server/src/main/scala/consumer/session_runner/globalStartFrom.scala new file mode 100644 index 000000000..fd8d9c47e --- /dev/null +++ b/server/src/main/scala/consumer/session_runner/globalStartFrom.scala @@ -0,0 +1,820 @@ +package consumer.session_runner + +import _root_.consumer.start_from.{ConsumerSessionStartFrom, NthMessageAfterEarliest, NthMessageBeforeLatest} +import org.apache.pulsar.client.api.{Consumer, MessageIdAdv, Message as PulsarMessage, MessageId as PulsarMessageId} + +import com.typesafe.scalalogging.Logger + +import scala.collection.mutable +import scala.jdk.CollectionConverters.* +import scala.util.{Failure, Success, Try} + +/** Where a message sits in its own log: the entry that holds it, plus its place inside that entry + * when the entry is a producer batch. + * + * `batchIndex` is -1 for a message that was not batched, which is also how Pulsar's own ids report + * it - so an unbatched message sorts before batch index 0 of the same entry, and the two can never + * be confused. `batchSize` is how many messages the entry holds (1 when unbatched); it is only + * needed to answer "was that the last message of this entry". + */ +final case class EntryPosition(ledgerId: Long, entryId: Long, batchIndex: Int, batchSize: Int) + +object EntryPosition: + /** Nothing retained: what `MessageId.earliest` reports, and what an empty topic answers with. */ + val empty: EntryPosition = EntryPosition(-1L, -1L, -1, 0) + + /** The position a delivered Pulsar message occupies. + * + * `MessageIdAdv` is the interface every addressable id implements - plain, batched, and the + * topic-qualified wrapper a multi-topic consumer hands back, which forwards these accessors to + * the id underneath. It reports `batchIndex` -1 and `batchSize` 0 for an unbatched message. + * + * Anything else is an id nothing here can address, and is reported as [[empty]] rather than + * guessed at - an unaddressable id must not be allowed to sort somewhere plausible. + */ + def of(messageId: PulsarMessageId): EntryPosition = messageId match + // An EMPTY topic answers with MessageId.earliest, whose ledger and entry are both -1. It has + // to land on exactly [[empty]] and not merely near it: "nothing retained" is recognised by + // equality, and a position of (-1, -1, -1, 1) is not equal to (-1, -1, -1, 0), so an empty + // partition would be waited on forever and hold the whole session at zero messages. + case id: MessageIdAdv if id.getLedgerId >= 0 && id.getEntryId >= 0 => + EntryPosition(id.getLedgerId, id.getEntryId, id.getBatchIndex, id.getBatchSize max 1) + case _ => empty + +/** THE TOTAL ORDER the two counting start-from modes are defined over: publish time, then topic + * name, then position in the log (ledger, entry, batch index). + * + * A total order is not a nicety here, it is what makes the modes testable. Publish-time ties are + * COMMON - a producer that sends fast stamps many messages with the same millisecond - so an order + * that stopped at the timestamp would leave the result of "the last 5" up to whichever partition + * the broker happened to deliver first, and a test asserting an exact set would flake. + * + * THE CONTRACT IS APPEND ORDER ACROSS PARTITIONS BY PUBLISH TIME - NOT EXACT PUBLISH-TIME ORDER. + * Stated plainly here rather than hedged, because neither algorithm can provide more and nothing + * downstream may claim they do: + * + * 1. `publishTime` is stamped by the PRODUCER's clock, and Pulsar keeps no global sequence in any + * version. Across partitions written by producers whose clocks disagree, this order is only as + * good as those clocks. + * + * 2. WITHIN one partition, Pulsar preserves APPEND order - NOT producer-clock order. Several + * producers on one partition, or one producer whose clock steps back, append publish times + * that run backwards inside a single log. Both modes read each partition as a SEQUENCE and + * compare only its current end: [[GlobalSkipMerge]] compares per-stream heads going forwards, + * `resolveLatestN` compares per-topic entry cursors going backwards. Neither can see where a + * log is not in clock order, because seeing that would mean reading the whole log - O(topic) + * at any n, which is exactly the cost both designs exist to avoid. So the position is taken BY + * THE LOG within a partition and by publish time across partitions. + * + * 3. Once a skip's budget is spent the merge stops merging entirely and passes everything + * straight through, so the DELIVERY SEQUENCE after the cut is the brokers' delivery order, not + * this one. "Latest n" never had a delivery order to give: it positions each partition and + * then lets it stream, exactly as every other mode does. See [[GlobalSkipMerge]]. + * + * The COUNT both modes deliver is exact under all three; WHICH messages make the cut is exact only + * as far as the logs really are in publish-time order. `globalStartFromTest` pins each of these as + * behaviour so the claim and the code cannot drift apart. + */ +final case class MessageOrderKey(publishTime: Long, topicFqn: String, ledgerId: Long, entryId: Long, batchIndex: Int) + +object MessageOrderKey: + given ordering: Ordering[MessageOrderKey] = + Ordering.by(key => (key.publishTime, key.topicFqn, key.ledgerId, key.entryId, key.batchIndex)) + + def of(publishTime: Long, topicFqn: String, position: EntryPosition): MessageOrderKey = + MessageOrderKey(publishTime, topicFqn, position.ledgerId, position.entryId, position.batchIndex) + +/** Whether `delivered` is at or past the last message the topic held when the session started - + * i.e. whether the PRE-EXISTING BACKLOG of that topic is now drained. + * + * ONLY [[GlobalSkipMerge]] needs this, and it needs exactly one bit: a k-way merge stalls forever + * on a partition that has nothing left to offer unless that partition can be declared "+infinity". + * "This is the recorded end" and "this proves the recorded end will never be delivered" both mean + * stop waiting, so one Boolean is the whole answer. + * + * It used to be a "latest n" input too, where the same Boolean was NOT enough: a live message past + * an undeliverable recorded end entered the historical top-n heap and could evict a backlog message + * the user had asked for. There is no heap any more - "latest n" resolves its cut from entry + * metadata before anything is delivered (see `resolveLatestN`) - so no delivered message can change + * the answer, and the distinction has nothing left to affect. + * + * THE BATCH WRINKLE: `Consumer.getLastMessageIds` may answer with a plain entry id (batch index -1) + * even when that entry is a producer batch, so "same entry, batch index >= -1" would declare the + * backlog drained on the FIRST message of the final batch and lose the rest of it. When the + * recorded end carries no batch index, the end of that entry is taken from the DELIVERED message's + * own batch size instead, which every batched message carries. + */ +def isPastBacklogEnd(delivered: EntryPosition, lastAtStart: EntryPosition): Boolean = + val deliveredEntry = (delivered.ledgerId, delivered.entryId) + val lastEntry = (lastAtStart.ledgerId, lastAtStart.entryId) + if deliveredEntry != lastEntry then Ordering[(Long, Long)].gt(deliveredEntry, lastEntry) + else if lastAtStart.batchIndex >= 0 then delivered.batchIndex >= lastAtStart.batchIndex + // The recorded end named the entry but not a position inside it. An unbatched message IS the + // whole entry, so reaching it is the end; a batched one has to be the last of its own batch. + else if delivered.batchIndex < 0 then true + else delivered.batchIndex >= delivered.batchSize - 1 + +/** What a global start-from layer decided about one message it was holding. */ +enum StartFromOutcome: + /** Not part of the position the user asked for - acknowledge it and show it to nobody. */ + case Drop + + /** Part of it - hand it to the session. */ + case Deliver + + +/** A session-wide reordering layer sitting between the per-topic delivery streams and the session. + * + * Every physical topic delivers in its own order and on its own thread; a GLOBAL position can only + * be decided by looking at all of them at once, so a message has to be HELD until enough is known + * about the others to place it. `offer` answers with the messages that offer RESOLVED - which is + * usually not the one just offered, and may be none at all. + */ +trait StartFromMerge[P]: + /** Take one delivered message. `atBacklogEnd` says this message is the last one its topic held + * when the session started, so the merge must stop waiting for that topic. */ + def offer(streamId: String, key: MessageOrderKey, atBacklogEnd: Boolean, payload: P): Vector[(P, StartFromOutcome)] + + /** Messages held right now. Exposed so a test can pin the MEMORY PROFILE - the whole point of + * these two algorithms is what they refuse to buffer. */ + def heldCount: Int + + /** The counter start-from progress is read off, when this layer owns one. */ + def progressDiscard: Option[StartFromDiscard] + + /** The TIME-DRIVEN half of the stall bound. The offer-driven check runs only when some stream + * speaks - and after the last held backlog message there may be nobody left to speak, so a + * waited-for stream that was retention-trimmed used to hold the merge FOREVER with the window + * long expired. A watchdog calls this on a timer; anything it resolves is handled exactly as + * an offer's resolutions are. The default holds nothing, so it has nothing to sweep. */ + def sweepStalled(): Vector[(P, StartFromOutcome)] = Vector.empty + + /** Restart the silent-stream clock. Called on session RESUME: a pause holds every source, so + * time spent paused proves nothing about a stream's health - counting it toward the give-up + * window abandoned a healthy stream the moment a long-paused session came back. The default + * tracks no clock. */ + def resetStallClock(): Unit = () + + /** True once this layer can never hold or reorder anything again - its work is done and every + * message from here on passes straight through. From that point the session-wide ordering + * lock is pure overhead (it would serialize every partition's deserialization and JS + * evaluation for the rest of the session), so [[StartFromOrdering.inOrder]] stops taking it. + * ONE-WAY: nothing may ever un-settle a merge. */ + def isSettled: Boolean = false + + /** Flip [[isSettled]] once the budget is spent and nothing is held. Called by the LISTENER + * after a resolved batch is fully handled - never from inside the merge's own advance - so + * the settling batch itself is processed under the ordering lock before any later message is + * allowed to bypass it. The default has nothing to settle. */ + def settleIfDone(): Unit = () + + /** The streams flow control wants PAUSED right now; absent means running. Memory is bounded by + * holding a hot source STILL (consumer.pause) instead of bouncing its messages back through + * redelivery - see [[startFromMergeMaxHeld]] for why the bounce was the bug. The default + * holds nothing and pauses nobody. */ + def desiredPausedStreams: Set[String] = Set.empty + + /** The streams a give-up abandoned - the DEGRADATION record, in the order they were dropped. + * Non-empty means the position was resolved best-effort: the count stayed exact, the exact + * SET may differ. Sticky for the layer's life, so the client can keep showing it. */ + def abandonedStreamIds: Vector[String] = Vector.empty + +/** The TOTAL-held high watermark: past this many held messages, every stream with a queue is + * marked for PAUSE until the total drains back under four fifths of it. + * + * The merge normally holds ONE message per physical topic: it advances as soon as every stream it + * is still waiting for has a head. A stream that goes quiet without reaching the end of its + * backlog - a stalled broker connection, a trimmed partition - would otherwise let the other + * streams queue up without bound while the merge waited. + * + * THE BOUND IS FLOW CONTROL, NOT REFUSAL. The merge used to DECLINE offers past the cap and hand + * them back for ~100ms redelivery, and that opened the one door a k-way merge cannot leave open: + * the broker redelivers a declined message while still delivering its successors, so a stream + * could re-enter the merge OUT OF ITS OWN APPEND ORDER. A one-key floor guard held the door for + * the FIRST declined message, but successors declined by the guard itself were not remembered - + * two of them returning out of order could still spend the budget's last unit on the wrong + * message, on a perfectly monotonic stream. Pausing the source closes the whole class: nothing + * is handed back, so nothing can return out of order, and the permanent nack storm at the cap is + * gone with it. What arrives between the watermark and the pause taking effect is ACCEPTED - + * in-order arrivals are always safe to hold - so this is a watermark with a bounded overshoot + * (the in-flight callback per stream), not a hard wall. + * + * THIS IS THE ONLY START-FROM LAYER THAT HOLDS MESSAGES AT ALL, and it holds them only because + * "skip first n" cannot be resolved without reading n messages: Pulsar keeps no message-ordinal + * index, so there is no entry metadata to compute the position from. "Latest n" CAN be resolved + * from metadata, and is - see `resolveLatestN` - so it buffers nothing. + * + * A stream the merge is BLIND on has an empty queue by definition, so no watermark ever marks it: + * the one stream whose next message can unblock the merge is always left running. + */ +val startFromMergeMaxHeld: Int = 10_000 + +/** PER-STREAM queue high watermark: a stream whose own queue reaches this is paused even while + * the total is fine - one hot stream must not own the whole budget while a slow one catches up. */ +val startFromMergePauseStreamAt: Int = 1_000 + +/** Per-stream LOW watermark: a stream paused for its own queue resumes once it drains to this. + * The gap to [[startFromMergePauseStreamAt]] is the hysteresis that keeps pause/resume from + * flapping around one boundary. */ +val startFromMergeResumeStreamAt: Int = 100 + +/** Held-BYTES high watermark. The count caps above know nothing about payload size, and ten + * thousand held 5 MB messages would be 50 GB: past this many held payload bytes every stream + * with a queue is paused, whatever the counts say. */ +val startFromMergePauseBytesAt: Long = 256L * 1024 * 1024 + +/** Held-bytes LOW watermark for resuming what the byte cap paused. */ +val startFromMergeResumeBytesAt: Long = 64L * 1024 * 1024 + +/** How long the merge stays blind on a waited-for stream that has produced no head before it SAYS SO + * in the log - naming the topic-partition an operator can then act on. + * + * The merge cannot decide anything while a stream it is waiting for has said nothing: it holds + * the others (and pauses the hot ones at the watermarks). That is correct for a stream that is + * merely slow, but a partition whose backlog was trimmed by retention AFTER the session recorded + * its end will never deliver that end - the wait and the holding are then permanent and, until + * this, entirely silent. Warning before acting keeps a slow-but-alive stream from being cut + * early. + */ +val startFromMergeStallWarnMs: Long = 5_000L + +/** How long the merge stays blind on a silent waited-for stream before it STOPS waiting for it - + * treating it as drained, logging which stream it abandoned, and advancing. + * + * This is the bound on the otherwise-permanent wait. It is deliberately far longer than the + * warning and than any healthy broker read, so a stream that is slow rather than gone is not cut: + * only a stream that has delivered nothing for this long - the trimmed-partition case - is given up + * on. A given-up stream that later speaks after all has its messages passed straight through, so the + * cost of abandoning it early is the same "which messages" fuzziness the append-order contract + * already carries, never a lost count. + */ +val startFromMergeStallWindowMs: Long = 30_000L + +/** How often the runner's watchdog sweeps a merge that might be stalled with no offers arriving. + * Small against the give-up window, so the bound the window promises is met within one period of + * the promised time even in total silence. */ +val startFromStallSweepPeriodMs: Long = 2_000L + +/** "Skip the globally-first n messages, by publish time, across every physical topic." + * + * A STREAMING K-WAY MERGE over per-stream heads. It holds at most one message per physical topic: + * take the smallest head under [[MessageOrderKey]], drop it, wait for that stream to produce its + * next one, repeat. Memory is O(number of topics) and NEVER O(n) - "skip first n" has deliberately + * no cap, so buffering n messages to sort them would let a user type a number that exhausts the + * heap. + * + * A k-way merge is exact only if each input is already sorted, and a Pulsar log is sorted by APPEND + * order rather than by producer clock - see [[MessageOrderKey]] for what that narrows the contract + * to, and why buffering to fix it is the one thing this class must not do. + * + * ONCE THE BUDGET IS SPENT THE MERGE STOPS. Everything held is released in order and every later + * message is passed straight through, so the delivery SEQUENCE after the cut is the brokers' and + * not this order: a stream can deliver a newer message before another stream delivers an older + * one. Continuing to merge would mean holding one message from every stream for the whole life of + * a session that is now only streaming. + * + * A stream that has reached the end of its pre-existing backlog counts as +infinity: the merge + * stops waiting for it, so a small partition cannot stall a session whose other partitions still + * have millions of messages to go. + * + * ONE STREAM NEEDS NO MERGE, and does not get one - a single log is already in order, so the + * caller uses the plain head-drop counter and holds nothing at all. + * + * `discard` is both the budget and the progress source: `claim` answers "yes, still dropping" and + * counts the drop, and answering "no" is what ends the skip. It must be a SHARED counter (one for + * the whole session), since the merge counts the merged stream and not any one topic. + */ +final class GlobalSkipMerge[P]( + streamIds: Vector[String], + drainedAtStart: Set[String], + discard: StartFromDiscard, + maxHeld: Int = startFromMergeMaxHeld, + stallWindowMs: Long = startFromMergeStallWindowMs, + // MONOTONIC, not wall-clock: the stall window is an elapsed-time promise, and an NTP step + // under currentTimeMillis either abandoned a healthy stream early or held a dead one longer + // than promised. + nowMs: () => Long = () => System.nanoTime() / 1_000_000L, + pauseStreamAt: Int = startFromMergePauseStreamAt, + resumeStreamAt: Int = startFromMergeResumeStreamAt, + pauseBytesAt: Long = startFromMergePauseBytesAt, + resumeBytesAt: Long = startFromMergeResumeBytesAt, + // How many payload bytes a held message costs, for the byte watermarks. The default counts + // nothing, which disables byte-based pausing - production wires the real payload size in. + payloadBytesOf: P => Long = (_: P) => 0L +) extends StartFromMerge[P]: + // A global skip counts the WHOLE merged stream, so its budget must be the SHARED counter - the + // one `claim` keys by nothing (it ignores its topic-FQN argument, see StartFromDiscard.claim). + // That is what lets `advance` hand `claim` a STREAM id rather than a topic FQN below. Asserted so + // the type confusion cannot silently become real if a per-topic counter is ever passed here. + require(discard.reportsProgress, "GlobalSkipMerge requires a shared (session-wide) discard counter") + + private val logger: Logger = Logger(getClass.getName) + + private val pending: mutable.Map[String, mutable.Queue[(MessageOrderKey, P)]] = + mutable.Map.from(streamIds.map(_ -> mutable.Queue.empty[(MessageOrderKey, P)])) + private var waiting: Set[String] = streamIds.toSet -- drainedAtStart + private var dropping: Boolean = discard.remaining > 0 + + // INCREMENTAL bookkeeping, so a decision costs O(log streams) instead of a scan per message: + // the held count (was a sum over every queue per offer), the waited-for streams with no head + // (was a filter over `waiting` per offer), and a min-heap of stream heads (was a minBy over + // every ready stream per drop). Heads only change on enqueue-to-empty and dequeue, so the + // heap is maintained at exactly those points; entries are validated against the live queue + // head on pop, and a mismatch is simply a stale entry to discard. + private var heldNow: Int = 0 + private val headless: mutable.Set[String] = mutable.Set.from(waiting) + private val headsHeap: mutable.PriorityQueue[(MessageOrderKey, String)] = + mutable.PriorityQueue.empty[(MessageOrderKey, String)](Ordering[(MessageOrderKey, String)].reverse) + + // Payload bytes currently held, for the byte watermarks. Maintained on every enqueue/dequeue. + private var heldBytes: Long = 0L + + // The highest APPEND position (ledger, entry, batchIndex) each stream has offered. The + // duplicate guard: a broker unload redelivers everything un-acked while the originals may + // still sit in `pending` or already be decided, and re-deciding a copy would spend a second + // budget unit on one message. Append position is strictly increasing within a stream - publish + // TIME is not (see [[MessageOrderKey]]) - so "at or below the watermark" is exactly "offered + // before", never a legitimate successor. + private val acceptedThrough: mutable.Map[String, (Long, Long, Int)] = mutable.Map.empty + + // What flow control wants paused right now; recomputed after every accept and advance under + // the lock. The hysteresis lives in the recompute - see recomputePauseTargets. + private var pausedDesired: Set[String] = Set.empty + + // Every stream a give-up abandoned, in give-up order: the degradation record the client shows. + private val abandoned = mutable.ArrayBuffer.empty[String] + + override def abandonedStreamIds: Vector[String] = synchronized(abandoned.toVector) + + // When the merge first became unable to advance because a WAITED-FOR stream had no head, and + // whether that has already been surfaced. Both reset the moment the merge can advance again - + // they exist only to bound and name a stream that never speaks (see the stall constants). + private var blindSince: Option[Long] = None + private var stallWarned: Boolean = false + + // One-way: set by settleIfDone once the budget is spent and nothing is held. Volatile because + // inOrder reads it WITHOUT the ordering lock - that read being lock-free is its whole point. + @volatile private var settled: Boolean = false + + override def isSettled: Boolean = settled + + override def settleIfDone(): Unit = synchronized { + if !dropping && held == 0 then settled = true + } + + override def resetStallClock(): Unit = synchronized { + blindSince = None + stallWarned = false + } + + override def progressDiscard: Option[StartFromDiscard] = Some(discard) + + override def heldCount: Int = synchronized(held) + + private def held: Int = heldNow + + /** The streams the merge is still WAITING ON that have delivered no head yet - what it is blocked + * by. Surfaced (alongside [[heldCount]]) so a stall is diagnosable rather than invisible. */ + def waitingOn: Set[String] = synchronized(blindStreams(exclude = "")) + + override def sweepStalled(): Vector[(P, StartFromOutcome)] = synchronized { + if !dropping then Vector.empty + else + // No stream is delivering, so nothing is excluded from "silent" - the exclusion in the + // offer path exists only because the offering stream is about to speak. + surfaceOrGiveUpOnSilentStreams(currentStreamId = "") + val resolved = advance() + recomputePauseTargets() + resolved + } + + override def offer(streamId: String, key: MessageOrderKey, atBacklogEnd: Boolean, payload: P): Vector[(P, StartFromOutcome)] = + synchronized { + if !dropping then Vector(payload -> StartFromOutcome.Deliver) + else + // Name, and eventually give up on, any OTHER stream that has gone silent - otherwise + // a waited-for partition whose backlog was trimmed holds the merge and, at the cap, + // triggers a permanent ~100ms nack storm that nothing surfaces. The current stream is + // excluded: it is about to deliver, so it must never be the one given up on. + surfaceOrGiveUpOnSilentStreams(currentStreamId = streamId) + // The declined message itself returning lifts its stream's floor. + val position = (key.ledgerId, key.entryId, key.batchIndex) + // A DUPLICATE: at or below the append position this stream has already offered. + // Only a broker redelivery produces one - an unload hands back everything un-acked + // while the originals may still be held here or already decided - and re-entering + // it would spend a SECOND budget unit on one message. Dropping the copy is right + // whichever way the original went: a dropped original was to be shown to nobody, + // and a delivered original was already shown. Acknowledged, not counted. + if acceptedThrough.get(streamId).exists(seen => Ordering[(Long, Long, Int)].lteq(position, seen)) then + Vector(payload -> StartFromOutcome.Drop) + else + // ALWAYS ACCEPTED: an in-order arrival is safe to hold, and refusing it was + // the bug (see [[startFromMergeMaxHeld]] - a declined message's redelivery + // races its own successors). Memory pressure pauses the SOURCE instead, via + // the recompute below. + acceptedThrough(streamId) = position + val queue = pending.getOrElseUpdate(streamId, mutable.Queue.empty) + if queue.isEmpty then headsHeap.enqueue(key -> streamId) + queue.enqueue(key -> payload) + heldNow += 1 + heldBytes += payloadBytesOf(payload) + headless -= streamId + if atBacklogEnd then waiting -= streamId + val resolved = advance() + recomputePauseTargets() + resolved + } + + /** The waited-for streams with no head, optionally excluding one (the stream currently + * delivering, which must not be counted as silent). Backed by the incrementally-maintained + * `headless` set - membership means "in `waiting` AND queue empty", by construction. */ + private def blindStreams(exclude: String): Set[String] = + if exclude.isEmpty then headless.toSet else headless.toSet - exclude + + /** Surface, and past the give-up window abandon, any waited-for stream that has delivered no head + * for too long. The merge holds messages (and pauses hot sources) for as long as it waits, so a + * stream that never speaks - a partition trimmed after its end was recorded - would block it + * forever in silence. Warn first (a slow-but-alive stream must not be cut early), then stop + * waiting for it, which lets the next `advance` drain what was held. Runs under the same lock as + * everything else; `nowMs` is injected so the window is testable without real time. */ + private def surfaceOrGiveUpOnSilentStreams(currentStreamId: String): Unit = + val silent = blindStreams(exclude = currentStreamId) + if silent.isEmpty then + blindSince = None + stallWarned = false + else + val now = nowMs() + if blindSince.isEmpty then blindSince = Some(now) + val elapsed = now - blindSince.getOrElse(now) + if !stallWarned && elapsed >= startFromMergeStallWarnMs then + logger.warn( + s"Start-from skip is waiting on ${silent.mkString(", ")}, which has delivered nothing for ${elapsed}ms while $held " + + "message(s) are held. If that partition's backlog was trimmed it may never arrive." + ) + stallWarned = true + if elapsed >= stallWindowMs then + logger.warn( + s"Start-from skip is giving up on ${silent.mkString(", ")} after ${elapsed}ms with no delivery; treating it as " + + "drained and continuing. The skip count stays exact; which messages were dropped may differ if that stream was only slow." + ) + waiting --= silent + headless --= silent + abandoned ++= silent.toVector.sorted + blindSince = None + stallWarned = false + + /** Take heads while the smallest one is KNOWN to be the smallest - that is, while every stream + * still being waited for has a head to compare. Answers with what that resolved. + */ + private def advance(): Vector[(P, StartFromOutcome)] = + val out = Vector.newBuilder[(P, StartFromOutcome)] + var going = true + while going do + // A stream that is still waited for but has no head could yet turn out to hold the + // smallest message, so the merge cannot decide ANYTHING until it speaks. Memory is + // bounded by pausing hot sources (see `offer`), never by deciding blind. + if headless.nonEmpty then going = false + else + // Pop heads until one matches its stream's LIVE queue head; anything else is a + // stale leftover from an earlier dequeue, discarded on sight. The (key, stream id) + // tuple breaks a PERFECT tie (the same message reached by two targets), so the + // choice never depends on heap internals or iteration order. + var chosen: Option[(MessageOrderKey, String)] = None + while chosen.isEmpty && headsHeap.nonEmpty do + val candidate = headsHeap.dequeue() + val live = pending.get(candidate._2).exists(queue => queue.nonEmpty && queue.head._1 == candidate._1) + if live then chosen = Some(candidate) + chosen match + case None => going = false + case Some((_, streamId)) => + val queue = pending(streamId) + // `claim` is keyed by topic FQN, but the constructor `require`d a SHARED + // counter, which ignores that key - so handing it a stream id (not a topic + // FQN) is sound. + if discard.claim(streamId) then + val dropped = queue.dequeue()._2 + heldNow -= 1 + heldBytes = (heldBytes - payloadBytesOf(dropped)) max 0L + if queue.nonEmpty then headsHeap.enqueue(queue.head._1 -> streamId) + else if waiting.contains(streamId) then headless += streamId + out += (dropped -> StartFromOutcome.Drop) + // The budget can hit zero on THIS claim. Waiting for one more head just + // to have the NEXT claim answer "no" held the boundary batch hostage: + // when this drop emptied a still-waited stream, everything already held + // stayed invisible until that stream spoke again or the stall window + // gave up on it - for an answer that was already fully decided. + if discard.remaining <= 0 then + dropping = false + out ++= drainInOrder().map(_ -> StartFromOutcome.Deliver) + going = false + else + // The budget is spent: nothing is dropped from here on, and everything + // held is released in the order the merge would have produced it. + dropping = false + out ++= drainInOrder().map(_ -> StartFromOutcome.Deliver) + going = false + out.result() + + private def drainInOrder(): Vector[P] = + val all = pending.toVector.flatMap((streamId, queue) => queue.map((key, payload) => (key, streamId, payload))) + pending.values.foreach(_.clear()) + heldNow = 0 + heldBytes = 0L + headsHeap.clear() + headless.clear() + headless ++= waiting + all.sortBy((key, streamId, _) => (key, streamId)).map((_, _, payload) => payload) + + /** Which streams flow control wants held still. Recomputed after every accept and advance. + * + * A stream pauses when its OWN queue reaches `pauseStreamAt`, or - when the TOTAL held + * (messages or bytes) passes its high watermark - whenever it has a queue at all, since any + * queued stream may be the next to grow. It resumes only once its queue is back under + * `resumeStreamAt` AND the totals are under their low watermarks: the gap is the hysteresis. + * A BLIND stream has an empty queue, so no watermark ever marks it - the one stream whose + * next message can unblock the merge always runs. Once the budget is spent the set empties, + * and the caller's reconcile resumes everything it paused. + */ + private def recomputePauseTargets(): Unit = + if !dropping then pausedDesired = Set.empty + else + val overTotal = held >= maxHeld || heldBytes >= pauseBytesAt + val underTotal = held <= (maxHeld * 4 / 5) && heldBytes <= resumeBytesAt + val newlyPaused = + if overTotal then pending.collect { case (id, queue) if queue.nonEmpty => id }.toSet + else pending.collect { case (id, queue) if queue.size >= pauseStreamAt => id }.toSet + val retained = pausedDesired.filter { id => + val size = pending.get(id).map(_.size).getOrElse(0) + size > resumeStreamAt || (!underTotal && size > 0) + } + pausedDesired = newlyPaused ++ retained + + override def desiredPausedStreams: Set[String] = synchronized(pausedDesired) + + /** Held payload bytes right now - the byte half of the memory profile, exposed for tests. */ + def heldBytesCount: Long = synchronized(heldBytes) + +/** One physical topic's delivery stream, as a global start-from layer knows it. + * + * `lastAtStart` is where that topic ENDED when the session was created; it is what tells the layer + * that the stream has run out of pre-existing messages and must stop being waited for. + */ +final case class StartFromStream(id: String, lastAtStart: EntryPosition) + +/** The identity of one delivery stream. + * + * NOT the topic alone: two enabled targets may select the SAME topic, and each has its own consumer + * delivering it independently. Merging both under one key would let one target's head hide the + * other's. + */ +def startFromStreamId(consumerName: String, topicFqn: String): String = s"$consumerName@$topicFqn" + +/** Where every consumer's topic ended when the session started. + * + * ONLY "SKIP FIRST N" ASKS FOR THIS, and only on a multi-stream session: it is what lets the merge + * stop waiting for a partition that has run out of pre-existing messages. "Latest n" used to need + * it too, to know when its heap could be released; it resolves its position from entry metadata + * now, so it costs one broker call per topic less than it did. + * + * `getLastMessageIds` rather than the deprecated singular form: it answers with one id per topic + * behind the consumer, which is the shape that stays right if a consumer is ever built over more + * than one. The LAST of them is taken, so a consumer covering several topics is not declared + * drained before all of them are. + * + * A NON-PERSISTENT topic is recorded as [[EntryPosition.empty]] - "already drained" - without + * being asked: it retains nothing, so it has no end to reach and must never be one the merge waits + * for. That is decided from the FQN, exactly as [[startFromNeedsRetainedHistory]] decides its own + * case, and NOT from whatever the call happens to throw. + * + * A PERSISTENT topic whose end cannot be read FAILS the session. It used to be recorded as empty + * too, which reads as "already drained": the merge then stopped waiting for that whole partition, + * so a global skip silently left it out of the count and out of the order while it went on + * delivering messages. An empty partition needs no special case here - it ANSWERS, with + * `MessageId.earliest`, which maps to [[EntryPosition.empty]] on its own. + */ +def startFromStreamsAt(consumers: Vector[Consumer[Array[Byte]]]): Vector[StartFromStream] = + consumers.map { consumer => + val topicFqn = consumer.getTopic + val lastAtStart = + if isNonPersistentTopic(topicFqn) then EntryPosition.empty + else + Try(consumer.getLastMessageIds.asScala.toVector) match + case Success(messageIds) => + messageIds + .map(EntryPosition.of) + .maxOption(Ordering.by[EntryPosition, (Long, Long, Int)](p => (p.ledgerId, p.entryId, p.batchIndex))) + .getOrElse(EntryPosition.empty) + case Failure(err) => + throw StartFromUnresolvableException( + s"Could not resolve the requested start-from position: reading where the topic ends failed for $topicFqn. ${err.getMessage}", + err + ) + StartFromStream(startFromStreamId(consumer.getConsumerName, topicFqn), lastAtStart) + } + +/** Which global reordering a start-from needs on top of its seek, if any. */ +enum StartFromOrderingPlan: + /** The seek (and whatever the head-drop counters correct) already lands exactly - every mode + * except the two counting ones, and both of those on a session with a single stream, where the + * one log is already in publish order. */ + case PassThrough + + /** Drop the globally-first `n` across `streams`: a [[GlobalSkipMerge]]. */ + case GlobalSkip(n: Long, streams: Vector[StartFromStream]) + +/** Whether this session needs a GLOBAL reordering layer on top of its seek. + * + * ONLY "SKIP FIRST N", and only on a session with more than one delivery stream. One log is + * already in append order, so the head-drop counter is exact on its own and nothing is ever held - + * the ordinary case, a single non-partitioned topic, must stay free. + * + * "LATEST N" DELIBERATELY NEEDS NONE, at any number of streams. It used to get a bounded top-n + * heap that buffered n full message payloads and narrowed the over-fetch after delivery; its cut + * is now resolved from entry METADATA before anything is delivered (`resolveLatestN`), so every + * consumer simply starts in the right place and streams. That removed the only start-from path + * whose memory grew with a number the user typed. + * + * Every other mode reaches its position with the seek itself. Both approximate modes stay per topic + * deliberately - [[consumer.start_from.ApproximateDataPosition]] per partition and + * [[consumer.start_from.ApproximateTimePosition]] per logical topic: a count of n can be reached by + * streaming n messages and stopping, whereas a fraction of the merged stream is only known once the + * whole of it has been measured. + * + * PURE, so both the fast path and the modes that must not acquire one are pinned by test. + */ +def needsGlobalOrdering(startFrom: ConsumerSessionStartFrom, streamCount: Int): Boolean = startFrom match + case v: NthMessageAfterEarliest => v.n > 0 && streamCount > 1 + case _ => false + +/** WHICH layer this session gets - a streaming merge for "skip first n", and nothing for everything + * else, "latest n" included. + * + * The single place the mapping is made, so `handleStartFrom` cannot disagree with it, and PURE, so + * it is pinned without a broker. Resolving `streams` costs a broker call per topic, which is why + * [[needsGlobalOrdering]] stays separate: it gates that cost before the streams are asked for. + */ +def globalOrderingPlanFor(startFrom: ConsumerSessionStartFrom, streams: Vector[StartFromStream]): StartFromOrderingPlan = + if !needsGlobalOrdering(startFrom, streams.size) then StartFromOrderingPlan.PassThrough + else + startFrom match + case v: NthMessageAfterEarliest => StartFromOrderingPlan.GlobalSkip(v.n, streams) + case _ => StartFromOrderingPlan.PassThrough + +/** Everything a start-from needs after its seek: what to drop off the head of each stream, and how + * to reorder what is left into the global order the counting modes are defined over. + */ +final case class StartFromPlan(discard: StartFromDiscardPlan, ordering: StartFromOrderingPlan) + +/** A delivered message the ordering layer is holding, with everything needed to resolve it later - + * possibly from a DIFFERENT topic's listener thread, since the merge releases whatever the newest + * head unblocked, not the message just offered. + */ +final case class HeldMessage( + consumer: Consumer[Array[Byte]], + message: PulsarMessage[Array[Byte]], + listener: ConsumerListener +) + +/** The session-wide ordering layer the listeners hand every delivered message to. + * + * Armed ONCE, by `ConsumerSessionRunner.make`, and shared by every target of the session: the two + * counting modes are defined over the session's whole merged stream, so a per-target layer would + * count each target separately. + * + * Generic in what it holds so the WHOLE routing - stream identity, sort key, end of backlog - can + * be driven with plain values and real Pulsar message ids, and no broker. Production instantiates + * it with [[HeldMessage]]. + */ +final class StartFromOrdering[P] private ( + private val merge: Option[StartFromMerge[P]], + private val streams: Map[String, StartFromStream] +): + /** The lock that keeps the merge's DECISION and the session's ACTIONS in the same order. + * + * `offer` is itself synchronized, but that is not enough: what it answers with is a batch of + * messages that were resolved by THIS offer and now have to be handled - possibly several of + * them, possibly belonging to other topics. Releasing after `offer` let another listener thread + * resolve a later batch and process it first, so the session's stateful filters, coloring rules + * and value projections saw messages in a different order than the merge had just decided on. + */ + private val orderingLock = new Object + + /** Run `use` with this session's ordering decisions serialized. + * + * A NO-OP when nothing is being reordered. The ordinary session - one non-partitioned topic, + * or any mode that reaches its position with the seek alone - decided no order, so a + * session-wide lock per message would only serialize its partitions' deserialization for + * nothing. That fast path must stay free. + */ + def inOrder[A](use: => A): A = merge match + case None => use + // Settled is ONE-WAY: the budget is spent and nothing is held, so the merge can never + // reorder anything again - from here the lock would only serialize every partition's + // deserialization and JS evaluation for the rest of the session's life, for nothing. + case Some(layer) => if layer.isSettled then use else orderingLock.synchronized(use) + + /** See [[StartFromMerge.settleIfDone]]. The listener calls this after a resolved batch is + * fully handled, still under [[inOrder]]. */ + def settleIfDone(): Unit = merge.foreach(_.settleIfDone()) + + /** See [[StartFromMerge.resetStallClock]]. The runner calls this on session RESUME. */ + def resetStallClock(): Unit = merge.foreach(_.resetStallClock()) + + /** See [[StartFromMerge.abandonedStreamIds]] - the degradation record for the progress API. */ + def abandonedStreams: Vector[String] = merge.map(_.abandonedStreamIds).getOrElse(Vector.empty) + + // FLOW CONTROL: how the merge's desired-paused set becomes consumer.pause()/resume() calls. + // Hooks are registered lazily by each listener on a stream's first delivery (a stream that + // never delivers has nothing to pause). + // + // GUARDED BY ITS OWN LOCK, deliberately not by [[inOrder]]: once the merge settles, inOrder + // stops taking the ordering lock (that is the whole point of settling) - but a topic that was + // quiet through the skip still registers its hooks on its first POST-cut delivery, and two + // listener threads doing that concurrently would race an unsynchronized map. The lock is + // uncontended and the critical sections are tiny, so this costs nothing measurable. + private val flowControlLock = new Object + private val streamPauseHooks = mutable.Map.empty[String, (() => Unit, () => Unit)] + private var pausedApplied: Set[String] = Set.empty + + /** Register how to pause and resume one stream's consumer. Idempotent; first registration + * wins. A no-op for a pass-through layer, which never pauses anybody. */ + def registerStreamPauseHooks(streamId: String, pause: () => Unit, resume: () => Unit): Unit = + if merge.isDefined then + flowControlLock.synchronized { + if !streamPauseHooks.contains(streamId) then streamPauseHooks(streamId) = (pause, resume) + } + + /** Apply the merge's flow-control wishes: pause what it wants held still, resume what it no + * longer does. Once the merge settles the desired set is empty forever, so the settling + * batch's reconcile resumes everything still paused and every later call is a no-op. + * + * EVERY desired stream is (re-)paused on EVERY reconcile, not only the newly-desired diff: + * `consumer.pause()` is a client-local flag and idempotent, and other machinery legitimately + * resumes consumers wholesale behind this layer's back - the user's own resume, and the + * delivery rate limiter releasing its permit hold. Re-asserting the pause each time makes + * that drift self-healing; pausing only the diff left a hot stream running forever while the + * bookkeeping claimed it was held. + */ + def reconcileFlowControl(): Unit = merge.foreach { layer => + val desired = layer.desiredPausedStreams + flowControlLock.synchronized { + val pausable = desired.filter(streamPauseHooks.contains) + val toResume = pausedApplied -- desired + pausable.foreach(streamId => streamPauseHooks(streamId)._1()) + toResume.foreach(streamId => streamPauseHooks.get(streamId).foreach(_._2())) + pausedApplied = pausable + } + } + + /** Forget which consumers this layer paused. Called on session RESUME, which has just resumed + * every consumer wholesale: the bookkeeping must match that reality. (Reconcile would also + * self-heal on its next pass - see above - but resume should not have to wait for one.) */ + def resetAppliedFlowControl(): Unit = flowControlLock.synchronized { pausedApplied = Set.empty } + + /** The counter start-from progress is read off while this layer is doing the counting. */ + def progressDiscard: Option[StartFromDiscard] = merge.flatMap(_.progressDiscard) + + /** Messages held right now - the memory profile, exposed for tests and diagnostics. */ + def heldCount: Int = merge.map(_.heldCount).getOrElse(0) + + /** See [[StartFromMerge.sweepStalled]]. Callers hold [[inOrder]], like every offer. */ + def sweepStalled(): Vector[(P, StartFromOutcome)] = merge.map(_.sweepStalled()).getOrElse(Vector.empty) + + /** Take one delivered message, and answer with everything that offer RESOLVED - which is often + * not the message just offered, and may be none at all. + */ + def offer( + consumerName: String, + topicFqn: String, + publishTime: Long, + messageId: PulsarMessageId, + payload: P + ): Vector[(P, StartFromOutcome)] = merge match + case None => Vector(payload -> StartFromOutcome.Deliver) + case Some(layer) => + val streamId = startFromStreamId(consumerName, topicFqn) + val position = EntryPosition.of(messageId) + // An unknown stream is treated as already drained, for the same reason an unanswerable + // one is: the merge must never wait on something it knows nothing about. + val atBacklogEnd = streams.get(streamId).forall(stream => isPastBacklogEnd(position, stream.lastAtStart)) + layer.offer(streamId, MessageOrderKey.of(publishTime, topicFqn, position), atBacklogEnd, payload) + +object StartFromOrdering: + /** Reorders nothing, and holds nothing. */ + def passThrough[P]: StartFromOrdering[P] = new StartFromOrdering[P](None, Map.empty) + + def make[P](plan: StartFromOrderingPlan, payloadBytesOf: P => Long = (_: P) => 0L): StartFromOrdering[P] = plan match + case StartFromOrderingPlan.PassThrough => passThrough[P] + case StartFromOrderingPlan.GlobalSkip(n, streams) => + new StartFromOrdering[P]( + Some(GlobalSkipMerge[P](streams.map(_.id), drainedAtStart(streams), StartFromDiscard.shared(n), payloadBytesOf = payloadBytesOf)), + byId(streams) + ) + + private def byId(streams: Vector[StartFromStream]): Map[String, StartFromStream] = + streams.map(stream => stream.id -> stream).toMap + + /** Streams that held nothing when the session started - an empty topic, or one that would not + * say. They can never be waited for. */ + private def drainedAtStart(streams: Vector[StartFromStream]): Set[String] = + streams.filter(_.lastAtStart == EntryPosition.empty).map(_.id).toSet diff --git a/server/src/main/scala/consumer/session_runner/handleStartFrom.scala b/server/src/main/scala/consumer/session_runner/handleStartFrom.scala index 350882d13..265bdedeb 100644 --- a/server/src/main/scala/consumer/session_runner/handleStartFrom.scala +++ b/server/src/main/scala/consumer/session_runner/handleStartFrom.scala @@ -3,9 +3,11 @@ package consumer.session_runner import org.apache.pulsar.client.admin.PulsarAdmin import java.time.ZonedDateTime -import org.apache.pulsar.client.api.{Consumer, PulsarClient, Message as PulsarMessage, MessageId as PulsarMessageId} +import org.apache.pulsar.client.api.{Consumer, PulsarClient, Message as PulsarMessage, MessageId as PulsarMessageId, MessageIdAdv} import _root_.topic.{TopicPartitioningType, getTopicPartitioning} -import _root_.consumer.start_from.{ConsumerSessionStartFrom, DateTime, DateTimeUnit, EarliestMessage, LatestMessage, MessageId, NthMessageAfterEarliest, NthMessageBeforeLatest, RelativeDateTime} +import _root_.consumer.start_from.{ApproximateDataPosition, ApproximateTimePosition, ConsumerSessionStartFrom, DateTime, DateTimeUnit, EarliestMessage, LatestMessage, MessageId, NthMessageAfterEarliest, NthMessageBeforeLatest, RelativeDateTime} + +import org.apache.pulsar.client.impl.MessageIdImpl import scala.util.{Failure, Success, Try} import scala.jdk.CollectionConverters.* @@ -23,43 +25,820 @@ def getPartitions(adminClient: PulsarAdmin, topicFqn: String): Vector[String] = .toVector partitions -def examineNonPartitionedTopicMessage(adminClient: PulsarAdmin, topicFqn: String, initialPosition: String, n: Long): Option[PulsarMessage[Array[Byte]]] = - Try(adminClient.topics.examineMessage(topicFqn, initialPosition, n)).toOption - -def examinePartitionedTopicMessage(adminClient: PulsarAdmin, topicFqn: String, initialPosition: String, n: Long): Option[PulsarMessage[Array[Byte]]] = - val partitions = getPartitions(adminClient, topicFqn) - partitions.flatMap(partitionFqn => examineNonPartitionedTopicMessage(adminClient, partitionFqn, initialPosition, n)) match - case Vector() => None - case candidates => - initialPosition match - case "earliest" => Some(candidates.minBy(msg => msg.getPublishTime)) - case "latest" => Some(candidates.maxBy(msg => msg.getPublishTime)) - -def findNthMessage(adminClient: PulsarAdmin, topicFqn: String, initialPosition: String, n: Long): Option[PulsarMessage[Array[Byte]]] = - getTopicPartitioning(adminClient, topicFqn).`type` match - case TopicPartitioningType.Partitioned => - examinePartitionedTopicMessage(adminClient, topicFqn, initialPosition, n) - case TopicPartitioningType.NonPartitioned => - examineNonPartitionedTopicMessage(adminClient, topicFqn, initialPosition, n) - -def findNthMessageMultiTopic(adminClient: PulsarAdmin, topics: Vector[String], initialPosition: String, n: Long): Option[PulsarMessage[Array[Byte]]] = - topics.flatMap(topicFqn => findNthMessage(adminClient, topicFqn, initialPosition, n)) match - case Vector() => None - case messages => - initialPosition match - case "earliest" => Some(messages.minBy(msg => msg.getPublishTime)) - case "latest" => Some(messages.maxBy(msg => msg.getPublishTime)) +/** Key the admin client uses to report how many messages the entry it just expanded holds. Set by + * `TopicsImpl.getIndividualMsgsFromBatch`; absent for a message that was not batched. + * + * VERIFIED against Pulsar 3.2.1: 100 messages sent with the default (batching) producer became ONE + * entry, and `examineMessage` answered with `X-Pulsar-num-batch-message -> 100` in + * `getProperties`. + */ +val batchSizeProperty = "X-Pulsar-num-batch-message" + +/** How many messages the entry behind `message` holds, read from a source a PRODUCER CANNOT FORGE. + * + * This count drives the backward walk's running total and the per-topic overshoot discard, so a + * value under producer control would let a crafted message overshoot the count and make the discard + * swallow real messages from the head of the stream. + * + * `X-Pulsar-num-batch-message` lives in `getProperties`, right beside the arbitrary keys a producer + * sets, and the admin client only overwrites it (from the entry's real batch metadata) for entries + * it actually expands as batches - so a forged value survives on an UNBATCHED message. The message + * id is the non-forgeable witness: the admin client returns a batch id (batch index >= 0) exactly + * for a real batch, which a producer cannot fake onto an unbatched message. + * + * - Batched id carrying its own batch size: use that - it is not a property at all. + * - Batched id without one: fall back to the verified property, but only as a POSITIVE number. + * - Unbatched id: exactly one message, whatever `getProperties` claims - the forged case. + * + * The fallback direction is deliberate: an unrecognised or out-of-range value is read as a single + * message, which makes the walk go DEEPER (over-deliver) rather than swallow. + */ +def messagesInEntryOf(message: PulsarMessage[Array[Byte]]): Int = + message.getMessageId match + case adv: MessageIdAdv if adv.getBatchIndex >= 0 => + val fromId = adv.getBatchSize + if fromId > 0 then fromId + else + message.getProperties.asScala + .get(batchSizeProperty) + .flatMap(value => Try(value.toInt).toOption) + .filter(_ >= 1) + .getOrElse(1) + case _ => 1 + +/** Strip the batch index off an id so it addresses the ENTRY. + * + * The admin client hands back a `BatchMessageIdImpl` pinned to batch index 0. Seeking to THAT id + * only delivers the whole entry while the consumer was built with `startMessageIdInclusive()` + * (without it the first message of the entry is skipped, which would silently shift every discard + * count by one). Seeking to the bare entry id behaves the same either way - VERIFIED against + * Pulsar 3.2.1 on a 3-entry x 10-message topic: both flags delivered the entry from its first + * message. + */ +def entryIdOf(messageId: PulsarMessageId): PulsarMessageId = messageId match + case id: MessageIdImpl => new MessageIdImpl(id.getLedgerId, id.getEntryId, id.getPartitionIndex) + case other => other + +/** One entry of a log, as the backward walk reads it: where it is, when it was published, and how + * many messages it holds. + * + * The publish time is what makes a MERGED backward walk possible across partitions. It costs + * nothing to carry - `examineMessage` returns a whole message - and it used to be thrown away, + * which is why narrowing the over-fetch afterwards needed a buffer of delivered messages. + * + * All messages of a batched entry share the entry's publish time (Pulsar stamps it once, on the + * batch's `MessageMetadata`), so an entry-level publish time is exact for every message in it. + */ +final case class LogEntry[A](entryId: A, publishTime: Long, messagesInEntry: Int) + +/** The k-th ENTRY counted back from the end of `topicFqn` (k = 1 is the last entry), or `None` once + * k is past the start of the log. + * + * `examineMessage` is ENTRY-addressed on both sides, but its failure modes differ: counting back + * from `latest` past the start FAILS (ManagedLedgerException "Incorrect parameter input", surfaced + * as an admin 500), while counting from `earliest` past the end silently CLAMPS to the last entry. + * Only the first is used here; the caller guards the clamping case as well. + * + * `None` means the BROKER SAID there is nothing there, and nothing else. A broker that could not + * answer - a timeout, a 401, a 404, a 500 about something else - throws, because the caller reads + * `None` as "this log is exhausted" and seeks to EARLIEST: erasing an operational failure into it + * turned "the latest 5" into the whole backlog with the session reporting success. See + * [[isEmptyLogAnswer]] for the measured classification. + */ +def entryFromLatest(adminClient: PulsarAdmin, topicFqn: String)(k: Long): Option[LogEntry[PulsarMessageId]] = + brokerAnswer(s"examining entry $k counted back from the end", topicFqn)(adminClient.topics.examineMessage(topicFqn, "latest", k)).map { message => + if isChunkPiece(message) then + throw StartFromUnresolvableException( + s"Could not resolve the requested start-from position: $topicFqn stores CHUNKED messages (one message split " + + "across several entries), and 'Latest n messages' counts entries - an entry count is not a message count " + + "there. Use a time-based position or 'Skip first n messages', which counts what is actually delivered.", + null + ) + LogEntry(entryIdOf(message.getMessageId), message.getPublishTime, messagesInEntryOf(message)) + } + +/** Whether this examined message is one CHUNK of a larger logical message. + * + * A chunking producer stores ONE logical message as SEVERAL ledger entries, which the consumer + * reassembles - so counting entries counts fragments, and every count the walk produces is + * silently wrong. Read from the message's own metadata behind a guarded impl cast; anything + * unreadable counts as "not chunked", so an exotic client type degrades to the old behaviour + * rather than refusing valid topics. */ +def isChunkPiece(message: PulsarMessage[Array[Byte]]): Boolean = message match + case impl: org.apache.pulsar.client.impl.MessageImpl[?] => + Try { + val metadata = impl.getMessageBuilder + metadata != null && metadata.hasNumChunksFromMsg && metadata.getNumChunksFromMsg > 1 + }.getOrElse(false) + case _ => false + +/** The FIRST entry `topicFqn` retains right now, or `None` when it retains nothing. One lookup; + * the retention re-check reads it per contributing topic after a latest-n walk resolves. */ +def earliestRetainedEntryId(adminClient: PulsarAdmin, topicFqn: String): Option[PulsarMessageId] = + brokerAnswer("examining the first retained entry", topicFqn)(adminClient.topics.examineMessage(topicFqn, "earliest", 1)) + .map(message => entryIdOf(message.getMessageId)) + +/** How many times ONE backward step may re-read a topic whose end moved forward under it before the + * walk REFUSES. + * + * A handful of appends can land inside the milliseconds of one admin round trip, and the walk + * re-anchors past them. A log that keeps outrunning the walk for this many consecutive lookups has + * no resolvable "last n" at this moment, and both silent endings are wrong answers delivered as + * success: classifying it as exhaustion seeks EARLIEST (the whole backlog - the original defect), + * and stopping short delivers fewer than n. The walk therefore fails with + * [[StartFromUnresolvableException]], naming the topic, so the user can retry when the producer + * quietens or reach for a time position instead. + * + * A broker that CLAMPS to its last entry forever (defensive - Pulsar 3.2 FAILS past the start + * rather than clamping) must not burn this bound or be refused: it is told apart from growth by + * ONE verification lookup - re-asking the k that produced the last ACCEPTED entry. A clamped end + * never moves, so that k answers the same entry again; a grown end answers a newer one. The clamp + * therefore still resolves as `Everything`, one lookup later than the old first-repeat guard - the + * guard that could not tell a clamp from a single concurrent append. + */ +val maxLatestNReanchorSteps: Int = 64 + +/** The wall-clock budget for resolving one 'Latest n messages' request. + * + * The count cap ([[latestNMaxAccepted]]) bounds N, but N is a poor proxy for COST: the walk pays + * roughly one synchronous `examineMessage` per ENTRY, so a batched topic answers ten million in + * thousands of lookups while an unbatched one would need ten million of them - hours, inside a + * session-create call that shows no progress and cannot be cancelled. Time is the honest bound: + * a resolution that cannot finish inside this budget fails with the same "narrow the request" + * guidance the count cap gives, instead of grinding on. */ +val latestNResolveBudgetMs: Long = 30_000L + +/** Entry-position order for the backward walk: `a` is strictly older than `b` when its id sorts + * before `b`'s. `MessageIdImpl.compareTo` orders by ledger then entry then partition, so an earlier + * append is strictly less. This is what tells a genuine step back from a `latest, k` answer that + * only moved because the log grew under the walk. */ +def latestNEntryIsOlder(a: PulsarMessageId, b: PulsarMessageId): Boolean = a.compareTo(b) < 0 + +/** Where ONE physical topic has to start so that the session's topics TOGETHER deliver the last n + * messages. */ +enum LatestNSeek[+A]: + /** Everything this topic holds is older than the cut, so it contributes no history at all: seek + * it to LATEST. NOT to earliest - that would show the whole log. */ + case Nothing + + /** The walk consumed this topic's whole log: seek to EARLIEST and discard nothing. */ + case Everything + + /** Seek to `entryId` and drop the first `discard` messages delivered from it - the overshoot + * inside the entry the walk stopped on, which a seek cannot express because it can only land + * on an entry boundary. */ + case FromEntry(entryId: A, discard: Long) + +/** Resolve "deliver exactly the last `n` messages across these topics, newest first by publish + * time" into a starting position per topic - WITHOUT reading a single message. + * + * ONE MERGED BACKWARD WALK, not one walk per topic plus a buffer afterwards. Each topic gets a + * cursor stepping back through its entries; repeatedly take the cursor whose current entry has the + * LARGEST publish time, add that entry's message count to a running total, and step that cursor + * back one. Stop when the total reaches n. Because a cursor only ever moves backwards, the entries + * taken from a topic are always a contiguous suffix of its log - so "seek to the oldest entry + * taken" delivers exactly the messages the walk chose, plus everything newer, and nothing else. + * + * MEMORY IS O(NUMBER OF TOPICS): one cursor and one entry's metadata each. Nothing is buffered and + * no message payload is ever held. This replaced a bounded top-n heap of exactly n DELIVERED + * messages, which made "the latest n" the one start-from whose memory was a number the user typed, + * and which could let a live message evict a historical one because both went through the same + * heap. Neither failure mode exists here: the cut is decided before anything is delivered. + * + * COST IS O(n / batch size + topics) admin lookups - strictly cheaper than the per-topic walks it + * replaces (those cost O(topics * n / batch size)), and independent of how big the topics are. + * + * THE CUT IS BY APPEND POSITION WITHIN A TOPIC AND BY ENTRY PUBLISH TIME ACROSS TOPICS, which is + * the contract [[MessageOrderKey]] states and not a stronger one. A partition whose producer clock + * stepped backwards can hold a high-timestamp message deeper in its log than the walk ever reaches, + * and it will not be found - finding it would mean scanning the whole log, which is O(topic) at any + * n. The publish-time tie-break is the same one [[MessageOrderKey]] uses (time, then topic name), + * so an entry-level cut and a message-level order cannot disagree. + * + * PURE: the broker sits behind `entryFromLatest`, so batched / unbatched / uneven / exhausted / + * clamped / empty logs, and every interleaving across topics, are testable with a plain lambda. + * + * `entryFromLatest(topic)(k)` must answer with the k-th entry counted back from the end of that + * topic (k = 1 is the last entry), or `None` once k is past its start. + * + * THE ANCHOR MOVES WHILE THE WALK RUNS. `examineMessage(topic, "latest", k)` counts back from + * whatever the end is at the moment it is asked, so a producer appending during session creation + * shifts position k forward under the cursor: a single append makes the next step answer with the + * entry JUST taken, a burst makes it answer with a NEWER one. Neither is exhaustion. The walk tells + * a real step back from a re-anchored answer with `entryIsOlder` (a total order on entry positions + * - `MessageIdImpl.compareTo` in production), and steps k forward until the answer is strictly + * older, re-anchoring past the growth. A topic ends on `None` or on a VERIFIED clamp; a log still + * outrunning the walk at the re-anchor bound FAILS the resolution loudly instead of answering + * with the whole backlog or a short count. See [[maxLatestNReanchorSteps]]. + * + * `entryIsOlder(a, b)` must be true exactly when position `a` sits strictly BEFORE `b` in the log. + */ +def resolveLatestN[A]( + n: Long, + topicFqns: Vector[String], + entryFromLatest: String => Long => Option[LogEntry[A]], + entryIsOlder: (A, A) => Boolean, + resolveBudgetMs: Long = latestNResolveBudgetMs, + nowMs: () => Long = () => System.nanoTime() / 1_000_000L +): Map[String, LatestNSeek[A]] = + val topics = topicFqns.distinct + if n <= 0 then topics.map(_ -> LatestNSeek.Nothing).toMap + else + val startedAtMs = nowMs() + def checkBudget(): Unit = + val elapsed = nowMs() - startedAtMs + if elapsed > resolveBudgetMs then + throw StartFromUnresolvableException( + s"Could not resolve the requested start-from position: 'Latest n messages' has been walking entry metadata for " + + s"${elapsed}ms, past its ${resolveBudgetMs}ms budget - the topics hold more entries than can be walked " + + "interactively (an unbatched topic costs one broker lookup per message). Ask for fewer messages, use " + + "'Skip first n messages' (which streams and reports progress), or a time-based position.", + null + ) + + val nextK = scala.collection.mutable.Map.from(topics.map(_ -> 1L)) + // The entry each cursor is currently offering. A topic missing from here has either run off + // the start of its log or had its offer taken and not yet been stepped. + val head = scala.collection.mutable.Map.empty[String, LogEntry[A]] + val previousEntryId = scala.collection.mutable.Map.empty[String, A] + // The k that ANSWERED with the accepted entry, at the moment it was accepted. Re-asking it + // is what tells a clamping broker (same answer - the end never moved) from a log that grew + // under the walk (a newer answer). See [[maxLatestNReanchorSteps]]. + val acceptedAtK = scala.collection.mutable.Map.empty[String, Long] + val oldestTaken = scala.collection.mutable.Map.empty[String, A] + val exhausted = scala.collection.mutable.Set.empty[String] + + def step(topicFqn: String): Unit = + // Advance k until the answer is STRICTLY OLDER than the entry last taken from this topic, + // re-anchoring past anything appended since the previous step. An answer that is not + // older is the moving anchor, not exhaustion: the same id means one append shifted k back + // onto the entry just taken (the old code read that as exhausted and fell back to + // EARLIEST - the whole backlog for a "latest n"); a newer id means a burst arrived (the + // old code took it, walking the cursor forward off the contiguous suffix and + // double-counting). Only `None` and a VERIFIED clamp end the topic; a log still + // outrunning the walk at the re-anchor bound fails the resolution rather than answering + // with a set nobody asked for - see [[maxLatestNReanchorSteps]] for both trades. + var reanchors = 0 + var settled = false + while !settled do + checkBudget() + entryFromLatest(topicFqn)(nextK(topicFqn)) match + case None => + exhausted += topicFqn + settled = true + case Some(entry) => + val previous = previousEntryId.get(topicFqn) + if previous.forall(prev => entryIsOlder(entry.entryId, prev)) then + head(topicFqn) = entry + previousEntryId(topicFqn) = entry.entryId + acceptedAtK(topicFqn) = nextK(topicFqn) + nextK(topicFqn) = nextK(topicFqn) + 1 + settled = true + else + // The SAME id again is ambiguous - one append per round trip and a + // clamping broker look identical from here - so it is settled by one + // verification lookup at the k that produced the accepted entry: a + // clamped end never moves and answers the same entry (exhausted); a + // grown end answers a newer one (re-anchor). `None` there means the log + // was trimmed under the walk past even the accepted entry - nothing + // older is left to take. A NEWER id needs no verification: only growth + // produces it. + val verifiedClamp = + previous.contains(entry.entryId) && { + entryFromLatest(topicFqn)(acceptedAtK(topicFqn)) match + case Some(check) => previous.contains(check.entryId) + case None => true + } + if verifiedClamp then + exhausted += topicFqn + settled = true + else if reanchors >= maxLatestNReanchorSteps then + throw StartFromUnresolvableException( + s"Could not resolve the requested start-from position: 'Latest n messages' walked $topicFqn " + + s"backwards, but new messages kept arriving faster than the walk could step for " + + s"$maxLatestNReanchorSteps consecutive lookups. Retry when the topic is quieter, or use a " + + "time-based position or 'Skip first n messages' instead.", + null + ) + else + nextK(topicFqn) = nextK(topicFqn) + 1 + reanchors += 1 + + topics.foreach(step) + + var total = 0L + var lastTaken: Option[String] = None + // A max-heap of current heads keyed (publish time, topic), so selecting each entry costs + // O(log topics) instead of a scan of every topic per entry. A head changes only when its + // topic's entry is TAKEN (and the topic re-stepped), so the heap is pushed at exactly + // those points; a popped pair that no longer matches the live head map is stale and + // discarded. The tuple tie-break is the same one the scan used, so the cut is unchanged. + val selection = scala.collection.mutable.PriorityQueue.empty[(Long, String)] + head.foreach((topicFqn, entry) => selection.enqueue(entry.publishTime -> topicFqn)) + var walking = true + while walking do + var chosen: Option[String] = None + while chosen.isEmpty && selection.nonEmpty do + val (publishTime, topicFqn) = selection.dequeue() + if head.get(topicFqn).exists(_.publishTime == publishTime) then chosen = Some(topicFqn) + chosen match + case None => walking = false + case Some(topicFqn) => + val entry = head(topicFqn) + total += (entry.messagesInEntry max 1) + oldestTaken(topicFqn) = entry.entryId + lastTaken = Some(topicFqn) + head.remove(topicFqn) + if total >= n then walking = false + else + step(topicFqn) + head.get(topicFqn).foreach(next => selection.enqueue(next.publishTime -> topicFqn)) + + // The entry the walk STOPPED on may hold more messages than were still needed, and those + // are the OLDEST inside it. They cannot be seeked past - a seek lands on an entry boundary - + // so they are dropped from the head of that one topic's stream. + val overshoot = (total - n) max 0L + + topics.map { topicFqn => + topicFqn -> (oldestTaken.get(topicFqn) match + // Contributed nothing, but its tail WAS inspected: anchor there, not at seek-time + // latest. The walk already paid for this knowledge (the untaken head IS the + // inspected tail), and seeking to "latest" AT SEEK TIME silently jumped anything + // published between the inspection and the seek - a live message lost outright, + // where every contributing topic kept its concurrent appends. The anchor entry + // itself is delivered and dropped per topic (the same head-drop the overshoot + // uses), which is exactly "everything after the inspected tail". + case None => + head.get(topicFqn) match + case Some(tail) => LatestNSeek.FromEntry(tail.entryId, (tail.messagesInEntry max 1).toLong) + // EMPTY when inspected: everything the topic holds at seek time arrived + // after the inspection, so all of it is live traffic the session must + // show - which is what seeking EARLIEST delivers. "Latest" would race the + // same appends the anchor above exists to keep. + case None => LatestNSeek.Everything + case Some(entryId) if overshoot > 0 && lastTaken.contains(topicFqn) => LatestNSeek.FromEntry(entryId, overshoot) + case Some(_) if exhausted.contains(topicFqn) => LatestNSeek.Everything + case Some(entryId) => LatestNSeek.FromEntry(entryId, 0L)) + }.toMap + +/** Whether `topicFqn` names a topic that stores nothing. + * + * A non-persistent topic has no backlog, no history and no entry to address: messages go straight + * from producer to whoever is connected. `PulsarAdmin.topics.examineMessage` refuses one outright + * (HTTP 405, "Examine messages on a non-persistent topic is not allowed"). + * + * Decided from the FQN and NOT from that 405: the admin call sits inside a `Try(...).toOption`, so + * catching the refusal would turn a request the session cannot satisfy into a silent fallback to + * earliest or latest - the user asks for a position in history and gets a different one, with the + * session looking like it worked. The `Try` stays as a backstop; this is the mechanism. + * + * Only the scheme counts. "persistent://public/default/non-persistent-audit" is an ordinary + * persistent topic that happens to be named after the word. + */ +def isNonPersistentTopic(topicFqn: String): Boolean = topicFqn.startsWith("non-persistent://") + +/** Whether this start-from needs messages to still be stored somewhere. + * + * Everything except "latest message" does. `EarliestMessage` is deliberately in the needs-history + * set: a seek to earliest on a non-persistent topic does not fail, it silently behaves as "from + * now" - answering a request for the start of the topic with the live tail. + * + * The catch-all treats an unrecognised mode as needing a history, so a mode added later fails + * loudly on a non-persistent topic rather than degrading quietly. + */ +def startFromNeedsRetainedHistory(startFrom: ConsumerSessionStartFrom): Boolean = startFrom match + case _: LatestMessage => false + case _: EarliestMessage => true + case _: NthMessageAfterEarliest => true + case _: NthMessageBeforeLatest => true + case _: MessageId => true + case _: DateTime => true + case _: RelativeDateTime => true + // Both approximate modes are proportions OF A HISTORY, and both need the broker to say what + // that history is - the entry count for one, the first and last publish times for the other. + // `examineMessage` answers neither on a non-persistent topic (405), so neither can compute a + // position there. + case _: ApproximateDataPosition => true + case _: ApproximateTimePosition => true + case _ => true + +/** Why this start-from cannot be honoured on these topics, or `None` if it can. + * + * Rejects only when NOTHING in the resolved set retains anything. A session may legitimately mix + * persistent and non-persistent topics, and failing all of it because one topic is live-only would + * make history positions unusable on any such session - the mixed case is handled by seeking the + * persistent topics to the requested position and the rest to "now" (see [[handleStartFrom]]). + * + * An empty resolved set is not this function's problem: `ConsumerSessionRunner.make` already + * rejects a session that resolved to no topics, with a message that points at the target. + */ +def startFromRejectionReason(startFrom: ConsumerSessionStartFrom, topicFqns: Vector[String]): Option[String] = + val allLiveOnly = topicFqns.nonEmpty && topicFqns.forall(isNonPersistentTopic) + Option.when(allLiveOnly && startFromNeedsRetainedHistory(startFrom)) { + val named = topicFqns.take(3).mkString(", ") + val andMore = if topicFqns.size > 3 then s" and ${topicFqns.size - 3} more" else "" + s"Start-from ${startFrom.getClass.getSimpleName} needs a retained message history, but every topic this session resolved to is " + + s"non-persistent ($named$andMore). A non-persistent topic stores no messages, so only LatestMessage can be used on one." + } + +/** Why this start-from's COUNT cannot be honoured, or `None` if it can. + * + * The count arrives over gRPC as a plain `int64` that any client can fill in with anything. The + * browser validates it too, so this is the TRUST BOUNDARY rather than the only guard - and a + * boundary refuses rather than clamps, because a clamp answers a different question in silence: + * "skip the first -1 messages" was read as EARLIEST (the whole topic) and "the latest -1 messages" + * as LATEST (nothing retained at all). + * + * SKIP-N HAS DELIBERATELY NO UPPER BOUND. Skipping n messages is O(n) whatever n is - Pulsar keeps + * no message-ordinal index, so the only exact way to reach message n is to stream n and throw them + * away - and the start-from progress API exists precisely so a long skip can be watched rather + * than forbidden. Any cap here would be an invented number, not a limit of the design. + * + * LATEST-N HAS ONE, and it is an OPERATIONAL bound, stated as such. The walk behind latest-n + * costs one broker lookup per entry, synchronously, while session creation holds the per-name + * lifecycle lock and reports no progress. The old boundary (Int.MaxValue, a leftover of a heap + * implementation that no longer exists) still admitted a request the server would grind on for + * hours; [[latestNMaxAccepted]] is the honest ceiling - at worst tens of thousands of entry + * lookups even on unbatched topics, i.e. minutes not hours - and the rejection says what to use + * instead. It is never NARROWED: narrowing served a different request without saying so. + * + * PURE, so every boundary is pinned by test. + */ +/** The most a 'Latest n messages' request may ask for. + * + * A sanity bound on N, NOT the cost bound: the walk pays roughly one broker lookup per ENTRY, so + * this many messages is thousands of lookups on a well-batched topic and ten million on an + * unbatched one - which no interactive request survives. The honest cost bound is + * [[latestNResolveBudgetMs]]: a walk that cannot finish in time fails with guidance, whatever N + * was. This ceiling stays to refuse the absurd outright (and to give the UI a number to mirror - + * see `latestMessageCountMax` in the frontend, pinned to this by test on both sides). */ +val latestNMaxAccepted: Long = 10_000_000L + +/** THE DUPLICATE-TARGET CONTRACT for the counted modes, stated once, and it is MODE-SPECIFIC. + * + * "Latest n": the count is per SESSION across its unique TOPICS, and when two enabled targets + * select the SAME topic each target delivers its own counted set through its own subscription. + * Two targets on one 10-message topic with "latest 3" therefore show three rows EACH (six in + * total, tagged with their target), not three split between them: a second target exists + * precisely to show a second view - its own filters, its own coloring - and starving one view to + * feed the other would make either target's output depend on the mere existence of the other. + * The metadata walk is still memoised per topic, so the broker is asked once however many + * targets share it. + * + * "Skip first n" REFUSES overlapping targets instead (see [[skipOverlapRejectionReason]]): its + * count is spent by ONE session-wide budget over the merged delivered stream, so with two + * subscriptions on one topic the budget would be spent on COPIES - a message dropped through one + * target while the other still shows it, in whichever interleaving the brokers produced. Until a + * per-source-message semantics exists (decide each unique message once, apply the decision to + * every view), refusing loudly is the only answer that means something. + */ +def startFromCountRejectionReason(startFrom: ConsumerSessionStartFrom): Option[String] = startFrom match + case v: NthMessageAfterEarliest if v.n < 0 => + Some(s"Start-from 'Skip first n messages' needs n to be zero or more, but it was ${v.n}.") + case v: NthMessageBeforeLatest if v.n < 0 => + Some(s"Start-from 'Latest n messages' needs n to be zero or more, but it was ${v.n}.") + case v: NthMessageBeforeLatest if v.n > latestNMaxAccepted => + Some( + s"Start-from 'Latest n messages' accepts at most $latestNMaxAccepted, but ${v.n} were asked for. " + + "The last n are located by walking entry metadata backwards, one broker lookup per entry, while session " + + "creation waits with no progress to show - a larger n would grind for hours. " + + "Use 'Skip first n messages' (which streams and reports progress) or a time position to reach further back." + ) + case _ => None + +/** Why 'Latest n messages' cannot be honoured against read-compacted targets, or `None`. + * + * Compacted reading changes WHAT IS VISIBLE - only the newest message per key survives before + * the compaction horizon - while the walk counts raw STORED entries. The two disagree whenever + * a key repeats, so the exact-count promise cannot be kept over a view the walk cannot see. + * Skip-n is fine on the same target: it counts what is actually DELIVERED. */ +def latestNReadCompactedRejectionReason(startFrom: ConsumerSessionStartFrom, readCompactedTargetIndexes: Vector[Int]): Option[String] = + startFrom match + case v: NthMessageBeforeLatest if v.n > 0 && readCompactedTargetIndexes.nonEmpty => + Some( + s"Start-from 'Latest n messages' counts STORED entries, but target(s) ${readCompactedTargetIndexes.sorted.mkString(", ")} " + + "read compacted - only the newest message per key is visible there, so the stored count and the visible count " + + "disagree whenever a key repeats. Use a time-based position or 'Latest message', or turn off compacted reading." + ) + case _ => None + +/** Why a counted SKIP cannot run over these enabled targets' topic sets, or `None`. + * + * See the duplicate-target contract above: skip-n's budget is session-wide over the merged + * stream, so two subscriptions on one physical topic spend it on COPIES and neither target's + * output means "everything after the first n". Refused at creation, before any consumer exists. */ +def skipOverlapRejectionReason(startFrom: ConsumerSessionStartFrom, topicsPerEnabledTarget: Vector[Vector[String]]): Option[String] = + startFrom match + case v: NthMessageAfterEarliest if v.n > 0 => + val seenBy = topicsPerEnabledTarget.flatMap(_.distinct).groupBy(identity).view.mapValues(_.size) + val overlapping = seenBy.collect { case (topicFqn, targets) if targets > 1 => topicFqn }.toVector.sorted + Option.when(overlapping.nonEmpty) { + val named = overlapping.take(3).mkString(", ") + val andMore = if overlapping.size > 3 then s" and ${overlapping.size - 3} more" else "" + s"Start-from 'Skip first n messages' cannot run while two enabled targets select the same topic ($named$andMore): " + + "the skip counts the session's merged stream once, so it would drop a message through one target while the " + + "other still shows it. Disable one of the overlapping targets or give them disjoint topics." + } + case _ => None + +/** Why a resolved latest-n cut can no longer be applied, or `None`: an anchor entry the walk + * counted was REMOVED by retention in the gap between resolving and seeking. Seeking to a + * trimmed anchor silently lands past it, and the session then holds fewer than the n it reported + * it would - a wrong answer delivered as success. One `earliestEntry` lookup per contributing + * topic; a topic that now retains NOTHING has lost its anchor by definition. */ +def latestNAnchorRejectionReason[A]( + cut: Map[String, LatestNSeek[A]], + earliestEntry: String => Option[A], + entryIsOlder: (A, A) => Boolean +): Option[String] = + cut.toVector.sortBy(_._1).collectFirst { + case (topicFqn, LatestNSeek.FromEntry(anchor, _)) + if earliestEntry(topicFqn).map(first => entryIsOlder(anchor, first)).getOrElse(true) => + s"Could not apply the requested start-from position: retention removed the resolved anchor entry on $topicFqn " + + "between resolving 'Latest n messages' and seeking to it, so the session would silently hold fewer than the " + + "n that was asked for. Retry; if it keeps happening, the topic's retention is shorter than the time it takes " + + "to position against it." + } + +/** Split by whether the topic behind each item retains anything: `(has a history, live tail only)`. */ +def splitByRetainedHistory[A](items: Vector[A], topicOf: A => String): (Vector[A], Vector[A]) = + items.partition(item => !isNonPersistentTopic(topicOf(item))) + +/** Where an [[ApproximateDataPosition]] lands on ONE physical topic. */ +enum ApproximateDataSeek: + /** The very beginning of the retained log. */ + case Earliest + + /** Past the last retained message - what "latest" means, i.e. nothing retained is shown. */ + case Latest + + /** The 1-based ENTRY ordinal counted from the earliest retained entry, as + * `examineMessage(topic, "earliest", entryOrdinal)` addresses it. + * + * NOT named `ordinal`: every Scala 3 enum case already has an `ordinal` member. + */ + case Entry(entryOrdinal: Long) + +/** Reject a fraction that is not one. Shared by both approximate modes, and `what` names the mode + * that refused it - two modes now carry a fraction, and a session can only be fixed if the error + * says which control was wrong. + * + * NaN needs its own check: every comparison against it is false, so a plain range test would let it + * through and it would then floor into an ordinal of 1 (data) or an epoch millisecond of 0 (time). + */ +private def requireFraction(fraction: Double, what: String): Unit = + if fraction.isNaN then throw new IllegalArgumentException(s"Start-from $what must be a fraction between 0.0 and 1.0, but it was NaN.") + if fraction < 0.0 || fraction > 1.0 then + throw new IllegalArgumentException(s"Start-from $what must be a fraction between 0.0 and 1.0, but it was $fraction.") + +/** Resolve "start approximately `fraction` of the way through the DATA this topic still holds" into + * a position. + * + * THE ROUNDING RULE: leave `floor(fraction * numberOfEntries)` entries behind, so the 1-based entry + * to seek to is that plus one, clamped into `[1, numberOfEntries]`. Rounding DOWN means the + * position never overshoots the proportion asked for - 0.5 of 100 entries leaves exactly 50 behind + * and starts on entry 51 - and it keeps the mapping monotonic in the fraction. + * + * THE ENDPOINTS ARE EXACT, and are not entry ordinals at all: 0.0 is `MessageId.earliest` and 1.0 + * is `MessageId.latest`, so they behave exactly as the "Earliest message" and "Latest message" + * modes, including 1.0 showing nothing retained and only what arrives from now on. + * + * AN EMPTY TOPIC resolves to Earliest for any interior fraction: there is no backlog to be a + * proportion of, and `examineMessage` FAILS on a topic with no entries ("Could not examine messages + * due to the total message is zero") rather than answering, so an ordinal must never be asked for. + * The session then behaves as "Earliest message" does on an empty topic - it shows what gets + * published from now on. 1.0 still means Latest, so the endpoint contract holds even there. + * + * ENTRY GRANULARITY, not message granularity - this is WHY the mode is called approximate. Pulsar + * has no message-ordinal index unless the operator enables brokerEntryMetadataInterceptors (empty + * by default), so the only position that resolves in constant time at any topic size is an entry + * ordinal. With batching (the Java producer default) one entry holds many messages, so the position + * lands on the START of the entry that covers the requested point - VERIFIED against Pulsar 3.2.1: + * 100 messages in 10 entries of 10, fraction 0.5 -> entry 6 -> first delivered b-51, fraction 0.25 + * -> entry 3 -> first delivered b-21 (the exact message-proportional point would have been b-26). + * The fraction is taken over STORED ENTRIES; it diverges from the same fraction over messages + * exactly as far as batch sizes varied over the topic's lifetime. + * + * PURE: `numberOfEntries` is a plain argument, so every log size and every rejected input is + * testable without a broker. + * + * @throws IllegalArgumentException + * for NaN, an infinity, or a fraction outside [0.0, 1.0] - see [[requireFraction]]. + */ +def resolveApproximateDataPosition(fraction: Double, numberOfEntries: Long): ApproximateDataSeek = + requireFraction(fraction, "approximate data position") + + if fraction <= 0.0 then ApproximateDataSeek.Earliest + else if fraction >= 1.0 then ApproximateDataSeek.Latest + else if numberOfEntries <= 0 then ApproximateDataSeek.Earliest + else + // No clamp needed, and none added: the guards above leave 0 < fraction < 1 and + // numberOfEntries > 0, so floor(fraction * numberOfEntries) is between 0 and + // numberOfEntries - 1 and the ordinal lands inside [1, numberOfEntries] on its own. The + // range is pinned by test instead - a clamp here would have hidden a rounding change rather + // than caught it. + val entriesToLeaveBehind = math.floor(fraction * numberOfEntries).toLong + ApproximateDataSeek.Entry(entriesToLeaveBehind + 1) + +/** How many entries `topicFqn` still holds. Entry-addressed, and answered from the managed ledger's + * own counters, so it costs the same on a topic of ten messages and on one of ten billion. + */ +def retainedEntryCount(adminClient: PulsarAdmin, topicFqn: String): Long = + adminClient.topics.getInternalStats(topicFqn).numberOfEntries + +/** The k-th ENTRY counted from the START of `topicFqn` (k = 1 is the first retained entry), as an + * entry-addressed id to seek to. + * + * `None` when the BROKER SAYS there is nothing there - an empty topic, which fails rather than + * answering. A broker that could not answer at all throws instead: the caller falls back to + * EARLIEST on `None`, which is honest for an entry that has aged out from under a stale entry + * count and dishonest for a transient 500. Counting from "earliest" past the END does not fail: it + * silently CLAMPS to the last entry, which is why the caller must never hand it an ordinal larger + * than the entry count. + */ +def entryFromEarliest(adminClient: PulsarAdmin, topicFqn: String)(entryOrdinal: Long): Option[PulsarMessageId] = + brokerAnswer(s"examining entry $entryOrdinal counted from the start", topicFqn)( + adminClient.topics.examineMessage(topicFqn, "earliest", entryOrdinal) + ).map(message => entryIdOf(message.getMessageId)) + +/** The publish times of the FIRST and LAST messages one physical topic still holds. */ +final case class TopicTimeSpan(firstPublishTimeMs: Long, lastPublishTimeMs: Long) + +/** Where an [[ApproximateTimePosition]] lands. Unlike [[ApproximateDataSeek]] this is one answer for + * a whole LOGICAL topic: every partition is seeked to the same instant. + */ +enum ApproximateTimeSeek: + /** The very beginning of the retained log. */ + case Earliest + + /** The first message published at or after this epoch millisecond, i.e. an ordinary + * `Consumer.seek(timestamp)` - the same broker path the "Specific time" mode uses. + */ + case Timestamp(publishTimeMs: Long) + +/** Resolve "start approximately `fraction` of the way through the TIME RANGE this topic still + * covers" into a position for the WHOLE logical topic. + * + * THE RANGE is `min(first publish time)` .. `max(last publish time)` taken across every partition + * of the topic, and the cutoff is `earliest + floor(fraction * (latest - earliest))`. Every + * partition is then seeked to that one instant. + * + * WHY MIN/MAX AND NOT A PER-PARTITION QUANTILE. Min/max makes the endpoints exact by construction, + * makes the mapping monotonic in the fraction, and makes the answer independent of how many + * partitions the topic has - the same range split three ways or six ways resolves identically. It + * also avoids the defect the DATA mode has to special-case: an idle partition given its own + * proportional cutoff hands back messages from the far past, whereas here it simply has nothing at + * or after the topic-wide instant. + * + * THE ENDPOINTS ARE EXACT AND ARE NOT INTERPOLATED. 0.0 is `MessageId.earliest` - the same position + * "Earliest message" takes, and the one reading that stays right even if the recorded times are + * already stale. 1.0 is the publish time of the LAST message, so 100% still shows something; that + * is deliberately unlike the data mode, whose 1.0 means "past the end". + * + * AN EMPTY TOPIC - one no partition can answer for - resolves to Earliest at EVERY fraction, + * including 1.0. `examineMessage` FAILS on a topic with no entries rather than answering, so there + * is no range and no last message to be exact about; on a topic holding nothing "the beginning" and + * "the end" are the same position, namely whatever arrives next. Partitions that individually hold + * nothing are SKIPPED rather than counted as time zero - counting them would drag `earliest` back + * to 1970 and put every interior fraction before the real data. + * + * A TOPIC THAT OCCUPIES ONE INSTANT (first == last, i.e. everything was published inside the same + * millisecond) has no interior to interpolate into, so every interior fraction resolves to + * Earliest. There is no division anywhere here - the fraction multiplies the span - so this is a + * definition rather than a guard against a divide-by-zero: every message shares the one instant, no + * position separates them, and "all of it" is the only honest answer. 1.0 still answers with that + * instant, which delivers the same set. The same rule covers a range reported BACKWARDS, which a + * producer clock that jumped can produce, since publish time is stamped by the producer. + * + * MILLISECOND GRANULARITY: a timestamp seek cannot separate messages published in the same + * millisecond, so the cutoff always lands on a whole millisecond boundary and takes everything + * stamped with it. + * + * PURE: the broker sits behind `timeSpanOf`, so every arrangement - balanced partitions, uneven + * ones, an idle one, an empty topic, both endpoints - is testable with a plain lambda. Cost in + * production is two admin calls per partition, both O(1) in the size of the topic, and ZERO for + * fraction 0.0, which needs no range at all. + * + * @throws IllegalArgumentException + * for NaN, an infinity, or a fraction outside [0.0, 1.0] - see [[requireFraction]]. + */ +def resolveApproximateTimePosition( + fraction: Double, + partitionFqns: Vector[String], + timeSpanOf: String => Option[TopicTimeSpan] +): ApproximateTimeSeek = + requireFraction(fraction, "approximate time position") + + if fraction <= 0.0 then ApproximateTimeSeek.Earliest + else + val spans = partitionFqns.distinct.flatMap(timeSpanOf) + if spans.isEmpty then ApproximateTimeSeek.Earliest + else + val earliest = spans.map(_.firstPublishTimeMs).min + val latest = spans.map(_.lastPublishTimeMs).max + if fraction >= 1.0 then ApproximateTimeSeek.Timestamp(latest) + else if latest <= earliest then ApproximateTimeSeek.Earliest + else ApproximateTimeSeek.Timestamp(earliest + math.floor(fraction * (latest - earliest)).toLong) + +/** The publish times of the first and last messages `topicFqn` still holds, or `None` when the + * BROKER SAYS it holds nothing - an empty topic, where `examineMessage` fails rather than + * answering. A non-persistent one (405) is ruled out by the caller before this is reached. + * + * A broker that could not answer THROWS rather than answering `None`. The range this feeds is + * min(first) .. max(last) across every partition, and a partition silently dropped out of it + * produced a confident cutoff over a narrower range - a different position, reported as success. + * + * `examineMessage(topic, "latest", 1)` addresses the last ENTRY, and every message of a producer + * batch carries that entry's publish time, so the entry's time IS the last message's time. + */ +def publishTimeSpan(adminClient: PulsarAdmin, topicFqn: String): Option[TopicTimeSpan] = + for + first <- brokerAnswer("reading the first retained message", topicFqn)(adminClient.topics.examineMessage(topicFqn, "earliest", 1L)) + last <- brokerAnswer("reading the last retained message", topicFqn)(adminClient.topics.examineMessage(topicFqn, "latest", 1L)) + yield TopicTimeSpan(first.getPublishTime, last.getPublishTime) + +/** The LOGICAL topic a physical one belongs to: a partition's parent topic, or the topic itself. + * + * Only the `-partition-N` suffix Pulsar itself mints is stripped. A non-partitioned topic literally + * named `orders-partition-3` would be grouped under `orders` - the same ambiguity Pulsar's own + * `TopicName` carries, and the reason the broker refuses to create such a name. + */ +def logicalTopicOf(topicFqn: String): String = + val partitionOf = """^(.*)-partition-\d+$""".r + topicFqn match + case partitionOf(parent) => parent + case _ => topicFqn def getIsSingleNonPartitionedTopic(adminClient: PulsarAdmin, topics: Vector[String]): Boolean = - topics.size == 1 && getTopicPartitioning(adminClient, topics.head) == TopicPartitioningType.NonPartitioned + // `.type` is essential: getTopicPartitioning returns a TopicPartitioning record, and comparing + // the whole record to a TopicPartitioningType was ALWAYS false - which silently disabled the + // single-topic fast path, so every start-from seeked by publishTime instead of by message id. + topics.size == 1 && getTopicPartitioning(adminClient, topics.head).`type` == TopicPartitioningType.NonPartitioned + +/** Resolve a "N units ago" start position against a supplied `now`. + * + * Split out of the seek path (and given an explicit `now`) so all unit x rounding combinations are + * testable with a frozen clock. `ZonedDateTime.truncatedTo` REJECTS units larger than a day, so + * Week/Month/Year must be rounded with date adjusters - passing them to truncatedTo threw + * UnsupportedTemporalTypeException, i.e. "1 month ago, rounded to the start of the month" - an + * ordinary UI selection - failed the whole session with a generic error. + */ +def resolveRelativeDateTime(v: RelativeDateTime, now: ZonedDateTime): ZonedDateTime = + import java.time.temporal.ChronoUnit + v.unit match + case DateTimeUnit.Year => + val dt = now.minusYears(v.value) + if v.isRoundedToUnitStart then dt.truncatedTo(ChronoUnit.DAYS).withDayOfYear(1) else dt + case DateTimeUnit.Month => + val dt = now.minusMonths(v.value) + if v.isRoundedToUnitStart then dt.truncatedTo(ChronoUnit.DAYS).withDayOfMonth(1) else dt + case DateTimeUnit.Week => + val dt = now.minusWeeks(v.value) + if v.isRoundedToUnitStart then + dt.truncatedTo(ChronoUnit.DAYS).`with`(java.time.temporal.TemporalAdjusters.previousOrSame(java.time.DayOfWeek.MONDAY)) + else dt + case DateTimeUnit.Day => + val dt = now.minusDays(v.value) + if v.isRoundedToUnitStart then dt.truncatedTo(ChronoUnit.DAYS) else dt + case DateTimeUnit.Hour => + val dt = now.minusHours(v.value) + if v.isRoundedToUnitStart then dt.truncatedTo(ChronoUnit.HOURS) else dt + case DateTimeUnit.Minute => + val dt = now.minusMinutes(v.value) + if v.isRoundedToUnitStart then dt.truncatedTo(ChronoUnit.MINUTES) else dt + case DateTimeUnit.Second => + val dt = now.minusSeconds(v.value) + if v.isRoundedToUnitStart then dt.truncatedTo(ChronoUnit.SECONDS) else dt +/** The message `messageId` names in `topicFqn`, or `None` if that topic genuinely does not hold it. + * + * `None` MEANS "NOT THERE", AND NOTHING ELSE. Every operational failure used to collapse into the + * same `None` - a message id that could not be parsed at all, a reader the broker refused to + * create, an unreachable topic, a read that timed out - and the caller then reported "Message with + * such ID not found", which is a diagnosis of the user's input for what was a fault in the server + * or the broker. Worse, on a multi-topic session it made an unreachable topic indistinguishable + * from one that simply does not hold the id, so the session could be positioned from whichever + * topics happened to answer. + * + * A malformed id is an INVALID ARGUMENT (the client sent something that is not a message id at + * all); everything else that prevents an answer is a [[StartFromUnresolvableException]]. + */ def getMessageById(pulsarClient: PulsarClient, topicFqn: String, messageId: Array[Byte]): Option[PulsarMessage[Array[Byte]]] = val subscriptionName = s"dekaf_${java.util.UUID.randomUUID.toString}" val resolvedMessageId = Try(PulsarMessageId.fromByteArrayWithTopic(messageId, topicFqn)) match case Success(messageId) => messageId - case Failure(_) => - return None + case Failure(err) => + throw new IllegalArgumentException( + s"Start-from message id could not be read: it is not a Pulsar message id (${messageId.length} bytes). ${err.getMessage}", + err + ) + + def unresolvable(what: String, err: Throwable): Nothing = + throw StartFromUnresolvableException( + s"Could not resolve the requested start-from position: $what failed for $topicFqn. ${err.getMessage}", + err + ) val reader = Try { pulsarClient @@ -70,21 +849,62 @@ def getMessageById(pulsarClient: PulsarClient, topicFqn: String, messageId: Arra .subscriptionName(subscriptionName) .create() } match - case Success(reader) => reader - case Failure(err) => - logger.error(s"Failed to create reader for topic $topicFqn", err) - return None + case Success(reader) => reader + case Failure(err) => unresolvable("opening a reader at the requested message id", err) try - if reader.hasMessageAvailable then - val message = reader.readNext(5, java.util.concurrent.TimeUnit.SECONDS) - if message.getMessageId.toByteArray sameElements messageId then Some(message) - else None - else None - catch { - case _: Throwable => None - } finally - reader.close() + val hasMessage = Try(reader.hasMessageAvailable) match + case Success(available) => available + case Failure(err) => unresolvable("asking whether the requested message id is still retained", err) + + if !hasMessage then None + else + Try(reader.readNext(5, java.util.concurrent.TimeUnit.SECONDS)) match + // `readNext` answers with null when the wait ran out. The broker said the message + // was there and then did not hand it over, which is a fault and not an absence. + case Success(null) => unresolvable("reading the message at the requested message id", new java.util.concurrent.TimeoutException("the read timed out after 5s")) + case Success(message) => Option.when(message.getMessageId.toByteArray sameElements messageId)(message) + case Failure(err) => unresolvable("reading the message at the requested message id", err) + finally Try(reader.close()) + +/** How ONE consumer is positioned for a Message-ID start-from, once the message has been found. */ +enum MessageIdSeek: + /** The exact message. Only the topic that OWNS the id can be positioned this way - a Pulsar + * message id addresses a ledger and entry of one topic and means nothing in another. */ + case ById(messageId: PulsarMessageId) + + /** The message's publish INSTANT - the only position that exists across topics, and only + * approximately the same place: everything published in that same millisecond is included. */ + case ByPublishTime(atMs: Long) + +/** Where each of a session's consumers starts, for a start-from that names one message id. + * + * THE OWNING TOPIC IS SEEKED BY ID, EXACTLY. It used to be seeked by publish time along with every + * other topic as soon as the session covered more than one, so "start from this message" silently + * became "start from this millisecond" even on the topic the user picked the message from - and any + * earlier message sharing that millisecond, or sharing its producer batch, came with it. + * + * EVERY OTHER TOPIC IS SEEKED BY PUBLISH TIME, and that is a real approximation rather than an + * oversight: a message id is unique only within one topic, so there is no exact corresponding + * position in the others. The instant is the closest thing that exists, and it is inclusive of + * everything stamped with the same millisecond. + * + * PURE, so both halves are pinned without a broker. Two targets on the owning topic each have their + * own consumer and both get the exact id. + */ +def messageIdSeeks[C]( + consumers: Vector[C], + topicOf: C => String, + ownerTopicFqn: String, + messageId: PulsarMessageId, + publishTime: Long +): Vector[(C, MessageIdSeek)] = + consumers.map { consumer => + val seek = + if topicOf(consumer) == ownerTopicFqn then MessageIdSeek.ById(messageId) + else MessageIdSeek.ByPublishTime(publishTime) + consumer -> seek + } def topicsToNonPartitionedTopic(pulsarAdmin: PulsarAdmin, topics: Vector[String]) = topics.flatMap { topicFqn => @@ -93,95 +913,285 @@ def topicsToNonPartitionedTopic(pulsarAdmin: PulsarAdmin, topics: Vector[String] case TopicPartitioningType.Partitioned => getPartitions(pulsarAdmin, topicFqn) } +/** The one message a Message-ID start-from resolves to, looked up across the physical topics the + * session covers. + * + * DISTINCT, and that is the whole point: a session's topic vector is the CONCATENATION of every + * enabled target's resolved topics, and two targets may legitimately select the same topic - each + * has its own consumer, filters and colouring. Looking the id up once per NAME rather than once + * per physical topic read the same message twice and refused a valid session with "Multiple + * messages found for the same message id". Every target's consumer is still seeked; only the + * LOOKUP is deduplicated. + * + * Two GENUINELY different topics answering is still refused: a message id is only unique within + * one topic, so there is no way to know which of them the user meant. + * + * PURE: the broker sits behind `lookup`, so the shapes that matter - nothing found, one found, the + * same physical topic named twice, two genuinely different topics answering - are all testable + * with a plain lambda. + */ +def resolveMessageIdAcrossTopics[M](topicFqns: Vector[String], lookup: String => Option[M]): Option[M] = + topicFqns.distinct.flatMap(topicFqn => lookup(topicFqn)) match + case Vector() => None + case Vector(msg) => Some(msg) + case _ => throw new RuntimeException("Multiple messages found for the same message id") + +/** The one message the id names, together with the topic that OWNS it - which is what lets that + * topic be seeked exactly while the rest are seeked by publish time (see [[messageIdSeeks]]). */ def getMessageByIdMultiTopic( pulsarAdmin: PulsarAdmin, pulsarClient: PulsarClient, nonPartitionedTopicFqns: Vector[String], messageId: Array[Byte] -): Option[PulsarMessage[Array[Byte]]] = - val messageIds = nonPartitionedTopicFqns.flatMap(topicFqn => getMessageById(pulsarClient, topicFqn, messageId)) - - messageIds match - case Vector() => None - case Vector(msg) => Some(msg) - case _ => throw new RuntimeException("Multiple messages found for the same message id") +): Option[(String, PulsarMessage[Array[Byte]])] = + resolveMessageIdAcrossTopics( + nonPartitionedTopicFqns, + topicFqn => getMessageById(pulsarClient, topicFqn, messageId).map(message => topicFqn -> message) + ) +/** Seek every consumer of a session to its start position, and report what the seek could not + * express exactly: messages to discard from the head of a stream, and the global reordering the two + * counting modes need on top. + * + * The two counting modes are EXACT under batching and across partitions, and both are GLOBAL: they + * count the session's whole merged stream, ordered by publish time. Neither can be done with a seek + * alone, because a seek only ever lands on an entry boundary - see [[StartFromDiscardPlan]]. + * + * "EXACT" IS ABOUT THE COUNT WITHOUT QUALIFICATION, and about WHICH messages only as far as the + * logs really are in publish-time order: publish time is stamped by the producer, and Pulsar + * preserves append order within a partition rather than clock order. [[MessageOrderKey]] states + * precisely what is and is not guaranteed, including that delivery SEQUENCE after a skip's cut is + * the brokers' order rather than the global one. + * + * - "Skip first n messages" ([[NthMessageAfterEarliest]]): seek everything to the very beginning, + * then drop the GLOBALLY-FIRST n by publish time across every physical topic and deliver the + * rest. On a single log that is exactly "start at message n+1" and costs nothing but a head-drop + * counter. Across partitions it is a streaming k-way merge over one held message per topic + * ([[GlobalSkipMerge]]) - the count is exactly n, the choice of WHICH n is exact as far as the + * logs are in publish-time order, and n is never buffered, because "skip first n" has + * deliberately no cap. + * + * - "Latest n messages" ([[NthMessageBeforeLatest]]): ONE MERGED BACKWARD WALK over every + * physical topic's entry metadata ([[resolveLatestN]]) - take whichever topic's current entry + * was published latest, count its messages, step that topic back one entry, until n messages + * are accounted for. Each topic is then seeked to the oldest entry the walk took from it (or to + * LATEST if it contributed none), and the overshoot inside the single entry the walk stopped on + * is dropped from that topic's head. NOTHING IS BUFFERED and no delivered message takes part in + * the decision: the cut is known before the consumers are resumed. Memory is O(number of + * topics) and cost is O(n / batch size + topics) admin lookups. A session over p partitions + * shows exactly n messages and not n * p, and it starts showing them immediately rather than + * waiting for every partition to drain. + * + * - "About % through the data" ([[ApproximateDataPosition]]): PER PHYSICAL TOPIC, and deliberately + * stays that way. The fraction is resolved against that topic's own entry count and seeked to, + * so a session 60% of the way into a 4-partition topic is 60% into each of the four logs, and a + * session over several enabled targets is 60% into every topic they resolve to. That is not the + * same position as "60% of the merged stream" unless the logs are the same size. Unlike the two + * counting modes it cannot be made global cheaply: a count of n can be reached by streaming n + * messages and stopping, whereas a FRACTION of the merged stream is only known once the whole of + * it has been measured, which is O(topic) at any n. Rounding, endpoints, why the position is + * only approximate, and empty topics: see [[resolveApproximateDataPosition]]. + * + * - "About % through the time range" ([[ApproximateTimePosition]]): PER LOGICAL TOPIC. The topic's + * partitions are pooled into one publish-time range - the earliest first message to the latest + * last message - and every partition is seeked to the single instant that fraction picks out of + * it. Still per topic and not per session: two enabled targets position against their own + * backlogs, not against a merged view. The seek is BY TIMESTAMP, so it rides the same broker + * path as [[DateTime]] and needs nothing new from the consumer. Endpoints, rounding, empty + * topics and a topic that occupies one instant: see [[resolveApproximateTimePosition]]. + * + * The two approximate modes answer different questions and that is the whole reason they are + * two: on a topic where almost everything arrived in the last hour of a 30-day retention, half + * the MESSAGES are behind you inside that last hour, while half the TIME is behind you fifteen + * days back. + * + * NON-PERSISTENT TOPICS keep nothing, so no history position exists on one. If EVERY topic the + * session resolved to is non-persistent, a history mode is REJECTED up front (see + * [[startFromRejectionReason]]) rather than degrading into a seek that silently means "from now". + * A MIXED session is not rejected: the persistent topics get the position that was asked for and + * the non-persistent ones are seeked to latest, which is the only position they have. One wrinkle + * follows from that - "skip first n" counts the MERGED DELIVERED stream, so on a mixed session + * anything the non-persistent topics deliver live counts towards its n as well. Such a topic holds + * no backlog, so it never holds the merge up waiting for a head it will not produce. "Latest n" + * counts stored entries instead of delivered messages, so a non-persistent topic contributes + * nothing to its n and simply streams alongside the historical tail. + */ def handleStartFrom( startFrom: ConsumerSessionStartFrom, consumers: Vector[Consumer[Array[Byte]]], adminClient: PulsarAdmin, pulsarClient: PulsarClient, nonPartitionedTopicFqns: Vector[String] -): Unit = - consumers.foreach(_.resume()) +): StartFromPlan = + // Before anything is resumed or seeked: a position that cannot exist on these topics is a + // validation error, not something to discover halfway through seeking them. + startFromRejectionReason(startFrom, nonPartitionedTopicFqns).foreach(reason => throw new IllegalArgumentException(reason)) + startFromCountRejectionReason(startFrom).foreach(reason => throw new IllegalArgumentException(reason)) - startFrom match + // The consumers STAY PAUSED for all of this. They used to be resumed here, which opened a + // delivery window over every broker round trip below - the backward entry walk, reading each + // topic's last message id - while the session was still being built: its counters and ordering + // layer are armed by the caller AFTER this returns, and its message handler is still a no-op. + // Whatever arrived in that window was therefore consumed by nobody, and a session could swallow + // its own backlog and then deliver nothing. Seeking does not need a running consumer; pausing + // only withholds flow permits. + + // Everything below positions the topics that HAVE a history. A non-persistent topic can only + // start from now, whatever the session asked for. + val (historyConsumers, liveOnlyConsumers) = splitByRetainedHistory(consumers, _.getTopic) + // DISTINCT: two enabled targets may select the same topic, and the session's FQN vector is the + // concatenation of what each resolved to. The CONSUMERS are deliberately not deduplicated - + // every one of them still has to be seeked - but a topic that is asked ABOUT twice pushed the + // single-topic fast path off (`size == 1`) and made the Message-ID lookup read one physical + // message once per name. + val historyTopicFqns = nonPartitionedTopicFqns.filterNot(isNonPersistentTopic).distinct + liveOnlyConsumers.foreach(_.seek(PulsarMessageId.latest)) + + // Resolving the streams costs one broker call per topic, so the gate is checked FIRST and the + // layer is only built for a session that will actually use one. + val isGlobal = needsGlobalOrdering(startFrom, consumers.size) + lazy val globalOrdering: StartFromOrderingPlan = + if isGlobal then globalOrderingPlanFor(startFrom, startFromStreamsAt(consumers)) else StartFromOrderingPlan.PassThrough + + val plan: StartFromPlan = startFrom match case _: EarliestMessage => - consumers.foreach(_.seek(PulsarMessageId.earliest)) + historyConsumers.foreach(_.seek(PulsarMessageId.earliest)) + StartFromPlan(StartFromDiscardPlan.Nothing, StartFromOrderingPlan.PassThrough) case _: LatestMessage => - consumers.foreach(_.seek(PulsarMessageId.latest)) + historyConsumers.foreach(_.seek(PulsarMessageId.latest)) + StartFromPlan(StartFromDiscardPlan.Nothing, StartFromOrderingPlan.PassThrough) case v: NthMessageAfterEarliest => - val n = v.n - if getIsSingleNonPartitionedTopic(adminClient, nonPartitionedTopicFqns) then - findNthMessage(adminClient, nonPartitionedTopicFqns.head, "earliest", n) match - case Some(message) => consumers.foreach(_.seek(message.getMessageId)) - case None => consumers.foreach(_.seek(PulsarMessageId.latest)) - else - findNthMessageMultiTopic(adminClient, nonPartitionedTopicFqns, "earliest", n) match - case Some(message) => consumers.foreach(_.seek(message.getPublishTime)) - case None => consumers.foreach(_.seek(PulsarMessageId.latest)) + // Seek + discard, NOT examineMessage. examineMessage is ENTRY-addressed and clamps past + // the end without failing, so "skip 5" on a topic whose 100 messages are one batched + // entry used to ask for entry 6, get the last entry, and skip fifty. Streaming n + // messages and throwing them away is O(n) - and n is a number the user typed. + historyConsumers.foreach(_.seek(PulsarMessageId.earliest)) + if isGlobal then + // The merge owns the counter, so the listener must NOT also hold one: two counters + // over the same messages would drop 2n. It is the progress source as well - the + // number the client is shown is the merge's own budget draining. + StartFromPlan(StartFromDiscardPlan.Nothing, globalOrdering) + else if v.n > 0 then StartFromPlan(StartFromDiscardPlan.SharedTotal(v.n), StartFromOrderingPlan.PassThrough) + else StartFromPlan(StartFromDiscardPlan.Nothing, StartFromOrderingPlan.PassThrough) case v: NthMessageBeforeLatest => - val n = v.n - if getIsSingleNonPartitionedTopic(adminClient, nonPartitionedTopicFqns) then - findNthMessage(adminClient, nonPartitionedTopicFqns.head, "latest", n) match - case Some(message) => consumers.foreach(_.seek(message.getMessageId)) - case None => consumers.foreach(_.seek(PulsarMessageId.earliest)) + if v.n <= 0 then + // "the last 0 messages" is nothing at all - the same position "Latest message" uses. + historyConsumers.foreach(_.seek(PulsarMessageId.latest)) + StartFromPlan(StartFromDiscardPlan.Nothing, StartFromOrderingPlan.PassThrough) else - findNthMessageMultiTopic(adminClient, nonPartitionedTopicFqns, "latest", n) match - case Some(message) => consumers.foreach(_.seek(message.getPublishTime)) - case None => consumers.foreach(_.seek(PulsarMessageId.earliest)) + // RESOLVE EVERY TOPIC BEFORE SEEKING ANY OF THEM - the same discipline both + // approximate modes follow, and for the same reason: a broker that stops answering + // half way through must not leave part of the session sitting at a position nobody + // asked for. + val cut = resolveLatestN(v.n, historyConsumers.map(_.getTopic).distinct, entryFromLatest(adminClient, _), latestNEntryIsOlder) + // RETENTION RE-CHECK, after resolving and before any seek: see + // [[latestNAnchorRejectionReason]]. Every topic verifies before any topic seeks - + // the same all-or-nothing discipline the resolution itself follows. + latestNAnchorRejectionReason(cut, earliestRetainedEntryId(adminClient, _), latestNEntryIsOlder) + .foreach(reason => throw StartFromUnresolvableException(reason, null)) + val discards = scala.collection.mutable.Map.empty[NonPartitionedTopicFqn, Long] + historyConsumers.foreach { consumer => + val topicFqn = consumer.getTopic + cut.getOrElse(topicFqn, LatestNSeek.Nothing) match + // Everything this topic holds is older than the cut. LATEST, not earliest: + // seeking a non-contributing partition to the beginning would show all of it. + case LatestNSeek.Nothing => consumer.seek(PulsarMessageId.latest) + case LatestNSeek.Everything => consumer.seek(PulsarMessageId.earliest) + case LatestNSeek.FromEntry(entryId, discard) => + consumer.seek(entryId) + if discard > 0 then discards(topicFqn) = discard + } + // A PER-TOPIC head-drop and never a session-wide one: this is the overshoot inside + // the single entry the walk stopped on, not a skip the user asked for, so it must + // not be reported as progress. Two targets on that topic each drop their own copy. + StartFromPlan(StartFromDiscardPlan.PerTopic(discards.toMap), StartFromOrderingPlan.PassThrough) case v: MessageId => - if getIsSingleNonPartitionedTopic(adminClient, nonPartitionedTopicFqns) then + if getIsSingleNonPartitionedTopic(adminClient, historyTopicFqns) then val messageId = MessageId.toPulsar(v).getOrElse(throw new RuntimeException(s"Failed to parse message ID.")) - val topicFqn = nonPartitionedTopicFqns.head + val topicFqn = historyTopicFqns.head getMessageById(pulsarClient, topicFqn, messageId.toByteArray) match - case Some(message) => consumers.foreach(_.seek(message.getMessageId)) + case Some(message) => historyConsumers.foreach(_.seek(message.getMessageId)) case None => throw new RuntimeException(s"Message with such ID not found in the topic: $topicFqn.") else - getMessageByIdMultiTopic(adminClient, pulsarClient, nonPartitionedTopicFqns, v.messageIdBytes) match - case Some(message) => - consumers.foreach(_.seek(message.getPublishTime)) + getMessageByIdMultiTopic(adminClient, pulsarClient, historyTopicFqns, v.messageIdBytes) match + case Some((ownerTopicFqn, message)) => + // The topic the id belongs to gets the EXACT message; the others get the + // instant it was published at, which is the only cross-topic position a + // message id has. See [[messageIdSeeks]]. + messageIdSeeks(historyConsumers, _.getTopic, ownerTopicFqn, message.getMessageId, message.getPublishTime) + .foreach { (consumer, seekTo) => + seekTo match + case MessageIdSeek.ById(messageId) => consumer.seek(messageId) + case MessageIdSeek.ByPublishTime(atMs) => consumer.seek(atMs) + } case None => throw new RuntimeException(s"Message with such ID not found.") + StartFromPlan(StartFromDiscardPlan.Nothing, StartFromOrderingPlan.PassThrough) case v: DateTime => val timestamp = v.dateTime.toEpochMilli - consumers.foreach(_.seek(timestamp)) + historyConsumers.foreach(_.seek(timestamp)) + StartFromPlan(StartFromDiscardPlan.Nothing, StartFromOrderingPlan.PassThrough) case v: RelativeDateTime => - val now = ZonedDateTime.now() - val dateTime = v.unit match - case DateTimeUnit.Year => - val dt = now.minusYears(v.value) - if v.isRoundedToUnitStart then dt.truncatedTo(java.time.temporal.ChronoUnit.YEARS) else dt - case DateTimeUnit.Month => - val dt = now.minusMonths(v.value) - if v.isRoundedToUnitStart then dt.truncatedTo(java.time.temporal.ChronoUnit.MONTHS) else dt - case DateTimeUnit.Week => - val dt = now.minusWeeks(v.value) - if v.isRoundedToUnitStart then dt.truncatedTo(java.time.temporal.ChronoUnit.WEEKS) else dt - case DateTimeUnit.Day => - val dt = now.minusDays(v.value) - if v.isRoundedToUnitStart then dt.truncatedTo(java.time.temporal.ChronoUnit.DAYS) else dt - case DateTimeUnit.Hour => - val dt = now.minusHours(v.value) - if v.isRoundedToUnitStart then dt.truncatedTo(java.time.temporal.ChronoUnit.HOURS) else dt - case DateTimeUnit.Minute => - val dt = now.minusMinutes(v.value) - if v.isRoundedToUnitStart then dt.truncatedTo(java.time.temporal.ChronoUnit.MINUTES) else dt - case DateTimeUnit.Second => - val dt = now.minusSeconds(v.value) - if v.isRoundedToUnitStart then dt.truncatedTo(java.time.temporal.ChronoUnit.SECONDS) else dt - consumers.foreach(_.seek(dateTime.toInstant.toEpochMilli)) + // Resolve ONCE, outside the loop: evaluating now() per consumer seeks each partition of + // a multi-topic session to a slightly different boundary. + val startAt = resolveRelativeDateTime(v, ZonedDateTime.now()).toInstant.toEpochMilli + historyConsumers.foreach(_.seek(startAt)) + StartFromPlan(StartFromDiscardPlan.Nothing, StartFromOrderingPlan.PassThrough) + + case v: ApproximateDataPosition => + // Resolve EVERY topic before seeking any of them: a rejected fraction, or a broker that + // will not answer, must not leave half the session sitting at a position nobody asked + // for. Two admin calls per physical topic, both O(1) in the size of the topic (three + // when the recheck below fires). + val positions = historyConsumers.map { consumer => + val topicFqn = consumer.getTopic + val seekTo = resolveApproximateDataPosition(v.fraction, retainedEntryCount(adminClient, topicFqn)) match + case ApproximateDataSeek.Earliest => PulsarMessageId.earliest + case ApproximateDataSeek.Latest => PulsarMessageId.latest + case ApproximateDataSeek.Entry(entryOrdinal) => + // The entry count and the examine are two calls, and retention can trim + // BETWEEN them. `examineMessage(earliest, k)` past the current end silently + // CLAMPS TO THE NEWEST entry - the exact opposite of the contract, which + // promises the earliest fallback when the position is gone. The recheck + // makes the trim detectable: a count now smaller than the ordinal means + // the answer in hand is the clamp, not the position. + entryFromEarliest(adminClient, topicFqn)(entryOrdinal) match + case None => PulsarMessageId.earliest + case Some(answer) => + if retainedEntryCount(adminClient, topicFqn) < entryOrdinal then PulsarMessageId.earliest + else answer + consumer -> seekTo + } + positions.foreach((consumer, seekTo) => consumer.seek(seekTo)) + StartFromPlan(StartFromDiscardPlan.Nothing, StartFromOrderingPlan.PassThrough) + + case v: ApproximateTimePosition => + // ONE position per LOGICAL topic, so a partitioned topic is a single time range rather + // than one range per partition. The lookup is memoised across the group because two + // enabled targets may deliver the same physical topic through separate consumers, and + // the broker must not be asked the same question twice. + val spans = scala.collection.mutable.Map.empty[String, Option[TopicTimeSpan]] + def spanOf(topicFqn: String): Option[TopicTimeSpan] = + spans.getOrElseUpdate(topicFqn, publishTimeSpan(adminClient, topicFqn)) + + // Same resolve-then-seek discipline as the data mode above, and for the same reason. + val seeksByTopic = historyConsumers + .map(_.getTopic) + .distinct + .groupBy(logicalTopicOf) + .view + .mapValues(partitionFqns => resolveApproximateTimePosition(v.fraction, partitionFqns, spanOf)) + .toMap + + historyConsumers.foreach { consumer => + seeksByTopic(logicalTopicOf(consumer.getTopic)) match + case ApproximateTimeSeek.Earliest => consumer.seek(PulsarMessageId.earliest) + case ApproximateTimeSeek.Timestamp(atMs) => consumer.seek(atMs) + } + StartFromPlan(StartFromDiscardPlan.Nothing, StartFromOrderingPlan.PassThrough) + // Belt and braces: they should never have been resumed, and the caller resumes them itself. consumers.foreach(_.pause()) + plan diff --git a/server/src/main/scala/consumer/session_runner/messageConverters.scala b/server/src/main/scala/consumer/session_runner/messageConverters.scala index 02b940f69..623c2cd65 100644 --- a/server/src/main/scala/consumer/session_runner/messageConverters.scala +++ b/server/src/main/scala/consumer/session_runner/messageConverters.scala @@ -57,11 +57,11 @@ object converters: val messageId = Option(msg.getMessageId.toByteArray) val sequenceId = Option(msg.getSequenceId) val producerName = Option(msg.getProducerName) - val key = Option(msg.getKey).flatMap(key => { - parseJson(s"""\"$key\"""") match - case Left(_) => None - case Right(k) => Some(k) - }) + // Encode via circe rather than splicing the raw key into a JSON string literal: a key + // containing a quote/backslash/newline produced invalid JSON, which parsed to Left and + // silently DROPPED the key - so key filters and key projections quietly missed those + // messages. (The value path already goes through primitiveConv.bytesToJsonString.) + val key = Option(msg.getKey).map(_.asJson) val size = Option(msg.size) val orderingKey = Option(msg.getOrderingKey) val topic = Option(msg.getTopicName) diff --git a/server/src/main/scala/consumer/session_runner/startFromLookups.scala b/server/src/main/scala/consumer/session_runner/startFromLookups.scala new file mode 100644 index 000000000..32f45bc03 --- /dev/null +++ b/server/src/main/scala/consumer/session_runner/startFromLookups.scala @@ -0,0 +1,92 @@ +package consumer.session_runner + +import org.apache.pulsar.client.admin.PulsarAdminException + +import scala.util.{Failure, Success, Try} + +/** The broker could not answer a question the requested start position depends on. + * + * Distinct from "the log holds nothing there", which is an ANSWER. A session that cannot be put + * where the user asked for it must fail to be created, rather than start somewhere else and report + * success. + */ +final class StartFromUnresolvableException(message: String, cause: Throwable) extends RuntimeException(message, cause) + +/** The two phrases the broker answers "there is nothing there" with. Lower-cased at the point of + * comparison, so a version that changes the capitalisation still matches. */ +private val emptyLogPhrases: Vector[String] = Vector("total message is zero", "incorrect parameter input") + +/** HTTP 412: `examinemessage`'s only precondition is that the log holds something. */ +private val preconditionFailed: Int = 412 + +private def causeChain(err: Throwable): Vector[Throwable] = + // Bounded: a cause chain that references itself is not unheard of, and this runs on the session + // creation path. + Iterator.iterate(err)(_.getCause).takeWhile(_ != null).take(10).toVector + +private def saysEmptyLog(message: String): Boolean = + Option(message).map(_.toLowerCase).exists(text => emptyLogPhrases.exists(text.contains)) + +/** Whether an admin failure is the broker SAYING THERE IS NOTHING THERE, rather than failing to + * answer at all. + * + * `PulsarAdmin.topics.examineMessage` has no "empty" answer: both of its legitimate "no such + * entry" outcomes arrive as failures, and everything else that can go wrong arrives the same way. + * Erasing all of them into one `None` is what turned a transient broker error into a DIFFERENT + * VALID START POSITION - "Latest 5" quietly showed the entire backlog, and creation reported + * success. + * + * MEASURED against the Pulsar this repo runs its e2e against (3.2.1), through `PulsarAdmin` + * itself, on a non-partitioned persistent topic holding 3 unbatched messages: + * + * - EMPTY TOPIC, `earliest/1`: `PreconditionFailedException`, statusCode 412, message and + * httpError both "Could not examine messages due to the total message is zero". + * - PAST THE START, `latest/99`: `ServerSideErrorException`, statusCode 500, message and + * httpError both "... Message: Incorrect parameter input error code: -14 ... + * org.apache.bookkeeper.mledger.ManagedLedgerException ...". + * - MISSING TOPIC: `NotFoundException`, statusCode 404, "Topic ... not found". + * - UNREACHABLE BROKER: plain `PulsarAdminException`, **statusCode 500**, httpError NULL, + * message "...RetryException: Could not complete the operation. Number of retries has been + * exhausted...". + * - PAST THE END, `earliest/4` and `earliest/99`: HTTP 200, silently CLAMPED to the last entry. + * That is why only the `latest` side is ever walked, and why [[resolveLatestN]] guards the + * clamp with a repeated-entry check rather than relying on a failure. + * + * THE STATUS CODE ALONE CANNOT CLASSIFY, and that last measurement is why: a broker that cannot be + * reached at all reports the same 500 as a walk that ran off the start of the log. Only 412 is + * unambiguous; the 500 has to be told apart by what it says. Both `getMessage` and `getHttpError` + * are checked because the admin client fills them from the same server reason but leaves + * `httpError` null when the failure never reached the server. + * + * EVERYTHING ELSE IS AN OPERATIONAL FAILURE: a timeout, a 401/403, a 404, a broker restarting + * mid-request, a 500 that says something else. None of those means the log is empty, and none of + * them may be answered with a position the user did not ask for. + * + * PURE, so every one of these shapes is pinned by test. + */ +def isEmptyLogAnswer(err: Throwable): Boolean = + causeChain(err).exists { + case admin: PulsarAdminException => + admin.getStatusCode == preconditionFailed || saysEmptyLog(admin.getMessage) || saysEmptyLog(admin.getHttpError) + case other => saysEmptyLog(other.getMessage) + } + +/** Ask the broker one question about a topic's log, keeping "there is nothing there" and "it could + * not say" apart. + * + * `Some(answer)` - the broker answered. `None` - the broker answered, and the answer is that there + * is nothing at that position. A throw - the broker could not answer, and the start position the + * user asked for cannot be resolved, so the session must not be created. + * + * `question` and `topicFqn` are for the message the client is shown; they are the only way a user + * can tell "your topic is empty" from "the broker is unwell". + */ +def brokerAnswer[A](question: String, topicFqn: String)(lookup: => A): Option[A] = + Try(lookup) match + case Success(value) => Some(value) + case Failure(err) if isEmptyLogAnswer(err) => None + case Failure(err) => + throw StartFromUnresolvableException( + s"Could not resolve the requested start-from position: $question failed for $topicFqn. ${err.getMessage}", + err + ) diff --git a/server/src/main/scala/consumer/session_runner/topicPositions.scala b/server/src/main/scala/consumer/session_runner/topicPositions.scala new file mode 100644 index 000000000..75dcdff30 --- /dev/null +++ b/server/src/main/scala/consumer/session_runner/topicPositions.scala @@ -0,0 +1,216 @@ +package consumer.session_runner + +import com.google.protobuf.ByteString +import com.tools.teal.pulsar.ui.api.v1.consumer as consumerPb +import org.apache.pulsar.client.api.MessageId as PulsarMessageId +import org.apache.pulsar.client.impl.MessageIdImpl + +/** The per-topic debug view behind the session's "Topic Positions" tab: where each physical topic + * begins and ends, and how far the session has read through it. + * + * EVERYTHING HERE IS PURE. The three broker lookups a row needs (first entry, last entry, internal + * stats) sit behind plain arguments, so every arrangement worth reasoning about - an empty topic, a + * topic occupying one instant, a cursor whose ledger has aged out, a clock that ran backwards - is + * a table test rather than a fixture. The impure part is one adapter in ConsumerServiceImpl. + * + * WHY A HIGH-WATER MARK AND NOT "THE LAST MESSAGE ON SCREEN". The cursor is recorded where messages + * are ACKNOWLEDGED, so it counts what the session read, not what survived its filters. A filter + * that drops 99% of a topic would otherwise make both progress figures read almost zero while the + * session was in fact nearly finished. + */ + +/** How far this session has read into one physical topic. */ +final case class TopicCursor(messageId: PulsarMessageId, publishTime: Long) + +/** One ledger of a topic's managed ledger, reduced to what an ordinal needs. */ +final case class LedgerSpan(ledgerId: Long, entries: Long) + +/** An endpoint of a retained log - its first or last message. */ +final case class LogEndpoint(messageId: PulsarMessageId, publishTime: Long) + +/** Everything one row of the table is computed from. + * + * `first`/`last` are absent for an EMPTY topic, which is not a failure: `examineMessage` answers + * "latest" by throwing there and "earliest" with a 412, and both are classified as "the log is + * empty" rather than as broker trouble (see [[isEmptyLogAnswer]]). `unavailableReason` is for a + * topic that could not be asked at all - a non-persistent one, which Pulsar refuses to examine with + * a 405 - and is the difference between "nothing to report" and "nothing is known". + */ +final case class TopicPositionInputs( + topicFqn: String, + first: Option[LogEndpoint], + last: Option[LogEndpoint], + cursor: Option[TopicCursor], + ledgers: Vector[LedgerSpan], + currentLedgerEntries: Long, + retainedEntries: Long, + unavailableReason: Option[String] +) + +/** One assembled row. Every figure is optional because every one of them can be genuinely unknown, + * and a blank cell is honest where a zero would be a lie. + */ +final case class TopicPositionRow( + topicFqn: String, + first: Option[LogEndpoint], + last: Option[LogEndpoint], + cursor: Option[TopicCursor], + cursorTimeFraction: Option[Double], + cursorEntryFraction: Option[Double], + cursorEntryOrdinal: Option[Long], + retainedEntries: Option[Long], + unavailableReason: Option[String] +) + +/** The ledger list with Pulsar's open-ledger hole filled in. + * + * THE HOLE: `getInternalStats` reports the CURRENT (still open) ledger with `entries: 0` and + * `size: 0` - the real count lives in `currentLedgerEntries` alongside the list, not in the entry + * itself. Measured on a 6060-entry single-ledger topic: `ledgers: [{ledgerId: 7057, entries: 0}]` + * with `currentLedgerEntries: 6060`. Walking the list as reported therefore puts EVERY cursor in + * the open ledger at ordinal 1, i.e. "0% through", for the whole life of that ledger - which on a + * topic that has never rolled is the whole topic. + * + * The correction is positional, not value-based: the open ledger is the LAST one, so only the last + * element is patched, and only when it reports nothing. A closed ledger that genuinely holds zero + * entries in the middle of the list is left alone. + */ +def retainedLedgerSpans(ledgers: Vector[LedgerSpan], currentLedgerEntries: Long): Vector[LedgerSpan] = + if ledgers.isEmpty then ledgers + else + val last = ledgers.last + if last.entries > 0 || currentLedgerEntries <= 0 then ledgers + else ledgers.init :+ last.copy(entries = currentLedgerEntries) + +/** The cursor's 1-BASED ordinal among the entries the topic still retains. + * + * `None` when the cursor's ledger is not in the retained list at all, which is what a cursor that + * has aged out from under retention looks like: the session read entries that have since been + * trimmed. Reporting 0, or clamping it to the first retained entry, would both claim the session is + * at the beginning when it is in fact past the beginning - so it reports nothing. + * + * ENTRIES, NOT MESSAGES. A batched entry counts once here however many messages it carries; see + * [[cursorEntryFractionOf]] for why the distinction is named rather than hidden. + */ +def entryOrdinalOf(ledgers: Vector[LedgerSpan], cursorLedgerId: Long, cursorEntryId: Long): Option[Long] = + val index = ledgers.indexWhere(_.ledgerId == cursorLedgerId) + if index < 0 then None + else + val before = ledgers.take(index).map(_.entries).sum + // `cursorEntryId` is 0-based within its ledger, so +1 makes the whole thing 1-based: the + // very first retained entry is ordinal 1, matching how `examineMessage` addresses entries. + Some(before + cursorEntryId + 1) + +/** Where the cursor sits in the topic's TIME range, in [0.0, 1.0]. + * + * `None` unless there is a cursor AND both endpoints AND a range with an interior: + * + * - FIRST == LAST - everything the topic holds was published inside one millisecond - has no + * interior to place anything in, and the same rule ApproximateTimePosition follows applies + * here: no position separates the messages, so no fraction describes one. Reporting 0.0 or 1.0 + * would both be inventions. + * - A RANGE REPORTED BACKWARDS (first > last) is not a range. Publish time is stamped by the + * PRODUCER, so a clock that stepped back can produce one; a negative denominator would hand + * back a nonsense fraction that looks like a real measurement. + * + * CLAMPED into [0.0, 1.0] because the three lookups are not atomic: the last entry is read before + * the cursor is, so a message published in between puts the cursor past the recorded end. That is a + * stale denominator, not a cursor that overran the topic, and 1.0 is the honest rendering of it. + */ +def cursorTimeFractionOf(first: Option[LogEndpoint], last: Option[LogEndpoint], cursor: Option[TopicCursor]): Option[Double] = + for + f <- first + l <- last + c <- cursor + if l.publishTime > f.publishTime + yield ((c.publishTime - f.publishTime).toDouble / (l.publishTime - f.publishTime).toDouble).max(0.0).min(1.0) + +/** Where the cursor sits among the topic's STORED ENTRIES, in [0.0, 1.0]. + * + * ENTRIES, NOT MESSAGES, and the name says so. Pulsar addresses stored data by entry, and a batched + * entry holds many messages, so this tracks a message-count percentage only as closely as batch + * sizes stayed uniform across the topic's life - exactly the approximation ApproximateDataPosition + * documents for the same reason. Calling it "% of messages" would be a number the broker cannot + * actually produce without a message-ordinal index, which needs an operator opt-in that is off by + * default in every Pulsar version. + * + * `None` on a topic retaining nothing: there is no denominator, and 0/0 is not 0%. + */ +def cursorEntryFractionOf(cursorEntryOrdinal: Option[Long], retainedEntries: Long): Option[Double] = + if retainedEntries <= 0 then None + else cursorEntryOrdinal.map(ordinal => (ordinal.toDouble / retainedEntries.toDouble).max(0.0).min(1.0)) + +/** The ledger and entry of a message id, with any BATCH INDEX stripped. + * + * A batched message's id carries a third coordinate, and the ordinal walk is entry-addressed - two + * messages of one batch share an entry and must land on one ordinal, not two. + */ +def ledgerAndEntryOf(messageId: PulsarMessageId): Option[(Long, Long)] = entryIdOf(messageId) match + case id: MessageIdImpl => Some((id.getLedgerId, id.getEntryId)) + case _ => None + +/** Assemble one row from what the broker said and what the session has read. */ +def buildTopicPositionRow(inputs: TopicPositionInputs): TopicPositionRow = + val spans = retainedLedgerSpans(inputs.ledgers, inputs.currentLedgerEntries) + val ordinal = for + c <- inputs.cursor + (ledgerId, entryId) <- ledgerAndEntryOf(c.messageId) + o <- entryOrdinalOf(spans, ledgerId, entryId) + yield o + + TopicPositionRow( + topicFqn = inputs.topicFqn, + first = inputs.first, + last = inputs.last, + cursor = inputs.cursor, + cursorTimeFraction = cursorTimeFractionOf(inputs.first, inputs.last, inputs.cursor), + cursorEntryFraction = cursorEntryFractionOf(ordinal, inputs.retainedEntries), + cursorEntryOrdinal = ordinal, + // A topic that could not be asked reports no denominator either - `retainedEntries` is 0 + // there only because nothing filled it in, and a "0 entries" cell would read as an empty + // topic rather than an unavailable one. + retainedEntries = Option.when(inputs.unavailableReason.isEmpty && inputs.retainedEntries >= 0)(inputs.retainedEntries), + unavailableReason = inputs.unavailableReason + ) + +/** Collapse the per-listener cursor maps of one session into one cursor per topic, keeping the + * FURTHEST each topic has been read. + * + * A session can consume the same topic from more than one target - two targets differing only in + * their filters is an ordinary configuration - and each keeps its own listener, so each keeps its + * own read position. The table has one row per topic, so the two have to be reconciled, and the + * furthest is the only answer that means "how far has this session read". + * + * Ordered by MESSAGE ID, not by publish time. Publish time is stamped by the producer and can run + * backwards between two messages; ledger-and-entry order is the log's own order and cannot. + */ +def furthestCursors(perListener: Iterable[Map[String, TopicCursor]]): Map[String, TopicCursor] = + perListener.flatten.foldLeft(Map.empty[String, TopicCursor]) { case (acc, (topicFqn, cursor)) => + val furthest = acc.get(topicFqn) match + case Some(existing) if existing.messageId.compareTo(cursor.messageId) >= 0 => existing + case _ => cursor + acc.updated(topicFqn, furthest) + } + +/** Put one row on the wire. + * + * ABSENT STAYS ABSENT. Every optional here maps to an unset protobuf wrapper rather than to a zero, + * because the client renders a blank cell for "not known" and a real figure for "known to be zero", + * and the two are different answers: a cursor at the very first entry IS 0% through, while a topic + * whose cursor has aged out is not. + */ +def topicPositionToPb(row: TopicPositionRow): consumerPb.TopicPosition = + consumerPb.TopicPosition( + topicFqn = row.topicFqn, + firstMessageId = row.first.map(e => ByteString.copyFrom(e.messageId.toByteArray)), + firstPublishTime = row.first.map(_.publishTime), + lastMessageId = row.last.map(e => ByteString.copyFrom(e.messageId.toByteArray)), + lastPublishTime = row.last.map(_.publishTime), + cursorMessageId = row.cursor.map(c => ByteString.copyFrom(c.messageId.toByteArray)), + cursorPublishTime = row.cursor.map(_.publishTime), + cursorTimeFraction = row.cursorTimeFraction, + cursorEntryFraction = row.cursorEntryFraction, + retainedEntries = row.retainedEntries, + cursorEntryOrdinal = row.cursorEntryOrdinal, + unavailableReason = row.unavailableReason + ) diff --git a/server/src/main/scala/consumer/session_target/topic_selector/MultiTopicSelector.scala b/server/src/main/scala/consumer/session_target/topic_selector/MultiTopicSelector.scala index 59471c532..a9308140e 100644 --- a/server/src/main/scala/consumer/session_target/topic_selector/MultiTopicSelector.scala +++ b/server/src/main/scala/consumer/session_target/topic_selector/MultiTopicSelector.scala @@ -14,9 +14,14 @@ case class MultiTopicSelector(topicFqns: Vector[String]): val partitions = getTopicPartitions(adminClient, topicFqn) partitions case TopicPartitioningType.NonPartitioned => Vector(topicFqn) - case Failure(_) => - println(s"Failed to get topic partitioning for topic $topicFqn") - Vector.empty + case Failure(err) => + // Swallowing this DROPPED the topic from the selection. The user named these + // FQNs explicitly, so silently consuming from a subset is wrong - and when + // every topic was unresolvable (unreachable broker, topic deleted between the + // picker and the session) the selector returned an empty vector, which the + // session runner accepted as a consumer-less session reported to the UI as OK. + // The sibling NamespacedRegexTopicSelector has always let these propagate. + throw new RuntimeException(s"Failed to resolve topic $topicFqn. ${err.getMessage}", err) }.distinct object MultiTopicSelector: diff --git a/server/src/main/scala/consumer/start_from/ApproximateDataPosition.scala b/server/src/main/scala/consumer/start_from/ApproximateDataPosition.scala new file mode 100644 index 000000000..64826b8c8 --- /dev/null +++ b/server/src/main/scala/consumer/start_from/ApproximateDataPosition.scala @@ -0,0 +1,29 @@ +package consumer.start_from + +import com.tools.teal.pulsar.ui.api.v1.consumer as pb + +/** Start APPROXIMATELY `fraction` of the way through the DATA a topic still holds - "about % through + * the data". 0.0 is the earliest retained message, 1.0 is past the latest. + * + * Addressed by ENTRY ordinal, which Pulsar resolves in constant time at any topic size - unlike a + * message ordinal, which has no index and has to be counted. That is where "approximate" comes + * from: one entry holds a whole batch, so a fraction of the entries is only that fraction of the + * MESSAGES as far as batch sizes stayed even. The consequences of that choice (the rounding rule, + * the endpoints, an empty topic, a partitioned topic) live in + * `consumer.session_runner.resolveApproximateDataPosition`, which is where the position is + * resolved. + * + * The counterpart is [[ApproximateTimePosition]], and the two are separate modes because they + * answer different questions. On a topic where 99% of the messages arrived in the last hour of a + * 30-day retention, "50%" of the DATA lands inside that last hour while 50% of the TIME lands + * fifteen days back. Both are legitimate; a single control that meant either could be named + * honestly. + */ +case class ApproximateDataPosition(fraction: Double) + +object ApproximateDataPosition: + def fromPb(v: pb.ApproximateDataPosition): ApproximateDataPosition = + ApproximateDataPosition(fraction = v.fraction) + + def toPb(v: ApproximateDataPosition): pb.ApproximateDataPosition = + pb.ApproximateDataPosition(fraction = v.fraction) diff --git a/server/src/main/scala/consumer/start_from/ApproximateTimePosition.scala b/server/src/main/scala/consumer/start_from/ApproximateTimePosition.scala new file mode 100644 index 000000000..f5b7336ad --- /dev/null +++ b/server/src/main/scala/consumer/start_from/ApproximateTimePosition.scala @@ -0,0 +1,24 @@ +package consumer.start_from + +import com.tools.teal.pulsar.ui.api.v1.consumer as pb + +/** Start APPROXIMATELY `fraction` of the way through the TIME RANGE a topic still covers - "about % + * through the time range". 0.0 is the earliest retained message, 1.0 is the last message. + * + * Resolved PER LOGICAL TOPIC, unlike [[ApproximateDataPosition]]: the range runs from the MINIMUM + * first-message publish time over the topic's partitions to the MAXIMUM last-message publish time + * over them, and every partition is seeked to the one instant that interpolation picks out. The + * rounding rule, the exact endpoints, an empty topic and a topic that occupies a single instant all + * live in `consumer.session_runner.resolveApproximateTimePosition`. + * + * The seek is by TIMESTAMP, so this rides the same broker path as the "Specific time" mode - the + * only new thing is where the timestamp comes from. + */ +case class ApproximateTimePosition(fraction: Double) + +object ApproximateTimePosition: + def fromPb(v: pb.ApproximateTimePosition): ApproximateTimePosition = + ApproximateTimePosition(fraction = v.fraction) + + def toPb(v: ApproximateTimePosition): pb.ApproximateTimePosition = + pb.ApproximateTimePosition(fraction = v.fraction) diff --git a/server/src/main/scala/consumer/start_from/ConsumerSessionStartFrom.scala b/server/src/main/scala/consumer/start_from/ConsumerSessionStartFrom.scala index f60608f91..86627d4c7 100644 --- a/server/src/main/scala/consumer/start_from/ConsumerSessionStartFrom.scala +++ b/server/src/main/scala/consumer/start_from/ConsumerSessionStartFrom.scala @@ -6,8 +6,14 @@ import com.google.protobuf.timestamp.Timestamp import java.time.Instant -type ConsumerSessionStartFrom = EarliestMessage | LatestMessage | NthMessageAfterEarliest | NthMessageBeforeLatest | MessageId | DateTime | RelativeDateTime +type ConsumerSessionStartFrom = EarliestMessage | LatestMessage | NthMessageAfterEarliest | NthMessageBeforeLatest | MessageId | DateTime | + RelativeDateTime | ApproximateDataPosition | ApproximateTimePosition +/** A UNION type, so these matches are NOT checked for exhaustiveness: a mode missing from either + * direction compiles fine and throws at runtime, surfacing as a generic failure on saving or + * loading a session. `startFromConversionsTest` sweeps every mode through both directions for that + * reason - it is the only thing standing in for the missing compiler check. + */ object ConsumerSessionStartFrom: def fromPb(startFrom: pb.ConsumerSessionStartFrom): ConsumerSessionStartFrom = startFrom.startFrom match @@ -18,6 +24,8 @@ object ConsumerSessionStartFrom: case pb.ConsumerSessionStartFrom.StartFrom.StartFromMessageId(v) => MessageId.fromPb(v) case pb.ConsumerSessionStartFrom.StartFrom.StartFromDateTime(v) => DateTime.fromPb(v) case pb.ConsumerSessionStartFrom.StartFrom.StartFromRelativeDateTime(v) => RelativeDateTime.fromPb(v) + case pb.ConsumerSessionStartFrom.StartFrom.StartFromApproximateDataPosition(v) => ApproximateDataPosition.fromPb(v) + case pb.ConsumerSessionStartFrom.StartFrom.StartFromApproximateTimePosition(v) => ApproximateTimePosition.fromPb(v) case _ => throw IllegalArgumentException("Unknown ConsumerSessionStartFrom type.") def toPb(startFrom: ConsumerSessionStartFrom): pb.ConsumerSessionStartFrom = @@ -26,10 +34,31 @@ object ConsumerSessionStartFrom: pb.ConsumerSessionStartFrom(startFrom = pb.ConsumerSessionStartFrom.StartFrom.StartFromEarliestMessage(EarliestMessage.toPb(v))) case v: LatestMessage => pb.ConsumerSessionStartFrom(startFrom = pb.ConsumerSessionStartFrom.StartFrom.StartFromLatestMessage(LatestMessage.toPb(v))) + // Both Nth modes were readable but not writable: saving a session that used one threw + // "Unknown ConsumerSessionStartFrom type" from here. + case v: NthMessageAfterEarliest => + pb.ConsumerSessionStartFrom(startFrom = + pb.ConsumerSessionStartFrom.StartFrom.StartFromNthMessageAfterEarliest(NthMessageAfterEarliest.toPb(v)) + ) + case v: NthMessageBeforeLatest => + pb.ConsumerSessionStartFrom(startFrom = + pb.ConsumerSessionStartFrom.StartFrom.StartFromNthMessageBeforeLatest(NthMessageBeforeLatest.toPb(v)) + ) case v: MessageId => pb.ConsumerSessionStartFrom(startFrom = pb.ConsumerSessionStartFrom.StartFrom.StartFromMessageId(MessageId.toPb(v))) case v: DateTime => pb.ConsumerSessionStartFrom(startFrom = pb.ConsumerSessionStartFrom.StartFrom.StartFromDateTime(DateTime.toPb(v))) case v: RelativeDateTime => pb.ConsumerSessionStartFrom(startFrom = pb.ConsumerSessionStartFrom.StartFrom.StartFromRelativeDateTime(RelativeDateTime.toPb(v))) + // The two approximate modes carry the SAME payload - one double - so a branch that + // reached for the other one's oneof case would still round-trip a fraction and look + // right; the only symptom would be a session positioned by the wrong rule. + case v: ApproximateDataPosition => + pb.ConsumerSessionStartFrom(startFrom = + pb.ConsumerSessionStartFrom.StartFrom.StartFromApproximateDataPosition(ApproximateDataPosition.toPb(v)) + ) + case v: ApproximateTimePosition => + pb.ConsumerSessionStartFrom(startFrom = + pb.ConsumerSessionStartFrom.StartFrom.StartFromApproximateTimePosition(ApproximateTimePosition.toPb(v)) + ) case _ => throw IllegalArgumentException("Unknown ConsumerSessionStartFrom type.") diff --git a/server/src/main/scala/library/Library.scala b/server/src/main/scala/library/Library.scala index cc3881e41..5d38907fe 100644 --- a/server/src/main/scala/library/Library.scala +++ b/server/src/main/scala/library/Library.scala @@ -31,20 +31,37 @@ object Library: // (e.g. `../../…`) can never escape the library directory. Real ids are UUIDs. private val SafeItemId = "^[A-Za-z0-9_-]{1,200}$".r + private def isSafeItemId(itemId: LibraryItemId): Boolean = + SafeItemId.findFirstIn(itemId).isDefined + + // The single file name an item may live under. Write, delete and scan must all agree on it. + private def fileNameOf(itemId: LibraryItemId): FileName = s"$itemId.binpb" + class Library: private var rootDir = "./data" - private var db = LibraryDb(itemsById = Map.empty) + @volatile private var db = LibraryDb(itemsById = Map.empty) private val logger: Logger = Logger(getClass.getName) + // A Library instance is shared by every gRPC call (LibraryServiceImpl holds exactly one), so + // saveLibraryItem/deleteLibraryItem run concurrently against it. Each mutation is + // "touch the file, rescan the dir, publish the snapshot" - three steps that MUST NOT interleave: + // - two writers could interleave so that an OLDER scan publishes last, dropping a + // just-written item from the snapshot even though its file is on disk; + // - a delete racing a scan could remove a file between os.list and os.read.bytes, blowing up + // the unrelated writer with NoSuchFileException; + // - deleteItem's exists-then-remove could let two concurrent deletes both report success. + // One lock over the whole sequence makes each mutation atomic with respect to the others. + private val mutationLock = new Object + def size: Int = db.itemsById.size private def requireSafeItemId(itemId: LibraryItemId): Unit = - if Library.SafeItemId.findFirstIn(itemId).isEmpty then + if !Library.isSafeItemId(itemId) then throw new IllegalArgumentException( s"Invalid library item id - only alphanumerics, '_' and '-' are allowed." ) - def writeItem(item: LibraryItem): Unit = + def writeItem(item: LibraryItem): Unit = mutationLock.synchronized { val itemId = item.spec.metadata.id requireSafeItemId(itemId) @@ -55,7 +72,7 @@ class Library: s"Library item $itemId must be available in at least one context; an item without contexts would be unreachable." ) - val fileName = s"$itemId.binpb" + val fileName = Library.fileNameOf(itemId) val filePath = os.Path(fileName, os.Path(rootDir, os.pwd)) val itemAsBinary = LibraryItem.toPb(item).toByteArray @@ -65,15 +82,27 @@ class Library: ) refreshDb() + } - def deleteItem(itemId: LibraryItemId): Unit = + def deleteItem(itemId: LibraryItemId): Unit = mutationLock.synchronized { requireSafeItemId(itemId) - val fileName = s"$itemId.binpb" + val fileName = Library.fileNameOf(itemId) val filePath = os.Path(fileName, os.Path(rootDir, os.pwd)) + // os.remove delegates to Files.deleteIfExists, which returns false rather than throwing - + // so deleting an id that was never there reported OK, indistinguishable from a real delete. + // Check the FILE, not the cached db: the db is a snapshot and a stale entry would otherwise + // decide the outcome. NoSuchElementException stays out of the IllegalArgumentException + // (INVALID_ARGUMENT) channel used for malformed ids; the service maps it to NOT_FOUND. + // The exists check and the remove are only meaningful together, hence the surrounding lock - + // unserialized, two concurrent deletes both saw the file and both reported success. + if !os.exists(filePath) then + throw new NoSuchElementException(s"No library item with id: $itemId") + os.remove(filePath) refreshDb() + } def getItemById(itemId: LibraryItemId): Option[LibraryItem] = db.itemsById.get(itemId) @@ -111,14 +140,29 @@ class Library: val scanResultEntryA = Try(LibraryItem.fromPb(pb.LibraryItem.parseFrom(fileContent))) val scanResultEntry = scanResultEntryA.toEither - val libraryItemIdFromFileName = fileName.split('.').head scanResultEntry match case Left(err) => logger.warn(s"Failed to parse library item from file $fileName: $err") fileName -> scanResultEntry case Right(item) => val itemId = item.spec.metadata.id - if itemId != libraryItemIdFromFileName then + // Whatever the scan surfaces must be ADDRESSABLE: writeItem and deleteItem + // both derive `$itemId.binpb` from the id and reject ids outside the safe + // charset, so the scan has to hold itself to the same two rules. Deriving + // the id with `fileName.split('.').head` instead accepted + // `id.extra.binpb` as item `id` - listed and gettable, but deleting it hit + // NOT_FOUND and saving it created a SECOND file; and skipping + // requireSafeItemId surfaced ids like `bad+id` that every write and delete + // then rejected with INVALID_ARGUMENT. + if !Library.isSafeItemId(itemId) then + logger.warn(s"Skipping library file $fileName: item id $itemId is not a valid item id") + fileName -> Left( + new Exception( + s"Library item id $itemId in file $fileName is not a valid item id" + ) + ) + else if fileName != Library.fileNameOf(itemId) then + logger.warn(s"Skipping library file $fileName: item id $itemId does not match the file name") fileName -> Left( new Exception( s"File name $fileName does not match library item id $itemId" @@ -128,7 +172,7 @@ class Library: } .toMap - private def refreshDb(): Unit = + private def refreshDb(): Unit = mutationLock.synchronized { val scanResult = scan() val itemsById = scanResult.collect { case (_, Right(item)) => val itemId = item.spec.metadata.id @@ -137,3 +181,4 @@ class Library: logger.info(s"Library refreshed. Found ${itemsById.size} items in library") db = LibraryDb(itemsById = itemsById) + } diff --git a/server/src/main/scala/library/LibraryServiceImpl.scala b/server/src/main/scala/library/LibraryServiceImpl.scala index 36ae23d6c..236e83d3b 100644 --- a/server/src/main/scala/library/LibraryServiceImpl.scala +++ b/server/src/main/scala/library/LibraryServiceImpl.scala @@ -35,9 +35,13 @@ val config = Await.result(readConfigAsync, Duration(10, SECONDS)) // DEKAF_DATA_DIR), so per-connection isolation lives at the deployment layer, not in here. val libraryRoot = s"${config.dataDir.get}/library" -class LibraryServiceImpl extends pb.LibraryServiceGrpc.LibraryService: +/** The store is a constructor parameter defaulting to the process-wide library directory - same + * reason as `pulsarAuthToCookie`/`PulsarAuthRoutes.routesWith`: `libraryRoot` is derived from a + * config val loaded once per JVM, so nothing could otherwise observe what these RPCs answer without + * writing into the running instance's own data directory. The production call site in `GrpcServer` + * is unchanged. */ +class LibraryServiceImpl(val library: Library = Library.createAndRefreshDb(libraryRoot)) extends pb.LibraryServiceGrpc.LibraryService: val logger: Logger = Logger(getClass.getName) - val library: Library = Library.createAndRefreshDb(libraryRoot) override def saveLibraryItem(request: SaveLibraryItemRequest): Future[SaveLibraryItemResponse] = logger.debug(s"Updating library item: ${request.item}") @@ -48,16 +52,16 @@ class LibraryServiceImpl extends pb.LibraryServiceGrpc.LibraryService: val libraryItem = LibraryItem.fromPb(request.item.get) library.writeItem(libraryItem) - val status: Status = Status(code = Code.OK.index) + val status: Status = Status(code = Code.OK.value) Future.successful(pb.SaveLibraryItemResponse(status = Some(status))) } catch { case err: IllegalArgumentException => logger.warn(s"Rejected library item save: ${err.getMessage}") - val status: Status = Status(code = Code.INVALID_ARGUMENT.index, message = s"Unable to save library item. ${err.getMessage}") + val status: Status = Status(code = Code.INVALID_ARGUMENT.value, message = s"Unable to save library item. ${err.getMessage}") Future.successful(pb.SaveLibraryItemResponse(status = Some(status))) case err: Exception => logger.warn(s"Failed to save library item: ${err.getMessage}") - val status: Status = Status(code = Code.INTERNAL.index, message = s"Unable to save library item. ${err.getMessage}}") + val status: Status = Status(code = Code.INTERNAL.value, message = s"Unable to save library item. ${err.getMessage}") Future.successful(pb.SaveLibraryItemResponse(status = Some(status))) } @@ -69,16 +73,22 @@ class LibraryServiceImpl extends pb.LibraryServiceGrpc.LibraryService: library.deleteItem(request.id) - val status: Status = Status(code = Code.OK.index) + val status: Status = Status(code = Code.OK.value) Future.successful(pb.DeleteLibraryItemResponse(status = Some(status))) } catch { case e: IllegalArgumentException => logger.warn(s"Rejected library item delete: ${e.getMessage}") - val status: Status = Status(code = Code.INVALID_ARGUMENT.index, message = s"Unable to delete library item. ${e.getMessage}") + val status: Status = Status(code = Code.INVALID_ARGUMENT.value, message = s"Unable to delete library item. ${e.getMessage}") + Future.successful(pb.DeleteLibraryItemResponse(status = Some(status))) + // Deleting something that isn't there is a client-visible NOT_FOUND, not a 500 - it + // reaches here whenever the id has no file (previously this reported OK silently). + case e: NoSuchElementException => + logger.warn(s"Library item to delete not found: ${e.getMessage}") + val status: Status = Status(code = Code.NOT_FOUND.value, message = s"Unable to delete library item. ${e.getMessage}") Future.successful(pb.DeleteLibraryItemResponse(status = Some(status))) case e: Exception => logger.warn(s"Failed to delete library item: ${e.getMessage}") - val status: Status = Status(code = Code.INTERNAL.index, message = "Unable to delete library item") + val status: Status = Status(code = Code.INTERNAL.value, message = "Unable to delete library item") Future.successful(pb.DeleteLibraryItemResponse(status = Some(status))) } @@ -91,17 +101,17 @@ class LibraryServiceImpl extends pb.LibraryServiceGrpc.LibraryService: val libraryItem = library.getItemById(request.id) if libraryItem.isEmpty then - val status: Status = Status(code = Code.NOT_FOUND.index) + val status: Status = Status(code = Code.NOT_FOUND.value) return Future.successful(pb.GetLibraryItemResponse(status = Some(status))) val libraryItemPb = LibraryItem.toPb(libraryItem.get) - val status: Status = Status(code = Code.OK.index) + val status: Status = Status(code = Code.OK.value) Future.successful(pb.GetLibraryItemResponse(status = Some(status), item = Some(libraryItemPb))) } catch { case e: Exception => logger.warn(s"Failed to get library item: ${e.getMessage}") - val status: Status = Status(code = Code.INTERNAL.index, message = s"Unable to get library item. ${e.getMessage}") + val status: Status = Status(code = Code.INTERNAL.value, message = s"Unable to get library item. ${e.getMessage}") Future.successful(pb.GetLibraryItemResponse(status = Some(status))) } @@ -117,16 +127,16 @@ class LibraryServiceImpl extends pb.LibraryServiceGrpc.LibraryService: val libraryItemsPb = libraryItems.map(LibraryItem.toPb) - val status: Status = Status(code = Code.OK.index) + val status: Status = Status(code = Code.OK.value) Future.successful(pb.ListLibraryItemsResponse(status = Some(status), items = libraryItemsPb)) } catch { case e: IllegalArgumentException => logger.warn(s"Rejected library items list: ${e.getMessage}") - val status: Status = Status(code = Code.INVALID_ARGUMENT.index, message = s"Unable to list library items. ${e.getMessage}") + val status: Status = Status(code = Code.INVALID_ARGUMENT.value, message = s"Unable to list library items. ${e.getMessage}") Future.successful(pb.ListLibraryItemsResponse(status = Some(status))) case e: Exception => logger.warn(s"Failed to list library items: ${e.getMessage}") - val status: Status = Status(code = Code.INTERNAL.index, message = s"Unable to list library items. ${e.getMessage}") + val status: Status = Status(code = Code.INTERNAL.value, message = s"Unable to list library items. ${e.getMessage}") Future.successful(pb.ListLibraryItemsResponse(status = Some(status))) } @@ -150,7 +160,7 @@ class LibraryServiceImpl extends pb.LibraryServiceGrpc.LibraryService: ) ).toVector - val status: Status = Status(code = Code.OK.index) + val status: Status = Status(code = Code.OK.value) Future.successful(pb.GetLibraryItemsCountResponse( status = Some(status), itemCountPerType = itemCountPerType @@ -158,10 +168,10 @@ class LibraryServiceImpl extends pb.LibraryServiceGrpc.LibraryService: } catch { case e: IllegalArgumentException => logger.warn(s"Rejected library items count: ${e.getMessage}") - val status: Status = Status(code = Code.INVALID_ARGUMENT.index, message = s"Unable to get library items count. ${e.getMessage}") + val status: Status = Status(code = Code.INVALID_ARGUMENT.value, message = s"Unable to get library items count. ${e.getMessage}") Future.successful(pb.GetLibraryItemsCountResponse(status = Some(status))) case e: Exception => logger.warn(s"Failed to get library items count: ${e.getMessage}") - val status: Status = Status(code = Code.INTERNAL.index, message = s"Unable to get library items count. ${e.getMessage}") + val status: Status = Status(code = Code.INTERNAL.value, message = s"Unable to get library items count. ${e.getMessage}") Future.successful(pb.GetLibraryItemsCountResponse(status = Some(status))) } diff --git a/server/src/main/scala/library/managed_items/ManagedConsumerSessionStartFrom.scala b/server/src/main/scala/library/managed_items/ManagedConsumerSessionStartFrom.scala index 80a6ecd6b..e96ae579c 100644 --- a/server/src/main/scala/library/managed_items/ManagedConsumerSessionStartFrom.scala +++ b/server/src/main/scala/library/managed_items/ManagedConsumerSessionStartFrom.scala @@ -3,11 +3,18 @@ package library.managed_items import com.tools.teal.pulsar.ui.library.v1.managed_items as pb import com.tools.teal.pulsar.ui.api.v1.consumer as consumerPb import library.{ManagedItemMetadata, ManagedItemReference, ManagedItemTrait} -import _root_.consumer.start_from.{EarliestMessage, LatestMessage, NthMessageAfterEarliest, NthMessageBeforeLatest} +import _root_.consumer.start_from.{ + ApproximateDataPosition, + ApproximateTimePosition, + EarliestMessage, + LatestMessage, + NthMessageAfterEarliest, + NthMessageBeforeLatest +} case class ManagedConsumerSessionStartFromSpec( - startFrom: EarliestMessage | LatestMessage | ManagedConsumerSessionStartFromValOrRef | ManagedMessageIdValOrRef | ManagedDateTimeValOrRef | - ManagedRelativeDateTimeValOrRef | NthMessageBeforeLatest | NthMessageAfterEarliest + startFrom: EarliestMessage | LatestMessage | ManagedMessageIdValOrRef | ManagedDateTimeValOrRef | + ManagedRelativeDateTimeValOrRef | NthMessageBeforeLatest | NthMessageAfterEarliest | ApproximateDataPosition | ApproximateTimePosition ) object ManagedConsumerSessionStartFromSpec: @@ -26,6 +33,10 @@ object ManagedConsumerSessionStartFromSpec: ManagedConsumerSessionStartFromSpec(startFrom = NthMessageAfterEarliest.fromPb(sf.value)) case sf: pb.ManagedConsumerSessionStartFromSpec.StartFrom.StartFromNthMessageBeforeLatest => ManagedConsumerSessionStartFromSpec(startFrom = NthMessageBeforeLatest.fromPb(sf.value)) + case sf: pb.ManagedConsumerSessionStartFromSpec.StartFrom.StartFromApproximateDataPosition => + ManagedConsumerSessionStartFromSpec(startFrom = ApproximateDataPosition.fromPb(sf.value)) + case sf: pb.ManagedConsumerSessionStartFromSpec.StartFrom.StartFromApproximateTimePosition => + ManagedConsumerSessionStartFromSpec(startFrom = ApproximateTimePosition.fromPb(sf.value)) case _ => throw new IllegalArgumentException("Invalid ManagedConsumerSessionStartFromSpec type") @@ -59,6 +70,14 @@ object ManagedConsumerSessionStartFromSpec: pb.ManagedConsumerSessionStartFromSpec( startFrom = pb.ManagedConsumerSessionStartFromSpec.StartFrom.StartFromNthMessageBeforeLatest(NthMessageBeforeLatest.toPb(v)) ) + case v: ApproximateDataPosition => + pb.ManagedConsumerSessionStartFromSpec( + startFrom = pb.ManagedConsumerSessionStartFromSpec.StartFrom.StartFromApproximateDataPosition(ApproximateDataPosition.toPb(v)) + ) + case v: ApproximateTimePosition => + pb.ManagedConsumerSessionStartFromSpec( + startFrom = pb.ManagedConsumerSessionStartFromSpec.StartFrom.StartFromApproximateTimePosition(ApproximateTimePosition.toPb(v)) + ) case _ => throw new IllegalArgumentException("Invalid ManagedConsumerSessionStartFromSpec type") diff --git a/server/src/main/scala/library/managed_items/ManagedRelativeDateTime.scala b/server/src/main/scala/library/managed_items/ManagedRelativeDateTime.scala index f0d92a669..cc33ca3dc 100644 --- a/server/src/main/scala/library/managed_items/ManagedRelativeDateTime.scala +++ b/server/src/main/scala/library/managed_items/ManagedRelativeDateTime.scala @@ -12,6 +12,17 @@ case class ManagedRelativeDateTimeSpec( object ManagedRelativeDateTimeSpec: def fromPb(v: pb.ManagedRelativeDateTimeSpec): ManagedRelativeDateTimeSpec = + // Trust boundary: managed_items.proto stores `value` as int64, but a relative date-time can + // only ever be resolved into the api form (consumer.proto RelativeDateTime.value is int32). + // A library file written by a non-Dekaf client could persist a value that is negative or + // outside int32 range; accepting it would let the later narrowing to int32 silently truncate. + // Reject here (IllegalArgumentException -> INVALID_ARGUMENT on save, skipped file on scan), + // matching the other malformed-input guards, rather than storing a value that can never + // become a valid session request. + if v.value < 0 || v.value > Int.MaxValue then + throw new IllegalArgumentException( + s"Managed relative date-time value ${v.value} is out of range; it must be a non-negative int32 (0..${Int.MaxValue})." + ) ManagedRelativeDateTimeSpec( value = v.value, unit = DateTimeUnit.fromPb(v.unit), diff --git a/server/src/main/scala/library/resourceMatchersConversions.scala b/server/src/main/scala/library/resourceMatchersConversions.scala index 794746adf..c38d7d3fc 100644 --- a/server/src/main/scala/library/resourceMatchersConversions.scala +++ b/server/src/main/scala/library/resourceMatchersConversions.scala @@ -33,14 +33,41 @@ def tenantMatcherToPb(v: TenantMatcher): pb.TenantMatcher = case v: AllTenantMatcher => pb.TenantMatcher(matcher = pb.TenantMatcher.Matcher.All(allTenantMatcherToPb(v))) +/** Nested matcher messages are proto3 optional, so an older/partial client can leave them unset. + * `.get` turned that into NoSuchElementException, which LibraryServiceImpl maps to INTERNAL (a + * 500 for a client-side mistake); IllegalArgumentException is the INVALID_ARGUMENT channel used + * by the oneof guards in this same file. */ +private def required[A](field: Option[A], name: String): A = + field.getOrElse(throw new IllegalArgumentException(s"Missing required field: $name")) + def exactNamespaceMatcherFromPb(v: pb.ExactNamespaceMatcher): ExactNamespaceMatcher = - ExactNamespaceMatcher(tenant = tenantMatcherFromPb(v.tenant.get), namespace = v.namespace) + ExactNamespaceMatcher(tenant = tenantMatcherFromPb(required(v.tenant, "ExactNamespaceMatcher.tenant")), namespace = v.namespace) def exactNamespaceMatcherToPb(v: ExactNamespaceMatcher): pb.ExactNamespaceMatcher = pb.ExactNamespaceMatcher(tenant = Some(tenantMatcherToPb(v.tenant)), namespace = v.namespace) +/** `AllNamespaceMatcher.namespace_regex` (proto field 2) is NOT implemented, and the model has no + * field for it - "all namespaces of a matching tenant" is the whole meaning of this matcher. + * + * It stays unimplemented deliberately rather than by oversight: `test` compares one MATCHER against + * another, not a matcher against a concrete namespace, so an AllNamespaceMatcher tested against + * another AllNamespaceMatcher would have to decide whether one regex subsumes another - undecidable + * in general. There is no honest semantics to give it for that case. + * + * So a set regex is REFUSED. Accepting it and dropping it returned a matcher covering every + * namespace of the tenant to a caller that asked for a subset - success plus a silently WIDER + * access scope. A server-side warning does not reach that caller; only an error does. + * IllegalArgumentException is the INVALID_ARGUMENT channel used by the other malformed-input guards + * in this file. An UNSET regex (the proto default) still converts, so every stored item and every + * request the UI makes are untouched - nothing writes the field. + */ def allNamespaceMatcherFromPb(v: pb.AllNamespaceMatcher): AllNamespaceMatcher = - AllNamespaceMatcher(tenant = tenantMatcherFromPb(v.tenant.get)) + if v.namespaceRegex.nonEmpty then + throw new IllegalArgumentException( + s"AllNamespaceMatcher.namespace_regex is not implemented (got '${v.namespaceRegex}'). " + + "This matcher covers EVERY namespace of the matching tenant; use ExactNamespaceMatcher to narrow the scope." + ) + AllNamespaceMatcher(tenant = tenantMatcherFromPb(required(v.tenant, "AllNamespaceMatcher.tenant"))) def allNamespaceMatcherToPb(v: AllNamespaceMatcher): pb.AllNamespaceMatcher = pb.AllNamespaceMatcher(tenant = Some(tenantMatcherToPb(v.tenant))) @@ -62,7 +89,7 @@ def namespaceMatcherToPb(v: NamespaceMatcher): pb.NamespaceMatcher = def exactTopicMatcherFromPb(v: pb.ExactTopicMatcher): ExactTopicMatcher = ExactTopicMatcher( - namespace = namespaceMatcherFromPb(v.namespace.get), + namespace = namespaceMatcherFromPb(required(v.namespace, "ExactTopicMatcher.namespace")), topic = v.topic ) @@ -74,7 +101,7 @@ def exactTopicMatcherToPb(v: ExactTopicMatcher): pb.ExactTopicMatcher = def allTopicMatcherFromPb(v: pb.AllTopicMatcher): AllTopicMatcher = AllTopicMatcher( - namespace = namespaceMatcherFromPb(v.namespace.get) + namespace = namespaceMatcherFromPb(required(v.namespace, "AllTopicMatcher.namespace")) ) def allTopicMatcherToPb(v: AllTopicMatcher): pb.AllTopicMatcher = diff --git a/server/src/main/scala/metrics/MetricsServiceImpl.scala b/server/src/main/scala/metrics/MetricsServiceImpl.scala index 55382eced..11acad97b 100644 --- a/server/src/main/scala/metrics/MetricsServiceImpl.scala +++ b/server/src/main/scala/metrics/MetricsServiceImpl.scala @@ -41,14 +41,14 @@ class MetricsServiceImpl extends MetricsServiceGrpc.MetricsService: (namespace, getOptionalNamespaceMetricsPb(metricsEntries, namespace)) ).toMap - val status: Status = Status(code = Code.OK.index) + val status: Status = Status(code = Code.OK.value) Future.successful(GetNamespacesMetricsResponse( status = Some(status), namespacesMetrics = optionalNamespacesMetrics )) } catch { case err => - val status: Status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status: Status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(GetNamespacesMetricsResponse(status = Some(status))) } @@ -58,14 +58,14 @@ class MetricsServiceImpl extends MetricsServiceGrpc.MetricsService: (namespace, getOptionalNamespacePersistentMetricsPb(metricsEntries, namespace)) ).toMap - val status: Status = Status(code = Code.OK.index) + val status: Status = Status(code = Code.OK.value) Future.successful(GetNamespacesPersistentMetricsResponse( status = Some(status), namespacesPersistentMetrics = optionalNamespacesPersistentMetrics )) } catch { case err => - val status: Status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status: Status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(GetNamespacesPersistentMetricsResponse(status = Some(status))) } @@ -76,14 +76,14 @@ class MetricsServiceImpl extends MetricsServiceGrpc.MetricsService: // (namespace, getOptionalTenMetricsPb(metricsEntries, namespace)) // ).toMap // -// val status: Status = Status(code = Code.OK.index) +// val status: Status = Status(code = Code.OK.value) // Future.successful(GetTenantsMetricsResponse( // status = Some(status), // namespacesMetrics = optionalNamespacesMetrics // )) // } catch { // case err => -// val status: Status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) +// val status: Status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) // Future.successful(GetTenantsMetricsResponse(status = Some(status))) // } diff --git a/server/src/main/scala/namespace/NamespaceServiceImpl.scala b/server/src/main/scala/namespace/NamespaceServiceImpl.scala index 6bd6aa30e..22a4f779a 100644 --- a/server/src/main/scala/namespace/NamespaceServiceImpl.scala +++ b/server/src/main/scala/namespace/NamespaceServiceImpl.scala @@ -89,11 +89,11 @@ class NamespaceServiceImpl extends NamespaceServiceGrpc.NamespaceService: try { adminClient.namespaces.createNamespace(request.namespaceName, policies) - val status = Status(code = Code.OK.index) + val status = Status(code = Code.OK.value) Future.successful(CreateNamespaceResponse(status = Some(status))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(CreateNamespaceResponse(status = Some(status))) } @@ -104,11 +104,11 @@ class NamespaceServiceImpl extends NamespaceServiceGrpc.NamespaceService: try { adminClient.namespaces.deleteNamespace(request.namespaceName, request.force) - val status = Status(code = Code.OK.index) + val status = Status(code = Code.OK.value) Future.successful(DeleteNamespaceResponse(status = Some(status))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(DeleteNamespaceResponse(status = Some(status))) } @@ -121,11 +121,11 @@ class NamespaceServiceImpl extends NamespaceServiceGrpc.NamespaceService: adminClient.namespaces.getNamespaces(request.tenant).asScala catch { case err: Exception => - val status: Status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status: Status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) return Future.successful(pb.ListNamespacesResponse(status = Some(status))) } - val status: Status = Status(code = Code.OK.index) + val status: Status = Status(code = Code.OK.value) Future.successful(pb.ListNamespacesResponse(status = Some(status), namespaces = namespaces.toSeq)) override def getTopicsCount(request: GetTopicsCountRequest): Future[GetTopicsCountResponse] = @@ -194,7 +194,7 @@ class NamespaceServiceImpl extends NamespaceServiceGrpc.NamespaceService: (ns, count) ).toMap - val status = Status(code = Code.OK.index) + val status = Status(code = Code.OK.value) Future.successful( GetTopicsCountResponse( status = Some(status), @@ -206,7 +206,7 @@ class NamespaceServiceImpl extends NamespaceServiceGrpc.NamespaceService: ) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(GetTopicsCountResponse(status = Some(status))) } @@ -225,19 +225,19 @@ class NamespaceServiceImpl extends NamespaceServiceGrpc.NamespaceService: try { val permissions = Option(adminClient.namespaces.getPermissions(request.namespace).asScala.toMap) match case None => - val status = Status(code = Code.INTERNAL.index) + val status = Status(code = Code.INTERNAL.value) return Future.successful(GetPermissionsResponse(status = Some(status))) case Some(v) => v.map(x => x._1 -> new pb.AuthActions(authActions = x._2.asScala.toList.map(authActionToPb))) Future.successful( GetPermissionsResponse( - status = Some(Status(code = Code.OK.index)), + status = Some(Status(code = Code.OK.value)), permissions ) ) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(GetPermissionsResponse(status = Some(status))) } override def grantPermissions(request: GrantPermissionsRequest): Future[GrantPermissionsResponse] = @@ -258,7 +258,7 @@ class NamespaceServiceImpl extends NamespaceServiceGrpc.NamespaceService: if permissions.exists(_._1 == request.role && request.existenceCheck) then val status = Status( - code = Code.FAILED_PRECONDITION.index, + code = Code.FAILED_PRECONDITION.value, message = s"There are already granted permissions for this role: ${request.role}. Please choose another role name." ) return Future.successful(GrantPermissionsResponse(status = Some(status))) @@ -267,12 +267,12 @@ class NamespaceServiceImpl extends NamespaceServiceGrpc.NamespaceService: Future.successful( GrantPermissionsResponse( - status = Some(Status(code = Code.OK.index)) + status = Some(Status(code = Code.OK.value)) ) ) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(GrantPermissionsResponse(status = Some(status))) } @@ -285,12 +285,12 @@ class NamespaceServiceImpl extends NamespaceServiceGrpc.NamespaceService: Future.successful( RevokePermissionsResponse( - status = Some(Status(code = Code.OK.index)) + status = Some(Status(code = Code.OK.value)) ) ) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(RevokePermissionsResponse(status = Some(status))) } @@ -301,7 +301,7 @@ class NamespaceServiceImpl extends NamespaceServiceGrpc.NamespaceService: try { val permissions = Option(adminClient.namespaces.getPermissionOnSubscription(request.namespace).asScala.toMap) match case None => - val status = Status(code = Code.INTERNAL.index) + val status = Status(code = Code.INTERNAL.value) return Future.successful(GetPermissionOnSubscriptionResponse(status = Some(status))) case Some(v) => v.collect { @@ -312,14 +312,14 @@ class NamespaceServiceImpl extends NamespaceServiceGrpc.NamespaceService: Future.successful( GetPermissionOnSubscriptionResponse( - status = Some(Status(code = Code.OK.index)), + status = Some(Status(code = Code.OK.value)), permissions, roles ) ) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(GetPermissionOnSubscriptionResponse(status = Some(status))) } @@ -332,7 +332,7 @@ class NamespaceServiceImpl extends NamespaceServiceGrpc.NamespaceService: if permissions.exists(_._1 == request.subscription && request.existenceCheck) then val status = Status( - code = Code.FAILED_PRECONDITION.index, + code = Code.FAILED_PRECONDITION.value, message = s"There are already assigned roles for this subscription: ${request.subscription}. Please choose another subscription name." ) return Future.successful(GrantPermissionOnSubscriptionResponse(status = Some(status))) @@ -340,12 +340,12 @@ class NamespaceServiceImpl extends NamespaceServiceGrpc.NamespaceService: adminClient.namespaces.grantPermissionOnSubscription(request.namespace, request.subscription, request.roles.toSet.asJava) Future.successful( GrantPermissionOnSubscriptionResponse( - status = Some(Status(code = Code.OK.index)) + status = Some(Status(code = Code.OK.value)) ) ) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(GrantPermissionOnSubscriptionResponse(status = Some(status))) } @@ -358,12 +358,12 @@ class NamespaceServiceImpl extends NamespaceServiceGrpc.NamespaceService: Future.successful( RevokePermissionOnSubscriptionResponse( - status = Some(Status(code = Code.OK.index)) + status = Some(Status(code = Code.OK.value)) ) ) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(RevokePermissionOnSubscriptionResponse(status = Some(status))) } @@ -382,13 +382,13 @@ class NamespaceServiceImpl extends NamespaceServiceGrpc.NamespaceService: Future.successful( GetPropertiesResponse( - status = Some(Status(code = Code.OK.index)), + status = Some(Status(code = Code.OK.value)), properties ) ) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(GetPropertiesResponse(status = Some(status))) } override def setProperties(request: SetPropertiesRequest): Future[SetPropertiesResponse] = @@ -405,12 +405,12 @@ class NamespaceServiceImpl extends NamespaceServiceGrpc.NamespaceService: Future.successful( SetPropertiesResponse( - status = Some(Status(code = Code.OK.index)) + status = Some(Status(code = Code.OK.value)) ) ) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(SetPropertiesResponse(status = Some(status))) } @@ -423,12 +423,12 @@ class NamespaceServiceImpl extends NamespaceServiceGrpc.NamespaceService: Future.successful( UnloadNamespaceResponse( - status = Some(Status(code = Code.OK.index)) + status = Some(Status(code = Code.OK.value)) ) ) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(UnloadNamespaceResponse(status = Some(status))) } @@ -441,12 +441,12 @@ class NamespaceServiceImpl extends NamespaceServiceGrpc.NamespaceService: Future.successful( UnloadNamespaceBundleResponse( - status = Some(Status(code = Code.OK.index)) + status = Some(Status(code = Code.OK.value)) ) ) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(UnloadNamespaceBundleResponse(status = Some(status))) } @@ -459,12 +459,12 @@ class NamespaceServiceImpl extends NamespaceServiceGrpc.NamespaceService: Future.successful( ClearNamespaceBacklogResponse( - status = Some(Status(code = Code.OK.index)) + status = Some(Status(code = Code.OK.value)) ) ) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(ClearNamespaceBacklogResponse(status = Some(status))) } @@ -477,12 +477,12 @@ class NamespaceServiceImpl extends NamespaceServiceGrpc.NamespaceService: Future.successful( ClearBundleBacklogResponse( - status = Some(Status(code = Code.OK.index)) + status = Some(Status(code = Code.OK.value)) ) ) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(ClearBundleBacklogResponse(status = Some(status))) } @@ -502,12 +502,12 @@ class NamespaceServiceImpl extends NamespaceServiceGrpc.NamespaceService: Future.successful( SplitNamespaceBundleResponse( - status = Some(Status(code = Code.OK.index)) + status = Some(Status(code = Code.OK.value)) ) ) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(SplitNamespaceBundleResponse(status = Some(status))) } @@ -520,12 +520,12 @@ class NamespaceServiceImpl extends NamespaceServiceGrpc.NamespaceService: Future.successful( GetBundlesResponse( - status = Some(Status(code = Code.OK.index)), + status = Some(Status(code = Code.OK.value)), bundles = Option(bundles.getBoundaries).map(_.asScala.toSeq).getOrElse(Seq.empty).sliding(2).map { case List(a, b) => s"${a}_$b" }.toSeq ) ) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(GetBundlesResponse(status = Some(status))) } diff --git a/server/src/main/scala/namespace_policies/NamespacePoliciesServiceImpl.scala b/server/src/main/scala/namespace_policies/NamespacePoliciesServiceImpl.scala index da4a26a06..5f4a3330e 100644 --- a/server/src/main/scala/namespace_policies/NamespacePoliciesServiceImpl.scala +++ b/server/src/main/scala/namespace_policies/NamespacePoliciesServiceImpl.scala @@ -24,7 +24,7 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac try { val isAllowAutoUpdateSchema = adminClient.namespaces.getIsAllowAutoUpdateSchema(request.namespace) - val status = Status(code = Code.OK.index) + val status = Status(code = Code.OK.value) Future.successful( GetIsAllowAutoUpdateSchemaResponse( status = Some(status), @@ -33,7 +33,7 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac ) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(GetIsAllowAutoUpdateSchemaResponse(status = Some(status))) } @@ -43,11 +43,11 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac try { adminClient.namespaces.setIsAllowAutoUpdateSchema(request.namespace, request.isAllowAutoUpdateSchema) - val status = Status(code = Code.OK.index) + val status = Status(code = Code.OK.value) Future.successful(SetIsAllowAutoUpdateSchemaResponse(status = Some(status))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(SetIsAllowAutoUpdateSchemaResponse(status = Some(status))) } @@ -56,7 +56,7 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac try { val strategy = adminClient.namespaces.getSchemaCompatibilityStrategy(request.namespace) - val status = Status(code = Code.OK.index) + val status = Status(code = Code.OK.value) Future.successful( GetSchemaCompatibilityStrategyResponse( status = Some(status), @@ -65,7 +65,7 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac ) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(GetSchemaCompatibilityStrategyResponse(status = Some(status))) } @@ -80,11 +80,11 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac request.namespace, schemaCompatibilityStrategyFromPb(request.strategy) ) - val status = Status(code = Code.OK.index) + val status = Status(code = Code.OK.value) Future.successful(SetSchemaCompatibilityStrategyResponse(status = Some(status))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(SetSchemaCompatibilityStrategyResponse(status = Some(status))) } @@ -94,7 +94,7 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac try { val schemaValidationEnforced = adminClient.namespaces.getSchemaValidationEnforced(request.namespace) - val status = Status(code = Code.OK.index) + val status = Status(code = Code.OK.value) Future.successful( GetSchemaValidationEnforceResponse( status = Some(status), @@ -103,7 +103,7 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac ) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(GetSchemaValidationEnforceResponse(status = Some(status))) } @@ -113,11 +113,11 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac try { logger.info(s"Setting schema validation enforce policy for namespace ${request.namespace}") adminClient.namespaces.setSchemaValidationEnforced(request.namespace, request.schemaValidationEnforced) - val status = Status(code = Code.OK.index) + val status = Status(code = Code.OK.value) Future.successful(SetSchemaValidationEnforceResponse(status = Some(status))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(SetSchemaValidationEnforceResponse(status = Some(status))) } @@ -133,13 +133,13 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac Future.successful( GetAutoSubscriptionCreationResponse( - status = Some(Status(code = Code.OK.index)), + status = Some(Status(code = Code.OK.value)), autoSubscriptionCreation = autoSubscriptionCreationPb ) ) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(GetAutoSubscriptionCreationResponse(status = Some(status))) } @@ -155,14 +155,14 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac case pb.AutoSubscriptionCreation.AUTO_SUBSCRIPTION_CREATION_DISABLED => AutoSubscriptionCreationOverride.builder.allowAutoSubscriptionCreation(false).build() case _ => - val status = Status(code = Code.INVALID_ARGUMENT.index, message = "Wrong allow subscription creation argument received") + val status = Status(code = Code.INVALID_ARGUMENT.value, message = "Wrong allow subscription creation argument received") return Future.successful(SetAutoSubscriptionCreationResponse(status = Some(status))) adminClient.namespaces.setAutoSubscriptionCreation(request.namespace, autoSubscriptionCreationOverride) - Future.successful(SetAutoSubscriptionCreationResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(SetAutoSubscriptionCreationResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(SetAutoSubscriptionCreationResponse(status = Some(status))) } @@ -173,10 +173,10 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac try { logger.info(s"Removing auto subscription creation policy for namespace ${request.namespace}") adminClient.namespaces.removeAutoSubscriptionCreation(request.namespace) - Future.successful(RemoveAutoSubscriptionCreationResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(RemoveAutoSubscriptionCreationResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(RemoveAutoSubscriptionCreationResponse(status = Some(status))) } @@ -205,14 +205,14 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac Future.successful( GetAutoTopicCreationResponse( - status = Some(Status(code = Code.OK.index)), + status = Some(Status(code = Code.OK.value)), autoTopicCreation = autoTopicCreationPb, autoTopicCreationOverride = autoTopicCreationOverridePb ) ) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(GetAutoTopicCreationResponse(status = Some(status))) } @@ -221,13 +221,13 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac val adminClient = RequestContext.pulsarAdmin.get() if !request.autoTopicCreation.isAutoTopicCreationSpecified then - val status = Status(code = Code.FAILED_PRECONDITION.index) + val status = Status(code = Code.FAILED_PRECONDITION.value) return Future.successful(SetAutoTopicCreationResponse(status = Some(status))) val autoTopicCreationOverridePb = request.autoTopicCreationOverride match case Some(v) => v case _ => - val status = Status(code = Code.FAILED_PRECONDITION.index) + val status = Status(code = Code.FAILED_PRECONDITION.value) return Future.successful(SetAutoTopicCreationResponse(status = Some(status))) try { @@ -250,10 +250,10 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac .build adminClient.namespaces.setAutoTopicCreation(request.namespace, autoTopicCreation) - Future.successful(SetAutoTopicCreationResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(SetAutoTopicCreationResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(SetAutoTopicCreationResponse(status = Some(status))) } @@ -263,10 +263,10 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac try { adminClient.namespaces.removeAutoTopicCreation(request.namespace) - Future.successful(RemoveAutoTopicCreationResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(RemoveAutoTopicCreationResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(RemoveAutoTopicCreationResponse(status = Some(status))) } @@ -307,14 +307,14 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac Future.successful( GetBacklogQuotasResponse( - status = Some(Status(code = Code.OK.index)), + status = Some(Status(code = Code.OK.value)), destinationStorage = destinationStorageBacklogQuotaPb, messageAge = messageAgeBacklogQuotaPb ) ) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(GetBacklogQuotasResponse(status = Some(status))) } @@ -361,10 +361,10 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac adminClient.namespaces.setBacklogQuota(request.namespace, backlogQuota, BacklogQuotaType.message_age) case None => - Future.successful(SetBacklogQuotasResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(SetBacklogQuotasResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(SetBacklogQuotasResponse(status = Some(status))) } @@ -380,13 +380,13 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac logger.info(s"Removing backlog quota (message age) on namespace ${request.namespace}") adminClient.namespaces.removeBacklogQuota(request.namespace, BacklogQuotaType.message_age) case _ => - val status = Status(code = Code.INVALID_ARGUMENT.index, message = "Backlog quota type should be specified") + val status = Status(code = Code.INVALID_ARGUMENT.value, message = "Backlog quota type should be specified") return Future.successful(RemoveBacklogQuotaResponse(status = Some(status))) - Future.successful(RemoveBacklogQuotaResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(RemoveBacklogQuotaResponse(status = Some(Status(code = Code.OK.value)))) catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(RemoveBacklogQuotaResponse(status = Some(status))) } @@ -398,13 +398,13 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac Future.successful( GetNamespaceAntiAffinityGroupResponse( - status = Some(Status(code = Code.OK.index)), + status = Some(Status(code = Code.OK.value)), namespaceAntiAffinityGroup = namespaceAntiAffinityGroup ) ) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(GetNamespaceAntiAffinityGroupResponse(status = Some(status))) } @@ -413,10 +413,10 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac try adminClient.namespaces.setNamespaceAntiAffinityGroup(request.namespace, request.namespaceAntiAffinityGroup) - Future.successful(SetNamespaceAntiAffinityGroupResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(SetNamespaceAntiAffinityGroupResponse(status = Some(Status(code = Code.OK.value)))) catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(SetNamespaceAntiAffinityGroupResponse(status = Some(status))) } @@ -425,10 +425,10 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac try adminClient.namespaces.deleteNamespaceAntiAffinityGroup(request.namespace) - Future.successful(RemoveNamespaceAntiAffinityGroupResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(RemoveNamespaceAntiAffinityGroupResponse(status = Some(Status(code = Code.OK.value)))) catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(RemoveNamespaceAntiAffinityGroupResponse(status = Some(status))) } @@ -446,13 +446,13 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac .toList Future.successful( GetAntiAffinityNamespacesResponse( - status = Some(Status(code = Code.OK.index)), + status = Some(Status(code = Code.OK.value)), namespaces ) ) catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(GetAntiAffinityNamespacesResponse(status = Some(status))) } @@ -468,13 +468,13 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac case None => None Future.successful( GetBookieAffinityGroupResponse( - status = Some(Status(code = Code.OK.index)), + status = Some(Status(code = Code.OK.value)), groupData = groupData ) ) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(GetBookieAffinityGroupResponse(status = Some(status))) } @@ -491,10 +491,10 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac logger.info(s"Setting bookie affinity group for namespace ${request.namespace}. $groupData") adminClient.namespaces.setBookieAffinityGroup(request.namespace, groupData) - Future.successful(SetBookieAffinityGroupResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(SetBookieAffinityGroupResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(SetBookieAffinityGroupResponse(status = Some(status))) } @@ -504,10 +504,10 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac try { logger.info(s"Removing bookie affinity group policy for namespace ${request.namespace}") adminClient.namespaces.deleteBookieAffinityGroup(request.namespace) - Future.successful(RemoveBookieAffinityGroupResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(RemoveBookieAffinityGroupResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(RemoveBookieAffinityGroupResponse(status = Some(status))) } @@ -519,12 +519,12 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac case None => pb.GetCompactionThresholdResponse.Threshold.Disabled(new pb.CompactionThresholdDisabled()) case Some(v) => pb.GetCompactionThresholdResponse.Threshold.Enabled(new CompactionThresholdEnabled(threshold = v)) Future.successful(GetCompactionThresholdResponse( - status = Some(Status(code = Code.OK.index)), + status = Some(Status(code = Code.OK.value)), threshold )) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(GetCompactionThresholdResponse(status = Some(status))) } @@ -534,10 +534,10 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac try { logger.info(s"Setting compaction threshold policy for namespace ${request.namespace}. ${request.threshold}") adminClient.namespaces.setCompactionThreshold(request.namespace, request.threshold) - Future.successful(SetCompactionThresholdResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(SetCompactionThresholdResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(SetCompactionThresholdResponse(status = Some(status))) } @@ -547,10 +547,10 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac try { logger.info(s"Removing compaction threshold policy for namespace ${request.namespace}") adminClient.namespaces.removeCompactionThreshold(request.namespace) - Future.successful(RemoveCompactionThresholdResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(RemoveCompactionThresholdResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(RemoveCompactionThresholdResponse(status = Some(status))) } @@ -565,12 +565,12 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac pb.GetDeduplicationSnapshotIntervalResponse.Interval.Enabled(new DeduplicationSnapshotIntervalEnabled(interval = v)) Future.successful(GetDeduplicationSnapshotIntervalResponse( - status = Some(Status(code = Code.OK.index)), + status = Some(Status(code = Code.OK.value)), interval )) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(GetDeduplicationSnapshotIntervalResponse(status = Some(status))) } @@ -580,10 +580,10 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac try { logger.info(s"Setting deduplication snapshot interval policy for namespace ${request.namespace}. ${request.interval}") adminClient.namespaces.setDeduplicationSnapshotInterval(request.namespace, request.interval) - Future.successful(SetDeduplicationSnapshotIntervalResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(SetDeduplicationSnapshotIntervalResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(SetDeduplicationSnapshotIntervalResponse(status = Some(status))) } @@ -593,10 +593,10 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac try { logger.info(s"Removing deduplication snapshot interval policy for namespace ${request.namespace}") adminClient.namespaces.removeDeduplicationSnapshotInterval(request.namespace) - Future.successful(RemoveDeduplicationSnapshotIntervalResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(RemoveDeduplicationSnapshotIntervalResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(RemoveDeduplicationSnapshotIntervalResponse(status = Some(status))) } @@ -611,12 +611,12 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac pb.GetDeduplicationResponse.Deduplication.Specified(new DeduplicationSpecified(enabled = v)) Future.successful(GetDeduplicationResponse( - status = Some(Status(code = Code.OK.index)), + status = Some(Status(code = Code.OK.value)), deduplication )) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(GetDeduplicationResponse(status = Some(status))) } @@ -626,10 +626,10 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac try { logger.info(s"Setting deduplication policy for namespace ${request.namespace}") adminClient.namespaces.setDeduplicationStatus(request.namespace, request.enabled) - Future.successful(SetDeduplicationResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(SetDeduplicationResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(SetDeduplicationResponse(status = Some(status))) } @@ -639,10 +639,10 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac try { logger.info(s"Removing deduplication policy for namespace ${request.namespace}") adminClient.namespaces.removeDeduplicationStatus(request.namespace) - Future.successful(RemoveDeduplicationResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(RemoveDeduplicationResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(RemoveDeduplicationResponse(status = Some(status))) } @@ -660,12 +660,12 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac )) Future.successful(GetDelayedDeliveryResponse( - status = Some(Status(code = Code.OK.index)), + status = Some(Status(code = Code.OK.value)), delayedDelivery = delayedDeliveryPb )) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(GetDelayedDeliveryResponse(status = Some(status))) } @@ -680,10 +680,10 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac .build() adminClient.namespaces.setDelayedDeliveryMessages(request.namespace, delayedDeliveryPolicies) - Future.successful(SetDelayedDeliveryResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(SetDelayedDeliveryResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(SetDelayedDeliveryResponse(status = Some(status))) } @@ -693,10 +693,10 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac try { logger.info(s"Removing delayed delivery policy for namespace ${request.namespace}") adminClient.namespaces.removeDelayedDeliveryMessages(request.namespace) - Future.successful(RemoveDelayedDeliveryResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(RemoveDelayedDeliveryResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(RemoveDelayedDeliveryResponse(status = Some(status))) } @@ -716,12 +716,12 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac )) Future.successful(GetDispatchRateResponse( - status = Some(Status(code = Code.OK.index)), + status = Some(Status(code = Code.OK.value)), dispatchRate = dispatchRatePb )) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(GetDispatchRateResponse(status = Some(status))) } @@ -738,10 +738,10 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac .build adminClient.namespaces.setDispatchRate(request.namespace, dispatchRate) - Future.successful(SetDispatchRateResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(SetDispatchRateResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(SetDispatchRateResponse(status = Some(status))) } @@ -751,10 +751,10 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac try { logger.info(s"Removing dispatch rate policy for namespace ${request.namespace}") adminClient.namespaces.removeDispatchRate(request.namespace) - Future.successful(RemoveDispatchRateResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(RemoveDispatchRateResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(RemoveDispatchRateResponse(status = Some(status))) } @@ -764,17 +764,17 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac try { val encryptionRequired = Option(adminClient.namespaces.getEncryptionRequiredStatus(request.namespace)) match case None => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = "Can't fetch encryption status from broker") + val status = Status(code = Code.FAILED_PRECONDITION.value, message = "Can't fetch encryption status from broker") return Future.successful(GetEncryptionRequiredResponse(status = Some(status))) case Some(v) => v Future.successful(GetEncryptionRequiredResponse( - status = Some(Status(code = Code.OK.index)), + status = Some(Status(code = Code.OK.value)), encryptionRequired )) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(GetEncryptionRequiredResponse(status = Some(status))) } @@ -784,10 +784,10 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac try { logger.info(s"Setting encryption required policy for namespace ${request.namespace}") adminClient.namespaces.setEncryptionRequiredStatus(request.namespace, request.encryptionRequired) - Future.successful(SetEncryptionRequiredResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(SetEncryptionRequiredResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(SetEncryptionRequiredResponse(status = Some(status))) } @@ -812,12 +812,12 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac )) Future.successful(GetInactiveTopicPoliciesResponse( - status = Some(Status(code = Code.OK.index)), + status = Some(Status(code = Code.OK.value)), inactiveTopicPolicies = inactiveTopicPoliciesPb )) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(GetInactiveTopicPoliciesResponse(status = Some(status))) } @@ -840,10 +840,10 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac throw new IllegalArgumentException("Invalid inactiveTopicDeleteMode mode") adminClient.namespaces.setInactiveTopicPolicies(request.namespace, inactiveTopicPolicies) - Future.successful(SetInactiveTopicPoliciesResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(SetInactiveTopicPoliciesResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(SetInactiveTopicPoliciesResponse(status = Some(status))) } @@ -853,10 +853,10 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac try { logger.info(s"Removing inactive topic policies policy for namespace ${request.namespace}") adminClient.namespaces.removeInactiveTopicPolicies(request.namespace) - Future.successful(RemoveInactiveTopicPoliciesResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(RemoveInactiveTopicPoliciesResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(RemoveInactiveTopicPoliciesResponse(status = Some(status))) } @@ -873,12 +873,12 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac )) Future.successful(GetMaxConsumersPerSubscriptionResponse( - status = Some(Status(code = Code.OK.index)), + status = Some(Status(code = Code.OK.value)), maxConsumersPerSubscription = maxConsumersPerSubscriptionPb )) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(GetMaxConsumersPerSubscriptionResponse(status = Some(status))) } @@ -888,10 +888,10 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac try { logger.info(s"Setting max consumers per subscription policy for namespace ${request.namespace}") adminClient.namespaces.setMaxConsumersPerSubscription(request.namespace, request.maxConsumersPerSubscription) - Future.successful(SetMaxConsumersPerSubscriptionResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(SetMaxConsumersPerSubscriptionResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(SetMaxConsumersPerSubscriptionResponse(status = Some(status))) } @@ -901,10 +901,10 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac try { logger.info(s"Removing max consumers per subscription policy for namespace ${request.namespace}") adminClient.namespaces.removeMaxConsumersPerSubscription(request.namespace) - Future.successful(RemoveMaxConsumersPerSubscriptionResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(RemoveMaxConsumersPerSubscriptionResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(RemoveMaxConsumersPerSubscriptionResponse(status = Some(status))) } @@ -921,12 +921,12 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac )) Future.successful(GetMaxConsumersPerTopicResponse( - status = Some(Status(code = Code.OK.index)), + status = Some(Status(code = Code.OK.value)), maxConsumersPerTopic = maxConsumersPerTopicPb )) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(GetMaxConsumersPerTopicResponse(status = Some(status))) } @@ -936,10 +936,10 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac try { logger.info(s"Setting max consumers per topic policy for namespace ${request.namespace}") adminClient.namespaces.setMaxConsumersPerTopic(request.namespace, request.maxConsumersPerTopic) - Future.successful(SetMaxConsumersPerTopicResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(SetMaxConsumersPerTopicResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(SetMaxConsumersPerTopicResponse(status = Some(status))) } @@ -949,10 +949,10 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac try { logger.info(s"Removing max consumers per topic policy for namespace ${request.namespace}") adminClient.namespaces.removeMaxConsumersPerTopic(request.namespace) - Future.successful(RemoveMaxConsumersPerTopicResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(RemoveMaxConsumersPerTopicResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(RemoveMaxConsumersPerTopicResponse(status = Some(status))) } @@ -969,12 +969,12 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac )) Future.successful(GetMaxProducersPerTopicResponse( - status = Some(Status(code = Code.OK.index)), + status = Some(Status(code = Code.OK.value)), maxProducersPerTopic = maxProducersPerTopicPb )) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(GetMaxProducersPerTopicResponse(status = Some(status))) } @@ -984,10 +984,10 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac try { logger.info(s"Setting max producers per topic policy for namespace ${request.namespace}") adminClient.namespaces.setMaxProducersPerTopic(request.namespace, request.maxProducersPerTopic) - Future.successful(SetMaxProducersPerTopicResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(SetMaxProducersPerTopicResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(SetMaxProducersPerTopicResponse(status = Some(status))) } @@ -997,10 +997,10 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac try { logger.info(s"Removing max producers per topic policy for namespace ${request.namespace}") adminClient.namespaces.removeMaxProducersPerTopic(request.namespace) - Future.successful(RemoveMaxProducersPerTopicResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(RemoveMaxProducersPerTopicResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(RemoveMaxProducersPerTopicResponse(status = Some(status))) } @@ -1017,12 +1017,12 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac )) Future.successful(GetMaxSubscriptionsPerTopicResponse( - status = Some(Status(code = Code.OK.index)), + status = Some(Status(code = Code.OK.value)), maxSubscriptionsPerTopic = maxSubscriptionsPerTopicPb )) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(GetMaxSubscriptionsPerTopicResponse(status = Some(status))) } @@ -1032,10 +1032,10 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac try { logger.info(s"Setting max subscriptions per topic policy for namespace ${request.namespace}") adminClient.namespaces.setMaxSubscriptionsPerTopic(request.namespace, request.maxSubscriptionsPerTopic) - Future.successful(SetMaxSubscriptionsPerTopicResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(SetMaxSubscriptionsPerTopicResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(SetMaxSubscriptionsPerTopicResponse(status = Some(status))) } @@ -1045,10 +1045,10 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac try { logger.info(s"Removing max subscriptions per topic policy for namespace ${request.namespace}") adminClient.namespaces.removeMaxSubscriptionsPerTopic(request.namespace) - Future.successful(RemoveMaxSubscriptionsPerTopicResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(RemoveMaxSubscriptionsPerTopicResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(RemoveMaxSubscriptionsPerTopicResponse(status = Some(status))) } @@ -1065,12 +1065,12 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac )) Future.successful(GetMaxTopicsPerNamespaceResponse( - status = Some(Status(code = Code.OK.index)), + status = Some(Status(code = Code.OK.value)), maxTopicsPerNamespace = maxTopicsPerNamespacePb )) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(GetMaxTopicsPerNamespaceResponse(status = Some(status))) } @@ -1080,10 +1080,10 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac try { logger.info(s"Setting max topics per namespace policy for namespace ${request.namespace}") adminClient.namespaces.setMaxTopicsPerNamespace(request.namespace, request.maxTopicsPerNamespace) - Future.successful(SetMaxTopicsPerNamespaceResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(SetMaxTopicsPerNamespaceResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(SetMaxTopicsPerNamespaceResponse(status = Some(status))) } @@ -1093,10 +1093,10 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac try { logger.info(s"Removing max topics per namespace policy for namespace ${request.namespace}") adminClient.namespaces.removeMaxTopicsPerNamespace(request.namespace) - Future.successful(RemoveMaxTopicsPerNamespaceResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(RemoveMaxTopicsPerNamespaceResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(RemoveMaxTopicsPerNamespaceResponse(status = Some(status))) } @@ -1113,12 +1113,12 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac )) Future.successful(GetMaxUnackedMessagesPerConsumerResponse( - status = Some(Status(code = Code.OK.index)), + status = Some(Status(code = Code.OK.value)), maxUnackedMessagesPerConsumer = maxUnackedMessagesPerConsumerPb )) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(GetMaxUnackedMessagesPerConsumerResponse(status = Some(status))) } @@ -1128,10 +1128,10 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac try { logger.info(s"Setting max unacked messages per consumer policy for namespace ${request.namespace}") adminClient.namespaces.setMaxUnackedMessagesPerConsumer(request.namespace, request.maxUnackedMessagesPerConsumer) - Future.successful(SetMaxUnackedMessagesPerConsumerResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(SetMaxUnackedMessagesPerConsumerResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(SetMaxUnackedMessagesPerConsumerResponse(status = Some(status))) } @@ -1141,10 +1141,10 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac try { logger.info(s"Removing max unacked messages per consumer policy for namespace ${request.namespace}") adminClient.namespaces.removeMaxUnackedMessagesPerConsumer(request.namespace) - Future.successful(RemoveMaxUnackedMessagesPerConsumerResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(RemoveMaxUnackedMessagesPerConsumerResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(RemoveMaxUnackedMessagesPerConsumerResponse(status = Some(status))) } @@ -1161,12 +1161,12 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac )) Future.successful(GetMaxUnackedMessagesPerSubscriptionResponse( - status = Some(Status(code = Code.OK.index)), + status = Some(Status(code = Code.OK.value)), maxUnackedMessagesPerSubscription = maxUnackedMessagesPerSubscriptionPb )) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(GetMaxUnackedMessagesPerSubscriptionResponse(status = Some(status))) } @@ -1176,10 +1176,10 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac try { logger.info(s"Setting max unacked messages per subscription policy for namespace ${request.namespace}") adminClient.namespaces.setMaxUnackedMessagesPerSubscription(request.namespace, request.maxUnackedMessagesPerSubscription) - Future.successful(SetMaxUnackedMessagesPerSubscriptionResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(SetMaxUnackedMessagesPerSubscriptionResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(SetMaxUnackedMessagesPerSubscriptionResponse(status = Some(status))) } @@ -1189,10 +1189,10 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac try { logger.info(s"Removing max unacked messages per subscription policy for namespace ${request.namespace}") adminClient.namespaces.removeMaxUnackedMessagesPerSubscription(request.namespace) - Future.successful(RemoveMaxUnackedMessagesPerSubscriptionResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(RemoveMaxUnackedMessagesPerSubscriptionResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(RemoveMaxUnackedMessagesPerSubscriptionResponse(status = Some(status))) } @@ -1209,12 +1209,12 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac )) Future.successful(GetMessageTtlResponse( - status = Some(Status(code = Code.OK.index)), + status = Some(Status(code = Code.OK.value)), messageTtl = messageTtlPb )) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(GetMessageTtlResponse(status = Some(status))) } @@ -1224,10 +1224,10 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac try { logger.info(s"Setting message TTL policy for namespace ${request.namespace}") adminClient.namespaces.setNamespaceMessageTTL(request.namespace, request.messageTtlSeconds) - Future.successful(SetMessageTtlResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(SetMessageTtlResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(SetMessageTtlResponse(status = Some(status))) } @@ -1237,10 +1237,10 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac try { logger.info(s"Removing message TTL policy for namespace ${request.namespace}") adminClient.namespaces.removeNamespaceMessageTTL(request.namespace) - Future.successful(RemoveMessageTtlResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(RemoveMessageTtlResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(RemoveMessageTtlResponse(status = Some(status))) } @@ -1257,12 +1257,12 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac )) Future.successful(GetOffloadDeletionLagResponse( - status = Some(Status(code = Code.OK.index)), + status = Some(Status(code = Code.OK.value)), offloadDeletionLag = offloadDeletionLagPb )) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(GetOffloadDeletionLagResponse(status = Some(status))) } @@ -1272,10 +1272,10 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac try { logger.info(s"Setting offload deletion lag policy for namespace ${request.namespace}") adminClient.namespaces.setOffloadDeleteLag(request.namespace, request.offloadDeletionLagMs, TimeUnit.MILLISECONDS) - Future.successful(SetOffloadDeletionLagResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(SetOffloadDeletionLagResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(SetOffloadDeletionLagResponse(status = Some(status))) } @@ -1285,10 +1285,10 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac try { logger.info(s"Removing offload deletion lag policy for namespace ${request.namespace}") adminClient.namespaces.clearOffloadDeleteLag(request.namespace) - Future.successful(RemoveOffloadDeletionLagResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(RemoveOffloadDeletionLagResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(RemoveOffloadDeletionLagResponse(status = Some(status))) } @@ -1298,7 +1298,7 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac try { val offloadThresholdPb = Option(adminClient.namespaces.getOffloadThreshold(request.namespace)) match case None => - val status = Status(code = Code.FAILED_PRECONDITION.index) + val status = Status(code = Code.FAILED_PRECONDITION.value) return Future.successful(GetOffloadThresholdResponse(status = Some(status))) case Some(v) => pb.GetOffloadThresholdResponse.OffloadThreshold.Specified(new OffloadThresholdSpecified( @@ -1306,12 +1306,12 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac )) Future.successful(GetOffloadThresholdResponse( - status = Some(Status(code = Code.OK.index)), + status = Some(Status(code = Code.OK.value)), offloadThreshold = offloadThresholdPb )) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(GetOffloadThresholdResponse(status = Some(status))) } @@ -1321,10 +1321,10 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac try { logger.info(s"Setting offload threshold policy for namespace ${request.namespace}") adminClient.namespaces.setOffloadThreshold(request.namespace, request.offloadThresholdBytes) - Future.successful(SetOffloadThresholdResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(SetOffloadThresholdResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(SetOffloadThresholdResponse(status = Some(status))) } @@ -1344,12 +1344,12 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac )) Future.successful(GetPersistenceResponse( - status = Some(Status(code = Code.OK.index)), + status = Some(Status(code = Code.OK.value)), persistence = persistencePb )) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(GetPersistenceResponse(status = Some(status))) } @@ -1360,10 +1360,10 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac logger.info(s"Setting persistence policy for namespace ${request.namespace}") val persistencePolicies = PersistencePolicies(request.bookkeeperEnsemble, request.bookkeeperWriteQuorum, request.bookkeeperAckQuorum, request.managedLedgerMaxMarkDeleteRate) adminClient.namespaces.setPersistence(request.namespace, persistencePolicies) - Future.successful(SetPersistenceResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(SetPersistenceResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(SetPersistenceResponse(status = Some(status))) } @@ -1373,10 +1373,10 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac try { logger.info(s"Removing persistence policy for namespace ${request.namespace}") adminClient.namespaces.removePersistence(request.namespace) - Future.successful(RemovePersistenceResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(RemovePersistenceResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(RemovePersistenceResponse(status = Some(status))) } @@ -1389,12 +1389,12 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac .getOrElse(Seq.empty[String]) Future.successful(GetReplicationClustersResponse( - status = Some(Status(code = Code.OK.index)), + status = Some(Status(code = Code.OK.value)), replicationClusters )) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(GetReplicationClustersResponse(status = Some(status))) } @@ -1404,10 +1404,10 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac try { logger.info(s"Setting replication clusters for namespace ${request.namespace}") adminClient.namespaces.setNamespaceReplicationClusters(request.namespace, request.replicationClusters.toSet.asJava) - Future.successful(SetReplicationClustersResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(SetReplicationClustersResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(SetReplicationClustersResponse(status = Some(status))) } @@ -1427,12 +1427,12 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac )) Future.successful(GetReplicatorDispatchRateResponse( - status = Some(Status(code = Code.OK.index)), + status = Some(Status(code = Code.OK.value)), replicatorDispatchRate = replicatorDispatchRatePb )) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(GetReplicatorDispatchRateResponse(status = Some(status))) } @@ -1449,10 +1449,10 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac .build adminClient.namespaces.setReplicatorDispatchRate(request.namespace, dispatchRate) - Future.successful(SetReplicatorDispatchRateResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(SetReplicatorDispatchRateResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(SetReplicatorDispatchRateResponse(status = Some(status))) } @@ -1462,10 +1462,10 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac try { logger.info(s"Removing replicator dispatch rate for namespace ${request.namespace}") adminClient.namespaces.removeReplicatorDispatchRate(request.namespace) - Future.successful(RemoveReplicatorDispatchRateResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(RemoveReplicatorDispatchRateResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(RemoveReplicatorDispatchRateResponse(status = Some(status))) } @@ -1485,12 +1485,12 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac )) Future.successful(GetSubscriptionDispatchRateResponse( - status = Some(Status(code = Code.OK.index)), + status = Some(Status(code = Code.OK.value)), subscriptionDispatchRate = subscriptionDispatchRatePb )) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(GetSubscriptionDispatchRateResponse(status = Some(status))) } @@ -1507,10 +1507,10 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac .build adminClient.namespaces.setSubscriptionDispatchRate(request.namespace, dispatchRate) - Future.successful(SetSubscriptionDispatchRateResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(SetSubscriptionDispatchRateResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(SetSubscriptionDispatchRateResponse(status = Some(status))) } @@ -1520,10 +1520,10 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac try { logger.info(s"Removing subscription dispatch rate for namespace ${request.namespace}") adminClient.namespaces.removeSubscriptionDispatchRate(request.namespace) - Future.successful(RemoveSubscriptionDispatchRateResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(RemoveSubscriptionDispatchRateResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(RemoveSubscriptionDispatchRateResponse(status = Some(status))) } @@ -1541,12 +1541,12 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac )) Future.successful(GetRetentionResponse( - status = Some(Status(code = Code.OK.index)), + status = Some(Status(code = Code.OK.value)), retention = retentionPb )) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(GetRetentionResponse(status = Some(status))) } @@ -1558,10 +1558,10 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac val retention = new RetentionPolicies(request.retentionTimeInMinutes, request.retentionSizeInMb) adminClient.namespaces.setRetention(request.namespace, retention) - Future.successful(SetRetentionResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(SetRetentionResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(SetRetentionResponse(status = Some(status))) } @@ -1571,10 +1571,10 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac try { logger.info(s"Removing retention for namespace ${request.namespace}") adminClient.namespaces.removeRetention(request.namespace) - Future.successful(RemoveRetentionResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(RemoveRetentionResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(RemoveRetentionResponse(status = Some(status))) } @@ -1592,12 +1592,12 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac )) Future.successful(GetSubscribeRateResponse( - status = Some(Status(code = Code.OK.index)), + status = Some(Status(code = Code.OK.value)), subscribeRate = subscribeRatePb )) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(GetSubscribeRateResponse(status = Some(status))) } @@ -1609,10 +1609,10 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac val subscribeRate = new SubscribeRate(request.subscribeThrottlingRatePerConsumer, request.ratePeriodInSeconds) adminClient.namespaces.setSubscribeRate(request.namespace, subscribeRate) - Future.successful(SetSubscribeRateResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(SetSubscribeRateResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(SetSubscribeRateResponse(status = Some(status))) } @@ -1622,10 +1622,10 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac try { logger.info(s"Removing subscribe rate policy for namespace ${request.namespace}") adminClient.namespaces.removeSubscribeRate(request.namespace) - Future.successful(RemoveSubscribeRateResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(RemoveSubscribeRateResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(RemoveSubscribeRateResponse(status = Some(status))) } @@ -1638,12 +1638,12 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac case SubscriptionAuthMode.Prefix => pb.SubscriptionAuthMode.SUBSCRIPTION_AUTH_MODE_PREFIX Future.successful(GetSubscriptionAuthModeResponse( - status = Some(Status(code = Code.OK.index)), + status = Some(Status(code = Code.OK.value)), subscriptionAuthMode = subscriptionAuthModePb )) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(GetSubscriptionAuthModeResponse(status = Some(status))) } @@ -1656,13 +1656,13 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac case pb.SubscriptionAuthMode.SUBSCRIPTION_AUTH_MODE_NONE => SubscriptionAuthMode.None case pb.SubscriptionAuthMode.SUBSCRIPTION_AUTH_MODE_PREFIX => SubscriptionAuthMode.Prefix case _ => - return Future.successful(SetSubscriptionAuthModeResponse(status = Some(Status(code = Code.INVALID_ARGUMENT.index, message = "Invalid subscription auth mode")))) + return Future.successful(SetSubscriptionAuthModeResponse(status = Some(Status(code = Code.INVALID_ARGUMENT.value, message = "Invalid subscription auth mode")))) adminClient.namespaces.setSubscriptionAuthMode(request.namespace, subscriptionAuthMode) - Future.successful(SetSubscriptionAuthModeResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(SetSubscriptionAuthModeResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(SetSubscriptionAuthModeResponse(status = Some(status))) } @@ -1679,12 +1679,12 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac )) Future.successful(GetSubscriptionExpirationTimeResponse( - status = Some(Status(code = Code.OK.index)), + status = Some(Status(code = Code.OK.value)), subscriptionExpirationTime = subscriptionExpirationTimePb )) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(GetSubscriptionExpirationTimeResponse(status = Some(status))) } @@ -1694,10 +1694,10 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac try { logger.info(s"Setting subscription expiration time policy for namespace ${request.namespace}") adminClient.namespaces.setSubscriptionExpirationTime(request.namespace, request.subscriptionExpirationTimeInMinutes) - Future.successful(SetSubscriptionExpirationTimeResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(SetSubscriptionExpirationTimeResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(SetSubscriptionExpirationTimeResponse(status = Some(status))) } @@ -1707,10 +1707,10 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac try { logger.info(s"Removing subscription expiration time policy for namespace ${request.namespace}") adminClient.namespaces.removeSubscriptionExpirationTime(request.namespace) - Future.successful(RemoveSubscriptionExpirationTimeResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(RemoveSubscriptionExpirationTimeResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(RemoveSubscriptionExpirationTimeResponse(status = Some(status))) } @@ -1727,7 +1727,7 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac try { val subscriptionTypesEnabledPb = Option(adminClient.namespaces.getSubscriptionTypesEnabled(request.namespace)) match case None => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = "Subscription types enabled can't be null. Looks like a Pulsar error.") + val status = Status(code = Code.FAILED_PRECONDITION.value, message = "Subscription types enabled can't be null. Looks like a Pulsar error.") return Future.successful(GetSubscriptionTypesEnabledResponse(status = Some(status))) case Some(v) if v.size() == 0 => pb.GetSubscriptionTypesEnabledResponse.SubscriptionTypesEnabled.Inherited(new SubscriptionTypesEnabledInherited()) @@ -1737,12 +1737,12 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac )) Future.successful(GetSubscriptionTypesEnabledResponse( - status = Some(Status(code = Code.OK.index)), + status = Some(Status(code = Code.OK.value)), subscriptionTypesEnabled = subscriptionTypesEnabledPb )) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(GetSubscriptionTypesEnabledResponse(status = Some(status))) } @@ -1762,10 +1762,10 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac val subscriptionTypesEnabled = request.types.map(pbToSubscriptionType).toSet.asJava adminClient.namespaces.setSubscriptionTypesEnabled(request.namespace, subscriptionTypesEnabled) - Future.successful(SetSubscriptionTypesEnabledResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(SetSubscriptionTypesEnabledResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(SetSubscriptionTypesEnabledResponse(status = Some(status))) } @@ -1775,10 +1775,10 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac try { logger.info(s"Removing subscription types enabled policy for namespace ${request.namespace}") adminClient.namespaces.removeSubscriptionTypesEnabled(request.namespace) - Future.successful(RemoveSubscriptionTypesEnabledResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(RemoveSubscriptionTypesEnabledResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(RemoveSubscriptionTypesEnabledResponse(status = Some(status))) } @@ -1830,12 +1830,12 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac pb.GetOffloadPoliciesResponse.OffloadPolicies.Specified(offloadPoliciesToPb(v)) Future.successful(GetOffloadPoliciesResponse( - status = Some(Status(code = Code.OK.index)), + status = Some(Status(code = Code.OK.value)), offloadPolicies = offloadPoliciesPb )) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(GetOffloadPoliciesResponse(status = Some(status))) } @@ -1887,15 +1887,15 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac request.offloadPolicies match case None => - val status = Status(code = Code.INVALID_ARGUMENT.index, "Offload policies should be specified") + val status = Status(code = Code.INVALID_ARGUMENT.value, "Offload policies should be specified") Future.successful(SetOffloadPoliciesResponse(status = Some(status))) case Some(v) => val offloadPolicies = offloadPoliciesFromPb(v) adminClient.namespaces.setOffloadPolicies(request.namespace, offloadPolicies) - Future.successful(SetOffloadPoliciesResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(SetOffloadPoliciesResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(SetOffloadPoliciesResponse(status = Some(status))) } @@ -1905,10 +1905,10 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac try { logger.info(s"Removing offload policies policy for namespace ${request.namespace}") adminClient.namespaces.removeOffloadPolicies(request.namespace) - Future.successful(RemoveOffloadPoliciesResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(RemoveOffloadPoliciesResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(RemoveOffloadPoliciesResponse(status = Some(status))) } override def getPublishRate(request: GetPublishRateRequest): Future[GetPublishRateResponse] = @@ -1925,12 +1925,12 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac )) Future.successful(GetPublishRateResponse( - status = Some(Status(code = Code.OK.index)), + status = Some(Status(code = Code.OK.value)), publishRate = publishRatePb )) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(GetPublishRateResponse(status = Some(status))) } override def setPublishRate(request: SetPublishRateRequest): Future[SetPublishRateResponse] = @@ -1940,10 +1940,10 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac logger.info(s"Setting publish rate policy for namespace ${request.namespace}. ${request.rateInMsg}, ${request.rateInByte}") val publishRate = PublishRate(request.rateInMsg, request.rateInByte) adminClient.namespaces.setPublishRate(request.namespace, publishRate) - Future.successful(SetPublishRateResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(SetPublishRateResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(SetPublishRateResponse(status = Some(status))) } override def removePublishRate(request: RemovePublishRateRequest): Future[RemovePublishRateResponse] = @@ -1952,10 +1952,10 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac try { logger.info(s"Removing publish rate policy for namespace ${request.namespace}") adminClient.namespaces.removePublishRate(request.namespace) - Future.successful(RemovePublishRateResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(RemovePublishRateResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(RemovePublishRateResponse(status = Some(status))) } @@ -1974,13 +1974,13 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac val resourceGroups = Option(adminClient.resourcegroups.getResourceGroups).map(_.asScala.toSeq).getOrElse(Seq.empty[String]) Future.successful(pb.GetResourceGroupResponse( - status = Some(Status(code = Code.OK.index)), + status = Some(Status(code = Code.OK.value)), resourceGroup, resourceGroups, )) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.GetResourceGroupResponse(status = Some(status))) } @@ -1990,10 +1990,10 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac try { logger.info(s"Setting resource group policy for namespace ${request.namespace}") adminClient.namespaces.setNamespaceResourceGroup(request.namespace, request.resourceGroup) - Future.successful(pb.SetResourceGroupResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(pb.SetResourceGroupResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.SetResourceGroupResponse(status = Some(status))) } @@ -2003,9 +2003,9 @@ class NamespacePoliciesServiceImpl extends NamespacePoliciesServiceGrpc.Namespac try { logger.info(s"Removing resource group policy for namespace ${request.namespace}") adminClient.namespaces.removeNamespaceResourceGroup(request.namespace) - Future.successful(RemoveResourceGroupResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(RemoveResourceGroupResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(RemoveResourceGroupResponse(status = Some(status))) } diff --git a/server/src/main/scala/producer/ProducerServiceImpl.scala b/server/src/main/scala/producer/ProducerServiceImpl.scala index ac1339ab2..b62096b45 100644 --- a/server/src/main/scala/producer/ProducerServiceImpl.scala +++ b/server/src/main/scala/producer/ProducerServiceImpl.scala @@ -1,6 +1,6 @@ package producer -import org.apache.pulsar.client.api.{Producer, ProducerAccessMode, Schema} +import org.apache.pulsar.client.api.{MessageId, Producer, ProducerAccessMode, Schema} import com.typesafe.scalalogging.Logger import com.google.rpc.status.Status import com.google.rpc.code.Code @@ -18,14 +18,32 @@ import io.circe.parser.parse as parseJson import pulsar_auth.RequestContext import java.nio.ByteBuffer -import scala.concurrent.Future +import java.util.concurrent.{CompletableFuture, ConcurrentHashMap} +import scala.concurrent.{ExecutionContext, Future} +import scala.jdk.FutureConverters.* import scala.util.boundary, boundary.break type ProducerName = String class ProducerServiceImpl extends ProducerServiceGrpc.ProducerService: val logger: Logger = Logger(getClass.getName) - var producers: Map[ProducerName, Producer[Array[Byte]]] = Map.empty + + /** Live broker producers, by the name the UI created them under. + * + * A ConcurrentHashMap rather than a `var Map`, because every entry is a resource: this service + * is a singleton bound on `ExecutionContext.global` (GrpcServer), so create/delete run + * concurrently, and `producers = producers + (name -> p)` is a read-modify-write. Two creates + * that read the same snapshot lost one entry - that producer stayed connected to the topic + * while its name vanished from the only map that could close it, so `deleteProducer` answered + * "no such producer" forever. `put`/`remove` here are atomic AND hand back whatever they + * displaced, which is what makes closing it possible at all. */ + private[producer] val producers: ConcurrentHashMap[ProducerName, Producer[Array[Byte]]] = ConcurrentHashMap() + + /** Close a producer this service is giving up. A failure here must not fail the RPC that caused + * it - the entry is already gone from the registry either way - but it must not be silent. */ + private def closeReleased(producerName: ProducerName, producer: Producer[Array[Byte]]): Unit = + try producer.close() + catch case err => logger.warn(s"Failed to close producer $producerName: ${err.getMessage}") override def createProducer(request: CreateProducerRequest): Future[CreateProducerResponse] = val producerName: ProducerName = request.producerName @@ -42,13 +60,16 @@ class ProducerServiceImpl extends ProducerServiceGrpc.ProducerService: .topic(request.topic) .create() - producers = producers + (producerName -> producer) + // Registering under a name that is already taken (a re-create after an edit or a page + // reload, or a concurrent create that got here second) used to overwrite the entry and + // leak the producer it displaced. Whatever this replaces is ours to close. + Option(producers.put(producerName, producer)).foreach(closeReleased(producerName, _)) - val status: Status = Status(code = Code.OK.index) + val status: Status = Status(code = Code.OK.value) Future.successful(CreateProducerResponse(status = Some(status))) } catch { case err => - val status: Status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status: Status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(CreateProducerResponse(status = Some(status))) } @@ -56,21 +77,23 @@ class ProducerServiceImpl extends ProducerServiceGrpc.ProducerService: val producerName: ProducerName = request.producerName logger.info(s"Deleting producer: $producerName") - producers.get(producerName) match + // Remove-then-close as one atomic claim: the producer this call closes is exactly the one it + // took out of the registry, so two concurrent deletes cannot both close the same handle and + // a create racing alongside cannot have its brand-new producer removed by the loser. + Option(producers.remove(producerName)) match case Some(p) => try { - producers = producers.removed(producerName) p.close() - val status: Status = Status(code = Code.OK.index) + val status: Status = Status(code = Code.OK.value) Future.successful(DeleteProducerResponse(status = Some(status))) } catch { case err => - val status: Status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status: Status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(DeleteProducerResponse(status = Some(status))) } case _ => - val status: Status = Status(code = Code.FAILED_PRECONDITION.index, message = s"No such producer: $producerName") + val status: Status = Status(code = Code.FAILED_PRECONDITION.value, message = s"No such producer: $producerName") Future.successful(DeleteProducerResponse(status = Some(status))) override def send(request: SendRequest): Future[SendResponse] = boundary: @@ -78,10 +101,10 @@ class ProducerServiceImpl extends ProducerServiceGrpc.ProducerService: logger.info(s"Sending message. Producer: $producerName") val adminClient = RequestContext.pulsarAdmin.get() - val producer = producers.get(producerName) match + val producer = Option(producers.get(producerName)) match case Some(p) => p case _ => - val status: Status = Status(code = Code.FAILED_PRECONDITION.index, message = s"No such producer: $producerName") + val status: Status = Status(code = Code.FAILED_PRECONDITION.value, message = s"No such producer: $producerName") break(Future.successful(SendResponse(status = Some(status)))) val messages: Seq[Either[Throwable, Message]] = request.format match @@ -92,7 +115,7 @@ class ProducerServiceImpl extends ProducerServiceGrpc.ProducerService: catch { case _: PulsarAdminException.NotFoundException => None case err => - val status: Status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status: Status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) break(Future.successful(SendResponse(status = Some(status)))) } @@ -117,35 +140,85 @@ class ProducerServiceImpl extends ProducerServiceGrpc.ProducerService: Right(message) ) - messages.foreach(msg => - msg match - case Right(message) => - try { - var newMessage = producer.newMessage - .value(message.value) - .properties(message.properties.asJava) - message.eventTime match - case Some(t) => newMessage = newMessage.eventTime(t) - case None => // do nothing - message.key match - case Some(k) => newMessage = newMessage.key(k) - case None => // do nothing - newMessage.sendAsync - } catch { - case err => - val status: Status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) - break(Future.successful(SendResponse(status = Some(status)))) - } - case Left(err) => - val status: Status = Status(code = Code.INVALID_ARGUMENT.index, message = err.getMessage) - break(Future.successful(SendResponse(status = Some(status)))) - ) - - val status: Status = Status(code = Code.OK.index) - Future.successful(SendResponse(status = Some(status))) + // Validate the WHOLE batch before publishing any of it. Validation used to be INTERLEAVED + // with `sendAsync` - each item was checked immediately before its own send - so a valid item + // preceding an invalid one was already on the topic when the call answered INVALID_ARGUMENT. + // The caller sees a wholly failed batch, retries it, and duplicates whatever did land. + messages.collectFirst { case Left(err) => err } match + case Some(err) => + val status: Status = Status(code = Code.INVALID_ARGUMENT.value, message = err.getMessage) + break(Future.successful(SendResponse(status = Some(status)))) + case None => // the whole batch converted; publishing it is safe + + // Publication is NOT atomic and cannot be made so after the fact: the items are submitted one + // at a time, and a send already handed to the client cannot be recalled. A builder/`sendAsync` + // that throws on item N (producer closed, payload over the max message size) used to break + // straight out of `send` with FAILED_PRECONDITION, ABANDONING the futures of items 1..N-1 - + // so the RPC reported failure while part of its own batch was still travelling to the topic, + // and the caller's retry duplicated whatever landed. Stop submitting at the first failure, + // but keep every future that was submitted and let `awaitSends` settle all of them before the + // verdict exists. A partial publish therefore remains possible; what is guaranteed is that + // the batch is finished travelling by the time its verdict is delivered. + val sendFutures = scala.collection.mutable.ArrayBuffer.empty[CompletableFuture[MessageId]] + var submitFailure: Option[Throwable] = None + + val toPublish = messages.collect { case Right(message) => message }.iterator + while toPublish.hasNext && submitFailure.isEmpty do + val message = toPublish.next() + try { + var newMessage = producer.newMessage + .value(message.value) + .properties(message.properties.asJava) + message.eventTime match + case Some(t) => newMessage = newMessage.eventTime(t) + case None => // do nothing + message.key match + case Some(k) => newMessage = newMessage.key(k) + case None => // do nothing + sendFutures += newMessage.sendAsync + } catch { + case err => submitFailure = Some(err) + } + + awaitSends(sendFutures.toSeq, submitFailure) override def getStats(request: GetStatsRequest): Future[GetStatsResponse] = ??? +/** Build the send response from the in-flight `sendAsync` futures, plus the failure (if any) that + * stopped `send` from submitting the rest of the batch. + * + * A Pulsar send future completes only when the BROKER has acknowledged (or rejected) the message, + * so answering before then is a guess: `send` used to discard every future and return Code.OK + * immediately, which reported a successful publish for messages the broker went on to reject + * (schema incompatibility, producer fenced, exceeded quota, terminated topic, send timeout). + * + * Every submitted future must SETTLE before the verdict exists. `Future.sequence` is fail-fast, so + * one rejection completed the RPC as failed while its siblings were still in flight; those siblings + * then landed on the topic AFTER the caller had been told the batch failed, and the natural retry + * duplicated them. Each future is therefore lifted to a `Try` (which never fails) and only the + * collected results decide the answer. The semantics are still non-atomic - a partial publish is + * possible and a retry can duplicate what landed - but the batch is no longer in motion when its + * verdict is delivered. + * + * Composed rather than blocked on, so the gRPC thread is not parked while the broker decides; + * `parasitic` runs the continuation on whichever thread completes the last future. + */ +def awaitSends(sendFutures: Seq[CompletableFuture[MessageId]], submitFailure: Option[Throwable] = None): Future[SendResponse] = + given ExecutionContext = ExecutionContext.parasitic + + val settled: Seq[Future[scala.util.Try[MessageId]]] = + sendFutures.map(_.asScala.transform(scala.util.Success(_))) + + Future.sequence(settled).map { results => + // The submit failure wins when there is one: it is the reason the batch is incomplete, and + // it says more than a sibling's broker error would. + submitFailure.orElse(results.collectFirst { case scala.util.Failure(err) => err }) match + case None => SendResponse(status = Some(Status(code = Code.OK.value))) + case Some(err) => + val message = Option(err.getMessage).getOrElse(err.toString) + SendResponse(status = Some(Status(code = Code.FAILED_PRECONDITION.value, message = s"Failed to send message. $message"))) + } + case class Message( key: Option[String], value: Array[Byte], @@ -153,6 +226,10 @@ case class Message( properties: Map[String, String] ) +/** True only for a payload that is a single, well-formed JSON number. */ +def isJsonNumber(payload: String): Boolean = + parseJson(payload).exists(_.isNumber) + def jsonToValue(schemaInfo: SchemaInfo, jsonAsBytes: Array[Byte]): Either[Throwable, Array[Byte]] = val result: Either[Throwable, Array[Byte]] = schemaInfo.getType match case SchemaType.AVRO => @@ -163,13 +240,23 @@ def jsonToValue(schemaInfo: SchemaInfo, jsonAsBytes: Array[Byte]): Either[Throwa protobufnative.converters.fromJson(schemaInfo.getSchema, jsonAsBytes) match case Right(v) => Right(v) case Left(err) => Left(err) - case SchemaType.JSON => Right(jsonAsBytes) + case SchemaType.JSON => + // The topic's schema says JSON, so the payload has to BE JSON. Returning the bytes + // unparsed published anything at all - the producer reported success and the consumer + // side then failed to deserialize what had already landed on the topic. Syntax only: + // the payload is not validated against the schema definition (that would need a JSON + // Schema validator), and the bytes are forwarded unchanged rather than re-serialized. + parseJson(String(jsonAsBytes, "UTF-8")) match + case Right(_) => Right(jsonAsBytes) + case Left(err) => Left(new Exception(s"Message should be formatted as JSON. ${err.getMessage}")) case SchemaType.STRING => parseJson(String(jsonAsBytes, "UTF-8")) match case Left(err) => Left(err) case Right(json) if json.isString => val str = json.asString.getOrElse("") - Right(str.getBytes) + // Explicit UTF-8: the read path (primitiveConv.bytesToString) decodes as UTF-8, + // so relying on the platform default here would diverge under -Dfile.encoding. + Right(str.getBytes(java.nio.charset.StandardCharsets.UTF_8)) case _ => Left(new Exception("Message should be formatted as JSON string.")) case SchemaType.NONE => Right(jsonAsBytes) case SchemaType.BOOLEAN => @@ -182,6 +269,12 @@ def jsonToValue(schemaInfo: SchemaInfo, jsonAsBytes: Array[Byte]): Either[Throwa Right(Array(v)) case SchemaType.INT8 => val jsonString = String(jsonAsBytes, "UTF-8") + // Guava's parser accepts JAVA integer literal syntax, not JSON: a leading zero (`01`, + // `00`, `-01`) parsed and was encoded onto the topic even though JSON forbids it. Same + // gate the FLOAT/DOUBLE branches below already apply, for the same reason - this is the + // JSON message format. + if !isJsonNumber(jsonString) then return Left(new Exception(s"Unable to parse INT8 value from the given JSON: $jsonString")) + val n = primitives.Ints.tryParse(jsonString) if n == null then return Left(new Exception(s"Unable to parse INT8 value from the given JSON: $jsonString")) @@ -193,6 +286,9 @@ def jsonToValue(schemaInfo: SchemaInfo, jsonAsBytes: Array[Byte]): Either[Throwa Right(Array(primitives.SignedBytes.checkedCast(n.toLong))) case SchemaType.INT16 => val jsonString = String(jsonAsBytes, "UTF-8") + // See the INT8 branch: Guava accepts leading zeros, JSON does not. + if !isJsonNumber(jsonString) then return Left(new Exception(s"Unable to parse INT16 value from the given the JSON: $jsonString")) + val n = primitives.Ints.tryParse(jsonString) if n == null then return Left(new Exception(s"Unable to parse INT16 value from the given the JSON: $jsonString")) @@ -203,6 +299,9 @@ def jsonToValue(schemaInfo: SchemaInfo, jsonAsBytes: Array[Byte]): Either[Throwa Right(primitives.Shorts.toByteArray(n.toShort)) case SchemaType.INT32 => val jsonString = String(jsonAsBytes, "UTF-8") + // See the INT8 branch: Guava accepts leading zeros, JSON does not. + if !isJsonNumber(jsonString) then return Left(new Exception(s"Unable to parse INT32 value from the given the JSON: $jsonString")) + val n = primitives.Ints.tryParse(jsonString) if n == null then return Left(new Exception(s"Unable to parse INT32 value from the given the JSON: $jsonString")) @@ -213,6 +312,9 @@ def jsonToValue(schemaInfo: SchemaInfo, jsonAsBytes: Array[Byte]): Either[Throwa Right(primitives.Ints.toByteArray(n)) case SchemaType.INT64 => val jsonString = String(jsonAsBytes, "UTF-8") + // See the INT8 branch: Guava accepts leading zeros, JSON does not. + if !isJsonNumber(jsonString) then return Left(new Exception(s"Unable to parse INT64 value from the given the JSON: $jsonString")) + val n = primitives.Longs.tryParse(jsonString) if n == null then return Left(new Exception(s"Unable to parse INT64 value from the given the JSON: $jsonString")) @@ -223,9 +325,19 @@ def jsonToValue(schemaInfo: SchemaInfo, jsonAsBytes: Array[Byte]): Either[Throwa Right(primitives.Longs.toByteArray(n)) case SchemaType.FLOAT => val jsonString = String(jsonAsBytes, "UTF-8") + // Guava's parser accepts JAVA float literal syntax, not JSON: `+1`, `01`, `.5`, `1.`, + // hex float literals and a trailing f/d suffix all parsed and were encoded onto the + // topic. Gate on JSON number syntax first (the STRING branch below has always required + // real JSON), then let Guava do the numeric conversion. + if !isJsonNumber(jsonString) then return Left(new Exception(s"Unable to parse FLOAT value from the given JSON: $jsonString")) + val n = primitives.Floats.tryParse(jsonString) if n == null then return Left(new Exception(s"Unable to parse FLOAT value from the given JSON: $jsonString")) + // NaN compares false against BOTH bounds, so it slipped through the very guard that + // rejects Infinity and got encoded onto the topic (and NaN is not valid JSON either). + if n.isNaN then return Left(new Exception(s"FLOAT value must be a number. Given: $jsonString")) + val MinValue = Float.MinValue val MaxValue = Float.MaxValue if n > MaxValue || n < MinValue then return Left(new Exception(s"FLOAT value should be in range from $MinValue to $MaxValue. Given: $n")) @@ -233,9 +345,15 @@ def jsonToValue(schemaInfo: SchemaInfo, jsonAsBytes: Array[Byte]): Either[Throwa Right(ByteBuffer.allocate(4).putFloat(n).array) case SchemaType.DOUBLE => val jsonString = String(jsonAsBytes, "UTF-8") + // See the FLOAT branch: same Guava parser, same non-JSON literal forms. + if !isJsonNumber(jsonString) then return Left(new Exception(s"Unable to parse DOUBLE value from the given JSON: $jsonString")) + val n = primitives.Doubles.tryParse(jsonString) if n == null then return Left(new Exception(s"Unable to parse DOUBLE value from the given JSON: $jsonString")) + // See the FLOAT branch: NaN evades both bounds checks. + if n.isNaN then return Left(new Exception(s"DOUBLE value must be a number. Given: $jsonString")) + val MinValue = Double.MinValue val MaxValue = Double.MaxValue if n > MaxValue || n < MinValue then return Left(new Exception(s"DOUBLE value should be in range from $MinValue to $MaxValue. Given: $n")) diff --git a/server/src/main/scala/pulsar_auth/PulsarAuth.scala b/server/src/main/scala/pulsar_auth/PulsarAuth.scala index 2dcdc11f6..a3e45169a 100644 --- a/server/src/main/scala/pulsar_auth/PulsarAuth.scala +++ b/server/src/main/scala/pulsar_auth/PulsarAuth.scala @@ -136,7 +136,15 @@ def parsePulsarAuthCookie(json: Option[String]): Either[Throwable, PulsarAuth] = val clientPulsarAuth = json match case None => Right(defaultPulsarAuth) case Some(encodedValue) => - val v = URLDecoder.decode(encodedValue, UTF_8) + // URLDecoder throws IllegalArgumentException on malformed percent-encoding ("%", "%ZZ", + // "a%2"), and it used to sit OUTSIDE this Either - so a hand-edited cookie produced a + // server error instead of the intended 400. + val v = + try URLDecoder.decode(encodedValue, UTF_8) + catch + case err: IllegalArgumentException => + logger.warn(s"Malformed percent-encoding in cookie: ${err.getMessage}") + return Left(new Exception("Unable to parse pulsar_auth cookie.")) decode[PulsarAuth](v) match case Left(err) => @@ -148,7 +156,15 @@ def parsePulsarAuthCookie(json: Option[String]): Either[Throwable, PulsarAuth] = clientPulsarAuth -def pulsarAuthToCookie(pulsarAuth: PulsarAuth): String = +/** The cookie-hardening inputs are parameters (defaulting to the process config, so the single + * production call site is unchanged) purely so they can be varied in tests - the package-level + * `config` val is loaded once per process and cannot be. */ +def pulsarAuthToCookie( + pulsarAuth: PulsarAuth, + publicBaseUrl: Option[String] = config.publicBaseUrl, + cookieSecure: Option[Boolean] = config.cookieSecure, + cookieSameSite: Option[String] = config.cookieSameSite +): String = val pulsarAuthWithoutEncodingMetadata = pulsarAuth.copy( credentials = pulsarAuth.credentials.map((name, credentials) => credentials match @@ -161,6 +177,13 @@ def pulsarAuthToCookie(pulsarAuth: PulsarAuth): String = scope = cr.scope.map(scope => URLEncoder.encode(scope, UTF_8)) ) ) + // parsePulsarAuthCookie URL-DECODES the whole cookie value, so anything written + // raw comes back mangled: a `+` in authParams turned into a space and `%xx` was + // eaten. authParams carries tokens/passwords, so encode it like the OAuth2 fields. + case cr: AuthParamsStringCredentials => ( + name, + cr.copy(authParams = URLEncoder.encode(cr.authParams, UTF_8)) + ) case _ => (name, credentials) ) ) @@ -168,20 +191,39 @@ def pulsarAuthToCookie(pulsarAuth: PulsarAuth): String = val cookieName = "pulsar_auth" val cookieValue = pulsarAuthWithoutEncodingMetadata.asJson.noSpaces - val cookiePath = config.publicBaseUrl.map { + val cookiePath = publicBaseUrl.map { java.net.URI.create(_).getPath match case "" => "/" case path => path }.getOrElse("/") - val cookieSecureValue = config.cookieSecure match + val cookieSecureValue = cookieSecure match case Some(true) => "Secure; " case _ => "" - val cookieSameSiteValue = (config.cookieSecure, config.cookieSameSite) match - case (_, Some("lax")) => "SameSite=Lax; " - case (_, Some("strict")) => "SameSite=Strict; " - case (Some(true), Some("none")) => "SameSite=None; " - case _ => "" - - s"$cookieName=$cookieValue; Path=$cookiePath; HttpOnly; Max-Age=31536000; $cookieSameSiteValue$cookieSameSiteValue" + s"$cookieName=$cookieValue; Path=$cookiePath; HttpOnly; Max-Age=31536000; $cookieSecureValue${sameSiteAttribute(cookieSecure, cookieSameSite)}" + +/** Render the SameSite attribute, the cookie's built-in CSRF control. + * + * The value is matched case-insensitively and trimmed: it arrives from a YAML key or the + * DEKAF_COOKIE_SAME_SITE environment variable, where `Lax`, `STRICT` and a stray trailing space are + * all ordinary things for an operator to write. A strict lowercase match silently fell through to + * "", emitting NO SameSite attribute at all - so a capitalised value looked configured but left the + * cookie on the browser default. Anything still unrecognised after normalisation is logged loudly + * for the same reason: dropping the attribute must never be the quiet outcome of a typo. + */ +def sameSiteAttribute(cookieSecure: Option[Boolean], cookieSameSite: Option[String]): String = + cookieSameSite.map(_.trim.toLowerCase) match + case None | Some("") => "" + case Some("lax") => "SameSite=Lax; " + case Some("strict") => "SameSite=Strict; " + case Some("none") => + // Browsers reject SameSite=None unless the cookie is also Secure, so emitting it on a + // plain-HTTP deployment would drop the cookie entirely and break auth. + if cookieSecure.contains(true) then "SameSite=None; " + else + logger.warn("cookieSameSite=none requires cookieSecure=true; omitting SameSite (browsers reject None without Secure).") + "" + case Some(other) => + logger.warn(s"Unknown cookieSameSite value '$other' (expected lax, strict or none); omitting SameSite.") + "" diff --git a/server/src/main/scala/pulsar_auth/PulsarAuthRoutes.scala b/server/src/main/scala/pulsar_auth/PulsarAuthRoutes.scala index e451b5649..03c645c16 100644 --- a/server/src/main/scala/pulsar_auth/PulsarAuthRoutes.scala +++ b/server/src/main/scala/pulsar_auth/PulsarAuthRoutes.scala @@ -10,13 +10,30 @@ import _root_.pulsar_auth.{defaultPulsarAuth, jwtCredentialsDecoder, validCreden import io.circe.parser.decode as decodeJson object PulsarAuthRoutes: - val routes: EndpointGroup = () => { - addCredentials() - useCredentials() - deleteCredentials() - } + /** What every successful route writes into `Set-Cookie`. */ + private type SetCookie = (io.javalin.http.Context, PulsarAuth) => Unit - private def addCredentials(): Unit = + def routes: EndpointGroup = routesWith() + + /** The cookie-hardening inputs are parameters defaulting to the process config - same reason as + * `pulsarAuthToCookie`: the package-level `config` val is loaded once per process and cannot be + * varied, so nothing could otherwise observe over HTTP that these routes really emit the + * CONFIGURED Secure/SameSite attributes. `routes` keeps the production call site unchanged. */ + def routesWith( + publicBaseUrl: Option[String] = config.publicBaseUrl, + cookieSecure: Option[Boolean] = config.cookieSecure, + cookieSameSite: Option[String] = config.cookieSameSite + ): EndpointGroup = + val setCookie: SetCookie = + (ctx, pulsarAuth) => setCookieAndSuccess(ctx, pulsarAuth, publicBaseUrl, cookieSecure, cookieSameSite) + + () => { + addCredentials(setCookie) + useCredentials(setCookie) + deleteCredentials(setCookie) + } + + private def addCredentials(setCookie: SetCookie): Unit = post( s"/pulsar-auth/add/{credentialsName}", ctx => @@ -51,13 +68,13 @@ object PulsarAuthRoutes: current = Some(credentialsName), credentials = pulsarAuth.credentials + (credentialsName -> credentials) ) - setCookieAndSuccess(ctx, newPulsarAuth) + setCookie(ctx, newPulsarAuth) case _ => ctx.status(400) ctx.result("Credentials name contains illegal characters. Only alphanumerics, underscores(_) and dashes(-) are allowed.") ) - private def useCredentials(): Unit = + private def useCredentials(setCookie: SetCookie): Unit = post( s"/pulsar-auth/use/{credentialsName}", ctx => @@ -73,12 +90,18 @@ object PulsarAuthRoutes: if credentialsName.isBlank then ctx.status(400) ctx.result("Credentials name shouldn't be blank") + // Selecting a name that isn't in the map used to succeed, after which every + // client construction failed and the interceptor answered UNAUTHENTICATED + // for all calls - a self-inflicted brick from one request. + else if !pulsarAuth.credentials.contains(credentialsName) then + ctx.status(404) + ctx.result(s"No credentials named '$credentialsName'") else val newPulsarAuth = pulsarAuth.copy(current = Some(credentialsName)) - setCookieAndSuccess(ctx, newPulsarAuth) + setCookie(ctx, newPulsarAuth) ) - private def deleteCredentials(): Unit = + private def deleteCredentials(setCookie: SetCookie): Unit = post( "/pulsar-auth/delete/{credentialsName}", ctx => @@ -99,15 +122,34 @@ object PulsarAuthRoutes: case DefaultCredentialsName => ctx.status(400) ctx.result(s"Can't delete default credentials") + // Deleting a name that isn't in the map used to answer 200 and still + // rewrite `current` - a typo silently changed which credentials every + // later Pulsar call ran under. Refuse, and write no cookie at all. + case credentialsName: String if !pulsarAuth.credentials.contains(credentialsName) => + ctx.status(404) + ctx.result(s"No credentials named '$credentialsName'") case credentialsName: String => val newCredentials = pulsarAuth.credentials - credentialsName - val newPulsarAuth = - pulsarAuth.copy(credentials = newCredentials, current = newCredentials.keys.headOption.orElse(Some("Default"))) + // `current` used to be reassigned unconditionally to + // `newCredentials.keys.headOption`, so removing an UNRELATED + // credential repointed the session at whatever came first in map + // iteration order. The selection may only move when the deleted + // name is the selected one - and then only to Default, which + // setCookieAndSuccess guarantees still exists. + val newCurrent = + if pulsarAuth.current.contains(credentialsName) then Some(DefaultCredentialsName) + else pulsarAuth.current - setCookieAndSuccess(ctx, newPulsarAuth) + setCookie(ctx, pulsarAuth.copy(credentials = newCredentials, current = newCurrent)) ) - def setCookieAndSuccess(ctx: io.javalin.http.Context, pulsarAuth: PulsarAuth): Unit = + def setCookieAndSuccess( + ctx: io.javalin.http.Context, + pulsarAuth: PulsarAuth, + publicBaseUrl: Option[String] = config.publicBaseUrl, + cookieSecure: Option[Boolean] = config.cookieSecure, + cookieSameSite: Option[String] = config.cookieSameSite + ): Unit = def withNewDefaultAuth(pulsarAuth: PulsarAuth): PulsarAuth = // Dekaf admin can change default credentials, // so we need deliver new default credentials to users. @@ -117,7 +159,7 @@ object PulsarAuthRoutes: ) ) - val newCookieHeader = pulsar_auth.pulsarAuthToCookie(withNewDefaultAuth(pulsarAuth)) + val newCookieHeader = pulsar_auth.pulsarAuthToCookie(withNewDefaultAuth(pulsarAuth), publicBaseUrl, cookieSecure, cookieSameSite) ctx.header( "Set-Cookie", diff --git a/server/src/main/scala/pulsar_auth/PulsarAuthServiceImpl.scala b/server/src/main/scala/pulsar_auth/PulsarAuthServiceImpl.scala index 91a2473d3..984a97e44 100644 --- a/server/src/main/scala/pulsar_auth/PulsarAuthServiceImpl.scala +++ b/server/src/main/scala/pulsar_auth/PulsarAuthServiceImpl.scala @@ -21,7 +21,7 @@ class PulsarAuthServiceImpl extends pb.PulsarAuthServiceGrpc.PulsarAuthService: override def getMaskedCredentials(request: GetMaskedCredentialsRequest): Future[GetMaskedCredentialsResponse] = val pulsarAuth = RequestContext.pulsarAuth.get() - val status: Status = Status(code = Code.OK.index) + val status: Status = Status(code = Code.OK.value) Future.successful( GetMaskedCredentialsResponse( status = Some(status), @@ -39,7 +39,7 @@ class PulsarAuthServiceImpl extends pb.PulsarAuthServiceGrpc.PulsarAuthService: override def getCurrentCredentials(request: GetCurrentCredentialsRequest): Future[GetCurrentCredentialsResponse] = val pulsarAuth = RequestContext.pulsarAuth.get() - val status: Status = Status(code = Code.OK.index) + val status: Status = Status(code = Code.OK.value) Future.successful( GetCurrentCredentialsResponse( status = Some(status), diff --git a/server/src/main/scala/schema/SchemaServiceImpl.scala b/server/src/main/scala/schema/SchemaServiceImpl.scala index c761fb31e..9d58a09c0 100644 --- a/server/src/main/scala/schema/SchemaServiceImpl.scala +++ b/server/src/main/scala/schema/SchemaServiceImpl.scala @@ -47,17 +47,17 @@ class SchemaServiceImpl extends SchemaServiceGrpc.SchemaService: adminClient.schemas.createSchema(request.topic, schemaInfo) logger.info(s"Successfully created schema with name ${s.name} for topic ${request.topic}.") - val status = Status(code = Code.OK.index) + val status = Status(code = Code.OK.value) Future.successful(CreateSchemaResponse(status = Some(status))) } catch { case err => logger.info(s"Failed to create schema with name ${s.name} for topic ${request.topic}. Reason: ${err.getMessage}.") - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(CreateSchemaResponse(status = Some(status))) } case _ => - val status = Status(code = Code.INVALID_ARGUMENT.index) + val status = Status(code = Code.INVALID_ARGUMENT.value) Future.successful(CreateSchemaResponse(status = Some(status))) override def deleteSchema(request: DeleteSchemaRequest): Future[DeleteSchemaResponse] = @@ -69,12 +69,12 @@ class SchemaServiceImpl extends SchemaServiceGrpc.SchemaService: adminClient.schemas.deleteSchema(request.topic, request.force) logger.info(s"Successfully deleted latest schema for topic ${request.topic}.") - val status = Status(code = Code.OK.index) + val status = Status(code = Code.OK.value) Future.successful(DeleteSchemaResponse(status = Some(status))) } catch { case err => logger.info(s"Failed to delete latest schema for topic ${request.topic}. Reason: ${err.getMessage}.") - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(DeleteSchemaResponse(status = Some(status))) } @@ -84,7 +84,7 @@ class SchemaServiceImpl extends SchemaServiceGrpc.SchemaService: try { val schemaInfoWithVersion = adminClient.schemas.getSchemaInfoWithVersion(request.topic) - val status = Status(code = Code.OK.index) + val status = Status(code = Code.OK.value) logger.info(s"Successfully got latest schema info for topic ${request.topic}.") Future.successful( @@ -97,11 +97,11 @@ class SchemaServiceImpl extends SchemaServiceGrpc.SchemaService: } catch { case (_: PulsarAdminException.NotFoundException) => logger.info(s"No schema where found for topic ${request.topic}.") - val status = Status(code = Code.OK.index) + val status = Status(code = Code.OK.value) Future.successful(GetLatestSchemaInfoResponse(status = Some(status), schemaInfo = None, schemaVersion = None)) case (err: PulsarAdminException) => logger.info(s"Failed to get latest schema info for topic ${request.topic}. Reason: ${err.getMessage}.") - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(GetLatestSchemaInfoResponse(status = Some(status))) } @@ -124,12 +124,12 @@ class SchemaServiceImpl extends SchemaServiceGrpc.SchemaService: .map(v => SchemaInfoWithVersion(schemaInfo = Some(schemaInfoToPb(v._1)), schemaVersion = v._2)) logger.info(s"Successfully listed schemas for topic ${request.topic}.") - val status = Status(code = Code.OK.index) + val status = Status(code = Code.OK.value) Future.successful(ListSchemasResponse(status = Some(status), schemas = schemas)) } catch { case err => logger.info(s"Failed to list schemas for topic ${request.topic}. Reason: ${err.getMessage}.") - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(ListSchemasResponse(status = Some(status))) } @@ -163,7 +163,7 @@ class SchemaServiceImpl extends SchemaServiceGrpc.SchemaService: ) logger.info(s"Compiled ${files.size} protobuf native files.") - val status = Status(code = Code.OK.index) + val status = Status(code = Code.OK.value) Future.successful(CompileProtobufNativeResponse(status = Some(status), files)) override def testCompatibility(request: TestCompatibilityRequest): Future[TestCompatibilityResponse] = @@ -174,14 +174,14 @@ class SchemaServiceImpl extends SchemaServiceGrpc.SchemaService: case Some(spb) => schemaInfoFromPb(spb) case None => logger.info(s"Successfully tested schema compatibility for topic ${request.topic}.") - val status = Status(code = Code.INVALID_ARGUMENT.index) + val status = Status(code = Code.INVALID_ARGUMENT.value) return Future.successful(TestCompatibilityResponse(status = Some(status))) val compatibilityTestResult = protobufnative.schemaCompatibility.test(pulsarAdmin = adminClient, topic = request.topic, schemaInfo = schemaInfo) logger.info(s"Successfully tested schema compatibility for topic ${request.topic}.") - val status = Status(code = Code.OK.index) + val status = Status(code = Code.OK.value) Future.successful( TestCompatibilityResponse( status = Some(status), @@ -196,7 +196,7 @@ class SchemaServiceImpl extends SchemaServiceGrpc.SchemaService: request.schemaType match case SchemaTypePb.SCHEMA_TYPE_PROTOBUF_NATIVE => val descriptor = ProtobufNativeSchemaUtils.deserialize(request.rawSchema.toByteArray) - val status = Status(code = Code.OK.index) + val status = Status(code = Code.OK.value) Future.successful( GetHumanReadableSchemaResponse( status = Some(status), @@ -204,7 +204,7 @@ class SchemaServiceImpl extends SchemaServiceGrpc.SchemaService: ) ) case _ => - val status = Status(code = Code.OK.index) + val status = Status(code = Code.OK.value) Future.successful( GetHumanReadableSchemaResponse( status = Some(status), diff --git a/server/src/main/scala/tenant/TenantServiceImpl.scala b/server/src/main/scala/tenant/TenantServiceImpl.scala index 36932db76..627401365 100644 --- a/server/src/main/scala/tenant/TenantServiceImpl.scala +++ b/server/src/main/scala/tenant/TenantServiceImpl.scala @@ -30,11 +30,11 @@ class TenantServiceImpl extends pb.TenantServiceGrpc.TenantService: try { adminClient.tenants.createTenant(request.tenantName, config.build) - val status: Status = Status(code = Code.OK.index) + val status: Status = Status(code = Code.OK.value) Future.successful(pb.CreateTenantResponse(status = Some(status))) } catch { case err => - val status: Status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status: Status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.CreateTenantResponse(status = Some(status))) } @@ -52,11 +52,11 @@ class TenantServiceImpl extends pb.TenantServiceGrpc.TenantService: try { adminClient.tenants.updateTenant(request.tenantName, config.build) - val status: Status = Status(code = Code.OK.index) + val status: Status = Status(code = Code.OK.value) Future.successful(pb.UpdateTenantResponse(status = Some(status))) } catch { case err => - val status: Status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status: Status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.UpdateTenantResponse(status = Some(status))) } @@ -66,11 +66,11 @@ class TenantServiceImpl extends pb.TenantServiceGrpc.TenantService: try { adminClient.tenants.deleteTenant(request.tenantName, request.force) - val status: Status = Status(code = Code.OK.index) + val status: Status = Status(code = Code.OK.value) Future.successful(pb.DeleteTenantResponse(status = Some(status))) } catch { case err => - val status: Status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status: Status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.DeleteTenantResponse(status = Some(status))) } @@ -96,7 +96,7 @@ class TenantServiceImpl extends pb.TenantServiceGrpc.TenantService: request.tenants.zip(tenantsInfo).toMap } catch { case err => - val status: Status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status: Status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) return Future.successful(pb.GetTenantsResponse(status = Some(status))) } @@ -110,12 +110,12 @@ class TenantServiceImpl extends pb.TenantServiceGrpc.TenantService: Map.empty } catch { case err => - val status: Status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status: Status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) return Future.successful(pb.GetTenantsResponse(status = Some(status))) } - val status: Status = Status(code = Code.OK.index) + val status: Status = Status(code = Code.OK.value) Future.successful(pb.GetTenantsResponse( status = Some(status), tenants, @@ -130,9 +130,9 @@ class TenantServiceImpl extends pb.TenantServiceGrpc.TenantService: adminClient.tenants.getTenants.asScala } catch { case err => - val status: Status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status: Status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) return Future.successful(pb.ListTenantsResponse(status = Some(status))) } - val status: Status = Status(code = Code.OK.index) + val status: Status = Status(code = Code.OK.value) Future.successful(pb.ListTenantsResponse(status = Some(status), tenants = tenants.toSeq)) diff --git a/server/src/main/scala/topic/TopicServiceImpl.scala b/server/src/main/scala/topic/TopicServiceImpl.scala index 09de3f3ed..e3ee2019b 100644 --- a/server/src/main/scala/topic/TopicServiceImpl.scala +++ b/server/src/main/scala/topic/TopicServiceImpl.scala @@ -31,11 +31,11 @@ class TopicServiceImpl extends pb.TopicServiceGrpc.TopicService: try { adminClient.topics.createPartitionedTopic(request.topic, request.numPartitions, request.properties.asJava) - val status: Status = Status(code = Code.OK.index) + val status: Status = Status(code = Code.OK.value) Future.successful(pb.CreatePartitionedTopicResponse(status = Some(status))) } catch { case err => - val status: Status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status: Status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.CreatePartitionedTopicResponse(status = Some(status))) } @@ -45,11 +45,11 @@ class TopicServiceImpl extends pb.TopicServiceGrpc.TopicService: try { adminClient.topics.createNonPartitionedTopic(request.topic, request.properties.asJava) - val status: Status = Status(code = Code.OK.index) + val status: Status = Status(code = Code.OK.value) Future.successful(pb.CreateNonPartitionedTopicResponse(status = Some(status))) } catch { case err => - val status: Status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status: Status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.CreateNonPartitionedTopicResponse(status = Some(status))) } @@ -71,11 +71,11 @@ class TopicServiceImpl extends pb.TopicServiceGrpc.TopicService: persistent ++ nonPersistent catch { case err: Throwable => - val status: Status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status: Status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) return Future.successful(pb.ListTopicsResponse(status = Some(status))) } - val status: Status = Status(code = Code.OK.index) + val status: Status = Status(code = Code.OK.value) Future.successful(pb.ListTopicsResponse(status = Some(status), topics = topics)) override def listPartitionedTopics(request: pb.ListPartitionedTopicsRequest): Future[pb.ListPartitionedTopicsResponse] = @@ -88,11 +88,11 @@ class TopicServiceImpl extends pb.TopicServiceGrpc.TopicService: adminClient.topics.getPartitionedTopicList(request.namespace, options) catch { case err: Throwable => - val status: Status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status: Status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) return Future.successful(pb.ListPartitionedTopicsResponse(status = Some(status))) } - val status: Status = Status(code = Code.OK.index) + val status: Status = Status(code = Code.OK.value) Future.successful(pb.ListPartitionedTopicsResponse(status = Some(status), topics = topics.asScala.toSeq)) override def getTopicsInternalStats(request: pb.GetTopicsInternalStatsRequest): Future[pb.GetTopicsInternalStatsResponse] = @@ -110,7 +110,7 @@ class TopicServiceImpl extends pb.TopicServiceGrpc.TopicService: case _ => None }.toMap - val status: Status = Status(code = Code.OK.index) + val status: Status = Status(code = Code.OK.value) Future.successful(pb.GetTopicsInternalStatsResponse(status = Some(status), stats = stats)) override def deleteTopic(request: pb.DeleteTopicRequest): Future[pb.DeleteTopicResponse] = @@ -126,12 +126,12 @@ class TopicServiceImpl extends pb.TopicServiceGrpc.TopicService: def lookupNonPartitionedTopic(): Try[Unit] = Try(adminClient.lookups().lookupTopic(request.topicName)) def handleSuccess(): Future[pb.DeleteTopicResponse] = { - val status = Status(code = Code.OK.index) + val status = Status(code = Code.OK.value) Future.successful(pb.DeleteTopicResponse(status = Some(status))) } def handleFailure(err: Throwable): Future[pb.DeleteTopicResponse] = { - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.DeleteTopicResponse(status = Some(status))) } @@ -154,10 +154,10 @@ class TopicServiceImpl extends pb.TopicServiceGrpc.TopicService: Try(adminClient.topics.unload(request.topicName)) match case Success(_) => - val status = Status(code = Code.OK.index) + val status = Status(code = Code.OK.value) Future.successful(pb.UnloadTopicResponse(status = Some(status))) case Failure(err) => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.UnloadTopicResponse(status = Some(status))) override def getTopicsStats(request: pb.GetTopicsStatsRequest): Future[pb.GetTopicsStatsResponse] = @@ -216,7 +216,7 @@ class TopicServiceImpl extends pb.TopicServiceGrpc.TopicService: // This RPC method always returns Code.OK because in case we request stats for a single topic, // we want to avoid additional API calls to detect is topic partitioned or not. - val status: Status = Status(code = Code.OK.index, message = errors.map(_.getMessage).mkString(". ")) + val status: Status = Status(code = Code.OK.value, message = errors.map(_.getMessage).mkString(". ")) Future.successful(pb.GetTopicsStatsResponse( status = Some(status), @@ -244,10 +244,10 @@ class TopicServiceImpl extends pb.TopicServiceGrpc.TopicService: match case Failure(err) => - val status: Status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status: Status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.GetTopicsPropertiesResponse(status = Some(status))) case Success(properties) => - val status: Status = Status(code = Code.OK.index) + val status: Status = Status(code = Code.OK.value) Future.successful(pb.GetTopicsPropertiesResponse( status = Some(status), topicsProperties = properties @@ -269,10 +269,10 @@ class TopicServiceImpl extends pb.TopicServiceGrpc.TopicService: adminClient.topics.updateProperties(request.topic, request.topicProperties.asJava) match case Failure(err: Throwable) => - val status: Status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status: Status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.SetTopicPropertiesResponse(status = Some(status))) case Success(value) => - val status: Status = Status(code = Code.OK.index) + val status: Status = Status(code = Code.OK.value) Future.successful(pb.SetTopicPropertiesResponse( status = Some(status) )) @@ -282,11 +282,11 @@ class TopicServiceImpl extends pb.TopicServiceGrpc.TopicService: Try(_root_.topic.getTopicPartitioning(adminClient, request.topicFqn)) match case Failure(err: Throwable) => - val status: Status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status: Status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.GetIsPartitionedTopicResponse(status = Some(status))) case Success(partitioning: TopicPartitioning) => val isPartitioned = partitioning.`type` == TopicPartitioningType.Partitioned - val status: Status = Status(code = Code.OK.index) + val status: Status = Status(code = Code.OK.value) Future.successful(pb.GetIsPartitionedTopicResponse( status = Some(status), @@ -300,10 +300,10 @@ class TopicServiceImpl extends pb.TopicServiceGrpc.TopicService: Try(adminClient.topics.updatePartitionedTopic(request.topicFqn, request.numPartitions, request.updateLocalTopicOnly, request.force)) match case Failure(err: Throwable) => - val status: Status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status: Status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.UpdatePartitionedTopicResponse(status = Some(status))) case Success(_) => - val status: Status = Status(code = Code.OK.index) + val status: Status = Status(code = Code.OK.value) Future.successful(pb.UpdatePartitionedTopicResponse(status = Some(status))) override def createMissedPartitions(request: CreateMissedPartitionsRequest): Future[CreateMissedPartitionsResponse] = @@ -311,10 +311,10 @@ class TopicServiceImpl extends pb.TopicServiceGrpc.TopicService: Try(adminClient.topics.createMissedPartitions(request.topicFqn)) match case Failure(err: Throwable) => - val status: Status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status: Status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.CreateMissedPartitionsResponse(status = Some(status))) case Success(_) => - val status: Status = Status(code = Code.OK.index) + val status: Status = Status(code = Code.OK.value) Future.successful(pb.CreateMissedPartitionsResponse(status = Some(status))) override def getCompactionStatus(request: GetCompactionStatusRequest): Future[GetCompactionStatusResponse] = @@ -322,10 +322,10 @@ class TopicServiceImpl extends pb.TopicServiceGrpc.TopicService: Try(adminClient.topics.compactionStatus(request.topicFqn)) match case Failure(err: Throwable) => - val status: Status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status: Status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.GetCompactionStatusResponse(status = Some(status))) case Success(lrps) => - val status: Status = Status(code = Code.OK.index) + val status: Status = Status(code = Code.OK.value) Future.successful(pb.GetCompactionStatusResponse( status = Some(status), processStatus = Some(LongRunningProcessStatus.toPb(lrps)) @@ -336,10 +336,10 @@ class TopicServiceImpl extends pb.TopicServiceGrpc.TopicService: Try(adminClient.topics.triggerCompaction(request.topicFqn)) match case Failure(err: Throwable) => - val status: Status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status: Status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.TriggerCompactionResponse(status = Some(status))) case Success(_) => - val status: Status = Status(code = Code.OK.index) + val status: Status = Status(code = Code.OK.value) Future.successful(pb.TriggerCompactionResponse(status = Some(status))) override def deleteSubscription(request: DeleteSubscriptionRequest): Future[DeleteSubscriptionResponse] = @@ -347,10 +347,10 @@ class TopicServiceImpl extends pb.TopicServiceGrpc.TopicService: Try(adminClient.topics.deleteSubscription(request.topicFqn, request.subscriptionName, request.isForce)) match case Failure(err: Throwable) => - val status: Status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status: Status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.DeleteSubscriptionResponse(status = Some(status))) case Success(_) => - val status: Status = Status(code = Code.OK.index) + val status: Status = Status(code = Code.OK.value) Future.successful(pb.DeleteSubscriptionResponse(status = Some(status))) override def createSubscription(request: CreateSubscriptionRequest): Future[CreateSubscriptionResponse] = @@ -378,10 +378,10 @@ class TopicServiceImpl extends pb.TopicServiceGrpc.TopicService: ) match case Failure(err: Throwable) => - val status: Status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status: Status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.CreateSubscriptionResponse(status = Some(status))) case Success(_) => - val status: Status = Status(code = Code.OK.index) + val status: Status = Status(code = Code.OK.value) Future.successful(pb.CreateSubscriptionResponse(status = Some(status))) override def expireMessages(request: ExpireMessagesRequest): Future[ExpireMessagesResponse] = @@ -424,10 +424,10 @@ class TopicServiceImpl extends pb.TopicServiceGrpc.TopicService: throw RuntimeException("Empty expire messages target (should be either expire of all subscriptions or on a specific one)") match case Failure(err: Throwable) => - val status: Status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status: Status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.ExpireMessagesResponse(status = Some(status))) case Success(_) => - val status: Status = Status(code = Code.OK.index) + val status: Status = Status(code = Code.OK.value) Future.successful(pb.ExpireMessagesResponse(status = Some(status))) override def skipSubscriptionMessages(request: SkipSubscriptionMessagesRequest): Future[SkipSubscriptionMessagesResponse] = @@ -445,10 +445,10 @@ class TopicServiceImpl extends pb.TopicServiceGrpc.TopicService: throw RuntimeException("Empty skip messages target (should be either skip of all messages or exact number of messages)") match case Failure(err: Throwable) => - val status: Status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status: Status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.SkipSubscriptionMessagesResponse(status = Some(status))) case Success(_) => - val status: Status = Status(code = Code.OK.index) + val status: Status = Status(code = Code.OK.value) Future.successful(pb.SkipSubscriptionMessagesResponse(status = Some(status))) override def resetCursor(request: ResetCursorRequest): Future[ResetCursorResponse] = @@ -468,10 +468,10 @@ class TopicServiceImpl extends pb.TopicServiceGrpc.TopicService: adminClient.topics().resetCursor(request.topicFqn, request.subscriptionName, timestamp) match case Failure(err: Throwable) => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(ResetCursorResponse(status = Some(status))) case Success(_) => - val status = Status(code = Code.OK.index) + val status = Status(code = Code.OK.value) Future.successful(ResetCursorResponse(status = Some(status))) override def getSubscriptionStats(request: GetSubscriptionStatsRequest): Future[GetSubscriptionStatsResponse] = @@ -501,10 +501,10 @@ class TopicServiceImpl extends pb.TopicServiceGrpc.TopicService: .getOrElse(throw new Exception(s"Subscription \"${request.subscriptionName}\" not found on topic \"${request.topicFqn}\"")) match case Failure(err: Throwable) => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(GetSubscriptionStatsResponse(status = Some(status))) case Success(subscriptionStats) => - val status = Status(code = Code.OK.index) + val status = Status(code = Code.OK.value) Future.successful(GetSubscriptionStatsResponse( status = Some(status), subscriptionStats = Some(subscriptionStatsToPb(subscriptionStats)) @@ -515,10 +515,10 @@ class TopicServiceImpl extends pb.TopicServiceGrpc.TopicService: Try(adminClient.topics().getSubscriptionProperties(request.topicFqn, request.subscriptionName)) match case Failure(err: Throwable) => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(GetSubscriptionPropertiesResponse(status = Some(status))) case Success(properties) => - val status = Status(code = Code.OK.index) + val status = Status(code = Code.OK.value) Future.successful(GetSubscriptionPropertiesResponse( status = Some(status), properties = Option(properties).map(_.asScala.toMap).getOrElse(Map.empty) @@ -530,8 +530,8 @@ class TopicServiceImpl extends pb.TopicServiceGrpc.TopicService: Try(adminClient.topics().updateSubscriptionProperties(request.topicFqn, request.subscriptionName, request.properties.asJava)) match case Failure(err: Throwable) => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(SetSubscriptionPropertiesResponse(status = Some(status))) case Success(_) => - val status = Status(code = Code.OK.index) + val status = Status(code = Code.OK.value) Future.successful(SetSubscriptionPropertiesResponse(status = Some(status))) diff --git a/server/src/main/scala/topic_policies/TopicPoliciesServiceImpl.scala b/server/src/main/scala/topic_policies/TopicPoliciesServiceImpl.scala index fb93bd61e..c424766ca 100644 --- a/server/src/main/scala/topic_policies/TopicPoliciesServiceImpl.scala +++ b/server/src/main/scala/topic_policies/TopicPoliciesServiceImpl.scala @@ -51,14 +51,14 @@ class TopicPoliciesServiceImpl extends pb.TopicPoliciesServiceGrpc.TopicPolicies Future.successful( pb.GetBacklogQuotasResponse( - status = Some(Status(code = Code.OK.index)), + status = Some(Status(code = Code.OK.value)), destinationStorage = destinationStorageBacklogQuotaPb, messageAge = messageAgeBacklogQuotaPb, ) ) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.GetBacklogQuotasResponse(status = Some(status))) } override def setBacklogQuotas(request: pb.SetBacklogQuotasRequest): Future[pb.SetBacklogQuotasResponse] = @@ -104,10 +104,10 @@ class TopicPoliciesServiceImpl extends pb.TopicPoliciesServiceGrpc.TopicPolicies adminClient.topicPolicies(request.isGlobal).setBacklogQuota(request.topic, backlogQuota, BacklogQuotaType.message_age) case None => - Future.successful(pb.SetBacklogQuotasResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(pb.SetBacklogQuotasResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.SetBacklogQuotasResponse(status = Some(status))) } override def removeBacklogQuota(request: pb.RemoveBacklogQuotaRequest): Future[pb.RemoveBacklogQuotaResponse] = @@ -122,13 +122,13 @@ class TopicPoliciesServiceImpl extends pb.TopicPoliciesServiceGrpc.TopicPolicies logger.info(s"Removing backlog quota (message age) on topic ${request.topic}") adminClient.topicPolicies(request.isGlobal).removeBacklogQuota(request.topic, BacklogQuotaType.message_age) case _ => - val status = Status(code = Code.INVALID_ARGUMENT.index, message = "Backlog quota type should be specified") + val status = Status(code = Code.INVALID_ARGUMENT.value, message = "Backlog quota type should be specified") return Future.successful(pb.RemoveBacklogQuotaResponse(status = Some(status))) - Future.successful(pb.RemoveBacklogQuotaResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(pb.RemoveBacklogQuotaResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.RemoveBacklogQuotaResponse(status = Some(status))) } override def getDelayedDelivery(request: pb.GetDelayedDeliveryRequest): Future[pb.GetDelayedDeliveryResponse] = @@ -147,12 +147,12 @@ class TopicPoliciesServiceImpl extends pb.TopicPoliciesServiceGrpc.TopicPolicies ) Future.successful(pb.GetDelayedDeliveryResponse( - status = Some(Status(code = Code.OK.index)), + status = Some(Status(code = Code.OK.value)), delayedDelivery = delayedDeliveryPb )) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.GetDelayedDeliveryResponse(status = Some(status))) } override def setDelayedDelivery(request: pb.SetDelayedDeliveryRequest): Future[pb.SetDelayedDeliveryResponse] = @@ -166,10 +166,10 @@ class TopicPoliciesServiceImpl extends pb.TopicPoliciesServiceGrpc.TopicPolicies .build() adminClient.topicPolicies(request.isGlobal).setDelayedDeliveryPolicy(request.topic, delayedDeliveryPolicies) - Future.successful(pb.SetDelayedDeliveryResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(pb.SetDelayedDeliveryResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.SetDelayedDeliveryResponse(status = Some(status))) } override def removeDelayedDelivery(request: pb.RemoveDelayedDeliveryRequest): Future[pb.RemoveDelayedDeliveryResponse] = @@ -179,10 +179,10 @@ class TopicPoliciesServiceImpl extends pb.TopicPoliciesServiceGrpc.TopicPolicies logger.info(s"Removing delayed delivery policy for topic ${request.topic}") adminClient.topicPolicies(request.isGlobal).removeDelayedDeliveryPolicy(request.topic) - Future.successful(pb.RemoveDelayedDeliveryResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(pb.RemoveDelayedDeliveryResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.RemoveDelayedDeliveryResponse(status = Some(status))) } override def getMessageTtl(request: pb.GetMessageTtlRequest): Future[pb.GetMessageTtlResponse] = @@ -200,12 +200,12 @@ class TopicPoliciesServiceImpl extends pb.TopicPoliciesServiceGrpc.TopicPolicies ) Future.successful(pb.GetMessageTtlResponse( - status = Some(Status(code = Code.OK.index)), + status = Some(Status(code = Code.OK.value)), messageTtl = messageTtlPb )) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.GetMessageTtlResponse(status = Some(status))) } override def setMessageTtl(request: pb.SetMessageTtlRequest): Future[pb.SetMessageTtlResponse] = @@ -215,10 +215,10 @@ class TopicPoliciesServiceImpl extends pb.TopicPoliciesServiceGrpc.TopicPolicies logger.info(s"Setting message TTL policy for topic ${request.topic}") adminClient.topicPolicies(request.isGlobal).setMessageTTL(request.topic, request.messageTtlSeconds) - Future.successful(pb.SetMessageTtlResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(pb.SetMessageTtlResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.SetMessageTtlResponse(status = Some(status))) } override def removeMessageTtl(request: pb.RemoveMessageTtlRequest): Future[pb.RemoveMessageTtlResponse] = @@ -228,10 +228,10 @@ class TopicPoliciesServiceImpl extends pb.TopicPoliciesServiceGrpc.TopicPolicies logger.info(s"Removing message TTL policy for topic ${request.topic}") adminClient.topicPolicies(request.isGlobal).removeMessageTTL(request.topic) - Future.successful(pb.RemoveMessageTtlResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(pb.RemoveMessageTtlResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.RemoveMessageTtlResponse(status = Some(status))) } override def getRetention(request: pb.GetRetentionRequest): Future[pb.GetRetentionResponse] = @@ -250,12 +250,12 @@ class TopicPoliciesServiceImpl extends pb.TopicPoliciesServiceGrpc.TopicPolicies ) Future.successful(pb.GetRetentionResponse( - status = Some(Status(code = Code.OK.index)), + status = Some(Status(code = Code.OK.value)), retention = retentionPb )) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.GetRetentionResponse(status = Some(status))) } override def setRetention(request: pb.SetRetentionRequest): Future[pb.SetRetentionResponse] = @@ -266,10 +266,10 @@ class TopicPoliciesServiceImpl extends pb.TopicPoliciesServiceGrpc.TopicPolicies val retention = new RetentionPolicies(request.retentionTimeInMinutes, request.retentionSizeInMb) adminClient.topicPolicies(request.isGlobal).setRetention(request.topic, retention) - Future.successful(pb.SetRetentionResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(pb.SetRetentionResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.SetRetentionResponse(status = Some(status))) } override def removeRetention(request: pb.RemoveRetentionRequest): Future[pb.RemoveRetentionResponse] = @@ -279,10 +279,10 @@ class TopicPoliciesServiceImpl extends pb.TopicPoliciesServiceGrpc.TopicPolicies logger.info(s"Removing retention for topic ${request.topic}") adminClient.topicPolicies(request.isGlobal).removeRetention(request.topic) - Future.successful(pb.RemoveRetentionResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(pb.RemoveRetentionResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.RemoveRetentionResponse(status = Some(status))) } override def getMaxUnackedMessagesOnConsumer(request: pb.GetMaxUnackedMessagesOnConsumerRequest): Future[pb.GetMaxUnackedMessagesOnConsumerResponse] = @@ -300,12 +300,12 @@ class TopicPoliciesServiceImpl extends pb.TopicPoliciesServiceGrpc.TopicPolicies ) Future.successful(pb.GetMaxUnackedMessagesOnConsumerResponse( - status = Some(Status(code = Code.OK.index)), + status = Some(Status(code = Code.OK.value)), maxUnackedMessagesOnConsumer = maxUnackedMessagesOnConsumerPb )) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.GetMaxUnackedMessagesOnConsumerResponse(status = Some(status))) } override def setMaxUnackedMessagesOnConsumer(request: pb.SetMaxUnackedMessagesOnConsumerRequest): Future[pb.SetMaxUnackedMessagesOnConsumerResponse] = @@ -315,10 +315,10 @@ class TopicPoliciesServiceImpl extends pb.TopicPoliciesServiceGrpc.TopicPolicies logger.info(s"Setting max unacked messages on consumer policy for topic ${request.topic}") adminClient.topicPolicies(request.isGlobal).setMaxUnackedMessagesOnConsumer(request.topic, request.maxUnackedMessagesOnConsumer) - Future.successful(pb.SetMaxUnackedMessagesOnConsumerResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(pb.SetMaxUnackedMessagesOnConsumerResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.SetMaxUnackedMessagesOnConsumerResponse(status = Some(status))) } @@ -329,10 +329,10 @@ class TopicPoliciesServiceImpl extends pb.TopicPoliciesServiceGrpc.TopicPolicies logger.info(s"Removing max unacked messages on consumer policy for topic ${request.topic}") adminClient.topicPolicies(request.isGlobal).removeMaxUnackedMessagesOnConsumer(request.topic) - Future.successful(pb.RemoveMaxUnackedMessagesOnConsumerResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(pb.RemoveMaxUnackedMessagesOnConsumerResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.RemoveMaxUnackedMessagesOnConsumerResponse(status = Some(status))) } override def getMaxUnackedMessagesOnSubscription(request: pb.GetMaxUnackedMessagesOnSubscriptionRequest): Future[pb.GetMaxUnackedMessagesOnSubscriptionResponse] = @@ -350,12 +350,12 @@ class TopicPoliciesServiceImpl extends pb.TopicPoliciesServiceGrpc.TopicPolicies ) Future.successful(pb.GetMaxUnackedMessagesOnSubscriptionResponse( - status = Some(Status(code = Code.OK.index)), + status = Some(Status(code = Code.OK.value)), maxUnackedMessagesOnSubscription = maxUnackedMessagesOnSubscriptionPb )) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.GetMaxUnackedMessagesOnSubscriptionResponse(status = Some(status))) } override def setMaxUnackedMessagesOnSubscription(request: pb.SetMaxUnackedMessagesOnSubscriptionRequest): Future[pb.SetMaxUnackedMessagesOnSubscriptionResponse] = @@ -365,10 +365,10 @@ class TopicPoliciesServiceImpl extends pb.TopicPoliciesServiceGrpc.TopicPolicies logger.info(s"Setting max unacked messages on subscription policy for topic ${request.topic}") adminClient.topicPolicies(request.isGlobal).setMaxUnackedMessagesOnSubscription(request.topic, request.maxUnackedMessagesOnSubscription) - Future.successful(pb.SetMaxUnackedMessagesOnSubscriptionResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(pb.SetMaxUnackedMessagesOnSubscriptionResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.SetMaxUnackedMessagesOnSubscriptionResponse(status = Some(status))) } override def removeMaxUnackedMessagesOnSubscription(request: pb.RemoveMaxUnackedMessagesOnSubscriptionRequest): Future[pb.RemoveMaxUnackedMessagesOnSubscriptionResponse] = @@ -378,10 +378,10 @@ class TopicPoliciesServiceImpl extends pb.TopicPoliciesServiceGrpc.TopicPolicies logger.info(s"Removing max unacked messages on subscription policy for topic ${request.topic}") adminClient.topicPolicies(request.isGlobal).removeMaxUnackedMessagesOnSubscription(request.topic) - Future.successful(pb.RemoveMaxUnackedMessagesOnSubscriptionResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(pb.RemoveMaxUnackedMessagesOnSubscriptionResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.RemoveMaxUnackedMessagesOnSubscriptionResponse(status = Some(status))) } override def getInactiveTopicPolicies(request: pb.GetInactiveTopicPoliciesRequest): Future[pb.GetInactiveTopicPoliciesResponse] = @@ -406,12 +406,12 @@ class TopicPoliciesServiceImpl extends pb.TopicPoliciesServiceGrpc.TopicPolicies ) Future.successful(pb.GetInactiveTopicPoliciesResponse( - status = Some(Status(code = Code.OK.index)), + status = Some(Status(code = Code.OK.value)), inactiveTopicPolicies = inactiveTopicPoliciesPb )) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.GetInactiveTopicPoliciesResponse(status = Some(status))) } override def setInactiveTopicPolicies(request: pb.SetInactiveTopicPoliciesRequest): Future[pb.SetInactiveTopicPoliciesResponse] = @@ -433,10 +433,10 @@ class TopicPoliciesServiceImpl extends pb.TopicPoliciesServiceGrpc.TopicPolicies throw new IllegalArgumentException("InactiveTopicPoliciesDeleteMode should be specified.") adminClient.topicPolicies(request.isGlobal).setInactiveTopicPolicies(request.topic, inactiveTopicPolicies) - Future.successful(pb.SetInactiveTopicPoliciesResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(pb.SetInactiveTopicPoliciesResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.SetInactiveTopicPoliciesResponse(status = Some(status))) } override def removeInactiveTopicPolicies(request: pb.RemoveInactiveTopicPoliciesRequest): Future[pb.RemoveInactiveTopicPoliciesResponse] = @@ -446,10 +446,10 @@ class TopicPoliciesServiceImpl extends pb.TopicPoliciesServiceGrpc.TopicPolicies logger.info(s"Removing inactive topic policies for topic ${request.topic}") adminClient.topicPolicies(request.isGlobal).removeInactiveTopicPolicies(request.topic) - Future.successful(pb.RemoveInactiveTopicPoliciesResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(pb.RemoveInactiveTopicPoliciesResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.RemoveInactiveTopicPoliciesResponse(status = Some(status))) } override def getPersistence(request: pb.GetPersistenceRequest): Future[pb.GetPersistenceResponse] = @@ -470,12 +470,12 @@ class TopicPoliciesServiceImpl extends pb.TopicPoliciesServiceGrpc.TopicPolicies ) Future.successful(pb.GetPersistenceResponse( - status = Some(Status(code = Code.OK.index)), + status = Some(Status(code = Code.OK.value)), persistence = persistencePb )) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.GetPersistenceResponse(status = Some(status))) } override def setPersistence(request: pb.SetPersistenceRequest): Future[pb.SetPersistenceResponse] = @@ -486,10 +486,10 @@ class TopicPoliciesServiceImpl extends pb.TopicPoliciesServiceGrpc.TopicPolicies val persistencePolicies = PersistencePolicies(request.bookkeeperEnsemble, request.bookkeeperWriteQuorum, request.bookkeeperAckQuorum, request.managedLedgerMaxMarkDeleteRate) adminClient.topicPolicies(request.isGlobal).setPersistence(request.topic, persistencePolicies) - Future.successful(pb.SetPersistenceResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(pb.SetPersistenceResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.SetPersistenceResponse(status = Some(status))) } override def removePersistence(request: pb.RemovePersistenceRequest): Future[pb.RemovePersistenceResponse] = @@ -499,10 +499,10 @@ class TopicPoliciesServiceImpl extends pb.TopicPoliciesServiceGrpc.TopicPolicies logger.info(s"Removing persistence policy for topic ${request.topic}") adminClient.topicPolicies(request.isGlobal).removePersistence(request.topic) - Future.successful(pb.RemovePersistenceResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(pb.RemovePersistenceResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.RemovePersistenceResponse(status = Some(status))) } override def getDeduplication(request: pb.GetDeduplicationRequest): Future[pb.GetDeduplicationResponse] = @@ -516,12 +516,12 @@ class TopicPoliciesServiceImpl extends pb.TopicPoliciesServiceGrpc.TopicPolicies pb.GetDeduplicationResponse.Deduplication.Specified(new pb.DeduplicationSpecified(enabled = v)) Future.successful(pb.GetDeduplicationResponse( - status = Some(Status(code = Code.OK.index)), + status = Some(Status(code = Code.OK.value)), deduplication )) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.GetDeduplicationResponse(status = Some(status))) } override def setDeduplication(request: pb.SetDeduplicationRequest): Future[pb.SetDeduplicationResponse] = @@ -530,10 +530,10 @@ class TopicPoliciesServiceImpl extends pb.TopicPoliciesServiceGrpc.TopicPolicies try { logger.info(s"Setting deduplication policy for topic ${request.topic}") adminClient.topicPolicies(request.isGlobal).setDeduplicationStatus(request.topic, request.enabled) - Future.successful(pb.SetDeduplicationResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(pb.SetDeduplicationResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.SetDeduplicationResponse(status = Some(status))) } override def removeDeduplication(request: pb.RemoveDeduplicationRequest): Future[pb.RemoveDeduplicationResponse] = @@ -543,10 +543,10 @@ class TopicPoliciesServiceImpl extends pb.TopicPoliciesServiceGrpc.TopicPolicies logger.info(s"Removing deduplication policy for topic ${request.topic}") adminClient.topicPolicies(request.isGlobal).removeDeduplicationStatus(request.topic) - Future.successful(pb.RemoveDeduplicationResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(pb.RemoveDeduplicationResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.RemoveDeduplicationResponse(status = Some(status))) } override def getDeduplicationSnapshotInterval(request: pb.GetDeduplicationSnapshotIntervalRequest): Future[pb.GetDeduplicationSnapshotIntervalResponse] = @@ -560,12 +560,12 @@ class TopicPoliciesServiceImpl extends pb.TopicPoliciesServiceGrpc.TopicPolicies pb.GetDeduplicationSnapshotIntervalResponse.Interval.Enabled(new pb.DeduplicationSnapshotIntervalEnabled(interval = v)) Future.successful(pb.GetDeduplicationSnapshotIntervalResponse( - status = Some(Status(code = Code.OK.index)), + status = Some(Status(code = Code.OK.value)), interval )) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.GetDeduplicationSnapshotIntervalResponse(status = Some(status))) } override def setDeduplicationSnapshotInterval(request: pb.SetDeduplicationSnapshotIntervalRequest): Future[pb.SetDeduplicationSnapshotIntervalResponse] = @@ -575,10 +575,10 @@ class TopicPoliciesServiceImpl extends pb.TopicPoliciesServiceGrpc.TopicPolicies logger.info(s"Setting deduplication snapshot interval policy for topic ${request.topic}. ${request.interval}") adminClient.topicPolicies(request.isGlobal).setDeduplicationSnapshotInterval(request.topic, request.interval) - Future.successful(pb.SetDeduplicationSnapshotIntervalResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(pb.SetDeduplicationSnapshotIntervalResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.SetDeduplicationSnapshotIntervalResponse(status = Some(status))) } override def removeDeduplicationSnapshotInterval(request: pb.RemoveDeduplicationSnapshotIntervalRequest): Future[pb.RemoveDeduplicationSnapshotIntervalResponse] = @@ -588,10 +588,10 @@ class TopicPoliciesServiceImpl extends pb.TopicPoliciesServiceGrpc.TopicPolicies logger.info(s"Removing deduplication snapshot interval policy for topic ${request.topic}") adminClient.topicPolicies(request.isGlobal).removeDeduplicationSnapshotInterval(request.topic) - Future.successful(pb.RemoveDeduplicationSnapshotIntervalResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(pb.RemoveDeduplicationSnapshotIntervalResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.RemoveDeduplicationSnapshotIntervalResponse(status = Some(status))) } override def getDispatchRate(request: pb.GetDispatchRateRequest): Future[pb.GetDispatchRateResponse] = @@ -612,12 +612,12 @@ class TopicPoliciesServiceImpl extends pb.TopicPoliciesServiceGrpc.TopicPolicies ) Future.successful(pb.GetDispatchRateResponse( - status = Some(Status(code = Code.OK.index)), + status = Some(Status(code = Code.OK.value)), dispatchRate = dispatchRatePb )) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.GetDispatchRateResponse(status = Some(status))) } override def setDispatchRate(request: pb.SetDispatchRateRequest): Future[pb.SetDispatchRateResponse] = @@ -633,10 +633,10 @@ class TopicPoliciesServiceImpl extends pb.TopicPoliciesServiceGrpc.TopicPolicies .build adminClient.topicPolicies(request.isGlobal).setDispatchRate(request.topic, dispatchRate) - Future.successful(pb.SetDispatchRateResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(pb.SetDispatchRateResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.SetDispatchRateResponse(status = Some(status))) } override def removeDispatchRate(request: pb.RemoveDispatchRateRequest): Future[pb.RemoveDispatchRateResponse] = @@ -646,10 +646,10 @@ class TopicPoliciesServiceImpl extends pb.TopicPoliciesServiceGrpc.TopicPolicies logger.info(s"Removing dispatch rate policy for topic ${request.topic}") adminClient.topicPolicies(request.isGlobal).removeDispatchRate(request.topic) - Future.successful(pb.RemoveDispatchRateResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(pb.RemoveDispatchRateResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.RemoveDispatchRateResponse(status = Some(status))) } override def getReplicatorDispatchRate(request: pb.GetReplicatorDispatchRateRequest): Future[pb.GetReplicatorDispatchRateResponse] = @@ -670,12 +670,12 @@ class TopicPoliciesServiceImpl extends pb.TopicPoliciesServiceGrpc.TopicPolicies ) Future.successful(pb.GetReplicatorDispatchRateResponse( - status = Some(Status(code = Code.OK.index)), + status = Some(Status(code = Code.OK.value)), replicatorDispatchRate = replicatorDispatchRatePb )) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.GetReplicatorDispatchRateResponse(status = Some(status))) } override def setReplicatorDispatchRate(request: pb.SetReplicatorDispatchRateRequest): Future[pb.SetReplicatorDispatchRateResponse] = @@ -691,10 +691,10 @@ class TopicPoliciesServiceImpl extends pb.TopicPoliciesServiceGrpc.TopicPolicies .build adminClient.topicPolicies(request.isGlobal).setReplicatorDispatchRate(request.topic, dispatchRate) - Future.successful(pb.SetReplicatorDispatchRateResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(pb.SetReplicatorDispatchRateResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.SetReplicatorDispatchRateResponse(status = Some(status))) } override def removeReplicatorDispatchRate(request: pb.RemoveReplicatorDispatchRateRequest): Future[pb.RemoveReplicatorDispatchRateResponse] = @@ -704,10 +704,10 @@ class TopicPoliciesServiceImpl extends pb.TopicPoliciesServiceGrpc.TopicPolicies logger.info(s"Removing replicator dispatch rate for topic ${request.topic}") adminClient.topicPolicies(request.isGlobal).removeReplicatorDispatchRate(request.topic) - Future.successful(pb.RemoveReplicatorDispatchRateResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(pb.RemoveReplicatorDispatchRateResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.RemoveReplicatorDispatchRateResponse(status = Some(status))) } override def getSubscriptionDispatchRate(request: pb.GetSubscriptionDispatchRateRequest): Future[pb.GetSubscriptionDispatchRateResponse] = @@ -728,12 +728,12 @@ class TopicPoliciesServiceImpl extends pb.TopicPoliciesServiceGrpc.TopicPolicies ) Future.successful(pb.GetSubscriptionDispatchRateResponse( - status = Some(Status(code = Code.OK.index)), + status = Some(Status(code = Code.OK.value)), subscriptionDispatchRate = subscriptionDispatchRatePb )) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.GetSubscriptionDispatchRateResponse(status = Some(status))) } override def setSubscriptionDispatchRate(request: pb.SetSubscriptionDispatchRateRequest): Future[pb.SetSubscriptionDispatchRateResponse] = @@ -749,10 +749,10 @@ class TopicPoliciesServiceImpl extends pb.TopicPoliciesServiceGrpc.TopicPolicies .build adminClient.topicPolicies(request.isGlobal).setSubscriptionDispatchRate(request.topic, dispatchRate) - Future.successful(pb.SetSubscriptionDispatchRateResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(pb.SetSubscriptionDispatchRateResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.SetSubscriptionDispatchRateResponse(status = Some(status))) } override def removeSubscriptionDispatchRate(request: pb.RemoveSubscriptionDispatchRateRequest): Future[pb.RemoveSubscriptionDispatchRateResponse] = @@ -762,10 +762,10 @@ class TopicPoliciesServiceImpl extends pb.TopicPoliciesServiceGrpc.TopicPolicies logger.info(s"Removing subscription dispatch rate for topic ${request.topic}") adminClient.topicPolicies(request.isGlobal).removeSubscriptionDispatchRate(request.topic) - Future.successful(pb.RemoveSubscriptionDispatchRateResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(pb.RemoveSubscriptionDispatchRateResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.RemoveSubscriptionDispatchRateResponse(status = Some(status))) } override def getCompactionThreshold(request: pb.GetCompactionThresholdRequest): Future[pb.GetCompactionThresholdResponse] = @@ -776,12 +776,12 @@ class TopicPoliciesServiceImpl extends pb.TopicPoliciesServiceGrpc.TopicPolicies case None => pb.GetCompactionThresholdResponse.Threshold.Disabled(new pb.CompactionThresholdDisabled()) case Some(v) => pb.GetCompactionThresholdResponse.Threshold.Enabled(new pb.CompactionThresholdEnabled(threshold = v)) Future.successful(pb.GetCompactionThresholdResponse( - status = Some(Status(code = Code.OK.index)), + status = Some(Status(code = Code.OK.value)), threshold )) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.GetCompactionThresholdResponse(status = Some(status))) } override def setCompactionThreshold(request: pb.SetCompactionThresholdRequest): Future[pb.SetCompactionThresholdResponse] = @@ -791,10 +791,10 @@ class TopicPoliciesServiceImpl extends pb.TopicPoliciesServiceGrpc.TopicPolicies logger.info(s"Setting compaction threshold policy for topic ${request.topic}. ${request.threshold}") adminClient.topicPolicies(request.isGlobal).setCompactionThreshold(request.topic, request.threshold) - Future.successful(pb.SetCompactionThresholdResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(pb.SetCompactionThresholdResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.SetCompactionThresholdResponse(status = Some(status))) } override def removeCompactionThreshold(request: pb.RemoveCompactionThresholdRequest): Future[pb.RemoveCompactionThresholdResponse] = @@ -804,10 +804,10 @@ class TopicPoliciesServiceImpl extends pb.TopicPoliciesServiceGrpc.TopicPolicies logger.info(s"Removing compaction threshold policy for topic ${request.topic}") adminClient.topicPolicies(request.isGlobal).removeCompactionThreshold(request.topic) - Future.successful(pb.RemoveCompactionThresholdResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(pb.RemoveCompactionThresholdResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.RemoveCompactionThresholdResponse(status = Some(status))) } override def getPublishRate(request: pb.GetPublishRateRequest): Future[pb.GetPublishRateResponse] = @@ -826,12 +826,12 @@ class TopicPoliciesServiceImpl extends pb.TopicPoliciesServiceGrpc.TopicPolicies ) Future.successful(pb.GetPublishRateResponse( - status = Some(Status(code = Code.OK.index)), + status = Some(Status(code = Code.OK.value)), publishRate = publishRatePb )) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.GetPublishRateResponse(status = Some(status))) } override def setPublishRate(request: pb.SetPublishRateRequest): Future[pb.SetPublishRateResponse] = @@ -842,10 +842,10 @@ class TopicPoliciesServiceImpl extends pb.TopicPoliciesServiceGrpc.TopicPolicies val publishRate = PublishRate( request.rateInMsg, request.rateInByte ) adminClient.topicPolicies(request.isGlobal).setPublishRate(request.topic, publishRate) - Future.successful(pb.SetPublishRateResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(pb.SetPublishRateResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.SetPublishRateResponse(status = Some(status))) } override def removePublishRate(request: pb.RemovePublishRateRequest): Future[pb.RemovePublishRateResponse] = @@ -855,10 +855,10 @@ class TopicPoliciesServiceImpl extends pb.TopicPoliciesServiceGrpc.TopicPolicies logger.info(s"Removing publish rate policy for topic ${request.topic}") adminClient.topicPolicies(request.isGlobal).removePublishRate(request.topic) - Future.successful(pb.RemovePublishRateResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(pb.RemovePublishRateResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.RemovePublishRateResponse(status = Some(status))) } override def getMaxConsumersPerSubscription(request: pb.GetMaxConsumersPerSubscriptionRequest): Future[pb.GetMaxConsumersPerSubscriptionResponse] = @@ -877,13 +877,13 @@ class TopicPoliciesServiceImpl extends pb.TopicPoliciesServiceGrpc.TopicPolicies Future.successful( pb.GetMaxConsumersPerSubscriptionResponse( - status = Some(Status(code = Code.OK.index)), + status = Some(Status(code = Code.OK.value)), maxConsumersPerSubscription = maxConsumersPerSubscriptionPb ) ) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.GetMaxConsumersPerSubscriptionResponse(status = Some(status))) } override def setMaxConsumersPerSubscription(request: pb.SetMaxConsumersPerSubscriptionRequest): Future[pb.SetMaxConsumersPerSubscriptionResponse] = @@ -893,10 +893,10 @@ class TopicPoliciesServiceImpl extends pb.TopicPoliciesServiceGrpc.TopicPolicies logger.info(s"Setting max consumers per subscription policy for topic ${request.topic}") adminClient.topicPolicies(request.isGlobal).setMaxConsumersPerSubscription(request.topic, request.maxConsumersPerSubscription) - Future.successful(pb.SetMaxConsumersPerSubscriptionResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(pb.SetMaxConsumersPerSubscriptionResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.SetMaxConsumersPerSubscriptionResponse(status = Some(status))) } override def removeMaxConsumersPerSubscription(request: pb.RemoveMaxConsumersPerSubscriptionRequest): Future[pb.RemoveMaxConsumersPerSubscriptionResponse] = @@ -906,10 +906,10 @@ class TopicPoliciesServiceImpl extends pb.TopicPoliciesServiceGrpc.TopicPolicies logger.info(s"Removing max consumers per subscription policy for topic ${request.topic}") adminClient.topicPolicies(request.isGlobal).removeMaxConsumersPerSubscription(request.topic) - Future.successful(pb.RemoveMaxConsumersPerSubscriptionResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(pb.RemoveMaxConsumersPerSubscriptionResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.RemoveMaxConsumersPerSubscriptionResponse(status = Some(status))) } override def getMaxProducers(request: pb.GetMaxProducersRequest): Future[pb.GetMaxProducersResponse] = @@ -926,13 +926,13 @@ class TopicPoliciesServiceImpl extends pb.TopicPoliciesServiceGrpc.TopicPolicies Future.successful( pb.GetMaxProducersResponse( - status = Some(Status(code = Code.OK.index)), + status = Some(Status(code = Code.OK.value)), maxProducers = maxProducersPb ) ) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.GetMaxProducersResponse(status = Some(status))) } override def setMaxProducers(request: pb.SetMaxProducersRequest): Future[pb.SetMaxProducersResponse] = @@ -942,10 +942,10 @@ class TopicPoliciesServiceImpl extends pb.TopicPoliciesServiceGrpc.TopicPolicies logger.info(s"Setting max producers per topic policy for topic ${request.topic}") adminClient.topicPolicies(request.isGlobal).setMaxProducers(request.topic, request.maxProducers) - Future.successful(pb.SetMaxProducersResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(pb.SetMaxProducersResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.SetMaxProducersResponse(status = Some(status))) } override def removeMaxProducers(request: pb.RemoveMaxProducersRequest): Future[pb.RemoveMaxProducersResponse] = @@ -955,10 +955,10 @@ class TopicPoliciesServiceImpl extends pb.TopicPoliciesServiceGrpc.TopicPolicies logger.info(s"Removing max producers per topic policy for topic ${request.topic}") adminClient.topicPolicies(request.isGlobal).removeMaxProducers(request.topic) - Future.successful(pb.RemoveMaxProducersResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(pb.RemoveMaxProducersResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.RemoveMaxProducersResponse(status = Some(status))) } override def getMaxSubscriptionsPerTopic(request: pb.GetMaxSubscriptionsPerTopicRequest): Future[pb.GetMaxSubscriptionsPerTopicResponse] = @@ -977,13 +977,13 @@ class TopicPoliciesServiceImpl extends pb.TopicPoliciesServiceGrpc.TopicPolicies Future.successful( pb.GetMaxSubscriptionsPerTopicResponse( - status = Some(Status(code = Code.OK.index)), + status = Some(Status(code = Code.OK.value)), maxSubscriptionsPerTopic = maxSubscriptionsPerTopicPb ) ) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.GetMaxSubscriptionsPerTopicResponse(status = Some(status))) } override def setMaxSubscriptionsPerTopic(request: pb.SetMaxSubscriptionsPerTopicRequest): Future[pb.SetMaxSubscriptionsPerTopicResponse] = @@ -993,10 +993,10 @@ class TopicPoliciesServiceImpl extends pb.TopicPoliciesServiceGrpc.TopicPolicies logger.info(s"Setting max subscriptions per topic policy for topic ${request.topic}") adminClient.topicPolicies(request.isGlobal).setMaxSubscriptionsPerTopic(request.topic, request.maxSubscriptionsPerTopic) - Future.successful(pb.SetMaxSubscriptionsPerTopicResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(pb.SetMaxSubscriptionsPerTopicResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.SetMaxSubscriptionsPerTopicResponse(status = Some(status))) } override def removeMaxSubscriptionsPerTopic(request: pb.RemoveMaxSubscriptionsPerTopicRequest): Future[pb.RemoveMaxSubscriptionsPerTopicResponse] = @@ -1006,10 +1006,10 @@ class TopicPoliciesServiceImpl extends pb.TopicPoliciesServiceGrpc.TopicPolicies logger.info(s"Removing max subscriptions per topic policy for topic ${request.topic}") adminClient.topicPolicies(request.isGlobal).removeMaxSubscriptionsPerTopic(request.topic) - Future.successful(pb.RemoveMaxSubscriptionsPerTopicResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(pb.RemoveMaxSubscriptionsPerTopicResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.RemoveMaxSubscriptionsPerTopicResponse(status = Some(status))) } override def getMaxConsumers(request: pb.GetMaxConsumersRequest): Future[pb.GetMaxConsumersResponse] = @@ -1028,13 +1028,13 @@ class TopicPoliciesServiceImpl extends pb.TopicPoliciesServiceGrpc.TopicPolicies Future.successful( pb.GetMaxConsumersResponse( - status = Some(Status(code = Code.OK.index)), + status = Some(Status(code = Code.OK.value)), maxConsumers = maxConsumersPb ) ) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.GetMaxConsumersResponse(status = Some(status))) } override def setMaxConsumers(request: pb.SetMaxConsumersRequest): Future[pb.SetMaxConsumersResponse] = @@ -1044,10 +1044,10 @@ class TopicPoliciesServiceImpl extends pb.TopicPoliciesServiceGrpc.TopicPolicies logger.info(s"Setting max consumers per topic policy for topic ${request.topic}") adminClient.topicPolicies(request.isGlobal).setMaxConsumers(request.topic, request.maxConsumers) - Future.successful(pb.SetMaxConsumersResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(pb.SetMaxConsumersResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.SetMaxConsumersResponse(status = Some(status))) } override def removeMaxConsumers(request: pb.RemoveMaxConsumersRequest): Future[pb.RemoveMaxConsumersResponse] = @@ -1057,10 +1057,10 @@ class TopicPoliciesServiceImpl extends pb.TopicPoliciesServiceGrpc.TopicPolicies logger.info(s"Removing max consumers per topic policy for topic ${request.topic}") adminClient.topicPolicies(request.isGlobal).removeMaxConsumers(request.topic) - Future.successful(pb.RemoveMaxConsumersResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(pb.RemoveMaxConsumersResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.RemoveMaxConsumersResponse(status = Some(status))) } override def getSubscriptionTypesEnabled(request: pb.GetSubscriptionTypesEnabledRequest): Future[pb.GetSubscriptionTypesEnabledResponse] = @@ -1086,13 +1086,13 @@ class TopicPoliciesServiceImpl extends pb.TopicPoliciesServiceGrpc.TopicPolicies ) Future.successful( pb.GetSubscriptionTypesEnabledResponse( - status = Some(Status(code = Code.OK.index)), + status = Some(Status(code = Code.OK.value)), subscriptionTypesEnabled = subscriptionTypesEnabledPb ) ) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.GetSubscriptionTypesEnabledResponse(status = Some(status))) } override def setSubscriptionTypesEnabled(request: pb.SetSubscriptionTypesEnabledRequest): Future[pb.SetSubscriptionTypesEnabledResponse] = @@ -1112,10 +1112,10 @@ class TopicPoliciesServiceImpl extends pb.TopicPoliciesServiceGrpc.TopicPolicies val subscriptionTypesEnabled = request.types.map(pbToSubscriptionType).toSet.asJava adminClient.topicPolicies(request.isGlobal).setSubscriptionTypesEnabled(request.topic, subscriptionTypesEnabled) - Future.successful(pb.SetSubscriptionTypesEnabledResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(pb.SetSubscriptionTypesEnabledResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.SetSubscriptionTypesEnabledResponse(status = Some(status))) } override def removeSubscriptionTypesEnabled(request: pb.RemoveSubscriptionTypesEnabledRequest): Future[pb.RemoveSubscriptionTypesEnabledResponse] = @@ -1125,10 +1125,10 @@ class TopicPoliciesServiceImpl extends pb.TopicPoliciesServiceGrpc.TopicPolicies logger.info(s"Removing subscription types enabled policy for topic ${request.topic}") adminClient.topicPolicies(request.isGlobal).removeSubscriptionTypesEnabled(request.topic) - Future.successful(pb.RemoveSubscriptionTypesEnabledResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(pb.RemoveSubscriptionTypesEnabledResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.RemoveSubscriptionTypesEnabledResponse(status = Some(status))) } override def getSubscribeRate(request: pb.GetSubscribeRateRequest): Future[pb.GetSubscribeRateResponse] = @@ -1148,13 +1148,13 @@ class TopicPoliciesServiceImpl extends pb.TopicPoliciesServiceGrpc.TopicPolicies Future.successful( pb.GetSubscribeRateResponse( - status = Some(Status(code = Code.OK.index)), + status = Some(Status(code = Code.OK.value)), subscribeRate = subscribeRatePb ) ) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.GetSubscribeRateResponse(status = Some(status))) } override def setSubscribeRate(request: pb.SetSubscribeRateRequest): Future[pb.SetSubscribeRateResponse] = @@ -1165,10 +1165,10 @@ class TopicPoliciesServiceImpl extends pb.TopicPoliciesServiceGrpc.TopicPolicies val subscribeRate = new SubscribeRate(request.subscribeThrottlingRatePerConsumer, request.ratePeriodInSeconds) adminClient.topicPolicies(request.isGlobal).setSubscribeRate(request.topic, subscribeRate) - Future.successful(pb.SetSubscribeRateResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(pb.SetSubscribeRateResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.SetSubscribeRateResponse(status = Some(status))) } override def removeSubscribeRate(request: pb.RemoveSubscribeRateRequest): Future[pb.RemoveSubscribeRateResponse] = @@ -1177,10 +1177,10 @@ class TopicPoliciesServiceImpl extends pb.TopicPoliciesServiceGrpc.TopicPolicies try { logger.info(s"Removing subscribe rate policy for topic ${request.topic}") adminClient.topicPolicies(request.isGlobal).removeSubscribeRate(request.topic) - Future.successful(pb.RemoveSubscribeRateResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(pb.RemoveSubscribeRateResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.RemoveSubscribeRateResponse(status = Some(status))) } override def getSchemaCompatibilityStrategy(request: pb.GetSchemaCompatibilityStrategyRequest): Future[pb.GetSchemaCompatibilityStrategyResponse] = @@ -1194,7 +1194,7 @@ class TopicPoliciesServiceImpl extends pb.TopicPoliciesServiceGrpc.TopicPolicies pb.GetSchemaCompatibilityStrategyResponse.Strategy.Specified(new pb.SchemaCompatibilityStrategySpecified( strategy = schemaCompatibilityStrategyToPb(v) )) - val status = Status(code = Code.OK.index) + val status = Status(code = Code.OK.value) Future.successful( pb.GetSchemaCompatibilityStrategyResponse( status = Some(status), @@ -1203,7 +1203,7 @@ class TopicPoliciesServiceImpl extends pb.TopicPoliciesServiceGrpc.TopicPolicies ) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.GetSchemaCompatibilityStrategyResponse(status = Some(status))) } override def setSchemaCompatibilityStrategy(request: pb.SetSchemaCompatibilityStrategyRequest): Future[pb.SetSchemaCompatibilityStrategyResponse] = @@ -1212,11 +1212,11 @@ class TopicPoliciesServiceImpl extends pb.TopicPoliciesServiceGrpc.TopicPolicies try { adminClient.topicPolicies(request.isGlobal).setSchemaCompatibilityStrategy(request.topic, schemaCompatibilityStrategyFromPb(request.strategy)) - val status = Status(code = Code.OK.index) + val status = Status(code = Code.OK.value) Future.successful(pb.SetSchemaCompatibilityStrategyResponse(status = Some(status))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.SetSchemaCompatibilityStrategyResponse(status = Some(status))) } override def removeSchemaCompatibilityStrategy(request: pb.RemoveSchemaCompatibilityStrategyRequest): Future[pb.RemoveSchemaCompatibilityStrategyResponse] = @@ -1225,10 +1225,10 @@ class TopicPoliciesServiceImpl extends pb.TopicPoliciesServiceGrpc.TopicPolicies try { logger.info(s"Removing schema compatibility strategy policy for topic ${request.topic}") adminClient.topicPolicies(request.isGlobal).removeSchemaCompatibilityStrategy(request.topic) - Future.successful(pb.RemoveSchemaCompatibilityStrategyResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(pb.RemoveSchemaCompatibilityStrategyResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.RemoveSchemaCompatibilityStrategyResponse(status = Some(status))) } override def getMaxMessageSize(request: pb.GetMaxMessageSizeRequest): Future[pb.GetMaxMessageSizeResponse] = @@ -1241,13 +1241,13 @@ class TopicPoliciesServiceImpl extends pb.TopicPoliciesServiceGrpc.TopicPolicies Future.successful( pb.GetMaxMessageSizeResponse( - status = Some(Status(code = Code.OK.index)), + status = Some(Status(code = Code.OK.value)), maxMessageSize ) ) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.GetMaxMessageSizeResponse(status = Some(status))) } override def setMaxMessageSize(request: pb.SetMaxMessageSizeRequest): Future[pb.SetMaxMessageSizeResponse] = @@ -1257,10 +1257,10 @@ class TopicPoliciesServiceImpl extends pb.TopicPoliciesServiceGrpc.TopicPolicies logger.info(s"Setting max message size policy for topic ${request.topic}. ${request.maxMessageSize}") adminClient.topicPolicies(request.isGlobal).setMaxMessageSize(request.topic, request.maxMessageSize) - Future.successful(pb.SetMaxMessageSizeResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(pb.SetMaxMessageSizeResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.SetMaxMessageSizeResponse(status = Some(status))) } override def removeMaxMessageSize(request: pb.RemoveMaxMessageSizeRequest): Future[pb.RemoveMaxMessageSizeResponse] = @@ -1270,9 +1270,9 @@ class TopicPoliciesServiceImpl extends pb.TopicPoliciesServiceGrpc.TopicPolicies logger.info(s"Removing max message size policy for topic ${request.topic}") adminClient.topicPolicies(request.isGlobal).removeMaxMessageSize(request.topic) - Future.successful(pb.RemoveMaxMessageSizeResponse(status = Some(Status(code = Code.OK.index)))) + Future.successful(pb.RemoveMaxMessageSizeResponse(status = Some(Status(code = Code.OK.value)))) } catch { case err: Exception => - val status = Status(code = Code.FAILED_PRECONDITION.index, message = err.getMessage) + val status = Status(code = Code.FAILED_PRECONDITION.value, message = err.getMessage) Future.successful(pb.RemoveMaxMessageSizeResponse(status = Some(status))) } diff --git a/server/src/test/scala/config/mergeConfigsTest.scala b/server/src/test/scala/config/mergeConfigsTest.scala new file mode 100644 index 000000000..49d2b86b4 --- /dev/null +++ b/server/src/test/scala/config/mergeConfigsTest.scala @@ -0,0 +1,222 @@ +package config + +// NB: no `import zio.*` here - it shadows this package's `Config` with `zio.Config` +// (the same trap as `zio.System` vs `java.lang.System`). +import zio.test.* + +/** `mergeConfigs` hand-copies every field of `Config`, and Scala's named arguments + case-class + * defaults make an OMITTED field compile silently - the field just resolves to its default. That + * is exactly how `cookieSecure` and `cookieSameSite` came to be dropped (both documented in + * docs/configuration-reference.md, both dead in practice), despite the warning comment on + * `Config`. These tests are written so the SAME mistake on field #34 fails immediately. + */ +object mergeConfigsTest extends ZIOSpecDefault: + + /** Every field set to a value that differs from the case-class default. The identity property + * below only detects a dropped field if the fixture's value differs from that field's default, + * hence the deliberately odd values. */ + private val allSet = Config( + bindAddress = Some("10.1.2.3"), + port = Some(19999), + publicBaseUrl = Some("http://example.test/dekaf"), + basePath = Some("/dekaf"), + protocol = Some("https"), + tlsCertificateFilePath = Some("/tls/cert.pem"), + tlsKeyFilePath = Some("/tls/key.pem"), + cookieSecure = Some(true), + cookieSameSite = Some("strict"), + dataDir = Some("/var/lib/dekaf-test"), + pulsarName = Some("fixture-pulsar"), + pulsarColor = Some("rebeccapurple"), + pulsarWebUrl = Some("http://pulsar.test:8080"), + pulsarBrokerUrl = Some("pulsar://pulsar.test:6650"), + pulsarListenerName = Some("external"), + pulsarTlsKeyFilePath = Some("/tls/pulsar-key.pem"), + pulsarTlsCertificateFilePath = Some("/tls/pulsar-cert.pem"), + pulsarTlsTrustCertsFilePath = Some("/tls/pulsar-ca.pem"), + pulsarAllowTlsInsecureConnection = Some(true), + pulsarEnableTlsHostnameVerification = Some(true), + pulsarUseKeyStoreTls = Some(true), + pulsarSslProvider = Some("Conscrypt"), + pulsarTlsKeyStoreType = Some("PKCS12"), + pulsarTlsKeyStorePath = Some("/tls/keystore.p12"), + pulsarTlsKeyStorePassword = Some("keystore-pass"), + pulsarTlsTrustStoreType = Some("JKS"), + pulsarTlsTrustStorePath = Some("/tls/truststore.p12"), + pulsarTlsTrustStorePassword = Some("truststore-pass"), + pulsarTlsCiphers = Some(List("TLS_AES_256_GCM_SHA384")), + pulsarTlsProtocols = Some(List("TLSv1.3")), + defaultPulsarAuth = Some("""{"type":"empty"}"""), + internalHttpPort = Some(18001), + internalGrpcPort = Some(18002) + ) + + /** A second fixture whose value for EVERY field differs from `allSet`'s value for that same + * field. The per-field crosswire loop below builds a `high` config that is `allSet` with exactly + * one field taken from here, so each field's "changed" value is guaranteed to actually differ. + * Booleans can only flip, so their values necessarily repeat across the three TLS flags - which + * is precisely why pairwise-distinct fixtures alone cannot catch a crosswire between two equal + * boolean fields, and why the loop (not the identity/high-wins tests) is what closes that hole. */ + private val allSetAlt = Config( + bindAddress = Some("10.9.8.7"), + port = Some(20001), + publicBaseUrl = Some("http://alt.test/dekaf-alt"), + basePath = Some("/dekaf-alt"), + protocol = Some("http"), + tlsCertificateFilePath = Some("/tls/alt-cert.pem"), + tlsKeyFilePath = Some("/tls/alt-key.pem"), + cookieSecure = Some(false), + cookieSameSite = Some("lax"), + dataDir = Some("/var/lib/dekaf-alt"), + pulsarName = Some("alt-pulsar"), + pulsarColor = Some("goldenrod"), + pulsarWebUrl = Some("http://alt.test:18080"), + pulsarBrokerUrl = Some("pulsar://alt.test:16650"), + pulsarListenerName = Some("internal"), + pulsarTlsKeyFilePath = Some("/tls/alt-pulsar-key.pem"), + pulsarTlsCertificateFilePath = Some("/tls/alt-pulsar-cert.pem"), + pulsarTlsTrustCertsFilePath = Some("/tls/alt-pulsar-ca.pem"), + pulsarAllowTlsInsecureConnection = Some(false), + pulsarEnableTlsHostnameVerification = Some(false), + pulsarUseKeyStoreTls = Some(false), + pulsarSslProvider = Some("SunJSSE"), + pulsarTlsKeyStoreType = Some("JKS"), + pulsarTlsKeyStorePath = Some("/tls/alt-keystore.jks"), + pulsarTlsKeyStorePassword = Some("alt-keystore-pass"), + pulsarTlsTrustStoreType = Some("PKCS12"), + pulsarTlsTrustStorePath = Some("/tls/alt-truststore.p12"), + pulsarTlsTrustStorePassword = Some("alt-truststore-pass"), + pulsarTlsCiphers = Some(List("TLS_CHACHA20_POLY1305_SHA256")), + pulsarTlsProtocols = Some(List("TLSv1.2")), + defaultPulsarAuth = Some("""{"type":"token"}"""), + internalHttpPort = Some(28001), + internalGrpcPort = Some(28002) + ) + + private val mirror = summon[scala.deriving.Mirror.ProductOf[Config]] + + /** `base` with field #`index` replaced by `value`, reconstructed generically so the loop below + * does not have to hand-name 33 `.copy(...)` calls (the very hand-copying that this suite exists + * to police). Relies on case-class product order matching the constructor order. */ + private def withField(base: Config, index: Int, value: Any): Config = + val arr = base.productIterator.toArray + arr(index) = value + mirror.fromProduct(Tuple.fromArray(arr)) + + private def fieldsOf(c: Config): Map[String, Any] = + c.productElementNames.zip(c.productIterator).toMap + + private def differingFields(a: Config, b: Config): List[String] = + val fa = fieldsOf(a) + fieldsOf(b).collect { case (name, bv) if fa(name) != bv => name }.toList.sorted + + def spec = suite(this.getClass.toString)( + test("Config declares exactly the fields this suite knows about") { + // The None-check below and the identity property both go blind to a field whose default + // is NOT None: the fixture would carry `Some(default)` (so it is not "unset"), and a + // mergeConfigs that forgot to copy it would fall back to that same default (so nothing + // differs). Config is mostly non-None defaults - bindAddress, port, publicBaseUrl, + // basePath, protocol, dataDir, pulsarName, pulsarColor, pulsarWebUrl, pulsarBrokerUrl - + // so that hole is not hypothetical. + // + // Pinning the NAME SET closes it: adding a field to Config fails here first, which is + // the reminder that `// XXX - don't forget to make changes in mergeConfigs.scala` asks + // for. Deliberately a name set rather than a count, so the failure says which field. + val expected = Set( + "bindAddress", "port", "publicBaseUrl", "basePath", "protocol", + "tlsCertificateFilePath", "tlsKeyFilePath", "cookieSecure", "cookieSameSite", + "dataDir", "pulsarName", "pulsarColor", "pulsarWebUrl", "pulsarBrokerUrl", + "pulsarListenerName", "pulsarTlsKeyFilePath", "pulsarTlsCertificateFilePath", + "pulsarTlsTrustCertsFilePath", "pulsarAllowTlsInsecureConnection", + "pulsarEnableTlsHostnameVerification", "pulsarUseKeyStoreTls", "pulsarSslProvider", + "pulsarTlsKeyStoreType", "pulsarTlsKeyStorePath", "pulsarTlsKeyStorePassword", + "pulsarTlsTrustStoreType", "pulsarTlsTrustStorePath", "pulsarTlsTrustStorePassword", + "pulsarTlsCiphers", "pulsarTlsProtocols", "defaultPulsarAuth", + "internalHttpPort", "internalGrpcPort" + ) + val actual = fieldsOf(allSet).keySet + assertTrue(actual == expected) ?? + s"added: ${(actual -- expected).mkString(", ")}; removed: ${(expected -- actual).mkString(", ")}" + }, + test("the fixture sets every field of Config") { + // Guards the tests below: a field added to Config but not to `allSet` would be None + // here, and the identity property could no longer detect it being dropped. + val unset = fieldsOf(allSet).collect { case (name, None) => name }.toList.sorted + assertTrue(unset.isEmpty) ?? s"fields missing from the fixture: ${unset.mkString(", ")}" + }, + test("merging a config with itself preserves every field") { + // The key property: a field mergeConfigs forgets to copy silently falls back to its + // case-class default, which differs from the fixture value - so this catches drops. + val merged = mergeConfigs(allSet, allSet) + val lost = differingFields(allSet, merged) + assertTrue(lost.isEmpty) ?? s"mergeConfigs dropped: ${lost.mkString(", ")}" + }, + test("each field is merged from its OWN source - no crosswire between fields") { + // The identity and high-wins tests below go blind to a copy-paste crosswire whenever the + // two swapped fields hold the SAME value: e.g. + // pulsarEnableTlsHostnameVerification = highPriority.pulsarAllowTlsInsecureConnection... + // still yields Some(true) either way when both booleans are Some(true), so every other + // test stays green. This loop discriminates PER FIELD: for each field it makes `high` + // equal to `allSet` except for that one field (taken from `allSetAlt`), merges it over + // `allSet`, and asserts the merge changed EXACTLY that field. A crosswire trips it twice - + // the field that is read is now written into two outputs (an extra field changes), and the + // field that is no longer read never changes when it should. + val names = allSet.productElementNames.toVector + val altValues = allSetAlt.productIterator.toVector + val offenders = (0 until allSet.productArity).flatMap { i => + val high = withField(allSet, i, altValues(i)) + val merged = mergeConfigs(allSet, high) + val changed = differingFields(allSet, merged).toSet + val expected = Set(names(i)) + if changed == expected then None + else Some(s"${names(i)} -> changed={${changed.toList.sorted.mkString(",")}} expected={${names(i)}}") + } + assertTrue(offenders.isEmpty) ?? offenders.mkString("; ") + }, + test("the high-priority config wins for every field") { + val low = allSet + val high = allSet.copy( + bindAddress = Some("127.0.0.9"), + port = Some(12345), + cookieSecure = Some(false), + cookieSameSite = Some("lax"), + pulsarTlsCiphers = Some(List("TLS_CHACHA20_POLY1305_SHA256")) + ) + val merged = mergeConfigs(low, high) + assertTrue( + merged.bindAddress == Some("127.0.0.9"), + merged.port == Some(12345), + merged.cookieSecure == Some(false), + merged.cookieSameSite == Some("lax"), + merged.pulsarTlsCiphers == Some(List("TLS_CHACHA20_POLY1305_SHA256")) + ) + }, + test("an unset high-priority field falls back to the low-priority value") { + val high = Config( + bindAddress = None, + port = None, + cookieSecure = None, + cookieSameSite = None, + dataDir = None, + pulsarName = None + ) + val merged = mergeConfigs(allSet, high) + assertTrue( + merged.bindAddress == allSet.bindAddress, + merged.port == allSet.port, + merged.cookieSecure == allSet.cookieSecure, + merged.cookieSameSite == allSet.cookieSameSite, + merged.dataDir == allSet.dataDir, + merged.pulsarName == allSet.pulsarName + ) + }, + test("cookie hardening options survive a merge") { + // Regression: both were absent from mergeConfigs, so any deployment that set them got + // silent None and an unhardened cookie. + val merged = mergeConfigs(Config(cookieSecure = Some(true), cookieSameSite = Some("none")), Config()) + assertTrue( + merged.cookieSecure == Some(true), + merged.cookieSameSite == Some("none") + ) + } + ) diff --git a/server/src/test/scala/consumer/consumerServiceDeleteTest.scala b/server/src/test/scala/consumer/consumerServiceDeleteTest.scala new file mode 100644 index 000000000..75b61d7be --- /dev/null +++ b/server/src/test/scala/consumer/consumerServiceDeleteTest.scala @@ -0,0 +1,150 @@ +package consumer + +import _root_.consumer.coloring_rules.ColoringRuleChain +import _root_.consumer.deserializer.Deserializer +import _root_.consumer.deserializer.deserializers.UseLatestTopicSchema +import _root_.consumer.message_filter.MessageFilterChain +import _root_.consumer.pause_trigger.ConsumerSessionPauseTriggerChain +import _root_.consumer.session_config.ConsumerSessionConfig +import _root_.consumer.session_runner.* +import _root_.consumer.session_target.ConsumerSessionTarget +import _root_.consumer.session_target.consumption_mode.ConsumerSessionTargetConsumptionMode +import _root_.consumer.session_target.consumption_mode.modes.RegularConsumptionMode +import _root_.consumer.session_target.topic_selector.{MultiTopicSelector, TopicSelector} +import _root_.consumer.start_from.EarliestMessage +import _root_.consumer.value_projections.ValueProjectionList +import com.tools.teal.pulsar.ui.api.v1.consumer as consumerPb +import org.apache.pulsar.client.api.Consumer +import zio.test.* + +import java.lang.reflect.{InvocationHandler, Method, Proxy} +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.{AtomicBoolean, AtomicLong} +import scala.concurrent.Await +import scala.concurrent.duration.{Duration, SECONDS} + +/** Deleting a consumer session: what the client is told, and what is left behind. + * + * Regression context: `deleteConsumer` removed the session from the map ONLY when `stop` returned + * without throwing - and `stop` never threw, because it swallowed every unsubscribe failure and + * reported nothing. So a broker that refused to delete a subscription produced a cheerful OK with + * the subscription still on the broker. Making `stop` honest then exposed the other half: if the + * removal stayed conditional on success, one undeletable subscription would make the session name + * permanently undeletable too. + */ +object consumerServiceDeleteTest extends ZIOSpecDefault: + + private val topicFqn = "persistent://public/default/delete-me" + + private final class RecordingConsumer(unsubscribeFails: Boolean): + val unsubscribed = AtomicBoolean(false) + val closed = AtomicBoolean(false) + + val consumer: Consumer[Array[Byte]] = + val handler = new InvocationHandler: + override def invoke(proxy: Object, method: Method, args: Array[Object]): Object = + method.getName match + case "getTopic" => topicFqn + case "unsubscribe" => + unsubscribed.set(true) + if unsubscribeFails then throw new RuntimeException("broker refused to delete the subscription") + null + case "close" => closed.set(true); null + case "pause" => null + case "equals" => java.lang.Boolean.valueOf(proxy eq args(0)) + case "hashCode" => java.lang.Integer.valueOf(topicFqn.hashCode) + case "toString" => "proxy-consumer" + case _ => null + Proxy + .newProxyInstance(getClass.getClassLoader, Array[Class[?]](classOf[Consumer[Array[Byte]]]), handler) + .asInstanceOf[Consumer[Array[Byte]]] + + private def session(consumer: Consumer[Array[Byte]]): ConsumerSessionRunner = + val pool = ConsumerSessionContextPool() + val target = ConsumerSessionTargetRunner( + targetIndex = 0, + targetConfig = ConsumerSessionTarget( + isEnabled = true, + consumptionMode = ConsumerSessionTargetConsumptionMode(mode = RegularConsumptionMode()), + messageValueDeserializer = Deserializer(deserializer = UseLatestTopicSchema()), + topicSelector = TopicSelector(topicSelector = MultiTopicSelector(topicFqns = Vector(topicFqn))), + messageFilterChain = MessageFilterChain.empty, + coloringRuleChain = ColoringRuleChain.empty, + valueProjectionList = ValueProjectionList(isEnabled = false, projections = Vector.empty) + ), + nonPartitionedTopicFqns = Vector(topicFqn), + schemasByTopic = Map.empty, + sessionContextPool = pool, + consumers = Map(topicFqn -> consumer), + consumerListener = ConsumerListener(ConsumerSessionTargetMessageHandler(onNext = _ => ())), + stats = ConsumerSessionTargetStats(messageProcessed = AtomicLong(0)) + ) + ConsumerSessionRunner( + sessionName = "cs-delete", + sessionConfig = ConsumerSessionConfig( + startFrom = EarliestMessage(), + targets = Vector.empty, + messageFilterChain = MessageFilterChain.empty, + coloringRuleChain = ColoringRuleChain.empty, + pauseTriggerChain = ConsumerSessionPauseTriggerChain.empty, + valueProjectionList = ValueProjectionList(isEnabled = false, projections = Vector.empty) + ), + sessionContextPool = pool, + grpcResponseObserver = None, + schemasByTopic = Map.empty, + targets = Map(0 -> target) + ) + + private def delete(service: ConsumerServiceImpl, name: String): consumerPb.DeleteConsumerResponse = + Await.result(service.deleteConsumer(consumerPb.DeleteConsumerRequest(consumerName = name)), Duration(30, SECONDS)) + + def spec = suite(this.getClass.toString)( + test("a clean delete releases the consumer and answers OK") { + val recording = RecordingConsumer(unsubscribeFails = false) + val sessions = new ConcurrentHashMap[ConsumerSessionName, ConsumerSessionRunner]() + sessions.put("cs-delete", session(recording.consumer)) + val service = ConsumerServiceImpl(sessions) + + val response = delete(service, "cs-delete") + + assertTrue( + response.getStatus.code == com.google.rpc.code.Code.OK.value, + recording.unsubscribed.get, + recording.closed.get, + sessions.isEmpty + ) + }, + test("a delete whose unsubscribe fails REPORTS it instead of answering OK") { + // The regression: the failure was printed to stdout and the client was told OK, so a + // subscription left on the broker looked like a successful delete. + val recording = RecordingConsumer(unsubscribeFails = true) + val sessions = new ConcurrentHashMap[ConsumerSessionName, ConsumerSessionRunner]() + sessions.put("cs-delete", session(recording.consumer)) + val service = ConsumerServiceImpl(sessions) + + val response = delete(service, "cs-delete") + + assertTrue( + response.getStatus.code == com.google.rpc.code.Code.FAILED_PRECONDITION.value, + response.getStatus.message.contains(topicFqn) + ) ?? s"status=${response.getStatus}" + }, + test("a delete whose unsubscribe fails still removes the handle and closes the consumer") { + // Otherwise one undeletable subscription strands the session forever: the entry stays, + // nothing can reach it, and every retry fails the same way. + val recording = RecordingConsumer(unsubscribeFails = true) + val sessions = new ConcurrentHashMap[ConsumerSessionName, ConsumerSessionRunner]() + sessions.put("cs-delete", session(recording.consumer)) + val service = ConsumerServiceImpl(sessions) + + delete(service, "cs-delete") + + assertTrue(sessions.isEmpty, recording.closed.get) ?? + s"sessionsLeft=${sessions.size} closed=${recording.closed.get}" + }, + test("deleting a session that does not exist is a clean FAILED_PRECONDITION") { + val service = ConsumerServiceImpl(new ConcurrentHashMap[ConsumerSessionName, ConsumerSessionRunner]()) + val response = delete(service, "nope") + assertTrue(response.getStatus.code == com.google.rpc.code.Code.FAILED_PRECONDITION.value) + } + ) @@ TestAspect.sequential diff --git a/server/src/test/scala/consumer/consumerServiceLifecycleTest.scala b/server/src/test/scala/consumer/consumerServiceLifecycleTest.scala new file mode 100644 index 000000000..b4149a779 --- /dev/null +++ b/server/src/test/scala/consumer/consumerServiceLifecycleTest.scala @@ -0,0 +1,334 @@ +package consumer + +import _root_.consumer.coloring_rules.ColoringRuleChain +import _root_.consumer.deserializer.Deserializer +import _root_.consumer.deserializer.deserializers.UseLatestTopicSchema +import _root_.consumer.message_filter.MessageFilterChain +import _root_.consumer.pause_trigger.ConsumerSessionPauseTriggerChain +import _root_.consumer.session_config.ConsumerSessionConfig +import _root_.consumer.session_runner.* +import _root_.consumer.session_target.ConsumerSessionTarget +import _root_.consumer.session_target.consumption_mode.ConsumerSessionTargetConsumptionMode +import _root_.consumer.session_target.consumption_mode.modes.RegularConsumptionMode +import _root_.consumer.session_target.topic_selector.{MultiTopicSelector, TopicSelector} +import _root_.consumer.start_from.EarliestMessage +import _root_.consumer.value_projections.ValueProjectionList +import com.tools.teal.pulsar.ui.api.v1.consumer as consumerPb +import org.apache.pulsar.client.api.Consumer +import zio.test.* + +import java.lang.reflect.{InvocationHandler, Method, Proxy} +import java.util.concurrent.atomic.{AtomicBoolean, AtomicInteger, AtomicLong} +import java.util.concurrent.{ConcurrentHashMap, ConcurrentLinkedQueue, CountDownLatch, TimeUnit} +import scala.concurrent.Await +import scala.concurrent.duration.{Duration, SECONDS} +import scala.jdk.CollectionConverters.* + +/** CREATE AND DELETE UNDER ONE SESSION NAME ARE ONE OPERATION, NOT TWO. + * + * Every target of a session subscribes as `${sessionName}-${targetIndex}`, NON-DURABLE and + * EXCLUSIVE. Two runners under one name are therefore not merely wasteful, they are mutually + * exclusive on the broker: whichever subscribes second is refused outright. + * + * Two orderings made that reachable from ordinary use, and the browser re-creates a session on + * every configuration change, so both were the common path rather than a corner: + * + * - CREATE built and SUBSCRIBED the replacement in full and only then stopped the session it was + * replacing, so on the same topic the still-live predecessor rejected it before the atomic map + * swap was ever reached; + * - DELETE read the runner, stopped it, and then removed the NAME unconditionally - so a create + * that installed a new session meanwhile had its session silently unhooked, left running with + * nothing holding a handle to it. + * + * The broker sits behind proxy consumers and an injected session builder, so all of it runs + * offline. + */ +object consumerServiceLifecycleTest extends ZIOSpecDefault: + + private val topicFqn = "persistent://public/default/lifecycle" + private val sessionName = "cs-lifecycle" + + /** A consumer that records what was done to it and can be held inside `unsubscribe`, which is + * where `stop` spends its time in production. `unsubscribeEntered` lets a test wait until a + * stop is COMMITTED to its broker work; `unsubscribeFails` makes the stop report a failure. */ + private final class RecordingConsumer(label: String, unsubscribeGate: Option[CountDownLatch] = None, unsubscribeFails: Boolean = false): + val unsubscribed = AtomicBoolean(false) + val closed = AtomicBoolean(false) + val unsubscribeEntered = CountDownLatch(1) + + val consumer: Consumer[Array[Byte]] = + val handler = new InvocationHandler: + override def invoke(proxy: Object, method: Method, args: Array[Object]): Object = + method.getName match + case "getTopic" => topicFqn + case "unsubscribe" => + unsubscribeEntered.countDown() + unsubscribeGate.foreach(_.await(60, TimeUnit.SECONDS)) + unsubscribed.set(true) + if unsubscribeFails then throw new RuntimeException("broker refused to delete the subscription") + null + case "close" => closed.set(true); null + case "pause" => null + case "equals" => java.lang.Boolean.valueOf(proxy eq args(0)) + case "hashCode" => java.lang.Integer.valueOf(label.hashCode) + case "toString" => s"proxy-consumer($label)" + case _ => null + Proxy + .newProxyInstance(getClass.getClassLoader, Array[Class[?]](classOf[Consumer[Array[Byte]]]), handler) + .asInstanceOf[Consumer[Array[Byte]]] + + private def session(consumer: Consumer[Array[Byte]]): ConsumerSessionRunner = + val pool = ConsumerSessionContextPool() + val target = ConsumerSessionTargetRunner( + targetIndex = 0, + targetConfig = ConsumerSessionTarget( + isEnabled = true, + consumptionMode = ConsumerSessionTargetConsumptionMode(mode = RegularConsumptionMode()), + messageValueDeserializer = Deserializer(deserializer = UseLatestTopicSchema()), + topicSelector = TopicSelector(topicSelector = MultiTopicSelector(topicFqns = Vector(topicFqn))), + messageFilterChain = MessageFilterChain.empty, + coloringRuleChain = ColoringRuleChain.empty, + valueProjectionList = ValueProjectionList(isEnabled = false, projections = Vector.empty) + ), + nonPartitionedTopicFqns = Vector(topicFqn), + schemasByTopic = Map.empty, + sessionContextPool = pool, + consumers = Map(topicFqn -> consumer), + consumerListener = ConsumerListener(ConsumerSessionTargetMessageHandler(onNext = _ => ())), + stats = ConsumerSessionTargetStats(messageProcessed = AtomicLong(0)) + ) + ConsumerSessionRunner( + sessionName = sessionName, + sessionConfig = ConsumerSessionConfig( + startFrom = EarliestMessage(), + targets = Vector.empty, + messageFilterChain = MessageFilterChain.empty, + coloringRuleChain = ColoringRuleChain.empty, + pauseTriggerChain = ConsumerSessionPauseTriggerChain.empty, + valueProjectionList = ValueProjectionList(isEnabled = false, projections = Vector.empty) + ), + sessionContextPool = pool, + grpcResponseObserver = None, + schemasByTopic = Map.empty, + targets = Map(0 -> target) + ) + + private def createRequest(name: String = sessionName): consumerPb.CreateConsumerRequest = + consumerPb.CreateConsumerRequest(consumerName = name, consumerSessionConfig = Some(consumerPb.ConsumerSessionConfig())) + + private def create(service: ConsumerServiceImpl, name: String = sessionName): consumerPb.CreateConsumerResponse = + Await.result(service.createConsumer(createRequest(name)), Duration(60, SECONDS)) + + private def delete(service: ConsumerServiceImpl, name: String = sessionName): consumerPb.DeleteConsumerResponse = + Await.result(service.deleteConsumer(consumerPb.DeleteConsumerRequest(consumerName = name)), Duration(60, SECONDS)) + + private def worker(name: String)(body: => Unit): Thread = + val t = new Thread((() => body): Runnable, name) + t.setDaemon(true) + t + + /** The fresh observer a racing resume brings - what the client's play stream sees. */ + private final class RecordingObserver extends io.grpc.stub.StreamObserver[consumerPb.ResumeResponse]: + val received = ConcurrentLinkedQueue[consumerPb.ResumeResponse]() + val completed = AtomicBoolean(false) + override def onNext(value: consumerPb.ResumeResponse): Unit = + received.add(value) + () + override def onError(t: Throwable): Unit = () + override def onCompleted(): Unit = completed.set(true) + + def spec = suite(this.getClass.toString)( + test("CREATING OVER AN EXISTING SESSION STOPS IT BEFORE THE REPLACEMENT IS BUILT") { + // THE ordering defect. Both runners want the same exclusive, non-durable subscription, + // so building the replacement first meant the predecessor - still live - refused it. + val events = ConcurrentLinkedQueue[String]() + val predecessor = RecordingConsumer("old") + val sessions = new ConcurrentHashMap[ConsumerSessionName, ConsumerSessionRunner]() + sessions.put(sessionName, session(predecessor.consumer)) + + val service = ConsumerServiceImpl( + sessions, + (_, _) => + events.add(if predecessor.unsubscribed.get then "built-after-stop" else "built-while-predecessor-live") + session(RecordingConsumer("new").consumer) + ) + + val response = create(service) + + assertTrue( + response.getStatus.code == com.google.rpc.code.Code.OK.value, + predecessor.unsubscribed.get, + predecessor.closed.get, + events.asScala.toVector == Vector("built-after-stop") + ) ?? s"status=${response.getStatus} events=${events.asScala.toVector}" + }, + test("TWO CONCURRENT CREATES UNDER ONE NAME NEVER BUILD AT THE SAME TIME") { + // Nothing serialized them, so two browser tabs (or a retry) could have two runners + // subscribing to one exclusive subscription at once. + val inside = AtomicInteger(0) + val overlaps = AtomicInteger(0) + val sessions = new ConcurrentHashMap[ConsumerSessionName, ConsumerSessionRunner]() + val service = ConsumerServiceImpl( + sessions, + (_, _) => + if inside.incrementAndGet() > 1 then overlaps.incrementAndGet() + // WIDENS the window on purpose: "they did not overlap" must be a claim about + // the serialization, not about how fast the two threads happened to run. + Thread.sleep(300) + inside.decrementAndGet() + session(RecordingConsumer("concurrent").consumer) + ) + + val first = worker("create-1")(create(service)) + val second = worker("create-2")(create(service)) + first.start() + second.start() + first.join(60_000) + second.join(60_000) + + assertTrue(overlaps.get == 0, sessions.size == 1) ?? + s"${overlaps.get} concurrent builds under one session name" + }, + test("A DELETE THAT RACES A CREATE REMOVES ONLY THE SESSION IT ACTUALLY STOPPED") { + // Delete read runner A, stopped it, then removed the NAME. A create that installed B + // meanwhile lost it: B stayed running with nothing holding a handle to it, and the + // browser kept a session the server no longer knew about. + val gate = CountDownLatch(1) + val doomed = RecordingConsumer("A", unsubscribeGate = Some(gate)) + val sessions = new ConcurrentHashMap[ConsumerSessionName, ConsumerSessionRunner]() + sessions.put(sessionName, session(doomed.consumer)) + val service = ConsumerServiceImpl(sessions) + + val deleting = worker("delete-A")(delete(service)) + deleting.start() + // Wait until delete is committed to stopping A, then install B exactly as a create + // would. A BARE `put`, deliberately: the per-name lifecycle lock would keep a real + // create out of this window, and the point here is that the compare-and-remove holds on + // its own rather than only because of the lock. + Thread.sleep(300) + val replacement = session(RecordingConsumer("B").consumer) + sessions.put(sessionName, replacement) + + gate.countDown() + deleting.join(60_000) + + assertTrue(sessions.get(sessionName) eq replacement) ?? + s"the delete removed a session it never stopped; left ${Option(sessions.get(sessionName)).map(_ => "something else").getOrElse("nothing")}" + }, + test("an ordinary create under a fresh name stores exactly one session") { + val sessions = new ConcurrentHashMap[ConsumerSessionName, ConsumerSessionRunner]() + val service = ConsumerServiceImpl(sessions, (_, _) => session(RecordingConsumer("fresh").consumer)) + + val response = create(service) + + assertTrue(response.getStatus.code == com.google.rpc.code.Code.OK.value, sessions.size == 1) + }, + test("a create that FAILS to build reports it and leaves no session behind") { + val sessions = new ConcurrentHashMap[ConsumerSessionName, ConsumerSessionRunner]() + val service = ConsumerServiceImpl(sessions, (_, _) => throw new IllegalArgumentException("no enabled targets")) + + val response = create(service) + + assertTrue( + response.getStatus.code == com.google.rpc.code.Code.FAILED_PRECONDITION.value, + response.getStatus.message.contains("no enabled targets"), + sessions.isEmpty + ) ?? s"status=${response.getStatus} sessions=${sessions.size}" + }, + test("TWO CREATES UNDER DIFFERENT NAMES DO NOT SERIALIZE - the lifecycle lock is per name") { + // The lifecycle lock is held across the predecessor's stop and the replacement's WHOLE + // build - subscribing every consumer, seeking it, and for a Latest-N start-from a + // backward walk of one admin lookup per entry - which is unbounded broker work, not + // "one broker round trip". The old 64-stripe scheme made two DIFFERENT names share a + // lock whenever their hashes collided mod 64; these two names collide there by + // construction, so this test fails against any name-independent striping. + val nameA = "cs-lifecycle-stripe-a" + val nameB = (1 to 100_000).view + .map(i => s"cs-lifecycle-stripe-b$i") + .find(candidate => math.floorMod(candidate.hashCode, 64) == math.floorMod(nameA.hashCode, 64)) + .get + val gate = CountDownLatch(1) + val enteredSlowBuild = CountDownLatch(1) + val sessions = new ConcurrentHashMap[ConsumerSessionName, ConsumerSessionRunner]() + val service = ConsumerServiceImpl( + sessions, + (name, _) => + if name == nameA then + enteredSlowBuild.countDown() + gate.await(60, TimeUnit.SECONDS) + session(RecordingConsumer(name).consumer) + ) + + val slow = worker("create-slow-name")(create(service, nameA)) + slow.start() + enteredSlowBuild.await(60, TimeUnit.SECONDS) + + val fast = worker("create-fast-name")(create(service, nameB)) + fast.start() + fast.join(5_000) + val fastFinishedWhileSlowHeldItsLock = !fast.isAlive + + gate.countDown() + slow.join(60_000) + fast.join(60_000) + + assertTrue(fastFinishedWhileSlowHeldItsLock, sessions.size == 2) ?? + s"create($nameB) sat behind create($nameA)'s broker work despite the different name" + }, + test("A RESUME THAT RACES A DELETE WAITS FOR IT AND IS TOLD THE SESSION IS GONE") { + // Unserialized, resume read the runner mid-delete and wired the fresh observer into a + // runner whose stop was already in flight; the stop then completed the stream with no + // status frame, and the client's play stream hung silent forever with nothing to show + // and nothing to say. + val gate = CountDownLatch(1) + val doomed = RecordingConsumer("doomed", unsubscribeGate = Some(gate)) + val sessions = new ConcurrentHashMap[ConsumerSessionName, ConsumerSessionRunner]() + sessions.put(sessionName, session(doomed.consumer)) + val service = ConsumerServiceImpl(sessions) + val observer = RecordingObserver() + + val deleting = worker("delete-racing")(delete(service)) + deleting.start() + doomed.unsubscribeEntered.await(60, TimeUnit.SECONDS) + + val resuming = worker("resume-racing")( + service.resume(consumerPb.ResumeRequest(consumerName = sessionName, includeConsumerStats = true), observer) + ) + resuming.start() + resuming.join(1_500) + val resumeWaitedForTheDelete = resuming.isAlive + + gate.countDown() + deleting.join(60_000) + resuming.join(60_000) + + val statuses = observer.received.asScala.toVector.flatMap(_.status).map(_.code) + assertTrue( + resumeWaitedForTheDelete, + statuses == Vector(com.google.rpc.code.Code.FAILED_PRECONDITION.value), + observer.completed.get + ) ?? s"waitedForDelete=$resumeWaitedForTheDelete statuses=$statuses completed=${observer.completed.get}" + }, + test("REPLACING A SESSION LOGS A PREDECESSOR THAT COULD NOT BE RELEASED, as its comment promises") { + // `storeConsumerSession`'s scaladoc says the stop failure "is logged" - but + // `Try(replaced.stop())` discarded it, so a predecessor that failed to release + // disappeared without a trace in exactly the situation an operator needs the trace. + val appender = new ch.qos.logback.core.read.ListAppender[ch.qos.logback.classic.spi.ILoggingEvent]() + appender.start() + val logbackLogger = org.slf4j.LoggerFactory + .getLogger("consumer.session_runner.storeConsumerSession") + .asInstanceOf[ch.qos.logback.classic.Logger] + logbackLogger.addAppender(appender) + try + val stubborn = RecordingConsumer("stubborn", unsubscribeFails = true) + val sessions = new ConcurrentHashMap[ConsumerSessionName, ConsumerSessionRunner]() + sessions.put(sessionName, session(stubborn.consumer)) + + storeConsumerSession(sessions, sessionName, session(RecordingConsumer("replacement").consumer)) + + val warned = appender.list.asScala.toVector.map(_.getFormattedMessage) + assertTrue(warned.exists(m => m.contains(sessionName) && m.contains("could not be fully released"))) ?? + s"the replaced runner's stop failure was logged nowhere; logged=$warned" + finally logbackLogger.detachAppender(appender) + } + ) @@ TestAspect.sequential diff --git a/server/src/test/scala/consumer/consumerServiceResumeTest.scala b/server/src/test/scala/consumer/consumerServiceResumeTest.scala new file mode 100644 index 000000000..6c82174ed --- /dev/null +++ b/server/src/test/scala/consumer/consumerServiceResumeTest.scala @@ -0,0 +1,213 @@ +package consumer + +import _root_.consumer.coloring_rules.ColoringRuleChain +import _root_.consumer.deserializer.Deserializer +import _root_.consumer.deserializer.deserializers.UseLatestTopicSchema +import _root_.consumer.message_filter.MessageFilterChain +import _root_.consumer.pause_trigger.ConsumerSessionPauseTriggerChain +import _root_.consumer.session_config.ConsumerSessionConfig +import _root_.consumer.session_runner.* +import _root_.consumer.session_target.ConsumerSessionTarget +import _root_.consumer.session_target.consumption_mode.ConsumerSessionTargetConsumptionMode +import _root_.consumer.session_target.consumption_mode.modes.RegularConsumptionMode +import _root_.consumer.session_target.topic_selector.{MultiTopicSelector, TopicSelector} +import _root_.consumer.start_from.EarliestMessage +import _root_.consumer.value_projections.ValueProjectionList +import com.tools.teal.pulsar.ui.api.v1.consumer as consumerPb +import zio.test.* + +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicLong +import scala.jdk.CollectionConverters.* + +/** What `ConsumerServiceImpl.resume` does with the flags on the request it was handed. + * + * Regression context: `ResumeRequest.include_consumer_stats` has existed in the proto since the + * progress API was added, and the browser sets it - but the service read only `is_debug` off the + * request and passed that alone to the session. Every client therefore received consumer stats, + * including the MESSAGE-LESS progress frames a skip in flight pushes, whether or not it had said + * it could handle them. + * + * The session map is a constructor parameter so this test can put a real session behind the RPC + * without a broker: everything the resume path touches is the runner and the observer. + */ +object consumerServiceResumeTest extends ZIOSpecDefault: + + private val topicFqn = "persistent://public/default/resume-flags" + + private final class RecordingObserver extends io.grpc.stub.StreamObserver[consumerPb.ResumeResponse]: + private val responses = java.util.concurrent.ConcurrentLinkedQueue[consumerPb.ResumeResponse]() + val completed = java.util.concurrent.atomic.AtomicBoolean(false) + val nextAfterCompleted = java.util.concurrent.atomic.AtomicInteger(0) + override def onNext(value: consumerPb.ResumeResponse): Unit = + if completed.get then nextAfterCompleted.incrementAndGet() + responses.add(value) + () + override def onError(t: Throwable): Unit = () + override def onCompleted(): Unit = completed.set(true) + def received: Vector[consumerPb.ResumeResponse] = responses.asScala.toVector + def statsFrames: Vector[consumerPb.ConsumerStats] = received.flatMap(_.consumerStats) + + /** A consumer whose `resume()` throws - the late-target failure that used to leave earlier + * targets' listeners pushing into an observer the catch had already completed. */ + private def resumeThrowingConsumer(): org.apache.pulsar.client.api.Consumer[Array[Byte]] = + val handler = new java.lang.reflect.InvocationHandler: + override def invoke(proxy: Object, method: java.lang.reflect.Method, args: Array[Object]): Object = + method.getName match + case "resume" => throw new IllegalStateException("the broker refused to resume this consumer") + case "getTopic" => topicFqn + case "equals" => java.lang.Boolean.valueOf(proxy eq args(0)) + case "hashCode" => java.lang.Integer.valueOf(topicFqn.hashCode) + case "toString" => "proxy-consumer(resume-throws)" + case _ => null + java.lang.reflect.Proxy + .newProxyInstance(getClass.getClassLoader, Array[Class[?]](classOf[org.apache.pulsar.client.api.Consumer[Array[Byte]]]), handler) + .asInstanceOf[org.apache.pulsar.client.api.Consumer[Array[Byte]]] + + private def listener(): ConsumerListener = + val l = ConsumerListener(ConsumerSessionTargetMessageHandler(onNext = _ => ())) + l.startAcceptingNewMessages() + l + + private def targetRunner( + consumerListener: ConsumerListener, + consumers: Map[String, org.apache.pulsar.client.api.Consumer[Array[Byte]]] = Map.empty + ): ConsumerSessionTargetRunner = + ConsumerSessionTargetRunner( + targetIndex = 0, + targetConfig = ConsumerSessionTarget( + isEnabled = true, + consumptionMode = ConsumerSessionTargetConsumptionMode(mode = RegularConsumptionMode()), + messageValueDeserializer = Deserializer(deserializer = UseLatestTopicSchema()), + topicSelector = TopicSelector(topicSelector = MultiTopicSelector(topicFqns = Vector(topicFqn))), + messageFilterChain = MessageFilterChain.empty, + coloringRuleChain = ColoringRuleChain.empty, + valueProjectionList = ValueProjectionList(isEnabled = false, projections = Vector.empty) + ), + nonPartitionedTopicFqns = Vector(topicFqn), + schemasByTopic = Map.empty, + sessionContextPool = ConsumerSessionContextPool(), + consumers = consumers, + consumerListener = consumerListener, + stats = ConsumerSessionTargetStats(messageProcessed = AtomicLong(0)) + ) + + private def session(consumerListener: ConsumerListener): ConsumerSessionRunner = + sessionWith(Map(0 -> targetRunner(consumerListener))) + + private def sessionWith(targets: Map[Int, ConsumerSessionTargetRunner]): ConsumerSessionRunner = + ConsumerSessionRunner( + sessionName = "cs-resume-flags", + sessionConfig = ConsumerSessionConfig( + startFrom = EarliestMessage(), + targets = Vector.empty, + messageFilterChain = MessageFilterChain.empty, + coloringRuleChain = ColoringRuleChain.empty, + pauseTriggerChain = ConsumerSessionPauseTriggerChain.empty, + valueProjectionList = ValueProjectionList(isEnabled = false, projections = Vector.empty) + ), + sessionContextPool = ConsumerSessionContextPool(), + grpcResponseObserver = None, + schemasByTopic = Map.empty, + targets = targets + ) + + /** A service holding one session that is part-way through a skip of 3. */ + private def serviceWithSkippingSession(): (ConsumerServiceImpl, ConsumerListener) = + val l = listener() + l.startFromDiscard = StartFromDiscard.shared(3) + val sessions = new ConcurrentHashMap[ConsumerSessionName, ConsumerSessionRunner]() + sessions.put("cs-resume-flags", session(l)) + (ConsumerServiceImpl(sessions), l) + + def spec = suite(this.getClass.toString)( + test("include_consumer_stats = false really does suppress the stats") { + val (service, l) = serviceWithSkippingSession() + val observer = RecordingObserver() + + service.resume( + consumerPb.ResumeRequest(consumerName = "cs-resume-flags", includeConsumerStats = false, isDebug = false), + observer + ) + (1 to 3).foreach(_ => l.decide(topicFqn, canAcknowledge = true)) + + assertTrue(observer.statsFrames.isEmpty, observer.received.isEmpty) ?? + s"the request asked for no consumer stats and got ${observer.received.size} frames: ${observer.statsFrames}" + }, + test("include_consumer_stats = true delivers them") { + val (service, l) = serviceWithSkippingSession() + val observer = RecordingObserver() + + service.resume( + consumerPb.ResumeRequest(consumerName = "cs-resume-flags", includeConsumerStats = true, isDebug = false), + observer + ) + (1 to 3).foreach(_ => l.decide(topicFqn, canAcknowledge = true)) + + assertTrue( + observer.statsFrames.flatMap(_.startFromProgress).map(_.messagesToSkip) == Vector(3L, 3L) + ) ?? s"got ${observer.statsFrames}" + }, + test("resuming a session that does not exist is still a clean FAILED_PRECONDITION") { + val service = ConsumerServiceImpl(new ConcurrentHashMap[ConsumerSessionName, ConsumerSessionRunner]()) + val observer = RecordingObserver() + + service.resume(consumerPb.ResumeRequest(consumerName = "nope", includeConsumerStats = true), observer) + + assertTrue( + observer.received.size == 1, + observer.received.head.getStatus.code == com.google.rpc.code.Code.FAILED_PRECONDITION.value + ) + }, + test("A RESUME THAT FAILS MID-WIRING ENDS THE STREAM THROUGH THE TERMINAL GATE, exactly once") { + // Two targets: the first resumes fine and its listener threads may already be pushing; + // the second throws. The catch used to write a status frame and onCompleted STRAIGHT to + // the observer - outside the send lock, without setting the terminal flag - so the + // earlier target's pushes kept landing in a stream that had already ended. + val healthy = listener() + val broken = listener() + val runner = sessionWith(Map( + 0 -> targetRunner(healthy), + 1 -> targetRunner(broken, consumers = Map(topicFqn -> resumeThrowingConsumer())) + )) + val sessions = new ConcurrentHashMap[ConsumerSessionName, ConsumerSessionRunner]() + sessions.put("cs-resume-flags", runner) + val service = ConsumerServiceImpl(sessions) + val observer = RecordingObserver() + + service.resume(consumerPb.ResumeRequest(consumerName = "cs-resume-flags", includeConsumerStats = true), observer) + val framesAfterCatch = observer.received.size + + // A listener thread still in flight pushes now: the terminal flag must silence it. + runner.sendResponse(observer, Seq(consumerPb.Message()), Vector.empty) + + assertTrue( + framesAfterCatch == 1, + observer.received.head.getStatus.code == com.google.rpc.code.Code.FAILED_PRECONDITION.value, + observer.completed.get, + observer.received.size == 1, + observer.nextAfterCompleted.get == 0 + ) ?? (s"framesAfterCatch=$framesAfterCatch total=${observer.received.size} " + + s"nextAfterCompleted=${observer.nextAfterCompleted.get} completed=${observer.completed.get}") + }, + test("RESUMING A SESSION WHOSE STREAM HAS ENDED answers non-OK instead of wiring a dead runner") { + // The delete/resume race, after the delete has won: the runner is stopped (terminal) + // but still reachable. Wiring the fresh observer into it hung the play stream silently + // - the terminal flag swallows every send, so the client waited on a stream that could + // never speak. It must be answered and completed instead; its remedy is to recreate. + val runner = session(listener()) + runner.stop() + val sessions = new ConcurrentHashMap[ConsumerSessionName, ConsumerSessionRunner]() + sessions.put("cs-resume-flags", runner) + val service = ConsumerServiceImpl(sessions) + val observer = RecordingObserver() + + service.resume(consumerPb.ResumeRequest(consumerName = "cs-resume-flags", includeConsumerStats = true), observer) + + assertTrue( + observer.received.size == 1, + observer.received.head.getStatus.code == com.google.rpc.code.Code.FAILED_PRECONDITION.value, + observer.completed.get + ) ?? s"a dead runner answered with frames=${observer.received.size} completed=${observer.completed.get}" + } + ) @@ TestAspect.sequential diff --git a/server/src/test/scala/consumer/consumerServiceTopicPositionsTest.scala b/server/src/test/scala/consumer/consumerServiceTopicPositionsTest.scala new file mode 100644 index 000000000..82f94f3db --- /dev/null +++ b/server/src/test/scala/consumer/consumerServiceTopicPositionsTest.scala @@ -0,0 +1,116 @@ +package consumer + +import com.google.rpc.code.Code +import com.tools.teal.pulsar.ui.api.v1.consumer.GetTopicPositionsRequest +import consumer.session_runner.{ConsumerListener, ConsumerSessionRunner, ConsumerSessionTargetMessageHandler, TopicCursor, furthestCursors} +import org.apache.pulsar.client.impl.MessageIdImpl +import zio.test.* + +import java.util.concurrent.ConcurrentHashMap + +/** The Topic Positions RPC and the cursor bookkeeping behind it. + * + * The arithmetic lives in `topicPositionsTest`; what is pinned here is everything that arithmetic + * cannot see - the answer for a session that does not exist, and how the read position is + * accumulated across listener threads and across targets that share a topic. + * + * NO BROKER. The session lookup happens before the admin client is resolved, so the not-found path + * runs without a request context; the cursor paths are the listener's own state. + */ +object consumerServiceTopicPositionsTest extends ZIOSpecDefault: + + private def msgId(ledger: Long, entry: Long) = MessageIdImpl(ledger, entry, -1) + + def spec = suite("consumerService topic positions")( + suite("a session that is not running")( + test("answers FAILED_PRECONDITION rather than failing the call") { + // The tab polls as soon as it is opened, which is routinely BEFORE the play button. + // That has to be a status the client can render as "not started yet", not a fault - + // and it must not need a broker to say so. + val service = ConsumerServiceImpl(new ConcurrentHashMap[ConsumerSessionName, ConsumerSessionRunner]()) + val response = service.getTopicPositions(GetTopicPositionsRequest(consumerName = "never-created")) + + for res <- zio.ZIO.fromFuture(_ => response) + yield assertTrue(res.status.exists(_.code == Code.FAILED_PRECONDITION.value)) && + assertTrue(res.positions.isEmpty) && + assertTrue(res.status.exists(_.message.contains("never-created"))) + } + ), + suite("the listener's read position")( + test("advances as messages are recorded") { + val listener = ConsumerListener(ConsumerSessionTargetMessageHandler(onNext = _ => ())) + listener.recordCursor("persistent://t/n/a", msgId(1, 5), 1000) + listener.recordCursor("persistent://t/n/a", msgId(1, 9), 2000) + + assertTrue(listener.cursors("persistent://t/n/a").messageId == msgId(1, 9)) && + assertTrue(listener.cursors("persistent://t/n/a").publishTime == 2000) + }, + test("does NOT walk backwards when an older message is redelivered") { + // `negativeAcknowledge` and the merge's cap both hand messages back, so an older one + // legitimately arrives after a newer one has been counted. Letting the cursor follow + // it would make the view flicker between two positions, neither of them "how far has + // this read". + val listener = ConsumerListener(ConsumerSessionTargetMessageHandler(onNext = _ => ())) + listener.recordCursor("persistent://t/n/a", msgId(1, 9), 2000) + listener.recordCursor("persistent://t/n/a", msgId(1, 4), 1500) + + assertTrue(listener.cursors("persistent://t/n/a").messageId == msgId(1, 9)) + }, + test("keeps one position PER TOPIC - a partitioned session must not share one") { + val listener = ConsumerListener(ConsumerSessionTargetMessageHandler(onNext = _ => ())) + listener.recordCursor("persistent://t/n/a-partition-0", msgId(1, 5), 1000) + listener.recordCursor("persistent://t/n/a-partition-1", msgId(2, 3), 1100) + + assertTrue(listener.cursors.size == 2) && + assertTrue(listener.cursors("persistent://t/n/a-partition-0").messageId == msgId(1, 5)) && + assertTrue(listener.cursors("persistent://t/n/a-partition-1").messageId == msgId(2, 3)) + }, + test("survives concurrent recording from several listener threads") { + // One Pulsar listener thread per physical topic writes this while a gRPC thread + // reads it. The high-water mark must be the true maximum however the two interleave. + val listener = ConsumerListener(ConsumerSessionTargetMessageHandler(onNext = _ => ())) + val entries = (1 to 500).toVector + + for _ <- zio.ZIO.foreachParDiscard(entries)(entry => + zio.ZIO.succeed(listener.recordCursor("persistent://t/n/a", msgId(1, entry.toLong), entry.toLong)) + ) + yield assertTrue(listener.cursors("persistent://t/n/a").messageId == msgId(1, 500)) + } + ), + suite("reconciling targets that share a topic")( + test("keeps the FURTHEST of two targets reading one topic") { + // Two targets differing only in their filters is an ordinary configuration; each + // keeps its own listener and so its own position, but the table has one row. + val behind = Map("persistent://t/n/a" -> TopicCursor(msgId(1, 3), 1000)) + val ahead = Map("persistent://t/n/a" -> TopicCursor(msgId(1, 40), 2000)) + + assertTrue(furthestCursors(Vector(behind, ahead))("persistent://t/n/a").messageId == msgId(1, 40)) && + // Order of the listeners must not change the answer. + assertTrue(furthestCursors(Vector(ahead, behind))("persistent://t/n/a").messageId == msgId(1, 40)) + }, + test("orders by MESSAGE ID, not publish time, so a producer clock cannot reorder it") { + // The further-along message carries the EARLIER publish time here, which a producer + // whose clock stepped back produces. Log order is the log's own and cannot invert. + val newerInLog = Map("persistent://t/n/a" -> TopicCursor(msgId(1, 40), 1000)) + val olderInLog = Map("persistent://t/n/a" -> TopicCursor(msgId(1, 3), 9999)) + + assertTrue(furthestCursors(Vector(olderInLog, newerInLog))("persistent://t/n/a").messageId == msgId(1, 40)) + }, + test("crossing a LEDGER boundary counts as further along") { + val earlierLedger = Map("persistent://t/n/a" -> TopicCursor(msgId(1, 900), 1000)) + val laterLedger = Map("persistent://t/n/a" -> TopicCursor(msgId(2, 0), 2000)) + + assertTrue(furthestCursors(Vector(earlierLedger, laterLedger))("persistent://t/n/a").messageId == msgId(2, 0)) + }, + test("keeps distinct topics apart rather than collapsing them") { + val one = Map("persistent://t/n/a" -> TopicCursor(msgId(1, 3), 1000)) + val two = Map("persistent://t/n/b" -> TopicCursor(msgId(1, 7), 1000)) + + assertTrue(furthestCursors(Vector(one, two)).size == 2) + }, + test("a session that has read nothing reports no cursors at all") { + assertTrue(furthestCursors(Vector(Map.empty)).isEmpty) && + assertTrue(furthestCursors(Vector.empty).isEmpty) + } + ) + ) diff --git a/server/src/test/scala/consumer/convertersTest.scala b/server/src/test/scala/consumer/convertersTest.scala index 3b31a20cd..317f9faec 100644 --- a/server/src/test/scala/consumer/convertersTest.scala +++ b/server/src/test/scala/consumer/convertersTest.scala @@ -41,6 +41,9 @@ val useLatestTopicSchemaDeserializer = Deserializer(deserializer = UseLatestTopi val treatBytesAsJsonDeserializer = Deserializer(deserializer = TreatBytesAsJson()) object convertersTest extends ZIOSpecDefault: + /* Renders a byte array as hex so a failing table case is identifiable in the report. */ + private def hex(bytes: Array[Byte]): String = bytes.map(b => f"0x$b%02x").mkString("[", " ", "]") + def spec = suite(s"${this.getClass.toString} - messageValueToJson()")( test("AVRO schema") { val avroSchemaDefinition = """ @@ -72,33 +75,33 @@ object convertersTest extends ZIOSpecDefault: .build val jsonToEncode = """{"name":"Alyssa","favorite_number":256}""" - val avroPayload = avro.converters.fromJson( + + avro.converters.fromJson( avroSchemaDefinition.getBytes, jsonToEncode.getBytes ) match - case Right(value) => value - case Left(error) => throw error - - val avroSchema = AvroSchema.of(schemaDefinition) - - val topicName = "topic-a" - val schemaVersion = 1L; - val messageMetadata = new MessageMetadata().setSchemaVersion(scala.math.BigInt(schemaVersion).toByteArray) - val message = MessageImpl.create[Array[Byte]]( - messageMetadata, - java.nio.ByteBuffer.wrap(avroPayload), - avroSchema, - topicName - ) - - val schemasByVersion: SchemasByVersion = Map(1L -> schemaInfo) - val schemasByTopic: SchemasByTopic = Map(topicName -> schemasByVersion) - - val decodedJson = converters.messageValueToJson(schemasByTopic, message, useLatestTopicSchemaDeserializer) match - case Right(value) => value - case Left(err) => throw err - - assertTrue(parseJson(decodedJson) == parseJson(jsonToEncode)) + case Left(error) => assertNever(s"failed to encode the AVRO test payload: $error") + case Right(avroPayload) => + val avroSchema = AvroSchema.of(schemaDefinition) + + val topicName = "topic-a" + val schemaVersion = 1L; + val messageMetadata = new MessageMetadata().setSchemaVersion(scala.math.BigInt(schemaVersion).toByteArray) + val message = MessageImpl.create[Array[Byte]]( + messageMetadata, + java.nio.ByteBuffer.wrap(avroPayload), + avroSchema, + topicName + ) + + val schemasByVersion: SchemasByVersion = Map(1L -> schemaInfo) + val schemasByTopic: SchemasByTopic = Map(topicName -> schemasByVersion) + + converters.messageValueToJson(schemasByTopic, message, useLatestTopicSchemaDeserializer) match + case Left(err) => assertNever(s"messageValueToJson failed for the AVRO message: $err") + case Right(decodedJson) => + assertTrue(parseJson(decodedJson) == parseJson(jsonToEncode)) ?? + s"decoded $decodedJson, expected $jsonToEncode" }, test("JSON schema") { val avroSchemaDefinition = @@ -148,11 +151,11 @@ object convertersTest extends ZIOSpecDefault: val schemasByVersion: SchemasByVersion = Map(schemaVersion -> schemaInfo) val schemasByTopic: SchemasByTopic = Map(topicName -> schemasByVersion) - val decodedJson = converters.messageValueToJson(schemasByTopic, message, useLatestTopicSchemaDeserializer) match - case Right(value) => value - case Left(err) => throw err - - assertTrue(parseJson(decodedJson) == parseJson(jsonToEncode)) + converters.messageValueToJson(schemasByTopic, message, useLatestTopicSchemaDeserializer) match + case Left(err) => assertNever(s"messageValueToJson failed for the JSON-schema message: $err") + case Right(decodedJson) => + assertTrue(parseJson(decodedJson) == parseJson(jsonToEncode)) ?? + s"decoded $decodedJson, expected $jsonToEncode" }, test("PROTOBUF_NATIVE schema") { val protoFileName = "user.proto" @@ -169,48 +172,50 @@ object convertersTest extends ZIOSpecDefault: """.stripMargin val compiledFiles = protobufnative.compiler.compileFiles(List(FileEntry(protoFileName, protoFileContent))) - val protoSchemaDefinition = compiledFiles.files.get(protoFileName) match + val compiledUserSchema: Either[String, Array[Byte]] = compiledFiles.files.get(protoFileName) match case Some(Right(file)) => - file.schemas.get("User") match - case Some(schema) => schema.rawSchema - case _ => throw new Exception(s"Failed to compile PROTOBUF_NATIVE message") - case _ => throw new Exception(s"Failed to compile PROTOBUF_NATIVE message") - - val schemaInfo = SchemaInfo.builder - .`type`(SchemaType.PROTOBUF_NATIVE) - .schema(protoSchemaDefinition) - .build - - val protoSchema = Schema.getSchema(schemaInfo).asInstanceOf[Schema[Array[Byte]]] - - val jsonToEncode = """{"name":"Alyssa","favorite_number":256}""" - val protoPayload = protobufnative.converters.fromJson(protoSchemaDefinition, jsonToEncode.getBytes) match - case Right(value) => value - case Left(error) => throw error - - val topicName = "topic-a" - val schemaVersion = 1L; - val messageMetadata = new MessageMetadata().setSchemaVersion(scala.math.BigInt(schemaVersion).toByteArray) - val message = MessageImpl.create[Array[Byte]]( - messageMetadata, - java.nio.ByteBuffer.wrap(protoPayload), - protoSchema, - topicName - ) - - val schemasByVersion: SchemasByVersion = Map(schemaVersion -> schemaInfo) - val schemasByTopic: SchemasByTopic = Map(topicName -> schemasByVersion) - - val decodedJson = converters.messageValueToJson(schemasByTopic, message, useLatestTopicSchemaDeserializer) match - case Right(value) => value - case Left(err) => throw err - - assertTrue(parseJson(decodedJson) == parseJson(jsonToEncode)) + file.schemas.get("User").map(_.rawSchema).toRight(s"""compiled $protoFileName has no "User" message""") + case Some(Left(error)) => Left(s"failed to compile $protoFileName: $error") + case None => Left(s"the PROTOBUF_NATIVE compiler returned no result for $protoFileName") + + compiledUserSchema match + case Left(reason) => assertNever(reason) + case Right(protoSchemaDefinition) => + val schemaInfo = SchemaInfo.builder + .`type`(SchemaType.PROTOBUF_NATIVE) + .schema(protoSchemaDefinition) + .build + + val protoSchema = Schema.getSchema(schemaInfo).asInstanceOf[Schema[Array[Byte]]] + + val jsonToEncode = """{"name":"Alyssa","favorite_number":256}""" + + protobufnative.converters.fromJson(protoSchemaDefinition, jsonToEncode.getBytes) match + case Left(error) => assertNever(s"failed to encode the PROTOBUF_NATIVE test payload: $error") + case Right(protoPayload) => + val topicName = "topic-a" + val schemaVersion = 1L; + val messageMetadata = new MessageMetadata().setSchemaVersion(scala.math.BigInt(schemaVersion).toByteArray) + val message = MessageImpl.create[Array[Byte]]( + messageMetadata, + java.nio.ByteBuffer.wrap(protoPayload), + protoSchema, + topicName + ) + + val schemasByVersion: SchemasByVersion = Map(schemaVersion -> schemaInfo) + val schemasByTopic: SchemasByTopic = Map(topicName -> schemasByVersion) + + converters.messageValueToJson(schemasByTopic, message, useLatestTopicSchemaDeserializer) match + case Left(err) => assertNever(s"messageValueToJson failed for the PROTOBUF_NATIVE message: $err") + case Right(decodedJson) => + assertTrue(parseJson(decodedJson) == parseJson(jsonToEncode)) ?? + s"decoded $decodedJson, expected $jsonToEncode" }, test("BOOLEAN to json") { case class TestCase(messagePayload: Array[Byte], expectedJson: String) - def runTestCase(testCase: TestCase): Boolean = + def runTestCase(testCase: TestCase, idx: Int): TestResult = val schemaInfo = SchemaInfo.builder .`type`(SchemaType.BOOLEAN) .build @@ -228,23 +233,23 @@ object convertersTest extends ZIOSpecDefault: val schemasByVersion: SchemasByVersion = Map(schemaVersion -> schemaInfo) val schemasByTopic: SchemasByTopic = Map(topicName -> schemasByVersion) - val json = converters.messageValueToJson(schemasByTopic, message, useLatestTopicSchemaDeserializer) match - case Right(value) => value - case Left(err) => throw err + val label = s"case #$idx: BOOLEAN payload=${hex(testCase.messagePayload)}, expected ${testCase.expectedJson}" - parseJson(json) == parseJson(testCase.expectedJson) + converters.messageValueToJson(schemasByTopic, message, useLatestTopicSchemaDeserializer) match + case Left(err) => assertNever(s"$label -- messageValueToJson failed: $err") + case Right(json) => assertTrue(parseJson(json) == parseJson(testCase.expectedJson)) ?? s"$label, actual $json" val testCases = List[TestCase]( TestCase(Array(0), "false"), TestCase(Array(1), "true") ) - assertTrue(testCases.forall(runTestCase)) + testCases.zipWithIndex.map { (testCase, idx) => runTestCase(testCase, idx) }.reduce(_ && _) }, test("INT8 to json") { case class TestCase(messagePayload: Array[Byte], expected: String) - def runTestCase(testCase: TestCase): Boolean = + def runTestCase(testCase: TestCase, idx: Int): TestResult = val schemaInfo = SchemaInfo.builder .`type`(SchemaType.INT8) .build @@ -262,11 +267,11 @@ object convertersTest extends ZIOSpecDefault: val schemasByVersion: SchemasByVersion = Map(schemaVersion -> schemaInfo) val schemasByTopic: SchemasByTopic = Map(topicName -> schemasByVersion) - val json = converters.messageValueToJson(schemasByTopic, message, useLatestTopicSchemaDeserializer) match - case Right(value) => value - case Left(err) => throw err + val label = s"case #$idx: INT8 payload=${hex(testCase.messagePayload)}, expected ${testCase.expected}" - parseJson(json) == parseJson(testCase.expected) + converters.messageValueToJson(schemasByTopic, message, useLatestTopicSchemaDeserializer) match + case Left(err) => assertNever(s"$label -- messageValueToJson failed: $err") + case Right(json) => assertTrue(parseJson(json) == parseJson(testCase.expected)) ?? s"$label, actual $json" val testCases = List[TestCase]( TestCase(Array(0), "0"), @@ -276,12 +281,12 @@ object convertersTest extends ZIOSpecDefault: TestCase(Array(-18), "-18") ) - assertTrue(testCases.forall(runTestCase)) + testCases.zipWithIndex.map { (testCase, idx) => runTestCase(testCase, idx) }.reduce(_ && _) }, test("INT16 to json") { case class TestCase(messagePayload: Array[Byte], expectedJson: String) - def runTestCase(testCase: TestCase): Boolean = + def runTestCase(testCase: TestCase, idx: Int): TestResult = val schemaInfo = SchemaInfo.builder .`type`(SchemaType.INT16) .build @@ -299,11 +304,11 @@ object convertersTest extends ZIOSpecDefault: val schemasByVersion: SchemasByVersion = Map(schemaVersion -> schemaInfo) val schemasByTopic: SchemasByTopic = Map(topicName -> schemasByVersion) - val json = converters.messageValueToJson(schemasByTopic, message, useLatestTopicSchemaDeserializer) match - case Right(value) => value - case Left(err) => throw err + val label = s"case #$idx: INT16 payload=${hex(testCase.messagePayload)}, expected ${testCase.expectedJson}" - parseJson(json) == parseJson(testCase.expectedJson) + converters.messageValueToJson(schemasByTopic, message, useLatestTopicSchemaDeserializer) match + case Left(err) => assertNever(s"$label -- messageValueToJson failed: $err") + case Right(json) => assertTrue(parseJson(json) == parseJson(testCase.expectedJson)) ?? s"$label, actual $json" val testCases = List[TestCase]( TestCase(Array(0), "0"), @@ -315,12 +320,12 @@ object convertersTest extends ZIOSpecDefault: TestCase(Array(0x7f, 0xff).map(_.toByte), Short.MaxValue.toString) ) - assertTrue(testCases.forall(runTestCase)) + testCases.zipWithIndex.map { (testCase, idx) => runTestCase(testCase, idx) }.reduce(_ && _) }, test("INT32 to json") { case class TestCase(messagePayload: Array[Byte], expectedJson: String) - def runTestCase(testCase: TestCase): Boolean = + def runTestCase(testCase: TestCase, idx: Int): TestResult = val schemaInfo = SchemaInfo.builder .`type`(SchemaType.INT32) .build @@ -338,11 +343,11 @@ object convertersTest extends ZIOSpecDefault: val schemasByVersion: SchemasByVersion = Map(schemaVersion -> schemaInfo) val schemasByTopic: SchemasByTopic = Map(topicName -> schemasByVersion) - val json = converters.messageValueToJson(schemasByTopic, message, useLatestTopicSchemaDeserializer) match - case Right(value) => value - case Left(err) => throw err + val label = s"case #$idx: INT32 payload=${hex(testCase.messagePayload)}, expected ${testCase.expectedJson}" - parseJson(json) == parseJson(testCase.expectedJson) + converters.messageValueToJson(schemasByTopic, message, useLatestTopicSchemaDeserializer) match + case Left(err) => assertNever(s"$label -- messageValueToJson failed: $err") + case Right(json) => assertTrue(parseJson(json) == parseJson(testCase.expectedJson)) ?? s"$label, actual $json" val testCases = List[TestCase]( TestCase(Array(0), "0"), @@ -354,12 +359,12 @@ object convertersTest extends ZIOSpecDefault: TestCase(Array(0x7f, 0xff, 0xff, 0xff).map(_.toByte), Int.MaxValue.toString) ) - assertTrue(testCases.forall(runTestCase)) + testCases.zipWithIndex.map { (testCase, idx) => runTestCase(testCase, idx) }.reduce(_ && _) }, test("INT64 to json") { case class TestCase(messagePayload: Array[Byte], expectedJson: String) - def runTestCase(testCase: TestCase): Boolean = + def runTestCase(testCase: TestCase, idx: Int): TestResult = val schemaInfo = SchemaInfo.builder .`type`(SchemaType.INT64) .build @@ -377,11 +382,11 @@ object convertersTest extends ZIOSpecDefault: val schemasByVersion: SchemasByVersion = Map(schemaVersion -> schemaInfo) val schemasByTopic: SchemasByTopic = Map(topicName -> schemasByVersion) - val json = converters.messageValueToJson(schemasByTopic, message, useLatestTopicSchemaDeserializer) match - case Right(value) => value - case Left(err) => throw err + val label = s"case #$idx: INT64 payload=${hex(testCase.messagePayload)}, expected ${testCase.expectedJson}" - parseJson(json) == parseJson(testCase.expectedJson) + converters.messageValueToJson(schemasByTopic, message, useLatestTopicSchemaDeserializer) match + case Left(err) => assertNever(s"$label -- messageValueToJson failed: $err") + case Right(json) => assertTrue(parseJson(json) == parseJson(testCase.expectedJson)) ?? s"$label, actual $json" val testCases = List[TestCase]( TestCase(Array(0), "0"), @@ -393,12 +398,12 @@ object convertersTest extends ZIOSpecDefault: TestCase(Array(0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff).map(_.toByte), s"""${Long.MaxValue.toString}""") ) - assertTrue(testCases.forall(runTestCase)) + testCases.zipWithIndex.map { (testCase, idx) => runTestCase(testCase, idx) }.reduce(_ && _) }, test("FLOAT to json") { case class TestCase(messagePayload: Array[Byte], expectedJson: String) - def runTestCase(testCase: TestCase): Boolean = + def runTestCase(testCase: TestCase, idx: Int): TestResult = val schemaInfo = SchemaInfo.builder .`type`(SchemaType.FLOAT) .build @@ -416,11 +421,11 @@ object convertersTest extends ZIOSpecDefault: val schemasByVersion: SchemasByVersion = Map(schemaVersion -> schemaInfo) val schemasByTopic: SchemasByTopic = Map(topicName -> schemasByVersion) - val json = converters.messageValueToJson(schemasByTopic, message, useLatestTopicSchemaDeserializer) match - case Right(value) => value - case Left(err) => throw err + val label = s"case #$idx: FLOAT payload=${hex(testCase.messagePayload)}, expected ${testCase.expectedJson}" - parseJson(json) == parseJson(testCase.expectedJson) + converters.messageValueToJson(schemasByTopic, message, useLatestTopicSchemaDeserializer) match + case Left(err) => assertNever(s"$label -- messageValueToJson failed: $err") + case Right(json) => assertTrue(parseJson(json) == parseJson(testCase.expectedJson)) ?? s"$label, actual $json" val testCases = List[TestCase]( TestCase(Array(0), "0.0"), @@ -432,12 +437,12 @@ object convertersTest extends ZIOSpecDefault: TestCase(Array(0xc6, 0xea, 0x60, 0x0f).map(_.toByte), "-30000.03") ) - assertTrue(testCases.forall(runTestCase)) + testCases.zipWithIndex.map { (testCase, idx) => runTestCase(testCase, idx) }.reduce(_ && _) }, test("DOUBLE to json") { case class TestCase(messagePayload: Array[Byte], expectedJson: String) - def runTestCase(testCase: TestCase): Boolean = + def runTestCase(testCase: TestCase, idx: Int): TestResult = val schemaInfo = SchemaInfo.builder .`type`(SchemaType.DOUBLE) .build @@ -455,11 +460,11 @@ object convertersTest extends ZIOSpecDefault: val schemasByVersion: SchemasByVersion = Map(schemaVersion -> schemaInfo) val schemasByTopic: SchemasByTopic = Map(topicName -> schemasByVersion) - val json = converters.messageValueToJson(schemasByTopic, message, useLatestTopicSchemaDeserializer) match - case Right(value) => value - case Left(err) => throw err + val label = s"case #$idx: DOUBLE payload=${hex(testCase.messagePayload)}, expected ${testCase.expectedJson}" - parseJson(json) == parseJson(testCase.expectedJson) + converters.messageValueToJson(schemasByTopic, message, useLatestTopicSchemaDeserializer) match + case Left(err) => assertNever(s"$label -- messageValueToJson failed: $err") + case Right(json) => assertTrue(parseJson(json) == parseJson(testCase.expectedJson)) ?? s"$label, actual $json" val testCases = List[TestCase]( TestCase(Array(0), "0.0"), @@ -471,12 +476,12 @@ object convertersTest extends ZIOSpecDefault: TestCase(Array(0xc0, 0xdd, 0x4c, 0x01, 0xeb, 0x85, 0x1e, 0xb8).map(_.toByte), "-30000.03") ) - assertTrue(testCases.forall(runTestCase)) + testCases.zipWithIndex.map { (testCase, idx) => runTestCase(testCase, idx) }.reduce(_ && _) }, test("STRING to json") { case class TestCase(messagePayload: Array[Byte], expectedJson: String) - def runTestCase(testCase: TestCase): Boolean = + def runTestCase(testCase: TestCase, idx: Int): TestResult = val schemaInfo = SchemaInfo.builder .`type`(SchemaType.STRING) .build @@ -494,11 +499,11 @@ object convertersTest extends ZIOSpecDefault: val schemasByVersion: SchemasByVersion = Map(schemaVersion -> schemaInfo) val schemasByTopic: SchemasByTopic = Map(topicName -> schemasByVersion) - val json = converters.messageValueToJson(schemasByTopic, message, useLatestTopicSchemaDeserializer) match - case Right(value) => value - case Left(err) => throw err + val label = s"case #$idx: STRING payload=${hex(testCase.messagePayload)}, expected ${testCase.expectedJson}" - parseJson(json) == parseJson(testCase.expectedJson) + converters.messageValueToJson(schemasByTopic, message, useLatestTopicSchemaDeserializer) match + case Left(err) => assertNever(s"$label -- messageValueToJson failed: $err") + case Right(json) => assertTrue(parseJson(json) == parseJson(testCase.expectedJson)) ?? s"$label, actual $json" val testCases = List[TestCase]( TestCase(Array(), "\"\""), @@ -512,6 +517,69 @@ object convertersTest extends ZIOSpecDefault: TestCase(Array(0x71, 0x75, 0x22, 0x6f, 0x74, 0x65, 0x22, 0x73).map(_.toByte), """"qu\"ote\"s"""") ) - assertTrue(testCases.forall(runTestCase)) + testCases.zipWithIndex.map { (testCase, idx) => runTestCase(testCase, idx) }.reduce(_ && _) + }, + test("TreatBytesAsJson deserializer - valid JSON payload") { + case class TestCase(payload: String) + + def runTestCase(testCase: TestCase, idx: Int): TestResult = + val topicName = "topic-a" + val message = MessageImpl.create[Array[Byte]]( + new MessageMetadata(), + ByteBuffer.wrap(testCase.payload.getBytes("UTF-8")), + BytesSchema.of.asInstanceOf[Schema[Array[Byte]]], + topicName + ) + + // The deserializer ignores the registered schemas by design - it treats the raw bytes as JSON. + val schemasByTopic: SchemasByTopic = Map.empty + + val label = s"case #$idx: payload=${testCase.payload}" + + converters.messageValueToJson(schemasByTopic, message, treatBytesAsJsonDeserializer) match + case Left(err) => assertNever(s"$label -- expected Right, got Left($err)") + case Right(json) => assertTrue(parseJson(json) == parseJson(testCase.payload)) ?? s"$label, actual $json" + + val testCases = List[TestCase]( + TestCase("""{"name":"Alyssa","favorite_number":256}"""), + TestCase("""{"a":2,"b":{"c":3}}"""), + TestCase("""[1,2,"a"]"""), + TestCase("[]"), + TestCase("null"), + TestCase("true"), + TestCase("-3.0"), + TestCase("\"Gruß\"") + ) + + testCases.zipWithIndex.map { (testCase, idx) => runTestCase(testCase, idx) }.reduce(_ && _) }, + test("TreatBytesAsJson deserializer - invalid JSON payload") { + case class TestCase(payload: String) + + def runTestCase(testCase: TestCase, idx: Int): TestResult = + val topicName = "topic-a" + val message = MessageImpl.create[Array[Byte]]( + new MessageMetadata(), + ByteBuffer.wrap(testCase.payload.getBytes("UTF-8")), + BytesSchema.of.asInstanceOf[Schema[Array[Byte]]], + topicName + ) + + val schemasByTopic: SchemasByTopic = Map.empty + + val label = s"case #$idx: payload=${testCase.payload}" + val actual = converters.messageValueToJson(schemasByTopic, message, treatBytesAsJsonDeserializer) + + assertTrue(actual.isLeft) ?? s"$label -- expected Left, actual $actual" + + val testCases = List[TestCase]( + TestCase(""), + TestCase("2z"), + TestCase("undefined"), + TestCase("""{a:2,"b":{"c":3}}"""), + TestCase("""{"a":}""") + ) + + testCases.zipWithIndex.map { (testCase, idx) => runTestCase(testCase, idx) }.reduce(_ && _) + } ) diff --git a/server/src/test/scala/consumer/message_filter/JsMessageFilterTest.scala b/server/src/test/scala/consumer/message_filter/JsMessageFilterTest.scala index cfd515752..b032ce898 100644 --- a/server/src/test/scala/consumer/message_filter/JsMessageFilterTest.scala +++ b/server/src/test/scala/consumer/message_filter/JsMessageFilterTest.scala @@ -21,8 +21,10 @@ object JsMessageFilterTest extends ZIOSpecDefault: isShouldFail: Boolean = false ) + // One pool (one GraalVM Engine) for the suite - see BasicMessageFilterTest. + private val sessionContextPool = ConsumerSessionContextPool() + def runTestSpec(spec: TestSpec): Boolean = - val sessionContextPool = ConsumerSessionContextPool() val jsMessageFilter = JsMessageFilter(jsCode = spec.jsCode) val filter = MessageFilter( isEnabled = true, @@ -36,9 +38,14 @@ object JsMessageFilterTest extends ZIOSpecDefault: val sessionContext = sessionContextPool.getNextContext sessionContext.setCurrentMessage(spec.messageAsJsonOmittingValue, Right(spec.messageValueAsJson.trim)) - val result = sessionContext.testMessageFilter(filter = filter).isOk + val result = sessionContext.testMessageFilter(filter = filter) - if spec.isShouldFail then !result else result + // See BasicMessageFilterTest: a thrown JS error must not satisfy an `isShouldFail` case. + if result.error.nonEmpty then + java.lang.System.err.println(s"[js-filter-test] unexpected evaluation error: ${result.error.get}") + false + else if spec.isShouldFail then !result.isOk + else result.isOk def spec = suite(s"${this.getClass.toString}")( test(JsMessageFilter.getClass.toString) { @@ -127,4 +134,4 @@ object JsMessageFilterTest extends ZIOSpecDefault: |""".stripMargin ))) } - ) + ) @@ TestAspect.sequential diff --git a/server/src/test/scala/consumer/message_filter/basic_message_filter/BasicMessageFilterTest.scala b/server/src/test/scala/consumer/message_filter/basic_message_filter/BasicMessageFilterTest.scala index ce368032d..5004206e7 100644 --- a/server/src/test/scala/consumer/message_filter/basic_message_filter/BasicMessageFilterTest.scala +++ b/server/src/test/scala/consumer/message_filter/basic_message_filter/BasicMessageFilterTest.scala @@ -23,8 +23,13 @@ object BasicMessageFilterTest extends ZIOSpecDefault: isShouldFail: Boolean = false ) + // ONE pool (and therefore one GraalVM Engine) for the whole suite: building an Engine per test + // cost ~132 of them. isDebug=false keeps ConsumerSessionContext from console.log-ing every + // message into the build output; TestResult.error is populated regardless of the debug flag. + // Safe because ZIO Test runs the tests of a suite sequentially. + private val sessionContextPool = ConsumerSessionContextPool(isDebug = false) + def runTestSpec(spec: TestSpec): Boolean = - val sessionContextPool = ConsumerSessionContextPool(isDebug = true) val basicMessageFilter = BasicMessageFilter(op = spec.op) val filter = MessageFilter( isEnabled = true, @@ -38,9 +43,16 @@ object BasicMessageFilterTest extends ZIOSpecDefault: val sessionContext = sessionContextPool.getNextContext sessionContext.setCurrentMessage(spec.messageJsonOmittingValue.toJson, Right(spec.messageValueAsJson.trim)) - val result = sessionContext.testMessageFilter(filter = filter).isOk + val result = sessionContext.testMessageFilter(filter = filter) - if spec.isShouldFail then !result else result + // A filter that THROWS also yields isOk=false (BasicMessageFilter catches Throwable), so + // reading isOk alone made every `isShouldFail` case pass on a crash just as it does on a + // correct rejection. An evaluation error is never an expected outcome here: fail loudly. + if result.error.nonEmpty then + java.lang.System.err.println(s"[filter-test] unexpected evaluation error: ${result.error.get}") + false + else if spec.isShouldFail then !result.isOk + else result.isOk def spec = suite(s"${this.getClass.toString}")( /* @@ -2680,4 +2692,4 @@ object BasicMessageFilterTest extends ZIOSpecDefault: ) ))) } - ) + ) @@ TestAspect.sequential diff --git a/server/src/test/scala/consumer/session_runner/approximateDataPositionTest.scala b/server/src/test/scala/consumer/session_runner/approximateDataPositionTest.scala new file mode 100644 index 000000000..eea560b2e --- /dev/null +++ b/server/src/test/scala/consumer/session_runner/approximateDataPositionTest.scala @@ -0,0 +1,163 @@ +package consumer.session_runner + +import zio.test.* + +import scala.util.Try + +/** "Start approximately this far through the DATA a topic still holds" + * ([[ApproximateDataPosition]]). + * + * The message/entry-proportional half of a two-mode split: this one answers "how many of the + * messages are behind me", while [[ApproximateTimePosition]] answers "how much of the time range is + * behind me". Everything below predates that split and is unchanged by it - the semantics are + * exactly as they were, only the name moved. + * + * The mode exists because Pulsar can address a position by ENTRY ordinal in constant time at any + * topic size, while a MESSAGE ordinal has no index at all and has to be counted. Resolving against + * entries is also WHY the position is only approximate - an entry is a whole batch of messages. So + * the position is resolved against entries, and [[resolveApproximateDataPosition]] is the whole + * rounding contract: + * given the fraction the user asked for and the number of entries the broker reports, which entry + * does the consumer seek to. + * + * PURE by construction - `numberOfEntries` is a plain argument, so every log size, both endpoints, + * the empty log and every rejected input are driven here without a broker. + * + * The expected ordinals below are not derived from the implementation: they were VERIFIED against a + * live Pulsar 3.2.1 (100 unbatched messages, one per entry, so entry ordinal == message ordinal). + * `examineMessage(topic, "earliest", k)` answered m-k for every k, and a consumer built the way + * `buildConsumer` builds one - `.startMessageIdInclusive()` - seeked to that entry and delivered + * m-k FIRST. + */ +object approximateDataPositionTest extends ZIOSpecDefault: + + import ApproximateDataSeek.* + + private def rejected(fraction: Double, numberOfEntries: Long = 100): Option[String] = + Try(resolveApproximateDataPosition(fraction, numberOfEntries)).failed.toOption.map(_.getMessage) + + private val endpointsSuite = suite("endpoints")( + test("0.0 is the earliest retained message, not entry 1 by ordinal") { + // Both would deliver the same first message, but only MessageId.earliest keeps working + // when the entry count the ordinal was computed from is already stale. + assertTrue(resolveApproximateDataPosition(0.0, 100) == Earliest) + }, + test("1.0 is the latest position, exactly as the 'Latest message' mode") { + // VERIFIED: a seek to MessageId.latest delivered nothing from a 100-message topic - the + // session shows only what is published from now on. 1.0 must mean that, not "the last + // entry". + assertTrue(resolveApproximateDataPosition(1.0, 100) == Latest) + }, + test("the endpoints are exact whatever the log size, including a log of one entry") { + assertTrue( + resolveApproximateDataPosition(0.0, 1) == Earliest, + resolveApproximateDataPosition(1.0, 1) == Latest, + resolveApproximateDataPosition(0.0, 1_000_000_000L) == Earliest, + resolveApproximateDataPosition(1.0, 1_000_000_000L) == Latest + ) + }, + test("negative zero is still the earliest end") { + assertTrue(resolveApproximateDataPosition(-0.0, 100) == Earliest) + } + ) + + private val roundingSuite = suite("rounding")( + test("100 entries: the fractions resolve to the entries the live broker delivered") { + // The oracle table. On the probe topic entry k held message m-k, and each of these + // seeks delivered exactly that message first. + assertTrue( + resolveApproximateDataPosition(0.01, 100) == Entry(2), // m-2 + resolveApproximateDataPosition(0.1, 100) == Entry(11), // m-11 + resolveApproximateDataPosition(0.25, 100) == Entry(26), // m-26 + resolveApproximateDataPosition(0.5, 100) == Entry(51), // m-51 + resolveApproximateDataPosition(0.75, 100) == Entry(76), // m-76 + resolveApproximateDataPosition(0.9, 100) == Entry(91), // m-91 + resolveApproximateDataPosition(0.99, 100) == Entry(100) // m-100 + ) + }, + test("the rule is: leave floor(fraction * entries) entries behind") { + // Stated as a property rather than as the formula: what is skipped never overshoots the + // fraction asked for, and is never a whole entry short of it. + val entries = 997L + val violations = (1 to 999).map(_ / 1000.0).flatMap { fraction => + resolveApproximateDataPosition(fraction, entries) match + case Entry(ordinal) => + val skipped = (ordinal - 1).toDouble + val exact = fraction * entries + Option.when(skipped > exact || exact - skipped >= 1.0)(s"$fraction -> entry $ordinal") + case other => Some(s"$fraction -> $other") + } + assertTrue(violations.isEmpty) ?? s"fractions that did not land within one entry of their proportion: $violations" + }, + test("a larger fraction never lands earlier in the log") { + val entries = 250L + val ordinals = (0 to 1000).map(_ / 1000.0).map { fraction => + resolveApproximateDataPosition(fraction, entries) match + case Earliest => 0L + case Entry(ordinal) => ordinal + case Latest => Long.MaxValue + } + assertTrue(ordinals == ordinals.sorted) ?? "the resolved position must be monotonic in the fraction" + }, + test("the ordinal is 1-based and never runs past the end of the log") { + // examineMessage CLAMPS past the end instead of failing, so an ordinal of entries + 1 + // would silently land on the last entry and look like it worked. + assertTrue( + resolveApproximateDataPosition(0.9999999, 10) == Entry(10), + resolveApproximateDataPosition(0.5, 1) == Entry(1), + resolveApproximateDataPosition(0.0000001, 10) == Entry(1) + ) + }, + test("a huge log resolves without overflowing") { + assertTrue(resolveApproximateDataPosition(0.5, 4_000_000_000L) == Entry(2_000_000_001L)) + } + ) + + private val emptyLogSuite = suite("empty log")( + test("a topic with no retained entries starts from the beginning") { + // VERIFIED: examineMessage on an empty topic FAILS ("Could not examine messages due to + // the total message is zero"), so an ordinal must never be asked for here. Seeking to + // earliest is what "EarliestMessage" does on an empty topic - the session shows what + // gets published from now on. + assertTrue( + resolveApproximateDataPosition(0.5, 0) == Earliest, + resolveApproximateDataPosition(0.0, 0) == Earliest + ) + }, + test("1.0 on an empty topic still means latest") { + assertTrue(resolveApproximateDataPosition(1.0, 0) == Latest) + }, + test("a negative entry count cannot produce an ordinal") { + assertTrue(resolveApproximateDataPosition(0.5, -1) == Earliest) + } + ) + + private val rejectionSuite = suite("rejected fractions")( + test("NaN is rejected - every comparison against it is false, so it would slip through a range check") { + val message = rejected(Double.NaN) + assertTrue(message.exists(_.contains("NaN"))) ?? s"NaN must be rejected with a clear message, got: $message" + }, + test("a fraction below 0 or above 1 is rejected and the message names the value") { + val below = rejected(-0.5) + val above = rejected(1.5) + assertTrue( + below.exists(m => m.contains("-0.5") && m.contains("0.0") && m.contains("1.0")), + above.exists(m => m.contains("1.5") && m.contains("0.0") && m.contains("1.0")) + ) ?? s"out-of-range fractions must be rejected clearly, got: $below / $above" + }, + test("infinities are rejected") { + assertTrue( + rejected(Double.PositiveInfinity).isDefined, + rejected(Double.NegativeInfinity).isDefined + ) + }, + test("a fraction just inside the range is accepted") { + assertTrue( + rejected(0.0).isEmpty, + rejected(1.0).isEmpty, + rejected(0.9999999999).isEmpty + ) + } + ) + + def spec = suite(this.getClass.toString)(endpointsSuite, roundingSuite, emptyLogSuite, rejectionSuite) diff --git a/server/src/test/scala/consumer/session_runner/approximateTimePositionTest.scala b/server/src/test/scala/consumer/session_runner/approximateTimePositionTest.scala new file mode 100644 index 000000000..72cff4c98 --- /dev/null +++ b/server/src/test/scala/consumer/session_runner/approximateTimePositionTest.scala @@ -0,0 +1,245 @@ +package consumer.session_runner + +import zio.test.* + +import scala.collection.mutable +import scala.util.Try + +/** "Start approximately this far through the TIME RANGE this topic covers" ([[ApproximateTimePosition]]). + * + * The sibling of "about % through the data", and the reason the two are separate modes: on a topic + * where almost every message arrived in the last hour of a 30-day retention, half the MESSAGES are + * behind you somewhere inside that last hour, while half the TIME is behind you fifteen days back. + * Both are legitimate questions; one control cannot answer both. + * + * THE CONTRACT, per LOGICAL topic: `earliest` is the MINIMUM first-message publish time over the + * topic's partitions, `latest` the MAXIMUM last-message publish time over them, and the cutoff is + * `earliest + fraction * (latest - earliest)`. Every partition is seeked to that one instant. + * Min/max rather than a per-partition quantile is what makes the endpoints exact by construction, + * the mapping monotonic, and the answer independent of how many partitions the topic has. + * + * PURE by construction: the broker sits behind the `timeSpanOf` lookup, so every arrangement below - + * balanced partitions, uneven ones, an idle one, an empty topic, a topic that occupies a single + * instant, both endpoints - is driven with a plain function and no broker. The live-broker + * counterpart is `CsStartFromOutcomesSpec` CS-SF-16..19. + */ +object approximateTimePositionTest extends ZIOSpecDefault: + + import ApproximateTimeSeek.* + + /** A topic whose partitions are named `p0`, `p1`, ... with the spans given. */ + private def topic(spans: (String, (Long, Long))*): (Vector[String], String => Option[TopicTimeSpan]) = + val byName = spans.toMap + (spans.map(_._1).toVector, name => byName.get(name).map((first, last) => TopicTimeSpan(first, last))) + + private def resolve(fraction: Double, spans: (String, (Long, Long))*): ApproximateTimeSeek = + val (partitions, lookup) = topic(spans*) + resolveApproximateTimePosition(fraction, partitions, lookup) + + private def rejected(fraction: Double): Option[String] = + Try(resolve(fraction, "p0" -> (1000L, 2000L))).failed.toOption.map(_.getMessage) + + /** One partition covering 1000..2000ms - the simplest possible arrangement. */ + private val single = Seq("p0" -> (1000L, 2000L)) + + private val endpointsSuite = suite("endpoints")( + test("0.0 is the earliest retained message, not the earliest TIMESTAMP") { + // Both would deliver the same first message when the recorded time is still accurate, + // but only MessageId.earliest keeps working when it is not - and a timestamp seek + // computed from a stale reading would silently skip the head of the log. + assertTrue(resolve(0.0, single*) == Earliest) + }, + test("1.0 is the last message: the seek is to its exact publish time") { + // NOT "past the end", which is what 1.0 means in the data mode. The time mode's high + // endpoint is the last message itself, so 100% still shows something. + assertTrue(resolve(1.0, single*) == Timestamp(2000L)) + }, + test("the endpoints are exact - never derived by interpolating to the ends") { + // Interpolation would reach the same two numbers by arithmetic on doubles. Pinned as + // its own case because the whole point of an endpoint is that it cannot be off by one. + val spread = Seq("p0" -> (1_700_000_000_123L, 1_700_000_086_400_000L)) + assertTrue( + resolve(0.0, spread*) == Earliest, + resolve(1.0, spread*) == Timestamp(1_700_000_086_400_000L) + ) + }, + test("1.0 takes the LAST message of the whole topic, over every partition") { + assertTrue(resolve(1.0, "p0" -> (1000L, 5000L), "p1" -> (2000L, 9000L)) == Timestamp(9000L)) + }, + test("0.0 answers without asking the broker anything at all") { + // The earliest end needs no time range, so it must not pay for one: two admin calls per + // partition is the cost this endpoint gets to skip. + val asked = mutable.ListBuffer.empty[String] + val seek = resolveApproximateTimePosition( + 0.0, + Vector("p0", "p1", "p2"), + name => + asked += name + Some(TopicTimeSpan(1000L, 2000L)) + ) + assertTrue(seek == Earliest, asked.isEmpty) ?? s"0.0 looked up $asked" + } + ) + + private val interpolationSuite = suite("the cutoff between the endpoints")( + test("half way through a single partition's range is half way through its time") { + assertTrue(resolve(0.5, "p0" -> (1000L, 2000L)) == Timestamp(1500L)) + }, + test("balanced partitions covering the same range resolve to that range's midpoint") { + // Every partition is seeked to the SAME instant - the position is a property of the + // logical topic, not of whichever partition a message happens to live on. + assertTrue(resolve(0.5, "p0" -> (1000L, 2000L), "p1" -> (1000L, 2000L)) == Timestamp(1500L)) + }, + test("partitions with different spans are combined as min(first) .. max(last)") { + // p0 covers 1000..3000 and p1 covers 2000..5000, so the topic covers 1000..5000 and 25% + // of it is 2000 - inside p1's very first message and a third of the way into p0. + assertTrue( + resolve(0.25, "p0" -> (1000L, 3000L), "p1" -> (2000L, 5000L)) == Timestamp(2000L), + resolve(0.5, "p0" -> (1000L, 3000L), "p1" -> (2000L, 5000L)) == Timestamp(3000L) + ) + }, + test("an IDLE partition does not drag the position backwards") { + // p1 stopped receiving at 1100 while p0 ran on to 9000. A per-partition quantile would + // give p1 a cutoff near its own middle and hand back messages from the far past; taking + // the topic's own min/max puts both partitions at the same instant, and the idle one + // simply has nothing at or after it. This is the defect the data mode has to live with + // and this mode is defined to avoid. + assertTrue(resolve(0.5, "p0" -> (1000L, 9000L), "p1" -> (1000L, 1100L)) == Timestamp(5000L)) + }, + test("the answer does not depend on how many partitions the topic has") { + // The same overall range, split three ways and then six ways: identical cutoff. A + // formula that averaged per-partition positions would move here. + val threeWays = Seq("p0" -> (0L, 300L), "p1" -> (100L, 600L), "p2" -> (200L, 900L)) + val sixWays = Seq( + "p0" -> (0L, 100L), + "p1" -> (100L, 200L), + "p2" -> (200L, 400L), + "p3" -> (300L, 600L), + "p4" -> (400L, 700L), + "p5" -> (500L, 900L) + ) + assertTrue(resolve(0.4, threeWays*) == resolve(0.4, sixWays*), resolve(0.4, threeWays*) == Timestamp(360L)) + }, + test("the cutoff is rounded DOWN, so it never lands past the proportion asked for") { + // 1/3 of 10ms is 3.33ms. Rounding down keeps the mapping monotonic and keeps the + // delivered set from starting later than the instant the user pointed at. + assertTrue( + resolve(1.0 / 3.0, "p0" -> (0L, 10L)) == Timestamp(3L), + resolve(2.0 / 3.0, "p0" -> (0L, 10L)) == Timestamp(6L) + ) + }, + test("a larger fraction never resolves to an earlier instant") { + val cutoffs = (0 to 1000).map(_ / 1000.0).map { fraction => + resolve(fraction, "p0" -> (1_700_000_000_000L, 1_700_002_592_000L)) match + case Earliest => Long.MinValue + case Timestamp(atMs) => atMs + } + assertTrue(cutoffs == cutoffs.sorted) ?? "the resolved instant must be monotonic in the fraction" + }, + test("a 30-day range resolves without losing milliseconds to double arithmetic") { + // Epoch millis are ~1.7e12 and a month is ~2.6e9 - both far inside a double's exact + // integer range, but the multiplication has to be done on the SPAN rather than on the + // absolute instants for that to hold. + val start = 1_700_000_000_000L + val thirtyDays = 30L * 24 * 60 * 60 * 1000 + assertTrue( + resolve(0.5, "p0" -> (start, start + thirtyDays)) == Timestamp(start + thirtyDays / 2), + resolve(0.1, "p0" -> (start, start + thirtyDays)) == Timestamp(start + thirtyDays / 10) + ) + }, + test("the skew a data-proportional position would show is exactly what this mode removes") { + // The motivating topic: a month of retention in which almost everything arrived in the + // final hour. 50% of the TIME RANGE is 15 days back, whatever the message density is - + // that is the whole reason this mode exists next to the data one. + val start = 1_700_000_000_000L + val thirtyDays = 30L * 24 * 60 * 60 * 1000 + val fifteenDays = thirtyDays / 2 + assertTrue(resolve(0.5, "p0" -> (start, start + thirtyDays)) == Timestamp(start + fifteenDays)) + } + ) + + private val degenerateSuite = suite("topics with no time range to speak of")( + test("a topic no partition can answer for starts from the beginning") { + // Empty: `examineMessage` FAILS on a topic with no entries rather than answering, so + // there is no range at all. Seeking to earliest is what "Earliest message" does on an + // empty topic - the session shows whatever is published from now on. + val (partitions, _) = topic("p0" -> (0L, 0L)) + val nothing = resolveApproximateTimePosition(0.5, partitions, _ => None) + assertTrue(nothing == Earliest) + }, + test("an empty topic resolves to the beginning at EVERY fraction, including 1.0") { + // There is no last message to be exact about, and on a topic holding nothing "the + // beginning" and "the end" are the same position: whatever arrives next. + val fractions = Vector(0.0, 0.25, 0.5, 0.75, 1.0) + val seeks = fractions.map(f => resolveApproximateTimePosition(f, Vector("p0"), _ => None)) + assertTrue(seeks.forall(_ == Earliest)) ?? s"an empty topic resolved to $seeks" + }, + test("partitions that hold nothing are skipped, not counted as time zero") { + // A partitioned topic where only some partitions were written to: an unanswerable + // partition contributing 0 to the minimum would drag `earliest` back to 1970 and make + // every interior fraction land before the real data. + val (partitions, lookup) = topic("p0" -> (4000L, 8000L)) + val withEmpties = resolveApproximateTimePosition(0.5, partitions ++ Vector("p1", "p2"), lookup) + assertTrue(withEmpties == Timestamp(6000L)) + }, + test("a topic that occupies ONE instant starts from the beginning for any interior fraction") { + // first == last: the range has no interior to interpolate into. Every message shares + // that instant, so there is no position that separates them and the honest answer is + // "all of it" - which is what a seek to earliest delivers. No division is involved, so + // this is a definition rather than a guard against a divide-by-zero. + assertTrue( + resolve(0.5, "p0" -> (1500L, 1500L)) == Earliest, + resolve(0.01, "p0" -> (1500L, 1500L)) == Earliest, + resolve(0.99, "p0" -> (1500L, 1500L)) == Earliest + ) + }, + test("a topic that occupies one instant still answers 1.0 with that instant") { + // The last message is at 1500 and a seek to 1500 delivers it, so the high endpoint + // stays exact even with no range. Everything else published in that same millisecond + // comes too: a timestamp seek is millisecond-granular and cannot separate them. + assertTrue(resolve(1.0, "p0" -> (1500L, 1500L)) == Timestamp(1500L)) + }, + test("a single message is a topic of one instant, and behaves like one") { + assertTrue( + resolve(0.0, "p0" -> (1500L, 1500L)) == Earliest, + resolve(0.5, "p0" -> (1500L, 1500L)) == Earliest, + resolve(1.0, "p0" -> (1500L, 1500L)) == Timestamp(1500L) + ) + }, + test("a range reported backwards by a producer's clock does not resolve to a cutoff past the data") { + // publishTime is stamped by the PRODUCER, so a clock that jumped backwards mid-topic can + // report a first later than the last. There is no interior to interpolate, and a cutoff + // taken from either end could hide messages, so the beginning is the only safe answer. + assertTrue(resolve(0.5, "p0" -> (9000L, 1000L)) == Earliest) + } + ) + + private val rejectionSuite = suite("rejected fractions")( + test("NaN is rejected - every comparison against it is false, so a range check alone lets it through") { + val message = rejected(Double.NaN) + assertTrue(message.exists(_.contains("NaN"))) ?? s"NaN must be rejected with a clear message, got: $message" + }, + test("a fraction below 0 or above 1 is rejected and the message names the value") { + val below = rejected(-0.5) + val above = rejected(1.5) + assertTrue( + below.exists(m => m.contains("-0.5") && m.contains("0.0") && m.contains("1.0")), + above.exists(m => m.contains("1.5") && m.contains("0.0") && m.contains("1.0")) + ) ?? s"out-of-range fractions must be rejected clearly, got: $below / $above" + }, + test("the message says which of the two modes refused it") { + // Two modes now carry a fraction, and a session can only be fixed if the error names the + // one that rejected it. + val message = rejected(1.5) + assertTrue(message.exists(m => m.contains("time") && !m.contains("data"))) ?? + s"the rejection must name the time mode, got: $message" + }, + test("infinities are rejected") { + assertTrue(rejected(Double.PositiveInfinity).isDefined, rejected(Double.NegativeInfinity).isDefined) + }, + test("a fraction just inside the range is accepted") { + assertTrue(rejected(0.0).isEmpty, rejected(1.0).isEmpty, rejected(0.9999999999).isEmpty) + } + ) + + def spec = suite(this.getClass.toString)(endpointsSuite, interpolationSuite, degenerateSuite, rejectionSuite) diff --git a/server/src/test/scala/consumer/session_runner/batchSizeTest.scala b/server/src/test/scala/consumer/session_runner/batchSizeTest.scala new file mode 100644 index 000000000..8c5c8a580 --- /dev/null +++ b/server/src/test/scala/consumer/session_runner/batchSizeTest.scala @@ -0,0 +1,58 @@ +package consumer.session_runner + +import org.apache.pulsar.client.api.{MessageId, Schema} +import org.apache.pulsar.client.impl.{BatchMessageIdImpl, MessageIdImpl, MessageImpl} +import org.apache.pulsar.common.api.proto.MessageMetadata +import zio.test.* + +import java.nio.ByteBuffer + +/** `messagesInEntryOf` decides how many messages the backward walk counts for one entry, and that + * count drives both the running total and the per-topic overshoot discard. A PRODUCER must not be + * able to inflate it: `X-Pulsar-num-batch-message` lives in `getProperties` next to arbitrary + * producer keys, and the admin client only overwrites it for entries it expands as real batches, so + * a forged value survives on an unbatched message. The non-forgeable witness is the message id - + * the admin client returns a batch id (batch index >= 0) only for a genuine batch. + */ +object batchSizeTest extends ZIOSpecDefault: + + private val topic = "persistent://public/default/batch-size" + + private def message(id: MessageId, properties: Map[String, String]): MessageImpl[Array[Byte]] = + val md = new MessageMetadata() + // publish_time is mandatory on MessageMetadata - reading it when unset throws. + md.setPublishTime(1_700_000_000_000L) + properties.foreach((k, v) => md.addProperty().setKey(k).setValue(v)) + val msg = MessageImpl.create[Array[Byte]](md, ByteBuffer.wrap("{}".getBytes("UTF-8")), Schema.BYTES, topic) + msg.setMessageId(id) + msg + + def spec = suite(this.getClass.toString)( + test("a forged batch-size property on an UNBATCHED message is ignored - the entry is one message") { + // THE forgery. An unbatched message (plain id, batch index -1) carrying a huge + // `X-Pulsar-num-batch-message` used to be counted as that many, overshooting the walk and + // letting the per-topic overshoot discard swallow real messages from the stream's head. + val forged = message(new MessageIdImpl(1L, 2L, -1), Map(batchSizeProperty -> "1000000")) + assertTrue(messagesInEntryOf(forged) == 1) ?? s"a forged property was counted as ${messagesInEntryOf(forged)}" + }, + test("a real batch id reports its own batch size, no property needed") { + // The id itself carries the count when the admin client populated it - not a property at all. + val batched = message(new BatchMessageIdImpl(1L, 2L, 0, 0, 50, null), Map.empty) + assertTrue(messagesInEntryOf(batched) == 50) + }, + test("a batch id the admin client left without a size falls back to the verified property") { + // batchSize -1 on the id, but the id CONFIRMS a batch (index 0), so the property is trusted. + val batched = message(new BatchMessageIdImpl(1L, 2L, 0, 0, -1, null), Map(batchSizeProperty -> "100")) + assertTrue(messagesInEntryOf(batched) == 100) + }, + test("a plain unbatched entry with no property is one message") { + assertTrue(messagesInEntryOf(message(new MessageIdImpl(1L, 2L, -1), Map.empty)) == 1) + }, + test("a garbage or non-positive forged size on a batch id falls back to one, never zero or negative") { + // Even where the id confirms a batch, a property that does not parse to a positive number + // is read as one message - the walk then goes DEEPER (over-delivers) rather than swallow. + val negative = message(new BatchMessageIdImpl(1L, 2L, 0, 0, -1, null), Map(batchSizeProperty -> "-5")) + val notANumber = message(new BatchMessageIdImpl(1L, 2L, 0, 0, -1, null), Map(batchSizeProperty -> "lots")) + assertTrue(messagesInEntryOf(negative) == 1, messagesInEntryOf(notANumber) == 1) + } + ) diff --git a/server/src/test/scala/consumer/session_runner/buildConsumerBackoffTest.scala b/server/src/test/scala/consumer/session_runner/buildConsumerBackoffTest.scala new file mode 100644 index 000000000..f7584fbb5 --- /dev/null +++ b/server/src/test/scala/consumer/session_runner/buildConsumerBackoffTest.scala @@ -0,0 +1,77 @@ +package consumer.session_runner + +import _root_.consumer.coloring_rules.ColoringRuleChain +import _root_.consumer.deserializer.Deserializer +import _root_.consumer.deserializer.deserializers.UseLatestTopicSchema +import _root_.consumer.message_filter.MessageFilterChain +import _root_.consumer.session_target.ConsumerSessionTarget +import _root_.consumer.session_target.consumption_mode.ConsumerSessionTargetConsumptionMode +import _root_.consumer.session_target.consumption_mode.modes.RegularConsumptionMode +import _root_.consumer.session_target.topic_selector.{MultiTopicSelector, TopicSelector} +import _root_.consumer.value_projections.ValueProjectionList +import org.apache.pulsar.client.api.PulsarClient +import org.apache.pulsar.client.impl.ConsumerBuilderImpl +import zio.test.* + +import java.util.concurrent.TimeUnit +import scala.util.Try + +/** What `buildConsumer` arms for NEGATIVE-ACKNOWLEDGMENT redelivery. + * + * Regression context: it set `negativeAckRedeliveryDelay(0, SECONDS)`, which the client floors to + * 100ms, so every sustained nack source became a ten-per-second redelivery hammer - a paused + * session nacks everything it receives, and a delivery that throws mid-batch is handed back the + * same way. (The start-from merge used to be the loudest source, nacking whatever it declined at + * its memory cap; it now pauses hot consumers instead and declines nothing.) A session pinned at the cap by one silent stream + * therefore redelivered its whole held set every ~100ms indefinitely - a permanent, silent storm + * of nack/redeliver traffic against the broker. + * + * A DECAYING backoff keeps the cases that should be prompt prompt (the first redelivery is still + * 100ms, so pause/resume feels immediate) while a message bounced over and over backs off toward + * a 10s ceiling, so the storm cools instead of spinning at the floor rate. + * + * The client is real and aimed at a closed port - building a consumer configuration connects to + * nothing. Asserted through `ConsumerBuilderImpl.getConf`, the same configuration `subscribe()` + * would use. + */ +object buildConsumerBackoffTest extends ZIOSpecDefault: + + private val topicFqn = "persistent://public/default/nack-backoff" + + private def targetConfig: ConsumerSessionTarget = + ConsumerSessionTarget( + isEnabled = true, + consumptionMode = ConsumerSessionTargetConsumptionMode(mode = RegularConsumptionMode()), + messageValueDeserializer = Deserializer(deserializer = UseLatestTopicSchema()), + topicSelector = TopicSelector(topicSelector = MultiTopicSelector(topicFqns = Vector(topicFqn))), + messageFilterChain = MessageFilterChain.empty, + coloringRuleChain = ColoringRuleChain.empty, + valueProjectionList = ValueProjectionList(isEnabled = false, projections = Vector.empty) + ) + + def spec = suite(this.getClass.toString)( + test("nack redelivery DECAYS from 100ms to a 10s ceiling instead of hammering at a flat floor") { + val client = PulsarClient.builder.serviceUrl("pulsar://127.0.0.1:1").operationTimeout(2, TimeUnit.SECONDS).build + try + val builder = buildConsumer( + pulsarClient = client, + consumerName = "cs-nack-backoff-0", + topicsToConsume = Vector(topicFqn), + listener = ConsumerListener(ConsumerSessionTargetMessageHandler(onNext = _ => ())), + targetConfig = targetConfig + ).toOption.get + val conf = builder.asInstanceOf[ConsumerBuilderImpl[Array[Byte]]].getConf + val backoff = Option(conf.getNegativeAckRedeliveryBackoff) + val delays = (0 to 20).toVector.map(redeliveryCount => backoff.map(_.next(redeliveryCount)).getOrElse(-1L)) + assertTrue( + backoff.isDefined, + delays.head == 100L, + delays.last == 10_000L, + delays.forall(_ <= 10_000L), + delays.zip(delays.tail).forall((sooner, later) => sooner <= later) + ) ?? s"negativeAckRedeliveryBackoff=$backoff delays=$delays" + finally + Try(client.close()) + () + } + ) diff --git a/server/src/test/scala/consumer/session_runner/consumerSessionRunnerTest.scala b/server/src/test/scala/consumer/session_runner/consumerSessionRunnerTest.scala new file mode 100644 index 000000000..025f16428 --- /dev/null +++ b/server/src/test/scala/consumer/session_runner/consumerSessionRunnerTest.scala @@ -0,0 +1,108 @@ +package consumer.session_runner + +import _root_.consumer.coloring_rules.ColoringRuleChain +import _root_.consumer.deserializer.Deserializer +import _root_.consumer.deserializer.deserializers.UseLatestTopicSchema +import _root_.consumer.message_filter.MessageFilterChain +import _root_.consumer.pause_trigger.ConsumerSessionPauseTriggerChain +import _root_.consumer.session_config.ConsumerSessionConfig +import _root_.consumer.session_target.ConsumerSessionTarget +import _root_.consumer.session_target.consumption_mode.ConsumerSessionTargetConsumptionMode +import _root_.consumer.session_target.consumption_mode.modes.RegularConsumptionMode +import _root_.consumer.session_target.topic_selector.{MultiTopicSelector, TopicSelector} +import _root_.consumer.start_from.EarliestMessage +import _root_.consumer.value_projections.ValueProjectionList +import org.apache.pulsar.client.admin.PulsarAdmin +import org.apache.pulsar.client.api.PulsarClient +import zio.test.* + +import java.util.concurrent.TimeUnit +import scala.util.Try + +/** `ConsumerSessionRunner.make` is what `ConsumerServiceImpl.createConsumer` reports on: if it + * returns, the session is stored and the client is told Code.OK. + * + * Regression context: it accepted a runner with ZERO consumers. A `MultiTopicSelector` with no + * topics (or one whose topics all failed to resolve, before that was made loud) produced a target + * runner with an empty consumer map; `make` built a session from it and `createConsumer` answered + * OK, so the UI showed a session in state `running` that could never deliver a message and never + * explained why. + * + * The clients are REAL, aimed at a closed port. They construct offline and are never used on this + * path (an empty selection touches neither `getSchemasByTopic` nor `handleStartFrom`), but using + * real objects rather than nulls means an accidental use would surface as a connection error, not + * as an NPE that makes the assertion pass for the wrong reason. + */ +object consumerSessionRunnerTest extends ZIOSpecDefault: + + private def withOfflineClients[A](f: (PulsarClient, PulsarAdmin) => A): A = + val client = PulsarClient.builder + .serviceUrl("pulsar://127.0.0.1:1") + .operationTimeout(2, TimeUnit.SECONDS) + .build + val admin = PulsarAdmin.builder + .serviceHttpUrl("http://127.0.0.1:1") + .connectionTimeout(2, TimeUnit.SECONDS) + .readTimeout(2, TimeUnit.SECONDS) + .requestTimeout(2, TimeUnit.SECONDS) + .build + try f(client, admin) + finally + Try(client.close()) + Try(admin.close()) + + private def target(isEnabled: Boolean, topicFqns: Vector[String]): ConsumerSessionTarget = + ConsumerSessionTarget( + isEnabled = isEnabled, + consumptionMode = ConsumerSessionTargetConsumptionMode(mode = RegularConsumptionMode()), + messageValueDeserializer = Deserializer(deserializer = UseLatestTopicSchema()), + topicSelector = TopicSelector(topicSelector = MultiTopicSelector(topicFqns = topicFqns)), + messageFilterChain = MessageFilterChain.empty, + coloringRuleChain = ColoringRuleChain.empty, + valueProjectionList = ValueProjectionList(isEnabled = false, projections = Vector.empty) + ) + + private def sessionConfig(targets: Vector[ConsumerSessionTarget]): ConsumerSessionConfig = + ConsumerSessionConfig( + startFrom = EarliestMessage(), + targets = targets, + messageFilterChain = MessageFilterChain.empty, + coloringRuleChain = ColoringRuleChain.empty, + pauseTriggerChain = ConsumerSessionPauseTriggerChain.empty, + valueProjectionList = ValueProjectionList(isEnabled = false, projections = Vector.empty) + ) + + private def make(sessionName: String, targets: Vector[ConsumerSessionTarget]): Try[ConsumerSessionRunner] = + withOfflineClients((client, admin) => + Try(ConsumerSessionRunner.make( + pulsarClient = client, + adminClient = admin, + sessionName = sessionName, + sessionConfig = sessionConfig(targets) + )) + ) + + def spec = suite(this.getClass.toString)( + test("a session whose only enabled target resolves to no topics is rejected") { + val result = make("cs-empty-target", Vector(target(isEnabled = true, topicFqns = Vector.empty))) + val message = result.failed.toOption.map(_.getMessage).getOrElse("") + + assertTrue(result.isFailure, message.contains("no topics")) ?? + s"a target that resolves to nothing must not produce a consumer-less session, got: $result" + }, + test("a disabled target does not rescue a session that has no enabled target left") { + // Disabled targets are filtered out before consumers are built, so a session made only + // of them is just as empty - and used to be accepted just as silently. + val result = make("cs-only-disabled", Vector(target(isEnabled = false, topicFqns = Vector("persistent://public/default/t1")))) + val message = result.failed.toOption.map(_.getMessage).getOrElse("") + + assertTrue(result.isFailure, message.contains("no enabled targets")) ?? + s"a session with nothing enabled must be rejected, got: $result" + }, + test("a session with no targets at all is rejected") { + val result = make("cs-no-targets", Vector.empty) + val message = result.failed.toOption.map(_.getMessage).getOrElse("") + + assertTrue(result.isFailure, message.contains("no enabled targets")) + } + ) diff --git a/server/src/test/scala/consumer/session_runner/deliveryBudgetTest.scala b/server/src/test/scala/consumer/session_runner/deliveryBudgetTest.scala new file mode 100644 index 000000000..3498eb22d --- /dev/null +++ b/server/src/test/scala/consumer/session_runner/deliveryBudgetTest.scala @@ -0,0 +1,199 @@ +package consumer.session_runner + +import _root_.consumer.coloring_rules.ColoringRuleChain +import _root_.consumer.deserializer.Deserializer +import _root_.consumer.deserializer.deserializers.TreatBytesAsJson +import _root_.consumer.message_filter.basic_message_filter.targets.{BasicMessageFilterTarget, BasicMessageFilterValueTarget} +import _root_.consumer.message_filter.{JsMessageFilter, MessageFilter, MessageFilterChain, MessageFilterChainMode} +import _root_.consumer.pause_trigger.ConsumerSessionPauseTriggerChain +import _root_.consumer.session_config.ConsumerSessionConfig +import _root_.consumer.session_target.ConsumerSessionTarget +import _root_.consumer.session_target.consumption_mode.ConsumerSessionTargetConsumptionMode +import _root_.consumer.session_target.consumption_mode.modes.RegularConsumptionMode +import _root_.consumer.session_target.topic_selector.{MultiTopicSelector, TopicSelector} +import _root_.consumer.start_from.EarliestMessage +import _root_.consumer.value_projections.ValueProjectionList +import com.tools.teal.pulsar.ui.api.v1.consumer as consumerPb +import org.apache.pulsar.client.api.{Consumer, Message as PulsarMessage, Schema} +import org.apache.pulsar.client.impl.{MessageIdImpl, MessageImpl} +import org.apache.pulsar.common.api.proto.MessageMetadata +import zio.test.* + +import java.lang.reflect.{InvocationHandler, Method, Proxy} +import java.nio.ByteBuffer +import java.util.concurrent.atomic.AtomicLong +import java.util.concurrent.{CompletableFuture, ConcurrentLinkedQueue} +import scala.jdk.CollectionConverters.* + +/** THE DELIVERY BUDGET'S ONE CONTRACT: "pause after n" means EXACTLY n LOADED - counted at the + * send, after every filter - while processed is free to run ahead. + * + * A client-side threshold can only ever be approximate: a whole chunk lands before the client can + * react, and under a rate limit the first chunk is the full one-second burst - "pause after 10" + * showed a hundred. This suite drives the REAL pipeline (real listener, real GraalVM filter + * chain, real runner send path; only the broker and the wire are replaced) and pins that the + * message spending the last budget unit is the last one sent, that everything behind it survives + * for the next resume, and that a filter dropping most messages makes processed exceed loaded + * without disturbing the loaded count's exactness. + */ +object deliveryBudgetTest extends ZIOSpecDefault: + + private val consumerName = "cs-delivery-budget" + private val p0 = "persistent://public/default/delivery-budget-0" + + private final class RecordingConsumer(topicFqn: String): + val acknowledged = ConcurrentLinkedQueue[String]() + + val consumer: Consumer[Array[Byte]] = + val handler = new InvocationHandler: + override def invoke(proxy: Object, method: Method, args: Array[Object]): Object = + method.getName match + case "getTopic" => topicFqn + case "getConsumerName" => consumerName + case "isConnected" => java.lang.Boolean.TRUE + case "pause" | "resume" => null + case "acknowledgeAsync" => + acknowledged.add(args(0).asInstanceOf[PulsarMessage[Array[Byte]]].getKey) + CompletableFuture.completedFuture(null) + case "negativeAcknowledge" => null + case "equals" => java.lang.Boolean.valueOf(proxy eq args(0)) + case "hashCode" => java.lang.Integer.valueOf(topicFqn.hashCode) + case "toString" => s"proxy-consumer($topicFqn)" + case _ => null + Proxy + .newProxyInstance(getClass.getClassLoader, Array[Class[?]](classOf[Consumer[Array[Byte]]]), handler) + .asInstanceOf[Consumer[Array[Byte]]] + + private def message(n: Int): MessageImpl[Array[Byte]] = + val md = new MessageMetadata() + md.setPublishTime(1_700_000_000_000L + n) + md.setPartitionKey(n.toString) + val msg = MessageImpl.create[Array[Byte]](md, ByteBuffer.wrap(s"""{"n":$n}""".getBytes("UTF-8")), Schema.BYTES, p0) + msg.setMessageId(new MessageIdImpl(1L, n.toLong, -1)) + msg + + /** Session-level filter keeping only even `n`: every message is PROCESSED, half are LOADED. */ + private val evenOnly: MessageFilterChain = + MessageFilterChain( + isEnabled = true, + isNegated = false, + mode = MessageFilterChainMode.All, + filters = Vector( + MessageFilter( + isEnabled = true, + isNegated = false, + targetField = BasicMessageFilterTarget(target = BasicMessageFilterValueTarget()), + filter = JsMessageFilter(jsCode = "v => v.n % 2 === 0") + ) + ) + ) + + private def targetRunner(consumerListener: ConsumerListener, consumers: Map[String, Consumer[Array[Byte]]]): ConsumerSessionTargetRunner = + ConsumerSessionTargetRunner( + targetIndex = 0, + targetConfig = ConsumerSessionTarget( + isEnabled = true, + consumptionMode = ConsumerSessionTargetConsumptionMode(mode = RegularConsumptionMode()), + messageValueDeserializer = Deserializer(deserializer = TreatBytesAsJson()), + topicSelector = TopicSelector(topicSelector = MultiTopicSelector(topicFqns = Vector(p0))), + messageFilterChain = MessageFilterChain.empty, + coloringRuleChain = ColoringRuleChain.empty, + valueProjectionList = ValueProjectionList(isEnabled = false, projections = Vector.empty) + ), + nonPartitionedTopicFqns = Vector(p0), + schemasByTopic = Map.empty, + sessionContextPool = ConsumerSessionContextPool(), + consumers = consumers, + consumerListener = consumerListener, + stats = ConsumerSessionTargetStats(messageProcessed = AtomicLong(0)) + ) + + private def session(target: ConsumerSessionTargetRunner, sessionFilter: MessageFilterChain): ConsumerSessionRunner = + ConsumerSessionRunner( + sessionName = consumerName, + sessionConfig = ConsumerSessionConfig( + startFrom = EarliestMessage(), + targets = Vector.empty, + messageFilterChain = sessionFilter, + coloringRuleChain = ColoringRuleChain.empty, + pauseTriggerChain = ConsumerSessionPauseTriggerChain.empty, + valueProjectionList = ValueProjectionList(isEnabled = false, projections = Vector.empty) + ), + sessionContextPool = ConsumerSessionContextPool(), + grpcResponseObserver = None, + schemasByTopic = Map.empty, + targets = Map(0 -> target) + ) + + /** Collects what actually went on the wire. Thread-safe: the drain thread writes it. */ + private final class CollectingObserver extends io.grpc.stub.StreamObserver[consumerPb.ResumeResponse]: + val loadedValues = ConcurrentLinkedQueue[String]() + override def onNext(value: consumerPb.ResumeResponse): Unit = + value.messages.filter(_.value.isDefined).foreach(m => loadedValues.add(m.value.get)) + () + override def onError(t: Throwable): Unit = () + override def onCompleted(): Unit = () + + /** Spin until `condition` holds and keeps holding for 200ms, or the deadline passes - the + * drain runs on the runner's real timer thread, so the test has to meet it in real time. */ + private def awaitStable(deadlineMs: Long = 10_000)(condition: => Boolean): Boolean = + val start = System.nanoTime() + var stableSince = -1L + var done = false + while !done && (System.nanoTime() - start) / 1_000_000 < deadlineMs do + if condition then + if stableSince < 0 then stableSince = System.nanoTime() + else if (System.nanoTime() - stableSince) / 1_000_000 >= 200 then done = true + else stableSince = -1L + if !done then Thread.sleep(20) + done + + def spec = suite("the delivery budget: exactly n loaded")( + test("the send spending the last unit is the last one sent; the tail survives for the next resume") { + val recording = RecordingConsumer(p0) + val listener = ConsumerListener(ConsumerSessionTargetMessageHandler(onNext = _ => ())) + val runner = session(targetRunner(listener, Map(p0 -> recording.consumer)), evenOnly) + val observer = CollectingObserver() + + // Budget 3, no rate limit: full speed until the third LOADED message, then a hard stop. + runner.resume(observer, isDebug = false, includeConsumerStats = true, maxMessagesPerSecond = 0, maxMessagesToDeliver = 3) + (1 to 20).foreach(n => listener.received(recording.consumer, message(n))) + + val firstStop = awaitStable() { + observer.loadedValues.size == 3 && runner.deliveryRateLimiter.queuedCount > 0 + } + val loadedAfterFirst = observer.loadedValues.asScala.toVector + val processedAfterFirst = runner.numMessageProcessed + val queuedAfterFirst = runner.deliveryRateLimiter.queuedCount + + // Play again with the same budget: the NEXT three evens, in order, from exactly where + // the drain stopped - nothing lost, nothing repeated. + runner.resume(observer, isDebug = false, includeConsumerStats = true, maxMessagesPerSecond = 0, maxMessagesToDeliver = 3) + val secondStop = awaitStable() { observer.loadedValues.size == 6 } + val loadedAfterSecond = observer.loadedValues.asScala.toVector + + assertTrue(firstStop) && + assertTrue(loadedAfterFirst.map(v => io.circe.parser.parse(v).toOption.get.hcursor.get[Int]("n").toOption.get) == Vector(2, 4, 6)) && + // Processed ran AHEAD of loaded - the filter read the odd ones too - but the loaded + // count stopped at exactly the budget. "100 may be processed, but only 10 loaded." + assertTrue(processedAfterFirst == 6L) && + assertTrue(queuedAfterFirst == 14) && + assertTrue(secondStop) && + assertTrue(loadedAfterSecond.map(v => io.circe.parser.parse(v).toOption.get.hcursor.get[Int]("n").toOption.get) == Vector(2, 4, 6, 8, 10, 12)) + }, + test("a budget WITH a rate limit: the one-second burst cannot blow past n") { + // The user's exact screenshot: rate 100, pause after 10. The bucket starts full, so + // without the budget the first drain hands out 100 at once - the budget must cap that + // very first batch. + val recording = RecordingConsumer(p0) + val listener = ConsumerListener(ConsumerSessionTargetMessageHandler(onNext = _ => ())) + val runner = session(targetRunner(listener, Map(p0 -> recording.consumer)), MessageFilterChain.empty) + val observer = CollectingObserver() + + runner.resume(observer, isDebug = false, includeConsumerStats = true, maxMessagesPerSecond = 100, maxMessagesToDeliver = 10) + (1 to 100).foreach(n => listener.received(recording.consumer, message(n))) + + val stopped = awaitStable() { observer.loadedValues.size == 10 } + assertTrue(stopped) && assertTrue(observer.loadedValues.size == 10) + } + ) @@ TestAspect.sequential @@ TestAspect.withLiveClock diff --git a/server/src/test/scala/consumer/session_runner/deliveryRateLimiterTest.scala b/server/src/test/scala/consumer/session_runner/deliveryRateLimiterTest.scala new file mode 100644 index 000000000..6c4dce34e --- /dev/null +++ b/server/src/test/scala/consumer/session_runner/deliveryRateLimiterTest.scala @@ -0,0 +1,280 @@ +package consumer.session_runner + +import zio.test.* + +import scala.collection.mutable.ArrayBuffer + +/** The delivery rate limiter's decisions, driven with a hand-cranked clock and a hand-cranked + * scheduler - no threads, no sleeps, every tick explicit. + * + * The properties worth pinning, in the order a message meets them: the bucket starts full so the + * first screenful is instant; the sustained rate is exact over any window; order is the queue's + * order under every interleaving; the watermarks fire their callbacks exactly once per crossing; + * a refused permit hold retries instead of sticking; a user pause stops the drain without losing + * the backlog; and one failing delivery costs exactly itself. + */ +object deliveryRateLimiterTest extends ZIOSpecDefault: + + /** A core with a manual clock. Tests advance `clock` and call the drain protocol by hand. */ + private final class ManualCore(initialRate: Long): + var clock: Long = 0L + val core = DeliveryRateLimiterCore[Int](nowMs = () => clock) + core.setRate(initialRate) + + /** A limiter whose timer is a list: `runPending()` is the scheduler thread. Delays are recorded + * so the pacing decisions themselves can be asserted. */ + private final class ManualLimiter(initialRate: Long, holdAnswers: Iterator[Boolean] = Iterator.continually(true)): + var clock: Long = 0L + val processed = ArrayBuffer[Int]() + val scheduledDelays = ArrayBuffer[Long]() + var holdCalls = 0 + var releaseCalls = 0 + var throwOn: Set[Int] = Set.empty + + private val pending = ArrayBuffer[Runnable]() + + val core = DeliveryRateLimiterCore[Int](nowMs = () => clock) + val limiter = DeliveryRateLimiter[Int]( + core = core, + schedule = (delayMs, task) => { scheduledDelays += delayMs; pending += task }, + process = i => { if throwOn.contains(i) then throw RuntimeException(s"boom on $i"); processed += i; () }, + holdPermits = () => { holdCalls += 1; holdAnswers.next() }, + releasePermits = () => { releaseCalls += 1 } + ) + core.setRate(initialRate) + + def runPending(): Unit = + val tasks = pending.toVector + pending.clear() + tasks.foreach(_.run()) + + def hasPending: Boolean = pending.nonEmpty + + def spec = suite("delivery rate limiter")( + suite("the core's arithmetic")( + test("the bucket starts FULL: the first second's worth drains at once") { + // A session capped at 100/s that begins with "latest 50" paints all 50 immediately - + // the user asked for exactly those. The cap shapes what follows, not the first paint. + val m = ManualCore(100) + (1 to 500).foreach(m.core.offer) + val first = m.core.beginDrain() + m.core.finishDrain() + assertTrue(first == (1 to 100).toVector) + }, + test("the sustained rate is EXACT: each elapsed second earns exactly the rate") { + val m = ManualCore(100) + (1 to 500).foreach(m.core.offer) + m.core.beginDrain(); m.core.finishDrain() // the initial burst of 100 + + val perSecond = (1 to 4).map { _ => + m.clock += 1000 + val batch = m.core.beginDrain() + m.core.finishDrain() + batch.size + } + assertTrue(perSecond == Vector(100, 100, 100, 100)) && + assertTrue(m.core.queuedCount == 0) + }, + test("idle time cannot bank more than one second's burst") { + val m = ManualCore(100) + m.core.offer(0) + m.core.beginDrain(); m.core.finishDrain() + // A minute of silence, then a flood: the bucket is capped at the rate, so the first + // drain answers with 100, not with 6000 saved-up tokens. + m.clock += 60_000 + (1 to 300).foreach(m.core.offer) + val batch = m.core.beginDrain() + assertTrue(batch.size == 100) + }, + test("FIFO: an offer made during a drain lands BEHIND everything queued") { + val inline = ManualCore(0).core.offer(1) + assertTrue(inline.processNow) + + // A backlog built under a limit, then the limit lifted MID-DRAIN: the unlimited + // shortcut must still be refused while the drain is in flight, or the new message + // would run concurrently with it and could overtake the batch into the session's + // stateful filters. + val m = ManualCore(5) + (2 to 4).foreach(m.core.offer) + val batch = m.core.beginDrain() + m.core.setRate(0) + val duringDrain = m.core.offer(5) + m.core.finishDrain() + assertTrue(!duringDrain.processNow) && + assertTrue(batch == Vector(2, 3, 4)) && + assertTrue(m.core.beginDrain() == Vector(5)) + }, + test("rate 0 with a backlog refuses the inline shortcut and flushes everything") { + // The backlog exists because the rate was JUST lowered to 0 with messages queued: the + // flush must stay ordered, so new offers join the queue until it has drained. + val m = ManualCore(100) + (1 to 150).foreach(m.core.offer) + m.core.setRate(0) + val outcome = m.core.offer(151) + val flush = m.core.beginDrain() + m.core.finishDrain() + assertTrue(!outcome.processNow) && assertTrue(flush == (1 to 151).toVector) + }, + test("one timer per backlog: a second offer never arms a second drain") { + val m = ManualCore(10) + val first = m.core.offer(1) + val second = m.core.offer(2) + assertTrue(first.scheduleDrainAfterMs.isDefined) && + assertTrue(second.scheduleDrainAfterMs.isEmpty) + }, + test("a cancelled timer can be re-armed, and re-arming an armed one is refused") { + val m = ManualCore(10) + m.core.offer(1) + m.core.cancelScheduledDrain() + val rearmed = m.core.rearmDrain() + val again = m.core.rearmDrain() + assertTrue(rearmed.isDefined) && assertTrue(again.isEmpty) + }, + test("the next-drain delay is the exact token wait, floored against timer churn") { + // rate 100: the next token is 10ms away, but waking every 10ms burns a thread on + // timers, so the wait is floored and the batch grows to match - the tokens keep + // accruing while asleep, so the floor costs no throughput. + val fast = ManualCore(100) + (1 to 200).foreach(fast.core.offer) + fast.core.beginDrain() + val fastNext = fast.core.finishDrain().rescheduleAfterMs + + // rate 1: the exact wait (a full second) is what gets scheduled - no busy ticks. + val slow = ManualCore(1) + slow.core.offer(1); slow.core.offer(2) + slow.core.beginDrain() + val slowNext = slow.core.finishDrain().rescheduleAfterMs + + assertTrue(fastNext.contains(deliveryRateLimitMinRescheduleDelayMs)) && + assertTrue(slowNext.contains(1000L)) + }, + test("one drain is capped, and the remainder reschedules immediately") { + // A fat bucket must not hold the drainer - and with it the session's single JS + // context - for an unbounded stretch. + val m = ManualCore(10_000) + (1 to 2000).foreach(m.core.offer) + val batch = m.core.beginDrain() + val next = m.core.finishDrain().rescheduleAfterMs + assertTrue(batch.size == deliveryRateLimitMaxDrainBatch) && assertTrue(next.contains(0L)) + } + ), + suite("the wrapper's side effects")( + test("under a limit, nothing is processed at offer time; the drain releases FIFO") { + val m = ManualLimiter(100) + (1 to 5).foreach(m.limiter.offer) + val beforeTick = m.processed.toVector + m.runPending() + assertTrue(beforeTick.isEmpty) && assertTrue(m.processed.toVector == (1 to 5).toVector) + }, + test("unlimited passes straight through on the calling thread") { + val m = ManualLimiter(0) + (1 to 5).foreach(m.limiter.offer) + assertTrue(m.processed.toVector == (1 to 5).toVector) && assertTrue(!m.hasPending) + }, + test("the permit hold fires ONCE at the high watermark and releases ONCE at the low") { + val m = ManualLimiter(1_000_000) + (1 to deliveryRateLimitHoldPermitsAboveQueued + 50).foreach(m.limiter.offer) + val holdsAfterCrossing = m.holdCalls + // Drain it down; the bucket is huge, so only the per-tick cap bounds each batch. + while m.core.queuedCount > 0 do m.runPending() + assertTrue(holdsAfterCrossing == 1) && + assertTrue(m.holdCalls == 1) && + assertTrue(m.releaseCalls == 1) && + assertTrue(m.processed.size == deliveryRateLimitHoldPermitsAboveQueued + 50) + }, + test("a REFUSED hold retries on the next offer - suppression must not stick") { + // The runner refuses holds while a counted start-from is still resolving. When the + // refusal's reason has passed, the very next crossing offer must re-assert the hold, + // not believe a stale flag. + val m = ManualLimiter(1_000_000, holdAnswers = Iterator(false, true)) + (1 to deliveryRateLimitHoldPermitsAboveQueued).foreach(m.limiter.offer) + val afterRefusal = m.holdCalls + m.limiter.offer(0) + assertTrue(afterRefusal == 1) && assertTrue(m.holdCalls == 2) + }, + test("a user pause stops the drain; resume re-arms it; the backlog survives both") { + val m = ManualLimiter(100) + (1 to 5).foreach(m.limiter.offer) + m.limiter.pauseDraining() + m.runPending() // the tick that was already armed fires into the pause and must no-op + val processedWhilePaused = m.processed.toVector + m.limiter.resumeDraining() + m.runPending() + assertTrue(processedWhilePaused.isEmpty) && assertTrue(m.processed.toVector == (1 to 5).toVector) + }, + test("a throwing delivery costs exactly itself") { + val m = ManualLimiter(100) + m.throwOn = Set(2) + (1 to 3).foreach(m.limiter.offer) + m.runPending() + assertTrue(m.processed.toVector == Vector(1, 3)) + }, + test("stop clears the backlog for good - re-arming afterwards delivers nothing") { + val m = ManualLimiter(100) + (1 to 5).foreach(m.limiter.offer) + m.limiter.stop() + m.limiter.resumeDraining() + m.runPending() + assertTrue(m.processed.isEmpty) && assertTrue(m.core.queuedCount == 0) + }, + test("an external resume resets the held flag so the next crossing re-asserts it") { + val m = ManualLimiter(1_000_000) + (1 to deliveryRateLimitHoldPermitsAboveQueued).foreach(m.limiter.offer) + m.limiter.onConsumersExternallyResumed() + m.limiter.offer(0) + assertTrue(m.holdCalls == 2) + } + ), + suite("the delivery budget's machinery")( + test("forceQueue closes the unlimited inline shortcut - a budget needs the queue") { + val m = ManualCore(0) + m.core.setForceQueue(true) + val outcome = m.core.offer(1) + assertTrue(!outcome.processNow) && assertTrue(m.core.queuedCount == 1) + }, + test("requeueFront puts the unprocessed tail back AT THE HEAD, order intact, tokens refunded") { + val m = ManualCore(10) + (1 to 10).foreach(m.core.offer) + val batch = m.core.beginDrain() // takes all 10, spends all 10 tokens + m.core.finishDrain() + // Pretend the budget stopped after 3: the remaining 7 go back untouched. + m.core.requeueFront(batch.drop(3)) + // The refund matters: without it those 7 would be double-charged on the next drain. + val next = m.core.beginDrain() + assertTrue(batch.size == 10) && assertTrue(next == (4 to 10).toVector) + }, + test("a stop DURING a drain delivers exactly up to the stop and requeues the rest in order") { + // The wrapper checks the pause flag between items; the process callback itself + // flips it at the third delivery - exactly what the runner's send-site budget does. + var deliveredCount = 0 + val stopAt = 3 + val limiterHolder = scala.collection.mutable.ArrayBuffer[DeliveryRateLimiter[Int]]() + val core = DeliveryRateLimiterCore[Int](nowMs = () => 0L) + val pending = scala.collection.mutable.ArrayBuffer[Runnable]() + val processed = scala.collection.mutable.ArrayBuffer[Int]() + val limiter = DeliveryRateLimiter[Int]( + core = core, + schedule = (_, task) => { pending += task; () }, + process = i => { + processed += i + deliveredCount += 1 + if deliveredCount == stopAt then limiterHolder.head.pauseDraining() + }, + holdPermits = () => true, + releasePermits = () => () + ) + limiterHolder += limiter + core.setRate(100) + (1 to 10).foreach(limiter.offer) + pending.toVector.foreach(_.run()); pending.clear() + + val afterStop = processed.toVector + // Resume: the tail is exactly where it was, and drains in order. + limiter.resumeDraining() + pending.toVector.foreach(_.run()); pending.clear() + + assertTrue(afterStop == Vector(1, 2, 3)) && + assertTrue(processed.toVector == (1 to 10).toVector) + } + ) + ) diff --git a/server/src/test/scala/consumer/session_runner/deliveryRateLimiterWiringTest.scala b/server/src/test/scala/consumer/session_runner/deliveryRateLimiterWiringTest.scala new file mode 100644 index 000000000..4de55d04d --- /dev/null +++ b/server/src/test/scala/consumer/session_runner/deliveryRateLimiterWiringTest.scala @@ -0,0 +1,314 @@ +package consumer.session_runner + +import _root_.consumer.coloring_rules.ColoringRuleChain +import _root_.consumer.deserializer.Deserializer +import _root_.consumer.deserializer.deserializers.UseLatestTopicSchema +import _root_.consumer.message_filter.MessageFilterChain +import _root_.consumer.pause_trigger.ConsumerSessionPauseTriggerChain +import _root_.consumer.session_config.ConsumerSessionConfig +import _root_.consumer.session_target.ConsumerSessionTarget +import _root_.consumer.session_target.consumption_mode.ConsumerSessionTargetConsumptionMode +import _root_.consumer.session_target.consumption_mode.modes.RegularConsumptionMode +import _root_.consumer.session_target.topic_selector.{MultiTopicSelector, TopicSelector} +import _root_.consumer.start_from.EarliestMessage +import _root_.consumer.value_projections.ValueProjectionList +import org.apache.pulsar.client.api.{Consumer, Message as PulsarMessage, Schema} +import org.apache.pulsar.client.impl.{MessageIdImpl, MessageImpl} +import org.apache.pulsar.common.api.proto.MessageMetadata +import zio.test.* + +import java.lang.reflect.{InvocationHandler, Method, Proxy} +import java.nio.ByteBuffer +import java.util.concurrent.atomic.AtomicLong +import java.util.concurrent.{CompletableFuture, ConcurrentLinkedQueue} +import scala.collection.mutable.ArrayBuffer +import scala.jdk.CollectionConverters.* + +/** The rate limiter wired into the REAL delivery path: `ConsumerListener.received` with proxy + * consumers, a hand-cranked drain, and a real runner for the permit arbitration. Only the broker + * is replaced. + * + * What must hold end to end, not just inside the limiter: a limited Deliver reaches nobody until + * the drain and is ACKNOWLEDGED at delivery, not at receipt; DROPS ignore the limit entirely, so + * a counted skip positions at full speed; a delivery that fails at the observer is handed back + * exactly as the unlimited path would; the user's pause outranks the limiter's permit hold; and a + * hold is refused while start-from counting is still resolving. + */ +object deliveryRateLimiterWiringTest extends ZIOSpecDefault: + + private val consumerName = "cs-rate-limit-wiring" + private val p0 = "persistent://public/default/rate-limit-wiring-0" + + /** A consumer recording acks, nacks AND permit calls - the last two are this suite's subject. */ + private final class RecordingConsumer(topicFqn: String): + val acknowledged = ConcurrentLinkedQueue[String]() + val handedBack = ConcurrentLinkedQueue[String]() + val permitCalls = ConcurrentLinkedQueue[String]() + + val consumer: Consumer[Array[Byte]] = + val handler = new InvocationHandler: + override def invoke(proxy: Object, method: Method, args: Array[Object]): Object = + method.getName match + case "getTopic" => topicFqn + case "getConsumerName" => consumerName + case "isConnected" => java.lang.Boolean.TRUE + case "pause" => permitCalls.add("pause"); null + case "resume" => permitCalls.add("resume"); null + case "acknowledgeAsync" => + acknowledged.add(args(0).asInstanceOf[PulsarMessage[Array[Byte]]].getKey) + CompletableFuture.completedFuture(null) + case "negativeAcknowledge" => + handedBack.add(args(0).asInstanceOf[PulsarMessage[Array[Byte]]].getKey) + null + case "equals" => java.lang.Boolean.valueOf(proxy eq args(0)) + case "hashCode" => java.lang.Integer.valueOf(topicFqn.hashCode) + case "toString" => s"proxy-consumer($topicFqn)" + case _ => null + Proxy + .newProxyInstance(getClass.getClassLoader, Array[Class[?]](classOf[Consumer[Array[Byte]]]), handler) + .asInstanceOf[Consumer[Array[Byte]]] + + private def message(key: String, entryId: Long): MessageImpl[Array[Byte]] = messageOn(p0, key, 1000L + entryId, entryId) + + private def messageOn(topicFqn: String, key: String, publishTime: Long, entryId: Long): MessageImpl[Array[Byte]] = + val md = new MessageMetadata() + md.setPublishTime(publishTime) + md.setPartitionKey(key) + val msg = MessageImpl.create[Array[Byte]](md, ByteBuffer.wrap(s"""{"k":"$key"}""".getBytes("UTF-8")), Schema.BYTES, topicFqn) + msg.setMessageId(new MessageIdImpl(1L, entryId, -1)) + msg + + /** A listener recording deliveries, with an optional per-key throw to play the cancelled + * client. Gate OPEN, pass-through ordering - the plain live-tail shape. */ + private def listener(delivered: ConcurrentLinkedQueue[String], throwOn: Set[String] = Set.empty): ConsumerListener = + val l = ConsumerListener(ConsumerSessionTargetMessageHandler(onNext = msg => + if throwOn.contains(msg.getKey) then throw io.grpc.StatusRuntimeException(io.grpc.Status.CANCELLED) + delivered.add(msg.getKey) + () + )) + l.startAcceptingNewMessages() + l + + /** A limiter with a hand-cranked timer, wired the way the runner wires it. */ + private final class ManualWiring(rate: Long, holdPermits: () => Boolean = () => true, releasePermits: () => Unit = () => ()): + private val pending = ArrayBuffer[Runnable]() + val core = DeliveryRateLimiterCore[HeldMessage](nowMs = () => 0L) + val limiter = DeliveryRateLimiter[HeldMessage]( + core = core, + schedule = (_, task) => { pending += task; () }, + process = held => held.listener.deliverNow(held), + holdPermits = holdPermits, + releasePermits = releasePermits + ) + core.setRate(rate) + + def drain(): Unit = + val tasks = pending.toVector + pending.clear() + tasks.foreach(_.run()) + + private def targetRunner(consumerListener: ConsumerListener, consumers: Map[String, Consumer[Array[Byte]]]): ConsumerSessionTargetRunner = + ConsumerSessionTargetRunner( + targetIndex = 0, + targetConfig = ConsumerSessionTarget( + isEnabled = true, + consumptionMode = ConsumerSessionTargetConsumptionMode(mode = RegularConsumptionMode()), + messageValueDeserializer = Deserializer(deserializer = UseLatestTopicSchema()), + topicSelector = TopicSelector(topicSelector = MultiTopicSelector(topicFqns = Vector(p0))), + messageFilterChain = MessageFilterChain.empty, + coloringRuleChain = ColoringRuleChain.empty, + valueProjectionList = ValueProjectionList(isEnabled = false, projections = Vector.empty) + ), + nonPartitionedTopicFqns = Vector(p0), + schemasByTopic = Map.empty, + sessionContextPool = ConsumerSessionContextPool(), + consumers = consumers, + consumerListener = consumerListener, + stats = ConsumerSessionTargetStats(messageProcessed = AtomicLong(0)) + ) + + private def session(target: ConsumerSessionTargetRunner): ConsumerSessionRunner = + ConsumerSessionRunner( + sessionName = "cs-rate-limit-wiring", + sessionConfig = ConsumerSessionConfig( + startFrom = EarliestMessage(), + targets = Vector.empty, + messageFilterChain = MessageFilterChain.empty, + coloringRuleChain = ColoringRuleChain.empty, + pauseTriggerChain = ConsumerSessionPauseTriggerChain.empty, + valueProjectionList = ValueProjectionList(isEnabled = false, projections = Vector.empty) + ), + sessionContextPool = ConsumerSessionContextPool(), + grpcResponseObserver = None, + schemasByTopic = Map.empty, + targets = Map(0 -> target) + ) + + def spec = suite("delivery rate limiter wired into the delivery path")( + test("a limited Deliver reaches nobody until the drain, then arrives in order, ACKED AT DELIVERY") { + val delivered = ConcurrentLinkedQueue[String]() + val recording = RecordingConsumer(p0) + val l = listener(delivered) + val wiring = ManualWiring(rate = 100) + l.deliveryRateLimiter = Some(wiring.limiter) + + (1 to 3).foreach(i => l.received(recording.consumer, message(s"m$i", i.toLong))) + val deliveredBeforeDrain = delivered.asScala.toVector + val ackedBeforeDrain = recording.acknowledged.asScala.toVector + wiring.drain() + + // Nothing moved before the drain - not the handler, and NOT the acks: a message + // acknowledged at receipt would be lost to a session that stopped before its delivery. + assertTrue(deliveredBeforeDrain.isEmpty) && + assertTrue(ackedBeforeDrain.isEmpty) && + assertTrue(delivered.asScala.toVector == Vector("m1", "m2", "m3")) && + assertTrue(recording.acknowledged.asScala.toVector == Vector("m1", "m2", "m3")) + }, + test("DROPS ignore the limit: a counted skip positions at full speed under any rate") { + val delivered = ConcurrentLinkedQueue[String]() + val recording = RecordingConsumer(p0) + val l = listener(delivered) + l.startFromDiscard = StartFromDiscard.shared(2) + val wiring = ManualWiring(rate = 1) // absurdly tight, to prove drops never touch it + l.deliveryRateLimiter = Some(wiring.limiter) + + (1 to 3).foreach(i => l.received(recording.consumer, message(s"m$i", i.toLong))) + + // The two drops were acknowledged IMMEDIATELY, with no drain ever run; only the third + // message - the first the user will see - sits waiting for the limiter. + assertTrue(recording.acknowledged.asScala.toVector == Vector("m1", "m2")) && + assertTrue(delivered.asScala.isEmpty) && + assertTrue(wiring.limiter.queuedCount == 1) + }, + test("a delivery failing at the observer is handed back; the batch behind it still delivers") { + val delivered = ConcurrentLinkedQueue[String]() + val recording = RecordingConsumer(p0) + val l = listener(delivered, throwOn = Set("m2")) + val wiring = ManualWiring(rate = 100) + l.deliveryRateLimiter = Some(wiring.limiter) + + (1 to 3).foreach(i => l.received(recording.consumer, message(s"m$i", i.toLong))) + wiring.drain() + + assertTrue(delivered.asScala.toVector == Vector("m1", "m3")) && + assertTrue(recording.handedBack.asScala.toVector == Vector("m2")) && + assertTrue(recording.acknowledged.asScala.toVector == Vector("m1", "m3")) + }, + test("the permit hold is REFUSED under a closed gate - the user's pause outranks the limiter") { + val recording = RecordingConsumer(p0) + val l = ConsumerListener(ConsumerSessionTargetMessageHandler(onNext = _ => ())) + val target = targetRunner(l, Map(p0 -> recording.consumer)) + + // Gate closed (never opened): the hold and the release must both refuse to touch the + // consumers - a "release" onto a paused session would resume what the user stopped. + target.setPermitHold(true) + target.setPermitHold(false) + val whileClosed = recording.permitCalls.asScala.toVector + + l.startAcceptingNewMessages() + target.setPermitHold(true) + target.setPermitHold(false) + + assertTrue(whileClosed.isEmpty) && + assertTrue(recording.permitCalls.asScala.toVector == Vector("pause", "resume")) + }, + test("the runner refuses a hold while start-from counting is in flight, then grants it") { + val recording = RecordingConsumer(p0) + val l = ConsumerListener(ConsumerSessionTargetMessageHandler(onNext = _ => ())) + l.startAcceptingNewMessages() + val runner = session(targetRunner(l, Map(p0 -> recording.consumer))) + // A real rate, so offers actually QUEUE - at 0 everything passes inline, the watermark + // is never reached, and this whole test measures nothing. + runner.deliveryRateLimiter.core.setRate(1) + + // A counted skip still has 5 to drop: the limiter crossing its watermark must NOT pause + // the consumers - a throttled-quiet stream is indistinguishable from the silent stream + // the merge gives up on. + l.startFromDiscard = StartFromDiscard.shared(5) + (1 to deliveryRateLimitHoldPermitsAboveQueued + 1) + .foreach(i => runner.deliveryRateLimiter.offer(HeldMessage(recording.consumer, message(s"m$i", i.toLong), l))) + val pausesDuringResolution = recording.permitCalls.asScala.count(_ == "pause") + val queuedDuringResolution = runner.deliveryRateLimiter.queuedCount + + // The skip finishes; the very next crossing offer must re-assert the hold. + (1 to 5).foreach(_ => l.startFromDiscard.claim(p0)) + runner.deliveryRateLimiter.offer(HeldMessage(recording.consumer, message("late", 9999L), l)) + + // The vacuity guard first: the backlog really did cross the watermark while refused. + assertTrue(queuedDuringResolution >= deliveryRateLimitHoldPermitsAboveQueued) && + assertTrue(pausesDuringResolution == 0) && + assertTrue(recording.permitCalls.asScala.count(_ == "pause") == 1) + }, + test("TWO TOPICS through one limiter: per-topic order holds, acks land on the right consumer") { + // One target consuming two topics has ONE listener and one limiter; the limit is per + // session, so both topics share the queue. Per-topic relative order must survive it, + // and each delivery must acknowledge on the consumer it arrived through. + val p1 = "persistent://public/default/rate-limit-wiring-1" + val delivered = ConcurrentLinkedQueue[String]() + val c0 = RecordingConsumer(p0) + val c1 = RecordingConsumer(p1) + val l = listener(delivered) + val wiring = ManualWiring(rate = 100) + l.deliveryRateLimiter = Some(wiring.limiter) + + l.received(c0.consumer, messageOn(p0, "a1", 100L, 0L)) + l.received(c1.consumer, messageOn(p1, "b1", 110L, 0L)) + l.received(c0.consumer, messageOn(p0, "a2", 120L, 1L)) + l.received(c1.consumer, messageOn(p1, "b2", 130L, 1L)) + wiring.drain() + + val out = delivered.asScala.toVector + assertTrue(out == Vector("a1", "b1", "a2", "b2")) && + assertTrue(c0.acknowledged.asScala.toVector == Vector("a1", "a2")) && + assertTrue(c1.acknowledged.asScala.toVector == Vector("b1", "b2")) + }, + test("the MERGE'S GLOBAL ORDER survives the limiter, even when arrival order fights it") { + // Two topics, arrival deliberately INVERTED against publish time: all of p0 arrives + // before any of p1. A global skip of 1 must drop the globally-oldest (a1, which arrived + // first but is only oldest by publish time), and everything DELIVERED must come out in + // the merge's key order - b1 (pt 200) before a2 (pt 300) - not in arrival order, and + // the limiter's queue must preserve exactly that decision order through its drain. + val p1 = "persistent://public/default/rate-limit-wiring-merge-1" + val delivered = ConcurrentLinkedQueue[String]() + val c0 = RecordingConsumer(p0) + val c1 = RecordingConsumer(p1) + val l = listener(delivered) + l.startFromOrdering = StartFromOrdering.make[HeldMessage]( + StartFromOrderingPlan.GlobalSkip(1, Vector( + StartFromStream(startFromStreamId(consumerName, p0), EntryPosition(1L, 1L, -1, 1)), + StartFromStream(startFromStreamId(consumerName, p1), EntryPosition(1L, 1L, -1, 1)) + )) + ) + val wiring = ManualWiring(rate = 100) + l.deliveryRateLimiter = Some(wiring.limiter) + + l.received(c0.consumer, messageOn(p0, "a1", 100L, 0L)) + l.received(c0.consumer, messageOn(p0, "a2", 300L, 1L)) + l.received(c1.consumer, messageOn(p1, "b1", 200L, 0L)) + l.received(c1.consumer, messageOn(p1, "b2", 400L, 1L)) + wiring.drain() + + // a1 was DROPPED - acknowledged immediately, never delivered, never rate limited. + assertTrue(delivered.asScala.toVector == Vector("b1", "a2", "b2")) && + assertTrue(c0.acknowledged.asScala.headOption.contains("a1")) && + assertTrue(!delivered.asScala.toVector.contains("a1")) + }, + test("resume installs the SHARED limiter on the listener, with the requested rate") { + val delivered = ConcurrentLinkedQueue[String]() + val recording = RecordingConsumer(p0) + val l = listener(delivered) + val runner = session(targetRunner(l, Map(p0 -> recording.consumer))) + + val before = l.deliveryRateLimiter + + runner.resume(new io.grpc.stub.StreamObserver[com.tools.teal.pulsar.ui.api.v1.consumer.ResumeResponse] { + override def onNext(value: com.tools.teal.pulsar.ui.api.v1.consumer.ResumeResponse): Unit = () + override def onError(t: Throwable): Unit = () + override def onCompleted(): Unit = () + }, isDebug = false, includeConsumerStats = true, maxMessagesPerSecond = 123) + + assertTrue(before.isEmpty) && + assertTrue(l.deliveryRateLimiter.contains(runner.deliveryRateLimiter)) && + assertTrue(runner.deliveryRateLimiter.core.rate == 123L) + } + ) diff --git a/server/src/test/scala/consumer/session_runner/globalStartFromTest.scala b/server/src/test/scala/consumer/session_runner/globalStartFromTest.scala new file mode 100644 index 000000000..5ff019562 --- /dev/null +++ b/server/src/test/scala/consumer/session_runner/globalStartFromTest.scala @@ -0,0 +1,1098 @@ +package consumer.session_runner + +import org.apache.pulsar.client.impl.{BatchMessageIdImpl, MessageIdImpl} +import zio.test.* + +/** The two GLOBAL start-from contracts: "skip the first n" and "the latest n" are counted over ALL + * physical topics of a session, ordered by publish time - not per partition. + * + * Contract change context: both modes used to be per physical topic. "Latest 2" on a 3-partition + * topic delivered SIX messages (the last 2 of each partition), and "skip 5" dropped whichever 5 + * arrived first out of the broker's arbitrary interleaving. Both are now defined over the merged + * stream, ordered by publish time. + * + * The two algorithms differ, and the memory profile is the reason. Skip-N is a streaming k-way + * merge holding ONE message per topic, because n has deliberately no cap and buffering n messages + * would let a typed number exhaust the heap. Latest-N is a bounded top-n heap of exactly n, because + * the last n cannot be known until every partition has been read to its end. + * + * Everything here is driven with plain values through the pure state machines: no broker, and no + * mock. + */ +object globalStartFromTest extends ZIOSpecDefault: + + private val p0 = "persistent://public/default/t-partition-0" + private val p1 = "persistent://public/default/t-partition-1" + private val p2 = "persistent://public/default/t-partition-2" + + /** One delivered message as the ordering layers see it. `value` is what the user would read. */ + private final case class Arrival(streamId: String, key: MessageOrderKey, atBacklogEnd: Boolean, value: String) + + private def at(publishTime: Long, topicFqn: String, entryId: Long, batchIndex: Int = -1): MessageOrderKey = + MessageOrderKey(publishTime = publishTime, topicFqn = topicFqn, ledgerId = 1L, entryId = entryId, batchIndex = batchIndex) + + /** A partition's whole backlog: entry ids run 0..k-1 and the LAST message is flagged as the end + * of the pre-existing backlog, exactly as the runtime flags it. + */ + private def backlog(topicFqn: String, messages: (Long, String)*): Vector[Arrival] = + messages.toVector.zipWithIndex.map { case ((publishTime, value), entryId) => + Arrival(topicFqn, at(publishTime, topicFqn, entryId), atBacklogEnd = entryId == messages.size - 1, value) + } + + private final case class Run( + dropped: Vector[String], + delivered: Vector[String], + maxHeld: Int, + leftHeld: Int + ) + + private def drive[M <: StartFromMerge[String]](merge: M, arrivals: Vector[Arrival]): Run = + var maxHeld = 0 + val out = arrivals.flatMap { arrival => + val resolved = merge.offer(arrival.streamId, arrival.key, arrival.atBacklogEnd, arrival.value) + maxHeld = maxHeld max merge.heldCount + resolved + } + Run( + dropped = out.collect { case (value, StartFromOutcome.Drop) => value }, + delivered = out.collect { case (value, StartFromOutcome.Deliver) => value }, + maxHeld = maxHeld, + leftHeld = merge.heldCount + ) + + private def skip(n: Long, streams: Vector[String], arrivals: Vector[Arrival], maxHeld: Int = startFromMergeMaxHeld): Run = + drive(GlobalSkipMerge[String](streams, drainedAtStart = Set.empty, discard = StartFromDiscard.shared(n), maxHeld = maxHeld), arrivals) + + /** Round-robin across partitions - the shape the broker actually delivers a partitioned topic + * in, and the shape under which "first n delivered" and "globally first n" differ. + */ + private def interleave(streams: Vector[Arrival]*): Vector[Arrival] = + val rounds = streams.toVector + val longest = rounds.map(_.size).maxOption.getOrElse(0) + (0 until longest).toVector.flatMap(i => rounds.flatMap(stream => stream.lift(i))) + + private val orderSuite = suite("the total order")( + test("publish time decides first") { + val earlier = at(100, p2, entryId = 9) + val later = at(101, p0, entryId = 0) + assertTrue(MessageOrderKey.ordering.lt(earlier, later)) + }, + test("a publish-time tie is broken by topic name, then by position in the log") { + // Ties are the COMMON case: a fast producer stamps many messages with one millisecond. + val a = at(100, p0, entryId = 5) + val b = at(100, p1, entryId = 0) + val c = at(100, p1, entryId = 1) + assertTrue(MessageOrderKey.ordering.lt(a, b), MessageOrderKey.ordering.lt(b, c)) + }, + test("messages inside one batched entry order by batch index") { + val first = at(100, p0, entryId = 3, batchIndex = 0) + val second = at(100, p0, entryId = 3, batchIndex = 1) + assertTrue(MessageOrderKey.ordering.lt(first, second)) + }, + test("an unbatched message sorts before batch index 0 of the same entry, never equal to it") { + val unbatched = at(100, p0, entryId = 3) + val batched = at(100, p0, entryId = 3, batchIndex = 0) + assertTrue(MessageOrderKey.ordering.lt(unbatched, batched), unbatched != batched) + }, + test("the order is TOTAL - two different shuffles of the same messages sort identically") { + // Without a full tiebreak this is exactly what flakes: the result would depend on the + // order the broker happened to deliver in. + val keys = Vector( + at(100, p0, 0), at(100, p1, 0), at(100, p1, 1), at(100, p2, 0), + at(101, p0, 1), at(101, p0, 2), at(99, p2, 7) + ) + val oneWay = scala.util.Random(1).shuffle(keys).sorted(MessageOrderKey.ordering) + val otherWay = scala.util.Random(2).shuffle(keys).sorted(MessageOrderKey.ordering) + assertTrue(oneWay == otherWay, oneWay.distinct.size == keys.size) + } + ) + + private val backlogEndSuite = suite("finding the end of the pre-existing backlog")( + test("a message before the last entry is still backlog") { + assertTrue(!isPastBacklogEnd(EntryPosition(1, 4, -1, 1), EntryPosition(1, 9, -1, 1))) + }, + test("the last entry of the log ends the backlog") { + assertTrue(isPastBacklogEnd(EntryPosition(1, 9, -1, 1), EntryPosition(1, 9, -1, 1))) + }, + test("a message published after the session started is past the end") { + assertTrue(isPastBacklogEnd(EntryPosition(1, 12, -1, 1), EntryPosition(1, 9, -1, 1))) + }, + test("a later ledger is past the end even with a smaller entry id") { + // Entry ids restart at 0 in each ledger, so comparing entry ids alone would declare a + // fresh ledger to be backlog forever and stall the merge. + assertTrue(isPastBacklogEnd(EntryPosition(2, 0, -1, 1), EntryPosition(1, 9, -1, 1))) + }, + test("a batched end is reached only at its own batch index, not at the start of its entry") { + val end = EntryPosition(1, 9, 4, 5) + assertTrue( + !isPastBacklogEnd(EntryPosition(1, 9, 0, 5), end), + !isPastBacklogEnd(EntryPosition(1, 9, 3, 5), end), + isPastBacklogEnd(EntryPosition(1, 9, 4, 5), end) + ) + }, + test("an end reported without a batch index still waits for the whole final batch") { + // getLastMessageId may answer with a bare entry id even when that entry is a batch; + // ending at batch index 0 would throw away the rest of the newest batch. + val end = EntryPosition(1, 9, -1, 1) + assertTrue( + !isPastBacklogEnd(EntryPosition(1, 9, 0, 10), end), + !isPastBacklogEnd(EntryPosition(1, 9, 8, 10), end), + isPastBacklogEnd(EntryPosition(1, 9, 9, 10), end) + ) + }, + test("MessageId.earliest resolves to EXACTLY the canonical empty position") { + // VERIFIED against Pulsar 3.2.1: an empty topic answers getLastMessageIds with + // MessageId.earliest, whose ledger and entry are -1 and whose batch size is 0. Mapping + // that to (-1, -1, -1, 1) left it merely NEAR the empty position and not equal to it - + // and "nothing retained" is recognised by equality, so an empty partition was waited on + // forever. A live 3-partition "latest 2" with one empty partition delivered ZERO. + val fromEarliest = EntryPosition.of(org.apache.pulsar.client.api.MessageId.earliest) + val fromNegativeId = EntryPosition.of(new MessageIdImpl(-1L, -1L, -1)) + assertTrue(fromEarliest == EntryPosition.empty, fromNegativeId == EntryPosition.empty) ?? + s"earliest -> $fromEarliest, (-1,-1) -> $fromNegativeId, empty is ${EntryPosition.empty}" + }, + test("an empty topic is drained by anything at all") { + assertTrue(isPastBacklogEnd(EntryPosition(0, 0, -1, 1), EntryPosition.empty)) + }, + test("a plain message id becomes an unbatched position") { + val position = EntryPosition.of(new MessageIdImpl(7L, 3L, 0)) + assertTrue(position == EntryPosition(7L, 3L, -1, 1)) + }, + test("a batched message id keeps its index and its batch size") { + val position = EntryPosition.of(new BatchMessageIdImpl(7L, 3L, 0, 2, 10, null)) + assertTrue(position == EntryPosition(7L, 3L, 2, 10)) + } + ) + + /** Three partitions whose publish times interleave, so the global order is NOT the per-partition + * order and not the delivery order either. Globally, by publish time: + * a1 a2 b1 c1 a3 b2 c2 b3 c3 + */ + private val threePartitions = Vector( + backlog(p0, 10L -> "a1", 20L -> "a2", 50L -> "a3"), + backlog(p1, 30L -> "b1", 60L -> "b2", 80L -> "b3"), + backlog(p2, 40L -> "c1", 70L -> "c2", 90L -> "c3") + ) + + private val globalOrder = Vector("a1", "a2", "b1", "c1", "a3", "b2", "c2", "b3", "c3") + + private val skipSuite = suite("skip the globally-first n")( + test("drops the globally-first n by publish time, not the first n the broker delivered") { + // THE contract change. Round-robin delivery offers a1 b1 c1 a2 b2 c2 ..., so the old + // arrival-order counter dropped a1 b1 c1 - two of which are NOT among the three oldest. + val run = skip(3, Vector(p0, p1, p2), interleave(threePartitions*)) + assertTrue( + run.dropped == globalOrder.take(3), + run.delivered == globalOrder.drop(3) + ) ?? s"dropped=${run.dropped} delivered=${run.delivered}" + }, + test("the count is exactly n, whatever the interleaving") { + val counts = Vector(0, 1, 4, 8, 9).map(n => n -> skip(n, Vector(p0, p1, p2), interleave(threePartitions*))) + assertTrue(counts.forall((n, run) => run.dropped.size == n && run.delivered.size == 9 - n)) ?? + s"${counts.map((n, run) => s"n=$n dropped=${run.dropped.size}")}" + }, + test("the messages left are exactly the globally-latest ones, in order") { + val run = skip(6, Vector(p0, p1, p2), interleave(threePartitions*)) + assertTrue(run.delivered == Vector("c2", "b3", "c3"), run.dropped == globalOrder.take(6)) + }, + test("the messages HELD at the cut are released in global order") { + // Scoped deliberately: this is a claim about the batch the merge was still holding when + // the budget ran out, which it sorts before releasing. It is NOT a claim about + // everything that arrives afterwards - see the post-cut suite below. + // + // The cut lands the moment the LAST unit is claimed (dropping a1 then a2), so the + // sorted batch is b1 c1 - what was held at that instant - and everything after passes + // through in ARRIVAL order (b2 c2 a3 b3 c3). The old code kept merging until a later + // claim ANSWERED no, which happened to widen the sorted batch; that wait is exactly + // what held boundary messages hostage when the Nth drop emptied a waited-for stream. + val run = skip(2, Vector(p0, p1, p2), interleave(threePartitions*)) + assertTrue( + run.dropped == globalOrder.take(2), + run.delivered == Vector("b1", "c1") ++ Vector("b2", "c2", "a3", "b3", "c3") + ) ?? s"dropped=${run.dropped} delivered=${run.delivered}" + }, + test("skipping more than the session holds delivers nothing and drops all of it") { + val run = skip(100, Vector(p0, p1, p2), interleave(threePartitions*)) + assertTrue(run.delivered.isEmpty, run.dropped.size == 9) + }, + test("HOLDS AT MOST ONE MESSAGE PER TOPIC - never n") { + // The memory contract. n has no cap: a merge that buffered n to sort it would let a + // typed number exhaust the heap. + val big = Vector( + backlog(p0, (1L to 400L).map(i => (i * 2, s"a$i"))*), + backlog(p1, (1L to 400L).map(i => (i * 2 + 1, s"b$i"))*) + ) + val run = skip(700, Vector(p0, p1), interleave(big*)) + assertTrue(run.maxHeld <= 2, run.dropped.size == 700, run.leftHeld == 0) ?? + s"held up to ${run.maxHeld} messages for a skip of 700" + }, + test("a topic that has drained its backlog does not stall the merge") { + // p1 holds one old message and nothing else. Without the +infinity rule the merge would + // wait forever for a second p1 head and the session would show nothing. + val arrivals = interleave( + backlog(p0, 10L -> "a1", 20L -> "a2", 30L -> "a3"), + backlog(p1, 5L -> "b1") + ) + val run = skip(2, Vector(p0, p1), arrivals) + assertTrue(run.dropped == Vector("b1", "a1"), run.delivered == Vector("a2", "a3"), run.leftHeld == 0) + }, + test("a topic that was already empty at session start never stalls the merge") { + val merge = GlobalSkipMerge[String](Vector(p0, p1), drainedAtStart = Set(p1), StartFromDiscard.shared(1)) + val run = drive(merge, backlog(p0, 10L -> "a1", 20L -> "a2")) + assertTrue(run.dropped == Vector("a1"), run.delivered == Vector("a2")) + }, + test("the same messages arriving in a different interleaving give the same answer") { + // Determinism: without the full tiebreak the answer would follow the delivery order. + val tied = Vector( + backlog(p0, 100L -> "a1", 100L -> "a2"), + backlog(p1, 100L -> "b1", 100L -> "b2"), + backlog(p2, 100L -> "c1", 100L -> "c2") + ) + val roundRobin = skip(3, Vector(p0, p1, p2), interleave(tied*)) + val oneAtATime = skip(3, Vector(p0, p1, p2), tied.flatten) + assertTrue(roundRobin.dropped == oneAtATime.dropped, roundRobin.dropped == Vector("a1", "a2", "b1")) ?? + s"roundRobin=${roundRobin.dropped} oneAtATime=${oneAtATime.dropped}" + }, + test("a single stream is exact without holding anything after the skip") { + val run = skip(2, Vector(p0), backlog(p0, 10L -> "a1", 20L -> "a2", 30L -> "a3")) + assertTrue(run.dropped == Vector("a1", "a2"), run.delivered == Vector("a3"), run.leftHeld == 0) + }, + test("a silent stream cannot make the merge grow without bound - the hot SOURCE is paused, not bounced") { + // p1 never delivers and never reaches its backlog end, so the merge can never know + // whether p1 holds something older than p0's head. At the watermark it marks p0 for + // PAUSE: in production the consumer stops delivering, so growth stops at the watermark + // plus the in-flight overshoot. Nothing is handed back - a declined message's + // redelivery races its own successors, which was the order bug - and nothing is + // decided while a stream that could still contribute has not spoken. + val merge = GlobalSkipMerge[String](Vector(p0, p1), Set.empty, StartFromDiscard.shared(5), maxHeld = 4) + (1L to 4L).foreach(i => merge.offer(p0, at(i, p0, i - 1), atBacklogEnd = false, s"a$i")) + val pausedAtCap = merge.desiredPausedStreams + // The pause is asynchronous, so a few in-flight messages still land: ACCEPTED, never bounced. + val inFlight = merge.offer(p0, at(5, p0, 4), atBacklogEnd = false, "a5") + assertTrue( + pausedAtCap == Set(p0), + inFlight.isEmpty, + merge.heldCount == 5, + merge.desiredPausedStreams == Set(p0), + !merge.desiredPausedStreams.contains(p1) // the BLIND stream is never paused + ) ?? s"pausedAtCap=$pausedAtCap inFlight=$inFlight held=${merge.heldCount}" + }, + test("AT THE MEMORY CAP THE MERGE MUST NOT GUESS which messages to drop") { + // The counterexample the cap used to answer wrongly: with n = 1 and room for two held + // messages, p0 delivers 100 and 200 while p1 is delayed. Advancing at the cap dropped + // p0/100 - and then p1 answered with 1, which was GLOBALLY EARLIEST and should have + // been the one message dropped, but was delivered instead. + // + // The count was exact either way; the SET was not, and the set is the contract. + val arrivals = Vector( + Arrival(p0, at(100, p0, 0), atBacklogEnd = false, "a1"), + Arrival(p0, at(200, p0, 1), atBacklogEnd = false, "a2"), + Arrival(p1, at(1, p1, 0), atBacklogEnd = true, "b1") + ) + val run = skip(1, Vector(p0, p1), arrivals, maxHeld = 2) + assertTrue( + run.dropped == Vector("b1"), + run.delivered == Vector("a1", "a2") + ) ?? s"dropped=${run.dropped} delivered=${run.delivered}" + }, + test("arrivals past the watermark are ACCEPTED - the pause has a bounded overshoot, never a bounce") { + // The pause takes effect asynchronously, so whatever was already in flight still + // lands. Accepting it is safe (it is in its stream's append order) and refusing it + // was the order bug. When the delayed stream finally speaks, everything held resolves + // exactly as if the stream had never been slow at all. + val merge = GlobalSkipMerge[String](Vector(p0, p1), Set.empty, StartFromDiscard.shared(1), maxHeld = 2) + merge.offer(p0, at(100, p0, 0), atBacklogEnd = false, "a1") + merge.offer(p0, at(200, p0, 1), atBacklogEnd = false, "a2") + val pastWatermark = merge.offer(p0, at(300, p0, 2), atBacklogEnd = false, "a3") + val pausedBefore = merge.desiredPausedStreams + val afterDelayed = merge.offer(p1, at(1, p1, 0), atBacklogEnd = true, "b1") + assertTrue( + pastWatermark.isEmpty, // accepted and held, not bounced + pausedBefore == Set(p0), + afterDelayed == Vector( + "b1" -> StartFromOutcome.Drop, + "a1" -> StartFromOutcome.Deliver, + "a2" -> StartFromOutcome.Deliver, + "a3" -> StartFromOutcome.Deliver + ), + merge.desiredPausedStreams.isEmpty // the cut releases the pause + ) ?? s"pastWatermark=$pastWatermark pausedBefore=$pausedBefore afterDelayed=$afterDelayed" + }, + test("the stream the merge is BLIND on is never paused - it is what unblocks the merge") { + // Pausing everything at the cap would deadlock: the merge is waiting for exactly this + // stream. A blind stream has an empty queue, so no watermark can ever mark it. + val merge = GlobalSkipMerge[String](Vector(p0, p1), Set.empty, StartFromDiscard.shared(1), maxHeld = 1) + merge.offer(p0, at(100, p0, 0), atBacklogEnd = false, "a1") + val fromBlind = merge.offer(p1, at(50, p1, 0), atBacklogEnd = true, "b1") + assertTrue(fromBlind == Vector("b1" -> StartFromOutcome.Drop, "a1" -> StartFromOutcome.Deliver)) ?? + s"the blind stream was refused at the cap and the merge could never advance: $fromBlind" + }, + test("progress is read off the merge's own budget") { + val discard = StartFromDiscard.shared(4) + val merge = GlobalSkipMerge[String](Vector(p0, p1), Set.empty, discard) + drive(merge, interleave(backlog(p0, 10L -> "a1", 20L -> "a2"), backlog(p1, 30L -> "b1", 40L -> "b2"))) + assertTrue(merge.progressDiscard.map(_.total) == Some(4L), merge.progressDiscard.map(_.remaining) == Some(0L)) + }, + test("n = 0 delivers everything and holds nothing") { + val run = skip(0, Vector(p0, p1, p2), interleave(threePartitions*)) + assertTrue(run.dropped.isEmpty, run.delivered.size == 9, run.maxHeld == 0) + } + ) + + /** A topic described from its END: element 0 is the LAST entry, as `(publish time, messages in + * that entry)`. Entry ids are `"#"`, so an assertion names the topic and how far + * back the walk went. + */ + private def entries(spec: Map[String, Vector[(Long, Int)]]): String => Long => Option[LogEntry[String]] = + topicFqn => + k => + val log = spec.getOrElse(topicFqn, Vector.empty) + Option.when(k >= 1 && k <= log.size) { + val (publishTime, messages) = log(k.toInt - 1) + LogEntry(s"$topicFqn#$k", publishTime, messages) + } + + /** One unbatched entry per message, newest first. */ + private def unbatched(publishTimes: Long*): Vector[(Long, Int)] = publishTimes.toVector.map(_ -> 1) + + /** The entry order the walk uses on the `entries` labels: `#k` is the k-th entry from the END, so + * a larger ordinal is strictly OLDER. The production comparator is `MessageIdImpl.compareTo`; + * here the label carries the same information. */ + private def olderByHashOrdinal(a: String, b: String): Boolean = a.split("#").last.toInt > b.split("#").last.toInt + + /** The three partitions above as stored logs - newest entry first. */ + private val threeLogs = Map( + p0 -> unbatched(50L, 20L, 10L), + p1 -> unbatched(80L, 60L, 30L), + p2 -> unbatched(90L, 70L, 40L) + ) + + private def cut(n: Long, spec: Map[String, Vector[(Long, Int)]]): Map[String, LatestNSeek[String]] = + resolveLatestN(n, spec.keys.toVector.sorted, entries(spec), olderByHashOrdinal) + + /** THE LAST N, RESOLVED FROM ENTRY METADATA AND NOT FROM DELIVERED MESSAGES. + * + * Contract change context: "latest 2" on a 3-partition topic used to deliver SIX messages (the + * last 2 of each partition). It was then narrowed to the globally-last two by a top-n HEAP of + * delivered messages - which made the session's memory a number the user typed, let live + * traffic evict the historical tail it was supposed to be picking from, and showed nothing at + * all until every partition had drained. + * + * The cut is now computed BEFORE anything is delivered, by one merged backward walk over entry + * metadata: take whichever topic's current entry was published latest, count its messages, step + * that topic back one entry, repeat until n are accounted for. Every consumer then starts in + * the right place and simply streams. Memory is one cursor per topic; nothing is buffered. + */ + private val latestSuite = suite("resolve the cut for the globally-last n")( + test("cuts exactly n across the whole session, not n per partition") { + // THE contract change. Only the two partitions holding the newest messages contribute; + // the third is ANCHORED at its inspected tail - that entry is delivered and dropped - + // so it shows no history and still keeps anything published after the inspection. + assertTrue( + cut(2, threeLogs) == Map( + p0 -> LatestNSeek.FromEntry(s"$p0#1", 1L), + p1 -> LatestNSeek.FromEntry(s"$p1#1", 0L), + p2 -> LatestNSeek.FromEntry(s"$p2#1", 0L) + ) + ) ?? s"${cut(2, threeLogs)}" + }, + test("the cut is the global tail by publish time, spread over whichever partitions hold it") { + // b2(60) c2(70) b3(80) c3(90) - so p1 and p2 each go two entries back, p0 none. + assertTrue( + cut(4, threeLogs) == Map( + p0 -> LatestNSeek.FromEntry(s"$p0#1", 1L), + p1 -> LatestNSeek.FromEntry(s"$p1#2", 0L), + p2 -> LatestNSeek.FromEntry(s"$p2#2", 0L) + ) + ) ?? s"${cut(4, threeLogs)}" + }, + test("a partition holding only older messages is ANCHORED at its inspected tail") { + // Never at EARLIEST - that would show its whole log - and never at seek-time LATEST + // either: the seek happens after the inspection, so "latest" would silently jump any + // message published in between, where every CONTRIBUTING partition kept its concurrent + // appends. The anchor is the tail the walk actually saw; that one entry is delivered + // and dropped (a per-topic head-drop), which is exactly "everything after it". + assertTrue( + cut(1, threeLogs)(p0) == LatestNSeek.FromEntry(s"$p0#1", 1L), + cut(1, threeLogs)(p1) == LatestNSeek.FromEntry(s"$p1#1", 1L) + ) + }, + test("a non-contributing topic's BATCHED tail is anchored and dropped WHOLE") { + // The anchor drop is counted in messages, so a 10-message batch tail costs a + // 10-message head-drop - the seek can only land on the entry boundary. + val spec = Map(p0 -> Vector(100L -> 10), p1 -> unbatched(200L)) + assertTrue( + cut(1, spec) == Map(p0 -> LatestNSeek.FromEntry(s"$p0#1", 10L), p1 -> LatestNSeek.FromEntry(s"$p1#1", 0L)) + ) ?? s"${cut(1, spec)}" + }, + test("the cut reaches back into a partition only as far as it has to") { + // 5 asked for: c3(90) b3(80) c2(70) b2(60) a3(50) - p0 contributes its newest entry. + assertTrue( + cut(5, threeLogs) == Map( + p0 -> LatestNSeek.FromEntry(s"$p0#1", 0L), + p1 -> LatestNSeek.FromEntry(s"$p1#2", 0L), + p2 -> LatestNSeek.FromEntry(s"$p2#2", 0L) + ) + ) ?? s"${cut(5, threeLogs)}" + }, + test("HOLDS ONE ENTRY PER TOPIC - the answer never grows with n") { + // The memory contract, and the whole reason the heap is gone. 900 messages over three + // partitions, any n: the answer is three positions. + val big = Map( + p0 -> unbatched((1L to 300L).reverse.map(_ * 3)*), + p1 -> unbatched((1L to 300L).reverse.map(_ * 3 + 1)*), + p2 -> unbatched((1L to 300L).reverse.map(_ * 3 + 2)*) + ) + assertTrue(cut(10, big).size == 3, cut(500, big).size == 3) + }, + test("the walk asks the broker O(n / batch size) times, not once per partition per message") { + // Strictly cheaper than the per-topic walks this replaced, which cost that PER TOPIC. + var lookups = 0 + val counting: String => Long => Option[LogEntry[String]] = topicFqn => + k => + lookups += 1 + entries(threeLogs)(topicFqn)(k) + resolveLatestN(2, Vector(p0, p1, p2), counting, olderByHashOrdinal) + // Three to prime the cursors, then one step after taking c3. + assertTrue(lookups == 4) ?? s"$lookups lookups" + }, + test("a batched entry contributes all its messages, and the overshoot is discarded at the head") { + // A seek can only land on an entry boundary, so the only exact way to reach the n-th + // message inside a batch is to seek to its entry and drop what precedes it. + val batched = Map(p0 -> Vector(100L -> 10, 90L -> 10)) + assertTrue(cut(3, batched) == Map(p0 -> LatestNSeek.FromEntry(s"$p0#1", 7L))) ?? s"${cut(3, batched)}" + }, + test("the overshoot lands on the topic the walk STOPPED on, and on no other") { + // p2 contributes two unbatched entries (90, 85); the walk then takes p1's newest entry, + // a batch of 10, for the single message still wanted - so 9 are dropped from p1's head + // and NOTHING from p2's, whose two entries were both wanted in full. + val mixed = Map(p1 -> Vector(80L -> 10), p2 -> unbatched(90L, 85L, 70L)) + assertTrue( + cut(3, mixed) == Map(p1 -> LatestNSeek.FromEntry(s"$p1#1", 9L), p2 -> LatestNSeek.FromEntry(s"$p2#2", 0L)) + ) ?? s"${cut(3, mixed)}" + }, + test("a session holding fewer than n messages shows all of it") { + val small = Map(p0 -> unbatched(10L), p1 -> unbatched(20L)) + assertTrue(cut(50, small) == Map(p0 -> LatestNSeek.Everything, p1 -> LatestNSeek.Everything)) + }, + test("an empty partition contributes nothing and cannot hold the cut back") { + // The heap had to wait for every partition to reach its recorded end before it could + // release anything, so an empty or stalled partition showed the user zero messages. + // A partition with no entries simply answers nothing here. + val withEmpty = Map(p0 -> unbatched(30L, 20L, 10L), p1 -> Vector.empty[(Long, Int)]) + // The empty partition maps to EVERYTHING: it held nothing when inspected, so whatever + // it holds at seek time arrived after the inspection - live traffic the session shows. + assertTrue(cut(2, withEmpty) == Map(p0 -> LatestNSeek.FromEntry(s"$p0#2", 0L), p1 -> LatestNSeek.Everything)) + }, + test("a publish-time tie is cut deterministically, by topic name") { + // The same tiebreak [[MessageOrderKey]] uses, so an entry-level cut and a message-level + // order cannot disagree. + val tied = Map(p0 -> unbatched(100L, 100L), p1 -> unbatched(100L, 100L)) + assertTrue( + cut(2, tied) == Map(p0 -> LatestNSeek.FromEntry(s"$p0#1", 1L), p1 -> LatestNSeek.FromEntry(s"$p1#2", 0L)) + ) ?? s"${cut(2, tied)}" + }, + test("a clamping broker is detected within a bounded number of steps, not by the first repeat") { + // `examineMessage` CLAMPS rather than fails on the earliest side; a Pulsar version that + // clamped on the latest side too would otherwise make the running total grow forever. + // + // CHANGED EXPECTATION, deliberately: the old code concluded "exhausted" the instant an + // entry id repeated and stopped in exactly 3 lookups. That same first repeat is what a + // SINGLE concurrent append also produces (the moving anchor - see `movingAnchorSuite`), + // so reading it as exhaustion was the whole-backlog bug. A clamp is now told apart from + // an append by ONE VERIFICATION LOOKUP at the k that produced the last accepted entry - + // a clamped end never moves, a grown end answers a newer entry - so it still resolves + // to `Everything`, one lookup later than the old first-repeat guard. + var lookups = 0 + val clamping: String => Long => Option[LogEntry[String]] = topicFqn => + k => + lookups += 1 + val clamped = k.min(2).max(1) + Some(LogEntry(s"$topicFqn#$clamped", 100L - clamped, 3)) + val resolved = resolveLatestN(100, Vector(p0), clamping, olderByHashOrdinal) + assertTrue( + resolved == Map(p0 -> LatestNSeek.Everything), + lookups > 3, + lookups <= maxLatestNReanchorSteps + 4 + ) ?? s"resolved=$resolved lookups=$lookups (bound $maxLatestNReanchorSteps)" + }, + test("a walk that outlives its TIME budget fails with guidance instead of grinding on") { + // N bounds the request; TIME bounds the cost - an unbatched topic pays one broker + // lookup per entry, and n alone cannot tell a thousand lookups from ten million. + var clock = 0L + val slowLog = Map(p0 -> unbatched((1L to 100L).reverse.map(_ * 10)*)) + val slow: String => Long => Option[LogEntry[String]] = topicFqn => + k => + clock += 1_000L // each lookup costs a second + entries(slowLog)(topicFqn)(k) + val outcome = scala.util.Try( + resolveLatestN(50, Vector(p0), slow, olderByHashOrdinal, resolveBudgetMs = 5_000L, nowMs = () => clock) + ) + assertTrue( + outcome.isFailure, + outcome.failed.toOption.exists(_.isInstanceOf[StartFromUnresolvableException]), + outcome.failed.toOption.exists(_.getMessage.contains("budget")) + ) ?? s"$outcome" + }, + test("a walk inside its time budget is untouched by it") { + var clock = 0L + val counting: String => Long => Option[LogEntry[String]] = topicFqn => + k => + clock += 10L + entries(threeLogs)(topicFqn)(k) + val resolved = resolveLatestN(2, Vector(p0, p1, p2), counting, olderByHashOrdinal, resolveBudgetMs = 5_000L, nowMs = () => clock) + assertTrue(resolved.values.count { case LatestNSeek.FromEntry(_, _) => true; case _ => false } == 3) + }, + test("n = 0 asks the broker nothing and positions everything at the live tail") { + var lookups = 0 + val counting: String => Long => Option[LogEntry[String]] = topicFqn => + k => + lookups += 1 + entries(threeLogs)(topicFqn)(k) + val resolved = resolveLatestN(0, Vector(p0, p1), counting, olderByHashOrdinal) + assertTrue(resolved == Map(p0 -> LatestNSeek.Nothing, p1 -> LatestNSeek.Nothing), lookups == 0) + }, + test("one topic named twice is walked once") { + // A session's topic vector is the concatenation of every enabled target's resolved + // topics, and two targets may legitimately select the same topic. + var lookups = 0 + val counting: String => Long => Option[LogEntry[String]] = topicFqn => + k => + lookups += 1 + entries(threeLogs)(topicFqn)(k) + val resolved = resolveLatestN(1, Vector(p0, p0, p0), counting, olderByHashOrdinal) + assertTrue(resolved == Map(p0 -> LatestNSeek.FromEntry(s"$p0#1", 0L)), lookups == 1) ?? s"lookups=$lookups" + } + ) + + /** A log with STABLE absolute entry ids `e1..eN` (e1 oldest), read the way + * `examineMessage(topic, "latest", k)` reads it: the k-th entry counted back from the CURRENT + * end. `appendsBefore` names the lookup ordinals at which a producer appends one entry FIRST - + * i.e. the anchor the walk counts back from moves forward under it, exactly as it does when a + * real producer writes during session creation. A frozen-log lambda cannot express this, which + * is why every existing latest-n test missed the moving anchor. + */ + private def growingLog(startEntries: Long, appendsBefore: Map[Int, Long] = Map.empty): (String => Long => Option[LogEntry[String]], () => Int) = + var total = startEntries + var lookups = 0 + val lambda: String => Long => Option[LogEntry[String]] = _ => + k => + lookups += 1 + total += appendsBefore.getOrElse(lookups, 0L) + val idx = total - k + 1 // 1-based index from the start; 1 is the oldest retained entry + Option.when(idx >= 1) { LogEntry(s"e$idx", idx * 10, 1) } + (lambda, () => lookups) + + /** Older = smaller absolute index, which is how `MessageIdImpl.compareTo` orders real entry ids + * (ledger, then entry). Shared by the growing-log tests so the walk can tell a strictly-older + * answer from a re-anchored one. */ + private def olderByAbsoluteId(a: String, b: String): Boolean = a.drop(1).toLong < b.drop(1).toLong + + private val movingAnchorSuite = suite("the backward walk survives a log that grows under it")( + test("a SINGLE append per gap is not misread as exhaustion - the whole backlog bug") { + // e1..e10 at start; the last 3 are e8, e9, e10. One entry is appended just before the + // SECOND lookup, so `latest, 2` answers e10 AGAIN (the anchor moved forward by one). + // The old guard read that repeated id as "exhausted", fell back to EARLIEST, and a + // request for the last 3 delivered the entire backlog while reporting success. + val (log, _) = growingLog(startEntries = 10, appendsBefore = Map(2 -> 1L)) + val resolved = resolveLatestN(3, Vector(p0), log, olderByAbsoluteId) + assertTrue(resolved == Map(p0 -> LatestNSeek.FromEntry("e8", 0L))) ?? + s"a single concurrent append turned 'latest 3' into ${resolved(p0)}" + }, + test("a BURST of appends does not make the cursor jump forward and double-count") { + // e1..e10 at start (last 4 = e7..e10); two entries land before the second lookup, so + // `latest, 2` answers e11 - NEWER than the e10 just taken. Taking it walks the cursor + // FORWARD off the contiguous suffix and double-counts, cutting the history too shallow + // (the old code stopped at e9, hiding e7 and e8). The cut must still be the four that + // were newest AT START, namely e7..e10; the two live appends stream in behind them. + val (log, _) = growingLog(startEntries = 10, appendsBefore = Map(2 -> 2L)) + val resolved = resolveLatestN(4, Vector(p0), log, olderByAbsoluteId) + assertTrue(resolved == Map(p0 -> LatestNSeek.FromEntry("e7", 0L))) ?? + s"a burst of concurrent appends cut 'latest 4' at ${resolved(p0)} instead of e7" + }, + test("a log that OUTRUNS the walk for the whole bound fails LOUDLY, naming the topic") { + // One entry lands before EVERY lookup, so the anchor moves exactly as fast as the walk + // steps and `latest, k` keeps answering at or above the entry just taken, forever. + // There is no honest "last n" on such a topic at this moment, and both silent endings + // answer a question nobody asked: classifying it as exhaustion seeks EARLIEST (the + // whole backlog as a successful session - the original defect), and stopping short + // delivers fewer than n as success. Refusing is the only honest outcome, and it names + // the topic so the user knows which producer to quiet down. + val (log, lookups) = growingLog(startEntries = 200, appendsBefore = (1 to 1_000).map(i => i -> 1L).toMap) + val outcome = scala.util.Try(resolveLatestN(5, Vector(p0), log, olderByAbsoluteId)) + assertTrue( + outcome.failed.toOption.exists(_.isInstanceOf[StartFromUnresolvableException]), + outcome.failed.toOption.exists(_.getMessage.contains(p0)), + // The refusal is BOUNDED: a prime, one ambiguous repeat plus its verification, and + // at most the re-anchor budget of catch-up lookups - never an unbounded chase. + lookups() <= maxLatestNReanchorSteps + 8 + ) ?? s"outcome=$outcome after ${lookups()} lookups" + } + ) + + /** THE CONTRACT, STATED RATHER THAN APOLOGISED FOR: APPEND ORDER WITHIN A PARTITION, PUBLISH + * TIME ACROSS PARTITIONS. + * + * Pulsar preserves APPEND order within a partition and stamps `publishTime` from the PRODUCER's + * clock. Those two are not the same thing: several producers writing one partition, or one + * producer whose clock steps back, append publish times that run backwards inside a single log. + * + * Neither counting mode reads a whole log, and neither can: "skip first n" is a streaming merge + * over per-stream heads, "latest n" is a backward walk over per-topic entry cursors, and both + * would have to buffer or scan an entire partition to notice that it is not in clock order. + * That is O(topic) at any n, which is the cost both designs exist to avoid. + * + * So the guarantee is: the COUNT is exact without qualification, and WHICH messages make the + * cut is exact as far as each log really is in publish-time order. These tests pin that as the + * real behaviour - including the case where it visibly differs from an exact publish-time + * answer - so no documentation, UI label or downstream test can claim more than the code does. + * The UI labels ("Skip first n messages", "Latest n messages") must not imply otherwise. + */ + private val nonMonotonicSuite = suite("the contract: append order within a partition")( + test("a stream whose publish times run backwards is NOT resorted") { + // p0's second message is stamped OLDER than its first - two producers, or one clock + // that stepped back. Globally by publish time the oldest message is a2 (1), so an + // order-by-publish-time contract would drop a2. The merge drops b1 (50) instead, + // because within p0 it takes the log's order as given and a2 is not yet a head. + val arrivals = Vector( + Arrival(p0, at(100, p0, 0), atBacklogEnd = false, "a1"), + Arrival(p1, at(50, p1, 0), atBacklogEnd = true, "b1"), + Arrival(p0, at(1, p0, 1), atBacklogEnd = true, "a2") + ) + val run = skip(1, Vector(p0, p1), arrivals) + assertTrue( + run.dropped == Vector("b1"), + run.delivered == Vector("a1", "a2") + ) ?? s"dropped=${run.dropped} delivered=${run.delivered}" + }, + test("an INTERIOR clock reversal is not resorted either, and the count survives it") { + // Not merely at the tail: p0's middle message is the oldest thing in the session. An + // exact publish-time contract would drop a2(1) and a3(5); the merge drops a1(100) and + // a2(1), because a3 is not a head until a2 has been taken. + val arrivals = Vector( + Arrival(p0, at(100, p0, 0), atBacklogEnd = false, "a1"), + Arrival(p0, at(1, p0, 1), atBacklogEnd = false, "a2"), + Arrival(p0, at(5, p0, 2), atBacklogEnd = true, "a3"), + Arrival(p1, at(200, p1, 0), atBacklogEnd = true, "b1") + ) + val run = skip(2, Vector(p0, p1), arrivals) + assertTrue( + run.dropped == Vector("a1", "a2"), + run.delivered == Vector("a3", "b1") + ) ?? s"dropped=${run.dropped} delivered=${run.delivered}" + }, + test("the COUNT is exact even when a stream's clock runs backwards") { + // The half of the contract that does survive: n messages are dropped, whatever the + // producer clocks did. Only WHICH n is approximate. + val arrivals = Vector( + Arrival(p0, at(100, p0, 0), atBacklogEnd = false, "a1"), + Arrival(p1, at(50, p1, 0), atBacklogEnd = false, "b1"), + Arrival(p0, at(1, p0, 1), atBacklogEnd = true, "a2"), + Arrival(p1, at(2, p1, 1), atBacklogEnd = true, "b2") + ) + val counts = Vector(0, 1, 2, 3, 4).map(n => n -> skip(n, Vector(p0, p1), arrivals)) + assertTrue(counts.forall((n, run) => run.dropped.size == n && run.delivered.size == 4 - n)) ?? + s"${counts.map((n, run) => s"n=$n dropped=${run.dropped.size} delivered=${run.delivered.size}")}" + }, + test("LATEST-N CUTS BY APPEND POSITION TOO - a buried high-timestamp message is not found") { + // p0's newest ENTRY is stamped 1 while an older entry of the same log is stamped 100. + // An exact publish-time answer for "the latest 1" would be that buried a1(100). The + // walk compares each topic's CURRENT entry, so it sees p0 offering 1, prefers p1's + // 50, and never looks deeper into p0. + // + // Deliberate, and it is the same limit as everywhere else here: finding that message + // would mean scanning the whole log. Recorded as behaviour so the contract and the code + // cannot drift apart - NOT as the answer an exact publish-time contract would give. + val reversed = Map(p0 -> unbatched(1L, 100L), p1 -> unbatched(50L)) + assertTrue( + cut(1, reversed) == Map(p0 -> LatestNSeek.FromEntry(s"$p0#1", 1L), p1 -> LatestNSeek.FromEntry(s"$p1#1", 0L)) + ) ?? s"${cut(1, reversed)}" + }, + test("the COUNT of a latest-n cut is exact even when a log's clock ran backwards") { + val reversed = Map(p0 -> unbatched(1L, 100L), p1 -> unbatched(50L)) + // Two asked for: p1's only entry, then p0's newest. Exactly two messages, whatever the + // clocks did. + assertTrue( + cut(2, reversed) == Map(p0 -> LatestNSeek.FromEntry(s"$p0#1", 0L), p1 -> LatestNSeek.Everything) + ) ?? s"${cut(2, reversed)}" + } + ) + + /** WHAT HAPPENS AFTER THE SKIP'S BUDGET IS SPENT. + * + * The merge exists to decide WHICH n messages are dropped. Once the budget is spent it stops + * merging entirely and every later message is passed straight through, because continuing to + * merge would mean holding a message from every stream for the whole life of the session - + * unbounded memory for a session that is now just streaming. + * + * So the delivery SEQUENCE after the cut is the order the brokers delivered in, one listener + * thread per physical topic. Pinned here so no documentation or downstream test can claim a + * global ordering that the code deliberately does not provide. + */ + private val postCutSuite = suite("delivery order after the cut")( + test("the budget's LAST unit finishes the skip immediately - no N+1st head is waited for") { + // Skip 1 over two streams; the drop EMPTIES p1, which is not at its backlog end. The + // old code kept `dropping` true until a LATER claim answered no, so it went blind on + // p1 and held a1 hostage - for up to the whole stall window - to learn something the + // spent budget had already decided. + val merge = GlobalSkipMerge[String](Vector(p0, p1), Set.empty, StartFromDiscard.shared(1)) + merge.offer(p0, at(10, p0, 0), atBacklogEnd = false, "a1") // held; p1 blind + val resolved = merge.offer(p1, at(5, p1, 0), atBacklogEnd = false, "b1") + assertTrue( + resolved == Vector("b1" -> StartFromOutcome.Drop, "a1" -> StartFromOutcome.Deliver), + merge.heldCount == 0 + ) ?? s"resolved=$resolved held=${merge.heldCount}" + }, + test("settleIfDone flips exactly when the budget is spent and nothing is held - and only then") { + // The settled flag is what lets the session drop its ordering lock for the rest of its + // life, so it must never flip early - and it only flips when ASKED, after the settling + // batch has been fully handled by the listener. + val merge = GlobalSkipMerge[String](Vector(p0, p1), Set.empty, StartFromDiscard.shared(1)) + merge.offer(p0, at(10, p0, 0), atBacklogEnd = false, "a1") + merge.settleIfDone() + val midSkip = merge.isSettled // still dropping, one message held: must stay false + merge.offer(p1, at(5, p1, 0), atBacklogEnd = true, "b1") // spends the budget, drains a1 + val beforeAsked = merge.isSettled // one-way, but only settleIfDone may flip it + merge.settleIfDone() + assertTrue(!midSkip, !beforeAsked, merge.isSettled) + }, + test("after the cut, a later message can be delivered before an older one from another stream") { + // Both streams are individually monotonic, so this is not the clock problem above: it + // is simply that nothing is held back once the skip is done. + val merge = GlobalSkipMerge[String](Vector(p0, p1), Set.empty, StartFromDiscard.shared(1)) + merge.offer(p0, at(10, p0, 0), atBacklogEnd = false, "a1") + val atCut = merge.offer(p1, at(5, p1, 0), atBacklogEnd = true, "b1") + val afterA = merge.offer(p0, at(100, p0, 1), atBacklogEnd = false, "a2") + val afterB = merge.offer(p1, at(30, p1, 1), atBacklogEnd = false, "b2") + + assertTrue( + atCut == Vector("b1" -> StartFromOutcome.Drop, "a1" -> StartFromOutcome.Deliver), + afterA == Vector("a2" -> StartFromOutcome.Deliver), + afterB == Vector("b2" -> StartFromOutcome.Deliver), + merge.heldCount == 0 + ) ?? s"a2 (published at 100) was delivered before b2 (published at 30): atCut=$atCut afterA=$afterA afterB=$afterB" + }, + test("nothing at all is held once the budget is spent") { + // The memory half of the same decision, and the reason it is not going to change. + val merge = GlobalSkipMerge[String](Vector(p0, p1), Set.empty, StartFromDiscard.shared(1)) + merge.offer(p1, at(5, p1, 0), atBacklogEnd = true, "b1") + merge.offer(p0, at(10, p0, 0), atBacklogEnd = false, "a1") + (1 to 500).foreach(i => merge.offer(p0, at(1000L + i, p0, i.toLong), atBacklogEnd = false, s"a$i")) + assertTrue(merge.heldCount == 0) + } + ) + + /** A silent waited-for stream must not hold the merge forever. */ + private val stallSuite = suite("a stream that never speaks is bounded and surfaced, not waited on forever")( + test("RESETTING the stall clock hands a silent stream a fresh window - paused time proves nothing") { + // The runner resets on RESUME: a pause holds every source, so a stream that was blind + // for one second before a five-minute pause must get a full window after it - the old + // wall-clock accounting abandoned it on the first sweep after resume. + var clock = 1_000L + val merge = GlobalSkipMerge[String]( + Vector(p0, p1), + drainedAtStart = Set.empty, + discard = StartFromDiscard.shared(1), + maxHeld = 2, + stallWindowMs = startFromMergeStallWindowMs, + nowMs = () => clock + ) + merge.offer(p0, at(10, p0, 0), atBacklogEnd = false, "a1") // held; p1 blind + clock += startFromMergeStallWindowMs - 1_000 // a long user pause elapses + merge.resetStallClock() // resume + clock += 2_000 // two REAL seconds after resume + val justAfterResume = merge.sweepStalled() // old code: gave up here (31s elapsed) + val stillWaiting = merge.waitingOn.contains(p1) + clock += startFromMergeStallWindowMs + 1 // a full window of real silence + val afterRealWindow = merge.sweepStalled() + assertTrue( + justAfterResume.isEmpty, + stillWaiting, + afterRealWindow == Vector("a1" -> StartFromOutcome.Drop), + merge.heldCount == 0 + ) ?? s"justAfterResume=$justAfterResume stillWaiting=$stillWaiting afterRealWindow=$afterRealWindow" + }, + test("a stream silent past the give-up window is abandoned and the merge advances") { + // p1's backlog was trimmed after the session recorded its end, so it delivers nothing. + // p0 reaches the watermark and is marked for PAUSE (its in-flight tail still lands). + // Once p1 has stayed silent past the window the merge stops waiting for it, drains + // what it held, and carries on - instead of holding a paused world forever in silence. + var clock = 1_000L + val merge = GlobalSkipMerge[String]( + Vector(p0, p1), + drainedAtStart = Set.empty, + discard = StartFromDiscard.shared(1), + maxHeld = 2, + stallWindowMs = startFromMergeStallWindowMs, + nowMs = () => clock + ) + val a1 = merge.offer(p0, at(10, p0, 0), atBacklogEnd = false, "a1") // held; p1 blind + val a2 = merge.offer(p0, at(20, p0, 1), atBacklogEnd = false, "a2") // held; watermark reached + val a3 = merge.offer(p0, at(30, p0, 2), atBacklogEnd = false, "a3") // in-flight overshoot: accepted, p0 marked for pause + val pausedWhileBlind = merge.desiredPausedStreams + val waitingBefore = merge.waitingOn + + clock += startFromMergeStallWindowMs + 1 // p1 still silent, past the window + val afterGiveUp = merge.offer(p0, at(40, p0, 3), atBacklogEnd = false, "a4") + + assertTrue( + a1.isEmpty, + a2.isEmpty, + a3.isEmpty, + pausedWhileBlind == Set(p0), + waitingBefore == Set(p1), + afterGiveUp == Vector( + "a1" -> StartFromOutcome.Drop, + "a2" -> StartFromOutcome.Deliver, + "a3" -> StartFromOutcome.Deliver, + "a4" -> StartFromOutcome.Deliver + ), + !merge.waitingOn.contains(p1) // p1 was abandoned; p0 is momentarily empty but still legitimately waited for + ) ?? s"pausedWhileBlind=$pausedWhileBlind waitingBefore=$waitingBefore afterGiveUp=$afterGiveUp stillWaiting=${merge.waitingOn}" + }, + test("the SWEEP gives up with NO further offer - the last backlog message has nobody behind it") { + // p0 delivered everything it had and ended its backlog; p1 was trimmed and never + // speaks. The offer-driven check can never run again - there are no offers left - so + // only the time-driven sweep can honour the window. It used to hang forever here. + var clock = 1_000L + val merge = GlobalSkipMerge[String]( + Vector(p0, p1), + drainedAtStart = Set.empty, + discard = StartFromDiscard.shared(1), + maxHeld = 2, + stallWindowMs = startFromMergeStallWindowMs, + nowMs = () => clock + ) + val a1 = merge.offer(p0, at(10, p0, 0), atBacklogEnd = true, "a1") // p0's LAST message; p1 blind + val beforeSweep = merge.sweepStalled() // window not yet passed: nothing moves + + clock += startFromMergeStallWindowMs + 1 + val swept = merge.sweepStalled() + + assertTrue( + a1.isEmpty, + beforeSweep.isEmpty, + swept == Vector("a1" -> StartFromOutcome.Drop), + merge.heldCount == 0 + ) ?? s"beforeSweep=$beforeSweep swept=$swept held=${merge.heldCount}" + }, + test("a stream that speaks before the window is NOT given up on - a slow stream is not cut") { + var clock = 1_000L + val merge = GlobalSkipMerge[String]( + Vector(p0, p1), + drainedAtStart = Set.empty, + discard = StartFromDiscard.shared(1), + maxHeld = 2, + stallWindowMs = startFromMergeStallWindowMs, + nowMs = () => clock + ) + merge.offer(p0, at(10, p0, 0), atBacklogEnd = false, "a1") + clock += startFromMergeStallWindowMs - 1 // just under the window + val stillWaiting = merge.waitingOn + // p1 finally speaks: its message is the globally-earliest, so it is dropped and a1 delivered. + val fromP1 = merge.offer(p1, at(5, p1, 0), atBacklogEnd = true, "b1") + assertTrue( + stillWaiting == Set(p1), + fromP1 == Vector("b1" -> StartFromOutcome.Drop, "a1" -> StartFromOutcome.Deliver) + ) ?? s"stillWaiting=$stillWaiting fromP1=$fromP1" + } + ) + + /** The exactness limit at the cap boundary - documented, not fixed (see [[startFromMergeMaxHeld]]). */ + private val capBoundarySuite = suite("the watermark keeps the COUNT exact but not always WHICH n")( + test("a NON-MONOTONIC log can still cross streams at the boundary - the append-order contract, not the flow control") { + // p0's log is not in publish-time order (two producers, or a clock that stepped back): + // its entries append as A(10), B(40), C(20). All three are accepted - nothing is + // declined any more - and the drops follow HEAD order: A(10) against b1(50), then + // B(40), because C sits BEHIND B in its own log. The COUNT is exactly 2; the SET is + // the append-order answer: the globally-earliest two by publish time are A(10) and + // C(20), but C cannot be seen past B. This is [[MessageOrderKey]]'s documented + // contract surfacing - reordering a stream against its own log would mean reading the + // whole log - and no watermark or pause changes it. Pinned so the limit stays + // documented rather than believed away. + val merge = GlobalSkipMerge[String](Vector(p0, p1), Set.empty, StartFromDiscard.shared(2), maxHeld = 2) + val a = merge.offer(p0, at(10, p0, 0), atBacklogEnd = false, "A") + val b = merge.offer(p0, at(40, p0, 1), atBacklogEnd = false, "B") + val c = merge.offer(p0, at(20, p0, 2), atBacklogEnd = false, "C") // accepted past the watermark + val resolved = merge.offer(p1, at(50, p1, 0), atBacklogEnd = true, "b1") + val droppedSet = resolved.collect { case (v, StartFromOutcome.Drop) => v }.toSet + assertTrue( + a.isEmpty, + b.isEmpty, + c.isEmpty, + resolved == Vector( + "A" -> StartFromOutcome.Drop, + "B" -> StartFromOutcome.Drop, + "C" -> StartFromOutcome.Deliver, + "b1" -> StartFromOutcome.Deliver + ), + droppedSet == Set("A", "B"), // the limit: C(20) should have been dropped instead of B(40) + !droppedSet.contains("C") + ) ?? s"resolved=$resolved" + } + ) + + /** FLOW CONTROL REPLACED DECLINING, AND WITH IT THE WHOLE OVERTAKE CLASS. + * + * A declined message came back through broker redelivery while its successors kept arriving, + * so a stream could re-enter the merge out of its own append order - the one premise a k-way + * merge cannot survive. The floor guard held the door for the FIRST declined message, but + * successors declined BY THE GUARD were not remembered: two of them returning out of order + * could still spend the budget's last unit on the wrong message, on a perfectly monotonic + * stream. Nothing is declined now - hot sources are PAUSED - so there is nothing to return + * out of order, and the counterexample is structurally impossible: the first test drives the + * exact sequence that used to break. + */ + private val flowControlSuite = suite("hot sources are paused, and a stream can no longer overtake itself")( + test("THE OLD COUNTEREXAMPLE IS GONE: the exact set survives a full merge on a monotonic stream") { + // Skip 5, room for 2. Under declining: t7 bounced at the cap, t8 bounced by the floor + // guard UNREMEMBERED, t7's return cleared the floor, and t9 could then spend the last + // unit while t8 was still in flight - dropped {t1,t5,t6,t7,t9}, t8 delivered. Under + // pause the arrivals are simply accepted in order and the dropped set is exactly the + // first five of the merged stream: {t1,t5,t6,t7,t8}. + val merge = GlobalSkipMerge[String](Vector(p0, p1), Set.empty, StartFromDiscard.shared(5), maxHeld = 2) + val h5 = merge.offer(p1, at(50, p1, 0), atBacklogEnd = false, "t5") + val h6 = merge.offer(p1, at(60, p1, 1), atBacklogEnd = false, "t6") + val h7 = merge.offer(p1, at(70, p1, 2), atBacklogEnd = false, "t7") // past the watermark: accepted, p1 marked for pause + val burst = merge.offer(p0, at(10, p0, 0), atBacklogEnd = true, "t1") + val t8 = merge.offer(p1, at(80, p1, 3), atBacklogEnd = false, "t8") + val t9 = merge.offer(p1, at(90, p1, 4), atBacklogEnd = false, "t9") + + val dropped = (burst ++ t8 ++ t9).collect { case (v, StartFromOutcome.Drop) => v } + assertTrue( + h5.isEmpty, + h6.isEmpty, + h7.isEmpty, + merge.desiredPausedStreams.isEmpty, // budget spent at t8: the pause lifted with it + burst == Vector( + "t1" -> StartFromOutcome.Drop, + "t5" -> StartFromOutcome.Drop, + "t6" -> StartFromOutcome.Drop, + "t7" -> StartFromOutcome.Drop + ), + t8 == Vector("t8" -> StartFromOutcome.Drop), + t9 == Vector("t9" -> StartFromOutcome.Deliver), + dropped.toSet == Set("t1", "t5", "t6", "t7", "t8") + ) ?? s"burst=$burst t8=$t8 t9=$t9 paused=${merge.desiredPausedStreams}" + }, + test("a stream paused for its own queue RESUMES once it drains below the low watermark") { + val merge = GlobalSkipMerge[String]( + Vector(p0, p1), + Set.empty, + StartFromDiscard.shared(10), + pauseStreamAt = 3, + resumeStreamAt = 1 + ) + merge.offer(p0, at(30, p0, 0), atBacklogEnd = false, "a1") + merge.offer(p0, at(40, p0, 1), atBacklogEnd = false, "a2") + merge.offer(p0, at(50, p0, 2), atBacklogEnd = false, "a3") + val pausedAtHigh = merge.desiredPausedStreams + // p1's NEWER head lets the merge drain p0's whole queue - well under the low watermark. + merge.offer(p1, at(100, p1, 0), atBacklogEnd = true, "b1") + val afterDrain = merge.desiredPausedStreams + assertTrue( + pausedAtHigh == Set(p0), + afterDrain.isEmpty, + merge.heldCount == 1 // only b1: p0 drained and is blind again, so its pause lifted + ) ?? s"pausedAtHigh=$pausedAtHigh afterDrain=$afterDrain held=${merge.heldCount}" + }, + test("the BYTE watermark pauses a fat stream that the count watermarks would never notice") { + val merge = GlobalSkipMerge[String]( + Vector(p0, p1), + Set.empty, + StartFromDiscard.shared(5), + pauseBytesAt = 10L, + resumeBytesAt = 5L, + payloadBytesOf = (v: String) => v.length.toLong + ) + merge.offer(p0, at(10, p0, 0), atBacklogEnd = false, "aaaaaa") // 6 bytes: under + val underBytes = merge.desiredPausedStreams + merge.offer(p0, at(20, p0, 1), atBacklogEnd = false, "bbbbbb") // 12 bytes total: over + val overBytes = merge.desiredPausedStreams + assertTrue( + underBytes.isEmpty, + overBytes == Set(p0), + merge.heldBytesCount == 12L, + !overBytes.contains(p1) // blind, and empty-queued: never paused + ) ?? s"under=$underBytes over=$overBytes heldBytes=${merge.heldBytesCount}" + } + ) + + /** A broker unload redelivers everything un-acked while the originals may still be HELD in the + * merge or already decided. Re-deciding a copy would spend a second budget unit on one + * message - count exact, set short. The guard is the per-stream APPEND position watermark: + * within one stream it only grows (publish time does not have to), so "at or below" is + * exactly "offered before". + */ + private val duplicateSuite = suite("a redelivered duplicate never spends a second budget unit")( + test("a duplicate of a message still HELD is acknowledged without a claim") { + val discard = StartFromDiscard.shared(2) + val merge = GlobalSkipMerge[String](Vector(p0, p1), Set.empty, discard) + val first = merge.offer(p0, at(10, p0, 0), atBacklogEnd = false, "a1") + val copy = merge.offer(p0, at(10, p0, 0), atBacklogEnd = false, "a1-copy") // unload redelivery + val resolved = merge.offer(p1, at(5, p1, 0), atBacklogEnd = true, "b1") + assertTrue( + first.isEmpty, + copy == Vector("a1-copy" -> StartFromOutcome.Drop), // shown to nobody, acknowledged, claimed from NOTHING + resolved == Vector("b1" -> StartFromOutcome.Drop, "a1" -> StartFromOutcome.Drop), + discard.remaining == 0L // exactly two claims: b1 and a1 - the copy spent nothing + ) ?? s"copy=$copy resolved=$resolved remaining=${discard.remaining}" + }, + test("a duplicate of a message already DECIDED is acknowledged without a claim") { + val discard = StartFromDiscard.shared(2) + val merge = GlobalSkipMerge[String](Vector(p0), Set.empty, discard) + val dropped = merge.offer(p0, at(10, p0, 0), atBacklogEnd = false, "a1") + val copy = merge.offer(p0, at(10, p0, 0), atBacklogEnd = false, "a1-copy") + val next = merge.offer(p0, at(20, p0, 1), atBacklogEnd = true, "a2") + assertTrue( + dropped == Vector("a1" -> StartFromOutcome.Drop), + copy == Vector("a1-copy" -> StartFromOutcome.Drop), + next == Vector("a2" -> StartFromOutcome.Drop), + discard.remaining == 0L // exactly two claims: a1 and a2, never the copy + ) ?? s"dropped=$dropped copy=$copy next=$next remaining=${discard.remaining}" + }, + test("a batched entry's LATER piece is not mistaken for a duplicate of an earlier one") { + // Same ledger and entry, higher batch index: a legitimate successor, not a copy. + val discard = StartFromDiscard.shared(1) + val merge = GlobalSkipMerge[String](Vector(p0), Set.empty, discard) + val first = merge.offer(p0, at(10, p0, 0, batchIndex = 0), atBacklogEnd = false, "a1#0") + val second = merge.offer(p0, at(10, p0, 0, batchIndex = 1), atBacklogEnd = true, "a1#1") + assertTrue( + first == Vector("a1#0" -> StartFromOutcome.Drop), + second == Vector("a1#1" -> StartFromOutcome.Deliver), + discard.remaining == 0L + ) ?? s"first=$first second=$second" + } + ) + + private val sharedCounterSuite = suite("the merge refuses a counter it could never claim")( + test("a PerTopic discard is refused at construction - the merge claims by STREAM id") { + // `advance` claims `discard.claim(streamId)`, and a stream id ("consumer@topic") is not + // a topic FQN. Only a SHARED counter matches any key; a PerTopic counter would never + // find its key, never claim, and the skip would silently drop nothing at all. The + // session wiring only ever hands the merge a shared counter today - this makes wiring + // anything else fail at construction instead of never-claiming. + val outcome = scala.util.Try( + GlobalSkipMerge[String](Vector(p0, p1), Set.empty, StartFromDiscard.perTopic(Map(p0 -> 3L))) + ) + assertTrue( + outcome.isFailure, + outcome.failed.toOption.exists(_.isInstanceOf[IllegalArgumentException]) + ) ?? s"a PerTopic counter was accepted: $outcome" + } + ) + + def spec = suite(this.getClass.toString)( + orderSuite, + backlogEndSuite, + skipSuite, + latestSuite, + movingAnchorSuite, + nonMonotonicSuite, + postCutSuite, + stallSuite, + capBoundarySuite, + flowControlSuite, + duplicateSuite, + sharedCounterSuite + ) diff --git a/server/src/test/scala/consumer/session_runner/handleStartFromTest.scala b/server/src/test/scala/consumer/session_runner/handleStartFromTest.scala new file mode 100644 index 000000000..eccebff05 --- /dev/null +++ b/server/src/test/scala/consumer/session_runner/handleStartFromTest.scala @@ -0,0 +1,142 @@ +package consumer.session_runner + +import consumer.start_from.{DateTimeUnit, RelativeDateTime} +import zio.test.* + +import java.time.{ZoneId, ZonedDateTime} + +/** `resolveRelativeDateTime` turns a "N units ago" selection into the timestamp a consumer seeks to. + * + * Regression context: rounding used `ZonedDateTime.truncatedTo`, which REJECTS any unit larger than + * a day - so Week/Month/Year with "round to unit start" threw UnsupportedTemporalTypeException. + * Three of the seven units were broken for an ordinary UI selection, surfacing only as a generic + * FAILED_PRECONDITION on the whole session. + * + * A frozen `now` keeps every case deterministic (the production call passes ZonedDateTime.now()). + */ +object handleStartFromTest extends ZIOSpecDefault: + + // A Wednesday, mid-month, mid-year, with non-zero time-of-day so truncation is observable. + private val now = ZonedDateTime.of(2026, 7, 15, 13, 47, 29, 123_000_000, ZoneId.of("UTC")) + + private def resolve(value: Int, unit: DateTimeUnit, rounded: Boolean): ZonedDateTime = + resolveRelativeDateTime(RelativeDateTime(value = value, unit = unit, isRoundedToUnitStart = rounded), now) + + private val allUnits = List( + DateTimeUnit.Second, + DateTimeUnit.Minute, + DateTimeUnit.Hour, + DateTimeUnit.Day, + DateTimeUnit.Week, + DateTimeUnit.Month, + DateTimeUnit.Year + ) + + def spec = suite(this.getClass.toString)( + test("every unit resolves when rounding is requested") { + // The regression itself: Week/Month/Year used to throw here. + val failures = allUnits.flatMap { unit => + scala.util.Try(resolve(1, unit, rounded = true)).failed.toOption.map(t => s"$unit -> ${t.getClass.getSimpleName}") + } + assertTrue(failures.isEmpty) ?? s"units that threw while rounding: ${failures.mkString(", ")}" + }, + test("every unit resolves without rounding") { + val failures = allUnits.flatMap { unit => + scala.util.Try(resolve(1, unit, rounded = false)).failed.toOption.map(t => s"$unit -> ${t.getClass.getSimpleName}") + } + assertTrue(failures.isEmpty) ?? s"units that threw: ${failures.mkString(", ")}" + }, + test("unrounded units subtract exactly, preserving time-of-day") { + assertTrue( + resolve(30, DateTimeUnit.Second, rounded = false) == now.minusSeconds(30), + resolve(30, DateTimeUnit.Minute, rounded = false) == now.minusMinutes(30), + resolve(5, DateTimeUnit.Hour, rounded = false) == now.minusHours(5), + resolve(3, DateTimeUnit.Day, rounded = false) == now.minusDays(3), + resolve(2, DateTimeUnit.Week, rounded = false) == now.minusWeeks(2), + resolve(2, DateTimeUnit.Month, rounded = false) == now.minusMonths(2), + resolve(1, DateTimeUnit.Year, rounded = false) == now.minusYears(1) + ) + }, + test("rounding to the start of a second/minute/hour zeroes the finer fields") { + val sec = resolve(0, DateTimeUnit.Second, rounded = true) + val min = resolve(0, DateTimeUnit.Minute, rounded = true) + val hour = resolve(0, DateTimeUnit.Hour, rounded = true) + assertTrue( + sec.getNano == 0, + min.getSecond == 0 && min.getNano == 0, + hour.getMinute == 0 && hour.getSecond == 0 && hour.getNano == 0 + ) + }, + test("rounding to the start of a day zeroes the time of day") { + val day = resolve(3, DateTimeUnit.Day, rounded = true) + assertTrue( + day.getHour == 0, + day.getMinute == 0, + day.getSecond == 0, + day.getNano == 0, + day.toLocalDate == now.minusDays(3).toLocalDate + ) + }, + test("rounding to the start of a week lands on Monday at midnight") { + // now is Wed 2026-07-15; one week earlier is Wed 2026-07-08, whose week starts Mon 2026-07-06. + val week = resolve(1, DateTimeUnit.Week, rounded = true) + assertTrue( + week.getDayOfWeek == java.time.DayOfWeek.MONDAY, + week.getHour == 0 && week.getMinute == 0 && week.getSecond == 0 && week.getNano == 0, + !week.isAfter(now.minusWeeks(1)) + ) + }, + test("rounding to the start of a month lands on the 1st at midnight") { + val month = resolve(2, DateTimeUnit.Month, rounded = true) + assertTrue( + month.getDayOfMonth == 1, + month.getMonthValue == 5, // 2026-07-15 minus 2 months -> May + month.getYear == 2026, + month.getHour == 0 && month.getMinute == 0 && month.getSecond == 0 && month.getNano == 0 + ) + }, + test("rounding to the start of a year lands on Jan 1st at midnight") { + val year = resolve(1, DateTimeUnit.Year, rounded = true) + assertTrue( + year.getDayOfYear == 1, + year.getMonthValue == 1, + year.getYear == 2025, + year.getHour == 0 && year.getMinute == 0 && year.getSecond == 0 && year.getNano == 0 + ) + }, + test("a rounded result is never later than the unrounded one") { + val violations = allUnits.filter { unit => + resolve(1, unit, rounded = true).isAfter(resolve(1, unit, rounded = false)) + } + assertTrue(violations.isEmpty) ?? s"rounding moved these units forward in time: $violations" + }, + test("value = 0 with rounding gives the start of the current unit") { + val month = resolve(0, DateTimeUnit.Month, rounded = true) + assertTrue(month.getYear == 2026, month.getMonthValue == 7, month.getDayOfMonth == 1) + }, + test("a year subtracted from Feb 29 lands on a valid date") { + val leap = ZonedDateTime.of(2024, 2, 29, 10, 0, 0, 0, ZoneId.of("UTC")) + val got = resolveRelativeDateTime( + RelativeDateTime(value = 1, unit = DateTimeUnit.Year, isRoundedToUnitStart = false), + leap + ) + assertTrue(got.getYear == 2023, got.getMonthValue == 2, got.getDayOfMonth == 28) + }, + test("a month subtracted from the 31st clamps to the shorter month") { + val endOfMonth = ZonedDateTime.of(2026, 3, 31, 10, 0, 0, 0, ZoneId.of("UTC")) + val got = resolveRelativeDateTime( + RelativeDateTime(value = 1, unit = DateTimeUnit.Month, isRoundedToUnitStart = false), + endOfMonth + ) + assertTrue(got.getMonthValue == 2, got.getDayOfMonth == 28) + }, + test("rounding across a DST transition keeps midnight wall-clock time") { + // Europe/Berlin springs forward on 2026-03-29; rounding must not yield 01:00 or 23:00. + val berlin = ZonedDateTime.of(2026, 4, 10, 15, 30, 0, 0, ZoneId.of("Europe/Berlin")) + val got = resolveRelativeDateTime( + RelativeDateTime(value = 2, unit = DateTimeUnit.Week, isRoundedToUnitStart = true), + berlin + ) + assertTrue(got.getHour == 0, got.getMinute == 0, got.getDayOfWeek == java.time.DayOfWeek.MONDAY) + } + ) diff --git a/server/src/test/scala/consumer/session_runner/latestNLiveCheckMain.scala b/server/src/test/scala/consumer/session_runner/latestNLiveCheckMain.scala new file mode 100644 index 000000000..397ece2b5 --- /dev/null +++ b/server/src/test/scala/consumer/session_runner/latestNLiveCheckMain.scala @@ -0,0 +1,134 @@ +package consumer.session_runner + +import org.apache.pulsar.client.admin.PulsarAdmin +import org.apache.pulsar.client.api.{PulsarClient, MessageId as PulsarMessageId} + +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.{AtomicBoolean, AtomicInteger} +import scala.util.Try + +/** MANUAL live-broker verification of "latest n" under a CONCURRENT PRODUCER - the moving-anchor + * defect that frozen-log tests structurally cannot exercise. + * + * Deliberately a `main` and NOT a ZIO spec: `sbt test` must stay broker-free (CI runs the server + * suite without Pulsar). Run it by hand against the e2e stack (dekaf-e2e-pulsar on + * localhost:6650 / localhost:18080): + * + * sbt "Test/runMain consumer.session_runner.latestNLiveCheckMain" # paced producer + * sbt "Test/runMain consumer.session_runner.latestNLiveCheckMain outrun" # producer at full rate + * + * It creates a THROWAWAY topic (deleted afterwards), seeds an unbatched backlog, runs the REAL + * `entryFromLatest` walk while a producer keeps publishing, then reads from the resolved cut and + * checks, against the topic as measured AFTER the walk: + * + * - the delivered set is a CONTIGUOUS SUFFIX of the final log (no gaps, no double-delivery); + * - it contains the final last n; + * - n <= delivered <= n + (messages published while the walk ran); + * - it is NOT the whole backlog - the failure mode this walk used to have. + * + * In `outrun` mode the log may genuinely move faster than the walk for its whole bound; the only + * acceptable outcome then is `StartFromUnresolvableException` - a session refusing loudly - never + * a "successful" session showing the wrong set. + */ +object latestNLiveCheckMain: + private val n = 50L + private val seed = 400 + + def main(args: Array[String]): Unit = + val outrun = args.contains("outrun") + val adminUrl = sys.env.getOrElse("DEKAF_LIVE_CHECK_ADMIN_URL", "http://localhost:18080") + val brokerUrl = sys.env.getOrElse("DEKAF_LIVE_CHECK_BROKER_URL", "pulsar://localhost:6650") + val topicFqn = s"persistent://public/default/dekaf-latestn-live-${java.util.UUID.randomUUID().toString.take(8)}" + + val admin = PulsarAdmin.builder().serviceHttpUrl(adminUrl).build() + val client = PulsarClient.builder().serviceUrl(brokerUrl).build() + + var failures = Vector.empty[String] + def check(ok: Boolean, what: => String): Unit = if !ok then failures :+= what + + try + admin.topics().createNonPartitionedTopic(topicFqn) + val producer = client.newProducer().topic(topicFqn).enableBatching(false).blockIfQueueFull(true).create() + (1 to seed).foreach(i => producer.send(s"seed-$i".getBytes("UTF-8"))) + + val producedDuringResolve = AtomicInteger(0) + val stop = AtomicBoolean(false) + val pump = new Thread( + (() => + var i = 0 + while !stop.get do + i += 1 + producer.send(s"live-$i".getBytes("UTF-8")) + producedDuringResolve.incrementAndGet() + if !outrun then Thread.sleep(8) + ): Runnable, + "latest-n-live-check-producer" + ) + pump.setDaemon(true) + pump.start() + + val lookups = AtomicInteger(0) + val startedAtMs = System.currentTimeMillis() + val walkOutcome = Try { + resolveLatestN( + n, + Vector(topicFqn), + topic => k => { lookups.incrementAndGet(); entryFromLatest(admin, topic)(k) }, + latestNEntryIsOlder + ) + } + val walkMs = System.currentTimeMillis() - startedAtMs + stop.set(true) + pump.join(10_000) + producer.flush() + producer.close() + + println(s"topic=$topicFqn mode=${if outrun then "outrun" else "paced"}") + println(s"walk: ${walkOutcome.fold(err => s"FAILED (${err.getClass.getSimpleName}: ${err.getMessage})", _ => "resolved")} " + + s"in ${walkMs}ms, ${lookups.get} lookups, producedDuringResolve=${producedDuringResolve.get}") + + walkOutcome match + case scala.util.Failure(err) => + // Refusing loudly is the CORRECT outcome when the log persistently outruns the + // walk; anything else that throws is a real failure. + check(err.isInstanceOf[StartFromUnresolvableException], s"unexpected walk failure: $err") + check(outrun, s"the walk refused under a paced producer - it should have kept up. $err") + case scala.util.Success(cut) => + def drainFrom(startAt: PulsarMessageId, inclusive: Boolean): Vector[String] = + val builder = client.newReader().topic(topicFqn).startMessageId(startAt) + val reader = (if inclusive then builder.startMessageIdInclusive() else builder).create() + val out = scala.collection.mutable.ArrayBuffer.empty[String] + while reader.hasMessageAvailable do + val message = reader.readNext(10, TimeUnit.SECONDS) + if message == null then throw new RuntimeException("reader timed out mid-drain") + out += new String(message.getData, "UTF-8") + reader.close() + out.toVector + + val all = drainFrom(PulsarMessageId.earliest, inclusive = false) + val delivered = cut(topicFqn) match + case LatestNSeek.Nothing => Vector.empty[String] + case LatestNSeek.Everything => all + case LatestNSeek.FromEntry(entryId, discard) => drainFrom(entryId, inclusive = true).drop(discard.toInt) + + val lastN = all.takeRight(n.toInt) + println(s"final log=${all.size} messages; delivered=${delivered.size} " + + s"[${delivered.headOption.getOrElse("-")} .. ${delivered.lastOption.getOrElse("-")}]") + + check(delivered.size >= n, s"delivered FEWER than n: ${delivered.size} < $n (cut=${cut(topicFqn)})") + check( + delivered.size <= n + producedDuringResolve.get, + s"delivered MORE than n + concurrent production: ${delivered.size} > $n + ${producedDuringResolve.get}" + ) + check(delivered == all.takeRight(delivered.size), "delivered set is NOT a contiguous suffix of the final log") + check(lastN.forall(delivered.contains), "delivered set is missing part of the final last n") + check(delivered.size < all.size, s"WHOLE BACKLOG delivered as 'latest $n' (${delivered.size} of ${all.size})") + finally + Try(client.close()) + Try(admin.topics().delete(topicFqn, true)) + Try(admin.close()) + + if failures.nonEmpty then + System.err.println(failures.mkString("LIVE CHECK FAILED:\n - ", "\n - ", "")) + sys.exit(1) + else println("LIVE CHECK PASSED") diff --git a/server/src/test/scala/consumer/session_runner/listenerGateAndBudgetTest.scala b/server/src/test/scala/consumer/session_runner/listenerGateAndBudgetTest.scala new file mode 100644 index 000000000..9e700c868 --- /dev/null +++ b/server/src/test/scala/consumer/session_runner/listenerGateAndBudgetTest.scala @@ -0,0 +1,301 @@ +package consumer.session_runner + +import _root_.consumer.coloring_rules.ColoringRuleChain +import _root_.consumer.deserializer.Deserializer +import _root_.consumer.deserializer.deserializers.UseLatestTopicSchema +import _root_.consumer.message_filter.MessageFilterChain +import _root_.consumer.session_target.ConsumerSessionTarget +import _root_.consumer.session_target.consumption_mode.ConsumerSessionTargetConsumptionMode +import _root_.consumer.session_target.consumption_mode.modes.RegularConsumptionMode +import _root_.consumer.session_target.topic_selector.{MultiTopicSelector, TopicSelector} +import _root_.consumer.value_projections.ValueProjectionList +import org.apache.pulsar.client.api.{Consumer, Message as PulsarMessage, Schema} +import org.apache.pulsar.client.impl.{MessageIdImpl, MessageImpl} +import org.apache.pulsar.common.api.proto.MessageMetadata +import zio.test.* + +import java.lang.reflect.{InvocationHandler, Method, Modifier, Proxy} +import java.nio.ByteBuffer +import java.util.concurrent.atomic.{AtomicBoolean, AtomicLong} +import java.util.concurrent.{CompletableFuture, ConcurrentLinkedQueue} +import scala.jdk.CollectionConverters.* + +/** THE TWO THINGS THAT DECIDE WHETHER A DELIVERED MESSAGE IS KEPT, AND WHAT EACH OF THEM COSTS WHEN + * IT IS WRONG. + * + * `ConsumerListener.decide` is entered from one Pulsar listener thread per physical topic, for + * every message the broker hands over. It reads two pieces of shared state: + * + * - THE PAUSE GATE, written from gRPC threads. A stale read here delivers a message into a + * session the user has paused. + * - THE START-FROM BUDGET, which is the user's "skip the first n". Claiming one costs a message + * - the message is acknowledged into nothing and nobody ever sees it - so a claim that is not + * matched by a real acknowledgment means Pulsar redelivers a message whose budget is already + * spent, and the session shows a message the user asked to skip while reporting that exactly n + * were skipped. + * + * Everything here drives the real `received`/`decide`/`pause` paths with proxy consumers and + * hand-built messages. Only the broker is replaced. + */ +object listenerGateAndBudgetTest extends ZIOSpecDefault: + + private val p0 = "persistent://public/default/gate-partition-0" + + private def message(label: String, entryId: Long): MessageImpl[Array[Byte]] = + val md = new MessageMetadata() + md.setPublishTime(1_000L + entryId) + md.setPartitionKey(label) + val msg = MessageImpl.create[Array[Byte]](md, ByteBuffer.wrap(s"""{"label":"$label"}""".getBytes("UTF-8")), Schema.BYTES, p0) + msg.setMessageId(new MessageIdImpl(1L, entryId, -1)) + msg + + /** A consumer that can lose its connection and can refuse an acknowledgment, both of which real + * brokers do. `acknowledged` records only the acknowledgments that actually SUCCEEDED - which + * is the whole question here. + */ + private final class RecordingConsumer(onPause: () => Unit = () => ()): + val connected = AtomicBoolean(true) + val acknowledgeFails = AtomicBoolean(false) + val acknowledged = ConcurrentLinkedQueue[String]() + val handedBack = ConcurrentLinkedQueue[String]() + val paused = AtomicBoolean(false) + + val consumer: Consumer[Array[Byte]] = + val handler = new InvocationHandler: + override def invoke(proxy: Object, method: Method, args: Array[Object]): Object = + method.getName match + case "getTopic" => p0 + case "getConsumerName" => "cs-gate-0" + case "isConnected" => java.lang.Boolean.valueOf(connected.get) + case "pause" => + paused.set(true) + onPause() + null + case "resume" => null + case "acknowledgeAsync" => + if acknowledgeFails.get then CompletableFuture.failedFuture(new RuntimeException("broker refused the acknowledgment")) + else + acknowledged.add(args(0).asInstanceOf[PulsarMessage[Array[Byte]]].getKey) + CompletableFuture.completedFuture(null) + case "negativeAcknowledge" => + handedBack.add(args(0).asInstanceOf[PulsarMessage[Array[Byte]]].getKey) + null + case "equals" => java.lang.Boolean.valueOf(proxy eq args(0)) + case "hashCode" => java.lang.Integer.valueOf(p0.hashCode) + case "toString" => "proxy-consumer(gate)" + case _ => null + Proxy + .newProxyInstance(getClass.getClassLoader, Array[Class[?]](classOf[Consumer[Array[Byte]]]), handler) + .asInstanceOf[Consumer[Array[Byte]]] + + private def openListener(): (ConsumerListener, ConcurrentLinkedQueue[String]) = + val delivered = ConcurrentLinkedQueue[String]() + val listener = ConsumerListener(ConsumerSessionTargetMessageHandler(onNext = msg => { delivered.add(msg.getKey); () })) + listener.startAcceptingNewMessages() + (listener, delivered) + + private def targetRunner(consumerListener: ConsumerListener, consumer: Consumer[Array[Byte]]): ConsumerSessionTargetRunner = + ConsumerSessionTargetRunner( + targetIndex = 0, + targetConfig = ConsumerSessionTarget( + isEnabled = true, + consumptionMode = ConsumerSessionTargetConsumptionMode(mode = RegularConsumptionMode()), + messageValueDeserializer = Deserializer(deserializer = UseLatestTopicSchema()), + topicSelector = TopicSelector(topicSelector = MultiTopicSelector(topicFqns = Vector(p0))), + messageFilterChain = MessageFilterChain.empty, + coloringRuleChain = ColoringRuleChain.empty, + valueProjectionList = ValueProjectionList(isEnabled = false, projections = Vector.empty) + ), + nonPartitionedTopicFqns = Vector(p0), + schemasByTopic = Map.empty, + sessionContextPool = ConsumerSessionContextPool(), + consumers = Map(p0 -> consumer), + consumerListener = consumerListener, + stats = ConsumerSessionTargetStats(messageProcessed = AtomicLong(0)) + ) + + private val budgetSuite = suite("a skip budget is spent by an ACKNOWLEDGED drop, and by nothing else")( + test("a drop the consumer could not acknowledge is handed back, and the budget survives it") { + // THE defect. `decide` claimed the budget and the acknowledgment helper then did + // NOTHING when the consumer was disconnected. Pulsar redelivered the message, the + // (already spent) budget let it through, and a session asked to skip one message showed + // it - while reporting that the skip had completed. + val (listener, delivered) = openListener() + listener.startFromDiscard = StartFromDiscard.shared(1) + val consumer = RecordingConsumer() + consumer.connected.set(false) + + listener.received(consumer.consumer, message("m1", 1L)) + val afterDisconnect = listener.startFromDiscard.remaining + + // The connection comes back and the broker redelivers what was never acknowledged. + consumer.connected.set(true) + listener.received(consumer.consumer, message("m1", 1L)) + + assertTrue( + afterDisconnect == 1L, + consumer.handedBack.asScala.toVector == Vector("m1"), + delivered.asScala.toVector.isEmpty, + consumer.acknowledged.asScala.toVector == Vector("m1"), + listener.startFromDiscard.remaining == 0L + ) ?? (s"budget after the disconnected delivery=$afterDisconnect handedBack=${consumer.handedBack.asScala.toVector} " + + s"delivered=${delivered.asScala.toVector} acknowledged=${consumer.acknowledged.asScala.toVector}") + }, + test("a drop whose ack FAILS stays decided: the unit stays spent on THAT message, its redelivery is paperwork") { + // The old contract REFUNDED the unit, which kept the count right and the set wrong: the + // refunded unit was spent on the NEXT message, and the redelivered original - the one + // the user asked to skip - was shown. Now the decision stands: budget spent once, on + // m1; m2 flows through untouched by that unit; m1's redelivery is acknowledged and + // shown to nobody. + val (listener, delivered) = openListener() + listener.startFromDiscard = StartFromDiscard.shared(1) + val consumer = RecordingConsumer() + consumer.acknowledgeFails.set(true) + + listener.received(consumer.consumer, message("m1", 1L)) + val afterFailedAck = listener.startFromDiscard.remaining + val retriesArmed = listener.awaitingAckRetryCount + + consumer.acknowledgeFails.set(false) + // The next NEW message arrives before m1's redelivery - with a refund, this one would + // have consumed the returned unit and m1 would later be shown. + listener.received(consumer.consumer, message("m2", 2L)) + listener.received(consumer.consumer, message("m1", 1L)) + + assertTrue( + afterFailedAck == 0L, + retriesArmed == 1, + delivered.asScala.toVector == Vector("m2"), + consumer.acknowledged.asScala.toVector == Vector("m2", "m1"), + listener.awaitingAckRetryCount == 0 + ) ?? (s"budget after failed ack=$afterFailedAck retriesArmed=$retriesArmed delivered=${delivered.asScala.toVector} " + + s"acknowledged=${consumer.acknowledged.asScala.toVector} retriesLeft=${listener.awaitingAckRetryCount}") + }, + test("a DELIVERED message whose ack fails is not shown twice when the broker redelivers it") { + // Same mechanism, other outcome: the delivery happened, only the paperwork failed. The + // redelivery must be acknowledged and NOT rendered again. + val (listener, delivered) = openListener() + val consumer = RecordingConsumer() + consumer.acknowledgeFails.set(true) + + listener.received(consumer.consumer, message("m1", 1L)) + consumer.acknowledgeFails.set(false) + listener.received(consumer.consumer, message("m1", 1L)) + + assertTrue( + delivered.asScala.toVector == Vector("m1"), + consumer.acknowledged.asScala.toVector == Vector("m1"), + listener.awaitingAckRetryCount == 0 + ) ?? (s"delivered=${delivered.asScala.toVector} acknowledged=${consumer.acknowledged.asScala.toVector}") + }, + test("exactly n UNIQUE messages are skipped when every acknowledgment lands") { + // The control: nothing above may cost the ordinary path its exactness. + val (listener, delivered) = openListener() + listener.startFromDiscard = StartFromDiscard.shared(3) + val consumer = RecordingConsumer() + + (1 to 5).foreach(i => listener.received(consumer.consumer, message(s"m$i", i.toLong))) + + assertTrue( + delivered.asScala.toVector == Vector("m4", "m5"), + consumer.acknowledged.asScala.toVector == Vector("m1", "m2", "m3", "m4", "m5"), + consumer.handedBack.asScala.toVector.isEmpty, + listener.startFromDiscard.remaining == 0L + ) ?? s"delivered=${delivered.asScala.toVector} acknowledged=${consumer.acknowledged.asScala.toVector}" + }, + test("a DELIVERED message on a disconnected consumer is handed back rather than shown unacknowledged") { + // Nothing may be decided about a message the consumer cannot answer for. Handing it + // back is the only outcome that neither loses it nor double-counts it. + val (listener, delivered) = openListener() + val consumer = RecordingConsumer() + consumer.connected.set(false) + + listener.received(consumer.consumer, message("m1", 1L)) + + assertTrue(delivered.asScala.toVector.isEmpty, consumer.handedBack.asScala.toVector == Vector("m1")) + } + ) + + private val gateSuite = suite("the pause gate")( + test("PAUSE CLOSES THE GATE BEFORE THE CONSUMERS STOP DELIVERING") { + // Order matters and it was the wrong way round: the consumers were paused first and the + // gate closed afterwards, so every callback the client had already buffered was + // delivered into a session the user had just paused. Closing the gate first makes the + // window empty by construction. + val (listener, _) = openListener() + val gateClosedWhenPaused = AtomicBoolean(false) + val consumer = RecordingConsumer(onPause = () => gateClosedWhenPaused.set(listener.decide(p0, canAcknowledge = true) == ConsumerListener.Action.Reject)) + val runner = targetRunner(listener, consumer.consumer) + + runner.pause() + + assertTrue( + consumer.paused.get, + gateClosedWhenPaused.get, + listener.decide(p0, canAcknowledge = true) == ConsumerListener.Action.Reject + ) ?? "the consumer was paused while the listener was still accepting messages" + }, + test("a paused listener rejects rather than consuming the skip budget") { + // A rejected message is coming back, so counting it as skipped would skip it twice. + val (listener, _) = openListener() + listener.startFromDiscard = StartFromDiscard.shared(2) + listener.stopAcceptingNewMessages() + + val action = listener.decide(p0, canAcknowledge = true) + + assertTrue(action == ConsumerListener.Action.Reject, listener.startFromDiscard.remaining == 2L) + }, + test("THE GATE CARRIES A HAPPENS-BEFORE - it is read by listener threads and written by gRPC threads") { + // A plain `var Boolean` has no memory-ordering guarantee at all, so a Pulsar listener + // thread was entitled to go on seeing "accepting" indefinitely after an RPC thread had + // paused the session. A data race cannot be observed reliably from a test, so the + // MECHANISM is pinned instead: reverting the field to a plain var fails this. + val field = classOf[ConsumerListener].getDeclaredFields.find(_.getName.toLowerCase.contains("acceptingnewmessages")) + val isSafe = field.exists(f => + classOf[java.util.concurrent.atomic.AtomicBoolean].isAssignableFrom(f.getType) || Modifier.isVolatile(f.getModifiers) + ) + assertTrue(field.isDefined, isSafe) ?? + s"the pause gate is ${field.map(f => s"${f.getType.getSimpleName} (volatile=${Modifier.isVolatile(f.getModifiers)})")}" + } + ) + + private val ackLoggingSuite = suite("a merge-path acknowledgment failure is LOGGED, as the scope note promises")( + test("a DELIVERED message whose acknowledgment fails leaves a warning naming the consumer") { + // `acknowledgeDrop`'s scope note says a failed merge-path acknowledgment "is logged" - + // but `acknowledge` (the Deliver/merge-Drop path) discarded the future outright, so an + // unacknowledged, soon-to-be-redelivered message left no trace at all. + val appender = new ch.qos.logback.core.read.ListAppender[ch.qos.logback.classic.spi.ILoggingEvent]() + appender.start() + // SLF4J hands a SubstituteLogger to callers that arrive while the backend is still + // initializing; under parallel suite execution the first fetch can land in that window + // and the WARN under test is replayed to the REAL logger later - without this appender. + // Re-fetch until the logback binding is in place (the same guard libraryScanTest uses). + var slf4jLogger = org.slf4j.LoggerFactory.getLogger(classOf[ConsumerListener].getName) + var attempts = 0 + while !slf4jLogger.isInstanceOf[ch.qos.logback.classic.Logger] && attempts < 500 do + Thread.sleep(2) + slf4jLogger = org.slf4j.LoggerFactory.getLogger(classOf[ConsumerListener].getName) + attempts += 1 + val logbackLogger = slf4jLogger.asInstanceOf[ch.qos.logback.classic.Logger] + logbackLogger.addAppender(appender) + try + val (listener, delivered) = openListener() + val consumer = RecordingConsumer() + consumer.acknowledgeFails.set(true) + + listener.received(consumer.consumer, message("m1", 1L)) + + val warned = appender.list.asScala.toVector + .filter(_.getLevel == ch.qos.logback.classic.Level.WARN) + .map(_.getFormattedMessage) + assertTrue( + delivered.asScala.toVector == Vector("m1"), + // The wording changed with the decision-stands contract; what the note promises + // is a WARN that names the consumer and says the ack failed. + warned.exists(m => m.contains("decision stands") && m.contains("acknowledgment failed") && m.contains("cs-gate-0")) + ) ?? s"delivered=${delivered.asScala.toVector} warned=$warned" + finally logbackLogger.detachAppender(appender) + } + ) + + def spec = suite(this.getClass.toString)(budgetSuite, gateSuite, ackLoggingSuite) diff --git a/server/src/test/scala/consumer/session_runner/mergeDeliveryFailureTest.scala b/server/src/test/scala/consumer/session_runner/mergeDeliveryFailureTest.scala new file mode 100644 index 000000000..8821c9e1e --- /dev/null +++ b/server/src/test/scala/consumer/session_runner/mergeDeliveryFailureTest.scala @@ -0,0 +1,159 @@ +package consumer.session_runner + +import _root_.consumer.coloring_rules.ColoringRuleChain +import _root_.consumer.deserializer.Deserializer +import _root_.consumer.deserializer.deserializers.UseLatestTopicSchema +import _root_.consumer.message_filter.MessageFilterChain +import org.apache.pulsar.client.api.{Consumer, Message as PulsarMessage, Schema} +import org.apache.pulsar.client.impl.{MessageIdImpl, MessageImpl} +import org.apache.pulsar.common.api.proto.MessageMetadata +import zio.test.* + +import java.lang.reflect.{InvocationHandler, Method, Proxy} +import java.nio.ByteBuffer +import java.util.concurrent.{CompletableFuture, ConcurrentLinkedQueue} +import scala.jdk.CollectionConverters.* + +/** WHAT HAPPENS WHEN THE CLIENT STREAM THROWS WHILE A RESOLVED BATCH IS BEING HANDED OUT. + * + * The global skip merge answers one `offer` with a BATCH of resolved messages - often several, and + * belonging to different topics than the one just offered. Each pair in that batch was already + * DEQUEUED from the merge, so it exists nowhere else. `StreamObserver.onNext` throws the instant the + * client's call has been cancelled, and that throw used to escape the batch loop: every pair after + * the failing one was neither delivered, acknowledged, nor handed back - and a NonDurable + * subscription has no ackTimeout, so the broker never redelivered them while the runner lived. + * + * The real `received` path is driven here with proxy consumers and a handler that throws exactly as + * a cancelled gRPC call does. Only the broker is replaced. + */ +object mergeDeliveryFailureTest extends ZIOSpecDefault: + + private val consumerName = "cs-merge-fail-0" + private def p(i: Int): String = s"persistent://public/default/merge-fail-partition-$i" + + /** A consumer on `topicFqn` that records which messages it acknowledged and which it handed back + * (negative-acknowledged). Both are the whole question here. `failFirstAck` makes the FIRST + * acknowledgment fail asynchronously - the disconnect race the retry set exists for - while + * every later one succeeds. */ + private final class RecordingConsumer(topicFqn: String, name: String = consumerName, failFirstAck: Boolean = false): + val acknowledged = ConcurrentLinkedQueue[String]() + val handedBack = ConcurrentLinkedQueue[String]() + private val failedOnce = java.util.concurrent.atomic.AtomicBoolean(false) + + val consumer: Consumer[Array[Byte]] = + val handler = new InvocationHandler: + override def invoke(proxy: Object, method: Method, args: Array[Object]): Object = + method.getName match + case "getTopic" => topicFqn + case "getConsumerName" => name + case "isConnected" => java.lang.Boolean.TRUE + case "acknowledgeAsync" => + if failFirstAck && failedOnce.compareAndSet(false, true) then + CompletableFuture.failedFuture(new RuntimeException("broker went away mid-ack")) + else + acknowledged.add(args(0).asInstanceOf[PulsarMessage[Array[Byte]]].getKey) + CompletableFuture.completedFuture(null) + case "negativeAcknowledge" => + handedBack.add(args(0).asInstanceOf[PulsarMessage[Array[Byte]]].getKey) + null + case "equals" => java.lang.Boolean.valueOf(proxy eq args(0)) + case "hashCode" => java.lang.Integer.valueOf(topicFqn.hashCode) + case "toString" => s"proxy-consumer($topicFqn)" + case _ => null + Proxy + .newProxyInstance(getClass.getClassLoader, Array[Class[?]](classOf[Consumer[Array[Byte]]]), handler) + .asInstanceOf[Consumer[Array[Byte]]] + + private def message(topicFqn: String, key: String, publishTime: Long, entryId: Long): MessageImpl[Array[Byte]] = + val md = new MessageMetadata() + md.setPublishTime(publishTime) + md.setPartitionKey(key) + val msg = MessageImpl.create[Array[Byte]](md, ByteBuffer.wrap(s"""{"k":"$key"}""".getBytes("UTF-8")), Schema.BYTES, topicFqn) + msg.setMessageId(new MessageIdImpl(1L, entryId, -1)) + msg + + /** A stream whose backlog ends on `lastEntryId`. */ + private def stream(topicFqn: String, lastEntryId: Long, name: String = consumerName): StartFromStream = + StartFromStream(startFromStreamId(name, topicFqn), EntryPosition(1L, lastEntryId, -1, 1)) + + /** A listener whose delivery handler throws (exactly as a cancelled gRPC `onNext` does) for any + * message whose key is in `throwOn`, and records the rest. */ + private def listenerThrowingOn(throwOn: Set[String], delivered: ConcurrentLinkedQueue[String]): ConsumerListener = + val handler = ConsumerSessionTargetMessageHandler(onNext = msg => + if throwOn.contains(msg.getKey) then throw new io.grpc.StatusRuntimeException(io.grpc.Status.CANCELLED) + delivered.add(msg.getKey) + () + ) + val l = ConsumerListener(handler) + l.startAcceptingNewMessages() + l + + def spec = suite(this.getClass.toString)( + test("a client-cancel throw mid-batch hands the rest back instead of losing them") { + // A skip of 1 over three streams. Offering c1 resolves the WHOLE batch at once: drop c1 + // (globally earliest), then deliver b1 and a1 in order. The client has cancelled, so the + // first delivery (b1) throws. a1 was already dequeued from the merge; without per-message + // containment it is neither delivered nor acknowledged nor handed back, and never comes + // back. With it, b1 is handed back for redelivery and a1 is still delivered. + val delivered = ConcurrentLinkedQueue[String]() + val listener = listenerThrowingOn(Set("b1"), delivered) + listener.startFromOrdering = StartFromOrdering.make[HeldMessage]( + StartFromOrderingPlan.GlobalSkip(1, Vector(stream(p(0), 5), stream(p(1), 5), stream(p(2), 0))) + ) + val c0 = RecordingConsumer(p(0)) + val c1 = RecordingConsumer(p(1)) + val c2 = RecordingConsumer(p(2)) + + // a1 and b1 are held (their streams are still waited for and blind); c1 ends p2's backlog + // and unblocks the merge, resolving the batch. Pulsar catches a throw out of `received` + // per message, so the test does too. + scala.util.Try(listener.received(c0.consumer, message(p(0), "a1", 100L, 0L))) + scala.util.Try(listener.received(c1.consumer, message(p(1), "b1", 90L, 0L))) + scala.util.Try(listener.received(c2.consumer, message(p(2), "c1", 50L, 0L))) + + assertTrue( + c2.acknowledged.asScala.toVector == Vector("c1"), // dropped by the skip + c1.handedBack.asScala.toVector == Vector("b1"), // its delivery threw -> handed back + delivered.asScala.toVector == Vector("a1"), // the message AFTER the throw still got out + c0.acknowledged.asScala.toVector == Vector("a1") // and was acknowledged + ) ?? (s"acked(c2)=${c2.acknowledged.asScala.toVector} handedBack(c1)=${c1.handedBack.asScala.toVector} " + + s"delivered=${delivered.asScala.toVector} acked(c0)=${c0.acknowledged.asScala.toVector}") + }, + test("a Drop resolved by ANOTHER target's offer registers its failed ack where the redelivery arrives") { + // Two TARGETS (two listeners) share one session-wide ordering. Target B's offer + // resolves target A's held message as the drop; A's broker connection fumbles the ack. + // The failed-ack id must be remembered on A - the redelivery arrives THERE - or A + // re-decides it as a fresh message and, with the skip settled, DELIVERS the one + // message the user asked to skip. The old code remembered it on the listener that + // happened to process the batch: B. + val deliveredA = ConcurrentLinkedQueue[String]() + val deliveredB = ConcurrentLinkedQueue[String]() + val listenerA = listenerThrowingOn(Set.empty, deliveredA) + val listenerB = listenerThrowingOn(Set.empty, deliveredB) + val shared = StartFromOrdering.make[HeldMessage]( + StartFromOrderingPlan.GlobalSkip(1, Vector(stream(p(0), 5, name = "csA"), stream(p(1), 0, name = "csB"))) + ) + listenerA.startFromOrdering = shared + listenerB.startFromOrdering = shared + val cA = RecordingConsumer(p(0), name = "csA", failFirstAck = true) + val cB = RecordingConsumer(p(1), name = "csB") + + // a1 (globally earliest) is held on A; b1 ends B's backlog and resolves the batch on + // B's thread: drop a1, deliver b1. a1's ack fails asynchronously - the decision stands. + scala.util.Try(listenerA.received(cA.consumer, message(p(0), "a1", 50L, 0L))) + scala.util.Try(listenerB.received(cB.consumer, message(p(1), "b1", 100L, 0L))) + // The broker redelivers the un-acked a1 to ITS listener: A. + scala.util.Try(listenerA.received(cA.consumer, message(p(0), "a1", 50L, 0L))) + + assertTrue( + deliveredA.asScala.toVector.isEmpty, // the skipped message never reaches the user + deliveredB.asScala.toVector == Vector("b1"), + cA.handedBack.asScala.toVector == Vector("a1"), // the failed ack handed it back once + cA.acknowledged.asScala.toVector == Vector("a1"), // the redelivery is finalized silently + listenerA.awaitingAckRetryCount == 0, // and the paperwork is closed on A... + listenerB.awaitingAckRetryCount == 0 // ...not parked forever on B + ) ?? (s"deliveredA=${deliveredA.asScala.toVector} deliveredB=${deliveredB.asScala.toVector} " + + s"handedBack(cA)=${cA.handedBack.asScala.toVector} acked(cA)=${cA.acknowledged.asScala.toVector} " + + s"retryA=${listenerA.awaitingAckRetryCount} retryB=${listenerB.awaitingAckRetryCount}") + } + ) diff --git a/server/src/test/scala/consumer/session_runner/messageConvertersTest.scala b/server/src/test/scala/consumer/session_runner/messageConvertersTest.scala new file mode 100644 index 0000000000000000000000000000000000000000..ec04fe1cdf50088149fa3c049548ffe0f19e4a00 GIT binary patch literal 6602 zcmc&(+iu*(8E$X&6n_Z;@+t*-+ehg456`eqUB4Nlq zcXuj%8HikjSJ+!5M(7|+VwqVi#=!*3si=wGyA=W_!7FG1wGQ$7fdeW{Rpw~tT3J<} zdL^xhgl$;;00p3w4K^Bcd=PUHWLRf)_lnsJrm8GuLXn8zLv$UyP)Q;~3v0em*<5NX zwP70LjSaeJtXPS9LDM2mB12PUugG3WaxzVe8KtE%WMB!h%DtRwbpsM4vdBHZoE*QV zc`R@6Bk&m-EkQq6?!xCOF zkD?tp3-xsm8$%4|@7|E7b zM%Ml@0iD8akf4BA0$#il5sBnREDSp%A{PZTgY@#F2$_z(iVY!b5G~I7<25y9Kh-o_ z;G6=0IA&!AjQs8Jc#_L-U(Q64*zGmQMi4lj3+n`c&UqZZ7OA8i@^SzmHaKaw$K$-1 z!e!&AJUVt8N5Wg7&<$-6fj`Rp&|MGqJITQBzQKv*Y~T+DeK{dQt)}nTvl6iUHcd(p z{YbBiBmt=mU&CfgE*6OrQL_yi@&>Q7Lo4WPV`;YOmB`H~K2d74Az=G0&uqTY6(ina`s|9A;&~w`4T#)q^ zWr8T#I{)+L=J{s(@2J%pL^$OBP`bJ#P_Yv5BTM>?T=>}2JJYzVWm}#)sD0@-TiSyN z>bYpUdjZ|St&D`#$kO#Cec3RhYo%1Eg z9%kGzy+x^iRM}$}<5l|WCZkRfuA4a~HVD!(k(K@1aT;5T)Z#Ed01irejVst4!7LFL zcJa@HtFX{Up(#{1*;lblBHx3!-XIXB7NMb0S&LONC}fBoQ=m=oxgIk5UZBhy>@G9+ zpp6{v!+cLro}fE$RyKv6#|*&QU4qkvH=x^|prD76aHLG}^_ zx15NQrzA{HXpIgWip&TCyCex_%=5FdXOCFl_>N`}n*(lc+p4*mt7==Y;m~B}nD?Zf zBYU7~J!Ysr%WgsHnFupoH;3Y>5qw1+&UsDgV}Yu<$vHwGhYr!*qM<@7^eXaL+Cm{4awpnHv4jh#Ev;_2Oz8-F5p8q?(;A~m{s_!YvnMS zT_zke%Us-z#h|hAwO?4X*N#2Nd zahbzZ^6-KfNC7ZzHb&LW$xRFsR(B>GNbOYvb_)z*EQRCGrz#VGR=-=|6orlu7_Ty1 z-5-_BKu;RpTQ^^9y7s{}l`DJm={It*?-c9pj_PcBG7W&5-Z{ou6=-NRQJ0ny9KYvJ z(~t6H6wup79~RmkwjbENgY1c_?d(a=39{ZgAMR?@=^9I0wQVAA)j_;L3kqAS-)MS^-!d?voL8@WYGwC%*)$5hROZ0`jF52Mc^9gqQl1B zy`Y14U97juxuoo9y*(6&zT(W}{({`V<-uv?&f?4R7y$LY%G=6YjxQK+mM@XsH?vI7 zR~?i%CM_CxNP1lDm0ZfTxW3Kc8F7Uy5#aR?CjBz#geh z^-#EC=8hrmvvR|zG&0}1wN`DaS7Y8)(;(AlBm6&v>c|Z1Xx!g(5??7W598@ETWmf1 V(-;3gbJWd_Uy0G0#r+zr{SWiYD?b1L literal 0 HcmV?d00001 diff --git a/server/src/test/scala/consumer/session_runner/messageIdStartFromTest.scala b/server/src/test/scala/consumer/session_runner/messageIdStartFromTest.scala new file mode 100644 index 000000000..07b8f1c53 --- /dev/null +++ b/server/src/test/scala/consumer/session_runner/messageIdStartFromTest.scala @@ -0,0 +1,143 @@ +package consumer.session_runner + +import org.apache.pulsar.client.api.PulsarClient +import org.apache.pulsar.client.impl.MessageIdImpl +import zio.test.* + +import java.util.concurrent.TimeUnit +import scala.util.Try + +/** Starting from a specific MESSAGE ID, across the physical topics one session covers. + * + * Regression context: a session's topic vector is built by concatenating every enabled target's + * resolved topics, and TWO TARGETS MAY SELECT THE SAME TOPIC - which is a supported configuration + * (each target has its own consumer, its own filters and its own colouring). The concatenation + * therefore named one physical topic twice, the duplicate pushed the vector past the + * single-topic fast path, and the multi-topic lookup read the SAME physical message once per name + * and threw "Multiple messages found for the same message id" on a perfectly valid session. + * + * [[resolveMessageIdAcrossTopics]] is pure - the broker sits behind a `String => Option[M]` lookup + * - so every shape is driven here with a plain lambda: no broker, and no mock. + */ +object messageIdStartFromTest extends ZIOSpecDefault: + + private val orders = "persistent://public/default/orders" + private val payments = "persistent://public/default/payments" + + /** A broker holding `messageOf`, plus a count of how many topics were actually asked. */ + private def broker(messageOf: Map[String, String]): (String => Option[String], () => Int) = + var reads = 0 + val lookup = (topicFqn: String) => + reads += 1 + messageOf.get(topicFqn) + (lookup, () => reads) + + private val messageId = new MessageIdImpl(7L, 3L, 0) + + /** Consumers as the seek planner sees them: just their topic. */ + private def consumersOn(topicFqns: String*): Vector[String] = topicFqns.toVector + + /** A real client aimed at a closed port, so an operational failure is a real one rather than a + * mock's idea of one. Two-second timeouts keep the suite quick. */ + private def withOfflineClient[A](f: PulsarClient => A): A = + val client = PulsarClient.builder.serviceUrl("pulsar://127.0.0.1:1").operationTimeout(2, TimeUnit.SECONDS).build + try f(client) + finally Try(client.close()) + + /** WHERE EACH CONSUMER STARTS once the message has been found. + * + * The whole session used to be seeked by the message's PUBLISH TIME as soon as it covered more + * than one topic - including the topic the id actually belongs to. "Start from this message" + * therefore became "start from this millisecond" on the very topic the user picked it from, and + * every earlier message sharing that millisecond (or that producer batch) came with it. + */ + private val seekSuite = suite("which position each topic is seeked to")( + test("THE TOPIC THAT OWNS THE ID IS SEEKED TO THE EXACT MESSAGE, not to its millisecond") { + val seeks = messageIdSeeks(consumersOn(orders, payments), identity, orders, messageId, 1_700_000_000_123L) + assertTrue(seeks.toMap.apply(orders) == MessageIdSeek.ById(messageId)) ?? s"seeks=$seeks" + }, + test("every OTHER topic is seeked by publish time - the only cross-topic position there is") { + val seeks = messageIdSeeks(consumersOn(orders, payments), identity, orders, messageId, 1_700_000_000_123L) + assertTrue(seeks.toMap.apply(payments) == MessageIdSeek.ByPublishTime(1_700_000_000_123L)) ?? s"seeks=$seeks" + }, + test("both consumers of a topic reached by two targets get the exact id") { + // Each enabled target has its own consumer on the topic, and both own the message. + val seeks = messageIdSeeks(Vector("a" -> orders, "b" -> orders, "c" -> payments), _._2, orders, messageId, 500L) + assertTrue( + seeks.count((_, seek) => seek == MessageIdSeek.ById(messageId)) == 2, + seeks.count((_, seek) => seek == MessageIdSeek.ByPublishTime(500L)) == 1 + ) ?? s"seeks=$seeks" + }, + test("a single-topic session is seeked by id and never by time") { + val seeks = messageIdSeeks(consumersOn(orders), identity, orders, messageId, 500L) + assertTrue(seeks.forall((_, seek) => seek == MessageIdSeek.ById(messageId))) + } + ) + + /** "NOT FOUND" IS A DIAGNOSIS OF THE USER'S INPUT, so it must not be what the server says when + * the fault is its own. Every operational failure used to collapse into the same `None` as a + * genuine absence. + */ + private val honestySuite = suite("a lookup that could not be made is not a lookup that found nothing")( + test("A MESSAGE ID THAT CANNOT BE PARSED is rejected as invalid, not reported as 'not found'") { + val result = withOfflineClient(client => Try(getMessageById(client, orders, Array[Byte](1, 2, 3)))) + assertTrue( + result.isFailure, + result.failed.toOption.exists(_.isInstanceOf[IllegalArgumentException]), + result.failed.toOption.exists(err => !err.getMessage.toLowerCase.contains("not found")) + ) ?? s"result=$result" + }, + test("A BROKER THAT CANNOT BE REACHED FAILS THE LOOKUP instead of answering 'not found'") { + // Reported as absence, this told the user their message id was wrong - and on a + // multi-topic session it let an unreachable topic be silently skipped, so the session + // was positioned from whichever topics happened to answer. + val realId = new MessageIdImpl(1L, 0L, -1).toByteArray + val result = withOfflineClient(client => Try(getMessageById(client, orders, realId))) + assertTrue( + result.isFailure, + result.failed.toOption.exists(_.isInstanceOf[StartFromUnresolvableException]), + result.failed.toOption.exists(_.getMessage.contains(orders)) + ) ?? s"result=$result" + } + ) + + private val dedupSuite = suite("looking the id up across a session's topics")( + test("TWO TARGETS ON THE SAME TOPIC resolve one message, not a duplicate of it") { + // THE regression. The same physical topic named twice is one message, and asking the + // broker for it twice is both wrong and wasteful. + val (lookup, reads) = broker(Map(orders -> "m1")) + val got = Try(resolveMessageIdAcrossTopics(Vector(orders, orders), lookup)) + assertTrue(got.toOption.flatten == Some("m1"), reads() == 1) ?? + s"the same topic reached by two targets must resolve once, got $got after ${reads()} lookups" + }, + test("three targets on one topic and one on another still resolve the single hit") { + val (lookup, reads) = broker(Map(orders -> "m1")) + val got = Try(resolveMessageIdAcrossTopics(Vector(orders, payments, orders, orders), lookup)) + assertTrue(got.toOption.flatten == Some("m1"), reads() == 2) ?? s"got $got after ${reads()} lookups" + }, + test("a message id that no topic holds resolves to nothing") { + val (lookup, _) = broker(Map.empty) + assertTrue(resolveMessageIdAcrossTopics(Vector(orders, payments), lookup).isEmpty) + }, + test("one topic holding it resolves to that message") { + val (lookup, _) = broker(Map(payments -> "m2")) + assertTrue(resolveMessageIdAcrossTopics(Vector(orders, payments), lookup) == Some("m2")) + }, + test("two GENUINELY DIFFERENT topics answering is still ambiguous and still refused") { + // The dedup must not paper over the real ambiguity it was added around: a message id is + // only unique within one topic, so two distinct topics answering means the session + // cannot know which message the user meant. + val (lookup, _) = broker(Map(orders -> "m1", payments -> "m2")) + val got = Try(resolveMessageIdAcrossTopics(Vector(orders, payments), lookup)) + assertTrue( + got.isFailure, + got.failed.toOption.exists(_.getMessage.contains("Multiple messages")) + ) ?? s"two different topics holding the id must be refused, got $got" + }, + test("no topics at all resolve to nothing without asking the broker") { + val (lookup, reads) = broker(Map(orders -> "m1")) + assertTrue(resolveMessageIdAcrossTopics(Vector.empty, lookup).isEmpty, reads() == 0) + } + ) + + def spec = suite(this.getClass.toString)(dedupSuite, seekSuite, honestySuite) diff --git a/server/src/test/scala/consumer/session_runner/nonPersistentTopicsTest.scala b/server/src/test/scala/consumer/session_runner/nonPersistentTopicsTest.scala new file mode 100644 index 000000000..8ec1b9b94 --- /dev/null +++ b/server/src/test/scala/consumer/session_runner/nonPersistentTopicsTest.scala @@ -0,0 +1,130 @@ +package consumer.session_runner + +import _root_.consumer.start_from.* +import zio.test.* + +import java.time.Instant + +/** Start-from on NON-PERSISTENT topics. + * + * A non-persistent topic stores nothing: no backlog, no history, no entry to address. Every + * history-based position is therefore unsatisfiable on one, and the broker says so - `examineMessage` + * answers HTTP 405 ("Examine messages on a non-persistent topic is not allowed"). + * + * That 405 used to be swallowed: the admin call sits inside a `Try(...).toOption`, so the refusal + * became a `None` and the session quietly fell back to seeking earliest or latest. The user asked + * for a position in history and silently got a different one - the worst kind of failure, because + * the session looks like it worked. + * + * So the decision is made from the topic FQN, BEFORE any consumer is seeked, and it is a validation + * error rather than an incidental exception. Everything here is pure: the rule is a function of the + * mode and the resolved topic names. + */ +object nonPersistentTopicsTest extends ZIOSpecDefault: + + private val persistentTopic = "persistent://public/default/orders" + private val nonPersistentTopic = "non-persistent://public/default/telemetry" + private val nonPersistentPartition = "non-persistent://public/default/telemetry-partition-3" + + private val historyModes: Vector[ConsumerSessionStartFrom] = Vector( + EarliestMessage(), + NthMessageAfterEarliest(n = 10), + NthMessageBeforeLatest(n = 10), + MessageId(messageIdBytes = Array[Byte](8, 1)), + DateTime(dateTime = Instant.ofEpochSecond(1_700_000_000L)), + RelativeDateTime(value = 1, unit = DateTimeUnit.Hour, isRoundedToUnitStart = false), + ApproximateDataPosition(fraction = 0.5), + ApproximateTimePosition(fraction = 0.5) + ) + + private val detectionSuite = suite("detecting a topic with no history")( + test("a non-persistent topic is recognised by its scheme, without asking the broker") { + assertTrue( + isNonPersistentTopic(nonPersistentTopic), + isNonPersistentTopic(nonPersistentPartition) + ) + }, + test("a persistent topic is not mistaken for one") { + assertTrue( + !isNonPersistentTopic(persistentTopic), + !isNonPersistentTopic("persistent://public/default/orders-partition-0"), + // The substring appears inside the name, not as the scheme. + !isNonPersistentTopic("persistent://public/default/non-persistent-audit") + ) + }, + test("an unqualified name is treated as persistent rather than silently rejected") { + // Rejecting a session is the loud outcome, so it must never be triggered by a name shape + // this function did not expect. + assertTrue(!isNonPersistentTopic("public/default/orders"), !isNonPersistentTopic("")) + } + ) + + private val modeSuite = suite("which modes need a history")( + test("every mode except 'latest message' needs a retained history") { + val wrong = historyModes.filterNot(startFromNeedsRetainedHistory) + assertTrue(wrong.isEmpty) ?? s"these modes cannot work without a backlog but claim they can: $wrong" + }, + test("'earliest message' counts as needing a history, although a seek to it would 'work'") { + // On a non-persistent topic a seek to earliest silently behaves as "from now". Accepting + // it would mean answering a request for the start of the topic with the live tail. + assertTrue(startFromNeedsRetainedHistory(EarliestMessage())) + }, + test("'latest message' is the one position a non-persistent topic can honour") { + assertTrue(!startFromNeedsRetainedHistory(LatestMessage())) + } + ) + + private val rejectionSuite = suite("rejecting what cannot work")( + test("a history position over only non-persistent topics is rejected, naming the mode and the reason") { + val reasons = historyModes.map(mode => mode -> startFromRejectionReason(mode, Vector(nonPersistentTopic))) + val accepted = reasons.collect { case (mode, None) => mode } + val unclear = reasons.collect { + case (mode, Some(reason)) if !reason.contains("non-persistent") || !reason.contains(mode.getClass.getSimpleName) => mode -> reason + } + assertTrue(accepted.isEmpty, unclear.isEmpty) ?? + s"silently accepted: $accepted; rejected without a clear reason: $unclear" + }, + test("the reason names the topics that caused it") { + val reason = startFromRejectionReason(EarliestMessage(), Vector(nonPersistentTopic)) + assertTrue(reason.exists(_.contains(nonPersistentTopic))) + }, + test("'latest message' is accepted on non-persistent topics") { + assertTrue(startFromRejectionReason(LatestMessage(), Vector(nonPersistentTopic, nonPersistentPartition)).isEmpty) + }, + test("a mixed session is NOT rejected - one persistent topic is enough to have a history") { + // Failing the whole session because one of its topics is non-persistent would make a + // history position unusable on any session that happens to include a live topic. + val stillAccepted = historyModes.filter(mode => startFromRejectionReason(mode, Vector(nonPersistentTopic, persistentTopic)).isEmpty) + assertTrue(stillAccepted == historyModes) ?? + s"a mixed session must keep working; these were rejected: ${historyModes.diff(stillAccepted)}" + }, + test("a session over persistent topics only is never rejected") { + val rejected = historyModes.filter(mode => startFromRejectionReason(mode, Vector(persistentTopic)).isDefined) + assertTrue(rejected.isEmpty) + }, + test("a session that resolved to no topics at all is left to the emptiness check") { + // ConsumerSessionRunner.make already rejects that, with a message about the target - + // reporting it here as a non-persistent problem would be misleading. + assertTrue(startFromRejectionReason(EarliestMessage(), Vector.empty).isEmpty) + } + ) + + private val splitSuite = suite("what a mixed session does")( + test("the history position applies to the persistent topics only") { + val (history, liveOnly) = splitByRetainedHistory(Vector(persistentTopic, nonPersistentTopic, nonPersistentPartition), identity) + assertTrue(history == Vector(persistentTopic), liveOnly == Vector(nonPersistentTopic, nonPersistentPartition)) + }, + test("the split is over the physical topic of each consumer, not the session's selector") { + // A partitioned non-persistent topic resolves to one consumer per partition, and each + // has to be recognised on its own. + val consumers = Vector("a" -> persistentTopic, "b" -> nonPersistentPartition) + val (history, liveOnly) = splitByRetainedHistory(consumers, _._2) + assertTrue(history.map(_._1) == Vector("a"), liveOnly.map(_._1) == Vector("b")) + }, + test("a session with no non-persistent topic keeps every consumer on the history path") { + val (history, liveOnly) = splitByRetainedHistory(Vector(persistentTopic, persistentTopic), identity) + assertTrue(history.size == 2, liveOnly.isEmpty) + } + ) + + def spec = suite(this.getClass.toString)(detectionSuite, modeSuite, rejectionSuite, splitSuite) diff --git a/server/src/test/scala/consumer/session_runner/sessionContextConcurrencyTest.scala b/server/src/test/scala/consumer/session_runner/sessionContextConcurrencyTest.scala new file mode 100644 index 000000000..b737bdcdf --- /dev/null +++ b/server/src/test/scala/consumer/session_runner/sessionContextConcurrencyTest.scala @@ -0,0 +1,424 @@ +package consumer.session_runner + +import java.util.concurrent.atomic.AtomicLong + +import _root_.consumer.coloring_rules.ColoringRuleChain +import _root_.consumer.deserializer.Deserializer +import _root_.consumer.deserializer.deserializers.TreatBytesAsJson +import _root_.consumer.message_filter.basic_message_filter.targets.{BasicMessageFilterTarget, BasicMessageFilterValueTarget} +import _root_.consumer.message_filter.{JsMessageFilter, MessageFilter, MessageFilterChain, MessageFilterChainMode} +import _root_.consumer.session_config.ConsumerSessionConfig +import _root_.consumer.session_target.ConsumerSessionTarget +import _root_.consumer.start_from.EarliestMessage +import _root_.consumer.pause_trigger.ConsumerSessionPauseTriggerChain +import _root_.consumer.session_target.consumption_mode.ConsumerSessionTargetConsumptionMode +import _root_.consumer.session_target.consumption_mode.modes.RegularConsumptionMode +import _root_.consumer.session_target.topic_selector.{MultiTopicSelector, TopicSelector} +import _root_.consumer.value_projections.ValueProjectionList +import io.circe.parser.parse as parseJson +import org.apache.pulsar.client.api.{Consumer, Schema} +import org.apache.pulsar.client.impl.{MessageIdImpl, MessageImpl} +import org.apache.pulsar.common.api.proto.MessageMetadata +import zio.test.* + +import java.lang.reflect.{InvocationHandler, Method, Proxy} +import java.nio.ByteBuffer +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.{ConcurrentLinkedQueue, CountDownLatch} +import scala.jdk.CollectionConverters.* + +/** One consumer session owns ONE GraalVM JS context, and every partition of every target is + * delivered on its own Pulsar listener thread. + * + * A GraalVM context may MIGRATE between threads but may not be entered by two at once - the loser + * gets "Multi threaded access requested by thread ... but is not allowed for language(s) js". And + * the context is entered per DELIVERED MESSAGE whether or not the user configured any JS, because + * `setCurrentMessage` is itself a JS call. + * + * Worse than the exception is the state hand-off it hides: `setCurrentMessage` writes the message + * under test into a GLOBAL JS variable, and the filter chain, the coloring rules and the value + * projections all read it back out afterwards. Two threads interleaving there evaluate one + * message's filter against another message's contents - a silently wrong retained set, with no + * exception anywhere. + * + * Everything here runs offline. `ConsumerSessionTargetRunner.resume` installs the REAL production + * message handler and `ConsumerListener.received` is the REAL delivery path; only the broker is + * replaced, by a proxy consumer and hand-built `MessageImpl`s. + */ +object sessionContextConcurrencyTest extends ZIOSpecDefault: + + private val consumerName = "cs-ctx-race-0" + + private def partitionFqn(i: Int): String = s"persistent://public/default/cs-ctx-race-partition-$i" + + /** Retains the even `n`s and nothing else - the oracle for the state hand-off. + * + * The filter reads `message.value` out of the shared context, so if another thread overwrote + * the current message in between, THIS message is judged by THAT message's payload: an odd `n` + * gets retained, or an even one dropped. Both show up as a wrong retained set. + */ + private val evenOnly: MessageFilterChain = + MessageFilterChain( + isEnabled = true, + isNegated = false, + mode = MessageFilterChainMode.All, + filters = Vector( + MessageFilter( + isEnabled = true, + isNegated = false, + targetField = BasicMessageFilterTarget(target = BasicMessageFilterValueTarget()), + filter = JsMessageFilter(jsCode = "v => v.n % 2 === 0") + ) + ) + ) + + private def message(topicFqn: String, n: Int, entryId: Long): MessageImpl[Array[Byte]] = + val md = new MessageMetadata() + // publish_time is mandatory on MessageMetadata - reading it when unset throws. + md.setPublishTime(1_700_000_000_000L + n) + md.setPartitionKey(n.toString) + val msg = MessageImpl.create[Array[Byte]]( + md, + ByteBuffer.wrap(s"""{"n":$n}""".getBytes("UTF-8")), + Schema.BYTES, + topicFqn + ) + // serializeMessage reads msg.getMessageId.toByteArray; MessageImpl.create leaves it null. + msg.setMessageId(new MessageIdImpl(1L, entryId, -1)) + msg + + /** The handful of things `ConsumerListener.received` asks a consumer. + * + * CONNECTED, and the acknowledgment is answered rather than short-circuited. This used to say + * `isConnected = false` purely to skip the acknowledge without a broker, which stopped working + * - and rightly so: a message the consumer cannot answer for is now handed straight back + * instead of being decided, so a disconnected fixture delivers nothing at all and this suite + * would have measured an empty session. A real delivering consumer is connected. + */ + private def consumerOn(topicFqn: String): Consumer[Array[Byte]] = + val handler = new InvocationHandler: + override def invoke(proxy: Object, method: Method, args: Array[Object]): Object = + method.getName match + case "getTopic" => topicFqn + case "getConsumerName" => consumerName + case "isConnected" => java.lang.Boolean.TRUE + case "acknowledgeAsync" => java.util.concurrent.CompletableFuture.completedFuture(null) + case "equals" => java.lang.Boolean.valueOf(proxy eq args(0)) + case "hashCode" => java.lang.Integer.valueOf(topicFqn.hashCode) + case "toString" => s"proxy-consumer($topicFqn)" + case _ => null + Proxy + .newProxyInstance(getClass.getClassLoader, Array[Class[?]](classOf[Consumer[Array[Byte]]]), handler) + .asInstanceOf[Consumer[Array[Byte]]] + + private def targetRunner( + filterChain: MessageFilterChain, + topicFqns: Vector[String], + pool: ConsumerSessionContextPool, + listener: ConsumerListener + ): ConsumerSessionTargetRunner = + ConsumerSessionTargetRunner( + targetIndex = 0, + targetConfig = ConsumerSessionTarget( + isEnabled = true, + consumptionMode = ConsumerSessionTargetConsumptionMode(mode = RegularConsumptionMode()), + messageValueDeserializer = Deserializer(deserializer = TreatBytesAsJson()), + topicSelector = TopicSelector(topicSelector = MultiTopicSelector(topicFqns = topicFqns)), + messageFilterChain = filterChain, + coloringRuleChain = ColoringRuleChain.empty, + valueProjectionList = ValueProjectionList(isEnabled = false, projections = Vector.empty) + ), + nonPartitionedTopicFqns = topicFqns, + schemasByTopic = Map.empty, + sessionContextPool = pool, + consumers = Map.empty, + consumerListener = listener, + stats = ConsumerSessionTargetStats(messageProcessed = AtomicLong(0)) + ) + + /** What one concurrent delivery run produced. */ + private final case class Outcome( + retained: Vector[Int], + dropped: Int, + jsErrors: Vector[String], + escaped: Vector[String], + consoleResults: Vector[String], + finished: Boolean, + // Production counters, read after the run. Both are incremented from the message handler + // BEFORE the context lease, i.e. concurrently from every partition thread. + processedByTarget: Long, + processedBySession: Long + ) + + private def nOf(msg: ConsumerSessionMessage): Option[Int] = + msg.messageValueAsJson.toOption + .flatMap(json => parseJson(json).toOption) + .flatMap(_.hcursor.downField("n").as[Int].toOption) + + /** `partitions` listener threads deliver `perPartition` messages each into ONE listener, exactly + * as Pulsar does for a partitioned topic: one listener per target, one thread per partition. + * + * `consoleRounds > 0` additionally drives `ConsumerServiceImpl.runCode`'s path from a further + * thread - the browser console evaluates in the SAME session context, off a gRPC thread. + */ + private def deliverConcurrently( + filterChain: MessageFilterChain, + partitions: Int, + perPartition: Int, + consoleRounds: Int = 0 + ): Outcome = + val topicFqns = Vector.tabulate(partitions)(partitionFqn) + val pool = ConsumerSessionContextPool() + val listener = ConsumerListener(ConsumerSessionTargetMessageHandler(onNext = _ => ())) + val runner = targetRunner(filterChain, topicFqns, pool, listener) + + // A REAL session, so `incrementNumMessageProcessed` below is the production method rather + // than a stand-in - that counter is stamped onto every pb.Message the browser receives. + val session = ConsumerSessionRunner( + sessionName = "cs-ctx-race", + sessionConfig = ConsumerSessionConfig( + startFrom = EarliestMessage(), + targets = Vector.empty, + messageFilterChain = MessageFilterChain.empty, + coloringRuleChain = ColoringRuleChain.empty, + pauseTriggerChain = ConsumerSessionPauseTriggerChain.empty, + valueProjectionList = ValueProjectionList(isEnabled = false, projections = Vector.empty) + ), + sessionContextPool = pool, + grpcResponseObserver = None, + schemasByTopic = Map.empty, + targets = Map(0 -> runner) + ) + + val retained = new ConcurrentLinkedQueue[Int]() + val dropped = new AtomicInteger(0) + val jsErrors = new ConcurrentLinkedQueue[String]() + val escaped = new ConcurrentLinkedQueue[String]() + val consoleResults = new ConcurrentLinkedQueue[String]() + + runner.resume( + onNext = (msg, _, _, errors) => + errors.foreach(jsErrors.add) + msg match + case Some(m) => nOf(m).foreach(retained.add) + case None => dropped.incrementAndGet() + , + isDebug = true, + incrementNumMessageProcessed = () => session.incrementNumMessageProcessed(), + onStartFromDiscardProgress = () => () + ) + + val start = new CountDownLatch(1) + + def worker(name: String)(body: => Unit): Thread = + val runnable: Runnable = () => + start.await() + try body + catch + case err: Throwable => + escaped.add(s"${err.getClass.getSimpleName}: ${err.getMessage}") + () + val t = new Thread(runnable, name) + t.setDaemon(true) + t + + val deliverers = topicFqns.zipWithIndex.map { (topicFqn, p) => + worker(s"pulsar-listener-$p") { + val consumer = consumerOn(topicFqn) + var i = 0 + while i < perPartition do + // Caught PER MESSAGE, as Pulsar does: an exception out of `received` is logged by + // the consumer's listener executor and the next message is delivered anyway. So + // the run continues past a collision and the retained set stays assertable. + try + // Globally unique `n`, so odd and even are spread across every partition. + listener.received(consumer, message(topicFqn, i * partitions + p, i.toLong)) + catch + case err: Throwable => + escaped.add(s"${err.getClass.getSimpleName}: ${err.getMessage}") + () + i += 1 + } + } + + val consoles = Option.when(consoleRounds > 0) { + worker("grpc-run-code") { + var i = 0 + while i < consoleRounds do + // Exactly what ConsumerServiceImpl.runCode does with the session's pool. + consoleResults.add(pool.withContext(0)(_.runCode("1 + 1"))) + i += 1 + } + }.toVector + + val workers = deliverers ++ consoles + workers.foreach(_.start()) + start.countDown() + workers.foreach(_.join(120_000)) + + Outcome( + retained = retained.asScala.toVector, + dropped = dropped.get, + jsErrors = jsErrors.asScala.toVector, + escaped = escaped.asScala.toVector, + consoleResults = consoleResults.asScala.toVector, + finished = workers.forall(!_.isAlive), + processedByTarget = runner.stats.messageProcessed.get, + processedBySession = session.numMessageProcessed + ) + + def spec = suite(this.getClass.toString)( + test("concurrent partition listeners never collide in the session's JS context") { + val partitions = 3 + val perPartition = 200 + val total = partitions * perPartition + val outcome = deliverConcurrently(evenOnly, partitions, perPartition) + val expected = (0 until total).filter(_ % 2 == 0).toVector + + assertTrue( + outcome.finished, + outcome.escaped.isEmpty, + outcome.jsErrors.isEmpty, + outcome.retained.sorted == expected, + outcome.dropped == total - expected.size + ) ?? (s"escaped=${outcome.escaped.take(3)} jsErrors=${outcome.jsErrors.take(3)} " + + s"retained=${outcome.retained.size}/${expected.size} dropped=${outcome.dropped}/${total - expected.size} " + + s"wronglyRetained=${outcome.retained.filter(_ % 2 != 0).take(5)}") + }, + test("a session with NO user JS enters the context per message too") { + // `setCurrentMessage` is a JS call, so the context is entered before any `isEnabled` + // check - a session that configured no filter at all races just the same. + val partitions = 4 + val perPartition = 150 + val total = partitions * perPartition + val outcome = deliverConcurrently(MessageFilterChain.empty, partitions, perPartition) + + assertTrue( + outcome.finished, + outcome.escaped.isEmpty, + outcome.retained.sorted == (0 until total).toVector, + outcome.dropped == 0 + ) ?? s"escaped=${outcome.escaped.take(3)} retained=${outcome.retained.size}/$total dropped=${outcome.dropped}" + }, + test("the browser console shares the session context with the listener threads") { + // `ConsumerServiceImpl.runCode` evaluates in `getContext(0)` off a gRPC thread while the + // listeners are inside the same context. `runCode` swallows what it catches, so a + // collision here is not an exception - it is an "[ERROR] ..." handed to the user as the + // answer to their expression. + val partitions = 2 + val perPartition = 200 + val outcome = deliverConcurrently(evenOnly, partitions, perPartition, consoleRounds = 200) + val badResults = outcome.consoleResults.filter(_ != "2") + + assertTrue( + outcome.finished, + outcome.escaped.isEmpty, + outcome.consoleResults.size == 200, + badResults.isEmpty + ) ?? s"escaped=${outcome.escaped.take(3)} console=${badResults.take(3)} (${badResults.size} of ${outcome.consoleResults.size})" + }, + test("the session's processed counter survives concurrent increment") { + // The end-to-end test below asserts the counters are RIGHT, but it cannot prove they are + // ATOMIC: the increments sit just before the JS lease, and the lock downstream throttles + // arrivals, so threads almost never collide in that window. It passes either way. + // + // This one contends on the counter directly and does discriminate: with a plain + // read-modify-write it loses updates well before the assertion. + val session = ConsumerSessionRunner( + sessionName = "cs-counter-race", + sessionConfig = ConsumerSessionConfig( + startFrom = EarliestMessage(), + targets = Vector.empty, + messageFilterChain = MessageFilterChain.empty, + coloringRuleChain = ColoringRuleChain.empty, + pauseTriggerChain = ConsumerSessionPauseTriggerChain.empty, + valueProjectionList = ValueProjectionList(isEnabled = false, projections = Vector.empty) + ), + sessionContextPool = ConsumerSessionContextPool(), + grpcResponseObserver = None, + schemasByTopic = Map.empty, + targets = Map.empty + ) + + val threads = 8 + val perThread = 50000 + val start = new CountDownLatch(1) + val workers = Vector.tabulate(threads) { i => + val r: Runnable = () => + start.await() + var k = 0 + while k < perThread do + session.incrementNumMessageProcessed() + k += 1 + val t = new Thread(r, s"pulsar-listener-counter-$i") + t.start() + t + } + start.countDown() + workers.foreach(_.join(60000)) + + val expected = (threads * perThread).toLong + assertTrue(session.numMessageProcessed == expected) ?? + s"expected $expected, counted ${session.numMessageProcessed} (lost ${expected - session.numMessageProcessed})" + }, + test("every delivered message is counted, on every partition thread") { + // Both counters are incremented BEFORE the context lease, so they are the one part of + // the handler that still runs fully concurrently - one listener thread per partition. + // As plain `var Long`s the read-modify-write updates were lost and a partitioned session + // under-reported, shipping the wrong number to the browser on every pb.Message. + // + // Counting is deliberately independent of filtering: `evenOnly` drops half the messages, + // and both counters must still see ALL of them. + val partitions = 4 + val perPartition = 250 + val total = partitions * perPartition + val outcome = deliverConcurrently(evenOnly, partitions, perPartition) + + assertTrue( + outcome.finished, + outcome.processedByTarget == total.toLong, + outcome.processedBySession == total.toLong + ) ?? (s"expected $total; target counted ${outcome.processedByTarget} " + + s"(lost ${total - outcome.processedByTarget}), session counted ${outcome.processedBySession} " + + s"(lost ${total - outcome.processedBySession})") + }, + test("a lease holds the current message for the WHOLE message, not for one JS call") { + // The state hand-off on its own, without waiting for a collision to happen to land. + // `setCurrentMessage` and every read of it are SEPARATE entries into the context, so + // excluding per call is not enough: between this thread's write and its read, another + // partition can legally enter and overwrite `globalThis.__dekaf_currentMessage`. That + // outcome throws nothing - it silently judges one message by another's contents. + val pool = ConsumerSessionContextPool() + val interloperReady = new CountDownLatch(1) + val readBack = new java.util.concurrent.atomic.AtomicReference("") + + // BOTH threads take context 0 EXPLICITLY, not `withNextContext`. The pool is one context + // today (poolSize pinned to 1), so `withNextContext` happens to hand both the same one - + // but nothing pins that pin, and a future pool-size bump would silently give the two + // threads DIFFERENT contexts, so they would never contend and this test would pass + // vacuously. Pinning both to key 0 keeps the collision real whatever the pool size. + val interloper = new Thread( + { () => + interloperReady.countDown() + pool.withContext(0)(_.setCurrentMessage("""{"key":"B"}""", Right("""{"n":2}"""))) + }: Runnable, + "other-partition" + ) + interloper.setDaemon(true) + + pool.withContext(0) { sessionContext => + sessionContext.setCurrentMessage("""{"key":"A"}""", Right("""{"n":1}""")) + interloper.start() + interloperReady.await() + // Not a readiness wait - the opposite. It WIDENS the window deliberately, so that + // "nothing got in" is a claim about the lease rather than about how fast the two + // threads happened to run. + Thread.sleep(200) + readBack.set(sessionContext.runCode(s"$CurrentMessageVarName.key + '/' + $CurrentMessageVarName.value.n")) + } + interloper.join(30_000) + + assertTrue(readBack.get.contains("A/1"), !readBack.get.contains("B")) ?? + s"the message read back mid-lease was not the one this thread set: ${readBack.get}" + } + ) @@ TestAspect.sequential diff --git a/server/src/test/scala/consumer/session_runner/sessionOutputSerializationTest.scala b/server/src/test/scala/consumer/session_runner/sessionOutputSerializationTest.scala new file mode 100644 index 000000000..5f7deeb05 --- /dev/null +++ b/server/src/test/scala/consumer/session_runner/sessionOutputSerializationTest.scala @@ -0,0 +1,446 @@ +package consumer.session_runner + +import _root_.consumer.coloring_rules.ColoringRuleChain +import _root_.consumer.deserializer.Deserializer +import _root_.consumer.deserializer.deserializers.UseLatestTopicSchema +import _root_.consumer.message_filter.MessageFilterChain +import _root_.consumer.pause_trigger.ConsumerSessionPauseTriggerChain +import _root_.consumer.session_config.ConsumerSessionConfig +import _root_.consumer.session_target.ConsumerSessionTarget +import _root_.consumer.session_target.consumption_mode.ConsumerSessionTargetConsumptionMode +import _root_.consumer.session_target.consumption_mode.modes.RegularConsumptionMode +import _root_.consumer.session_target.topic_selector.{MultiTopicSelector, TopicSelector} +import _root_.consumer.start_from.EarliestMessage +import _root_.consumer.value_projections.ValueProjectionList +import com.tools.teal.pulsar.ui.api.v1.consumer as consumerPb +import org.apache.pulsar.client.api.{Consumer, Message as PulsarMessage, Schema} +import org.apache.pulsar.client.impl.{MessageIdImpl, MessageImpl} +import org.apache.pulsar.common.api.proto.MessageMetadata +import zio.test.* + +import java.lang.reflect.{InvocationHandler, Method, Proxy} +import java.nio.ByteBuffer +import java.util.concurrent.atomic.{AtomicBoolean, AtomicInteger, AtomicLong, AtomicReference} +import java.util.concurrent.{CompletableFuture, ConcurrentLinkedQueue, CountDownLatch, TimeUnit} +import scala.jdk.CollectionConverters.* + +/** THE SESSION'S OUTPUT PATH IS SHARED AND ITS INPUTS ARE NOT. + * + * Pulsar delivers each physical topic on its own listener thread. Everything downstream of that is + * shared by the whole session: one global ordering layer that decides the delivery ORDER, one + * gRPC `StreamObserver` that every response leaves through, and one start-from budget. + * + * Three things were unserialized, and each loses something different: + * + * - the ordering layer's lock was released before the messages it resolved were processed, so a + * later message could overtake an earlier one and stateful filters and projections saw them in + * a different order than the merge decided; + * - progress pushes called `StreamObserver.onNext` straight from listener threads, and + * `StreamObserver` is not thread-safe; + * - the discard budget was decremented before the message was acknowledged, so a progress push + * that threw (a cancelled stream, a concurrently entered observer) spent the budget on a + * message that was never acknowledged - its redelivery was then DELIVERED instead of skipped, + * and the session showed a message the user asked to skip. + * + * Everything here runs offline: `ConsumerListener.received` is the real delivery path, driven from + * real threads with a proxy consumer and hand-built messages. Only the broker is replaced. + */ +object sessionOutputSerializationTest extends ZIOSpecDefault: + + private val consumerName = "cs-serialized-0" + private val p0 = "persistent://public/default/cs-serialized-partition-0" + private val p1 = "persistent://public/default/cs-serialized-partition-1" + + /** A delivered message labelled by its partition key, which is what the assertions read back. */ + private def message(topicFqn: String, label: String, publishTime: Long, entryId: Long): MessageImpl[Array[Byte]] = + val md = new MessageMetadata() + md.setPublishTime(publishTime) + md.setPartitionKey(label) + val msg = MessageImpl.create[Array[Byte]](md, ByteBuffer.wrap(s"""{"label":"$label"}""".getBytes("UTF-8")), Schema.BYTES, topicFqn) + msg.setMessageId(new MessageIdImpl(1L, entryId, -1)) + msg + + /** A connected consumer that records what was acknowledged and what was handed back. */ + private final class RecordingConsumer(topicFqn: String): + val acknowledged = ConcurrentLinkedQueue[String]() + val handedBack = ConcurrentLinkedQueue[String]() + + val consumer: Consumer[Array[Byte]] = + val handler = new InvocationHandler: + override def invoke(proxy: Object, method: Method, args: Array[Object]): Object = + method.getName match + case "getTopic" => topicFqn + case "getConsumerName" => consumerName + case "isConnected" => java.lang.Boolean.TRUE + case "acknowledgeAsync" => + acknowledged.add(args(0).asInstanceOf[PulsarMessage[Array[Byte]]].getKey) + CompletableFuture.completedFuture(null) + case "negativeAcknowledge" => + handedBack.add(args(0).asInstanceOf[PulsarMessage[Array[Byte]]].getKey) + null + case "equals" => java.lang.Boolean.valueOf(proxy eq args(0)) + case "hashCode" => java.lang.Integer.valueOf(topicFqn.hashCode) + case "toString" => s"proxy-consumer($topicFqn)" + case _ => null + Proxy + .newProxyInstance(getClass.getClassLoader, Array[Class[?]](classOf[Consumer[Array[Byte]]]), handler) + .asInstanceOf[Consumer[Array[Byte]]] + + private def stream(topicFqn: String, lastEntryId: Long): StartFromStream = + StartFromStream(startFromStreamId(consumerName, topicFqn), EntryPosition(1L, lastEntryId, -1, 1)) + + private def worker(name: String)(body: => Unit): Thread = + val t = new Thread((() => body): Runnable, name) + t.setDaemon(true) + t + + private def targetRunner(consumerListener: ConsumerListener): ConsumerSessionTargetRunner = + ConsumerSessionTargetRunner( + targetIndex = 0, + targetConfig = ConsumerSessionTarget( + isEnabled = true, + consumptionMode = ConsumerSessionTargetConsumptionMode(mode = RegularConsumptionMode()), + messageValueDeserializer = Deserializer(deserializer = UseLatestTopicSchema()), + topicSelector = TopicSelector(topicSelector = MultiTopicSelector(topicFqns = Vector(p0))), + messageFilterChain = MessageFilterChain.empty, + coloringRuleChain = ColoringRuleChain.empty, + valueProjectionList = ValueProjectionList(isEnabled = false, projections = Vector.empty) + ), + nonPartitionedTopicFqns = Vector(p0), + schemasByTopic = Map.empty, + sessionContextPool = ConsumerSessionContextPool(), + consumers = Map.empty, + consumerListener = consumerListener, + stats = ConsumerSessionTargetStats(messageProcessed = AtomicLong(0)) + ) + + private def session(consumerListener: ConsumerListener): ConsumerSessionRunner = + ConsumerSessionRunner( + sessionName = "cs-serialized", + sessionConfig = ConsumerSessionConfig( + startFrom = EarliestMessage(), + targets = Vector.empty, + messageFilterChain = MessageFilterChain.empty, + coloringRuleChain = ColoringRuleChain.empty, + pauseTriggerChain = ConsumerSessionPauseTriggerChain.empty, + valueProjectionList = ValueProjectionList(isEnabled = false, projections = Vector.empty) + ), + sessionContextPool = ConsumerSessionContextPool(), + grpcResponseObserver = None, + schemasByTopic = Map.empty, + targets = Map(0 -> targetRunner(consumerListener)) + ) + + /** A `StreamObserver` exactly as unforgiving as the real one: it is not thread-safe, and it + * says so. `received` is a plain `var List` so a lost update shows up as a missing response, + * and `overlaps` counts every time two threads were inside `onNext` at once. + */ + private final class UnsafeObserver extends io.grpc.stub.StreamObserver[consumerPb.ResumeResponse]: + private val inside = AtomicInteger(0) + val overlaps = AtomicInteger(0) + var received: List[consumerPb.ResumeResponse] = Nil + + override def onNext(value: consumerPb.ResumeResponse): Unit = + if inside.incrementAndGet() != 1 then overlaps.incrementAndGet() + val current = received + Thread.`yield`() + received = value :: current + inside.decrementAndGet() + () + + override def onError(t: Throwable): Unit = () + override def onCompleted(): Unit = () + + private val observerSuite = suite("every response leaves through one serialized sender")( + test("concurrent listener threads never enter the response observer at once") { + // Progress pushes made this concrete: one listener thread per physical topic, each + // calling `onNext` directly on the session's single observer while a normal response + // could be on its way out from another. + val runner = session(ConsumerListener(ConsumerSessionTargetMessageHandler(onNext = _ => ()))) + val observer = UnsafeObserver() + val threads = 8 + val perThread = 400 + val start = CountDownLatch(1) + + val workers = Vector.tabulate(threads) { i => + worker(s"pulsar-listener-$i") { + start.await() + var k = 0 + while k < perThread do + runner.sendResponse(observer, Seq(consumerPb.Message(numMessageProcessed = k.toLong)), Vector.empty) + k += 1 + } + } + workers.foreach(_.start()) + start.countDown() + workers.foreach(_.join(120_000)) + + assertTrue( + observer.overlaps.get == 0, + observer.received.size == threads * perThread + ) ?? s"${observer.overlaps.get} concurrent entries; ${observer.received.size} of ${threads * perThread} responses survived" + }, + test("concurrent progress pushes never enter the response observer at once") { + // The same thing through the production path: every partition thread claiming the + // shared discard, each of which may push a progress frame. + val listener = ConsumerListener(ConsumerSessionTargetMessageHandler(onNext = _ => ())) + listener.startAcceptingNewMessages() + listener.startFromDiscard = StartFromDiscard.shared(80_000) + val runner = session(listener) + val observer = UnsafeObserver() + runner.resume(observer, isDebug = false) + + val threads = 8 + val start = CountDownLatch(1) + val workers = Vector.tabulate(threads) { i => + worker(s"pulsar-listener-progress-$i") { + start.await() + var k = 0 + while k < 10_000 do + listener.decide(p0, canAcknowledge = true) + k += 1 + } + } + workers.foreach(_.start()) + start.countDown() + workers.foreach(_.join(120_000)) + + assertTrue( + observer.overlaps.get == 0, + listener.startFromDiscard.remaining == 0L, + observer.received.nonEmpty + ) ?? s"${observer.overlaps.get} concurrent entries across ${observer.received.size} progress frames" + } + ) + + private val budgetSuite = suite("a failing progress push must not cost a skipped message")( + test("a progress observer that throws still leaves exactly n messages skipped and acknowledged") { + // The client's stream can be cancelled at any moment, and `StreamObserver.onNext` then + // throws. The budget was already decremented by the time it did, and the message was + // NOT acknowledged - so the broker redelivered it, the (now spent) budget let it + // through, and the session showed a message the user had asked to skip. + val listener = ConsumerListener(ConsumerSessionTargetMessageHandler(onNext = _ => ())) + listener.startAcceptingNewMessages() + listener.startFromDiscard = StartFromDiscard.shared(3) + listener.onStartFromDiscardProgress = () => throw new IllegalStateException("call already cancelled") + + val delivered = ConcurrentLinkedQueue[String]() + listener.targetMessageHandler.onNext = msg => delivered.add(msg.getKey) + + val consumer = RecordingConsumer(p0) + val escaped = ConcurrentLinkedQueue[String]() + (1 to 5).foreach { i => + try listener.received(consumer.consumer, message(p0, s"m$i", 100L + i, i.toLong)) + catch case err: Throwable => escaped.add(s"${err.getClass.getSimpleName}: ${err.getMessage}") + } + + assertTrue( + escaped.asScala.toVector.isEmpty, + consumer.acknowledged.asScala.toVector == Vector("m1", "m2", "m3", "m4", "m5"), + delivered.asScala.toVector == Vector("m4", "m5"), + listener.startFromDiscard.remaining == 0L + ) ?? (s"escaped=${escaped.asScala.toVector} acknowledged=${consumer.acknowledged.asScala.toVector} " + + s"delivered=${delivered.asScala.toVector}") + } + ) + + /** Records the ORDER of everything the client sees, and whether the two kinds of event ever + * overlapped. `onNext` is deliberately slow so "nothing overlapped" is a claim about the lock + * rather than about how fast two threads happened to run. + */ + private final class SequencedObserver(writeDelayMs: Long = 0) extends io.grpc.stub.StreamObserver[consumerPb.ResumeResponse]: + val enteredNext = CountDownLatch(1) + private val insideNext = AtomicInteger(0) + val completed = AtomicBoolean(false) + val completedWhileWriting = AtomicBoolean(false) + val nextAfterCompleted = AtomicInteger(0) + private val frames = ConcurrentLinkedQueue[consumerPb.ResumeResponse]() + + def received: Vector[consumerPb.ResumeResponse] = frames.asScala.toVector + + override def onNext(value: consumerPb.ResumeResponse): Unit = + if completed.get then nextAfterCompleted.incrementAndGet() + insideNext.incrementAndGet() + enteredNext.countDown() + if writeDelayMs > 0 then Thread.sleep(writeDelayMs) + frames.add(value) + insideNext.decrementAndGet() + () + + override def onError(t: Throwable): Unit = () + + override def onCompleted(): Unit = + if insideNext.get > 0 then completedWhileWriting.set(true) + completed.set(true) + + /** A listener whose progress snapshot can be interleaved on purpose. The FIRST caller is held + * inside the snapshot and comes away with an INCOMPLETE reading; every caller after it gets the + * COMPLETE one. That is precisely the window `sendResponse` used to leave open - the response, + * including its progress counters, was built BEFORE the send lock was taken. + */ + private final class GatedProgressListener(gate: CountDownLatch, reachedSnapshot: CountDownLatch) + extends ConsumerListener(ConsumerSessionTargetMessageHandler(onNext = _ => ())): + private val callers = AtomicInteger(0) + private val stale = StartFromDiscard.shared(2) + private val fresh = StartFromDiscard.shared(2) + fresh.claim(p0) + fresh.claim(p0) + + override def progressDiscard: StartFromDiscard = + if callers.incrementAndGet() == 1 then + reachedSnapshot.countDown() + gate.await(60, TimeUnit.SECONDS) + stale + else fresh + + private val terminalSuite = suite("progress never goes backwards, and nothing follows the end of the stream")( + test("AN OLDER PROGRESS FRAME CANNOT OVERTAKE A NEWER COMPLETE ONE") { + // The response was built - and its start-from counters read - before `sendLock` was + // taken, so two listener threads could snapshot in one order and send in the other. The + // client clears its progress panel when it sees `complete`, then a stale incomplete + // frame arriving behind it reopened a "skipping..." panel that never went away. + val gate = CountDownLatch(1) + val reached = CountDownLatch(1) + val runner = session(GatedProgressListener(gate, reached)) + val observer = SequencedObserver() + + val older = worker("pulsar-listener-stale")(runner.sendResponse(observer, Seq.empty, Vector.empty)) + older.start() + reached.await(60, TimeUnit.SECONDS) + + val newer = worker("pulsar-listener-fresh")(runner.sendResponse(observer, Seq.empty, Vector.empty)) + newer.start() + // Under the fix the newer thread CANNOT get in: the older one holds the send lock while + // it is held at the snapshot. Under the defect it sails past and lands first. + newer.join(2_000) + + gate.countDown() + older.join(60_000) + newer.join(60_000) + + val progress = observer.received.flatMap(_.consumerStats).flatMap(_.startFromProgress) + val firstComplete = progress.indexWhere(_.complete) + assertTrue( + progress.size == 2, + firstComplete >= 0, + progress.drop(firstComplete).forall(_.complete) + ) ?? s"progress frames in the order the client saw them: ${progress.map(p => s"${p.messagesSkipped}/${p.messagesToSkip} complete=${p.complete}")}" + }, + test("NO RESPONSE REACHES THE CLIENT AFTER THE STREAM HAS BEEN COMPLETED") { + // `stop` called `onCompleted` with no terminal gate at all, so any listener thread still + // in flight - or any later push - called `onNext` on a finished stream. Real gRPC throws + // there; the browser sees a stream that ended and then spoke again. + val runner = session(ConsumerListener(ConsumerSessionTargetMessageHandler(onNext = _ => ()))) + val observer = SequencedObserver() + runner.resume(observer, isDebug = false) + + runner.stop() + runner.sendResponse(observer, Seq(consumerPb.Message(numMessageProcessed = 1L)), Vector.empty) + + assertTrue(observer.completed.get, observer.nextAfterCompleted.get == 0) ?? + s"${observer.nextAfterCompleted.get} responses were written after the stream was completed" + }, + test("THE STREAM IS NOT COMPLETED WHILE A RESPONSE IS STILL BEING WRITTEN") { + // `onCompleted` was called outside the send lock, so it could interleave with an + // `onNext` from a listener thread - and `StreamObserver` is not thread-safe. + val runner = session(ConsumerListener(ConsumerSessionTargetMessageHandler(onNext = _ => ()))) + val observer = SequencedObserver(writeDelayMs = 400) + runner.resume(observer, isDebug = false) + + val writer = worker("pulsar-listener-writing")(runner.sendResponse(observer, Seq(consumerPb.Message()), Vector.empty)) + writer.start() + observer.enteredNext.await(60, TimeUnit.SECONDS) + + runner.stop() + writer.join(60_000) + + assertTrue(observer.completed.get, !observer.completedWhileWriting.get) ?? + "the response stream was completed while a listener thread was inside onNext" + } + ) + + private val orderSuite = suite("resolved messages are processed in the order the merge decided")( + test("a later message cannot overtake the one the merge released first") { + // The merge holds p0/100 until p1 speaks. p1's message resolves BOTH - dropping p1/50 + // and releasing p0/100 - and the releasing thread then has to process p0/100. Meanwhile + // p0's own thread offers 200, which the merge (its budget now spent) passes straight + // through. + // + // Releasing the merge's lock before processing let p0/200 be handled first: the + // session's stateful filters, projections and accumulated state then saw the two + // messages in the opposite order to the one the merge had just decided on. + val listener = ConsumerListener(ConsumerSessionTargetMessageHandler(onNext = _ => ())) + listener.startAcceptingNewMessages() + listener.startFromOrdering = StartFromOrdering.make[HeldMessage]( + StartFromOrderingPlan.GlobalSkip(1, Vector(stream(p0, 9), stream(p1, 0))) + ) + + val processed = ConcurrentLinkedQueue[String]() + val firstEntered = CountDownLatch(1) + listener.targetMessageHandler.onNext = msg => + if msg.getKey == "a1" then + firstEntered.countDown() + // WIDENS the window on purpose: "nothing overtook it" must be a claim about + // the lock, not about how fast the two threads happened to run. + Thread.sleep(300) + processed.add(msg.getKey) + + val consumerP0 = RecordingConsumer(p0) + val consumerP1 = RecordingConsumer(p1) + val escaped = ConcurrentLinkedQueue[String]() + + // p0's first message is held by the merge and returns nothing. + listener.received(consumerP0.consumer, message(p0, "a1", 100L, 0L)) + + val releaser = worker("pulsar-listener-p1") { + try listener.received(consumerP1.consumer, message(p1, "b1", 50L, 0L)) + catch case err: Throwable => escaped.add(s"p1: ${err.getMessage}") + } + val overtaker = worker("pulsar-listener-p0") { + firstEntered.await(30, TimeUnit.SECONDS) + try listener.received(consumerP0.consumer, message(p0, "a2", 200L, 1L)) + catch case err: Throwable => escaped.add(s"p0: ${err.getMessage}") + } + + releaser.start() + overtaker.start() + releaser.join(60_000) + overtaker.join(60_000) + + assertTrue( + escaped.asScala.toVector.isEmpty, + processed.asScala.toVector == Vector("a1", "a2"), + consumerP1.acknowledged.asScala.toVector == Vector("b1") + ) ?? s"escaped=${escaped.asScala.toVector} processed=${processed.asScala.toVector}" + }, + test("a pass-through session is NOT serialized - the ordinary path pays nothing") { + // The lock exists to preserve an order the merge decided. A session with no merge + // decided no order, so taking a session-wide lock per message there would serialize + // every partition's deserialization for nothing. + val ordering = StartFromOrdering.passThrough[String] + val inside = AtomicInteger(0) + val concurrent = AtomicInteger(0) + val start = CountDownLatch(1) + val workers = Vector.tabulate(4) { i => + worker(s"pass-through-$i") { + start.await() + var k = 0 + while k < 200 do + ordering.inOrder { + if inside.incrementAndGet() > 1 then concurrent.incrementAndGet() + Thread.`yield`() + inside.decrementAndGet() + } + k += 1 + } + } + workers.foreach(_.start()) + start.countDown() + workers.foreach(_.join(60_000)) + + assertTrue(concurrent.get > 0) ?? + "a pass-through ordering layer took a session-wide lock it has no order to protect" + } + ) + + def spec = suite(this.getClass.toString)(observerSuite, budgetSuite, terminalSuite, orderSuite) @@ TestAspect.sequential diff --git a/server/src/test/scala/consumer/session_runner/sessionResourceSafetyTest.scala b/server/src/test/scala/consumer/session_runner/sessionResourceSafetyTest.scala new file mode 100644 index 000000000..1b6bd6000 --- /dev/null +++ b/server/src/test/scala/consumer/session_runner/sessionResourceSafetyTest.scala @@ -0,0 +1,427 @@ +package consumer.session_runner + +import _root_.consumer.coloring_rules.ColoringRuleChain +import _root_.consumer.deserializer.Deserializer +import _root_.consumer.deserializer.deserializers.UseLatestTopicSchema +import _root_.consumer.message_filter.MessageFilterChain +import _root_.consumer.pause_trigger.ConsumerSessionPauseTriggerChain +import _root_.consumer.session_config.ConsumerSessionConfig +import _root_.consumer.session_target.ConsumerSessionTarget +import _root_.consumer.session_target.consumption_mode.ConsumerSessionTargetConsumptionMode +import _root_.consumer.session_target.consumption_mode.modes.RegularConsumptionMode +import _root_.consumer.session_target.topic_selector.{MultiTopicSelector, TopicSelector} +import _root_.consumer.start_from.EarliestMessage +import _root_.consumer.value_projections.ValueProjectionList +import com.tools.teal.pulsar.ui.api.v1.consumer as consumerPb +import org.apache.pulsar.client.api.Consumer +import zio.test.* + +import java.lang.reflect.{InvocationHandler, Method, Proxy} +import java.util.concurrent.atomic.{AtomicBoolean, AtomicLong} +import java.util.concurrent.ConcurrentLinkedQueue +import scala.jdk.CollectionConverters.* +import scala.util.Try + +/** A CONSUMER SESSION OWNS BROKER RESOURCES, and every path that stops owning them has to release + * them. + * + * A session holds one Pulsar consumer per physical topic (each with a live subscription and its own + * listener thread) and one GraalVM engine with its JS contexts. Four paths used to drop the handle + * without releasing anything: + * + * - a target subscribing to several topics built them in a plain `map`, so a failure on the third + * topic left the first two subscribed and unreachable - the partly-built runner was never + * returned, so nothing could close them; + * - the same one level up: a session builds one runner per enabled target; + * - creating a session under a name that already existed simply overwrote the old runner, whose + * consumers went on consuming for the life of the process; + * - stopping swallowed unsubscribe failures and never closed the consumers, the Graal contexts or + * the client's response stream at all. + * + * The broker sits behind plain functions and proxy consumers, so all of it runs offline. + */ +object sessionResourceSafetyTest extends ZIOSpecDefault: + + private val topicA = "persistent://public/default/res-a" + private val topicB = "persistent://public/default/res-b" + + private val buildSuite = suite("building a set of resources, all or nothing")( + test("a failure part-way through RELEASES everything already built") { + // THE leak. Three subscriptions, the third refused: without this the first two stayed + // subscribed with nothing holding a handle to them. + val released = ConcurrentLinkedQueue[String]() + val result = Try(buildAllOrRelease[String, String]( + inputs = Vector("a", "b", "boom"), + build = input => if input == "boom" then throw new RuntimeException("broker refused") else s"consumer-$input", + release = released.add(_) + )) + assertTrue( + result.isFailure, + result.failed.toOption.exists(_.getMessage.contains("broker refused")), + released.asScala.toVector == Vector("consumer-a", "consumer-b") + ) ?? s"result=$result released=${released.asScala.toVector}" + }, + test("the ORIGINAL failure propagates, not one thrown while cleaning up") { + // Cleaning up is second-chance work: a close that fails on the way out must not replace + // the cause of the failure with a consequence of it. + val released = ConcurrentLinkedQueue[String]() + val result = Try(buildAllOrRelease[String, String]( + inputs = Vector("a", "b", "boom"), + build = input => if input == "boom" then throw new RuntimeException("broker refused") else s"consumer-$input", + release = resource => + released.add(resource) + throw new IllegalStateException("close also failed") + )) + assertTrue( + result.failed.toOption.exists(_.getMessage.contains("broker refused")), + released.asScala.toVector == Vector("consumer-a", "consumer-b") + ) ?? s"result=$result released=${released.asScala.toVector}" + }, + test("nothing is released when everything builds") { + val released = ConcurrentLinkedQueue[String]() + val built = buildAllOrRelease[String, String](Vector("a", "b"), input => s"consumer-$input", released.add(_)) + assertTrue(built == Vector("consumer-a", "consumer-b"), released.asScala.toVector.isEmpty) + }, + test("the very first failing resource releases nothing and still fails") { + val released = ConcurrentLinkedQueue[String]() + val result = Try(buildAllOrRelease[String, String](Vector("boom"), _ => throw new RuntimeException("no"), released.add(_))) + assertTrue(result.isFailure, released.asScala.toVector.isEmpty) + } + ) + + /** A consumer that records what was done to it, and can refuse to unsubscribe or to close. */ + private final class RecordingConsumer(topicFqn: String, unsubscribeFails: Boolean = false, closeFails: Boolean = false): + val unsubscribed = AtomicBoolean(false) + val closed = AtomicBoolean(false) + val paused = AtomicBoolean(false) + + val consumer: Consumer[Array[Byte]] = + val handler = new InvocationHandler: + override def invoke(proxy: Object, method: Method, args: Array[Object]): Object = + method.getName match + case "getTopic" => topicFqn + case "unsubscribe" => + unsubscribed.set(true) + if unsubscribeFails then throw new RuntimeException(s"cannot unsubscribe from $topicFqn") + null + case "close" => + closed.set(true) + if closeFails then throw new RuntimeException(s"cannot close the consumer for $topicFqn") + null + case "pause" => paused.set(true); null + case "equals" => java.lang.Boolean.valueOf(proxy eq args(0)) + case "hashCode" => java.lang.Integer.valueOf(topicFqn.hashCode) + case "toString" => s"proxy-consumer($topicFqn)" + case _ => null + Proxy + .newProxyInstance(getClass.getClassLoader, Array[Class[?]](classOf[Consumer[Array[Byte]]]), handler) + .asInstanceOf[Consumer[Array[Byte]]] + + private def targetRunner(pool: ConsumerSessionContextPool, consumers: Map[String, Consumer[Array[Byte]]]): ConsumerSessionTargetRunner = + ConsumerSessionTargetRunner( + targetIndex = 0, + targetConfig = ConsumerSessionTarget( + isEnabled = true, + consumptionMode = ConsumerSessionTargetConsumptionMode(mode = RegularConsumptionMode()), + messageValueDeserializer = Deserializer(deserializer = UseLatestTopicSchema()), + topicSelector = TopicSelector(topicSelector = MultiTopicSelector(topicFqns = consumers.keys.toVector)), + messageFilterChain = MessageFilterChain.empty, + coloringRuleChain = ColoringRuleChain.empty, + valueProjectionList = ValueProjectionList(isEnabled = false, projections = Vector.empty) + ), + nonPartitionedTopicFqns = consumers.keys.toVector, + schemasByTopic = Map.empty, + sessionContextPool = pool, + consumers = consumers, + consumerListener = ConsumerListener(ConsumerSessionTargetMessageHandler(onNext = _ => ())), + stats = ConsumerSessionTargetStats(messageProcessed = AtomicLong(0)) + ) + + private def session( + sessionName: String, + pool: ConsumerSessionContextPool, + consumers: Map[String, Consumer[Array[Byte]]] + ): ConsumerSessionRunner = + ConsumerSessionRunner( + sessionName = sessionName, + sessionConfig = ConsumerSessionConfig( + startFrom = EarliestMessage(), + targets = Vector.empty, + messageFilterChain = MessageFilterChain.empty, + coloringRuleChain = ColoringRuleChain.empty, + pauseTriggerChain = ConsumerSessionPauseTriggerChain.empty, + valueProjectionList = ValueProjectionList(isEnabled = false, projections = Vector.empty) + ), + sessionContextPool = pool, + grpcResponseObserver = None, + schemasByTopic = Map.empty, + targets = Map(0 -> targetRunner(pool, consumers)) + ) + + private final class RecordingObserver extends io.grpc.stub.StreamObserver[consumerPb.ResumeResponse]: + val completed = AtomicBoolean(false) + override def onNext(value: consumerPb.ResumeResponse): Unit = () + override def onError(t: Throwable): Unit = () + override def onCompleted(): Unit = completed.set(true) + + private val stopSuite = suite("stopping a session releases what it holds")( + test("a consumer is CLOSED and not merely unsubscribed") { + // Unsubscribing deletes the subscription; the consumer object, its connection and its + // listener thread are only released by closing it. Stopping did the first and not the + // second, so every session ever stopped leaked its consumers. + val pool = ConsumerSessionContextPool() + val a = RecordingConsumer(topicA) + val runner = session("cs-stop", pool, Map(topicA -> a.consumer)) + + runner.stop() + + assertTrue(a.unsubscribed.get, a.closed.get) ?? + s"unsubscribed=${a.unsubscribed.get} closed=${a.closed.get}" + }, + test("a consumer that refuses to unsubscribe is still CLOSED, and the failure is reported") { + // Reported, because deleting the session used to answer OK while the subscription it + // failed to delete stayed on the broker; closed, because a failed unsubscribe must not + // strand the consumer as well. + val pool = ConsumerSessionContextPool() + val a = RecordingConsumer(topicA, unsubscribeFails = true) + val b = RecordingConsumer(topicB) + val runner = session("cs-stop-fail", pool, Map(topicA -> a.consumer, topicB -> b.consumer)) + + val result = Try(runner.stop()) + + assertTrue( + result.isFailure, + result.failed.toOption.exists(_.getMessage.contains(topicA)), + a.closed.get, + // The other consumer must not be stranded by its neighbour's failure. + b.unsubscribed.get, + b.closed.get + ) ?? s"result=$result aClosed=${a.closed.get} bUnsubscribed=${b.unsubscribed.get} bClosed=${b.closed.get}" + }, + test("the session's GraalVM contexts are closed") { + // One engine and one JS context per session, held for the session's whole life. Nothing + // closed them, so every session ever created leaked a Graal context. + val pool = ConsumerSessionContextPool() + val runner = session("cs-stop-graal", pool, Map(topicA -> RecordingConsumer(topicA).consumer)) + val stillUsable = Try(pool.getContext(0).context.eval("js", "1 + 1")).isSuccess + + runner.stop() + + assertTrue(stillUsable, Try(pool.getContext(0).context.eval("js", "1 + 1")).isFailure) ?? + "the session's JS context was still open after the session was stopped" + }, + test("A CONSUMER THAT REFUSES TO CLOSE IS REPORTED, not silently left running") { + // The close was wrapped in a bare `Try` whose result was discarded, so a consumer still + // connected and still holding its listener thread was invisible: `deleteConsumer` + // answered OK with the consumer very much alive. + val pool = ConsumerSessionContextPool() + val stubborn = RecordingConsumer(topicA, closeFails = true) + val runner = session("cs-stop-close-fail", pool, Map(topicA -> stubborn.consumer)) + + val result = Try(runner.stop()) + + assertTrue( + result.isFailure, + result.failed.toOption.exists(_.getMessage.contains(topicA)), + result.failed.toOption.exists(_.getMessage.toLowerCase.contains("close")), + // Unsubscribing still happened - the failure is the close, and only the close. + stubborn.unsubscribed.get + ) ?? s"result=$result" + }, + test("a consumer that refuses BOTH reports both failures") { + val pool = ConsumerSessionContextPool() + val stubborn = RecordingConsumer(topicA, unsubscribeFails = true, closeFails = true) + val runner = session("cs-stop-both-fail", pool, Map(topicA -> stubborn.consumer)) + + val message = Try(runner.stop()).failed.toOption.map(_.getMessage).getOrElse("") + + assertTrue(message.toLowerCase.contains("unsubscribe"), message.toLowerCase.contains("close")) ?? s"message=$message" + }, + test("the client's response stream is completed") { + // The stored observer was left open: the browser kept a stream to a session that no + // longer exists and was never told it had ended. + val pool = ConsumerSessionContextPool() + val runner = session("cs-stop-observer", pool, Map(topicA -> RecordingConsumer(topicA).consumer)) + val observer = RecordingObserver() + runner.resume(observer, isDebug = false) + + runner.stop() + + assertTrue(observer.completed.get) + } + ) + + private val replaceSuite = suite("creating a session over one that already exists")( + test("the session it REPLACES is stopped, not abandoned") { + // Creating twice under one name (the browser re-creating on a config change) used to + // overwrite the entry. The old runner's consumers stayed subscribed and delivering, with + // nothing left holding a handle to them. + val sessions = new java.util.concurrent.ConcurrentHashMap[String, ConsumerSessionRunner]() + val oldConsumer = RecordingConsumer(topicA) + val oldPool = ConsumerSessionContextPool() + val newConsumer = RecordingConsumer(topicA) + val replacement = session("cs-dup", ConsumerSessionContextPool(), Map(topicA -> newConsumer.consumer)) + + storeConsumerSession(sessions, "cs-dup", session("cs-dup", oldPool, Map(topicA -> oldConsumer.consumer))) + storeConsumerSession(sessions, "cs-dup", replacement) + + assertTrue( + oldConsumer.unsubscribed.get, + oldConsumer.closed.get, + !newConsumer.closed.get, + sessions.get("cs-dup") eq replacement + ) ?? s"oldClosed=${oldConsumer.closed.get} newClosed=${newConsumer.closed.get}" + }, + test("a first create under a fresh name stops nothing") { + val sessions = new java.util.concurrent.ConcurrentHashMap[String, ConsumerSessionRunner]() + val consumer = RecordingConsumer(topicA) + val runner = session("cs-fresh", ConsumerSessionContextPool(), Map(topicA -> consumer.consumer)) + + storeConsumerSession(sessions, "cs-fresh", runner) + + assertTrue(!consumer.closed.get, sessions.get("cs-fresh") eq runner) + }, + test("a replaced session that fails to stop is still replaced") { + // Otherwise one undeletable subscription would make the name permanently unusable. + val sessions = new java.util.concurrent.ConcurrentHashMap[String, ConsumerSessionRunner]() + val stubborn = RecordingConsumer(topicA, unsubscribeFails = true) + val replacement = session("cs-dup-fail", ConsumerSessionContextPool(), Map(topicB -> RecordingConsumer(topicB).consumer)) + + storeConsumerSession(sessions, "cs-dup-fail", session("cs-dup-fail", ConsumerSessionContextPool(), Map(topicA -> stubborn.consumer))) + val result = Try(storeConsumerSession(sessions, "cs-dup-fail", replacement)) + + assertTrue(result.isSuccess, stubborn.closed.get, sessions.get("cs-dup-fail") eq replacement) + } + ) + + /** A pool that refuses to give its contexts up. Subclassed rather than mocked, so the real + * aggregation path in `ConsumerSessionRunner.stop` is the thing under test. */ + private final class RefusingPool extends ConsumerSessionContextPool(isDebug = false): + override def close(): Vector[String] = Vector("JS context 0: still executing on another thread") + + /** Real clients aimed at a closed port, so a construction failure is a real broker failure + * rather than a mock's idea of one. */ + private def withOfflineClients[A](f: (org.apache.pulsar.client.api.PulsarClient, org.apache.pulsar.client.admin.PulsarAdmin) => A): A = + val client = org.apache.pulsar.client.api.PulsarClient.builder + .serviceUrl("pulsar://127.0.0.1:1") + .operationTimeout(2, java.util.concurrent.TimeUnit.SECONDS) + .build + val admin = org.apache.pulsar.client.admin.PulsarAdmin.builder + .serviceHttpUrl("http://127.0.0.1:1") + .connectionTimeout(2, java.util.concurrent.TimeUnit.SECONDS) + .readTimeout(2, java.util.concurrent.TimeUnit.SECONDS) + .requestTimeout(2, java.util.concurrent.TimeUnit.SECONDS) + .build + try f(client, admin) + finally + Try(client.close()) + Try(admin.close()) + + private val oneEnabledTarget = ConsumerSessionConfig( + startFrom = EarliestMessage(), + targets = Vector(ConsumerSessionTarget( + isEnabled = true, + consumptionMode = ConsumerSessionTargetConsumptionMode(mode = RegularConsumptionMode()), + messageValueDeserializer = Deserializer(deserializer = UseLatestTopicSchema()), + topicSelector = TopicSelector(topicSelector = MultiTopicSelector(topicFqns = Vector(topicA))), + messageFilterChain = MessageFilterChain.empty, + coloringRuleChain = ColoringRuleChain.empty, + valueProjectionList = ValueProjectionList(isEnabled = false, projections = Vector.empty) + )), + messageFilterChain = MessageFilterChain.empty, + coloringRuleChain = ColoringRuleChain.empty, + pauseTriggerChain = ConsumerSessionPauseTriggerChain.empty, + valueProjectionList = ValueProjectionList(isEnabled = false, projections = Vector.empty) + ) + + private def poolIsClosed(pool: ConsumerSessionContextPool): Boolean = + Try(pool.getContext(0).context.eval("js", "1 + 1")).isFailure + + private val constructionSuite = suite("a session that cannot be built releases what it had already taken")( + test("A TARGET THAT FAILS TO BUILD CLOSES THE SESSION'S GRAAL POOL") { + // The pool is the FIRST thing a session takes and was created outside the all-or-nothing + // guard, so a target that failed to resolve its topics released the targets built before + // it and left a whole GraalVM engine open with nothing holding a handle to it. The + // browser retries a failed create, so this leaked an engine per attempt. + val pool = ConsumerSessionContextPool() + val usableBefore = !poolIsClosed(pool) + + val result = withOfflineClients((client, admin) => + Try(ConsumerSessionRunner.make( + pulsarClient = client, + adminClient = admin, + sessionName = "cs-build-fail", + sessionConfig = oneEnabledTarget, + sessionContextPool = pool + )) + ) + + assertTrue(usableBefore, result.isFailure, poolIsClosed(pool)) ?? + s"result=$result poolClosed=${poolIsClosed(pool)}" + }, + test("a session with no enabled targets closes the pool as well") { + val pool = ConsumerSessionContextPool() + val result = withOfflineClients((client, admin) => + Try(ConsumerSessionRunner.make( + pulsarClient = client, + adminClient = admin, + sessionName = "cs-no-targets", + sessionConfig = oneEnabledTarget.copy(targets = Vector.empty), + sessionContextPool = pool + )) + ) + assertTrue(result.isFailure, poolIsClosed(pool)) + } + ) + + private val aggregationSuite = suite("stopping reports everything it could not release")( + test("A GRAAL POOL THAT WILL NOT CLOSE IS REPORTED, not swallowed twice over") { + // Swallowed once inside `close` and then discarded again by the caller, so a JS context + // holding its heap for the life of the process was reported to the client as released. + val runner = session("cs-stop-pool-fail", RefusingPool(), Map(topicA -> RecordingConsumer(topicA).consumer)) + + val result = Try(runner.stop()) + + assertTrue( + result.isFailure, + result.failed.toOption.exists(_.getMessage.contains("JS context 0")) + ) ?? s"result=$result" + }, + test("an UNEXPECTED throw out of a target's stop is a failure, not an empty result") { + // `Try(target.stop()).getOrElse(Vector.empty)` read "this target blew up" as "this + // target released everything cleanly". + val pool = ConsumerSessionContextPool() + val exploding = new ConsumerSessionTargetRunner( + targetIndex = 7, + targetConfig = ConsumerSessionTarget( + isEnabled = true, + consumptionMode = ConsumerSessionTargetConsumptionMode(mode = RegularConsumptionMode()), + messageValueDeserializer = Deserializer(deserializer = UseLatestTopicSchema()), + topicSelector = TopicSelector(topicSelector = MultiTopicSelector(topicFqns = Vector(topicA))), + messageFilterChain = MessageFilterChain.empty, + coloringRuleChain = ColoringRuleChain.empty, + valueProjectionList = ValueProjectionList(isEnabled = false, projections = Vector.empty) + ), + nonPartitionedTopicFqns = Vector(topicA), + schemasByTopic = Map.empty, + sessionContextPool = pool, + consumers = Map.empty, + consumerListener = ConsumerListener(ConsumerSessionTargetMessageHandler(onNext = _ => ())), + stats = ConsumerSessionTargetStats(messageProcessed = AtomicLong(0)) + ): + override def stop(): Vector[String] = throw new IllegalStateException("the target could not be released at all") + + val runner = session("cs-stop-target-throws", pool, Map.empty).copy(targets = Map(7 -> exploding)) + + val result = Try(runner.stop()) + + assertTrue( + result.isFailure, + result.failed.toOption.exists(_.getMessage.contains("could not be released at all")), + result.failed.toOption.exists(_.getMessage.contains("target 7")) + ) ?? s"result=$result" + } + ) + + def spec = + suite(this.getClass.toString)(buildSuite, stopSuite, replaceSuite, constructionSuite, aggregationSuite) @@ TestAspect.sequential diff --git a/server/src/test/scala/consumer/session_runner/startFromBrokerFailureTest.scala b/server/src/test/scala/consumer/session_runner/startFromBrokerFailureTest.scala new file mode 100644 index 000000000..6837c14e3 --- /dev/null +++ b/server/src/test/scala/consumer/session_runner/startFromBrokerFailureTest.scala @@ -0,0 +1,259 @@ +package consumer.session_runner + +import org.apache.pulsar.client.admin.PulsarAdminException +import org.apache.pulsar.client.api.Consumer +import zio.test.* + +import java.lang.reflect.{InvocationHandler, Method, Proxy} +import java.util.concurrent.TimeoutException +import scala.util.Try + +/** A BROKER THAT COULD NOT ANSWER IS NOT AN EMPTY TOPIC. + * + * Every start-from position that has to be looked up went through `Try(...).toOption`, so a + * timeout, a 401, a 404 or a broker restarting mid-request produced exactly the same `None` as + * "this log holds nothing there" - and every caller reads that `None` as an answer: + * + * - `resolveLatestN` reads it as "the log holds fewer than n messages" and seeks to EARLIEST, so + * a transient 500 turned "the latest 5 messages" into the entire backlog; + * - the approximate-data seek reads it as "that entry is gone" and falls back to EARLIEST; + * - the approximate-time span reads it as "this partition holds nothing" and computes the cutoff + * from only the partitions that happened to answer; + * - the global merge reads a failed `getLastMessageIds` as "this stream is already drained" and + * stops waiting for it, so a whole partition can be left out of a global skip or latest. + * + * In every case the session was created successfully and started somewhere the user did not ask + * for. The two states are kept apart by [[isEmptyLogAnswer]], whose classification is MEASURED - + * see its scaladoc for the exact status codes and reasons this Pulsar answers with. + */ +object startFromBrokerFailureTest extends ZIOSpecDefault: + + private val topicFqn = "persistent://public/default/failing" + private val otherTopicFqn = "persistent://public/default/failing-other" + + /** The measured 412 an EMPTY topic answers `examinemessage` with. */ + private def emptyTopicError: Throwable = + PulsarAdminException.PreconditionFailedException( + new RuntimeException("Could not examine messages due to the total message is zero"), + "Could not examine messages due to the total message is zero", + 412 + ) + + /** The measured 500 a walk that ran PAST THE START of the log answers with. */ + private val pastStartReason = + "\n --- An unexpected error occurred in the server ---\n\nMessage: Incorrect parameter input error code: -14\n\n" + + "Stacktrace:\n\norg.apache.bookkeeper.mledger.ManagedLedgerException: Incorrect parameter input error code: -14" + + private def pastStartOfLogError: Throwable = + PulsarAdminException.ServerSideErrorException(new RuntimeException("past the start"), pastStartReason, pastStartReason, 500) + + /** A 500 that means the broker is unwell, not that the log ran out. */ + private val serverErrorReason = "\n --- An unexpected error occurred in the server ---\n\nMessage: Failed to get managed ledger" + + private def serverError: Throwable = + PulsarAdminException.ServerSideErrorException(new RuntimeException("boom"), serverErrorReason, serverErrorReason, 500) + + /** MEASURED: a broker that cannot be reached at all reports the SAME statusCode 500 as a walk + * that ran off the start of the log, with a null `httpError`. It is the one shape that makes + * classifying on the status code alone unsafe. */ + private def unreachableBrokerError: Throwable = + PulsarAdminException( + new java.util.concurrent.CompletionException(new RuntimeException("retries exhausted")), + "java.util.concurrent.CompletionException: org.apache.pulsar.client.admin.internal.http.AsyncHttpConnector$RetryException: " + + "Could not complete the operation. Number of retries has been exhausted. Failed reason: connection refused", + 500 + ) + + private val classificationSuite = suite("telling an empty log from a broker that could not answer")( + test("the measured EMPTY-TOPIC answer is an answer") { + assertTrue(isEmptyLogAnswer(emptyTopicError)) + }, + test("the measured PAST-THE-START answer is an answer") { + assertTrue(isEmptyLogAnswer(pastStartOfLogError)) + }, + test("an answer buried in a cause chain is still recognised") { + // The admin client wraps, and so does everything between here and it. + val wrapped = new RuntimeException("Failed to resolve start position", new RuntimeException("wrapper", pastStartOfLogError)) + assertTrue(isEmptyLogAnswer(wrapped)) + }, + test("an unrelated server error is NOT an empty log") { + // THE defect: this used to be indistinguishable from an empty topic. + assertTrue(!isEmptyLogAnswer(serverError)) + }, + test("an UNREACHABLE BROKER is not an empty log, although it reports the same 500") { + // MEASURED, and the reason the status code alone cannot decide this: a broker that was + // never reached answers with statusCode 500 exactly as a walk past the start of the log + // does. Classifying 500 as "nothing there" would have turned every connection failure + // into a silent seek to earliest. + assertTrue(!isEmptyLogAnswer(unreachableBrokerError)) + }, + test("a timeout, an authorization failure and a missing topic are NOT empty logs") { + val timeout = new TimeoutException("Request timed out after 30000 ms") + val notAuthorized = PulsarAdminException.NotAuthorizedException(new RuntimeException("no"), "Don't have permission", 401) + val notFound = PulsarAdminException.NotFoundException(new RuntimeException("no"), "Topic not found", 404) + val misclassified = Vector[Throwable](timeout, notAuthorized, notFound).filter(isEmptyLogAnswer) + assertTrue(misclassified.isEmpty) ?? s"read as an empty log: ${misclassified.map(_.getMessage)}" + }, + test("an exception carrying no message at all is NOT an empty log") { + assertTrue(!isEmptyLogAnswer(new RuntimeException())) + }, + test("a cause chain that loops terminates instead of hanging") { + // Bounded on purpose: this runs on the session-creation path, and the JVM permits a + // cycle of length two even though it refuses direct self-causation. + val first = new RuntimeException("round") + val second = new RuntimeException("and round") + first.initCause(second) + second.initCause(first) + assertTrue(!isEmptyLogAnswer(first)) + } + ) + + private val lookupSuite = suite("asking the broker one question")( + test("an answer comes back as an answer") { + assertTrue(brokerAnswer("examining an entry", topicFqn)("entry-1") == Some("entry-1")) + }, + test("'there is nothing there' comes back as None, for both of its shapes") { + val empty = brokerAnswer[String]("examining an entry", topicFqn)(throw emptyTopicError) + val pastStart = brokerAnswer[String]("examining an entry", topicFqn)(throw pastStartOfLogError) + assertTrue(empty.isEmpty, pastStart.isEmpty) + }, + test("a broker that could not answer FAILS, naming the topic and the question") { + // A user has to be able to tell "your topic is empty" from "the broker is unwell", and + // the only place that can be said is here. + val cause = serverError + val result = Try(brokerAnswer[String]("examining an entry", topicFqn)(throw cause)) + assertTrue( + result.isFailure, + result.failed.toOption.exists(_.isInstanceOf[StartFromUnresolvableException]), + result.failed.toOption.exists(_.getMessage.contains(topicFqn)), + result.failed.toOption.exists(_.getMessage.contains("examining an entry")), + // The cause is kept, so the operator sees the broker's own words in the log. + result.failed.toOption.flatMap(err => Option(err.getCause)).exists(_ eq cause) + ) ?? s"result=$result" + } + ) + + /** The production shape: `resolveLatestN` walking entries back from the end of a log, with the + * broker behind [[brokerAnswer]] exactly as `entryFromLatest` puts it there. */ + private def walkBackFrom(answers: Long => Any): String => Long => Option[LogEntry[String]] = + _ => k => brokerAnswer("examining the entry", topicFqn)(answers(k)).map(_ => LogEntry(s"entry-$k", 1_000L, 1)) + + /** `entry-$k` counts back from the end, so a larger ordinal is strictly OLDER - the same order + * `MessageIdImpl.compareTo` gives real entry ids. */ + private def olderByEntryOrdinal(a: String, b: String): Boolean = a.split("-").last.toInt > b.split("-").last.toInt + + private def resolveOne(n: Long, lookup: String => Long => Option[LogEntry[String]]): LatestNSeek[String] = + resolveLatestN(n, Vector(topicFqn), lookup, olderByEntryOrdinal)(topicFqn) + + private val latestNSuite = suite("latest n: a failed lookup must not become 'the log is exhausted'")( + test("A TRANSIENT BROKER FAILURE FAILS THE RESOLUTION instead of seeking to earliest") { + // THE defect. `entryFromLatest` answering None means "this log has no entry there", and + // the walk reads that as the end of the log - so the caller shows all of it. A 500 on + // the third entry of a large log therefore turned "the latest 5" into the whole backlog + // - with the session reporting success. + val lookup = walkBackFrom(k => if k >= 3 then throw serverError else s"message-$k") + val result = Try(resolveOne(5, lookup)) + assertTrue( + result.isFailure, + result.failed.toOption.exists(_.isInstanceOf[StartFromUnresolvableException]) + ) ?? s"a broker failure resolved to $result, which the caller reads as 'show the whole log'" + }, + test("a log genuinely shorter than n still shows all of it") { + // The control, and the reason the two states cannot simply be merged into a failure: + // this is an ordinary, correct outcome that the caller turns into "seek to earliest". + val lookup = walkBackFrom(k => if k > 3 then throw pastStartOfLogError else s"message-$k") + assertTrue(resolveOne(5, lookup) == LatestNSeek.Everything) + }, + test("an empty log maps to EVERYTHING - its whole content at seek time is post-inspection live traffic") { + // The classifier still answers None (not a failure); the MAPPING is what changed: + // seeking earliest preserves anything appended between the inspection and the seek, + // where seek-time "latest" silently lost it. + val lookup = walkBackFrom(_ => throw emptyTopicError) + assertTrue(resolveOne(5, lookup) == LatestNSeek.Everything) + }, + test("a log long enough still resolves exactly") { + val lookup = walkBackFrom(k => if k > 10 then throw pastStartOfLogError else s"message-$k") + assertTrue(resolveOne(3, lookup) == LatestNSeek.FromEntry("entry-3", 0L)) + } + ) + + private val timeSpanSuite = suite("approximate time: a partition that could not be read must not be dropped from the range")( + test("A PARTITION THE BROKER COULD NOT READ FAILS THE RESOLUTION") { + // The range is min(first) .. max(last) across every partition. Silently leaving out the + // partition that failed produced a confident cutoff over a narrower range - a different + // position, reported as success. + val spanOf = (topic: String) => + brokerAnswer("reading the publish-time span", topic) { + if topic == otherTopicFqn then throw serverError else TopicTimeSpan(1_000L, 2_000L) + } + val result = Try(resolveApproximateTimePosition(0.5, Vector(topicFqn, otherTopicFqn), spanOf)) + assertTrue( + result.isFailure, + result.failed.toOption.exists(_.isInstanceOf[StartFromUnresolvableException]) + ) ?? s"the cutoff was computed from only the partitions that answered: $result" + }, + test("a partition that genuinely holds nothing is still skipped, as it must be") { + // An empty partition has no first or last message; counting it would drag the range back + // to 1970 and put every interior fraction before the real data. + val spanOf = (topic: String) => + brokerAnswer("reading the publish-time span", topic) { + if topic == otherTopicFqn then throw emptyTopicError else TopicTimeSpan(1_000L, 2_000L) + } + assertTrue( + resolveApproximateTimePosition(0.5, Vector(topicFqn, otherTopicFqn), spanOf) == + ApproximateTimeSeek.Timestamp(1_500L) + ) + } + ) + + /** A consumer whose `getLastMessageIds` behaves as told. */ + private def consumerOn(topicFqn: String, lastMessageIds: () => java.util.List[org.apache.pulsar.client.api.MessageId]): Consumer[Array[Byte]] = + val handler = new InvocationHandler: + override def invoke(proxy: Object, method: Method, args: Array[Object]): Object = + method.getName match + case "getTopic" => topicFqn + case "getConsumerName" => "cs-failing-0" + case "getLastMessageIds" => lastMessageIds() + case "equals" => java.lang.Boolean.valueOf(proxy eq args(0)) + case "hashCode" => java.lang.Integer.valueOf(topicFqn.hashCode) + case "toString" => s"proxy-consumer($topicFqn)" + case _ => null + Proxy + .newProxyInstance(getClass.getClassLoader, Array[Class[?]](classOf[Consumer[Array[Byte]]]), handler) + .asInstanceOf[Consumer[Array[Byte]]] + + private val streamsSuite = suite("global ordering: a stream whose end could not be read must not be called drained")( + test("A BROKER THAT WILL NOT SAY WHERE A PARTITION ENDS FAILS THE SESSION") { + // A stream recorded as "empty" is never waited for, so a global skip or latest simply + // leaves that whole partition out of the merge - it delivers messages that are neither + // counted nor ordered against the rest. + val consumer = consumerOn(topicFqn, () => throw serverError) + val result = Try(startFromStreamsAt(Vector(consumer))) + assertTrue( + result.isFailure, + result.failed.toOption.exists(_.isInstanceOf[StartFromUnresolvableException]), + result.failed.toOption.exists(_.getMessage.contains(topicFqn)) + ) ?? s"result=$result" + }, + test("a NON-PERSISTENT topic is still recorded as drained, without asking") { + // It retains nothing by definition, so there is no end to read and nothing to wait for. + // Decided from the FQN rather than from whatever the call happens to throw. + val nonPersistent = "non-persistent://public/default/live-only" + val consumer = consumerOn(nonPersistent, () => throw new UnsupportedOperationException("must not be asked")) + val streams = startFromStreamsAt(Vector(consumer)) + assertTrue(streams.map(_.lastAtStart) == Vector(EntryPosition.empty)) + }, + test("a partition that answers is recorded at the end it reported") { + val consumer = consumerOn( + topicFqn, + () => java.util.List.of(new org.apache.pulsar.client.impl.MessageIdImpl(7L, 3L, 0)) + ) + assertTrue(startFromStreamsAt(Vector(consumer)).map(_.lastAtStart) == Vector(EntryPosition(7L, 3L, -1, 1))) + }, + test("an EMPTY partition answers with MessageId.earliest and is drained from the start") { + val consumer = consumerOn(topicFqn, () => java.util.List.of(org.apache.pulsar.client.api.MessageId.earliest)) + assertTrue(startFromStreamsAt(Vector(consumer)).map(_.lastAtStart) == Vector(EntryPosition.empty)) + } + ) + + def spec = suite(this.getClass.toString)(classificationSuite, lookupSuite, latestNSuite, timeSpanSuite, streamsSuite) diff --git a/server/src/test/scala/consumer/session_runner/startFromCountValidationTest.scala b/server/src/test/scala/consumer/session_runner/startFromCountValidationTest.scala new file mode 100644 index 000000000..de5598988 --- /dev/null +++ b/server/src/test/scala/consumer/session_runner/startFromCountValidationTest.scala @@ -0,0 +1,255 @@ +package consumer.session_runner + +import _root_.consumer.start_from.{ + ApproximateDataPosition, + ApproximateTimePosition, + ConsumerSessionStartFrom, + DateTime, + DateTimeUnit, + EarliestMessage, + LatestMessage, + MessageId, + NthMessageAfterEarliest, + NthMessageBeforeLatest, + RelativeDateTime +} +import org.apache.pulsar.client.admin.PulsarAdmin +import org.apache.pulsar.client.api.PulsarClient +import zio.test.* + +import java.util.concurrent.TimeUnit +import scala.util.Try + +/** THE COUNT ON A COUNTING START-FROM IS A NUMBER SOMEBODY TYPED, and it arrives over gRPC as a + * plain `int64` that any client can fill in with anything. + * + * The server used to CLAMP rather than refuse, and a clamp answers a different question without + * saying so: + * + * - "skip the first -1 messages" was read as EARLIEST - the whole topic; + * - "the latest -1 messages" was read as LATEST - nothing retained at all; + * - "the latest 3,000,000,000 messages" was truncated to `Int.MaxValue` inside the retain heap. + * + * Each of those is a valid position the user did not ask for, delivered with a successful session. + * + * THERE IS DELIBERATELY NO UPPER BOUND ON SKIP-N. Skipping n messages is O(n) by nature - Pulsar + * keeps no message-ordinal index - so any cap would be an arbitrary number rather than a limit of + * the design, and the progress API exists precisely so a long skip can be watched. The bound on + * LATEST-N is not a policy choice: its retained set is an in-memory heap of exactly n, indexed by + * Int, so a larger n cannot be represented at all. + */ +object startFromCountValidationTest extends ZIOSpecDefault: + + private def reason(startFrom: ConsumerSessionStartFrom): Option[String] = startFromCountRejectionReason(startFrom) + + private val rejectionSuite = suite("which counts the server refuses")( + test("a NEGATIVE skip-n is refused, not read as 'start at the beginning'") { + val refusals = Vector(-1L, -5L, Long.MinValue).map(n => n -> reason(NthMessageAfterEarliest(n = n))) + assertTrue(refusals.forall((_, why) => why.isDefined)) ?? s"$refusals" + }, + test("a NEGATIVE latest-n is refused, not read as 'show nothing'") { + val refusals = Vector(-1L, -5L, Long.MinValue).map(n => n -> reason(NthMessageBeforeLatest(n = n))) + assertTrue(refusals.forall((_, why) => why.isDefined)) ?? s"$refusals" + }, + test("the refusal says which control was wrong and what it was set to") { + // A session can only be fixed if the error names the field. Both counting modes carry an + // n, so "n must be positive" on its own is not enough. + val skip = reason(NthMessageAfterEarliest(n = -3)).getOrElse("") + val latest = reason(NthMessageBeforeLatest(n = -3)).getOrElse("") + assertTrue( + skip.contains("-3") && skip.toLowerCase.contains("skip"), + latest.contains("-3") && latest.toLowerCase.contains("latest"), + skip != latest + ) ?? s"skip=$skip latest=$latest" + }, + test("ZERO is accepted by both - it is a real position, not a mistake") { + // "skip nothing" is the beginning, and "the latest 0 messages" is the live tail. + assertTrue(reason(NthMessageAfterEarliest(n = 0)).isEmpty, reason(NthMessageBeforeLatest(n = 0)).isEmpty) + }, + test("SKIP-N HAS NO UPPER BOUND - not even an enormous one is refused") { + // Deliberate. Skipping is O(n) whatever the number, the progress API exists to show it + // happening, and a cap would be an invented limit rather than a real one. + val enormous = Vector(1_000_000L, Int.MaxValue.toLong + 1, Long.MaxValue) + val wronglyRefused = enormous.filter(n => reason(NthMessageAfterEarliest(n = n)).isDefined) + assertTrue(wronglyRefused.isEmpty) ?? s"a cap was introduced on skip-n: $wronglyRefused" + }, + test("a latest-n above the OPERATIONAL boundary is REFUSED, not truncated") { + // It used to be silently narrowed to Int.MaxValue, which answers a different request - + // and Int.MaxValue itself, a leftover of a heap that no longer exists, still admitted a + // request the server would grind on for hours holding the lifecycle lock: the walk + // costs one synchronous broker lookup per entry with no progress to show. The boundary + // is an operational one now, and the refusal points at skip-n, which streams. + val why = reason(NthMessageBeforeLatest(n = latestNMaxAccepted + 1)) + val enormous = reason(NthMessageBeforeLatest(n = Int.MaxValue.toLong)) + assertTrue( + why.isDefined, + why.exists(_.contains(latestNMaxAccepted.toString)), + enormous.isDefined + ) ?? s"why=$why enormous=$enormous" + }, + test("a latest-n of exactly the boundary is still accepted") { + assertTrue(reason(NthMessageBeforeLatest(n = latestNMaxAccepted)).isEmpty) + }, + test("every mode that carries no count is untouched") { + val countless: Vector[ConsumerSessionStartFrom] = Vector( + EarliestMessage(), + LatestMessage(), + DateTime(dateTime = java.time.Instant.EPOCH), + RelativeDateTime(value = 1, unit = DateTimeUnit.Hour, isRoundedToUnitStart = false), + MessageId(messageIdBytes = Array.empty), + ApproximateDataPosition(fraction = 0.6), + ApproximateTimePosition(fraction = 0.6) + ) + val wronglyRefused = countless.filter(mode => reason(mode).isDefined) + assertTrue(wronglyRefused.isEmpty) ?? s"${wronglyRefused.map(_.getClass.getSimpleName)}" + } + ) + + /** Real clients aimed at a closed port. An empty consumer set reaches no broker on this path, so + * an accidental broker call would surface as a connection error rather than as an NPE that + * makes the assertion pass for the wrong reason. */ + private def withOfflineClients[A](f: (PulsarClient, PulsarAdmin) => A): A = + val client = PulsarClient.builder.serviceUrl("pulsar://127.0.0.1:1").operationTimeout(2, TimeUnit.SECONDS).build + val admin = PulsarAdmin.builder + .serviceHttpUrl("http://127.0.0.1:1") + .connectionTimeout(2, TimeUnit.SECONDS) + .readTimeout(2, TimeUnit.SECONDS) + .requestTimeout(2, TimeUnit.SECONDS) + .build + try f(client, admin) + finally + Try(client.close()) + Try(admin.close()) + + private def plan(startFrom: ConsumerSessionStartFrom): Try[StartFromPlan] = + withOfflineClients((client, admin) => + Try(handleStartFrom( + startFrom = startFrom, + consumers = Vector.empty, + adminClient = admin, + pulsarClient = client, + nonPartitionedTopicFqns = Vector.empty + )) + ) + + private val boundarySuite = suite("the refusal happens at the trust boundary")( + test("a negative skip-n fails the session instead of seeking to earliest") { + val result = plan(NthMessageAfterEarliest(n = -5)) + assertTrue( + result.isFailure, + result.failed.toOption.exists(_.isInstanceOf[IllegalArgumentException]), + result.failed.toOption.exists(_.getMessage.contains("-5")) + ) ?? s"result=$result" + }, + test("a negative latest-n fails the session instead of seeking to latest") { + val result = plan(NthMessageBeforeLatest(n = -5)) + assertTrue(result.isFailure, result.failed.toOption.exists(_.getMessage.contains("-5"))) ?? s"result=$result" + }, + test("an ordinary count still plans normally") { + assertTrue(plan(NthMessageAfterEarliest(n = 5)).map(_.discard) == scala.util.Success(StartFromDiscardPlan.SharedTotal(5))) + } + ) + + /** A log of `entries` unbatched entries, newest first, all with the same publish time. */ + private def oneTopic(entries: Int): String => Long => Option[LogEntry[String]] = + _ => k => Option.when(k >= 1 && k <= entries)(LogEntry(s"entry-$k", 1_000L, 1)) + + /** `entry-$k` counts back from the end, so a larger ordinal is strictly OLDER - the same order + * `MessageIdImpl.compareTo` gives real entry ids. */ + private def olderByEntryOrdinal(a: String, b: String): Boolean = a.split("-").last.toInt > b.split("-").last.toInt + + private val memorySuite = suite("LATEST-N BUFFERS NOTHING, whatever n is")( + test("a latest-n session arms no ordering layer, so it can hold nothing at all") { + // THE structural guarantee. Latest-n used to narrow its answer with a top-n heap of n + // DELIVERED messages plus an unbounded queue of live traffic beside it, so its memory + // was a number the user typed and a live message could evict a historical one. The cut + // is now resolved from entry metadata before a single message is delivered. + val armed = Vector(1, 2, 5, 50).map(streams => streams -> needsGlobalOrdering(NthMessageBeforeLatest(n = 1_000_000), streams)) + assertTrue(armed.forall((_, needed) => !needed)) ?? s"latest-n asked for a buffering layer: $armed" + }, + test("the resolved cut is O(topics) in memory - it holds one entry per topic, never n") { + // 1,000,000 messages asked for over 3 topics resolves to at most 3 answers. + val cut = resolveLatestN(1_000_000L, Vector("a", "b", "c"), oneTopic(10), olderByEntryOrdinal) + assertTrue(cut.size == 3) ?? s"cut=$cut" + }, + test("the walk is O(n / batch size) lookups and never touches a message payload") { + var lookups = 0 + val entryFromLatest: String => Long => Option[LogEntry[String]] = _ => + k => + lookups += 1 + Option.when(k >= 1 && k <= 1000)(LogEntry(s"entry-$k", 1_000L - k, 10)) + resolveLatestN(50L, Vector("a"), entryFromLatest, olderByEntryOrdinal) + assertTrue(lookups == 5) ?? s"$lookups lookups for 50 messages over batches of 10" + } + ) + + /** Target-aware refusals: sessions whose counted mode cannot MEAN what it promises on these + * targets are refused at creation, with the reason naming what to change. */ + private val targetAwareSuite = suite("target-aware refusals for the counted modes")( + test("latest-n is refused when any enabled target reads COMPACTED") { + val refused = latestNReadCompactedRejectionReason(NthMessageBeforeLatest(n = 3), readCompactedTargetIndexes = Vector(1)) + val fine = latestNReadCompactedRejectionReason(NthMessageBeforeLatest(n = 3), readCompactedTargetIndexes = Vector.empty) + assertTrue( + refused.exists(_.contains("compacted")), + refused.exists(_.contains("1")), + fine.isEmpty + ) ?? s"refused=$refused fine=$fine" + }, + test("latest-0 and other modes pass whatever the targets read - there is nothing to count") { + assertTrue( + latestNReadCompactedRejectionReason(NthMessageBeforeLatest(n = 0), Vector(0)).isEmpty, + latestNReadCompactedRejectionReason(NthMessageAfterEarliest(n = 5), Vector(0)).isEmpty + ) + }, + test("skip-n is refused when two enabled targets share a physical topic, and the reason NAMES it") { + val shared = "persistent://t/ns/shared-partition-0" + val refused = skipOverlapRejectionReason( + NthMessageAfterEarliest(n = 5), + topicsPerEnabledTarget = Vector(Vector(shared, "persistent://t/ns/a"), Vector(shared)) + ) + assertTrue( + refused.exists(_.contains(shared)), + refused.exists(_.contains("same topic")) + ) ?? s"refused=$refused" + }, + test("skip-n with DISJOINT targets, one target, or n = 0 is not refused") { + assertTrue( + skipOverlapRejectionReason( + NthMessageAfterEarliest(n = 5), + Vector(Vector("persistent://t/ns/a"), Vector("persistent://t/ns/b")) + ).isEmpty, + skipOverlapRejectionReason(NthMessageAfterEarliest(n = 5), Vector(Vector("persistent://t/ns/a"))).isEmpty, + skipOverlapRejectionReason( + NthMessageAfterEarliest(n = 0), + Vector(Vector("persistent://t/ns/a"), Vector("persistent://t/ns/a")) + ).isEmpty, + // Latest-n keeps its per-view duplicate contract: overlap is not its problem. + skipOverlapRejectionReason( + NthMessageBeforeLatest(n = 5), + Vector(Vector("persistent://t/ns/a"), Vector("persistent://t/ns/a")) + ).isEmpty + ) + }, + test("a latest-n anchor trimmed between resolving and seeking is refused, not silently shortened") { + // Entry ids compare as the production comparator does; the topic now retains only + // entry 5, and the resolved anchor was entry 3 - gone. + val cut = Map("persistent://t/ns/a" -> LatestNSeek.FromEntry(3L, 0L)) + val trimmed = latestNAnchorRejectionReason[Long](cut, _ => Some(5L), (a, b) => a < b) + val intact = latestNAnchorRejectionReason[Long](cut, _ => Some(2L), (a, b) => a < b) + val emptied = latestNAnchorRejectionReason[Long](cut, _ => None, (a, b) => a < b) + assertTrue( + trimmed.exists(_.contains("retention removed")), + intact.isEmpty, + emptied.isDefined // retains nothing at all: the anchor is gone by definition + ) ?? s"trimmed=$trimmed intact=$intact emptied=$emptied" + }, + test("EVERYTHING and NOTHING cuts need no anchor - only FromEntry is re-checked") { + val cut = Map( + "persistent://t/ns/a" -> LatestNSeek.Everything, + "persistent://t/ns/b" -> (LatestNSeek.Nothing: LatestNSeek[Long]) + ) + assertTrue(latestNAnchorRejectionReason[Long](cut, _ => None, (a, b) => a < b).isEmpty) + } + ) + + def spec = suite(this.getClass.toString)(rejectionSuite, boundarySuite, memorySuite, targetAwareSuite) diff --git a/server/src/test/scala/consumer/session_runner/startFromDiscardOnceTest.scala b/server/src/test/scala/consumer/session_runner/startFromDiscardOnceTest.scala new file mode 100644 index 000000000..09c67621d --- /dev/null +++ b/server/src/test/scala/consumer/session_runner/startFromDiscardOnceTest.scala @@ -0,0 +1,230 @@ +package consumer.session_runner + +import java.util.concurrent.atomic.AtomicLong + +import _root_.consumer.coloring_rules.ColoringRuleChain +import _root_.consumer.deserializer.Deserializer +import _root_.consumer.deserializer.deserializers.UseLatestTopicSchema +import _root_.consumer.message_filter.MessageFilterChain +import _root_.consumer.pause_trigger.ConsumerSessionPauseTriggerChain +import _root_.consumer.session_config.ConsumerSessionConfig +import _root_.consumer.session_target.ConsumerSessionTarget +import _root_.consumer.session_target.consumption_mode.ConsumerSessionTargetConsumptionMode +import _root_.consumer.session_target.consumption_mode.modes.RegularConsumptionMode +import _root_.consumer.session_target.topic_selector.{MultiTopicSelector, TopicSelector} +import _root_.consumer.start_from.EarliestMessage +import _root_.consumer.value_projections.ValueProjectionList +import zio.test.* + +/** The start-from discard must be applied EXACTLY ONCE, at session start. + * + * This is the regression that seek + discard invites: the counter lives next to the pause/resume + * machinery, and re-arming it on resume would silently skip a fresh n messages every time the user + * hits play - a bug that only shows up on the second play and looks like data loss. + * + * Everything here runs offline. `ConsumerSessionTargetRunner.resume` and `pause` are the REAL + * production methods; they are driven with an empty consumer map, so the only work left in them is + * the listener state they touch - which is exactly what is under test. `ConsumerListener.decide` is + * the real decision `received` makes, one call per delivered message. + */ +object startFromDiscardOnceTest extends ZIOSpecDefault: + + private val topicFqn = "persistent://public/default/discard-once" + + /** A listener already open for business. It starts CLOSED in production - nothing may be + * consumed before the session is armed and a client has resumed it - and + * `ConsumerSessionTargetRunner.resume` is what opens it. */ + private def listener(): ConsumerListener = + val l = ConsumerListener(ConsumerSessionTargetMessageHandler(onNext = _ => ())) + l.startAcceptingNewMessages() + l + + private def targetConfig(topicFqns: Vector[String]): ConsumerSessionTarget = + ConsumerSessionTarget( + isEnabled = true, + consumptionMode = ConsumerSessionTargetConsumptionMode(mode = RegularConsumptionMode()), + messageValueDeserializer = Deserializer(deserializer = UseLatestTopicSchema()), + topicSelector = TopicSelector(topicSelector = MultiTopicSelector(topicFqns = topicFqns)), + messageFilterChain = MessageFilterChain.empty, + coloringRuleChain = ColoringRuleChain.empty, + valueProjectionList = ValueProjectionList(isEnabled = false, projections = Vector.empty) + ) + + private def targetRunner(consumerListener: ConsumerListener, topicFqns: Vector[String] = Vector(topicFqn)): ConsumerSessionTargetRunner = + ConsumerSessionTargetRunner( + targetIndex = 0, + targetConfig = targetConfig(topicFqns), + nonPartitionedTopicFqns = topicFqns, + schemasByTopic = Map.empty, + sessionContextPool = ConsumerSessionContextPool(), + consumers = Map.empty, + consumerListener = consumerListener, + stats = ConsumerSessionTargetStats(messageProcessed = AtomicLong(0)) + ) + + /** Drives the production `resume` with no-op callbacks - the same call `ConsumerSessionRunner` + * makes on every play. */ + private def resume(runner: ConsumerSessionTargetRunner): Unit = + runner.resume(onNext = (_, _, _, _) => (), isDebug = false, incrementNumMessageProcessed = () => (), onStartFromDiscardProgress = () => ()) + + /** How the listener would treat `count` consecutively delivered messages. */ + private def deliver(l: ConsumerListener, count: Int): Vector[ConsumerListener.Action] = + Vector.fill(count)(l.decide(topicFqn, canAcknowledge = true)) + + import ConsumerListener.Action.* + + def spec = suite(this.getClass.toString)( + test("a listener that has never been resumed consumes NOTHING - it hands every message back") { + // VERIFIED against Pulsar 3.2.1, and the reason this matters: the start-from set-up does + // broker round trips (the backward entry walk, reading each topic's last message id) + // while the session is still being built. If the listener accepted during that window it + // would ACKNOWLEDGE messages into a message handler that is still a no-op, and a session + // could swallow its whole backlog and then deliver nothing at all. A live 3-partition + // "skip first 4" delivered ZERO of its 12 messages until this was closed. + val fresh = ConsumerListener(ConsumerSessionTargetMessageHandler(onNext = _ => ())) + fresh.startFromDiscard = StartFromDiscard.shared(3) + val beforeAnyResume = deliver(fresh, 5) + assertTrue( + beforeAnyResume.forall(_ == Reject), + fresh.startFromDiscard.remaining == 3L + ) ?? s"an unarmed session consumed messages: $beforeAnyResume" + }, + test("resuming the target is what opens the listener") { + val fresh = ConsumerListener(ConsumerSessionTargetMessageHandler(onNext = _ => ())) + fresh.startFromDiscard = StartFromDiscard.shared(1) + val runner = targetRunner(fresh) + val closed = deliver(fresh, 1) + resume(runner) + val open = deliver(fresh, 2) + assertTrue(closed == Vector(Reject), open == Vector(Drop, Deliver)) + }, + test("the first n delivered messages are dropped and everything after them is delivered") { + val l = listener() + l.startFromDiscard = StartFromDiscard.shared(3) + assertTrue(deliver(l, 6) == Vector(Drop, Drop, Drop, Deliver, Deliver, Deliver), l.startFromDiscard.remaining == 0L) + }, + test("resuming a target does not re-arm the discard") { + // The regression: 3 to skip, 2 already skipped, then the user pauses and plays again. + // Re-arming would drop 3 MORE messages here. + val l = listener() + l.startFromDiscard = StartFromDiscard.shared(3) + val runner = targetRunner(l) + + resume(runner) + val beforePause = deliver(l, 2) + runner.pause() + resume(runner) + val afterResume = deliver(l, 3) + + assertTrue( + beforePause == Vector(Drop, Drop), + afterResume == Vector(Drop, Deliver, Deliver), + l.startFromDiscard.remaining == 0L + ) ?? "exactly 3 messages may ever be dropped, however many times the session is resumed" + }, + test("a discard already spent stays spent across further pause/resume cycles") { + val l = listener() + l.startFromDiscard = StartFromDiscard.shared(2) + val runner = targetRunner(l) + resume(runner) + deliver(l, 2) + + val laterRounds = (1 to 3).flatMap { _ => + runner.pause() + resume(runner) + deliver(l, 2) + }.toVector + + assertTrue(laterRounds.forall(_ == Deliver), l.startFromDiscard.remaining == 0L) ?? + s"a spent discard was re-armed by resume: $laterRounds" + }, + test("a message rejected while paused does not consume the discard") { + // A paused listener nacks, and `negativeAckRedeliveryDelay(0)` brings the message + // straight back. Counting it as dropped would skip n+1 messages. + val l = listener() + l.startFromDiscard = StartFromDiscard.shared(2) + val runner = targetRunner(l) + + resume(runner) + val first = l.decide(topicFqn, canAcknowledge = true) + runner.pause() + val whilePaused = deliver(l, 5) + resume(runner) + val afterResume = deliver(l, 3) + + assertTrue( + first == Drop, + whilePaused.forall(_ == Reject), + afterResume == Vector(Drop, Deliver, Deliver), + l.startFromDiscard.remaining == 0L + ) + }, + test("resume leaves the counter object itself alone") { + // Belt and braces on the mechanism rather than the effect: `resume` must not swap the + // discard for a fresh one either. + val l = listener() + val armed = StartFromDiscard.shared(5) + l.startFromDiscard = armed + val runner = targetRunner(l) + resume(runner) + runner.pause() + resume(runner) + assertTrue(l.startFromDiscard eq armed) + }, + test("a session reports the remaining discard, counting a shared counter once") { + // Two targets over one shared counter: summing per target would report double and make + // "skipped exactly n" unassertable. + val shared = StartFromDiscard.shared(4) + val firstListener = listener() + val secondListener = listener() + firstListener.startFromDiscard = shared + secondListener.startFromDiscard = shared + + val session = ConsumerSessionRunner( + sessionName = "cs-discard", + sessionConfig = ConsumerSessionConfig( + startFrom = EarliestMessage(), + targets = Vector.empty, + messageFilterChain = MessageFilterChain.empty, + coloringRuleChain = ColoringRuleChain.empty, + pauseTriggerChain = ConsumerSessionPauseTriggerChain.empty, + valueProjectionList = ValueProjectionList(isEnabled = false, projections = Vector.empty) + ), + sessionContextPool = ConsumerSessionContextPool(), + grpcResponseObserver = None, + schemasByTopic = Map.empty, + targets = Map(0 -> targetRunner(firstListener), 1 -> targetRunner(secondListener)) + ) + + val atStart = session.remainingStartFromDiscard + deliver(firstListener, 1) + deliver(secondListener, 2) + + assertTrue(atStart == 4L, session.remainingStartFromDiscard == 1L) + }, + test("a session sums per-topic counters across its targets") { + val firstListener = listener() + val secondListener = listener() + firstListener.startFromDiscard = StartFromDiscard.perTopic(Map(topicFqn -> 3L)) + secondListener.startFromDiscard = StartFromDiscard.perTopic(Map(topicFqn -> 3L)) + + val session = ConsumerSessionRunner( + sessionName = "cs-discard-per-topic", + sessionConfig = ConsumerSessionConfig( + startFrom = EarliestMessage(), + targets = Vector.empty, + messageFilterChain = MessageFilterChain.empty, + coloringRuleChain = ColoringRuleChain.empty, + pauseTriggerChain = ConsumerSessionPauseTriggerChain.empty, + valueProjectionList = ValueProjectionList(isEnabled = false, projections = Vector.empty) + ), + sessionContextPool = ConsumerSessionContextPool(), + grpcResponseObserver = None, + schemasByTopic = Map.empty, + targets = Map(0 -> targetRunner(firstListener), 1 -> targetRunner(secondListener)) + ) + + deliver(firstListener, 1) + assertTrue(session.remainingStartFromDiscard == 5L) + } + ) @@ TestAspect.sequential diff --git a/server/src/test/scala/consumer/session_runner/startFromDiscardTest.scala b/server/src/test/scala/consumer/session_runner/startFromDiscardTest.scala new file mode 100644 index 000000000..2fa636f09 --- /dev/null +++ b/server/src/test/scala/consumer/session_runner/startFromDiscardTest.scala @@ -0,0 +1,211 @@ +package consumer.session_runner + +import zio.test.* + +/** The exactness of "Skip first n messages" and "Latest n messages". + * + * Regression context: both modes were built on `PulsarAdmin.examineMessage`, which is + * ENTRY-addressed, not message-addressed - and the Java producer batches by default. VERIFIED + * against Pulsar 3.2.1: 100 messages sent with a default producer became ONE broker entry, and + * `examineMessage(topic, "earliest", k)` answered with that same entry for every k from 1 to 101, + * silently clamping instead of failing. "Skip the first 5" therefore asked for entry 6, got the + * last entry, and skipped fifty messages - on the most ordinary setup there is, one non-partitioned + * topic with a default producer. Counting back from "latest" past the start does NOT clamp on 3.2: + * it fails, and the failure was swallowed into a fallback that seeked to EARLIEST, so "the latest + * 5" showed every message in the topic. + * + * The fix is seek + discard: a seek can only land on an ENTRY boundary (batch-index positions such + * as `1696:1:25` are rejected by the broker), so the only exact way to land on a MESSAGE is to seek + * to the entry holding it and drop the messages in front of it. + * + * [[resolveLatestN]] is pure - the broker sits behind a `Long => Option[(id, batchSize)]` lookup - + * so every log shape is driven here with a plain lambda: no broker, and no mock. + */ +object startFromDiscardTest extends ZIOSpecDefault: + + private val oneTopic = "persistent://public/default/one" + + /** A log described from its END: element 0 is the LAST entry, and each element is the number of + * messages that entry holds. All entries share one publish time, so nothing here depends on the + * cross-topic ordering - that is `globalStartFromTest`'s job. Returns the lookup + * [[resolveLatestN]] expects plus a counter of how many lookups it made (the walk must be + * O(n / batch size), not O(log size)). + */ + private def logFromLatest(entriesFromLatest: Int*): (String => Long => Option[LogEntry[String]], () => Int) = + var lookups = 0 + val lookup = (_: String) => + (k: Long) => + lookups += 1 + Option.when(k >= 1 && k <= entriesFromLatest.size)(LogEntry(s"entry-$k", 1_000L, entriesFromLatest(k.toInt - 1))) + (lookup, () => lookups) + + /** A broker that CLAMPS instead of failing once the walk runs past the start of the log - the + * behaviour `examineMessage` shows on the "earliest" side, guarded against here so a Pulsar + * version that clamps on "latest" too cannot turn the walk into an infinite loop. + */ + private def clampingLogFromLatest(entriesFromLatest: Int*): (String => Long => Option[LogEntry[String]], () => Int) = + var lookups = 0 + val lookup = (_: String) => + (k: Long) => + lookups += 1 + val clamped = k.min(entriesFromLatest.size).max(1) + Option.when(entriesFromLatest.nonEmpty)(LogEntry(s"entry-$clamped", 1_000L, entriesFromLatest(clamped.toInt - 1))) + (lookup, () => lookups) + + /** The `entry-$k` labels carry the walk's entry order: `k` counts back from the end, so a larger + * ordinal is strictly OLDER (the production comparator is `MessageIdImpl.compareTo`). */ + private def olderByEntryOrdinal(a: String, b: String): Boolean = a.split("-").last.toInt > b.split("-").last.toInt + + private def resolveOne(n: Long, lookup: String => Long => Option[LogEntry[String]]): LatestNSeek[String] = + resolveLatestN(n, Vector(oneTopic), lookup, olderByEntryOrdinal)(oneTopic) + + private val resolveLatestNSuite = suite("resolveLatestN over a single log")( + test("one batched entry: the last 5 of 100 seek to that entry and discard the 95 in front") { + // THE regression. Entry-addressing answered "entry 1" and discarded nothing, so all 100 + // messages were shown for a request of 5. + val (lookup, _) = logFromLatest(100) + assertTrue(resolveOne(5, lookup) == LatestNSeek.FromEntry("entry-1", 95L)) + }, + test("unbatched log: the last 5 land on the 5th entry from the end with nothing to discard") { + // The control: with one message per entry, entry-addressing and message-addressing + // coincide - which is exactly why unbatched fixtures never exposed the defect. + val (lookup, lookups) = logFromLatest(1, 1, 1, 1, 1, 1, 1, 1, 1, 1) + assertTrue(resolveOne(5, lookup) == LatestNSeek.FromEntry("entry-5", 0L), lookups() == 5) + }, + test("uneven batches: the walk stops on the entry that covers the n-th message") { + // From the end: 2, then 3 -> exactly 5 accounted for on the second entry. + val (lookup, lookups) = logFromLatest(2, 3, 10, 10) + assertTrue(resolveOne(5, lookup) == LatestNSeek.FromEntry("entry-2", 0L), lookups() == 2) + }, + test("uneven batches: the overshoot inside the stopping entry is discarded") { + // From the end: 2, then 3 -> 5 accounted for, but only 4 were asked for, so drop 1. + val (lookup, _) = logFromLatest(2, 3, 10) + assertTrue(resolveOne(4, lookup) == LatestNSeek.FromEntry("entry-2", 1L)) + }, + test("the last 1 of a 10-message batch discards the other 9") { + val (lookup, _) = logFromLatest(10, 10) + assertTrue(resolveOne(1, lookup) == LatestNSeek.FromEntry("entry-1", 9L)) + }, + test("a log shorter than n shows all of it") { + val (lookup, _) = logFromLatest(10, 10, 10) + assertTrue(resolveOne(100, lookup) == LatestNSeek.Everything) + }, + test("an empty log maps to EVERYTHING - all it will ever hold is post-inspection live traffic") { + // Empty when inspected means whatever exists at seek time arrived AFTER the inspection; + // seeking EARLIEST delivers exactly that. Seeking "latest" at seek time raced those + // same appends and silently lost them. + val (lookup, _) = logFromLatest() + assertTrue(resolveOne(5, lookup) == LatestNSeek.Everything) + }, + test("a clamping broker is detected within a bounded number of steps, not by the first repeat") { + // Without a termination guard this walk never ends: the lookup keeps answering with the + // last entry and the running total keeps growing by 3 forever, so it would eventually + // "reach" n and seek to the WRONG entry with a nonsense discard. + // + // CHANGED EXPECTATION, deliberately: the old guard stopped on the FIRST repeated id, in + // exactly 3 lookups - but a single concurrent append produces that same first repeat (the + // moving anchor), so treating it as exhaustion was the whole-backlog bug. A clamp is now + // told apart by ONE VERIFICATION LOOKUP at the k that produced the last accepted entry - + // a clamped end never moves, a grown end answers newer - so it is still `Everything`, + // one lookup later than the old first-repeat guard. + val (lookup, lookups) = clampingLogFromLatest(3, 3) + assertTrue( + resolveOne(100, lookup) == LatestNSeek.Everything, + lookups() > 3, + lookups() <= maxLatestNReanchorSteps + 4 + ) ?? s"resolved to Everything after ${lookups()} lookups (bound $maxLatestNReanchorSteps)" + }, + test("a clamping broker still resolves exactly when the log does hold n messages") { + val (lookup, _) = clampingLogFromLatest(3, 4, 50) + assertTrue(resolveOne(5, lookup) == LatestNSeek.FromEntry("entry-2", 2L)) + }, + test("the walk costs O(n / batch size) lookups, not one per message") { + // 50 messages over batches of 10 is 5 admin calls - and it does not matter that the log + // behind them is 1000 entries long. + val (lookup, lookups) = logFromLatest(Vector.fill(1000)(10)*) + assertTrue(resolveOne(50, lookup) == LatestNSeek.FromEntry("entry-5", 0L), lookups() == 5) + }, + test("n = 0 shows nothing retained, without asking the broker anything") { + val (lookup, lookups) = logFromLatest(10, 10) + assertTrue(resolveOne(0, lookup) == LatestNSeek.Nothing, lookups() == 0) + } + ) + + private val topicA = "persistent://public/default/a" + private val topicB = "persistent://public/default/b" + + private val startFromDiscardSuite = suite("StartFromDiscard")( + test("a shared counter drops exactly n messages of the merged stream, then stops") { + // "Skip first n" across partitions: the count is exact even though the interleaving is + // not - which is the whole point of counting the merged stream instead of each log. + val discard = StartFromDiscard.shared(4) + val topics = Vector(topicA, topicB, topicA, topicB, topicA, topicB, topicA) + val dropped = topics.map(discard.claim) + assertTrue(dropped == Vector(true, true, true, true, false, false, false), discard.remaining == 0) + }, + test("a shared counter reports what is left and never goes below zero") { + val discard = StartFromDiscard.shared(2) + val afterNone = discard.remaining + discard.claim(topicA) + val afterOne = discard.remaining + discard.claim(topicA) + discard.claim(topicA) + discard.claim(topicA) + assertTrue(afterNone == 2L, afterOne == 1L, discard.remaining == 0L) + }, + test("per-topic counters are independent - one topic's overshoot does not eat another's") { + // "Latest n" on a partitioned topic: each partition seeks to its own entry and owes its + // own overshoot. + val discard = StartFromDiscard.perTopic(Map(topicA -> 2, topicB -> 1)) + val a = Vector(discard.claim(topicA), discard.claim(topicA), discard.claim(topicA)) + val b = Vector(discard.claim(topicB), discard.claim(topicB)) + assertTrue(a == Vector(true, true, false), b == Vector(true, false), discard.remaining == 0) + }, + test("a topic with no counter is never dropped") { + val discard = StartFromDiscard.perTopic(Map(topicA -> 5)) + assertTrue(!discard.claim(topicB), discard.remaining == 5L) + }, + test("the empty discard drops nothing") { + assertTrue(!StartFromDiscard.none.claim(topicA), StartFromDiscard.none.remaining == 0L) + }, + test("concurrent claims on a shared counter drop exactly n in total") { + // One listener per consumer, each on its own Pulsar client io thread, all claiming from + // the same counter. A read-then-write counter would over-drop here. + val discard = StartFromDiscard.shared(100) + val claims = 8 + val perThread = 100 + val dropped = java.util.concurrent.atomic.AtomicLong(0) + val threads = (1 to claims).map(i => + Thread(() => (1 to perThread).foreach(_ => if discard.claim(s"topic-$i") then dropped.incrementAndGet())) + ) + threads.foreach(_.start()) + threads.foreach(_.join()) + assertTrue(dropped.get == 100L, discard.remaining == 0L) + }, + test("forTarget hands every target the SAME shared counter") { + val plan = StartFromDiscardPlan.SharedTotal(3) + val shared = StartFromDiscard.shared(3) + val first = StartFromDiscard.forTarget(plan, shared, Vector(topicA)) + val second = StartFromDiscard.forTarget(plan, shared, Vector(topicB)) + first.claim(topicA) + first.claim(topicA) + assertTrue(first eq second, second.remaining == 1L) + }, + test("forTarget gives each target its OWN per-topic counters, scoped to its topics") { + // Two targets may select the same topic; each has its own consumer, and each has to + // drop its own overshoot. + val plan = StartFromDiscardPlan.PerTopic(Map(topicA -> 2L, topicB -> 7L)) + val first = StartFromDiscard.forTarget(plan, StartFromDiscard.none, Vector(topicA)) + val second = StartFromDiscard.forTarget(plan, StartFromDiscard.none, Vector(topicA)) + first.claim(topicA) + first.claim(topicA) + assertTrue( + !(first eq second), + first.remaining == 0L, + second.remaining == 2L, // untouched by the other target + second.claim(topicB) == false // topicB belongs to neither target + ) + } + ) + + def spec = suite(this.getClass.toString)(resolveLatestNSuite, startFromDiscardSuite) diff --git a/server/src/test/scala/consumer/session_runner/startFromOrderingTest.scala b/server/src/test/scala/consumer/session_runner/startFromOrderingTest.scala new file mode 100644 index 000000000..94d03af62 --- /dev/null +++ b/server/src/test/scala/consumer/session_runner/startFromOrderingTest.scala @@ -0,0 +1,401 @@ +package consumer.session_runner + +import java.util.concurrent.atomic.AtomicLong + +import _root_.consumer.coloring_rules.ColoringRuleChain +import _root_.consumer.deserializer.Deserializer +import _root_.consumer.deserializer.deserializers.UseLatestTopicSchema +import _root_.consumer.message_filter.MessageFilterChain +import _root_.consumer.pause_trigger.ConsumerSessionPauseTriggerChain +import _root_.consumer.session_config.ConsumerSessionConfig +import _root_.consumer.session_target.ConsumerSessionTarget +import _root_.consumer.session_target.consumption_mode.ConsumerSessionTargetConsumptionMode +import _root_.consumer.session_target.consumption_mode.modes.RegularConsumptionMode +import _root_.consumer.session_target.topic_selector.{MultiTopicSelector, TopicSelector} +import _root_.consumer.start_from.{ConsumerSessionStartFrom, DateTime, DateTimeUnit, EarliestMessage, LatestMessage, MessageId, NthMessageAfterEarliest, NthMessageBeforeLatest, ApproximateDataPosition, ApproximateTimePosition, RelativeDateTime} +import _root_.consumer.value_projections.ValueProjectionList +import org.apache.pulsar.client.impl.MessageIdImpl +import zio.test.* + +/** Wiring the two GLOBAL start-from modes into the session: which delivery stream a message belongs + * to, when that stream has run out of the messages it held at session start, and where the client + * reads the skip's progress from once the merge owns the counting. + * + * `StartFromOrdering` is generic in what it holds, so the whole routing is driven here with real + * Pulsar message ids and plain payloads - no broker, no mock. The layer never looks inside what it + * holds; it only decides drop-or-deliver and hands the payload back. + */ +object startFromOrderingTest extends ZIOSpecDefault: + + private val p0 = "persistent://public/default/ord-partition-0" + private val p1 = "persistent://public/default/ord-partition-1" + private val consumerName = "cs-ordering-0" + private val otherConsumerName = "cs-ordering-1" + + private def id(entryId: Long): MessageIdImpl = new MessageIdImpl(1L, entryId, 0) + + /** A stream whose backlog ended on `lastEntryId`. */ + private def stream(consumer: String, topicFqn: String, lastEntryId: Long): StartFromStream = + StartFromStream(startFromStreamId(consumer, topicFqn), EntryPosition(1L, lastEntryId, -1, 1)) + + private def emptyStream(consumer: String, topicFqn: String): StartFromStream = + StartFromStream(startFromStreamId(consumer, topicFqn), EntryPosition.empty) + + extension (ordering: StartFromOrdering[String]) + /** One delivered message, as the listener hands it over. */ + def deliver(consumer: String, topicFqn: String, publishTime: Long, entryId: Long, value: String): Vector[(String, StartFromOutcome)] = + ordering.offer(consumer, topicFqn, publishTime, id(entryId), value) + + def delivered(resolved: Vector[(String, StartFromOutcome)]): Vector[String] = + resolved.collect { case (value, StartFromOutcome.Deliver) => value } + + private val planSuite = suite("which sessions get a global ordering layer at all")( + test("SKIP-N gets one as soon as there is more than one stream") { + assertTrue( + needsGlobalOrdering(NthMessageAfterEarliest(n = 5), streamCount = 2), + needsGlobalOrdering(NthMessageAfterEarliest(n = 5), streamCount = 3) + ) + }, + test("LATEST-N NEVER GETS ONE - it resolves its cut before anything is delivered") { + // Changed expectation, deliberately: latest-n used to arm a top-n heap of DELIVERED + // messages here, which made a session's memory a number the user typed and let live + // traffic evict the historical tail. The cut now comes from entry metadata + // (`resolveLatestN`), so there is nothing left to hold and nothing left to reorder. + val armed = Vector(1, 2, 3, 50).filter(streams => needsGlobalOrdering(NthMessageBeforeLatest(n = 5), streams)) + assertTrue(armed.isEmpty) ?? s"latest-n asked for a buffering layer at stream counts $armed" + }, + test("a single stream keeps the FAST PATH - the ordinary non-partitioned session pays nothing") { + // One log is already in append order: the head-drop counter is exact on its own, and a + // merge would hold messages for no reason. + assertTrue( + !needsGlobalOrdering(NthMessageAfterEarliest(n = 5), streamCount = 1), + !needsGlobalOrdering(NthMessageAfterEarliest(n = 5), streamCount = 0) + ) + }, + test("n = 0 needs no ordering, however many streams there are") { + // A negative n is REFUSED at the trust boundary before this gate is reached (see + // `startFromCountRejectionReason`), so it can no longer arrive here from a request. The + // gate stays defensive about it anyway - answering "no ordering" is the safe reading if + // one ever did. + assertTrue( + !needsGlobalOrdering(NthMessageAfterEarliest(n = 0), streamCount = 4), + !needsGlobalOrdering(NthMessageAfterEarliest(n = -3), streamCount = 4) + ) + }, + test("APPROXIMATE POSITION stays per partition, and every exact-seek mode gets nothing") { + // Deliberate, not an oversight: a count of n is reached by streaming n messages and + // stopping, but a proportion of the merged stream is only known once all of it has been + // measured, which is O(topic) at any n. + val exactSeekModes: Vector[ConsumerSessionStartFrom] = Vector( + ApproximateDataPosition(fraction = 0.6), + ApproximateTimePosition(fraction = 0.6), + EarliestMessage(), + LatestMessage(), + DateTime(dateTime = java.time.Instant.EPOCH), + RelativeDateTime(value = 1, unit = DateTimeUnit.Hour, isRoundedToUnitStart = false), + MessageId(messageIdBytes = Array.empty) + ) + val wrongly = exactSeekModes.filter(mode => needsGlobalOrdering(mode, streamCount = 4)) + assertTrue(wrongly.isEmpty) ?? s"these modes asked for a global ordering they must not have: ${wrongly.map(_.getClass.getSimpleName)}" + }, + test("only skip-n gets the streaming MERGE, and only it") { + val streams = Vector(stream(consumerName, p0, 9), stream(consumerName, p1, 9)) + val skip = globalOrderingPlanFor(NthMessageAfterEarliest(n = 7), streams) + val latest = globalOrderingPlanFor(NthMessageBeforeLatest(n = 7), streams) + assertTrue( + skip == StartFromOrderingPlan.GlobalSkip(7, streams), + latest == StartFromOrderingPlan.PassThrough + ) ?? s"skip=$skip latest=$latest" + }, + test("the fast path and the exact-seek modes build no layer at all") { + val oneStream = Vector(stream(consumerName, p0, 9)) + val twoStreams = Vector(stream(consumerName, p0, 9), stream(consumerName, p1, 9)) + assertTrue( + globalOrderingPlanFor(NthMessageAfterEarliest(n = 7), oneStream) == StartFromOrderingPlan.PassThrough, + globalOrderingPlanFor(NthMessageBeforeLatest(n = 7), oneStream) == StartFromOrderingPlan.PassThrough, + globalOrderingPlanFor(ApproximateDataPosition(fraction = 0.6), twoStreams) == StartFromOrderingPlan.PassThrough, + globalOrderingPlanFor(ApproximateTimePosition(fraction = 0.6), twoStreams) == StartFromOrderingPlan.PassThrough, + globalOrderingPlanFor(EarliestMessage(), twoStreams) == StartFromOrderingPlan.PassThrough + ) + } + ) + + private val identitySuite = suite("which stream a message belongs to")( + test("the same topic reached by two targets is TWO streams") { + // Each enabled target has its OWN consumer on the topic and delivers it independently. + // Merging both under the topic name would let one target's head hide the other's. + assertTrue(startFromStreamId(consumerName, p0) != startFromStreamId(otherConsumerName, p0)) + }, + test("one target's two partitions are two streams") { + assertTrue(startFromStreamId(consumerName, p0) != startFromStreamId(consumerName, p1)) + }, + test("a skip merge waits for BOTH targets on one topic before it decides") { + // The regression this guards: keyed by topic alone, the second target's consumer would + // overwrite the first's head and the merge would drop whichever arrived last. + val ordering = StartFromOrdering.make[String]( + StartFromOrderingPlan.GlobalSkip(1, Vector(stream(consumerName, p0, 5), stream(otherConsumerName, p0, 5))) + ) + val afterFirst = ordering.deliver(consumerName, p0, 200L, 0, "later-target-a") + val afterSecond = ordering.deliver(otherConsumerName, p0, 100L, 0, "earlier-target-b") + assertTrue( + afterFirst.isEmpty, + // The drop spends the budget's only unit, so the OTHER target's held message is + // released in the same resolution - the merge does not wait for a head it can no + // longer use. + afterSecond == Vector("earlier-target-b" -> StartFromOutcome.Drop, "later-target-a" -> StartFromOutcome.Deliver) + ) ?? s"afterFirst=$afterFirst afterSecond=$afterSecond" + } + ) + + private val backlogEndSuite = suite("when a stream stops being waited for")( + test("a stream is waited for until its recorded last message arrives") { + val ordering = StartFromOrdering.make[String]( + StartFromOrderingPlan.GlobalSkip(1, Vector(stream(consumerName, p0, 9), stream(consumerName, p1, 0))) + ) + // p1's whole backlog is entry 0, so its first delivery also ends it - and the merge can + // decide without ever hearing from p1 again. + val held = ordering.deliver(consumerName, p0, 100L, 0, "a1") + val decided = ordering.deliver(consumerName, p1, 50L, 0, "b1") + val next = ordering.deliver(consumerName, p0, 200L, 1, "a2") + assertTrue( + held.isEmpty, + decided == Vector("b1" -> StartFromOutcome.Drop, "a1" -> StartFromOutcome.Deliver), + next == Vector("a2" -> StartFromOutcome.Deliver) + ) ?? s"held=$held decided=$decided next=$next" + }, + test("a topic that was empty at session start is never waited for") { + val ordering = StartFromOrdering.make[String]( + StartFromOrderingPlan.GlobalSkip(1, Vector(stream(consumerName, p0, 9), emptyStream(consumerName, p1))) + ) + assertTrue(ordering.deliver(consumerName, p0, 100L, 0, "a1") == Vector("a1" -> StartFromOutcome.Drop)) + }, + test("a stream the plan never heard of is never waited for") { + // A topic that appears under the session's feet must not hang it. It delivers once and + // goes quiet - if it had joined the waited-for set, everything after it would be held + // forever. Two things keep it out of that set, deliberately: the waited-for set is built + // from the PLAN, and an unknown stream reports itself at its backlog end. + val ordering = StartFromOrdering.make[String](StartFromOrderingPlan.GlobalSkip(1, Vector(emptyStream(consumerName, p0)))) + val fromUnknown = ordering.deliver(consumerName, p1, 100L, 0, "surprise") + val fromKnown = ordering.deliver(consumerName, p0, 200L, 0, "known") + assertTrue( + fromUnknown == Vector("surprise" -> StartFromOutcome.Drop), + fromKnown == Vector("known" -> StartFromOutcome.Deliver), + ordering.heldCount == 0 + ) ?? s"fromUnknown=$fromUnknown fromKnown=$fromKnown" + }, + test("a partition that answered with MessageId.earliest is drained, not waited for") { + // The end-to-end shape of the same bug: the plan records what getLastMessageIds said, + // and for an empty partition that is MessageId.earliest. + val emptyByEarliest = + StartFromStream(startFromStreamId(consumerName, p1), EntryPosition.of(org.apache.pulsar.client.api.MessageId.earliest)) + val ordering = StartFromOrdering.make[String]( + StartFromOrderingPlan.GlobalSkip(1, Vector(stream(consumerName, p0, 1), emptyByEarliest)) + ) + val a1 = ordering.deliver(consumerName, p0, 10L, 0, "a1") + val a2 = ordering.deliver(consumerName, p0, 20L, 1, "a2") + assertTrue( + a1 == Vector("a1" -> StartFromOutcome.Drop), + ordering.delivered(a2) == Vector("a2") + ) ?? s"an empty partition held the merge back: a1=$a1 a2=$a2" + } + ) + + private val passThroughSuite = suite("no reordering at all")( + test("pass-through delivers every message untouched and holds nothing") { + val ordering = StartFromOrdering.passThrough[String] + val resolved = ordering.deliver(consumerName, p0, 10L, 0, "a1") + assertTrue(resolved == Vector("a1" -> StartFromOutcome.Deliver), ordering.heldCount == 0, ordering.progressDiscard.isEmpty) + }, + test("a plan with no ordering makes a pass-through") { + val ordering = StartFromOrdering.make[String](StartFromOrderingPlan.PassThrough) + assertTrue(ordering.progressDiscard.isEmpty, ordering.deliver(consumerName, p0, 10L, 0, "a1").size == 1) + } + ) + + /** A listener already open for business. It starts CLOSED in production - nothing may be + * consumed before the session is armed and a client has resumed it - and + * `ConsumerSessionTargetRunner.resume` is what opens it. */ + private def listener(): ConsumerListener = + val l = ConsumerListener(ConsumerSessionTargetMessageHandler(onNext = _ => ())) + l.startAcceptingNewMessages() + l + + private def targetRunner(consumerListener: ConsumerListener): ConsumerSessionTargetRunner = + ConsumerSessionTargetRunner( + targetIndex = 0, + targetConfig = ConsumerSessionTarget( + isEnabled = true, + consumptionMode = ConsumerSessionTargetConsumptionMode(mode = RegularConsumptionMode()), + messageValueDeserializer = Deserializer(deserializer = UseLatestTopicSchema()), + topicSelector = TopicSelector(topicSelector = MultiTopicSelector(topicFqns = Vector(p0))), + messageFilterChain = MessageFilterChain.empty, + coloringRuleChain = ColoringRuleChain.empty, + valueProjectionList = ValueProjectionList(isEnabled = false, projections = Vector.empty) + ), + nonPartitionedTopicFqns = Vector(p0), + schemasByTopic = Map.empty, + sessionContextPool = ConsumerSessionContextPool(), + consumers = Map.empty, + consumerListener = consumerListener, + stats = ConsumerSessionTargetStats(messageProcessed = AtomicLong(0)) + ) + + private def session(targets: (Int, ConsumerSessionTargetRunner)*): ConsumerSessionRunner = + ConsumerSessionRunner( + sessionName = "cs-ordering", + sessionConfig = ConsumerSessionConfig( + startFrom = EarliestMessage(), + targets = Vector.empty, + messageFilterChain = MessageFilterChain.empty, + coloringRuleChain = ColoringRuleChain.empty, + pauseTriggerChain = ConsumerSessionPauseTriggerChain.empty, + valueProjectionList = ValueProjectionList(isEnabled = false, projections = Vector.empty) + ), + sessionContextPool = ConsumerSessionContextPool(), + grpcResponseObserver = None, + schemasByTopic = Map.empty, + targets = targets.toMap + ) + + private def skipOrdering(n: Long): StartFromOrdering[HeldMessage] = + StartFromOrdering.make[HeldMessage]( + StartFromOrderingPlan.GlobalSkip(n, Vector(stream(consumerName, p0, 9), stream(consumerName, p1, 9))) + ) + + private val progressSuite = suite("where the client reads the skip's progress from")( + test("a listener with no ordering reads its own head-drop counter") { + val l = listener() + l.startFromDiscard = StartFromDiscard.shared(6) + assertTrue(l.progressDiscard eq l.startFromDiscard) + }, + test("a listener whose merge owns the counting reads the MERGE's counter") { + // The merge decides the drops itself, so the listener's own counter is deliberately + // empty - reading that one would report a session with nothing to skip. + val l = listener() + val ordering = skipOrdering(4) + l.startFromOrdering = ordering + assertTrue(l.progressDiscard.total == 4L, !(l.progressDiscard eq l.startFromDiscard)) + }, + test("a latest-n reports NOTHING - its head-drop is a seek correction, not a user skip") { + // A latest-n session's only counter is the overshoot inside the single entry its + // backward walk stopped on. That is the session reaching the requested position, not + // the user skipping anything: reporting it told a client asking for the last 5 messages + // that it was "skipping 95". + val l = listener() + l.startFromDiscard = StartFromDiscard.perTopic(Map(p0 -> 3L)) + assertTrue(l.progressDiscard.total == 0L, l.effectiveDiscard.remaining == 3L) ?? + s"latest-n reported a skip of ${l.progressDiscard.total}" + }, + test("a session reports the merge's progress, and completes when its budget is spent") { + val l = listener() + val ordering = skipOrdering(4) + l.startFromOrdering = ordering + val runner = session(0 -> targetRunner(l)) + val budget = ordering.progressDiscard.get + + val atStart = runner.startFromProgress.map(p => (p.messagesSkipped, p.messagesToSkip, p.complete)) + budget.claim(p0) + budget.claim(p1) + val midway = runner.startFromProgress.map(p => (p.messagesSkipped, p.messagesToSkip, p.complete)) + budget.claim(p0) + budget.claim(p1) + val atEnd = runner.startFromProgress.map(p => (p.messagesSkipped, p.messagesToSkip, p.complete)) + + assertTrue( + atStart == Some((0L, 4L, false)), + midway == Some((2L, 4L, false)), + atEnd == Some((4L, 4L, true)), + runner.remainingStartFromDiscard == 0L + ) ?? s"atStart=$atStart midway=$midway atEnd=$atEnd" + }, + test("two targets sharing one merge report its counter ONCE, not twice") { + val ordering = skipOrdering(10) + val first = listener() + val second = listener() + first.startFromOrdering = ordering + second.startFromOrdering = ordering + val runner = session(0 -> targetRunner(first), 1 -> targetRunner(second)) + assertTrue(runner.startFromProgress.map(_.messagesToSkip) == Some(10L)) + } + ) + + private val armOnceSuite = suite("the ordering layer is armed once")( + test("resuming a target does not swap the ordering layer") { + // The same regression the discard counter has: re-arming on play would start the merge + // over and skip a fresh n every time the user hits it. + val l = listener() + val armed = skipOrdering(3) + l.startFromOrdering = armed + val runner = targetRunner(l) + runner.resume(onNext = (_, _, _, _) => (), isDebug = false, incrementNumMessageProcessed = () => (), onStartFromDiscardProgress = () => ()) + runner.pause() + runner.resume(onNext = (_, _, _, _) => (), isDebug = false, incrementNumMessageProcessed = () => (), onStartFromDiscardProgress = () => ()) + assertTrue(l.startFromOrdering eq armed) + }, + test("a merge partway through a skip stays partway through it across a pause") { + val l = listener() + val ordering = skipOrdering(5) + l.startFromOrdering = ordering + val runner = targetRunner(l) + ordering.progressDiscard.foreach(_.claim(p0)) + ordering.progressDiscard.foreach(_.claim(p0)) + runner.pause() + runner.resume(onNext = (_, _, _, _) => (), isDebug = false, incrementNumMessageProcessed = () => (), onStartFromDiscardProgress = () => ()) + assertTrue(l.progressDiscard.remaining == 3L, l.progressDiscard.total == 5L) + } + ) @@ TestAspect.sequential + + /** THE FLOW-CONTROL WIRING, end to end at the ordering layer: the merge's desired-paused set + * actually reaching (and releasing) the per-stream pause hooks. The merge-side watermark + * logic is pinned in globalStartFromTest; THIS is the half nothing else exercises - e2e + * counts never cross the 1000-message watermark. + */ + private val flowControlWiringSuite = suite("the desired-paused set reaches the consumer hooks")( + test("pause fires past the watermark, resume fires at the cut, and re-assertion heals an external resume") { + // Two real streams: p1 never delivers (blind), p0 crosses the per-stream watermark. + val ordering = StartFromOrdering.make[String]( + StartFromOrderingPlan.GlobalSkip( + 1, + Vector(stream(consumerName, p0, 5_000), stream(otherConsumerName, p1, 5_000)) + ) + ) + var p0Paused = 0 + var p0Resumed = 0 + var p1Paused = 0 + ordering.registerStreamPauseHooks(startFromStreamId(consumerName, p0), () => p0Paused += 1, () => p0Resumed += 1) + ordering.registerStreamPauseHooks(startFromStreamId(otherConsumerName, p1), () => p1Paused += 1, () => ()) + + // Cross the production per-stream watermark (1000) while p1 stays silent. + (1L to (startFromMergePauseStreamAt + 5L)).foreach { i => + ordering.deliver(consumerName, p0, 100L + i, i, s"a$i") + } + ordering.reconcileFlowControl() + val pausedAtWatermark = p0Paused + val blindNeverPaused = p1Paused + + // An EXTERNAL wholesale resume (user resume, or the rate limiter releasing its permit + // hold) wakes every consumer behind this layer's back. The bookkeeping reset models + // the runner's call; even without it, the next reconcile re-asserts the pause. + ordering.resetAppliedFlowControl() + ordering.reconcileFlowControl() + val pausedAgainAfterExternalResume = p0Paused + + // p1 finally speaks: older than everything held, at its backlog end... its end is + // entry 5000, so this message does NOT end its backlog - the budget's exhaustion is + // what releases everything (skip 1: the drop finishes the merge). + val atCut = ordering.deliver(otherConsumerName, p1, 1L, 0, "b1") + ordering.settleIfDone() + ordering.reconcileFlowControl() + + assertTrue( + pausedAtWatermark >= 1, + blindNeverPaused == 0, + pausedAgainAfterExternalResume > pausedAtWatermark, // the drift self-heals + atCut.head == ("b1" -> StartFromOutcome.Drop), + atCut.count(_._2 == StartFromOutcome.Deliver) == (startFromMergePauseStreamAt + 5).toInt, + p0Resumed >= 1 // the cut released the pause + ) ?? s"pausedAtWatermark=$pausedAtWatermark again=$pausedAgainAfterExternalResume resumed=$p0Resumed p1Paused=$p1Paused atCutHead=${atCut.headOption}" + } + ) + + def spec = suite(this.getClass.toString)(planSuite, identitySuite, backlogEndSuite, passThroughSuite, progressSuite, armOnceSuite, flowControlWiringSuite) diff --git a/server/src/test/scala/consumer/session_runner/startFromProgressTest.scala b/server/src/test/scala/consumer/session_runner/startFromProgressTest.scala new file mode 100644 index 000000000..6e1d5294c --- /dev/null +++ b/server/src/test/scala/consumer/session_runner/startFromProgressTest.scala @@ -0,0 +1,411 @@ +package consumer.session_runner + +import _root_.consumer.coloring_rules.ColoringRuleChain +import _root_.consumer.deserializer.Deserializer +import _root_.consumer.deserializer.deserializers.UseLatestTopicSchema +import _root_.consumer.message_filter.MessageFilterChain +import _root_.consumer.pause_trigger.ConsumerSessionPauseTriggerChain +import _root_.consumer.session_config.ConsumerSessionConfig +import _root_.consumer.session_target.ConsumerSessionTarget +import _root_.consumer.session_target.consumption_mode.ConsumerSessionTargetConsumptionMode +import _root_.consumer.session_target.consumption_mode.modes.RegularConsumptionMode +import _root_.consumer.session_target.topic_selector.{MultiTopicSelector, TopicSelector} +import _root_.consumer.start_from.EarliestMessage +import _root_.consumer.value_projections.ValueProjectionList +import com.tools.teal.pulsar.ui.api.v1.consumer as consumerPb +import zio.test.* + +import java.util.concurrent.atomic.AtomicLong +import scala.jdk.CollectionConverters.* + +/** Progress reporting for a start-from position that has to be reached by COUNTING. + * + * "Skip the first n messages" is O(n) - Pulsar keeps no message-ordinal index, so the only exact + * way to land on message n is to stream n messages and throw them away. n is a number the user + * typed and is deliberately uncapped, so a session can spend a long time delivering nothing at all. + * Without a report the UI cannot tell that from a hung session. + * + * The awkward part is that a discarded message never reaches the delivery path - the listener drops + * it before the message handler - so nothing on the normal response path fires while the skip is in + * flight. The report therefore has to be pushed from the discard itself, and throttled, because one + * gRPC frame per skipped message would be millions of frames. + */ +object startFromProgressTest extends ZIOSpecDefault: + + private val topicFqn = "persistent://public/default/progress" + private val otherTopicFqn = "persistent://public/default/progress-other" + + private final class RecordingObserver extends io.grpc.stub.StreamObserver[consumerPb.ResumeResponse]: + private val responses = java.util.concurrent.ConcurrentLinkedQueue[consumerPb.ResumeResponse]() + override def onNext(value: consumerPb.ResumeResponse): Unit = responses.add(value) + override def onError(t: Throwable): Unit = () + override def onCompleted(): Unit = () + def received: Vector[consumerPb.ResumeResponse] = responses.asScala.toVector + def progressReports: Vector[(Long, Long, Boolean)] = + received.flatMap(_.consumerStats).flatMap(_.startFromProgress).map(p => (p.messagesSkipped, p.messagesToSkip, p.complete)) + + /** A listener already open for business. It starts CLOSED in production - nothing may be + * consumed before the session is armed and a client has resumed it - and + * `ConsumerSessionTargetRunner.resume` is what opens it. */ + private def listener(): ConsumerListener = + val l = ConsumerListener(ConsumerSessionTargetMessageHandler(onNext = _ => ())) + l.startAcceptingNewMessages() + l + + private def targetConfig(topicFqns: Vector[String]): ConsumerSessionTarget = + ConsumerSessionTarget( + isEnabled = true, + consumptionMode = ConsumerSessionTargetConsumptionMode(mode = RegularConsumptionMode()), + messageValueDeserializer = Deserializer(deserializer = UseLatestTopicSchema()), + topicSelector = TopicSelector(topicSelector = MultiTopicSelector(topicFqns = topicFqns)), + messageFilterChain = MessageFilterChain.empty, + coloringRuleChain = ColoringRuleChain.empty, + valueProjectionList = ValueProjectionList(isEnabled = false, projections = Vector.empty) + ) + + private def targetRunner(consumerListener: ConsumerListener, topicFqns: Vector[String] = Vector(topicFqn)): ConsumerSessionTargetRunner = + ConsumerSessionTargetRunner( + targetIndex = 0, + targetConfig = targetConfig(topicFqns), + nonPartitionedTopicFqns = topicFqns, + schemasByTopic = Map.empty, + sessionContextPool = ConsumerSessionContextPool(), + consumers = Map.empty, + consumerListener = consumerListener, + stats = ConsumerSessionTargetStats(messageProcessed = AtomicLong(0)) + ) + + private def session(targets: (Int, ConsumerSessionTargetRunner)*): ConsumerSessionRunner = + ConsumerSessionRunner( + sessionName = "cs-progress", + sessionConfig = ConsumerSessionConfig( + startFrom = EarliestMessage(), + targets = Vector.empty, + messageFilterChain = MessageFilterChain.empty, + coloringRuleChain = ColoringRuleChain.empty, + pauseTriggerChain = ConsumerSessionPauseTriggerChain.empty, + valueProjectionList = ValueProjectionList(isEnabled = false, projections = Vector.empty) + ), + sessionContextPool = ConsumerSessionContextPool(), + grpcResponseObserver = None, + schemasByTopic = Map.empty, + targets = targets.toMap + ) + + private def progressOf(runner: ConsumerSessionRunner): Option[(Long, Long, Boolean)] = + runner.startFromProgress.map(p => (p.messagesSkipped, p.messagesToSkip, p.complete)) + + private val totalSuite = suite("what the discard was armed with")( + test("a shared discard remembers its total after the counter has been spent") { + // messagesToSkip is the number the user asked for; reading it off the live counter + // would report a total that shrinks to zero as the skip progresses. + val discard = StartFromDiscard.shared(5) + discard.claim(topicFqn) + discard.claim(topicFqn) + assertTrue(discard.total == 5L, discard.remaining == 3L) + }, + test("a per-topic discard totals every topic it covers") { + val discard = StartFromDiscard.perTopic(Map(topicFqn -> 2L, otherTopicFqn -> 3L)) + discard.claim(topicFqn) + assertTrue(discard.total == 5L, discard.remaining == 4L) + }, + test("the empty discard has nothing to skip") { + assertTrue(StartFromDiscard.none.total == 0L) + }, + test("a negative arming is clamped in the total too") { + // DEFENCE IN DEPTH, and no longer reachable from a request: a negative count is now + // REFUSED at the trust boundary (see `startFromCountRejectionReason`) instead of being + // clamped into a different valid position. This pins the counter's own behaviour so an + // internal caller that ever armed one negative cannot produce a nonsense total. + assertTrue(StartFromDiscard.shared(-7).total == 0L) + } + ) + + private val throttleSuite = suite("how often a skip in flight is reported")( + test("the very first skipped message is reported, so the UI learns the total immediately") { + assertTrue(shouldReportStartFromProgress(skipped = 1, total = 1_000_000, reportEvery = 10_000)) + }, + test("the last skipped message is reported, so the UI sees the run complete") { + assertTrue(shouldReportStartFromProgress(skipped = 1_000_000, total = 1_000_000, reportEvery = 10_000)) + }, + test("only every reportEvery-th message in between is reported") { + val reported = (1L to 30_000L).filter(skipped => shouldReportStartFromProgress(skipped, total = 1_000_000, reportEvery = 10_000)) + assertTrue(reported == Vector(1L, 10_000L, 20_000L, 30_000L)) ?? + s"a skip of a million must not put a frame on the wire per message, got ${reported.size} reports" + }, + test("a skip shorter than one interval still reports its start and its end") { + val reported = (1L to 5L).filter(skipped => shouldReportStartFromProgress(skipped, total = 5, reportEvery = 10_000)) + assertTrue(reported == Vector(1L, 5L)) + }, + test("a count past the total still reports - a race between listener threads must not lose the end") { + assertTrue(shouldReportStartFromProgress(skipped = 7, total = 5, reportEvery = 10_000)) + }, + test("nothing is reported for a session with nothing to skip") { + assertTrue( + !shouldReportStartFromProgress(skipped = 0, total = 0, reportEvery = 10_000), + !shouldReportStartFromProgress(skipped = 1, total = 0, reportEvery = 10_000) + ) + }, + test("a zero or negative interval degrades to first-and-last instead of dividing by zero") { + val reported = (1L to 20L).filter(skipped => shouldReportStartFromProgress(skipped, total = 20, reportEvery = 0)) + assertTrue(reported == Vector(1L, 20L)) + }, + test("the production interval is coarse enough that a million-message skip is a trickle") { + val reports = (1L to 1_000_000L).count(skipped => shouldReportStartFromProgress(skipped, 1_000_000, startFromProgressReportInterval)) + assertTrue(reports > 1, reports < 1000) ?? s"a 1,000,000 skip produced $reports progress frames" + } + ) + + private val sessionProgressSuite = suite("what the session reports")( + test("a session with no skip to do reports no progress at all") { + // Earliest / Latest / date-time / message-id / approximate all seek exactly: there is + // nothing to count, and a progress bar for them would be a lie. + val runner = session(0 -> targetRunner(listener())) + assertTrue(progressOf(runner).isEmpty) + }, + test("a skip in flight reports the total and how much of it is actually done") { + val l = listener() + l.startFromDiscard = StartFromDiscard.shared(10) + val runner = session(0 -> targetRunner(l)) + val atStart = progressOf(runner) + (1 to 4).foreach(_ => l.decide(topicFqn, canAcknowledge = true)) + assertTrue(atStart == Some((0L, 10L, false)), progressOf(runner) == Some((4L, 10L, false))) + }, + test("progress completes exactly when the last message has been skipped") { + val l = listener() + l.startFromDiscard = StartFromDiscard.shared(3) + val runner = session(0 -> targetRunner(l)) + (1 to 2).foreach(_ => l.decide(topicFqn, canAcknowledge = true)) + val beforeLast = progressOf(runner) + l.decide(topicFqn, canAcknowledge = true) + val afterLast = progressOf(runner) + l.decide(topicFqn, canAcknowledge = true) // now delivering normally + assertTrue(beforeLast == Some((2L, 3L, false)), afterLast == Some((3L, 3L, true)), progressOf(runner) == Some((3L, 3L, true))) + }, + test("a shared counter across two targets is counted once, not twice") { + // Both targets hold the SAME counter; summing per target would report 20 to skip. + val shared = StartFromDiscard.shared(10) + val first = listener() + val second = listener() + first.startFromDiscard = shared + second.startFromDiscard = shared + val runner = session(0 -> targetRunner(first), 1 -> targetRunner(second)) + first.decide(topicFqn, canAcknowledge = true) + second.decide(topicFqn, canAcknowledge = true) + assertTrue(progressOf(runner) == Some((2L, 10L, false))) + }, + test("a LATEST-N seek correction is not user-facing progress") { + // The per-topic counter of a "latest n" is INTERNAL: the seek can only land on an entry + // boundary, so each topic over-fetches and drops its own overshoot. That is the session + // reaching the position the user asked for, not the user's own skip - and the proto + // says so ("Only NthMessageAfterEarliest needs this"). Reporting it told a client that + // asked for the last 5 messages that it was "skipping 95 messages". + val first = listener() + val second = listener() + first.startFromDiscard = StartFromDiscard.perTopic(Map(topicFqn -> 3L)) + second.startFromDiscard = StartFromDiscard.perTopic(Map(otherTopicFqn -> 2L)) + val runner = session(0 -> targetRunner(first), 1 -> targetRunner(second, Vector(otherTopicFqn))) + first.decide(topicFqn, canAcknowledge = true) + assertTrue(progressOf(runner).isEmpty) ?? + s"a latest-n overshoot was reported to the client as a skip: ${progressOf(runner)}" + }, + test("the latest-n correction still HAPPENS, it is just not reported") { + // The counter must keep dropping the overshoot - only its visibility changed. + val l = listener() + l.startFromDiscard = StartFromDiscard.perTopic(Map(topicFqn -> 2L)) + session(0 -> targetRunner(l)) + val actions = (1 to 4).map(_ => l.decide(topicFqn, canAcknowledge = true)) + assertTrue(actions.count(_ == ConsumerListener.Action.Drop) == 2, l.startFromDiscard.remaining == 0L) + }, + test("a SKIP-N counter is user-facing progress and is still reported") { + // The control: the same machinery, armed by "skip the first n", is exactly what the + // progress API exists for. + val l = listener() + l.startFromDiscard = StartFromDiscard.shared(4) + val runner = session(0 -> targetRunner(l)) + l.decide(topicFqn, canAcknowledge = true) + assertTrue(progressOf(runner) == Some((1L, 4L, false))) + }, + test("a message rejected while paused does not count as skipped") { + val l = listener() + l.startFromDiscard = StartFromDiscard.shared(4) + val target = targetRunner(l) + val runner = session(0 -> target) + l.decide(topicFqn, canAcknowledge = true) + target.pause() + (1 to 5).foreach(_ => l.decide(topicFqn, canAcknowledge = true)) + assertTrue(progressOf(runner) == Some((1L, 4L, false))) + } + ) + + private val responseSuite = suite("what goes on the wire")( + test("a response carrying messages also carries the progress") { + val progress = consumerPb.StartFromProgress(messagesSkipped = 7, messagesToSkip = 9, complete = false) + val response = resumeResponse(Seq(consumerPb.Message(numMessageProcessed = 3)), Vector.empty, Some(progress)) + assertTrue( + response.consumerStats.flatMap(_.startFromProgress) == Some(progress), + response.messages.size == 1 + ) + }, + test("a response for a session with nothing to skip carries no stats at all") { + val response = resumeResponse(Seq(consumerPb.Message()), Vector.empty, None) + assertTrue(response.consumerStats.isEmpty) + }, + test("errors still decide the status, whether or not progress is attached") { + val withErrors = resumeResponse(Seq.empty, Vector("boom", "bang"), None) + val withoutErrors = resumeResponse(Seq.empty, Vector.empty, None) + assertTrue( + withErrors.getStatus.code == com.google.rpc.code.Code.UNKNOWN.value, + withErrors.getStatus.message.contains("boom") && withErrors.getStatus.message.contains("bang"), + withoutErrors.getStatus.code == com.google.rpc.code.Code.OK.value + ) + }, + test("a delivered message carries the completed progress, so the client clears its panel") { + // The client hides its progress panel on `complete` or on an absent field. If the + // responses that resume normal delivery dropped the stats, the panel would be left on + // screen for the rest of the session. + val l = listener() + l.startFromDiscard = StartFromDiscard.shared(2) + val runner = session(0 -> targetRunner(l)) + val observer = RecordingObserver() + (1 to 2).foreach(_ => l.decide(topicFqn, canAcknowledge = true)) + + runner.sendResponse(observer, Seq(consumerPb.Message(numMessageProcessed = 5)), Vector.empty) + + assertTrue(observer.progressReports == Vector((2L, 2L, true)), observer.received.head.messages.size == 1) + }, + test("a delivered message on a session that skipped nothing carries no stats at all") { + val runner = session(0 -> targetRunner(listener())) + val observer = RecordingObserver() + + runner.sendResponse(observer, Seq(consumerPb.Message()), Vector.empty) + + assertTrue(observer.received.size == 1, observer.received.head.consumerStats.isEmpty) + }, + test("delivery never begins while the skip is still reported as in flight") { + // The invariant the client's "clear on complete" rule depends on: the first message that + // is NOT dropped must already see a completed progress, never a stale in-flight one. + val l = listener() + l.startFromDiscard = StartFromDiscard.shared(3) + val runner = session(0 -> targetRunner(l)) + + val atDelivery = (1 to 6).map(_ => (l.decide(topicFqn, canAcknowledge = true), progressOf(runner))).collect { + case (ConsumerListener.Action.Deliver, progress) => progress + } + + assertTrue(atDelivery.size == 3, atDelivery.forall(_.exists((_, _, complete) => complete))) ?? + s"a message was delivered while the skip still claimed to be running: $atDelivery" + }, + test("a progress-only push is an OK, message-less response") { + // The client reads the trailing message's counters only when a message is present, so a + // message-less progress frame must not pretend to carry one. + val response = resumeResponse(Seq.empty, Vector.empty, Some(consumerPb.StartFromProgress(messagesSkipped = 1, messagesToSkip = 9))) + assertTrue( + response.messages.isEmpty, + response.getStatus.code == com.google.rpc.code.Code.OK.value, + response.consumerStats.flatMap(_.startFromProgress).map(_.messagesSkipped) == Some(1L) + ) + } + ) + + private val pushSuite = suite("pushing progress while nothing is being delivered")( + test("a skip in flight pushes progress to the client although no message is delivered") { + val l = listener() + l.startFromDiscard = StartFromDiscard.shared(3) + val runner = session(0 -> targetRunner(l)) + val observer = RecordingObserver() + + runner.resume(observer, isDebug = false) + (1 to 3).foreach(_ => l.decide(topicFqn, canAcknowledge = true)) + + assertTrue(observer.progressReports == Vector((1L, 3L, false), (3L, 3L, true))) ?? + s"expected a first and a completing report, got ${observer.progressReports}" + }, + test("a long skip is reported periodically, not per message") { + val l = listener() + l.startFromDiscard = StartFromDiscard.shared(25_000) + val runner = session(0 -> targetRunner(l)) + val observer = RecordingObserver() + + runner.resume(observer, isDebug = false) + (1 to 25_000).foreach(_ => l.decide(topicFqn, canAcknowledge = true)) + + assertTrue( + observer.progressReports.head == (1L, 25_000L, false), + observer.progressReports.last == (25_000L, 25_000L, true), + observer.progressReports.size == 4 // 1, 10_000, 20_000, 25_000 + ) ?? s"got ${observer.progressReports}" + }, + test("a session with nothing to skip pushes nothing") { + val l = listener() + val runner = session(0 -> targetRunner(l)) + val observer = RecordingObserver() + + runner.resume(observer, isDebug = false) + (1 to 100).foreach(_ => l.decide(topicFqn, canAcknowledge = true)) + + assertTrue(observer.received.isEmpty) + }, + test("nothing is pushed before the session is resumed") { + // The listener is armed at session creation, long before any client is listening. + val l = listener() + l.startFromDiscard = StartFromDiscard.shared(3) + session(0 -> targetRunner(l)) + val dropped = (1 to 3).map(_ => l.decide(topicFqn, canAcknowledge = true)) + assertTrue(dropped.forall(_ == ConsumerListener.Action.Drop)) + }, + test("a client that did NOT ask for consumer stats is sent none") { + // `ResumeRequest.include_consumer_stats` is the client saying whether it wants them. + // It was read off the request and then dropped on the floor, so every client got the + // stats - and, worse, got message-LESS progress frames it never asked to handle. + val l = listener() + l.startFromDiscard = StartFromDiscard.shared(3) + val runner = session(0 -> targetRunner(l)) + val observer = RecordingObserver() + + runner.resume(observer, isDebug = false, includeConsumerStats = false) + (1 to 3).foreach(_ => l.decide(topicFqn, canAcknowledge = true)) + runner.sendResponse(observer, Seq(consumerPb.Message(numMessageProcessed = 1)), Vector.empty) + + assertTrue( + observer.received.size == 1, + observer.received.head.consumerStats.isEmpty + ) ?? s"a client that asked for no stats received ${observer.received.size} responses: ${observer.progressReports}" + }, + test("a client that DID ask for consumer stats still gets them") { + val l = listener() + l.startFromDiscard = StartFromDiscard.shared(3) + val runner = session(0 -> targetRunner(l)) + val observer = RecordingObserver() + + runner.resume(observer, isDebug = false, includeConsumerStats = true) + (1 to 3).foreach(_ => l.decide(topicFqn, canAcknowledge = true)) + + assertTrue(observer.progressReports == Vector((1L, 3L, false), (3L, 3L, true))) + }, + test("resuming again keeps reporting to the NEW client without re-arming the skip") { + // Pause/resume must not skip a fresh batch - and the second client must still see where + // the skip got to. + val l = listener() + l.startFromDiscard = StartFromDiscard.shared(4) + val target = targetRunner(l) + val runner = session(0 -> target) + val first = RecordingObserver() + val second = RecordingObserver() + + runner.resume(first, isDebug = false) + l.decide(topicFqn, canAcknowledge = true) + runner.pause() + runner.resume(second, isDebug = false) + val afterResume = (1 to 5).map(_ => l.decide(topicFqn, canAcknowledge = true)) + + assertTrue( + first.progressReports == Vector((1L, 4L, false)), + second.progressReports == Vector((4L, 4L, true)), + afterResume.count(_ == ConsumerListener.Action.Drop) == 3, + l.startFromDiscard.remaining == 0L + ) ?? s"first=${first.progressReports} second=${second.progressReports} actions=$afterResume" + } + ) @@ TestAspect.sequential + + def spec = suite(this.getClass.toString)(totalSuite, throttleSuite, sessionProgressSuite, responseSuite, pushSuite) diff --git a/server/src/test/scala/consumer/session_runner/topicPositionsTest.scala b/server/src/test/scala/consumer/session_runner/topicPositionsTest.scala new file mode 100644 index 000000000..2320b8bc5 --- /dev/null +++ b/server/src/test/scala/consumer/session_runner/topicPositionsTest.scala @@ -0,0 +1,170 @@ +package consumer.session_runner + +import zio.test.* +import zio.test.Assertion.* +import org.apache.pulsar.client.impl.{BatchMessageIdImpl, MessageIdImpl} + +/** The per-topic debug view's arithmetic and its refusals. + * + * Every case here is one the broker actually produces - the open-ledger hole is measured, the empty + * topic and the non-persistent refusal are the failure modes `startFromLookups` already classifies, + * and the backwards range is what a producer clock that stepped back leaves behind. The point of + * the suite is that each of them reports NOTHING rather than a plausible-looking zero. + */ +object topicPositionsTest extends ZIOSpecDefault: + + private def msgId(ledger: Long, entry: Long) = MessageIdImpl(ledger, entry, -1) + private def endpoint(publishTime: Long, ledger: Long = 1, entry: Long = 0) = + LogEndpoint(msgId(ledger, entry), publishTime) + private def cursorAt(publishTime: Long, ledger: Long = 1, entry: Long = 0) = + TopicCursor(msgId(ledger, entry), publishTime) + + private val inputs = TopicPositionInputs( + topicFqn = "persistent://t/n/topic", + first = None, + last = None, + cursor = None, + ledgers = Vector.empty, + currentLedgerEntries = 0, + retainedEntries = 0, + unavailableReason = None + ) + + def spec = suite("topicPositions")( + suite("the open-ledger hole")( + test("the CURRENT ledger's real entry count is used where the list reports zero") { + // Measured on a live 6060-entry topic: the one open ledger reports entries 0 while + // currentLedgerEntries holds 6060. Walking the list as reported puts every cursor in + // that ledger at ordinal 1 - "0% through" for the whole life of the ledger. + val patched = retainedLedgerSpans(Vector(LedgerSpan(7057, 0)), currentLedgerEntries = 6060) + assertTrue(patched == Vector(LedgerSpan(7057, 6060))) + }, + test("a CLOSED ledger reporting zero in the middle of the list is left alone") { + val ledgers = Vector(LedgerSpan(1, 0), LedgerSpan(2, 50), LedgerSpan(3, 0)) + val patched = retainedLedgerSpans(ledgers, currentLedgerEntries = 7) + // Only the last is patched; the leading zero is somebody else's business. + assertTrue(patched == Vector(LedgerSpan(1, 0), LedgerSpan(2, 50), LedgerSpan(3, 7))) + }, + test("a last ledger that already reports entries is NOT overwritten") { + val ledgers = Vector(LedgerSpan(1, 10), LedgerSpan(2, 20)) + assertTrue(retainedLedgerSpans(ledgers, currentLedgerEntries = 999) == ledgers) + }, + test("an empty ledger list stays empty rather than growing a phantom ledger") { + assertTrue(retainedLedgerSpans(Vector.empty, currentLedgerEntries = 500).isEmpty) + } + ), + suite("entry ordinal")( + test("counts every entry in the ledgers BEFORE the cursor's own, and is 1-based") { + val ledgers = Vector(LedgerSpan(1, 100), LedgerSpan(2, 50), LedgerSpan(3, 10)) + // Entry 0 of ledger 2 is the 101st retained entry. + assertTrue(entryOrdinalOf(ledgers, 2, 0).contains(101L)) && + assertTrue(entryOrdinalOf(ledgers, 2, 49).contains(150L)) && + assertTrue(entryOrdinalOf(ledgers, 1, 0).contains(1L)) && + assertTrue(entryOrdinalOf(ledgers, 3, 9).contains(160L)) + }, + test("a cursor whose ledger has AGED OUT reports nothing, not the beginning") { + // Retention trimmed ledger 1 from under a session that had read it. Answering 1, or + // 0%, would claim the session is at the start when it is in fact past it. + val ledgers = Vector(LedgerSpan(2, 50), LedgerSpan(3, 10)) + assertTrue(entryOrdinalOf(ledgers, 1, 5).isEmpty) + } + ), + suite("time fraction")( + test("places the cursor proportionally between the endpoints") { + val f = cursorTimeFractionOf(Some(endpoint(1000)), Some(endpoint(2000)), Some(cursorAt(1500))) + assertTrue(f.contains(0.5)) + }, + test("a topic occupying ONE INSTANT has no interior, so no fraction describes it") { + // first == last: no position separates the messages. 0.0 and 1.0 would both be + // inventions - the same rule ApproximateTimePosition follows. + val f = cursorTimeFractionOf(Some(endpoint(1000)), Some(endpoint(1000)), Some(cursorAt(1000))) + assertTrue(f.isEmpty) + }, + test("a range reported BACKWARDS is refused rather than turned into a negative fraction") { + // Publish time is stamped by the producer, so a clock that stepped back produces it. + val f = cursorTimeFractionOf(Some(endpoint(2000)), Some(endpoint(1000)), Some(cursorAt(1500))) + assertTrue(f.isEmpty) + }, + test("a cursor PAST the recorded end clamps to 1.0 - a stale denominator, not an overrun") { + // The endpoints and the cursor are not read atomically: a message published between + // the two lookups leaves the cursor beyond the last entry that was recorded. + val f = cursorTimeFractionOf(Some(endpoint(1000)), Some(endpoint(2000)), Some(cursorAt(9999))) + assertTrue(f.contains(1.0)) + }, + test("no cursor and no endpoints each yield nothing") { + assertTrue(cursorTimeFractionOf(Some(endpoint(1000)), Some(endpoint(2000)), None).isEmpty) && + assertTrue(cursorTimeFractionOf(None, Some(endpoint(2000)), Some(cursorAt(1500))).isEmpty) && + assertTrue(cursorTimeFractionOf(Some(endpoint(1000)), None, Some(cursorAt(1500))).isEmpty) + } + ), + suite("entry fraction")( + test("is the ordinal over the retained count") { + assertTrue(cursorEntryFractionOf(Some(50L), 100L).contains(0.5)) + }, + test("the BOUNDARIES read as proportion CONSUMED: first of N is 1/N, last is 1.0, a single entry is 1.0") { + // The contract the proto states: a cursor exists only once something was consumed, + // so there is no 0.0 with a cursor present - sitting ON the first of 100 entries + // means one entry consumed, 1%. A one-entry topic is fully consumed by its first + // read. Pinned here so a future switch to geometric position (ordinal-1 over N) + // has to change the contract on purpose, in both places. + assertTrue(cursorEntryFractionOf(Some(1L), 100L).contains(0.01)) && + assertTrue(cursorEntryFractionOf(Some(100L), 100L).contains(1.0)) && + assertTrue(cursorEntryFractionOf(Some(1L), 1L).contains(1.0)) + }, + test("a topic retaining NOTHING has no denominator, and 0/0 is not 0%") { + assertTrue(cursorEntryFractionOf(Some(1L), 0L).isEmpty) + }, + test("clamps rather than exceeding 1.0 when the count is staler than the cursor") { + assertTrue(cursorEntryFractionOf(Some(150L), 100L).contains(1.0)) + } + ), + suite("batch ids")( + test("two messages of ONE BATCH share the entry, so they share the ordinal") { + // The ordinal walk is entry-addressed. A batch index is a third coordinate inside the + // entry and must not shift the count. + val ledgers = Vector(LedgerSpan(1, 100)) + val first = BatchMessageIdImpl(1, 7, -1, 0) + val fifth = BatchMessageIdImpl(1, 7, -1, 4) + val rowOf = (id: org.apache.pulsar.client.api.MessageId) => + buildTopicPositionRow( + inputs.copy( + cursor = Some(TopicCursor(id, 1500)), + ledgers = ledgers, + retainedEntries = 100 + ) + ).cursorEntryOrdinal + assertTrue(rowOf(first) == rowOf(fifth)) && assertTrue(rowOf(first).contains(8L)) + } + ), + suite("assembled rows")( + test("an EMPTY topic reports no endpoints and no reason - it answered, with nothing in it") { + val row = buildTopicPositionRow(inputs) + assertTrue(row.first.isEmpty) && assertTrue(row.last.isEmpty) && + assertTrue(row.unavailableReason.isEmpty) && + assertTrue(row.cursorTimeFraction.isEmpty) && assertTrue(row.cursorEntryFraction.isEmpty) + }, + test("an UNAVAILABLE topic reports a reason and withholds the entry count") { + // A non-persistent topic: Pulsar refuses to examine it (405). Its retained count is 0 + // only because nothing filled it in, and a "0 entries" cell reads as an empty topic. + val row = buildTopicPositionRow(inputs.copy(unavailableReason = Some("non-persistent topic"))) + assertTrue(row.unavailableReason.contains("non-persistent topic")) && + assertTrue(row.retainedEntries.isEmpty) + }, + test("a fully populated row carries both fractions and the ordinal") { + val row = buildTopicPositionRow( + inputs.copy( + first = Some(endpoint(1000, ledger = 1, entry = 0)), + last = Some(endpoint(2000, ledger = 1, entry = 99)), + cursor = Some(cursorAt(1500, ledger = 1, entry = 49)), + ledgers = Vector(LedgerSpan(1, 0)), + currentLedgerEntries = 100, + retainedEntries = 100 + ) + ) + assertTrue(row.cursorTimeFraction.contains(0.5)) && + assertTrue(row.cursorEntryOrdinal.contains(50L)) && + assertTrue(row.cursorEntryFraction.contains(0.5)) && + assertTrue(row.retainedEntries.contains(100L)) + } + ) + ) diff --git a/server/src/test/scala/consumer/session_target/topic_selector/multiTopicSelectorTest.scala b/server/src/test/scala/consumer/session_target/topic_selector/multiTopicSelectorTest.scala new file mode 100644 index 000000000..d1a146bbe --- /dev/null +++ b/server/src/test/scala/consumer/session_target/topic_selector/multiTopicSelectorTest.scala @@ -0,0 +1,67 @@ +package consumer.session_target.topic_selector + +import org.apache.pulsar.client.admin.PulsarAdmin +import zio.test.* + +import java.util.concurrent.TimeUnit +import scala.util.Try + +/** `MultiTopicSelector` turns the FQNs the user picked into the concrete non-partitioned topics a + * session subscribes to. + * + * Regression context: a topic whose partitioning could not be read was logged with `println` and + * replaced by `Vector.empty`, i.e. silently dropped from the selection. With every topic + * unresolvable (an unreachable broker, a topic deleted between the picker and the session) the + * whole selector returned an empty vector, the session runner accepted a target with zero + * consumers, and `createConsumer` answered Code.OK - a session in state `running` that could never + * deliver a message. The sibling `NamespacedRegexTopicSelector` never swallowed these. + * + * Driven with a REAL PulsarAdmin aimed at a closed port: it constructs offline (see + * `pulsar_auth.ClientConstructionTest`) and then fails every call with a connection error, which is + * exactly the production failure. No broker, no mock. + */ +object multiTopicSelectorTest extends ZIOSpecDefault: + + private def withUnreachableAdmin[A](f: PulsarAdmin => A): A = + val admin = PulsarAdmin.builder + .serviceHttpUrl("http://127.0.0.1:1") + .connectionTimeout(2, TimeUnit.SECONDS) + .readTimeout(2, TimeUnit.SECONDS) + .requestTimeout(2, TimeUnit.SECONDS) + .build + try f(admin) + finally Try(admin.close()) + + def spec = suite(this.getClass.toString)( + test("a topic whose partitioning cannot be resolved fails loudly instead of being dropped") { + val topicFqn = "persistent://public/default/topic-that-cannot-be-resolved" + val result = withUnreachableAdmin(admin => Try(MultiTopicSelector(Vector(topicFqn)).getNonPartitionedTopics(admin))) + val message = result.failed.toOption.map(_.getMessage).getOrElse("") + + assertTrue(result.isFailure, message.contains(topicFqn)) ?? + s"an unresolvable topic must not be silently dropped, got: $result" + }, + test("one unresolvable topic fails the whole selection rather than returning the rest") { + // The partial-drop case: the user explicitly named three topics, so quietly consuming + // from a subset is just as wrong as quietly consuming from none. + val result = withUnreachableAdmin(admin => + Try( + MultiTopicSelector(Vector( + "persistent://public/default/t1", + "persistent://public/default/t2", + "persistent://public/default/t3" + )).getNonPartitionedTopics(admin) + ) + ) + + assertTrue(result.isFailure) + }, + test("a selector with no topics resolves to an empty vector without contacting the broker") { + // Control for the session-runner guard below: MultiTopicSelector reports an honest + // empty result here (no failure to report), which is why rejecting a target that + // resolves to nothing has to happen in ConsumerSessionRunner. + val result = withUnreachableAdmin(admin => Try(MultiTopicSelector(Vector.empty).getNonPartitionedTopics(admin))) + + assertTrue(result == scala.util.Success(Vector.empty)) + } + ) diff --git a/server/src/test/scala/consumer/start_from/startFromConversionsTest.scala b/server/src/test/scala/consumer/start_from/startFromConversionsTest.scala new file mode 100644 index 000000000..5ac157039 --- /dev/null +++ b/server/src/test/scala/consumer/start_from/startFromConversionsTest.scala @@ -0,0 +1,121 @@ +package consumer.start_from + +import com.tools.teal.pulsar.ui.api.v1.consumer as pb +import zio.test.* + +import java.time.Instant +import scala.util.{Failure, Success, Try} + +/** Every start-from mode has to survive the trip to protobuf and back. + * + * `ConsumerSessionStartFrom` is a UNION type, so its `match`es are NOT checked for exhaustiveness - + * a mode the conversion forgot falls into `case _ => throw`, and the failure surfaces at RUNTIME as + * a generic FAILED_PRECONDITION on saving or loading a session, not at compile time. Adding a mode + * without adding it here is therefore silent until a user picks it, which is exactly what happened + * to the two Nth modes: they were readable (`fromPb`) but not writable (`toPb`). + * + * The sweep below is over ALL modes, so a mode added later fails here rather than in production. + */ +object startFromConversionsTest extends ZIOSpecDefault: + + private val allModes: Vector[ConsumerSessionStartFrom] = Vector( + EarliestMessage(), + LatestMessage(), + NthMessageAfterEarliest(n = 5), + NthMessageBeforeLatest(n = 7), + MessageId(messageIdBytes = Array[Byte](8, 1, 16, 2)), + DateTime(dateTime = Instant.ofEpochSecond(1_700_000_000L, 123_000_000)), + RelativeDateTime(value = 3, unit = DateTimeUnit.Hour, isRoundedToUnitStart = true), + ApproximateDataPosition(fraction = 0.42), + ApproximateTimePosition(fraction = 0.42) + ) + + /** MessageId holds an Array, whose `==` is reference identity - a round trip would never look + * equal without this. */ + private def sameMode(a: ConsumerSessionStartFrom, b: ConsumerSessionStartFrom): Boolean = + (a, b) match + case (x: MessageId, y: MessageId) => x.messageIdBytes.sameElements(y.messageIdBytes) + case _ => a == b + + private def roundTrip(mode: ConsumerSessionStartFrom): Try[ConsumerSessionStartFrom] = + Try(ConsumerSessionStartFrom.fromPb(ConsumerSessionStartFrom.toPb(mode))) + + def spec = suite(this.getClass.toString)( + test("every start-from mode survives a proto round trip") { + val failures = allModes.flatMap { mode => + roundTrip(mode) match + case Success(back) if sameMode(back, mode) => None + case Success(back) => Some(s"$mode came back as $back") + case Failure(err) => Some(s"$mode threw ${err.getClass.getSimpleName}: ${err.getMessage}") + } + assertTrue(failures.isEmpty) ?? s"start-from modes that do not round trip: ${failures.mkString("; ")}" + }, + test("an approximate DATA position travels in the start_from_approximate_data_position field") { + val encoded = ConsumerSessionStartFrom.toPb(ApproximateDataPosition(fraction = 0.6)) + assertTrue( + encoded.startFrom.isStartFromApproximateDataPosition, + encoded.getStartFromApproximateDataPosition.fraction == 0.6 + ) + }, + test("an approximate TIME position travels in its own field, not the data one") { + // The two modes carry the same payload - a single double - so a conversion that reached + // for the wrong oneof case would still round-trip a fraction and look correct. The only + // symptom would be a session positioned by the wrong rule. + val encoded = ConsumerSessionStartFrom.toPb(ApproximateTimePosition(fraction = 0.6)) + assertTrue( + encoded.startFrom.isStartFromApproximateTimePosition, + !encoded.startFrom.isStartFromApproximateDataPosition, + encoded.getStartFromApproximateTimePosition.fraction == 0.6 + ) + }, + test("an approximate data position is read back off the wire as the model type") { + val decoded = ConsumerSessionStartFrom.fromPb( + pb.ConsumerSessionStartFrom( + startFrom = pb.ConsumerSessionStartFrom.StartFrom.StartFromApproximateDataPosition(pb.ApproximateDataPosition(fraction = 0.35)) + ) + ) + assertTrue(decoded == ApproximateDataPosition(fraction = 0.35)) + }, + test("an approximate time position is read back off the wire as the model type") { + val decoded = ConsumerSessionStartFrom.fromPb( + pb.ConsumerSessionStartFrom( + startFrom = pb.ConsumerSessionStartFrom.StartFrom.StartFromApproximateTimePosition(pb.ApproximateTimePosition(fraction = 0.35)) + ) + ) + assertTrue(decoded == ApproximateTimePosition(fraction = 0.35)) + }, + test("the fraction is carried at full double precision, not rounded on the way") { + // A UI slider at 1/3 must not come back as 0.33: the entry ordinal (data) and the + // millisecond cutoff (time) are both computed from it. + val precise = 1.0 / 3.0 + assertTrue( + roundTrip(ApproximateDataPosition(fraction = precise)) == Success(ApproximateDataPosition(fraction = precise)), + roundTrip(ApproximateTimePosition(fraction = precise)) == Success(ApproximateTimePosition(fraction = precise)) + ) + }, + test("an unset oneof is still rejected") { + // The catch-all `case _` must stay: a start-from with nothing selected is not a mode. + val result = Try(ConsumerSessionStartFrom.fromPb(pb.ConsumerSessionStartFrom())) + assertTrue(result.isFailure) + }, + test("every start-from mode survives a managed-item (library) round trip") { + // The saved-session side of the same union, with its own separate pair of matches. + import _root_.library.managed_items.ManagedConsumerSessionStartFromSpec + val managedModes = Vector( + EarliestMessage(), + LatestMessage(), + NthMessageAfterEarliest(n = 5), + NthMessageBeforeLatest(n = 7), + ApproximateDataPosition(fraction = 0.42), + ApproximateTimePosition(fraction = 0.42) + ) + val failures = managedModes.flatMap { mode => + val spec = ManagedConsumerSessionStartFromSpec(startFrom = mode) + Try(ManagedConsumerSessionStartFromSpec.fromPb(ManagedConsumerSessionStartFromSpec.toPb(spec))) match + case Success(back) if back == spec => None + case Success(back) => Some(s"$mode came back as ${back.startFrom}") + case Failure(err) => Some(s"$mode threw ${err.getClass.getSimpleName}: ${err.getMessage}") + } + assertTrue(failures.isEmpty) ?? s"managed start-from modes that do not round trip: ${failures.mkString("; ")}" + } + ) diff --git a/server/src/test/scala/conversions/primitiveConvTest.scala b/server/src/test/scala/conversions/primitiveConvTest.scala index a53210bb3..7ad2984a9 100644 --- a/server/src/test/scala/conversions/primitiveConvTest.scala +++ b/server/src/test/scala/conversions/primitiveConvTest.scala @@ -14,6 +14,13 @@ import com.google.common.primitives.{Bytes, Doubles, Ints, Shorts} object primitiveConvTest extends ZIOSpecDefault { private val floatPrecision = 0.000_000_1 + + /* Renders a byte array as hex so a failing table case is identifiable in the report. */ + private def hex(bytes: Array[Byte]): String = bytes.map(b => f"0x$b%02x").mkString("[", " ", "]") + + /* Keeps a label on a single line. */ + private def show(s: String): String = s.replace("\\", "\\\\").replace("\n", "\\n").replace("\t", "\\t") + def spec = suite(this.getClass.toString)( test("eqFloat") { case class TestCase(a: Double, b: Double, precision: Double, expected: Boolean) @@ -33,7 +40,11 @@ object primitiveConvTest extends ZIOSpecDefault { TestCase(Float.NaN, Float.NaN, floatPrecision, false) ) - assertTrue(testCases.forall(runTestCase)) + testCases.zipWithIndex.map { (testCase, idx) => + val actual = primitiveConv.eqFloat(testCase.a, testCase.b, testCase.precision) + assertTrue(runTestCase(testCase)) ?? + s"case #$idx: eqFloat(${testCase.a}, ${testCase.b}, ${testCase.precision}) = $actual, expected ${testCase.expected}" + }.reduce(_ && _) }, test("bytesToInt8") { case class TestCase(bytes: Array[Byte], check: (result: Either[Throwable, Byte]) => Boolean) @@ -55,7 +66,10 @@ object primitiveConvTest extends ZIOSpecDefault { TestCase(Array(0x00, 0x01, 0x01).map(_.toByte), _.isLeft) ) - assertTrue(testCases.forall(runTestCase)) + testCases.zipWithIndex.map { (testCase, idx) => + assertTrue(runTestCase(testCase)) ?? + s"case #$idx: bytesToInt8(${hex(testCase.bytes)}) = ${primitiveConv.bytesToInt8(testCase.bytes)}" + }.reduce(_ && _) }, test("bytesToInt16") { case class TestCase(bytes: Array[Byte], check: (result: Either[Throwable, Short]) => Boolean) @@ -78,7 +92,10 @@ object primitiveConvTest extends ZIOSpecDefault { TestCase(Array(0x00, 0x00, 0x01, 0x01).map(_.toByte), _.isLeft) ) - assertTrue(testCases.forall(runTestCase)) + testCases.zipWithIndex.map { (testCase, idx) => + assertTrue(runTestCase(testCase)) ?? + s"case #$idx: bytesToInt16(${hex(testCase.bytes)}) = ${primitiveConv.bytesToInt16(testCase.bytes)}" + }.reduce(_ && _) }, test("bytesToInt32") { case class TestCase(bytes: Array[Byte], check: (result: Either[Throwable, Int]) => Boolean) @@ -104,7 +121,10 @@ object primitiveConvTest extends ZIOSpecDefault { TestCase(Array(0x00, 0x00, 0x00, 0x00, 0x00).map(_.toByte), _.isLeft) ) - assertTrue(testCases.forall(runTestCase)) + testCases.zipWithIndex.map { (testCase, idx) => + assertTrue(runTestCase(testCase)) ?? + s"case #$idx: bytesToInt32(${hex(testCase.bytes)}) = ${primitiveConv.bytesToInt32(testCase.bytes)}" + }.reduce(_ && _) }, test("bytesToInt64") { case class TestCase(bytes: Array[Byte], check: (result: Either[Throwable, Long]) => Boolean) @@ -139,7 +159,10 @@ object primitiveConvTest extends ZIOSpecDefault { TestCase(Array(0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00).map(_.toByte), _.isLeft) ) - assertTrue(testCases.forall(runTestCase)) + testCases.zipWithIndex.map { (testCase, idx) => + assertTrue(runTestCase(testCase)) ?? + s"case #$idx: bytesToInt64(${hex(testCase.bytes)}) = ${primitiveConv.bytesToInt64(testCase.bytes)}" + }.reduce(_ && _) }, test("bytesToFloat32") { case class TestCase(bytes: Array[Byte], check: (result: Either[Throwable, Float]) => Boolean) @@ -165,7 +188,10 @@ object primitiveConvTest extends ZIOSpecDefault { TestCase(Array(0x00, 0x00, 0x00, 0x00, 0x00).map(_.toByte), _.isLeft) ) - assertTrue(testCases.forall(runTestCase)) + testCases.zipWithIndex.map { (testCase, idx) => + assertTrue(runTestCase(testCase)) ?? + s"case #$idx: bytesToFloat32(${hex(testCase.bytes)}) = ${primitiveConv.bytesToFloat32(testCase.bytes)}" + }.reduce(_ && _) }, test("bytesToFloat64") { case class TestCase(bytes: Array[Byte], check: (result: Either[Throwable, Double]) => Boolean) @@ -200,7 +226,10 @@ object primitiveConvTest extends ZIOSpecDefault { TestCase(Array(0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00).map(_.toByte), _.isLeft) ) - assertTrue(testCases.forall(runTestCase)) + testCases.zipWithIndex.map { (testCase, idx) => + assertTrue(runTestCase(testCase)) ?? + s"case #$idx: bytesToFloat64(${hex(testCase.bytes)}) = ${primitiveConv.bytesToFloat64(testCase.bytes)}" + }.reduce(_ && _) }, test("bytesToString") { case class TestCase(bytes: Array[Byte], expected: String) @@ -223,7 +252,11 @@ object primitiveConvTest extends ZIOSpecDefault { TestCase(Array(0x71, 0x75, 0x22, 0x6f, 0x74, 0x65, 0x22, 0x73).map(_.toByte), """qu"ote"s""") ) - assertTrue(testCases.forall(runTestCase)) + testCases.zipWithIndex.map { (testCase, idx) => + val actual = show(primitiveConv.bytesToString(testCase.bytes)) + assertTrue(runTestCase(testCase)) ?? + s"case #$idx: bytesToString(${hex(testCase.bytes)}) = $actual, expected ${show(testCase.expected)}" + }.reduce(_ && _) }, test("bytesToJsonString") { case class TestCase(bytes: Array[Byte], expected: String) @@ -244,7 +277,11 @@ object primitiveConvTest extends ZIOSpecDefault { TestCase(Array(0x71, 0x75, 0x22, 0x6f, 0x74, 0x65, 0x22, 0x73).map(_.toByte), """"qu\"ote\"s"""") ) - assertTrue(testCases.forall(runTestCase)) + testCases.zipWithIndex.map { (testCase, idx) => + val actual = show(primitiveConv.bytesToJsonString(testCase.bytes)) + assertTrue(runTestCase(testCase)) ?? + s"case #$idx: bytesToJsonString(${hex(testCase.bytes)}) = $actual, expected ${show(testCase.expected)}" + }.reduce(_ && _) }, test("bytesToBoolean") { case class TestCase(bytes: Array[Byte], check: (result: Either[Throwable, Boolean]) => Boolean) @@ -261,7 +298,10 @@ object primitiveConvTest extends ZIOSpecDefault { TestCase(Array(0x00, 0x01).map(_.toByte), _.isLeft) ) - assertTrue(testCases.forall(runTestCase)) + testCases.zipWithIndex.map { (testCase, idx) => + assertTrue(runTestCase(testCase)) ?? + s"case #$idx: bytesToBoolean(${hex(testCase.bytes)}) = ${primitiveConv.bytesToBoolean(testCase.bytes)}" + }.reduce(_ && _) }, test("bytesToJson") { case class TestCase(bytes: Array[Byte], check: (result: Either[Throwable, String]) => Boolean) @@ -289,7 +329,11 @@ object primitiveConvTest extends ZIOSpecDefault { TestCase("""{a:2,"b":{"c":3}}""".getBytes("UTF-8"), _.isLeft), ) - assertTrue(testCases.forall(runTestCase)) + testCases.zipWithIndex.map { (testCase, idx) => + val actual = primitiveConv.bytesToJson(testCase.bytes) + assertTrue(runTestCase(testCase)) ?? + s"case #$idx: bytesToJson(${show(primitiveConv.bytesToString(testCase.bytes))}) = $actual" + }.reduce(_ && _) }, test("leftPad") { case class TestCase(bytes: Array[Byte], size: Int, pad: Byte, expected: Array[Byte]) @@ -308,7 +352,11 @@ object primitiveConvTest extends ZIOSpecDefault { TestCase(Array(0x01).map(_.toByte), 1, 0, Array(0x01).map(_.toByte)) ) - assertTrue(testCases.forall(runTestCase)) + testCases.zipWithIndex.map { (testCase, idx) => + val actual = hex(primitiveConv.leftPad(testCase.bytes, testCase.size, testCase.pad)) + assertTrue(runTestCase(testCase)) ?? + s"case #$idx: leftPad(${hex(testCase.bytes)}, ${testCase.size}, ${testCase.pad}) = $actual, expected ${hex(testCase.expected)}" + }.reduce(_ && _) } ) } diff --git a/server/src/test/scala/library/libraryConcurrencyTest.scala b/server/src/test/scala/library/libraryConcurrencyTest.scala new file mode 100644 index 000000000..38b9cc2c4 --- /dev/null +++ b/server/src/test/scala/library/libraryConcurrencyTest.scala @@ -0,0 +1,89 @@ +package library + +import zio.* +import zio.test.* +import _root_.library.managed_items.{ManagedMarkdownDocument, ManagedMarkdownDocumentSpec} + +/** Concurrency contracts for `library/Library.scala`. + * + * `Library` is a per-instance singleton shared by every gRPC call (`LibraryServiceImpl.library`), so + * saveLibraryItem/deleteLibraryItem run concurrently on ONE object. Both mutators are + * "touch a file, then rescan the whole dir, then replace `db`": + * + * - two racing writers can interleave so an OLDER scan publishes LAST, dropping a just-written + * item from the snapshot even though its file is on disk (get/list stop seeing it until the + * next unrelated mutation); + * - deleteItem does exists-then-remove, so two racing deletes of the same id can both pass the + * exists check and both report OK. + * + * These tests race real threads (`ZIO.attemptBlocking` on the blocking pool) rather than asserting + * on structure, and repeat under `TestAspect.nonFlaky` because a single round can get lucky. + * Each test mints its own temp dir and its own Library, so parallel execution is safe. + */ +object libraryConcurrencyTest extends ZIOSpecDefault { + + private def tempDir(): os.Path = + os.temp.dir(prefix = "library-concurrency") + + private def tenantContext(tenant: String): ResourceMatcher = + ResourceMatcher(matcher = TenantMatcher(matcher = ExactTenantMatcher(tenant = tenant))) + + private def markdownItem(id: String): LibraryItem = + LibraryItem( + metadata = LibraryItemMetadata(updatedAt = "2026-07-25T00:00:00Z", availableForContexts = Vector(tenantContext("t1"))), + spec = ManagedMarkdownDocument( + metadata = ManagedItemMetadata( + `type` = ManagedItemType.MarkdownDocument, + id = id, + name = s"item-$id", + descriptionMarkdown = "" + ), + spec = ManagedMarkdownDocumentSpec(markdown = "hello") + ) + ) + + def spec = suite(this.getClass.toString)( + test("concurrent writes of distinct ids all survive in the in-memory snapshot") { + val root = tempDir() + val library = Library.createAndRefreshDb(root.toString) + val ids = (0 until 12).map(i => f"item$i%02d").toVector + + for _ <- ZIO.foreachParDiscard(ids)(id => ZIO.attemptBlocking(library.writeItem(markdownItem(id)))) + yield + val missingOnDisk = ids.filterNot(id => os.exists(root / s"$id.binpb")) + val missingInDb = ids.filterNot(id => library.getItemById(id).isDefined) + assertTrue(missingOnDisk.isEmpty, missingInDb.isEmpty, library.size == ids.size) ?? + s"written to disk but lost from the snapshot: ${missingInDb.mkString(", ")}" + } @@ TestAspect.nonFlaky(25), + test("a concurrent write is not dropped by a concurrent delete's rescan") { + // The same lost-update, mixed: the deleter's scan may predate the writer's file. + val root = tempDir() + val library = Library.createAndRefreshDb(root.toString) + library.writeItem(markdownItem("victim")) + + for _ <- ZIO.collectAllParDiscard(Seq( + ZIO.attemptBlocking(library.writeItem(markdownItem("newone"))), + ZIO.attemptBlocking(library.deleteItem("victim")) + )) + yield assertTrue( + library.getItemById("newone").isDefined, + library.getItemById("victim").isEmpty, + os.exists(root / "newone.binpb"), + library.size == 1 + ) + } @@ TestAspect.nonFlaky(25), + test("exactly one of several concurrent deletes of the same id succeeds") { + val root = tempDir() + val library = Library.createAndRefreshDb(root.toString) + library.writeItem(markdownItem("dupdel")) + + for results <- ZIO.foreachPar(1 to 4)(_ => ZIO.attemptBlocking(library.deleteItem("dupdel")).either) + yield assertTrue( + results.count(_.isRight) == 1, + results.count(_.isLeft) == 3, + !os.exists(root / "dupdel.binpb"), + library.size == 0 + ) ?? s"delete attempts that reported success: ${results.count(_.isRight)} (expected exactly 1)" + } @@ TestAspect.nonFlaky(25) + ) +} diff --git a/server/src/test/scala/library/libraryScanTest.scala b/server/src/test/scala/library/libraryScanTest.scala new file mode 100644 index 000000000..7831d7cb8 --- /dev/null +++ b/server/src/test/scala/library/libraryScanTest.scala @@ -0,0 +1,279 @@ +package library + +import zio.test.* +import com.tools.teal.pulsar.ui.library.v1.library as pb +import _root_.library.managed_items.{ManagedMarkdownDocument, ManagedMarkdownDocumentSpec} +import scala.util.Try +import ch.qos.logback.classic.{Level, Logger as LogbackLogger} +import ch.qos.logback.classic.spi.ILoggingEvent +import ch.qos.logback.core.read.ListAppender +import org.slf4j.LoggerFactory +import scala.jdk.CollectionConverters.* + +/** Unit coverage for the on-disk scan/refresh path in `library/Library.scala` - `scan`, `refreshDb` + * and `deleteItem`. `LibraryBugRegressionsTest` covers the write-side guards (id charset, empty + * contexts, empty search filter); the read side (what the scanner does with files it did NOT write) + * was untested. + * + * Every test mints its OWN `os.temp.dir` and its own `Library` instance, so the default parallel + * test execution is safe - there is no shared mutable state and no fixed path. + */ +object libraryScanTest extends ZIOSpecDefault { + + private def tempDir(): os.Path = + os.temp.dir(prefix = "library-scan") + + private def tenantContext(tenant: String): ResourceMatcher = + ResourceMatcher(matcher = TenantMatcher(matcher = ExactTenantMatcher(tenant = tenant))) + + private def markdownItem(id: String): LibraryItem = + LibraryItem( + metadata = LibraryItemMetadata(updatedAt = "2026-07-25T00:00:00Z", availableForContexts = Vector(tenantContext("t1"))), + spec = ManagedMarkdownDocument( + metadata = ManagedItemMetadata( + `type` = ManagedItemType.MarkdownDocument, + id = id, + name = s"item-$id", + descriptionMarkdown = "" + ), + spec = ManagedMarkdownDocumentSpec(markdown = "hello") + ) + ) + + private def itemBytes(item: LibraryItem): Array[Byte] = LibraryItem.toPb(item).toByteArray + + /** Capture the events emitted on the `library.Library` logger while `body` runs. The appender is + * attached before the scan and detached after, so it observes the scan's own logging. Other + * library suites log to the same logger under the default parallel execution, so the assertions + * below key on a file name UNIQUE to each test rather than on an event count - a foreign warning + * can never masquerade as the one under test. */ + // SLF4J hands a SubstituteLogger to callers that arrive while the backend is still initializing; + // under the default parallel suite execution our first `getLogger` can land in that window and a + // direct cast to the logback Logger throws ClassCastException. Re-fetch until the real logback + // binding is in place (initialization completes in milliseconds, so this resolves at once). + private def libraryLogbackLogger(): LogbackLogger = + var logger = LoggerFactory.getLogger("library.Library") + var attempts = 0 + while !logger.isInstanceOf[LogbackLogger] && attempts < 500 do + Thread.sleep(2) + logger = LoggerFactory.getLogger("library.Library") + attempts += 1 + logger match + case l: LogbackLogger => l + case other => throw new IllegalStateException(s"Expected a logback logger, got ${other.getClass.getName}") + + private def withCapturedLibraryLogs[A](body: => A): (A, List[ILoggingEvent]) = + val logbackLogger = libraryLogbackLogger() + val appender = new ListAppender[ILoggingEvent]() + appender.start() + logbackLogger.addAppender(appender) + try + val result = body + (result, appender.list.asScala.toList) + finally + logbackLogger.detachAppender(appender) + appender.stop() + + // Tag byte 0x0f = field 1 with wire type 7, which is not a valid protobuf wire type. + private val corruptBytes: Array[Byte] = Array[Byte](0x0f, 0x7f, 0x7f, 0x7f) + + def spec = suite(this.getClass.toString)( + test("a corrupt .binpb file is skipped without failing the scan") { + val root = tempDir() + os.write(root / "corrupt1.binpb", corruptBytes) + os.write(root / "valid1.binpb", itemBytes(markdownItem("valid1"))) + + val library = Library.createAndRefreshDb(root.toString) + + assertTrue( + // the fixture really is unparseable - otherwise this test would prove nothing + Try(pb.LibraryItem.parseFrom(corruptBytes)).isFailure, + // the corrupt file neither loads nor takes the whole scan down with it + library.getItemById("corrupt1").isEmpty, + library.getItemById("valid1").isDefined, + library.size == 1, + // the scanner is read-only: it does not quarantine or delete what it cannot parse + os.exists(root / "corrupt1.binpb") + ) + }, + test("a file whose embedded item id does not match its file name is not loaded") { + val bytes = itemBytes(markdownItem("aaaaaa")) + + val mismatchedRoot = tempDir() + os.write(mismatchedRoot / "bbbbbb.binpb", bytes) + val mismatched = Library.createAndRefreshDb(mismatchedRoot.toString) + + // control: the SAME bytes under the matching file name do load, so the rejection above is + // attributable to the name mismatch and not to bad content. + val matchingRoot = tempDir() + os.write(matchingRoot / "aaaaaa.binpb", bytes) + val matching = Library.createAndRefreshDb(matchingRoot.toString) + + assertTrue( + mismatched.size == 0, + mismatched.getItemById("aaaaaa").isEmpty, // not keyed by the embedded id + mismatched.getItemById("bbbbbb").isEmpty, // nor by the file name + os.exists(mismatchedRoot / "bbbbbb.binpb"), // left on disk untouched + matching.size == 1, + matching.getItemById("aaaaaa").isDefined + ) + }, + test("non-.binpb entries in the library directory are ignored") { + val root = tempDir() + os.write(root / "notes.txt", "not a library item") + os.write(root / "item.json", """{"metadata":{}}""") + os.write(root / "README", "no extension at all") + os.write(root / "valid1.binpb.bak", itemBytes(markdownItem("valid1"))) // real bytes, wrong ext + os.makeDir(root / "subdir.binpb") // right ext, but a directory - the os.isFile guard + os.write(root / "valid1.binpb", itemBytes(markdownItem("valid1"))) + + val library = Library.createAndRefreshDb(root.toString) + + assertTrue( + library.size == 1, + library.getItemById("valid1").isDefined, + library.getItemById("notes").isEmpty, + library.getItemById("item").isEmpty, + // the scan leaves foreign files alone + os.exists(root / "notes.txt"), + os.exists(root / "README"), + os.exists(root / "subdir.binpb") + ) + }, + test("a file whose name is not exactly `.binpb` is not loaded") { + // The file name is the ONLY handle the API has on an item: writeItem and deleteItem both + // derive `$itemId.binpb` from the id. The scan derived the id with + // `fileName.split('.').head`, so `aaaaaa.extra.binpb` was accepted as item `aaaaaa` - + // listed and gettable, but deleting it targets `aaaaaa.binpb` (NOT_FOUND) and saving it + // creates a SECOND file. Require the exact canonical name instead. + val root = tempDir() + os.write(root / "aaaaaa.extra.binpb", itemBytes(markdownItem("aaaaaa"))) + os.write(root / "bbbbbb.binpb", itemBytes(markdownItem("bbbbbb"))) // control: canonical name + + val library = Library.createAndRefreshDb(root.toString) + + assertTrue( + library.getItemById("aaaaaa").isEmpty, + library.size == 1, + library.getItemById("bbbbbb").isDefined, // the control really does load + // the scanner stays read-only about what it rejects + os.exists(root / "aaaaaa.extra.binpb") + ) + }, + test("a file whose id is outside the safe charset is not loaded") { + // `requireSafeItemId` guards writeItem/deleteItem but was never applied by the scan, so a + // file dropped into the library dir with e.g. `bad+id` loaded happily - and then every + // write/delete for that id was rejected with INVALID_ARGUMENT. An item the API cannot + // address must not be surfaced. + val root = tempDir() + os.write(root / "bad+id.binpb", itemBytes(markdownItem("bad+id"))) + os.write(root / "goodid.binpb", itemBytes(markdownItem("goodid"))) // control + + val library = Library.createAndRefreshDb(root.toString) + val deleteBad = Try(library.deleteItem("bad+id")) + + assertTrue( + library.getItemById("bad+id").isEmpty, + library.size == 1, + library.getItemById("goodid").isDefined, + // context: the write path really does refuse this id, which is why surfacing it is wrong + deleteBad.failed.toOption.exists(_.isInstanceOf[IllegalArgumentException]), + os.exists(root / "bad+id.binpb") + ) + }, + test("a file rejected for an unsafe id is logged as a warning, not dropped silently") { + // The rejection branch built a Left(...) that only refreshDb's Right-collector reads - + // nothing logged it, so an operator saw "Found N items" with no hint a file was skipped. + val root = tempDir() + // Names unique to this test so a parallel suite scanning its own unsafe-id file cannot + // supply the warning we assert on. + os.write(root / "unsafe+scanlog+id.binpb", itemBytes(markdownItem("unsafe+scanlog+id"))) + os.write(root / "goodscanlogid.binpb", itemBytes(markdownItem("goodscanlogid"))) // control + + val (library, logs) = withCapturedLibraryLogs(Library.createAndRefreshDb(root.toString)) + val warnings = logs.filter(_.getLevel == Level.WARN).map(_.getFormattedMessage) + + assertTrue( + // still excluded from the db ... + library.getItemById("unsafe+scanlog+id").isEmpty, + library.getItemById("goodscanlogid").isDefined, + // ... but no longer silently: a WARN names the offending file + warnings.exists(_.contains("unsafe+scanlog+id.binpb")) + ) + }, + test("a file rejected for a name/id mismatch is logged as a warning, not dropped silently") { + val root = tempDir() + os.write(root / "mismatchlogfile.binpb", itemBytes(markdownItem("mismatchlogid"))) + os.write(root / "goodscanlogid2.binpb", itemBytes(markdownItem("goodscanlogid2"))) // control + + val (library, logs) = withCapturedLibraryLogs(Library.createAndRefreshDb(root.toString)) + val warnings = logs.filter(_.getLevel == Level.WARN).map(_.getFormattedMessage) + + assertTrue( + library.getItemById("mismatchlogid").isEmpty, + library.getItemById("mismatchlogfile").isEmpty, + library.getItemById("goodscanlogid2").isDefined, + warnings.exists(_.contains("mismatchlogfile.binpb")) + ) + }, + test("every item the scan surfaces is addressable by its id") { + // The invariant behind the two tests above, stated directly: whatever getItemById returns + // must be deletable under that same id. + val root = tempDir() + os.write(root / "cccccc.extra.binpb", itemBytes(markdownItem("cccccc"))) + os.write(root / "bad+id.binpb", itemBytes(markdownItem("bad+id"))) + os.write(root / "dddddd.binpb", itemBytes(markdownItem("dddddd"))) + + val library = Library.createAndRefreshDb(root.toString) + val surfaced = List("cccccc", "bad+id", "dddddd").filter(library.getItemById(_).isDefined) + val notDeletable = surfaced.filterNot(id => Try(library.deleteItem(id)).isSuccess) + + assertTrue(surfaced == List("dddddd"), notDeletable.isEmpty) + }, + test("deleteItem removes the file and drops the item from the db") { + val root = tempDir() + val library = Library.createAndRefreshDb(root.toString) + library.writeItem(markdownItem("keepme")) + library.writeItem(markdownItem("dropme")) + + val sizeBefore = library.size + library.deleteItem("dropme") + + assertTrue( + sizeBefore == 2, + !os.exists(root / "dropme.binpb"), + library.getItemById("dropme").isEmpty, + // the delete is surgical - the sibling item survives + os.exists(root / "keepme.binpb"), + library.getItemById("keepme").isDefined, + library.size == 1 + ) + }, + test("deleteItem on an unknown id reports not-found instead of succeeding silently") { + // REGRESSION (fixed 2026-07-25) - Library.scala used to call `os.remove(filePath)` alone, which in os-lib 0.9.3 is + // `Files.deleteIfExists` (returns false, throws nothing). deleteItem therefore returns + // normally for an id that never existed, and LibraryServiceImpl.deleteLibraryItem reports + // Code.OK - indistinguishable from a real delete. Its sibling getLibraryItem already + // returns NOT_FOUND for the same id, so the API is internally inconsistent and a UI + // "deleted" confirmation is unearned. Expected: deleteItem signals the miss (and the + // service maps it to NOT_FOUND); IllegalArgumentException is deliberately NOT the right + // answer here since that is already the INVALID_ARGUMENT channel for a malformed id. + val root = tempDir() + val library = Library.createAndRefreshDb(root.toString) + library.writeItem(markdownItem("present1")) + + val deleteMissing = Try(library.deleteItem("missing1")) + + assertTrue( + // context: the id is genuinely absent, and a malformed id DOES still fail loudly + library.getItemById("missing1").isEmpty, + Try(library.deleteItem("../../evil")).isFailure, + // nothing collateral happened - the real item is untouched + library.getItemById("present1").isDefined, + library.size == 1, + deleteMissing.isFailure, + !deleteMissing.failed.toOption.exists(_.isInstanceOf[IllegalArgumentException]) + ) + } + ) +} diff --git a/server/src/test/scala/library/libraryServiceDeleteTest.scala b/server/src/test/scala/library/libraryServiceDeleteTest.scala new file mode 100644 index 000000000..6124a4078 --- /dev/null +++ b/server/src/test/scala/library/libraryServiceDeleteTest.scala @@ -0,0 +1,120 @@ +package library + +import zio.* +import zio.test.* + +import com.google.rpc.code.Code +import com.tools.teal.pulsar.ui.library.v1.library.{DeleteLibraryItemRequest, GetLibraryItemRequest} +import _root_.library.managed_items.{ManagedMarkdownDocument, ManagedMarkdownDocumentSpec} + +import scala.concurrent.duration.{Duration, SECONDS} +import scala.concurrent.{Await, Future} + +/** `LibraryServiceImpl.deleteLibraryItem` - the gRPC STATUS the UI is handed, not the store beneath + * it. + * + * `libraryScanTest` pins `Library.deleteItem`: it throws `NoSuchElementException` for an id with no + * file and `IllegalArgumentException` for an id outside the safe charset. Nothing pinned what the + * service does with those two exceptions, and they leave by different `catch` arms of the same + * `try`. Adding a `case e: Exception` arm above them, or reordering them, silently collapses both + * into INTERNAL - the store stays correct while every caller starts seeing a server error, and no + * test notices. `getLibraryItem` already answers NOT_FOUND for a missing id, so a delete that + * answered INTERNAL (or, before 2026-07-25, OK) for the same id also makes the API inconsistent + * with itself. + * + * Each test mints its own `os.temp.dir` and its own service, so parallel execution is safe. + */ +object libraryServiceDeleteTest extends ZIOSpecDefault: + + private def markdownItem(id: String): LibraryItem = + LibraryItem( + metadata = LibraryItemMetadata( + updatedAt = "2026-07-26T00:00:00Z", + availableForContexts = Vector(ResourceMatcher(matcher = TenantMatcher(matcher = ExactTenantMatcher(tenant = "t1")))) + ), + spec = ManagedMarkdownDocument( + metadata = ManagedItemMetadata( + `type` = ManagedItemType.MarkdownDocument, + id = id, + name = s"item-$id", + descriptionMarkdown = "" + ), + spec = ManagedMarkdownDocumentSpec(markdown = "hello") + ) + ) + + /** A service over a throwaway library directory. The process-wide `libraryRoot` is fixed for the + * JVM, so the store is passed in - the same device `PulsarAuthRoutes.routesWith` uses. */ + private def serviceOver(root: os.Path): LibraryServiceImpl = + LibraryServiceImpl(Library.createAndRefreshDb(root.toString)) + + private def await[A](future: Future[A]): A = Await.result(future, Duration(30, SECONDS)) + + def spec = suite(this.getClass.toString)( + test("deleting an id that has no file answers NOT_FOUND, not OK and not INTERNAL") { + // REGRESSION - `Library.deleteItem` used `os.remove`, i.e. `Files.deleteIfExists`, which + // returns false rather than throwing, so deleting an id that never existed reported + // Code.OK: the UI showed a "deleted" confirmation for something it had not deleted. + val root = os.temp.dir(prefix = "library-service-delete") + val service = serviceOver(root) + await(service.saveLibraryItem(pbSave(markdownItem("present1")))) + + val missing = await(service.deleteLibraryItem(DeleteLibraryItemRequest(id = "missing1"))) + val present = await(service.getLibraryItem(GetLibraryItemRequest(id = "present1"))) + + assertTrue( + missing.getStatus.code == Code.NOT_FOUND.value, + // ... and it is the SAME verdict its sibling read gives for the same id + await(service.getLibraryItem(GetLibraryItemRequest(id = "missing1"))).getStatus.code == Code.NOT_FOUND.value, + // nothing collateral happened + present.getStatus.code == Code.OK.value, + os.exists(root / "present1.binpb") + ) ?? s"missing=${missing.getStatus} present=${present.getStatus}" + }, + test("deleting a malformed id answers INVALID_ARGUMENT, which is not the missing-item verdict") { + // Item ids become file names, so ids outside the safe charset are refused before any + // filesystem call - a caller error, distinct from "there is no such item". Collapsing + // the two would tell the UI to retry a path-traversal attempt as if it were a typo. + val root = os.temp.dir(prefix = "library-service-delete") + val service = serviceOver(root) + + val traversal = await(service.deleteLibraryItem(DeleteLibraryItemRequest(id = "../../evil"))) + val badCharset = await(service.deleteLibraryItem(DeleteLibraryItemRequest(id = "bad+id"))) + + assertTrue( + traversal.getStatus.code == Code.INVALID_ARGUMENT.value, + badCharset.getStatus.code == Code.INVALID_ARGUMENT.value, + // nothing escaped the library root + !os.exists(root / os.up / "evil.binpb"), + !os.exists(root / os.up / os.up / "evil.binpb") + ) ?? s"traversal=${traversal.getStatus} badCharset=${badCharset.getStatus}" + }, + test("deleting a real item answers OK and the item is gone from disk and from reads") { + // The control: the two refusals above prove nothing unless a genuine delete still works, + // and OK has to mean the file is actually gone - not merely that no exception escaped. + val root = os.temp.dir(prefix = "library-service-delete") + val service = serviceOver(root) + await(service.saveLibraryItem(pbSave(markdownItem("dropme")))) + await(service.saveLibraryItem(pbSave(markdownItem("keepme")))) + + val readBefore = await(service.getLibraryItem(GetLibraryItemRequest(id = "dropme"))) + val deleted = await(service.deleteLibraryItem(DeleteLibraryItemRequest(id = "dropme"))) + val readAfter = await(service.getLibraryItem(GetLibraryItemRequest(id = "dropme"))) + val deletedAgain = await(service.deleteLibraryItem(DeleteLibraryItemRequest(id = "dropme"))) + + assertTrue( + readBefore.getStatus.code == Code.OK.value, + deleted.getStatus.code == Code.OK.value, + !os.exists(root / "dropme.binpb"), + readAfter.getStatus.code == Code.NOT_FOUND.value, + // a repeated delete is now a miss, not a second success + deletedAgain.getStatus.code == Code.NOT_FOUND.value, + // the delete is surgical + await(service.getLibraryItem(GetLibraryItemRequest(id = "keepme"))).getStatus.code == Code.OK.value, + os.exists(root / "keepme.binpb") + ) ?? s"deleted=${deleted.getStatus} readAfter=${readAfter.getStatus} deletedAgain=${deletedAgain.getStatus}" + } + ) + + private def pbSave(item: LibraryItem) = + com.tools.teal.pulsar.ui.library.v1.library.SaveLibraryItemRequest(item = Some(LibraryItem.toPb(item))) diff --git a/server/src/test/scala/library/managedItemsConversionsTest.scala b/server/src/test/scala/library/managedItemsConversionsTest.scala new file mode 100644 index 000000000..81f107c9a --- /dev/null +++ b/server/src/test/scala/library/managedItemsConversionsTest.scala @@ -0,0 +1,94 @@ +package library.managed_items + +import zio.test.* +import com.tools.teal.pulsar.ui.library.v1.managed_items as pb +import _root_.consumer.start_from.DateTimeUnit +import library.{ManagedItemMetadata, ManagedItemType} +import scala.util.Try + +/** Conversion-layer coverage for `library/managed_items/`. + * + * - Defect 4 (trust boundary): `ManagedRelativeDateTimeSpec` carries a `Long` because + * managed_items.proto stores `value` as int64, but the api form (consumer.proto + * `RelativeDateTime`) is int32. A library file written by a non-Dekaf client can persist a + * value that is negative or out of int32 range; `fromPb` is the server-side ingestion boundary + * (reached by both SaveLibraryItem and the on-disk scan), so it must reject such a value loudly + * rather than let a later narrowing to int32 silently truncate it. + * - Defect 3 (dead union member): `ManagedConsumerSessionStartFromValOrRef` was removed from the + * `ManagedConsumerSessionStartFromSpec.startFrom` union (it is absent from the proto oneof and + * no conversion ever produced it). The relative-date-time member - structurally adjacent to the + * one removed - must still round-trip through both directions. + */ +object managedItemsConversionsTest extends ZIOSpecDefault { + + private def relativeDateTimeSpecPb(value: Long): pb.ManagedRelativeDateTimeSpec = + pb.ManagedRelativeDateTimeSpec( + value = value, + unit = DateTimeUnit.toPb(DateTimeUnit.Hour), + isRoundedToUnitStart = false + ) + + def spec = suite(this.getClass.toString)( + test("ManagedRelativeDateTimeSpec.fromPb accepts an in-range non-negative value") { + val small = ManagedRelativeDateTimeSpec.fromPb(relativeDateTimeSpecPb(5L)) + val zero = ManagedRelativeDateTimeSpec.fromPb(relativeDateTimeSpecPb(0L)) + // Int.MaxValue is the largest value the api int32 can hold - it must be accepted. + val maxInt = ManagedRelativeDateTimeSpec.fromPb(relativeDateTimeSpecPb(Int.MaxValue.toLong)) + assertTrue( + small.value == 5L, + zero.value == 0L, + maxInt.value == Int.MaxValue.toLong + ) + }, + test("ManagedRelativeDateTimeSpec.fromPb rejects a value above int32 range") { + // One past Int.MaxValue: fits int64 on disk, cannot fit the api int32 without truncation. + val tooBig = Try(ManagedRelativeDateTimeSpec.fromPb(relativeDateTimeSpecPb(Int.MaxValue.toLong + 1L))) + val wayBig = Try(ManagedRelativeDateTimeSpec.fromPb(relativeDateTimeSpecPb(Long.MaxValue))) + assertTrue( + tooBig.isFailure, + tooBig.failed.toOption.exists(_.isInstanceOf[IllegalArgumentException]), + wayBig.isFailure, + wayBig.failed.toOption.exists(_.isInstanceOf[IllegalArgumentException]) + ) + }, + test("ManagedRelativeDateTimeSpec.fromPb rejects a negative value") { + val negOne = Try(ManagedRelativeDateTimeSpec.fromPb(relativeDateTimeSpecPb(-1L))) + val negBig = Try(ManagedRelativeDateTimeSpec.fromPb(relativeDateTimeSpecPb(Long.MinValue))) + assertTrue( + negOne.isFailure, + negOne.failed.toOption.exists(_.isInstanceOf[IllegalArgumentException]), + negBig.isFailure, + negBig.failed.toOption.exists(_.isInstanceOf[IllegalArgumentException]) + ) + }, + test("a relative-date-time start-from still round-trips after the dead union member is removed") { + val original = ManagedConsumerSessionStartFromSpec( + startFrom = ManagedRelativeDateTimeValOrRef( + value = Some( + ManagedRelativeDateTime( + metadata = ManagedItemMetadata( + `type` = ManagedItemType.RelativeDateTime, + id = "rdt-roundtrip", + name = "rdt-roundtrip-name", + descriptionMarkdown = "" + ), + spec = ManagedRelativeDateTimeSpec(value = 7L, unit = DateTimeUnit.Day, isRoundedToUnitStart = true) + ) + ), + reference = None + ) + ) + + val roundTripped = ManagedConsumerSessionStartFromSpec.fromPb(ManagedConsumerSessionStartFromSpec.toPb(original)) + + val recoveredValue = roundTripped.startFrom match + case v: ManagedRelativeDateTimeValOrRef => v.value.map(_.spec.value) + case _ => None + + assertTrue( + roundTripped.startFrom.isInstanceOf[ManagedRelativeDateTimeValOrRef], + recoveredValue.contains(7L) + ) + } + ) +} diff --git a/server/src/test/scala/library/resourceMatchersConversionsTest.scala b/server/src/test/scala/library/resourceMatchersConversionsTest.scala new file mode 100644 index 000000000..42d272934 --- /dev/null +++ b/server/src/test/scala/library/resourceMatchersConversionsTest.scala @@ -0,0 +1,214 @@ +package library + +import zio.test.* +import com.tools.teal.pulsar.ui.library.v1.resource_matchers as pb +import scala.util.Try + +/** Unit coverage for `library/resourceMatchersConversions.scala` - the proto <-> model boundary. + * + * `resourceMatchersTest.scala` only exercises the `.test()` predicates in `resourceMatchers.scala`; + * the conversions were untested, even though `LibraryServiceImpl.listLibraryItems` / + * `getLibraryItemsCount` call `resourceMatcherFromPb` directly on UNTRUSTED request data, and + * `LibraryItemMetadata.fromPb` calls it on every matcher of every item read off disk. + * + * The proto has no regex matcher variant - the shape vocabulary is exactly + * instance | tenant{exact,all} | namespace{exact,all} | topic{exact,all}, enumerated below. + * (`AllNamespaceMatcher.namespace_regex` is a declared proto FIELD with no model counterpart - + * setting it is rejected rather than ignored; pinned by its own two tests.) + * + * No shared mutable state, so the default parallel test execution is safe here. + */ +object resourceMatchersConversionsTest extends ZIOSpecDefault { + + private val tenantExact = TenantMatcher(matcher = ExactTenantMatcher(tenant = "tenant-a")) + private val tenantAll = TenantMatcher(matcher = AllTenantMatcher()) + + private val nsExactUnderExactTenant = + NamespaceMatcher(matcher = ExactNamespaceMatcher(tenant = tenantExact, namespace = "ns-a")) + private val nsExactUnderAllTenant = + NamespaceMatcher(matcher = ExactNamespaceMatcher(tenant = tenantAll, namespace = "ns-a")) + private val nsAllUnderExactTenant = NamespaceMatcher(matcher = AllNamespaceMatcher(tenant = tenantExact)) + private val nsAllUnderAllTenant = NamespaceMatcher(matcher = AllNamespaceMatcher(tenant = tenantAll)) + + /** Every ResourceMatcher shape the model can express, including each nested tenant/namespace variant. */ + private val allShapes: List[ResourceMatcher] = List( + ResourceMatcher(matcher = InstanceMatcher()), + ResourceMatcher(matcher = tenantExact), + ResourceMatcher(matcher = tenantAll), + ResourceMatcher(matcher = nsExactUnderExactTenant), + ResourceMatcher(matcher = nsExactUnderAllTenant), + ResourceMatcher(matcher = nsAllUnderExactTenant), + ResourceMatcher(matcher = nsAllUnderAllTenant), + ResourceMatcher(matcher = TopicMatcher(matcher = ExactTopicMatcher(namespace = nsExactUnderExactTenant, topic = "topic-a"))), + ResourceMatcher(matcher = TopicMatcher(matcher = ExactTopicMatcher(namespace = nsAllUnderAllTenant, topic = "topic-a"))), + ResourceMatcher(matcher = TopicMatcher(matcher = AllTopicMatcher(namespace = nsExactUnderExactTenant))), + ResourceMatcher(matcher = TopicMatcher(matcher = AllTopicMatcher(namespace = nsAllUnderAllTenant))) + ) + + /** The deepest shape: topic -> namespace -> tenant, every level an `exact`. */ + private val deepestShape = + ResourceMatcher(matcher = TopicMatcher(matcher = ExactTopicMatcher(namespace = nsExactUnderExactTenant, topic = "topic-a"))) + + def spec = suite(this.getClass.toString)( + test("every matcher shape survives a model -> pb -> model round-trip") { + val broken = allShapes.filter(shape => resourceMatcherFromPb(resourceMatcherToPb(shape)) != shape) + assertTrue( + allShapes.size == 11, // instance + 2 tenant + 4 namespace + 4 topic + allShapes.distinct.size == 11, // the shapes really are distinct - equality discriminates + broken.isEmpty + ) + }, + test("toPb populates the nested pb message fields instead of leaving them unset") { + // A dropped nested field would round-trip "fine" only because fromPb would blow up on it - + // assert the pb intermediate directly so the two directions cannot hide each other's bug. + val encoded = resourceMatcherToPb(deepestShape) + val exactTopic = encoded.matcher.topic.flatMap(_.matcher.exact) + val namespace = exactTopic.flatMap(_.namespace) + val exactNamespace = namespace.flatMap(_.matcher.exact) + val tenant = exactNamespace.flatMap(_.tenant) + + assertTrue( + exactTopic.map(_.topic).contains("topic-a"), + namespace.isDefined, + exactNamespace.map(_.namespace).contains("ns-a"), + tenant.isDefined, + tenant.flatMap(_.matcher.exact).map(_.tenant).contains("tenant-a") + ) + }, + test("a wire-encoded matcher decodes back into the same model value") { + // The on-disk library format and the gRPC surface are both real protobuf bytes, so exercise + // the encode/parse legs too - not just the in-memory case-class hop. + val bytes = resourceMatcherToPb(deepestShape).toByteArray + val decoded = resourceMatcherFromPb(pb.ResourceMatcher.parseFrom(bytes)) + assertTrue( + bytes.nonEmpty, + decoded == deepestShape, + // a different-tenant shape must NOT decode equal - guards against a degenerate compare + decoded != ResourceMatcher(matcher = + TopicMatcher(matcher = + ExactTopicMatcher( + namespace = NamespaceMatcher(matcher = + ExactNamespaceMatcher(tenant = TenantMatcher(matcher = ExactTenantMatcher("tenant-b")), namespace = "ns-a") + ), + topic = "topic-a" + ) + ) + ) + ) + }, + test("an unset oneof is rejected with IllegalArgumentException") { + // The oneof discriminators ARE guarded (`case _ => throw new IllegalArgumentException`), + // which LibraryServiceImpl maps to INVALID_ARGUMENT. This is the contrast case for the + // unset-nested-message tests below. + val resource = Try(resourceMatcherFromPb(pb.ResourceMatcher())) + val tenant = Try(tenantMatcherFromPb(pb.TenantMatcher())) + val namespace = Try(namespaceMatcherFromPb(pb.NamespaceMatcher())) + val topic = Try(topicMatcherFromPb(pb.TopicMatcher())) + + assertTrue( + resource.failed.toOption.exists(_.isInstanceOf[IllegalArgumentException]), + tenant.failed.toOption.exists(_.isInstanceOf[IllegalArgumentException]), + namespace.failed.toOption.exists(_.isInstanceOf[IllegalArgumentException]), + topic.failed.toOption.exists(_.isInstanceOf[IllegalArgumentException]) + ) + }, + test("an unset nested message field is rejected cleanly, not with NoSuchElementException") { + // REGRESSION (fixed 2026-07-25) - resourceMatchersConversions.scala used to call `.get` on the + // Option-typed nested proto field. A default-constructed proto (an older client, or any + // request that simply omits the field) therefore raises NoSuchElementException, which + // LibraryServiceImpl's `case e: Exception` maps to INTERNAL - a 500 for what is plainly + // caller-supplied malformed input. It should be an IllegalArgumentException like every + // other malformed-input path in this same file (-> INVALID_ARGUMENT). + val exactNamespace = Try(exactNamespaceMatcherFromPb(pb.ExactNamespaceMatcher(namespace = "ns-a"))) + val allNamespace = Try(allNamespaceMatcherFromPb(pb.AllNamespaceMatcher())) + val exactTopic = Try(exactTopicMatcherFromPb(pb.ExactTopicMatcher(topic = "topic-a"))) + val allTopic = Try(allTopicMatcherFromPb(pb.AllTopicMatcher())) + + assertTrue( + // all four do fail - the defect is the TYPE of failure + exactNamespace.isFailure, + allNamespace.isFailure, + exactTopic.isFailure, + allTopic.isFailure, + exactNamespace.failed.toOption.exists(_.isInstanceOf[IllegalArgumentException]), + allNamespace.failed.toOption.exists(_.isInstanceOf[IllegalArgumentException]), + exactTopic.failed.toOption.exists(_.isInstanceOf[IllegalArgumentException]), + allTopic.failed.toOption.exists(_.isInstanceOf[IllegalArgumentException]) + ) + }, + test("a request whose nested matcher field is unset is rejected cleanly at the public entry point") { + // REGRESSION (fixed 2026-07-25) - same root cause as above, reached the way a real client does: + // ListLibraryItemsRequest.contexts -> resourceMatcherFromPb. A client that sends + // {namespace: {exact: {namespace: "ns-a"}}} (tenant omitted) gets INTERNAL instead of + // INVALID_ARGUMENT. Expected: IllegalArgumentException. + val namespaceWithoutTenant = pb.ResourceMatcher(matcher = + pb.ResourceMatcher.Matcher.Namespace( + pb.NamespaceMatcher(matcher = pb.NamespaceMatcher.Matcher.Exact(pb.ExactNamespaceMatcher(namespace = "ns-a"))) + ) + ) + val topicWithoutNamespace = pb.ResourceMatcher(matcher = + pb.ResourceMatcher.Matcher.Topic( + pb.TopicMatcher(matcher = pb.TopicMatcher.Matcher.Exact(pb.ExactTopicMatcher(topic = "topic-a"))) + ) + ) + val namespaceResult = Try(resourceMatcherFromPb(namespaceWithoutTenant)) + val topicResult = Try(resourceMatcherFromPb(topicWithoutNamespace)) + + assertTrue( + namespaceResult.isFailure, + topicResult.isFailure, + namespaceResult.failed.toOption.exists(_.isInstanceOf[IllegalArgumentException]), + topicResult.failed.toOption.exists(_.isInstanceOf[IllegalArgumentException]) + ) + }, + test("a set AllNamespaceMatcher.namespace_regex is rejected instead of silently widening the scope") { + // REGRESSION (fixed 2026-07-26) - the conversion used to ACCEPT a nonempty + // `namespace_regex`, log a server-side warning, drop it, and return a plain + // all-namespaces matcher. A caller asking for `audit-.*` therefore got success plus a + // strictly WIDER scope than it requested, and the only record of that was a log line it + // cannot see. Scope widening must never be the quiet outcome of an unimplemented field. + // + // The field stays unimplemented on purpose - matchers are tested against other + // MATCHERS, not against a concrete namespace, so All-vs-All would have to decide + // whether one regex subsumes another, undecidable in general. Narrow with + // ExactNamespaceMatcher instead. "Unimplemented" therefore has to mean rejected, not + // ignored: IllegalArgumentException is the INVALID_ARGUMENT channel LibraryServiceImpl + // already maps for malformed request data. + val allWithRegex = pb.AllNamespaceMatcher(tenant = Some(tenantMatcherToPb(tenantAll)), namespaceRegex = "audit-.*") + val withRegex = pb.NamespaceMatcher(matcher = pb.NamespaceMatcher.Matcher.All(allWithRegex)) + + val direct = Try(allNamespaceMatcherFromPb(allWithRegex)) + val viaNamespace = Try(namespaceMatcherFromPb(withRegex)) + // the way a real client reaches it: ListLibraryItemsRequest.contexts -> resourceMatcherFromPb + val viaEntryPoint = Try(resourceMatcherFromPb(pb.ResourceMatcher(matcher = pb.ResourceMatcher.Matcher.Namespace(withRegex)))) + + assertTrue( + direct.failed.toOption.exists(_.isInstanceOf[IllegalArgumentException]), + viaNamespace.failed.toOption.exists(_.isInstanceOf[IllegalArgumentException]), + viaEntryPoint.failed.toOption.exists(_.isInstanceOf[IllegalArgumentException]), + // the message must name the field and echo the pattern, or the caller cannot tell + // which part of its request was refused + viaEntryPoint.failed.toOption.exists(_.getMessage.contains("namespace_regex")), + viaEntryPoint.failed.toOption.exists(_.getMessage.contains("audit-.*")) + ) + }, + test("an unset namespace_regex still converts - the rejection is scoped to the field being SET") { + // The rejection reaches the disk-read path too (LibraryItemMetadata.fromPb runs on every + // stored item), so it must be inert for the default value. Nothing writes the field: the + // UI never sets it, and decoding all 329 items in the dogfooding data dir with + // `protoc --decode` found zero occurrences. Refusing a SET regex therefore breaks no + // existing reader; refusing an unset one would make every library item unreadable. + val allNamespaces = pb.AllNamespaceMatcher(tenant = Some(tenantMatcherToPb(tenantAll))) + val expected = AllNamespaceMatcher(tenant = tenantAll) + + val decoded = Try(allNamespaceMatcherFromPb(allNamespaces)) + // the exact shape an on-disk item arrives in: parsed back from real protobuf bytes + val fromBytes = Try(allNamespaceMatcherFromPb(pb.AllNamespaceMatcher.parseFrom(allNamespaces.toByteArray))) + + assertTrue( + decoded.toOption.contains(expected), + fromBytes.toOption.contains(expected) + ) + } + ) +} diff --git a/server/src/test/scala/producer/ProducerRegistryTest.scala b/server/src/test/scala/producer/ProducerRegistryTest.scala new file mode 100644 index 000000000..9765234a7 --- /dev/null +++ b/server/src/test/scala/producer/ProducerRegistryTest.scala @@ -0,0 +1,242 @@ +package producer + +import zio.* +import zio.test.* + +import com.google.rpc.code.Code +import com.tools.teal.pulsar.ui.api.v1.producer.{ + CreateProducerRequest, + CreateProducerResponse, + DeleteProducerRequest, + DeleteProducerResponse +} +import org.apache.pulsar.client.api.{Producer, ProducerBuilder, PulsarClient} +import pulsar_auth.RequestContext + +import java.lang.reflect.{InvocationHandler, Method, Proxy} +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.{ConcurrentLinkedQueue, CountDownLatch, CyclicBarrier} +import scala.concurrent.duration.{Duration, SECONDS} +import scala.concurrent.{Await, Future} +import scala.jdk.CollectionConverters.* + +/** The producer REGISTRY: what `createProducer`/`deleteProducer` do to the live broker producers the + * service is holding on behalf of the UI. + * + * Regression context: the registry was a plain `var producers: Map[...]`, and every mutation was a + * read-modify-write (`producers = producers + (name -> p)`) performed from `ExecutionContext.global` + * - the service is a singleton bound on that pool in `GrpcServer`. Two creates that read the same + * old map lose one another's entry, and a create under a name that is already registered simply + * overwrote its predecessor. Either way a producer that is LIVE on the broker disappears from the + * only map that knows its name, so `deleteProducer` can never close it: it holds its topic + * connection (and, on a topic with exclusive access, blocks the next producer) until Dekaf exits. + * + * Everything is asserted through the SERVICE, never through the map field, so the tests describe + * the contract rather than the data structure: a producer the registry no longer holds must have + * been closed, and every name a create reported OK for must still be deletable. + * + * No broker here (the server test tier runs before Pulsar is up), so `PulsarClient`, + * `ProducerBuilder` and `Producer` are implemented in-test with `java.lang.reflect.Proxy` - the same + * device `consumerServiceDeleteTest` uses. `close()` appends to a queue, and that queue is the + * oracle for "what did NOT leak". + */ +object ProducerRegistryTest extends ZIOSpecDefault: + + private val topicFqn = "persistent://public/default/registry-test" + + /** A producer that records the fact it was closed, under a label the test can recognise. */ + private def fakeProducer(label: String, closed: ConcurrentLinkedQueue[String]): Producer[Array[Byte]] = + val handler = new InvocationHandler: + override def invoke(proxy: Object, method: Method, args: Array[Object]): Object = + method.getName match + case "close" => + closed.add(label) + null + case "getProducerName" => label + case "getTopic" => topicFqn + case "equals" => java.lang.Boolean.valueOf(proxy eq args(0)) + case "hashCode" => java.lang.Integer.valueOf(label.hashCode) + case "toString" => s"producer-$label" + case _ => null + Proxy + .newProxyInstance(getClass.getClassLoader, Array[Class[?]](classOf[Producer[Array[Byte]]]), handler) + .asInstanceOf[Producer[Array[Byte]]] + + /** A client whose only job is to hand back the next producer when `create()` is called. The + * supplier may block - that is how a create is held inside the broker call while another one + * races past it. */ + private def fakeClient(nextProducer: () => Producer[Array[Byte]]): PulsarClient = + val builderHandler = new InvocationHandler: + override def invoke(proxy: Object, method: Method, args: Array[Object]): Object = + method.getName match + case "create" => nextProducer() + case "equals" => java.lang.Boolean.valueOf(proxy eq args(0)) + case "hashCode" => java.lang.Integer.valueOf(java.lang.System.identityHashCode(proxy)) + case "toString" => "producer-builder" + // accessMode/producerName/topic are fluent - they return the same builder + case _ => proxy + + val builder = Proxy + .newProxyInstance(getClass.getClassLoader, Array[Class[?]](classOf[ProducerBuilder[Array[Byte]]]), builderHandler) + .asInstanceOf[ProducerBuilder[Array[Byte]]] + + val clientHandler = new InvocationHandler: + override def invoke(proxy: Object, method: Method, args: Array[Object]): Object = + method.getName match + case "newProducer" => builder + case "equals" => java.lang.Boolean.valueOf(proxy eq args(0)) + case "hashCode" => java.lang.Integer.valueOf(java.lang.System.identityHashCode(proxy)) + case "toString" => "fake-pulsar-client" + case _ => null + + Proxy + .newProxyInstance(getClass.getClassLoader, Array[Class[?]](classOf[PulsarClient]), clientHandler) + .asInstanceOf[PulsarClient] + + private def await[A](future: Future[A]): A = Await.result(future, Duration(30, SECONDS)) + + private def create(service: ProducerServiceImpl, client: PulsarClient, name: String): CreateProducerResponse = + await( + io.grpc.Context + .current() + .withValue(RequestContext.pulsarClient, client) + .call(() => service.createProducer(CreateProducerRequest(producerName = name, topic = topicFqn))) + ) + + private def delete(service: ProducerServiceImpl, name: String): DeleteProducerResponse = + await(service.deleteProducer(DeleteProducerRequest(producerName = name))) + + def spec = suite(this.getClass.toString)( + test("creating a producer under a name that is already registered closes the one it replaces") { + // REGRESSION - the second create just overwrote the map entry. The first producer is + // still connected to the topic and its name now points at the second one, so nothing can + // ever close it. Reusing a producer name is ordinary: the UI re-creates a producer for + // the same topic after an edit or a page reload. + val closed = ConcurrentLinkedQueue[String]() + val supply = ConcurrentLinkedQueue(List("first", "second").map(fakeProducer(_, closed)).asJava) + val service = ProducerServiceImpl() + val client = fakeClient(() => supply.poll()) + + val firstCreate = create(service, client, "p") + val secondCreate = create(service, client, "p") + val closedAfterReplacement = closed.asScala.toList + + val deleted = delete(service, "p") + + assertTrue( + firstCreate.getStatus.code == Code.OK.value, + secondCreate.getStatus.code == Code.OK.value, + // the predecessor is closed AT the moment it is replaced, not left to chance + closedAfterReplacement == List("first"), + deleted.getStatus.code == Code.OK.value, + // and the survivor is the one delete closes - nothing is left live on the broker + closed.asScala.toList == List("first", "second") + ) ?? s"closedAfterReplacement=$closedAfterReplacement closed=${closed.asScala.toList}" + }, + test("when two creates race under the same name, the producer that loses is closed, not orphaned") { + // REGRESSION, deterministically ordered: the losing create is held inside the broker call + // until the winning one has fully registered, so the late writer overwrites a map entry + // it can see. With a `var Map` the overwritten producer is simply dropped; the registry + // has to hand back whatever it displaced so the caller can close it. + val closed = ConcurrentLinkedQueue[String]() + val slow = fakeProducer("slow", closed) + val fast = fakeProducer("fast", closed) + + val slowIsInsideTheBrokerCall = CountDownLatch(1) + val releaseSlow = CountDownLatch(1) + + val service = ProducerServiceImpl() + val slowClient = fakeClient { () => + slowIsInsideTheBrokerCall.countDown() + releaseSlow.await() + slow + } + val fastClient = fakeClient(() => fast) + + val slowThread = Thread(() => { create(service, slowClient, "p"); () }) + slowThread.start() + + for + _ <- ZIO.attemptBlocking(slowIsInsideTheBrokerCall.await()) + fastCreate <- ZIO.attemptBlocking(create(service, fastClient, "p")) + _ <- ZIO.attemptBlocking { + releaseSlow.countDown() + slowThread.join() + } + closedAfterRace = closed.asScala.toList + deleted <- ZIO.attemptBlocking(delete(service, "p")) + yield assertTrue( + fastCreate.getStatus.code == Code.OK.value, + // the create that arrived last displaced `fast`, so `fast` is the one to close + closedAfterRace == List("fast"), + deleted.getStatus.code == Code.OK.value, + // whichever won, BOTH broker producers are accounted for + closed.asScala.toSet == Set("fast", "slow") + ) ?? s"closedAfterRace=$closedAfterRace closed=${closed.asScala.toList}" + }, + test("concurrent creates under distinct names all stay deletable") { + // REGRESSION - `producers = producers + (name -> p)` reads the map, builds a new one and + // assigns it. Two creates that read the same snapshot lose one entry: that producer is + // live on the broker and its name is no longer in the registry, so `deleteProducer` + // answers FAILED_PRECONDITION forever and the connection is held until Dekaf exits. + // Asserted as "every create the service reported OK for is still deletable", which is + // the property a UI actually depends on. + val closed = ConcurrentLinkedQueue[String]() + val threadCount = 8 + val perThread = 40 + val names = (0 until threadCount * perThread).map(i => f"p$i%04d").toList + + val minted = AtomicInteger(0) + val service = ProducerServiceImpl() + val client = fakeClient(() => fakeProducer(s"producer-${minted.incrementAndGet()}", closed)) + + val startTogether = CyclicBarrier(threadCount) + val threads = (0 until threadCount).map { t => + Thread { () => + startTogether.await() + (0 until perThread).foreach(i => create(service, client, names(t * perThread + i))) + } + } + + for + _ <- ZIO.attemptBlocking { + threads.foreach(_.start()) + threads.foreach(_.join()) + } + deleteResults <- ZIO.attemptBlocking(names.map(name => name -> delete(service, name).getStatus.code)) + undeletable = deleteResults.collect { case (name, code) if code != Code.OK.value => name } + yield assertTrue( + minted.get == names.size, // control: every create really did reach the broker + undeletable.isEmpty, + // nothing survives the sweep - a lost producer is never closed by anything + closed.size == names.size + ) ?? s"undeletable=${undeletable.take(10)} (${undeletable.size}) closed=${closed.size} minted=${minted.get}" + }, + test("deleting a producer closes exactly that producer and leaves its siblings registered") { + // The control for the three above, and the guard on the delete path itself: removal and + // close must stay a matched pair, a second delete of the same name must be refused + // rather than closing something else, and an unrelated name must survive untouched. + val closed = ConcurrentLinkedQueue[String]() + val supply = ConcurrentLinkedQueue(List("a", "b").map(fakeProducer(_, closed)).asJava) + val service = ProducerServiceImpl() + val client = fakeClient(() => supply.poll()) + + create(service, client, "a") + create(service, client, "b") + + val deletedA = delete(service, "a") + val closedAfterFirstDelete = closed.asScala.toList + val deletedAgain = delete(service, "a") + val closedAfterSecondDelete = closed.asScala.toList + val deletedB = delete(service, "b") + + assertTrue( + deletedA.getStatus.code == Code.OK.value, + closedAfterFirstDelete == List("a"), + deletedAgain.getStatus.code == Code.FAILED_PRECONDITION.value, + closedAfterSecondDelete == List("a"), // the refusal closed nothing + deletedB.getStatus.code == Code.OK.value, + closed.asScala.toList == List("a", "b") + ) ?? s"closed=${closed.asScala.toList}" + } + ) diff --git a/server/src/test/scala/producer/ProducerSendTest.scala b/server/src/test/scala/producer/ProducerSendTest.scala new file mode 100644 index 000000000..6db7fc356 --- /dev/null +++ b/server/src/test/scala/producer/ProducerSendTest.scala @@ -0,0 +1,324 @@ +package producer + +import zio.* +import zio.test.* + +import com.google.protobuf.ByteString +import com.google.rpc.code.Code +import com.tools.teal.pulsar.ui.api.v1.producer.{MessageFormat, ProducerMessage, SendRequest, SendResponse} +import org.apache.pulsar.client.admin.{PulsarAdmin, Schemas} +import org.apache.pulsar.client.api.transaction.Transaction +import org.apache.pulsar.client.api.{MessageId, Producer, ProducerStats, Schema, TypedMessageBuilder} +import org.apache.pulsar.common.schema.{SchemaInfo, SchemaType} +import pulsar_auth.RequestContext + +import java.util.concurrent.{CompletableFuture, ConcurrentLinkedQueue} +import scala.concurrent.Future +import scala.jdk.CollectionConverters.* + +/** Service-level coverage for `ProducerServiceImpl.send` - the method itself, not its helpers. + * + * `awaitSendsTest` pins the tail helper, but nothing proved that `send` still CALLS it, nor what + * `send` does to the broker when the batch it was handed is only partly valid. Both are properties + * of the method, so they are asserted by invoking the real `send`. + * + * The server test tier has no broker (CI runs these before Pulsar is even started), so the Pulsar + * client interfaces are implemented directly here rather than with a mocking library. `Recording` + * is deliberately dumb: it appends every `sendAsync` payload to a queue and hands back a + * caller-supplied future. That queue is the broker oracle - "what actually got published" - and the + * future is the broker's verdict, controllable by hand so "answered before the ack" is observable + * instead of timing-dependent. Every method `send` does not use is left `???`, so an unexpected + * interaction fails loudly rather than passing silently. + * + * Each test builds its own service instance and producer, so parallel execution is safe. + */ +object ProducerSendTest extends ZIOSpecDefault { + + private final class Recording(nextFuture: () => CompletableFuture[MessageId]) extends Producer[Array[Byte]]: + /** Every payload handed to the broker, in call order. */ + private val sent = ConcurrentLinkedQueue[String]() + + def published: List[String] = sent.asScala.toList + + private def record(value: Array[Byte]): CompletableFuture[MessageId] = + // `nextFuture` first: it may THROW, which is how a real builder/`sendAsync` reports a + // synchronous failure (producer closed, payload over the max message size), and in that + // case nothing was submitted, so nothing may be recorded as published either. + val future = nextFuture() + sent.add(String(value, "UTF-8")) + future + + override def newMessage(): TypedMessageBuilder[Array[Byte]] = Builder(this) + override def getTopic: String = "persistent://public/default/send-test" + + override def newMessage[V](schema: Schema[V]): TypedMessageBuilder[V] = ??? + override def newMessage(txn: Transaction): TypedMessageBuilder[Array[Byte]] = ??? + override def getProducerName: String = ??? + override def send(message: Array[Byte]): MessageId = ??? + override def sendAsync(message: Array[Byte]): CompletableFuture[MessageId] = ??? + override def flush(): Unit = ??? + override def flushAsync(): CompletableFuture[Void] = ??? + override def getLastSequenceId: Long = ??? + override def getStats: ProducerStats = ??? + override def close(): Unit = ??? + override def closeAsync(): CompletableFuture[Void] = ??? + override def isConnected: Boolean = ??? + override def getLastDisconnectedTimestamp: Long = ??? + override def getNumOfPartitions: Int = ??? + + private final class Builder(producer: Recording) extends TypedMessageBuilder[Array[Byte]]: + private var payload: Array[Byte] = Array.empty + + override def value(value: Array[Byte]): TypedMessageBuilder[Array[Byte]] = + payload = value + this + override def properties(properties: java.util.Map[String, String]): TypedMessageBuilder[Array[Byte]] = this + override def key(key: String): TypedMessageBuilder[Array[Byte]] = this + override def eventTime(timestamp: Long): TypedMessageBuilder[Array[Byte]] = this + override def sendAsync(): CompletableFuture[MessageId] = producer.record(payload) + + override def send(): MessageId = ??? + override def keyBytes(key: Array[Byte]): TypedMessageBuilder[Array[Byte]] = ??? + override def orderingKey(orderingKey: Array[Byte]): TypedMessageBuilder[Array[Byte]] = ??? + override def property(name: String, value: String): TypedMessageBuilder[Array[Byte]] = ??? + override def sequenceId(sequenceId: Long): TypedMessageBuilder[Array[Byte]] = ??? + override def replicationClusters(clusters: java.util.List[String]): TypedMessageBuilder[Array[Byte]] = ??? + override def disableReplication(): TypedMessageBuilder[Array[Byte]] = ??? + override def deliverAt(timestamp: Long): TypedMessageBuilder[Array[Byte]] = ??? + override def deliverAfter(delay: Long, unit: java.util.concurrent.TimeUnit): TypedMessageBuilder[Array[Byte]] = ??? + override def loadConf(config: java.util.Map[String, Object]): TypedMessageBuilder[Array[Byte]] = ??? + + /** A PulsarAdmin that answers exactly one question: "what schema does this topic carry?". */ + private final class SchemaOnlyAdmin(schemaInfo: SchemaInfo) extends PulsarAdmin: + override def schemas(): Schemas = SchemaLookup(schemaInfo) + + override def clusters(): org.apache.pulsar.client.admin.Clusters = ??? + override def brokers(): org.apache.pulsar.client.admin.Brokers = ??? + override def tenants(): org.apache.pulsar.client.admin.Tenants = ??? + override def resourcegroups(): org.apache.pulsar.client.admin.ResourceGroups = ??? + override def properties(): org.apache.pulsar.client.admin.Properties = ??? + override def namespaces(): org.apache.pulsar.client.admin.Namespaces = ??? + override def topics(): org.apache.pulsar.client.admin.Topics = ??? + override def topicPolicies(): org.apache.pulsar.client.admin.TopicPolicies = ??? + override def topicPolicies(isGlobal: Boolean): org.apache.pulsar.client.admin.TopicPolicies = ??? + override def bookies(): org.apache.pulsar.client.admin.Bookies = ??? + override def nonPersistentTopics(): org.apache.pulsar.client.admin.NonPersistentTopics = ??? + override def resourceQuotas(): org.apache.pulsar.client.admin.ResourceQuotas = ??? + override def lookups(): org.apache.pulsar.client.admin.Lookup = ??? + override def functions(): org.apache.pulsar.client.admin.Functions = ??? + override def source(): org.apache.pulsar.client.admin.Source = ??? + override def sources(): org.apache.pulsar.client.admin.Sources = ??? + override def sink(): org.apache.pulsar.client.admin.Sink = ??? + override def sinks(): org.apache.pulsar.client.admin.Sinks = ??? + override def worker(): org.apache.pulsar.client.admin.Worker = ??? + override def brokerStats(): org.apache.pulsar.client.admin.BrokerStats = ??? + override def proxyStats(): org.apache.pulsar.client.admin.ProxyStats = ??? + override def getServiceUrl: String = ??? + override def packages(): org.apache.pulsar.client.admin.Packages = ??? + override def transactions(): org.apache.pulsar.client.admin.Transactions = ??? + override def close(): Unit = ??? + + private final class SchemaLookup(schemaInfo: SchemaInfo) extends Schemas: + override def getSchemaInfo(topic: String): SchemaInfo = schemaInfo + + override def getSchemaInfoAsync(topic: String): CompletableFuture[SchemaInfo] = ??? + override def getSchemaInfoWithVersion(topic: String): org.apache.pulsar.common.schema.SchemaInfoWithVersion = ??? + override def getSchemaInfoWithVersionAsync(topic: String): CompletableFuture[org.apache.pulsar.common.schema.SchemaInfoWithVersion] = ??? + override def getSchemaInfo(topic: String, version: Long): SchemaInfo = ??? + override def getSchemaInfoAsync(topic: String, version: Long): CompletableFuture[SchemaInfo] = ??? + override def deleteSchema(topic: String): Unit = ??? + override def deleteSchemaAsync(topic: String): CompletableFuture[Void] = ??? + override def deleteSchema(topic: String, force: Boolean): Unit = ??? + override def deleteSchemaAsync(topic: String, force: Boolean): CompletableFuture[Void] = ??? + override def createSchema(topic: String, schemaInfo: SchemaInfo): Unit = ??? + override def createSchemaAsync(topic: String, schemaInfo: SchemaInfo): CompletableFuture[Void] = ??? + override def createSchema(topic: String, payload: org.apache.pulsar.common.protocol.schema.PostSchemaPayload): Unit = ??? + override def createSchemaAsync( + topic: String, + payload: org.apache.pulsar.common.protocol.schema.PostSchemaPayload + ): CompletableFuture[Void] = ??? + override def testCompatibility( + topic: String, + payload: org.apache.pulsar.common.protocol.schema.PostSchemaPayload + ): org.apache.pulsar.common.protocol.schema.IsCompatibilityResponse = ??? + override def testCompatibilityAsync( + topic: String, + payload: org.apache.pulsar.common.protocol.schema.PostSchemaPayload + ): CompletableFuture[org.apache.pulsar.common.protocol.schema.IsCompatibilityResponse] = ??? + override def getVersionBySchema(topic: String, payload: org.apache.pulsar.common.protocol.schema.PostSchemaPayload): java.lang.Long = ??? + override def getVersionBySchemaAsync( + topic: String, + payload: org.apache.pulsar.common.protocol.schema.PostSchemaPayload + ): CompletableFuture[java.lang.Long] = ??? + override def testCompatibility(topic: String, schemaInfo: SchemaInfo): org.apache.pulsar.common.protocol.schema.IsCompatibilityResponse = ??? + override def testCompatibilityAsync( + topic: String, + schemaInfo: SchemaInfo + ): CompletableFuture[org.apache.pulsar.common.protocol.schema.IsCompatibilityResponse] = ??? + override def getVersionBySchema(topic: String, schemaInfo: SchemaInfo): java.lang.Long = ??? + override def getVersionBySchemaAsync(topic: String, schemaInfo: SchemaInfo): CompletableFuture[java.lang.Long] = ??? + override def getAllSchemas(topic: String): java.util.List[SchemaInfo] = ??? + override def getAllSchemasAsync(topic: String): CompletableFuture[java.util.List[SchemaInfo]] = ??? + + /** A JSON-schema'd topic: `jsonToValue` accepts well-formed JSON and rejects everything else, so + * it is the shortest route to a batch that is valid up to item N and invalid at item N+1. */ + private val jsonSchema: SchemaInfo = + SchemaInfo.builder().name("send-test").`type`(SchemaType.JSON).schema(Array.emptyByteArray).build() + + private def msg(value: String): ProducerMessage = + ProducerMessage(value = ByteString.copyFromUtf8(value)) + + private def sendRequest(format: MessageFormat, values: String*): SendRequest = + SendRequest(producerName = "p", format = format, messages = values.map(msg)) + + /** Invoke the real `send` with the admin the interceptor would have installed. */ + private def callSend(producer: Producer[Array[Byte]], request: SendRequest): Future[SendResponse] = + val service = ProducerServiceImpl() + service.producers.put("p", producer) + io.grpc.Context + .current() + .withValue(RequestContext.pulsarAdmin, SchemaOnlyAdmin(jsonSchema)) + .call(() => service.send(request)) + + private def acked(): CompletableFuture[MessageId] = CompletableFuture.completedFuture(MessageId.earliest) + + def spec = suite(this.getClass.toString)( + // ---- finding 7: an invalid item must abort the batch BEFORE anything is published ---- + test("a batch whose second item is invalid publishes nothing") { + // REGRESSION - `send` converted every item first but then INTERLEAVED validation with + // `sendAsync`, so a valid item preceding an invalid one was already on the topic by the + // time the call answered INVALID_ARGUMENT. The caller sees a wholly failed batch and + // retries it, duplicating the item that did land. Validation must cover the whole batch + // before the first publish. + val producer = Recording(() => acked()) + val response = callSend(producer, sendRequest(MessageFormat.MESSAGE_FORMAT_JSON, """{"ok":1}""", "not json")) + + for r <- ZIO.fromFuture(_ => response) + yield assertTrue( + r.getStatus.code == Code.INVALID_ARGUMENT.value, + producer.published.isEmpty + ) ?? s"status=${r.getStatus} published=${producer.published}" + }, + test("a batch whose first item is invalid publishes nothing either") { + // The symmetric case, which the interleaved version happened to get right - kept so the + // pair pins "rejection is position-independent" rather than one lucky ordering. + val producer = Recording(() => acked()) + val response = callSend(producer, sendRequest(MessageFormat.MESSAGE_FORMAT_JSON, "not json", """{"ok":1}""")) + + for r <- ZIO.fromFuture(_ => response) + yield assertTrue( + r.getStatus.code == Code.INVALID_ARGUMENT.value, + producer.published.isEmpty + ) ?? s"status=${r.getStatus} published=${producer.published}" + }, + test("one invalid item anywhere in a longer batch still publishes nothing") { + val producer = Recording(() => acked()) + val values = Seq("""{"a":1}""", """{"b":2}""", """{"c":3}""", "}{", """{"d":4}""") + val response = callSend(producer, sendRequest(MessageFormat.MESSAGE_FORMAT_JSON, values*)) + + for r <- ZIO.fromFuture(_ => response) + yield assertTrue( + r.getStatus.code == Code.INVALID_ARGUMENT.value, + producer.published.isEmpty + ) ?? s"status=${r.getStatus} published=${producer.published}" + }, + test("a wholly valid batch is published in full, in request order") { + // The control for the three above: whole-batch validation must not start rejecting or + // dropping items that are fine. + val producer = Recording(() => acked()) + val values = Seq("""{"a":1}""", """{"b":2}""", """{"c":3}""") + val response = callSend(producer, sendRequest(MessageFormat.MESSAGE_FORMAT_JSON, values*)) + + for r <- ZIO.fromFuture(_ => response) + yield assertTrue( + r.getStatus.code == Code.OK.value, + producer.published == values.toList + ) ?? s"status=${r.getStatus} published=${producer.published}" + }, + // ---- finding 24: `send` itself must wait for the broker, not just the helper ---- + test("send does not answer while the broker has not acked") { + // REGRESSION - `send` used to discard the `sendAsync` futures and return Code.OK at once, + // reporting a successful publish for messages the broker had not accepted yet. Asserted + // through `send` rather than through `awaitSends`: restoring the immediate OK inside + // `send` leaves every helper test green. + val brokerVerdict = CompletableFuture[MessageId]() + val producer = Recording(() => brokerVerdict) + val response = callSend(producer, sendRequest(MessageFormat.MESSAGE_FORMAT_BYTES, "a", "b")) + + val answeredBeforeAck = response.isCompleted + brokerVerdict.complete(MessageId.latest) + + for r <- ZIO.fromFuture(_ => response) + yield assertTrue( + !answeredBeforeAck, + r.getStatus.code == Code.OK.value, + producer.published == List("a", "b") + ) ?? s"answeredBeforeAck=$answeredBeforeAck status=${r.getStatus}" + }, + test("a broker rejection arriving after the call turns send's response non-OK") { + val brokerVerdict = CompletableFuture[MessageId]() + val producer = Recording(() => brokerVerdict) + val response = callSend(producer, sendRequest(MessageFormat.MESSAGE_FORMAT_BYTES, "a")) + + brokerVerdict.completeExceptionally(RuntimeException("Producer send timeout")) + + for r <- ZIO.fromFuture(_ => response) + yield assertTrue( + r.getStatus.code == Code.FAILED_PRECONDITION.value, + r.getStatus.message.contains("Producer send timeout") + ) ?? s"status=${r.getStatus}" + }, + // ---- finding 22: no verdict while any submitted send is still travelling ---- + test("a synchronous send failure on a later item waits for the items already submitted") { + // REGRESSION - publication is submitted one item at a time, and a builder/`sendAsync` + // that throws on item N used to break straight out of `send` with FAILED_PRECONDITION. + // Items 1..N-1 were already in flight to the broker and their futures were dropped on + // the floor, so the RPC answered "failed" while part of its own batch was still on its + // way to the topic. The caller retries, and whatever landed is duplicated. Publication + // cannot be made atomic after the fact, but the verdict must not exist until every + // submitted send has settled. + val submitted = java.util.concurrent.atomic.AtomicInteger(0) + val inFlight = CompletableFuture[MessageId]() + val producer = Recording { () => + if submitted.incrementAndGet() == 3 then throw RuntimeException("Producer is closed") + inFlight + } + + val response = callSend(producer, sendRequest(MessageFormat.MESSAGE_FORMAT_BYTES, "a", "b", "c")) + + val answeredWhileInFlight = response.isCompleted + inFlight.complete(MessageId.latest) + + for r <- ZIO.fromFuture(_ => response) + yield assertTrue( + !answeredWhileInFlight, + r.getStatus.code == Code.FAILED_PRECONDITION.value, + r.getStatus.message.contains("Producer is closed"), + // the third item never reached the broker; the first two did and cannot be recalled + producer.published == List("a", "b") + ) ?? s"answeredWhileInFlight=$answeredWhileInFlight status=${r.getStatus} published=${producer.published}" + }, + test("a broker rejection on one item does not answer while a sibling is still in flight") { + // The same guarantee for the asynchronous half: the broker refuses item 1 while item 2 + // is still being decided. Asserted through `send` because `awaitSends` being correct + // proves nothing if `send` stops calling it with the whole batch. + val rejected = CompletableFuture[MessageId]() + val stillInFlight = CompletableFuture[MessageId]() + val nth = java.util.concurrent.atomic.AtomicInteger(0) + val producer = Recording(() => if nth.incrementAndGet() == 1 then rejected else stillInFlight) + + val response = callSend(producer, sendRequest(MessageFormat.MESSAGE_FORMAT_BYTES, "a", "b")) + rejected.completeExceptionally(RuntimeException("Producer fenced")) + + val answeredWhileSiblingInFlight = response.isCompleted + stillInFlight.complete(MessageId.latest) + + for r <- ZIO.fromFuture(_ => response) + yield assertTrue( + !answeredWhileSiblingInFlight, + r.getStatus.code == Code.FAILED_PRECONDITION.value, + r.getStatus.message.contains("Producer fenced") + ) ?? s"answeredWhileSiblingInFlight=$answeredWhileSiblingInFlight status=${r.getStatus}" + } + ) +} diff --git a/server/src/test/scala/producer/awaitSendsTest.scala b/server/src/test/scala/producer/awaitSendsTest.scala new file mode 100644 index 000000000..96b88ec92 --- /dev/null +++ b/server/src/test/scala/producer/awaitSendsTest.scala @@ -0,0 +1,168 @@ +package producer + +import zio.* +import zio.test.* + +import com.google.rpc.code.Code +import org.apache.pulsar.client.api.MessageId + +import java.util.concurrent.CompletableFuture + +/** `producer.awaitSends` is the tail of `ProducerServiceImpl.send`: it turns the in-flight + * `sendAsync` futures into the gRPC response. + * + * Regression context: `send` called `newMessage.sendAsync` and DISCARDED every future, then + * answered `Code.OK` unconditionally. A Pulsar `sendAsync` future completes only when the broker + * has acknowledged (or rejected) the message, so every asynchronous rejection - schema + * incompatibility, producer fenced, exceeded quota, terminated topic, send timeout - was reported + * to the UI as a successful publish for a message that never landed. + * + * The second half of the same defect: once the futures WERE awaited, `Future.sequence` awaited them + * fail-fast, so one rejection completed the RPC while its siblings were still travelling to the + * broker. Those siblings landed after the caller had been told the batch failed, and the obvious + * retry duplicated them. Publication is not atomic - `send` submits one item at a time - so what + * this helper owes the caller is that the batch has stopped moving by the time its verdict exists. + * + * These use real `CompletableFuture`s (exactly what the Pulsar client hands back) rather than a + * mock producer, and complete them by hand so the "answered before the ack" case is observable + * instead of timing-dependent. `awaitSends` holds no shared state, so parallel execution is safe. + */ +object awaitSendsTest extends ZIOSpecDefault { + + private def acked(): CompletableFuture[MessageId] = + CompletableFuture.completedFuture(MessageId.earliest) + + private def pending(): CompletableFuture[MessageId] = + new CompletableFuture[MessageId]() + + def spec = suite(this.getClass.toString)( + test("no response is produced while a send is still in flight") { + val inFlight = pending() + val response = awaitSends(Seq(acked(), inFlight)) + + val answeredBeforeAck = response.isCompleted + inFlight.complete(MessageId.latest) + + for r <- ZIO.fromFuture(_ => response) + yield assertTrue(!answeredBeforeAck, r.getStatus.code == Code.OK.value) ?? + "the response must not exist until the broker has acked every message" + }, + test("a rejection that arrives after the call still turns the response non-OK") { + // The exact production shape: the response is being built while the broker is still + // deciding, and it decides "no". + val inFlight = pending() + val response = awaitSends(Seq(acked(), inFlight)) + + inFlight.completeExceptionally(new RuntimeException("Producer send timeout")) + + for r <- ZIO.fromFuture(_ => response) + yield assertTrue( + r.getStatus.code != Code.OK.value, + r.getStatus.message.contains("Producer send timeout") + ) + }, + test("a rejection already present when the response is built is reported") { + val rejected = pending() + rejected.completeExceptionally(new RuntimeException("Topic terminated")) + + for r <- ZIO.fromFuture(_ => awaitSends(Seq(rejected, acked()))) + yield assertTrue( + r.getStatus.code != Code.OK.value, + r.getStatus.message.contains("Topic terminated") + ) + }, + test("a rejection does not answer the batch while a sibling send is still in flight") { + // REGRESSION - `Future.sequence` is FAIL-FAST: the first rejected future completed the + // whole response while its siblings were still on their way to the broker. The caller is + // told the batch failed, the siblings land afterwards, and the natural retry duplicates + // them. Every submitted send has to SETTLE before the verdict exists. + val rejected = pending() + rejected.completeExceptionally(new RuntimeException("Producer send timeout")) + val stillInFlight = pending() + + val response = awaitSends(Seq(rejected, stillInFlight)) + + val answeredWhileSiblingInFlight = response.isCompleted + stillInFlight.complete(MessageId.latest) + + for r <- ZIO.fromFuture(_ => response) + yield assertTrue( + !answeredWhileSiblingInFlight, + r.getStatus.code != Code.OK.value, + r.getStatus.message.contains("Producer send timeout") + ) ?? s"answeredWhileSiblingInFlight=$answeredWhileSiblingInFlight status=${r.getStatus}" + }, + test("a rejection arriving mid-flight still waits for the sibling that is left") { + // Same defect, reached the other way round: both sends are pending when the response is + // built and one is rejected afterwards, so the fail-fast short circuit fires on the + // completion thread rather than on the calling thread. + val first = pending() + val second = pending() + + val response = awaitSends(Seq(first, second)) + first.completeExceptionally(new RuntimeException("Topic terminated")) + + val answeredWhileSiblingInFlight = response.isCompleted + second.complete(MessageId.latest) + + for r <- ZIO.fromFuture(_ => response) + yield assertTrue( + !answeredWhileSiblingInFlight, + r.getStatus.code != Code.OK.value, + r.getStatus.message.contains("Topic terminated") + ) ?? s"answeredWhileSiblingInFlight=$answeredWhileSiblingInFlight status=${r.getStatus}" + }, + test("two rejections report one of them rather than losing the verdict") { + val first = pending() + val second = pending() + val response = awaitSends(Seq(first, second)) + + first.completeExceptionally(new RuntimeException("Producer fenced")) + second.completeExceptionally(new RuntimeException("Topic terminated")) + + for r <- ZIO.fromFuture(_ => response) + yield assertTrue( + r.getStatus.code == Code.FAILED_PRECONDITION.value, + r.getStatus.message.contains("Producer fenced") + ) ?? s"status=${r.getStatus}" + }, + test("a submit failure is reported only after every send already submitted has settled") { + // `send` publishes one item at a time, so a builder/`sendAsync` that throws on item N + // leaves items 1..N-1 in flight. The failure the caller sees is the submit failure, but + // it may not be produced until those siblings have settled - otherwise the RPC answers + // while part of its own batch is still travelling to the topic. + val inFlight = pending() + val submitFailure = new RuntimeException("Producer is closed") + + val response = awaitSends(Seq(inFlight), Some(submitFailure)) + + val answeredWhileSiblingInFlight = response.isCompleted + inFlight.complete(MessageId.latest) + + for r <- ZIO.fromFuture(_ => response) + yield assertTrue( + !answeredWhileSiblingInFlight, + r.getStatus.code == Code.FAILED_PRECONDITION.value, + r.getStatus.message.contains("Producer is closed") + ) ?? s"answeredWhileSiblingInFlight=$answeredWhileSiblingInFlight status=${r.getStatus}" + }, + test("a submit failure with nothing in flight is reported immediately") { + val response = awaitSends(Seq.empty, Some(new RuntimeException("Producer is closed"))) + for r <- ZIO.fromFuture(_ => response) + yield assertTrue( + response.isCompleted, + r.getStatus.code == Code.FAILED_PRECONDITION.value, + r.getStatus.message.contains("Producer is closed") + ) + }, + test("a fully acked batch is OK") { + for r <- ZIO.fromFuture(_ => awaitSends(Seq(acked(), acked(), acked()))) + yield assertTrue(r.getStatus.code == Code.OK.value, r.getStatus.message.isEmpty) + }, + test("an empty batch answers OK immediately") { + val response = awaitSends(Seq.empty) + for r <- ZIO.fromFuture(_ => response) + yield assertTrue(response.isCompleted, r.getStatus.code == Code.OK.value) + } + ) +} diff --git a/server/src/test/scala/producer/jsonToValueTest.scala b/server/src/test/scala/producer/jsonToValueTest.scala new file mode 100644 index 000000000..4d262f102 --- /dev/null +++ b/server/src/test/scala/producer/jsonToValueTest.scala @@ -0,0 +1,502 @@ +package producer + +import zio.* +import zio.test.* +import zio.test.Assertion.* + +import org.apache.pulsar.common.schema.{SchemaInfo, SchemaType} +import _root_.conversions.primitiveConv +import _root_.schema.avro + +import java.nio.charset.StandardCharsets +import io.circe.parser.parse as parseJson + +/* Tests for `producer.jsonToValue` — the WRITE path that turns a JSON payload from the + * producer UI into the wire bytes for a topic's schema. It is the mirror of the READ path + * covered by `conversions.primitiveConvTest`, so wherever possible the corresponding + * `primitiveConv` decoder is used as the oracle for a round-trip. + * + * `jsonToValue` is a pure function over its arguments (no shared/singleton state), so the + * default parallel execution of ZIO Test suites is safe here — no `TestAspect.sequential`. + */ +object jsonToValueTest extends ZIOSpecDefault { + + private def schemaInfoOf(schemaType: SchemaType, definition: Array[Byte] = Array.emptyByteArray): SchemaInfo = + SchemaInfo.builder + .name(s"test-$schemaType") + .`type`(schemaType) + .schema(definition) + .build + + private def encode(schemaType: SchemaType, json: String): Either[Throwable, Array[Byte]] = + jsonToValue(schemaInfoOf(schemaType), json.getBytes(StandardCharsets.UTF_8)) + + private def passesThrough(schemaType: SchemaType, payload: String): Boolean = + encode(schemaType, payload) match + case Right(bytes) => bytes.sameElements(payload.getBytes(StandardCharsets.UTF_8)) + case Left(_) => false + + def spec = suite(this.getClass.toString)( + // ---------------------------------------------------------------- INT8 + test("INT8 encodes one big-endian byte that round-trips through primitiveConv.bytesToInt8") { + case class TestCase(json: String, expected: Byte) + + def runTestCase(testCase: TestCase): Boolean = + encode(SchemaType.INT8, testCase.json) match + case Right(bytes) => bytes.length == 1 && primitiveConv.bytesToInt8(bytes) == Right(testCase.expected) + case Left(_) => false + + val testCases = List( + TestCase("0", 0), + TestCase("1", 1), + TestCase("-1", -1), + TestCase("42", 42), + TestCase("-42", -42), + TestCase("127", Byte.MaxValue), + TestCase("-128", Byte.MinValue) + ) + + val failures = testCases.filterNot(runTestCase).map(_.json) + assertTrue(failures.isEmpty) + }, + test("INT8 rejects out-of-range and unparseable input") { + // Out-of-range is caught by the explicit Byte.MinValue/MaxValue guard + // (ProducerServiceImpl.scala:190) — this one is a real, non-vacuous check. + val rejected = List("128", "-128000", "-129", "1.5", "0x2a", "abc", "true", " 42", "42 ", "+42", "") + + val failures = rejected.filterNot(json => encode(SchemaType.INT8, json).isLeft) + assertTrue(failures.isEmpty) + }, + // --------------------------------------------------------------- INT16 + test("INT16 encodes two big-endian bytes that round-trip through primitiveConv.bytesToInt16") { + case class TestCase(json: String, expected: Short) + + def runTestCase(testCase: TestCase): Boolean = + encode(SchemaType.INT16, testCase.json) match + case Right(bytes) => bytes.length == 2 && primitiveConv.bytesToInt16(bytes) == Right(testCase.expected) + case Left(_) => false + + val testCases = List( + TestCase("0", 0), + TestCase("1", 1), + TestCase("-1", -1), + TestCase("42", 42), + TestCase("-42", -42), + TestCase("32767", Short.MaxValue), + TestCase("-32768", Short.MinValue) + ) + + val failures = testCases.filterNot(runTestCase).map(_.json) + assertTrue(failures.isEmpty) + }, + test("INT16 rejects out-of-range and unparseable input") { + // Guarded by the explicit Short.MinValue/MaxValue check (ProducerServiceImpl.scala:201). + val rejected = List("32768", "-32769", "2147483647", "1.5", "abc", "") + + val failures = rejected.filterNot(json => encode(SchemaType.INT16, json).isLeft) + assertTrue(failures.isEmpty) + }, + // --------------------------------------------------------------- INT32 + test("INT32 encodes four big-endian bytes that round-trip through primitiveConv.bytesToInt32") { + case class TestCase(json: String, expected: Int) + + def runTestCase(testCase: TestCase): Boolean = + encode(SchemaType.INT32, testCase.json) match + case Right(bytes) => bytes.length == 4 && primitiveConv.bytesToInt32(bytes) == Right(testCase.expected) + case Left(_) => false + + val testCases = List( + TestCase("0", 0), + TestCase("1", 1), + TestCase("-1", -1), + TestCase("42", 42), + TestCase("-42", -42), + TestCase("2147483647", Int.MaxValue), + TestCase("-2147483648", Int.MinValue) + ) + + val failures = testCases.filterNot(runTestCase).map(_.json) + assertTrue(failures.isEmpty) + }, + test("INT32 rejects out-of-range and unparseable input") { + // NOTE: the explicit range guard at ProducerServiceImpl.scala:211 is VACUOUS — + // `n` is already an Int, so `n > Int.MaxValue || n < Int.MinValue` can never hold. + // Out-of-range input is nonetheless rejected, because Guava's Ints.tryParse returns + // null on overflow. The observable contract is therefore still fail-closed; only the + // error MESSAGE is wrong ("Unable to parse" instead of "out of range"). Asserting the + // rejection, not the message. + val rejected = List("2147483648", "-2147483649", "9223372036854775807", "1.5", "abc", "") + + val failures = rejected.filterNot(json => encode(SchemaType.INT32, json).isLeft) + assertTrue(failures.isEmpty) + }, + // --------------------------------------------------------------- INT64 + test("INT64 encodes eight big-endian bytes that round-trip through primitiveConv.bytesToInt64") { + case class TestCase(json: String, expected: Long) + + def runTestCase(testCase: TestCase): Boolean = + encode(SchemaType.INT64, testCase.json) match + case Right(bytes) => bytes.length == 8 && primitiveConv.bytesToInt64(bytes) == Right(testCase.expected) + case Left(_) => false + + val testCases = List( + TestCase("0", 0L), + TestCase("1", 1L), + TestCase("-1", -1L), + TestCase("42", 42L), + TestCase("-42", -42L), + TestCase("2147483648", 2147483648L), + TestCase("9223372036854775807", Long.MaxValue), + TestCase("-9223372036854775808", Long.MinValue) + ) + + val failures = testCases.filterNot(runTestCase).map(_.json) + assertTrue(failures.isEmpty) + }, + test("INT64 rejects out-of-range and unparseable input") { + // Same shape as INT32: the guard at ProducerServiceImpl.scala:221 is vacuous + // (`n` is already a Long); Longs.tryParse returning null is what actually rejects. + val rejected = List("9223372036854775808", "-9223372036854775809", "1.5", "abc", "") + + val failures = rejected.filterNot(json => encode(SchemaType.INT64, json).isLeft) + assertTrue(failures.isEmpty) + }, + // ----------------------------------------------- INT8/16/32/64, shared + // REGRESSION - the four integer branches handed the payload straight to Guava's + // `Ints/Longs.tryParse`, which accepts JAVA integer literal syntax rather than JSON: `01`, + // `00` and `-01` all parsed and were encoded onto the topic. A leading zero is not a valid + // JSON number, and this is the JSON message format - FLOAT/DOUBLE on the same switch have + // gated on JSON number syntax since 2026-07-25, the integer widths had not. + test("integer schemas reject leading-zero literals that are not valid JSON numbers") { + val rejected = List("01", "00", "007", "-01", "-00", "0123") + val widths = List(SchemaType.INT8, SchemaType.INT16, SchemaType.INT32, SchemaType.INT64) + + val accepted = + for + width <- widths + json <- rejected + if encode(width, json).isRight + yield s"$width accepted $json" + + assertTrue(accepted.isEmpty) ?? s"accepted non-JSON integer literals: ${accepted.mkString(", ")}" + }, + test("a single zero and ordinary integers still encode for every integer width") { + // The control for the case above: the JSON-syntax gate must reject the leading-zero + // forms WITHOUT also rejecting `0` itself, or negative and multi-digit values. + val accepted = List("0", "-0", "7", "-7", "42", "-42") + val widths = List(SchemaType.INT8, SchemaType.INT16, SchemaType.INT32, SchemaType.INT64) + + val rejected = + for + width <- widths + json <- accepted + if encode(width, json).isLeft + yield s"$width rejected $json" + + assertTrue(rejected.isEmpty) ?? s"rejected valid JSON integers: ${rejected.mkString(", ")}" + }, + // --------------------------------------------------------------- FLOAT + test("FLOAT encodes four bytes that round-trip through primitiveConv.bytesToFloat32") { + case class TestCase(json: String, expected: Float) + + def runTestCase(testCase: TestCase): Boolean = + encode(SchemaType.FLOAT, testCase.json) match + case Right(bytes) => bytes.length == 4 && primitiveConv.bytesToFloat32(bytes) == Right(testCase.expected) + case Left(_) => false + + val testCases = List( + TestCase("0", 0.0f), + TestCase("0.0", 0.0f), + TestCase("1", 1.0f), + TestCase("-1", -1.0f), + TestCase("1.5", 1.5f), + TestCase("-1.5", -1.5f), + TestCase("42", 42.0f), + TestCase("3.4028235E38", Float.MaxValue), + TestCase("-3.4028235E38", Float.MinValue) + ) + + val failures = testCases.filterNot(runTestCase).map(_.json) + assertTrue(failures.isEmpty) + }, + test("FLOAT rejects infinities and values that overflow the float range") { + // The bound check at ProducerServiceImpl.scala:231 is a symmetric magnitude bound + // (Scala's Float.MinValue is -Float.MaxValue), so the only values it can reject are + // the infinities — including inputs that parse INTO an infinity, e.g. "1e39". + val rejected = List("Infinity", "-Infinity", "1e39", "-1e39") + + val failures = rejected.filterNot(json => encode(SchemaType.FLOAT, json).isLeft) + assertTrue(failures.isEmpty) + }, + // REGRESSION (fixed 2026-07-25) - FLOAT used to accept NaN. The guard at ProducerServiceImpl.scala:231 + // (`n > MaxValue || n < MinValue`) is always false for NaN, so NaN slips past the very + // check that rejects both infinities and is encoded as 0x7fc00000 onto the topic — + // even though `NaN` is not valid JSON and this is the JSON message format. + test("FLOAT rejects NaN") { + val rejected = List("NaN", "-NaN") + + val failures = rejected.filterNot(json => encode(SchemaType.FLOAT, json).isLeft) + assertTrue(failures.isEmpty) + }, + // REGRESSION (fixed 2026-07-25) - the FLOAT/DOUBLE branches delegated parsing to Guava, whose + // FLOATING_POINT_PATTERN is Java literal syntax, not JSON: `+1`, `01`, `.5`, `1.`, a hex + // float literal and a trailing `f`/`d` suffix all parsed and were silently encoded onto the + // topic. This is the JSON message format - the STRING branch on the same switch has always + // required real JSON - so the payload has to be a JSON number. + test("FLOAT rejects Java float literals that are not valid JSON numbers") { + val rejected = List("+1", "01", ".5", "1.", "0x1p3", "1f", "1d", "1.5F") + + val failures = rejected.filterNot(json => encode(SchemaType.FLOAT, json).isLeft) + assertTrue(failures.isEmpty) ?? s"accepted non-JSON numeric forms: ${failures.mkString(", ")}" + }, + // -------------------------------------------------------------- DOUBLE + test("DOUBLE encodes eight bytes that round-trip through primitiveConv.bytesToFloat64") { + case class TestCase(json: String, expected: Double) + + def runTestCase(testCase: TestCase): Boolean = + encode(SchemaType.DOUBLE, testCase.json) match + case Right(bytes) => bytes.length == 8 && primitiveConv.bytesToFloat64(bytes) == Right(testCase.expected) + case Left(_) => false + + val testCases = List( + TestCase("0", 0.0d), + TestCase("0.0", 0.0d), + TestCase("1", 1.0d), + TestCase("-1", -1.0d), + TestCase("1.5", 1.5d), + TestCase("-1.5", -1.5d), + TestCase("42", 42.0d), + TestCase("1e39", 1e39d), + TestCase("1.7976931348623157E308", Double.MaxValue), + TestCase("-1.7976931348623157E308", Double.MinValue) + ) + + val failures = testCases.filterNot(runTestCase).map(_.json) + assertTrue(failures.isEmpty) + }, + test("DOUBLE rejects infinities and values that overflow the double range") { + val rejected = List("Infinity", "-Infinity", "1e309", "-1e309") + + val failures = rejected.filterNot(json => encode(SchemaType.DOUBLE, json).isLeft) + assertTrue(failures.isEmpty) + }, + // REGRESSION (fixed 2026-07-25) - DOUBLE used to accept NaN, same reason as FLOAT: the guard at + // ProducerServiceImpl.scala:241 compares against NaN and is therefore always false, + // so NaN is encoded as 0x7ff8000000000000 while both infinities are rejected. + test("DOUBLE rejects NaN") { + val rejected = List("NaN", "-NaN") + + val failures = rejected.filterNot(json => encode(SchemaType.DOUBLE, json).isLeft) + assertTrue(failures.isEmpty) + }, + // See the FLOAT case above - same Guava parser, same non-JSON forms. + test("DOUBLE rejects Java double literals that are not valid JSON numbers") { + val rejected = List("+1", "01", ".5", "1.", "0x1p3", "1f", "1d", "1.5D") + + val failures = rejected.filterNot(json => encode(SchemaType.DOUBLE, json).isLeft) + assertTrue(failures.isEmpty) ?? s"accepted non-JSON numeric forms: ${failures.mkString(", ")}" + }, + // ------------------------------------------------------------- BOOLEAN + test("BOOLEAN encodes one byte that round-trips through primitiveConv.bytesToBoolean") { + case class TestCase(json: String, expected: Boolean) + + def runTestCase(testCase: TestCase): Boolean = + encode(SchemaType.BOOLEAN, testCase.json) match + case Right(bytes) => bytes.length == 1 && primitiveConv.bytesToBoolean(bytes) == Right(testCase.expected) + case Left(_) => false + + val testCases = List( + TestCase("true", true), + TestCase("false", false) + ) + + val failures = testCases.filterNot(runTestCase).map(_.json) + assertTrue(failures.isEmpty) + }, + test("BOOLEAN accepts only the two bare lowercase literals, byte-for-byte") { + // Documents the ACTUAL contract: BOOLEAN does a raw string compare of the whole + // payload (ProducerServiceImpl.scala:177-181) rather than parsing JSON like STRING + // does. It happens to accept exactly the canonical JSON boolean literals, but it is + // stricter than JSON: " true" and "true\n" are valid JSON booleans and are rejected. + // Fail-closed, so documented rather than flagged as a defect — but the two branches + // of the same endpoint disagree about what "JSON" means. + val rejected = List("True", "TRUE", "\"true\"", " true", "true ", "true\n", "1", "0", "yes", "on", "null", "") + + val failures = rejected.filterNot(json => encode(SchemaType.BOOLEAN, json).isLeft) + assertTrue(failures.isEmpty) + }, + // -------------------------------------------------------------- STRING + test("STRING requires a quoted JSON string literal and round-trips through primitiveConv.bytesToString") { + case class TestCase(json: String, expected: String) + + def runTestCase(testCase: TestCase): Boolean = + encode(SchemaType.STRING, testCase.json) match + case Right(bytes) => primitiveConv.bytesToString(bytes) == testCase.expected + case Left(_) => false + + val testCases = List( + TestCase("\"\"", ""), + TestCase("\"hello\"", "hello"), + TestCase("\"123\"", "123"), + TestCase("\"a\\nb\"", "a\nb"), + TestCase("\"qu\\\"ote\\\"s\"", """qu"ote"s"""), + // Non-ASCII guards the encoding of the write path: the payload must come back out + // as UTF-8, which is what the read path (primitiveConv.bytesToString) assumes. + TestCase("\"Gruß\"", "Gruß"), + TestCase("\"世界\"", "世界") + ) + + val failures = testCases.filterNot(runTestCase).map(_.json) + assertTrue(failures.isEmpty) + }, + test("STRING rejects unquoted, non-string and malformed JSON") { + val rejected = List( + "hello", // bare token: not valid JSON at all + "123", // valid JSON, but a number + "true", + "null", + "{}", + """{"a":1}""", + "[]", + """["a"]""", + "\"unterminated", + "" // empty payload + ) + + val failures = rejected.filterNot(json => encode(SchemaType.STRING, json).isLeft) + assertTrue(failures.isEmpty) + }, + // ---------------------------------------------------------- JSON, NONE + test("JSON forwards a syntactically valid payload byte-for-byte") { + // The bytes must reach the topic unchanged - validation must not reformat the payload. + val payloads = List( + """{"a":1}""", + """{"a":{"b":[1,2]},"c":null}""", + "[1,2,3]", + "null", + "true", + "123", + "\"a string\"", + """ {"a":1} """, // surrounding whitespace is legal JSON and is preserved + """{"a":"Grüß 世界"}""" + ) + + val failures = payloads.filterNot(p => passesThrough(SchemaType.JSON, p)) + assertTrue(failures.isEmpty) + }, + // REGRESSION (fixed 2026-07-25) - `case SchemaType.JSON => Right(jsonAsBytes)` did no parsing at all, so a + // topic with a JSON schema accepted arbitrary bytes: the producer reported success and the + // broken payload landed on the topic, where the READ path then fails to deserialize it. The + // sibling STRING branch on the same switch has always parsed strict JSON. + test("JSON rejects a payload that is not valid JSON") { + val rejected = List( + "not json at all", + """{"a":}""", + "{", + "[1,2", + "{'a':1}", // single quotes are not JSON + """{"a":1} trailing""", + """{"a":1}{"b":2}""", // two documents, not one + "undefined", + "NaN", + "" // empty payload + ) + + val failures = rejected.filterNot(json => encode(SchemaType.JSON, json).isLeft) + assertTrue(failures.isEmpty) + }, + test("NONE stays permissive and forwards raw bytes byte-for-byte") { + // Deliberately NOT tightened: SchemaType.NONE means "no schema", so the payload is + // opaque bytes that may legitimately not be JSON at all. + val payloads = List("""{"a":1}""", "[1,2,3]", "null", "", "not json at all", """{"a":}""", "raw") + + val failures = payloads.filterNot(p => passesThrough(SchemaType.NONE, p)) + assertTrue(failures.isEmpty) + }, + // ---------------------------------------------------------------- AVRO + test("AVRO encodes against the writer schema and rejects payloads that do not fit it") { + val avroSchemaDefinition = + """ + |{ + | "type": "record", + | "name": "User", + | "fields": [ + | {"name": "name", "type": "string"}, + | {"name": "favorite_number", "type": "int"} + | ] + |} + """.stripMargin + + val schemaInfo = schemaInfoOf(SchemaType.AVRO, avroSchemaDefinition.getBytes(StandardCharsets.UTF_8)) + + val jsonToEncode = """{"name":"Alyssa","favorite_number":256}""" + val encoded = jsonToValue(schemaInfo, jsonToEncode.getBytes(StandardCharsets.UTF_8)) + + // Round-trip back through the matching decoder used by the consumer path. + val decoded = encoded.flatMap(bytes => avro.converters.toJson(avroSchemaDefinition.getBytes(StandardCharsets.UTF_8), bytes)) + val roundTripped = decoded.map(bytes => parseJson(String(bytes, StandardCharsets.UTF_8))) + + val rejected = List( + """{"name":"Alyssa"}""", // missing required field + """{"name":"Alyssa","favorite_number":"not a number"}""", + "not json at all", + "" + ) + val rejectFailures = rejected.filterNot(json => jsonToValue(schemaInfo, json.getBytes(StandardCharsets.UTF_8)).isLeft) + + assertTrue( + encoded.isRight, + roundTripped == Right(parseJson(jsonToEncode)), + rejectFailures.isEmpty + ) + }, + // ---------------------------------------------------- PROTOBUF_NATIVE + test("PROTOBUF_NATIVE rejects a payload it cannot encode against the schema descriptor") { + // A valid descriptor needs a compiled .proto (covered in schema.protobufnative tests); + // here we only pin that the branch surfaces the converter's failure as a Left rather + // than throwing out of jsonToValue. + val schemaInfo = schemaInfoOf(SchemaType.PROTOBUF_NATIVE, "not a descriptor".getBytes(StandardCharsets.UTF_8)) + + assertTrue(jsonToValue(schemaInfo, """{"a":1}""".getBytes(StandardCharsets.UTF_8)).isLeft) + }, + // --------------------------------------------------- unsupported types + test("unsupported schema types are rejected") { + val unsupported = List( + SchemaType.BYTES, + SchemaType.DATE, + SchemaType.TIME, + SchemaType.TIMESTAMP, + SchemaType.INSTANT, + SchemaType.LOCAL_DATE, + SchemaType.LOCAL_TIME, + SchemaType.LOCAL_DATE_TIME, + SchemaType.KEY_VALUE, + SchemaType.PROTOBUF + ) + + def rejectsWithUnsupportedMessage(schemaType: SchemaType): Boolean = + encode(schemaType, """{"a":1}""") match + case Left(err) => err.getMessage.startsWith("Unsupported schema type") + case Right(_) => false + + val failures = unsupported.filterNot(rejectsWithUnsupportedMessage).map(_.toString) + assertTrue(failures.isEmpty) + }, + // --------------------------------------------------------- empty input + test("an empty payload is rejected for every primitive schema type") { + val primitives = List( + SchemaType.INT8, + SchemaType.INT16, + SchemaType.INT32, + SchemaType.INT64, + SchemaType.FLOAT, + SchemaType.DOUBLE, + SchemaType.BOOLEAN, + SchemaType.STRING + ) + + val failures = primitives.filterNot(t => jsonToValue(schemaInfoOf(t), Array.emptyByteArray).isLeft).map(_.toString) + assertTrue(failures.isEmpty) + } + ) +} diff --git a/server/src/test/scala/pulsar_auth/pulsarAuthCookieTest.scala b/server/src/test/scala/pulsar_auth/pulsarAuthCookieTest.scala new file mode 100644 index 000000000..fd2a825bc --- /dev/null +++ b/server/src/test/scala/pulsar_auth/pulsarAuthCookieTest.scala @@ -0,0 +1,224 @@ +package pulsar_auth + +import zio.test.* + +/** Cookie construction + parsing. This carries the session credential (tokens, OAuth2 private keys, + * auth-param strings), so both the hardening attributes and the encode/decode round-trip matter. + * + * Regression context: `Secure` was computed and then never interpolated into the header (the + * SameSite fragment was interpolated twice instead), so the cookie could never be marked Secure. + */ +object pulsarAuthCookieTest extends ZIOSpecDefault: + + private def auth(creds: (String, Credentials)*): PulsarAuth = + PulsarAuth(current = Some(creds.headOption.map(_._1).getOrElse("Default")), credentials = creds.toMap) + + private val empty = auth("Default" -> EmptyCredentials(`type` = "empty")) + + /** The attribute segment after the JSON value - everything the browser enforces. */ + private def attributesOf(cookie: String): String = + cookie.dropWhile(_ != ';') + + def spec = suite(this.getClass.toString)( + test("Secure is emitted when cookieSecure is true") { + val cookie = pulsarAuthToCookie(empty, publicBaseUrl = None, cookieSecure = Some(true), cookieSameSite = None) + assertTrue(attributesOf(cookie).contains("Secure")) + }, + test("Secure is absent when cookieSecure is false or unset") { + val off = pulsarAuthToCookie(empty, publicBaseUrl = None, cookieSecure = Some(false), cookieSameSite = None) + val unset = pulsarAuthToCookie(empty, publicBaseUrl = None, cookieSecure = None, cookieSameSite = None) + assertTrue( + !attributesOf(off).contains("Secure"), + !attributesOf(unset).contains("Secure") + ) + }, + test("SameSite appears exactly once") { + // It used to be interpolated twice, which is also how the Secure fragment went missing. + val cookie = pulsarAuthToCookie(empty, publicBaseUrl = None, cookieSecure = Some(true), cookieSameSite = Some("strict")) + val occurrences = "SameSite".r.findAllIn(cookie).size + assertTrue(occurrences == 1) ?? s"SameSite occurred $occurrences times in: $cookie" + }, + test("Secure and SameSite are emitted together when both are configured") { + val cookie = pulsarAuthToCookie(empty, publicBaseUrl = None, cookieSecure = Some(true), cookieSameSite = Some("lax")) + val attrs = attributesOf(cookie) + assertTrue(attrs.contains("Secure"), attrs.contains("SameSite=Lax")) + }, + test("SameSite=None is only emitted when the cookie is also Secure") { + // Browsers reject SameSite=None without Secure, so emitting it alone would break auth. + val secure = pulsarAuthToCookie(empty, publicBaseUrl = None, cookieSecure = Some(true), cookieSameSite = Some("none")) + val insecure = pulsarAuthToCookie(empty, publicBaseUrl = None, cookieSecure = Some(false), cookieSameSite = Some("none")) + assertTrue( + attributesOf(secure).contains("SameSite=None"), + !attributesOf(insecure).contains("SameSite") + ) + }, + test("lax and strict are emitted regardless of Secure") { + val lax = pulsarAuthToCookie(empty, publicBaseUrl = None, cookieSecure = None, cookieSameSite = Some("lax")) + val strict = pulsarAuthToCookie(empty, publicBaseUrl = None, cookieSecure = None, cookieSameSite = Some("strict")) + assertTrue( + attributesOf(lax).contains("SameSite=Lax"), + attributesOf(strict).contains("SameSite=Strict") + ) + }, + test("the cookie is always HttpOnly and long-lived") { + val attrs = attributesOf(pulsarAuthToCookie(empty, publicBaseUrl = None, cookieSecure = None, cookieSameSite = None)) + assertTrue(attrs.contains("HttpOnly"), attrs.contains("Max-Age=31536000")) + }, + test("Path is derived from publicBaseUrl, defaulting to /") { + val default = pulsarAuthToCookie(empty, publicBaseUrl = None, cookieSecure = None, cookieSameSite = None) + val rooted = pulsarAuthToCookie(empty, publicBaseUrl = Some("http://host:8090"), cookieSecure = None, cookieSameSite = None) + val subPath = pulsarAuthToCookie(empty, publicBaseUrl = Some("http://host:8090/dekaf"), cookieSecure = None, cookieSameSite = None) + assertTrue( + default.contains("Path=/;"), + rooted.contains("Path=/;"), + subPath.contains("Path=/dekaf;") + ) + }, + test("cookieSameSite is matched case-insensitively and trimmed") { + // REGRESSION (fixed 2026-07-25) - the match required exact lowercase, so `Lax`/`STRICT` + // (and a stray trailing space from YAML) fell through to "" and emitted NO SameSite + // attribute. The operator saw a configured value; the browser got its default. Silently + // dropping a CSRF control on a capitalisation difference is the bug this pins. + val cases = List( + "lax" -> "SameSite=Lax", + "Lax" -> "SameSite=Lax", + "LAX" -> "SameSite=Lax", + " lax " -> "SameSite=Lax", + "strict" -> "SameSite=Strict", + "Strict" -> "SameSite=Strict", + "STRICT" -> "SameSite=Strict", + "\tStRiCt\n" -> "SameSite=Strict" + ) + val wrong = cases.filterNot: (configured, expected) => + attributesOf(pulsarAuthToCookie(empty, publicBaseUrl = None, cookieSecure = None, cookieSameSite = Some(configured))).contains(expected) + assertTrue(wrong.isEmpty) ?? s"these did not produce their attribute: ${wrong.map(_._1).mkString("|")}" + }, + test("cookieSameSite=none is matched case-insensitively too, and still requires Secure") { + val variants = List("none", "None", "NONE", " none ") + val emitted = variants.filter: v => + attributesOf(pulsarAuthToCookie(empty, publicBaseUrl = None, cookieSecure = Some(true), cookieSameSite = Some(v))).contains("SameSite=None") + val leakedWithoutSecure = variants.filter: v => + attributesOf(pulsarAuthToCookie(empty, publicBaseUrl = None, cookieSecure = Some(false), cookieSameSite = Some(v))).contains("SameSite") + assertTrue( + emitted == variants, + leakedWithoutSecure.isEmpty + ) ?? s"emitted=$emitted leakedWithoutSecure=$leakedWithoutSecure" + }, + test("an unknown or blank cookieSameSite value emits no SameSite attribute") { + // Unrecognised values stay omitted (a warning is logged) - the fix widened what counts + // as recognised, it did not start emitting arbitrary operator input into the header. + val ignored = List("bogus", "", " ", "lax; Domain=evil.example", "same-site=lax") + val emitted = ignored.filter: v => + attributesOf(pulsarAuthToCookie(empty, publicBaseUrl = None, cookieSecure = Some(true), cookieSameSite = Some(v))).contains("SameSite") + assertTrue(emitted.isEmpty) ?? s"these emitted a SameSite attribute: ${emitted.mkString("|")}" + }, + test("a configured cookieSameSite never injects extra cookie attributes") { + // The value is interpolated straight into the Set-Cookie header, so an operator value + // carrying `;` must not be able to append attributes of its own. + val cookie = pulsarAuthToCookie(empty, publicBaseUrl = None, cookieSecure = Some(true), cookieSameSite = Some("lax; Domain=evil.example")) + assertTrue(!cookie.contains("evil.example")) + }, + // ---- round-trip: what is written must read back identically ---- + test("an empty credential round-trips through the cookie") { + val cookie = pulsarAuthToCookie(empty, publicBaseUrl = None, cookieSecure = None, cookieSameSite = None) + val value = cookie.stripPrefix("pulsar_auth=").takeWhile(_ != ';') + assertTrue(parsePulsarAuthCookie(Some(value)) == Right(empty)) + }, + test("a jwt credential round-trips through the cookie") { + val jwt = auth("Default" -> JwtCredentials(`type` = "jwt", token = "aaa.bbb.ccc")) + val cookie = pulsarAuthToCookie(jwt, publicBaseUrl = None, cookieSecure = None, cookieSameSite = None) + val value = cookie.stripPrefix("pulsar_auth=").takeWhile(_ != ';') + assertTrue(parsePulsarAuthCookie(Some(value)) == Right(jwt)) + }, + test("an authParamsString credential round-trips, including + and % in authParams") { + // REGRESSION (fixed 2026-07-25) - pulsarAuthToCookie used to URL-encode ONLY the OAuth2 fields, but + // parsePulsarAuthCookie URL-DECODES the whole cookie value. So authParams (which carries + // passwords/tokens) is written raw and read decoded: `+` becomes a space and `%xx` is + // eaten. Asserting the correct behavior - the round trip must be lossless. + val creds = AuthParamsStringCredentials( + `type` = "authParamsString", + authPluginClassName = "org.apache.pulsar.client.impl.auth.AuthenticationToken", + authParams = "token:a+b%2Fc" + ) + val a = auth("Default" -> creds) + val cookie = pulsarAuthToCookie(a, publicBaseUrl = None, cookieSecure = None, cookieSameSite = None) + val value = cookie.stripPrefix("pulsar_auth=").takeWhile(_ != ';') + assertTrue(parsePulsarAuthCookie(Some(value)) == Right(a)) + }, + test("an oauth2 credential round-trips, including a private key with + / and = in it") { + // The four oauth2 fields are URL-encoded individually while the READ decodes the whole + // cookie once, so this is the pairing most likely to drift. A base64 private key is the + // realistic payload: it is full of the exact characters URL coding rewrites. + val creds = OAuth2Credentials( + `type` = "oauth2", + issuerUrl = "https://issuer.example.com/oauth2/token?tenant=a b", + privateKey = "MIIEvQIBADAN+Bg/kqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQ==", + audience = Some("urn:dekaf:audience with spaces"), + scope = Some("read write+admin") + ) + val a = auth("Default" -> creds) + val cookie = pulsarAuthToCookie(a, publicBaseUrl = None, cookieSecure = None, cookieSameSite = None) + val value = cookie.stripPrefix("pulsar_auth=").takeWhile(_ != ';') + assertTrue(parsePulsarAuthCookie(Some(value)) == Right(a)) + }, + test("optional oauth2 fields round-trip as None") { + val creds = OAuth2Credentials( + `type` = "oauth2", + issuerUrl = "https://issuer.example.com", + privateKey = "key", + audience = None, + scope = None + ) + val a = auth("Default" -> creds) + val cookie = pulsarAuthToCookie(a, publicBaseUrl = None, cookieSecure = None, cookieSameSite = None) + val value = cookie.stripPrefix("pulsar_auth=").takeWhile(_ != ';') + assertTrue(parsePulsarAuthCookie(Some(value)) == Right(a)) + }, + test("several credentials round-trip together and `current` selects among them") { + // Every real deployment holds more than one entry; the map and the selected name have to + // survive together, or switching credentials silently reverts. + val a = PulsarAuth( + current = Some("staging"), + credentials = Map( + "Default" -> EmptyCredentials(`type` = "empty"), + "staging" -> JwtCredentials(`type` = "jwt", token = "aaa.bbb.ccc"), + "prod" -> AuthParamsStringCredentials( + `type` = "authParamsString", + authPluginClassName = "org.apache.pulsar.client.impl.auth.AuthenticationToken", + authParams = "token:x+y%2Fz" + ) + ) + ) + val cookie = pulsarAuthToCookie(a, publicBaseUrl = None, cookieSecure = None, cookieSameSite = None) + val value = cookie.stripPrefix("pulsar_auth=").takeWhile(_ != ';') + assertTrue(parsePulsarAuthCookie(Some(value)) == Right(a)) + }, + test("a credential whose type does not match its shape is rejected") { + // credentialsDecoder tries each decoder in turn and takes the first success, so a + // malformed entry must fail rather than silently decode as a weaker credential type. + val badJwt = """{"current":"Default","credentials":{"Default":{"type":"jwt","token":"not-a-jwt"}}}""" + val badIssuer = """{"current":"Default","credentials":{"Default":{"type":"oauth2","issuerUrl":"ftp://x","privateKey":"k"}}}""" + assertTrue( + parsePulsarAuthCookie(Some(badJwt)).isLeft, + parsePulsarAuthCookie(Some(badIssuer)).isLeft + ) + }, + test("a missing cookie yields the default auth rather than an error") { + assertTrue(parsePulsarAuthCookie(None).isRight) + }, + test("a malformed cookie yields a Left, not an exception") { + assertTrue(parsePulsarAuthCookie(Some("not json at all")).isLeft) + }, + test("malformed percent-encoding yields a Left, not a thrown IllegalArgumentException") { + // URLDecoder.decode throws on these; it used to run outside the Either, turning a + // hand-edited cookie into a server error instead of a 400. + val malformed = List("%", "%ZZ", "a%2", "%E0%A4%A") + val escaped = malformed.filter(c => scala.util.Try(parsePulsarAuthCookie(Some(c))).isFailure) + assertTrue(escaped.isEmpty) ?? s"these threw instead of returning Left: ${escaped.mkString(", ")}" + }, + test("every malformed cookie form is reported as a parse failure") { + val malformed = List("%", "%ZZ", "a%2", "not json at all", "{\"current\":}") + val notLeft = malformed.filterNot(c => scala.util.Try(parsePulsarAuthCookie(Some(c))).toOption.exists(_.isLeft)) + assertTrue(notLeft.isEmpty) ?? s"these did not yield Left: ${notLeft.mkString(", ")}" + } + ) diff --git a/server/src/test/scala/pulsar_auth/pulsarAuthRoutesHttpTest.scala b/server/src/test/scala/pulsar_auth/pulsarAuthRoutesHttpTest.scala new file mode 100644 index 000000000..4f0ca632e --- /dev/null +++ b/server/src/test/scala/pulsar_auth/pulsarAuthRoutesHttpTest.scala @@ -0,0 +1,210 @@ +package pulsar_auth + +import zio.* +import zio.test.* + +import io.javalin.Javalin + +import java.net.URI +import java.net.http.{HttpClient, HttpRequest, HttpResponse} +import scala.jdk.OptionConverters.* + +/** The add/use/delete routes under `/pulsar-auth`, over real HTTP. + * + * `pulsarAuthCookieTest` covers `pulsarAuthToCookie`/`parsePulsarAuthCookie` as functions, but the + * routes are what a browser actually talks to, and everything between the function and the wire was + * untested: whether the configured hardening attributes reach `Set-Cookie` at all, whether a cookie + * value carrying `+`/`%` in `authParams` survives being handed to a client and sent back (the value + * is URL-encoded on write and URL-decoded on read, and it travels through Jetty's cookie parser in + * between), and whether a rejected request leaves the caller's current credential alone. + * + * Real Javalin on an ephemeral port and a real `java.net.http` client - no servlet stubs. The + * cookie config is passed to `routesWith` because the process-level `config` val loads once per JVM + * and cannot be varied. + */ +object pulsarAuthRoutesHttpTest extends ZIOSpecDefault: + + /** `+` and `%` are the two characters the encode/decode pairing gets wrong when it drifts, and + * `authParams` is where a token or password lives. */ + private val authParams = "token:a+b%2Fc" + + private val credentials = AuthParamsStringCredentials( + `type` = "authParamsString", + authPluginClassName = "org.apache.pulsar.client.impl.auth.AuthenticationToken", + authParams = authParams + ) + + private val credentialsJson = + s"""{"type":"authParamsString","authPluginClassName":"${credentials.authPluginClassName}","authParams":"$authParams"}""" + + private val client = HttpClient.newHttpClient() + + private case class Response(status: Int, body: String, setCookie: Option[String]): + /** The cookie value a browser would store and send back. */ + def cookieHeader: String = "pulsar_auth=" + setCookie.getOrElse("").stripPrefix("pulsar_auth=").takeWhile(_ != ';') + + /** The attribute segment - everything the browser enforces. */ + def attributes: String = setCookie.getOrElse("").dropWhile(_ != ';') + + def parsedAuth: Either[Throwable, PulsarAuth] = + parsePulsarAuthCookie(Some(cookieHeader.stripPrefix("pulsar_auth="))) + + private def post(base: String, path: String, body: String = "", cookie: Option[String] = None): Task[Response] = + ZIO.attemptBlocking { + val builder = HttpRequest.newBuilder(URI.create(s"$base$path")).POST(HttpRequest.BodyPublishers.ofString(body)) + cookie.foreach(c => builder.header("Cookie", c)) + val response = client.send(builder.build(), HttpResponse.BodyHandlers.ofString()) + Response(response.statusCode, response.body, response.headers.firstValue("Set-Cookie").toScala) + } + + private def serve[A]( + publicBaseUrl: Option[String] = None, + cookieSecure: Option[Boolean] = None, + cookieSameSite: Option[String] = None + )(use: String => Task[A]): Task[A] = + ZIO.acquireReleaseWith( + ZIO.attemptBlocking { + Javalin + .create(config => config.showJavalinBanner = false) + .routes(PulsarAuthRoutes.routesWith(publicBaseUrl, cookieSecure, cookieSameSite)) + .start(0) + } + )(app => ZIO.attemptBlocking(app.stop()).ignore)(app => use(s"http://localhost:${app.port()}")) + + def spec = suite(this.getClass.toString)( + test("the configured Secure/SameSite attributes reach the Set-Cookie header") { + // The routes render the cookie themselves. A correct `pulsarAuthToCookie` proves nothing + // if the route calls it with the wrong inputs (or drops the header), and this credential + // is the session's Pulsar authentication - Secure/SameSite/HttpOnly are what stop it + // travelling in clear text or riding along on a cross-site request. + serve(publicBaseUrl = Some("http://gateway.example/dekaf"), cookieSecure = Some(true), cookieSameSite = Some("strict")) { base => + for response <- post(base, "/pulsar-auth/add/prod", credentialsJson) + yield assertTrue( + response.status == 200, + response.setCookie.isDefined, + response.attributes.contains("Secure"), + response.attributes.contains("SameSite=Strict"), + response.attributes.contains("HttpOnly"), + response.attributes.contains("Path=/dekaf"), + response.attributes.contains("Max-Age=31536000") + ) ?? s"Set-Cookie: ${response.setCookie}" + } + }, + test("SameSite=None is withheld over plain HTTP, where a browser would reject the whole cookie") { + serve(cookieSecure = Some(false), cookieSameSite = Some("none")) { base => + for response <- post(base, "/pulsar-auth/add/prod", credentialsJson) + yield assertTrue( + response.status == 200, + !response.attributes.contains("SameSite"), + !response.attributes.contains("Secure") + ) ?? s"Set-Cookie: ${response.setCookie}" + } + }, + test("a credential with + and % in authParams survives a full HTTP cookie round trip") { + // Write path: the route URL-encodes authParams into the cookie. Read path: the whole + // cookie value is URL-decoded. If those two ever disagree again, `+` becomes a space and + // `%xx` is eaten - and the failure is a broken Pulsar connection, not a parse error. + // Sending the cookie back to /use/{name} is the tight oracle: that route answers 200 + // only if the server re-read the credential map out of the cookie it just issued. + serve() { base => + for + added <- post(base, "/pulsar-auth/add/prod", credentialsJson) + reused <- post(base, "/pulsar-auth/use/prod", cookie = Some(added.cookieHeader)) + yield assertTrue( + added.status == 200, + reused.status == 200, + added.parsedAuth.map(_.credentials.get("prod")) == Right(Some(credentials)), + reused.parsedAuth.map(_.credentials.get("prod")) == Right(Some(credentials)), + reused.parsedAuth.map(_.current) == Right(Some("prod")) + ) ?? s"added=${added.setCookie} reused=${reused.setCookie}" + } + }, + test("an unknown /use/{name} is a 404 that does not touch the current cookie") { + // REGRESSION - selecting a name that is not in the map used to succeed and write it into + // the cookie, after which every client construction failed and the interceptor answered + // UNAUTHENTICATED for every call: one request bricked the session. The route must both + // refuse AND leave the caller's cookie alone, so no Set-Cookie may be written at all. + serve() { base => + for + added <- post(base, "/pulsar-auth/add/prod", credentialsJson) + // control: a name that DOES exist still switches, so the assertions below are not vacuous + known <- post(base, "/pulsar-auth/use/Default", cookie = Some(added.cookieHeader)) + unknown <- post(base, "/pulsar-auth/use/nope", cookie = Some(added.cookieHeader)) + yield assertTrue( + known.status == 200, + known.parsedAuth.map(_.current) == Right(Some("Default")), + unknown.status == 404, + unknown.setCookie.isEmpty, + unknown.body.contains("nope") + ) ?? s"known=${known.status}/${known.setCookie} unknown=${unknown.status}/${unknown.setCookie}" + } + }, + test("deleting a credential that is not the current one leaves the selection alone") { + // REGRESSION - delete unconditionally reassigned + // `current = newCredentials.keys.headOption.orElse(Some("Default"))`, so removing an + // unrelated credential silently repointed the session at whatever key happened to come + // first in map iteration order. The user deletes a stale entry and their next Pulsar + // call runs under different (often Default/empty) credentials - an authorization change + // nobody asked for and nothing reports. + serve() { base => + for + aaa <- post(base, "/pulsar-auth/add/aaa", credentialsJson) + bbb <- post(base, "/pulsar-auth/add/bbb", credentialsJson, cookie = Some(aaa.cookieHeader)) + ccc <- post(base, "/pulsar-auth/add/ccc", credentialsJson, cookie = Some(bbb.cookieHeader)) + deleted <- post(base, "/pulsar-auth/delete/aaa", cookie = Some(ccc.cookieHeader)) + // oracle: the deleted name is really gone from the cookie the delete handed back + reuseDeleted <- post(base, "/pulsar-auth/use/aaa", cookie = Some(deleted.cookieHeader)) + reuseSurvivor <- post(base, "/pulsar-auth/use/bbb", cookie = Some(deleted.cookieHeader)) + yield assertTrue( + ccc.parsedAuth.map(_.current) == Right(Some("ccc")), + deleted.status == 200, + deleted.parsedAuth.map(_.current) == Right(Some("ccc")), + deleted.parsedAuth.map(_.credentials.keySet) == Right(Set("Default", "bbb", "ccc")), + reuseDeleted.status == 404, + reuseSurvivor.status == 200 + ) ?? s"beforeDelete=${ccc.setCookie} afterDelete=${deleted.setCookie}" + } + }, + test("deleting the current credential falls back to Default, never to a name that is gone") { + // The other half of the contract: `current` may only change when it names the credential + // actually removed, and it must then land on a credential that still exists. Default is + // always present (setCookieAndSuccess re-adds it on every response). + serve() { base => + for + aaa <- post(base, "/pulsar-auth/add/aaa", credentialsJson) + bbb <- post(base, "/pulsar-auth/add/bbb", credentialsJson, cookie = Some(aaa.cookieHeader)) + deleted <- post(base, "/pulsar-auth/delete/bbb", cookie = Some(bbb.cookieHeader)) + reuseDeleted <- post(base, "/pulsar-auth/use/bbb", cookie = Some(deleted.cookieHeader)) + yield assertTrue( + bbb.parsedAuth.map(_.current) == Right(Some("bbb")), // it really was current + deleted.status == 200, + deleted.parsedAuth.map(_.current) == Right(Some("Default")), + deleted.parsedAuth.map(_.credentials.keySet) == Right(Set("Default", "aaa")), + reuseDeleted.status == 404 + ) ?? s"beforeDelete=${bbb.setCookie} afterDelete=${deleted.setCookie}" + } + }, + test("deleting an unknown credential is a 404 that does not touch the current cookie") { + // Deleting `/nope` answered 200 AND rewrote `current` - a typo could switch the session's + // authentication. Refuse, and write no cookie at all. Deleting Default is a separate, + // pre-existing 400 and is pinned here so the new 404 branch cannot swallow it. + serve() { base => + for + aaa <- post(base, "/pulsar-auth/add/aaa", credentialsJson) + bbb <- post(base, "/pulsar-auth/add/bbb", credentialsJson, cookie = Some(aaa.cookieHeader)) + unknown <- post(base, "/pulsar-auth/delete/nope", cookie = Some(bbb.cookieHeader)) + default <- post(base, "/pulsar-auth/delete/Default", cookie = Some(bbb.cookieHeader)) + // control: a name that DOES exist is still deletable, so the refusals are not vacuous + known <- post(base, "/pulsar-auth/delete/aaa", cookie = Some(bbb.cookieHeader)) + yield assertTrue( + unknown.status == 404, + unknown.setCookie.isEmpty, + unknown.body.contains("nope"), + default.status == 400, + default.setCookie.isEmpty, + known.status == 200, + known.parsedAuth.map(_.credentials.keySet) == Right(Set("Default", "bbb")) + ) ?? s"unknown=${unknown.status}/${unknown.setCookie} default=${default.status} known=${known.status}" + } + } + ) @@ TestAspect.sequential diff --git a/server/src/test/scala/schema/protobufnative/compilerTest.scala b/server/src/test/scala/schema/protobufnative/compilerTest.scala index f12325ebb..545f4b444 100644 --- a/server/src/test/scala/schema/protobufnative/compilerTest.scala +++ b/server/src/test/scala/schema/protobufnative/compilerTest.scala @@ -23,11 +23,41 @@ object compilerTest extends ZIOSpecDefault: val fileEntry = FileEntry(relativePath, fileEntryContent) val compiledFiles = compiler.compileFiles(Seq(fileEntry)) - val file = compiledFiles.files.getOrElse(relativePath, Left("No such file")) match - case Right(f) => f - file.schemas.get("Person") match - case Some(schema) => - assertTrue(!schema.rawSchema.isEmpty) - assertTrue(schema.humanReadableSchema.contains("Person")) + // Every arm must FAIL the test rather than throw MatchError, and both conditions must + // be in ONE assertTrue - a second `assertTrue` statement is evaluated and discarded, + // so the rawSchema check used to be dead. + compiledFiles.files.getOrElse(relativePath, Left("No such file")) match + case Left(err) => assertNever(s"compilation failed for $relativePath: $err") + case Right(file) => + file.schemas.get("Person") match + case None => assertNever(s"no 'Person' schema; got: ${file.schemas.keys.mkString(", ")}") + case Some(schema) => + assertTrue( + !schema.rawSchema.isEmpty, + schema.humanReadableSchema.contains("Person"), + schema.humanReadableSchema.contains("person_name"), + schema.humanReadableSchema.contains("person_age") + ) + }, + test("reports a compilation error for a malformed proto file") { + val relativePath = "bad/file" + val compiledFiles = compiler.compileFiles(Seq(FileEntry(relativePath, "this is not a proto file"))) + + assertTrue(compiledFiles.files.get(relativePath).exists(_.isLeft)) + }, + test("a message absent from the file does not resolve") { + val relativePath = "a/b/c" + val fileEntryContent = + """syntax = "proto3"; + | + |message Person { + | string person_name = 1; + |} + |""".stripMargin + val compiledFiles = compiler.compileFiles(Seq(FileEntry(relativePath, fileEntryContent))) + + compiledFiles.files.getOrElse(relativePath, Left("No such file")) match + case Left(err) => assertNever(s"compilation failed: $err") + case Right(file) => assertTrue(file.schemas.get("Absent").isEmpty) } ) diff --git a/server/src/test/scala/server/grpc/statusCodeTest.scala b/server/src/test/scala/server/grpc/statusCodeTest.scala new file mode 100644 index 000000000..466f240cf --- /dev/null +++ b/server/src/test/scala/server/grpc/statusCodeTest.scala @@ -0,0 +1,69 @@ +package server.grpc + +import com.google.rpc.code.Code +import zio.test.* + +/** The number that actually goes on the wire for a `google.rpc.Status`. + * + * `google/rpc/code.proto` DECLARES ITS ENUM OUT OF NUMERIC ORDER - `UNAUTHENTICATED = 16` sits + * ninth, between `PERMISSION_DENIED = 7` and `RESOURCE_EXHAUSTED = 8`. ScalaPB's `.index` is the + * DECLARATION POSITION and `.value` is the proto number, so the two agree only for the first eight + * codes and diverge for every one after them: + * + * {{{ + * position: 8 UNAUTHENTICATED(16) 9 RESOURCE_EXHAUSTED(8) 10 FAILED_PRECONDITION(9) ... + * }}} + * + * Sending `.index` therefore mislabels every status from `UNAUTHENTICATED` onwards - a + * FAILED_PRECONDITION goes out as 10, which is ABORTED, and an INTERNAL goes out as 14, which is + * UNAVAILABLE. It stayed invisible because clients that only ask "is this OK?" get the right answer + * either way; it surfaces the moment one tests for a SPECIFIC non-OK code, as the Topic Positions + * tab does when it distinguishes "the session has not been started" from a real fault. + */ +object statusCodeTest extends ZIOSpecDefault: + + def spec = suite("google.rpc.Status codes")( + test("value is the PROTO NUMBER, which is what a client compares against") { + assertTrue(Code.OK.value == 0) && + assertTrue(Code.INVALID_ARGUMENT.value == 3) && + assertTrue(Code.NOT_FOUND.value == 5) && + assertTrue(Code.FAILED_PRECONDITION.value == 9) && + assertTrue(Code.INTERNAL.value == 13) && + assertTrue(Code.UNAUTHENTICATED.value == 16) + }, + test("index is the DECLARATION POSITION and DIVERGES past the first eight codes") { + // This is the trap, written down. If a future ScalaPB or a re-ordered code.proto ever + // makes these agree, this test fails and the warning above can be retired. + assertTrue(Code.FAILED_PRECONDITION.index == 10) && + assertTrue(Code.FAILED_PRECONDITION.index != Code.FAILED_PRECONDITION.value) && + assertTrue(Code.INTERNAL.index != Code.INTERNAL.value) && + assertTrue(Code.UNAUTHENTICATED.index != Code.UNAUTHENTICATED.value) && + // ...and agree for the first eight, which is why `.index` looked correct for years. + assertTrue(Code.OK.index == Code.OK.value) && + assertTrue(Code.INVALID_ARGUMENT.index == Code.INVALID_ARGUMENT.value) + }, + test("no service builds a Status out of .index any more") { + // The whole-tree guard: `.value` is correct for every code, `.index` only for the first + // eight, so the safe rule is that `.index` never reaches a Status at all. + import scala.jdk.CollectionConverters.* + val sources = java.nio.file.Files + .walk(java.nio.file.Paths.get("src/main/scala")) + .iterator + .asScala + .filter(p => p.toString.endsWith(".scala")) + .toVector + + val offenders = sources.flatMap { path => + java.nio.file.Files + .readAllLines(path) + .asScala + .zipWithIndex + .filter((line, _) => line.contains("Code.") && line.contains(".index")) + // A commented-out line is not code. + .filterNot((line, _) => line.trim.startsWith("//")) + .map((line, i) => s"${path.getFileName}:${i + 1}: ${line.trim}") + } + + assertTrue(offenders.isEmpty) || assertTrue(offenders.mkString("\n").isEmpty) + } + ) diff --git a/ui/build.js b/ui/build.js index ccf4586c9..bf70ae2c6 100644 --- a/ui/build.js +++ b/ui/build.js @@ -1,3 +1,4 @@ +const fs = require("fs"); const path = require("path"); const cssModulesPlugin = require("esbuild-css-modules-plugin"); @@ -16,6 +17,15 @@ const outdir = path.resolve( const isDevelopment = process.argv.includes("--dev"); const isWatch = process.argv.includes("--watch"); +// Monaco is not part of the bundle: @monaco-editor/react loads it at RUNTIME with its own AMD +// loader, whose default base is cdn.jsdelivr.net. That makes every code editor in Dekaf depend on +// the public internet - it never mounts at all without it. Ship the same files next to the bundle +// and point the loader at them (see loader.config in components/ui/CodeEditor/CodeEditor.tsx). +const copyMonaco = () => { + const from = path.resolve(__dirname, "node_modules", "monaco-editor", "min", "vs"); + fs.cpSync(from, path.join(outdir, "vs"), { recursive: true }); +}; + require("esbuild") .build({ target: ["chrome100"], @@ -43,4 +53,11 @@ require("esbuild") watch: isWatch, logLevel: "info", }) - .catch(() => process.exit(1)); + .then(copyMonaco) + .catch((err) => { + // Log before exiting: a bare `process.exit(1)` turns a bundle error or a missing + // `monaco-editor/min/vs` (the copyMonaco cpSync) into a silent failed exit with nothing to + // diagnose from. + console.error(err); + process.exit(1); + }); diff --git a/ui/components/TopicPage/TopicPage.test.tsx b/ui/components/TopicPage/TopicPage.test.tsx new file mode 100644 index 000000000..583e84240 --- /dev/null +++ b/ui/components/TopicPage/TopicPage.test.tsx @@ -0,0 +1,125 @@ +/** + * @jest-environment jsdom + * @jest-environment-options {"customExportConditions": ["node"]} + * + * Which topic the page's children belong to. + * + * Pulsar allows `persistent://t/n/x` and `non-persistent://t/n/x` to exist at the same time: two + * different topics whose tenant, namespace and name are identical. The route carries the scheme, so + * navigating from one to the other changes NOTHING else about this page - and a child keyed only by + * tenant/namespace/name is not remounted, so it keeps consuming the topic the user just left. + * + * Only the transport is replaced; the page, its router and the consumer session are real. + * + * Note: with a jest.mock() in the file, esbuild-jest runs babel's hoisting pass over untyped JS, so + * imported bindings must not appear in type annotations here (inference only). + */ +jest.mock('nanoid', () => ({ nanoid: () => 'test-id' })); +jest.mock('mermaid', () => ({ __esModule: true, default: { initialize: () => undefined } })); + +const mockClients = { current: undefined as unknown }; +jest.mock('../app/contexts/GrpcClient/GrpcClient', () => ({ + useContext: () => mockClients.current, +})); + +import React from 'react'; +import { act, render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { HelmetProvider } from 'react-helmet-async'; +import { SWRConfig } from 'swr'; +import TopicPage from './TopicPage'; +import { Status } from '../../grpc-web/google/rpc/status_pb'; +import { Code } from '../../grpc-web/google/rpc/code_pb'; + +const okStatus = () => { + const s = new Status(); + s.setCode(Code.OK); + s.setMessage(''); + return s; +}; + +const installClients = () => { + mockClients.current = { + topicServiceClient: { + getIsPartitionedTopic: () => + Promise.resolve({ + getStatus: () => okStatus(), + getIsPartitioned: () => false, + getPartitionsCount: () => undefined, + getActivePartitionsCount: () => undefined, + }), + }, + consumerServiceClient: { + createConsumer: () => new Promise(() => undefined), + resume: () => ({ on: () => undefined, removeListener: () => undefined, cancel: () => undefined }), + pause: () => new Promise(() => undefined), + deleteConsumer: () => Promise.resolve({ getStatus: () => okStatus() }), + resolveTopicSelector: () => Promise.reject(new Error('not used by these tests')), + }, + producerServiceClient: { + createProducer: () => Promise.resolve({ getStatus: () => okStatus() }), + deleteProducer: () => Promise.resolve({ getStatus: () => okStatus() }), + send: () => Promise.resolve({ getStatus: () => okStatus() }), + }, + libraryServiceClient: { + listLibraryItems: () => Promise.reject(new Error('no library in these tests')), + getLibraryItem: () => Promise.reject(new Error('no library in these tests')), + }, + }; +}; + +const page = (topicPersistency: 'persistent' | 'non-persistent') => ( + + + + + + + +); + +describe('navigating between the two topics that differ only in their scheme', () => { + it('starts a new consumer session rather than keeping the previous topic\'s one', async () => { + installClients(); + + let rerender: any; + await act(async () => { + ({ rerender } = render(page('persistent'))); + }); + + const before = screen.getByTestId('cs-session'); + // The session identifies itself; a remount produces a different element. + (before as any).__markedByThisTest = true; + + await act(async () => { + rerender(page('non-persistent')); + }); + + const after = screen.getByTestId('cs-session'); + expect((after as any).__markedByThisTest).toBeUndefined(); + }); + + it('keeps the same session while the topic does not change', async () => { + installClients(); + + let rerender: any; + await act(async () => { + ({ rerender } = render(page('persistent'))); + }); + + const before = screen.getByTestId('cs-session'); + (before as any).__markedByThisTest = true; + + await act(async () => { + rerender(page('persistent')); + }); + + expect((screen.getByTestId('cs-session') as any).__markedByThisTest).toBe(true); + }); +}); diff --git a/ui/components/TopicPage/TopicPage.tsx b/ui/components/TopicPage/TopicPage.tsx index ddd3e2fb6..33271a953 100644 --- a/ui/components/TopicPage/TopicPage.tsx +++ b/ui/components/TopicPage/TopicPage.tsx @@ -125,7 +125,10 @@ const TopicPage: React.FC = (props) => { extraCrumbs = extraCrumbs.concat([{ type: 'link', id: 'subscriptions', value: 'Subscriptions' }]); } - const key = `${props.tenant}-${props.namespace}-${props.topic}`; + // The persistency belongs in here: `persistent://t/n/x` and `non-persistent://t/n/x` can both + // exist, and they are different topics. Without the scheme, navigating between them left every + // child of this page mounted - the consumer session kept consuming the topic just left. + const key = `${props.topicPersistency}-${props.tenant}-${props.namespace}-${props.topic}`; let buttons: ToolbarButtonProps[] = [ { diff --git a/ui/components/app/contexts/Notifications.test.tsx b/ui/components/app/contexts/Notifications.test.tsx new file mode 100644 index 000000000..2d73f2bce --- /dev/null +++ b/ui/components/app/contexts/Notifications.test.tsx @@ -0,0 +1,28 @@ +/** + * @jest-environment jsdom + */ +import { defaultValue } from './Notifications'; + +// Four call sites do `const res = await call().catch(err => notifyError(...))` and then branch on +// `res === undefined`. That guard is only sound if the notifier genuinely returns undefined. +// +// These were once bare arrows returning `toast.*(...)`, i.e. react-toastify's Id. TypeScript allows a +// value-returning function where `=> void` is declared, so the compiler could not catch it, and the +// consequence showed up far away: `res` held a toast id, the undefined check passed straight through, +// and the next line threw "res.getStatus is not a function" - crashing the component instead of +// showing the error it was trying to report. +// +// Asserted directly because the end-to-end symptom is timing-dependent and an unreliable detector: +// with the bug reintroduced a full jest run still reported 217 passed while logging the TypeError. +describe('notification helpers return void', () => { + const notifiers = [ + ['notifySuccess', defaultValue.notifySuccess], + ['notifyInfo', defaultValue.notifyInfo], + ['notifyWarn', defaultValue.notifyWarn], + ['notifyError', defaultValue.notifyError], + ] as const; + + it.each(notifiers)('%s returns undefined so `res === undefined` guards hold', (_name, notify) => { + expect(notify('a message')).toBeUndefined(); + }); +}); diff --git a/ui/components/app/contexts/Notifications.tsx b/ui/components/app/contexts/Notifications.tsx index 2f3fee9d1..72a921bf1 100644 --- a/ui/components/app/contexts/Notifications.tsx +++ b/ui/components/app/contexts/Notifications.tsx @@ -40,11 +40,18 @@ const withCopyButton = (content: ReactNode) => { } const isShortTimeout = 100; -const defaultValue: Value = { - notifySuccess: (content, notificationId, isShort) => toast.success(withCopyButton(content), { containerId: toastContainerId, toastId: notificationId || content?.toString(), autoClose: isShort ? isShortTimeout : undefined }), - notifyInfo: (content, notificationId, isShort) => toast.info(withCopyButton(content), { containerId: toastContainerId, toastId: notificationId || content?.toString(), autoClose: isShort ? isShortTimeout : undefined }), - notifyWarn: (content, notificationId, isShort) => toast.warn(withCopyButton(content), { containerId: toastContainerId, toastId: notificationId || content?.toString(), autoClose: isShort ? isShortTimeout : undefined }), - notifyError: (content, notificationId, isShort) => toast.error(withCopyButton(content), { containerId: toastContainerId, toastId: notificationId || content?.toString(), autoClose: isShort ? isShortTimeout : undefined }), + +// Each body is braced so it genuinely returns undefined, matching the `=> void` above. +// As bare expressions these returned react-toastify's Id, and TypeScript permits a +// value-returning function where `void` is declared - so nothing caught it. Callers do +// `const res = await call().catch(err => notifyError(...))` and then test `res === undefined`; +// with an Id coming back that guard silently failed and the next line threw +// "res.getStatus is not a function", crashing the component instead of showing the error. +export const defaultValue: Value = { + notifySuccess: (content, notificationId, isShort) => { toast.success(withCopyButton(content), { containerId: toastContainerId, toastId: notificationId || content?.toString(), autoClose: isShort ? isShortTimeout : undefined }); }, + notifyInfo: (content, notificationId, isShort) => { toast.info(withCopyButton(content), { containerId: toastContainerId, toastId: notificationId || content?.toString(), autoClose: isShort ? isShortTimeout : undefined }); }, + notifyWarn: (content, notificationId, isShort) => { toast.warn(withCopyButton(content), { containerId: toastContainerId, toastId: notificationId || content?.toString(), autoClose: isShort ? isShortTimeout : undefined }); }, + notifyError: (content, notificationId, isShort) => { toast.error(withCopyButton(content), { containerId: toastContainerId, toastId: notificationId || content?.toString(), autoClose: isShort ? isShortTimeout : undefined }); }, }; const Context = React.createContext(defaultValue); diff --git a/ui/components/app/pulsar-auth/Editor/Editor.test.tsx b/ui/components/app/pulsar-auth/Editor/Editor.test.tsx new file mode 100644 index 000000000..93659526d --- /dev/null +++ b/ui/components/app/pulsar-auth/Editor/Editor.test.tsx @@ -0,0 +1,123 @@ +/** + * @jest-environment jsdom + * @jest-environment-options {"customExportConditions": ["node"]} + * + * The credential list's "Set as current" and "Delete" actions POST to /pulsar-auth/use|delete. The + * server now answers 404 for a name it does not know - e.g. a row the browser still shows after the + * store changed under it. Those handlers only guarded a REJECTED fetch (`.catch`); a resolved but + * non-OK response slipped through, so the click did nothing and said nothing. A non-OK status must + * surface, the way the Add flow (CredentialsEditor) already does. + */ +const mockClients = { current: undefined as unknown }; +jest.mock('../../contexts/GrpcClient/GrpcClient', () => ({ + useContext: () => mockClients.current, +})); + +const mockNotifications = { current: undefined as unknown }; +jest.mock('../../../app/contexts/Notifications', () => ({ + useContext: () => mockNotifications.current, +})); + +const mockAppContext = { current: undefined as unknown }; +jest.mock('../../../app/contexts/AppContext', () => ({ + useContext: () => mockAppContext.current, +})); + +import React from 'react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { SWRConfig } from 'swr'; +import Editor from './Editor'; +import { + GetMaskedCredentialsResponse, + GetCurrentCredentialsResponse, + MaskedCredentials, + CredentialsType, +} from '../../../../grpc-web/tools/teal/pulsar/ui/api/v1/pulsar_auth_pb'; +import { StringValue } from 'google-protobuf/google/protobuf/wrappers_pb'; + +const listResponse = () => { + const res = new GetMaskedCredentialsResponse(); + const cred = new MaskedCredentials(); + cred.setName('cred-a'); + cred.setType(CredentialsType.CREDENTIALS_TYPE_JWT); + res.setCredentialsList([cred]); + return res; +}; + +const currentResponse = () => { + const res = new GetCurrentCredentialsResponse(); + res.setName(new StringValue().setValue('Default')); + return res; +}; + +const notFound = () => + Promise.resolve({ ok: false, status: 404, text: () => Promise.resolve('unknown credentials name') } as unknown as Response); +const okResponse = () => + Promise.resolve({ ok: true, status: 200, text: () => Promise.resolve('') } as unknown as Response); + +const makeHarness = (fetchImpl: () => Promise) => { + const notifyError = jest.fn(); + mockClients.current = { + pulsarAuthServiceClient: { + getMaskedCredentials: () => Promise.resolve(listResponse()), + getCurrentCredentials: () => Promise.resolve(currentResponse()), + }, + }; + mockNotifications.current = { notifyError, notifySuccess: jest.fn(), notifyInfo: jest.fn(), notifyWarn: jest.fn() }; + mockAppContext.current = { config: { publicBaseUrl: '' } }; + (global as any).fetch = jest.fn(fetchImpl); + return { notifyError }; +}; + +const renderEditor = async () => { + await act(async () => { + render( + // A fresh cache per render, so the global `mutate` in the handlers never bleeds between tests. + new Map(), shouldRetryOnError: false, dedupingInterval: 0, revalidateOnFocus: false }}> + + + ); + }); +}; + +describe('a resolved non-OK response to a use/delete action is surfaced', () => { + it('surfaces a 404 from "Set as current"', async () => { + const { notifyError } = makeHarness(notFound); + await renderEditor(); + + const button = await screen.findByTestId('credentials-set-current'); + await act(async () => { + fireEvent.click(button); + }); + + await waitFor(() => expect(notifyError).toHaveBeenCalled()); + expect(String(notifyError.mock.calls[0][0])).toContain('404'); + }); + + it('surfaces a 404 from "Delete"', async () => { + const { notifyError } = makeHarness(notFound); + await renderEditor(); + + const button = await screen.findByTestId('credentials-delete'); + await act(async () => { + fireEvent.click(button); + }); + + await waitFor(() => expect(notifyError).toHaveBeenCalled()); + expect(String(notifyError.mock.calls[0][0])).toContain('404'); + }); + + it('says nothing when the action succeeds - the toast is for failures only', async () => { + // The counterpart: "surface non-OK" is trivially satisfiable by shouting on every click. + const { notifyError } = makeHarness(okResponse); + await renderEditor(); + + const button = await screen.findByTestId('credentials-set-current'); + await act(async () => { + fireEvent.click(button); + await Promise.resolve(); + }); + + expect(notifyError).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/components/app/pulsar-auth/Editor/Editor.tsx b/ui/components/app/pulsar-auth/Editor/Editor.tsx index ac15ea778..f3f6be9f4 100644 --- a/ui/components/app/pulsar-auth/Editor/Editor.tsx +++ b/ui/components/app/pulsar-auth/Editor/Editor.tsx @@ -96,8 +96,16 @@ const Editor: React.FC = (props) => { testId="credentials-set-current" type='regular' onClick={async () => { - await fetch(`${config.publicBaseUrl}/pulsar-auth/use/${encodeURIComponent(item.name)}`, { method: 'POST' }) - .catch((err) => notifyError(`Unable to set current credentials: ${err}`)); + const res = await fetch(`${config.publicBaseUrl}/pulsar-auth/use/${encodeURIComponent(item.name)}`, { method: 'POST' }) + .catch((err) => { + notifyError(`Unable to set current credentials: ${err}`); + return undefined; + }); + // A resolved response can still be an error (e.g. 404 for a name the + // server no longer knows); the .catch above only handles a rejected call. + if (res !== undefined && !res.ok) { + notifyError(`Unable to set current credentials. ${res.status}: ${await res.text()}`); + } await mutate(swrKeys.pulsar.auth.credentials._()); await mutate(swrKeys.pulsar.auth.credentials.current._()); }} @@ -107,8 +115,16 @@ const Editor: React.FC = (props) => { testId="credentials-delete" type='danger' onClick={async () => { - await fetch(`${config.publicBaseUrl}/pulsar-auth/delete/${encodeURIComponent(item.name)}`, { method: 'POST' }) - .catch((err) => notifyError(`Unable to delete credentials: ${err}`)); + const res = await fetch(`${config.publicBaseUrl}/pulsar-auth/delete/${encodeURIComponent(item.name)}`, { method: 'POST' }) + .catch((err) => { + notifyError(`Unable to delete credentials: ${err}`); + return undefined; + }); + // A resolved response can still be an error (e.g. 404 for a name the + // server no longer knows); the .catch above only handles a rejected call. + if (res !== undefined && !res.ok) { + notifyError(`Unable to delete credentials. ${res.status}: ${await res.text()}`); + } await mutate(swrKeys.pulsar.auth.credentials._()); await mutate(swrKeys.pulsar.auth.credentials.current._()); }} diff --git a/ui/components/conversions/conversions.spec.ts b/ui/components/conversions/conversions.spec.ts new file mode 100644 index 000000000..43728fc1e --- /dev/null +++ b/ui/components/conversions/conversions.spec.ts @@ -0,0 +1,75 @@ +import { hexStringFromByteArray, hexStringToByteArray } from "./conversions"; + +/** + * Regression: the shared hex parser used for binary/hex message input (message ids, "Start + * from" positions, the producer's `bytes-hex` value) accepted malformed input and silently produced + * bytes for it, because it fed every 2-char slice through `parseInt(_, 16)` and assigned the result + * into a `Uint8Array` - `NaN` becomes 0, a negative becomes its two's complement, and `parseInt` + * happily stops at the first non-hex character instead of failing. + * + * The `invalidCases` table below records the exact bytes each input produced BEFORE the fix. + */ +describe("hexStringToByteArray", () => { + const validCases: { name: string; input: string; bytes: number[] }[] = [ + { name: "empty string", input: "", bytes: [] }, + { name: "whitespace only", input: " ", bytes: [] }, + { name: "single byte", input: "a1", bytes: [0xa1] }, + { name: "packed bytes ('hex-no-space' rendering)", input: "a1b2d3", bytes: [0xa1, 0xb2, 0xd3] }, + { name: "space separated bytes ('hex-with-space' rendering)", input: "a1 b2 d3", bytes: [0xa1, 0xb2, 0xd3] }, + { name: "upper case digits", input: "A1B2", bytes: [0xa1, 0xb2] }, + { name: "surrounding whitespace", input: " a1b2 ", bytes: [0xa1, 0xb2] }, + { name: "newline separated bytes", input: "a1\nb2", bytes: [0xa1, 0xb2] }, + ]; + + it.each(validCases)("accepts $name", ({ input, bytes }) => { + expect(hexStringToByteArray(input)).toEqual(Uint8Array.from(bytes)); + }); + + it("round-trips both rendering styles produced by hexStringFromByteArray", () => { + const bytes = Uint8Array.from([0x00, 0x0f, 0xa1, 0xff]); + expect(hexStringToByteArray(hexStringFromByteArray(bytes, "hex-no-space"))).toEqual(bytes); + expect(hexStringToByteArray(hexStringFromByteArray(bytes, "hex-with-space"))).toEqual(bytes); + }); + + // `producedBefore` documents the corrupt output of the pre-fix parser for that exact input. + const invalidCases: { input: string; producedBefore: string }[] = [ + { input: "zz", producedBefore: "[0]" }, + { input: "gg", producedBefore: "[0]" }, + { input: "1g", producedBefore: "[1]" }, + { input: "g1", producedBefore: "[0]" }, + { input: "z1z2", producedBefore: "[0, 0]" }, + { input: "a1b2!!", producedBefore: "[161, 178, 0]" }, + { input: "0x", producedBefore: "[0]" }, + { input: "0xff", producedBefore: "[0, 255]" }, + { input: "-1", producedBefore: "[255]" }, + { input: "+1", producedBefore: "[1]" }, + { input: "Infinity", producedBefore: "[0, 15, 0, 0]" }, + // Whitespace was stripped everywhere, so a pair split across a space was silently regrouped. + { input: "a 1b 2", producedBefore: "[161, 178]" }, + ]; + + it.each(invalidCases)("rejects $input instead of silently producing $producedBefore", ({ input }) => { + expect(() => hexStringToByteArray(input)).toThrow(Error); + }); + + // Odd-length input was already rejected, but by throwing a bare string literal - so callers doing + // `catch (err) { err.message }` or `err instanceof Error` got nothing usable. + const oddLengthCases = ["a", "abc", "a1b"]; + + it.each(oddLengthCases)("rejects odd-length %s with a real Error", (input) => { + expect(() => hexStringToByteArray(input)).toThrow(Error); + }); + + it("throws Error instances, never bare strings", () => { + for (const input of [...invalidCases.map((c) => c.input), ...oddLengthCases]) { + let thrown: unknown = undefined; + try { + hexStringToByteArray(input); + } catch (err) { + thrown = err; + } + expect(thrown).toBeInstanceOf(Error); + expect(String((thrown as Error).message)).not.toHaveLength(0); + } + }); +}); diff --git a/ui/components/conversions/conversions.tsx b/ui/components/conversions/conversions.tsx index 31b1cbc35..35601061b 100644 --- a/ui/components/conversions/conversions.tsx +++ b/ui/components/conversions/conversions.tsx @@ -1,8 +1,23 @@ +const hexGroupRegExp = /^[0-9a-fA-F]+$/; + export function hexStringToByteArray(hexString: string): Uint8Array { - const normalizedHexString = hexString.replace(/\s/g, ''); - if (normalizedHexString.length % 2 !== 0) { - throw "Must have an even number of hex digits to convert to bytes"; + // Byte pairs may be separated by whitespace - that is how hexStringFromByteArray renders + // 'hex-with-space'. A pair itself must not be split though, so validate group by group instead of + // stripping all whitespace up front: otherwise "a 1b 2" silently regroups into different bytes. + const groups = hexString.split(/\s+/).filter(group => group.length > 0); + + for (const group of groups) { + if (!hexGroupRegExp.test(group)) { + // parseInt() would otherwise stop at the first non-hex character or yield NaN, and the value + // assigned into a Uint8Array would silently become some other byte. + throw new Error(`Invalid hex string: "${group}" is not a hex number.`); + } + if (group.length % 2 !== 0) { + throw new Error(`Invalid hex string: "${group}" must have an even number of hex digits to convert to bytes.`); + } } + + const normalizedHexString = groups.join(''); var numBytes = normalizedHexString.length / 2; var byteArray = new Uint8Array(numBytes); for (var i = 0; i < numBytes; i++) { diff --git a/ui/components/local-storage-keys.ts b/ui/components/local-storage-keys.ts index 272f70b77..67e0645f7 100644 --- a/ui/components/local-storage-keys.ts +++ b/ui/components/local-storage-keys.ts @@ -2,5 +2,31 @@ export const localStorageKeys = { messageExportConfig: "messageExportConfig", autoRefresh: "autoRefresh", defaultMessageFilterType: "defaultMessageFilterType", - isHidePartitionedTopics: "isHidePartitionedTopics" + isHidePartitionedTopics: "isHidePartitionedTopics", + /** + * Whether the consumer session polls the per-topic debug view (the "Topic Positions" tab). + * + * DELIBERATELY NOT PART OF THE SESSION CONFIG. It is a property of this browser, not of the + * session: saving it into a library item would carry one person's debugging preference to + * everyone who later opened that session, and would make two otherwise identical sessions + * compare as different. + * + * OFF BY DEFAULT because each refresh costs three admin round trips PER PARTITION. + */ + /** + * Cap on messages per second a consumer session DELIVERS, browser-wide. 0 = unlimited. + * + * Rides each Resume request (like `include_consumer_stats`), NEVER the session config: the + * number belongs to the browser doing the watching, so it must not travel with a saved session + * into a library item. Applied when a session starts or resumes. + */ + consumerSessionRateLimit: "consumerSessionRateLimit", + /** + * Auto-pause the consumer session each time this many MORE messages have loaded. 0 = off. + * + * Purely client-side - the same Pause the toolbar button sends, triggered by the loaded + * counter - and browser-wide for the same reason as the rate limit above. Re-arms on every + * resume, so Play works as "load the next n". + */ + consumerSessionPauseAfterLoaded: "consumerSessionPauseAfterLoaded" } as const; diff --git a/ui/components/ui/CodeEditor/CodeEditor.tsx b/ui/components/ui/CodeEditor/CodeEditor.tsx index 550c46f4c..77f52b7a2 100644 --- a/ui/components/ui/CodeEditor/CodeEditor.tsx +++ b/ui/components/ui/CodeEditor/CodeEditor.tsx @@ -5,6 +5,15 @@ import { IRange } from 'monaco-editor'; import s from './CodeEditor.module.css'; +// @monaco-editor/react fetches Monaco at runtime through its own AMD loader, and its default base +// is cdn.jsdelivr.net - so every code editor here depended on the public internet: seconds of +// third-party network before the first editor mounts, and no editor at all when jsdelivr is +// unreachable (offline, air-gapped, or blocked). Dekaf serves the identical files itself +// (ui/build.js copies monaco-editor/min/vs next to the bundle), so resolve them from our own +// origin. Absolute-ised against document.baseURI because the loader also builds worker URLs from +// this value, and those are not resolved against the page's . +loader.config({ paths: { vs: new URL('ui/static/dist/vs', document.baseURI).toString() } }); + export type Dependencies = { label: string, documentation: string, diff --git a/ui/components/ui/ConsumerSession/Console/Console.tsx b/ui/components/ui/ConsumerSession/Console/Console.tsx index 37ae22c76..beb2c5b08 100644 --- a/ui/components/ui/ConsumerSession/Console/Console.tsx +++ b/ui/components/ui/ConsumerSession/Console/Console.tsx @@ -7,6 +7,7 @@ import Tabs, { Tab } from '../../Tabs/Tabs'; import s from './Console.module.css' import DebugLogs from './ContextLogs/ContextLogs'; import ExpressionInspector from './ContextRepl/ContextRepl'; +import TopicPositions from './TopicPositions/TopicPositions'; import { LibraryContext } from '../../LibraryBrowser/model/library-context'; export type ConsoleProps = { @@ -23,16 +24,40 @@ export type ConsoleProps = { libraryContext: LibraryContext; }; -type TabKey = 'producer' | 'visualize' | 'context-logs' | 'context-repl' | 'export'; +type TabKey = 'producer' | 'visualize' | 'context-logs' | 'context-repl' | 'export' | 'topic-positions'; const Console: React.FC = (props) => { - const [activeTab, setActiveTab] = React.useState('producer'); + // Topic Positions leads and is the default: it is the tab that answers "where am I?", which + // is the first question on an open session - and unlike 'producer' it exists on EVERY page + // (the Produce tab only renders on topic pages, so a producer default pointed at a missing + // tab everywhere else). + const [activeTab, setActiveTab] = React.useState('topic-positions'); if (props.sessionConfig === undefined) { return null; } - let tabs: Tab[] = []; + let tabs: Tab[] = [ + { + key: 'topic-positions', + title: 'Topic Positions', + testId: 'console-tab-topic-positions', + // Rendered always, like its siblings - but it is handed `isVisible` and polls nothing + // while hidden, which is what keeps a debug view off the broker for everyone not looking + // at it. + isRenderAlways: true, + render: () => ( + + ) + } + ]; if (props.libraryContext.pulsarResource.type === 'topic') { tabs = tabs.concat([{ @@ -58,6 +83,7 @@ const Console: React.FC = (props) => { } tabs = tabs.concat([ + { key: 'context-repl', title: 'Context REPL', diff --git a/ui/components/ui/ConsumerSession/Console/Producer/lib/lib.spec.ts b/ui/components/ui/ConsumerSession/Console/Producer/lib/lib.spec.ts index c56070ad7..3f0bf21c4 100644 --- a/ui/components/ui/ConsumerSession/Console/Producer/lib/lib.spec.ts +++ b/ui/components/ui/ConsumerSession/Console/Producer/lib/lib.spec.ts @@ -45,4 +45,38 @@ describe("valueToBytes", () => { ); } ); + + // The hex parser REFUSES malformed input rather than silently assigning some other byte - it + // throws. This function advertises an Either, and its caller checks `isRight` inside an async + // click handler with no try around it, so a throw here rejects the handler: no toast, no message + // sent, nothing on screen to say why. + const invalidHexTestCases: { hex: string; why: string }[] = [ + { hex: "zz", why: "not hex digits at all" }, + { hex: "a1 zz", why: "one bad group among good ones" }, + { hex: "a1b", why: "an odd number of digits is half a byte" }, + { hex: "0xa1", why: "a JavaScript literal is not a hex byte string" }, + { hex: "a1,b2", why: "only whitespace separates bytes" }, + ]; + + it.each(invalidHexTestCases)( + "should return an error, not throw, when the hex string is invalid ($why)", + ({ hex }) => { + expect(() => valueToBytes(hex, "bytes-hex")).not.toThrow(); + expect(Either.isLeft(valueToBytes(hex, "bytes-hex"))).toBe(true); + } + ); + + it("explains what was wrong with the hex it refused", () => { + const got = valueToBytes("a1b", "bytes-hex"); + + pipe( + got, + Either.match( + (err) => expect(String(err.message)).toMatch(/hex/i), + () => { + throw new Error("should return an error, but bytes where returned"); + } + ) + ); + }); }); diff --git a/ui/components/ui/ConsumerSession/Console/Producer/lib/lib.ts b/ui/components/ui/ConsumerSession/Console/Producer/lib/lib.ts index 439639eb0..f9756abd8 100644 --- a/ui/components/ui/ConsumerSession/Console/Producer/lib/lib.ts +++ b/ui/components/ui/ConsumerSession/Console/Producer/lib/lib.ts @@ -21,8 +21,15 @@ export function valueToBytes(value: string, valueType: ValueType): Either.Either return Either.right(bytes); }; case 'bytes-hex': { - const bytes = hexStringToByteArray(value); - return Either.right(bytes); + // The parser REFUSES malformed hex by throwing rather than quietly writing some other byte. + // This function advertises an Either and its caller inspects it inside an async click + // handler with no try of its own, so an escaping throw rejected that handler: no message + // sent, and no toast either - the click simply did nothing. + try { + return Either.right(hexStringToByteArray(value)); + } catch (err) { + return Either.left(err as Error); + } }; } } diff --git a/ui/components/ui/ConsumerSession/Console/TopicPositions/TopicPositions.module.css b/ui/components/ui/ConsumerSession/Console/TopicPositions/TopicPositions.module.css new file mode 100644 index 000000000..9e3100219 --- /dev/null +++ b/ui/components/ui/ConsumerSession/Console/TopicPositions/TopicPositions.module.css @@ -0,0 +1,42 @@ +.TopicPositions { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; +} + +/* The table itself sits flush with the panel; only the text states carry padding. */ +.Empty, +.Error { + color: var(--text-color-secondary, #666); + padding: 8rem 12rem; +} + +.Error { + color: var(--error-color, #b00020); +} + +/* The table is wide by nature - eleven columns of ids and timestamps - so it scrolls inside its + own box rather than making the console scroll sideways. The shared Table sizes itself with + flex (its scroll container is flex: 1), so this wrap must be a flex column with a definite + height - as a plain block the scroll container collapses to 0 and no rows render. */ +.TableWrap { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.Id { + font-family: monospace; +} + +.Unavailable { + color: var(--text-color-secondary, #666); + font-style: italic; +} + +.AggregateLabel { + font-weight: 600; +} diff --git a/ui/components/ui/ConsumerSession/Console/TopicPositions/TopicPositions.test.tsx b/ui/components/ui/ConsumerSession/Console/TopicPositions/TopicPositions.test.tsx new file mode 100644 index 000000000..d46e81bbd --- /dev/null +++ b/ui/components/ui/ConsumerSession/Console/TopicPositions/TopicPositions.test.tsx @@ -0,0 +1,158 @@ +/** + * @jest-environment jsdom + * @jest-environment-options {"customExportConditions": ["node"]} + * + * The Topic Positions tab as a component, now built on the shared Table: what it polls, when it + * polls at all, and what it renders for each answer. The loader's TRANSITIONS (keep-last-good, + * error recovery, vanished session) are pinned in topic-positions.spec.ts against the extracted + * loader; here the wiring is pinned - gating by tab visibility and session state, the deadline + * on the RPC, the aggregate row reaching the screen. + */ +const mockClients = { current: undefined as unknown }; +jest.mock('../../../../app/contexts/GrpcClient/GrpcClient', () => ({ + useContext: () => mockClients.current +})); + +// react-virtuoso (inside the shared Table) measures itself with ResizeObserver, which jsdom does +// not provide. A no-op stand-in is enough: nothing here asserts on measured sizes. +class ResizeObserverStub { + observe(): void {} + unobserve(): void {} + disconnect(): void {} +} +(globalThis as { ResizeObserver?: unknown }).ResizeObserver = + (globalThis as { ResizeObserver?: unknown }).ResizeObserver ?? ResizeObserverStub; + +import React from 'react'; +import { act, cleanup, render, screen, waitFor } from '@testing-library/react'; +import { SWRConfig } from 'swr'; +import TopicPositions from './TopicPositions'; +import { Code } from '../../../../../grpc-web/google/rpc/code_pb'; + +// With jest.mock in the file, esbuild-jest runs babel's hoisting over untyped JS, so imported +// bindings must not appear in type annotations here (the lifecycle suite documents the same +// constraint) - hence the inlined literal union instead of the imported SessionState. +type SessionStateLiteral = 'new' | 'initializing' | 'running' | 'pausing' | 'paused'; + +const statusOf = (code: number, message = '') => ({ + getCode: () => code, + getMessage: () => message +}); + +type FakePosition = { + getTopicFqn: () => string; + getFirstMessageId: () => undefined; + getFirstPublishTime: () => { getValue: () => number } | undefined; + getLastMessageId: () => undefined; + getLastPublishTime: () => { getValue: () => number } | undefined; + getCursorMessageId: () => undefined; + getCursorPublishTime: () => { getValue: () => number } | undefined; + getCursorTimeFraction: () => undefined; + getCursorEntryFraction: () => undefined; + getCursorEntryOrdinal: () => { getValue: () => number } | undefined; + getRetainedEntries: () => { getValue: () => number } | undefined; + getUnavailableReason: () => undefined; +}; + +const position = (fqn: string, over: { first?: number; last?: number; cursor?: number; ordinal?: number; retained?: number } = {}): FakePosition => ({ + getTopicFqn: () => fqn, + getFirstMessageId: () => undefined, + getFirstPublishTime: () => (over.first === undefined ? undefined : { getValue: () => over.first as number }), + getLastMessageId: () => undefined, + getLastPublishTime: () => (over.last === undefined ? undefined : { getValue: () => over.last as number }), + getCursorMessageId: () => undefined, + getCursorPublishTime: () => (over.cursor === undefined ? undefined : { getValue: () => over.cursor as number }), + getCursorTimeFraction: () => undefined, + getCursorEntryFraction: () => undefined, + getCursorEntryOrdinal: () => (over.ordinal === undefined ? undefined : { getValue: () => over.ordinal as number }), + getRetainedEntries: () => (over.retained === undefined ? undefined : { getValue: () => over.retained as number }), + getUnavailableReason: () => undefined +}); + +const answer = (code: number, message: string, positions: FakePosition[]) => ({ + getStatus: () => statusOf(code, message), + getPositionsList: () => positions +}); + +const withClient = (getTopicPositions: jest.Mock) => { + mockClients.current = { consumerServiceClient: { getTopicPositions } }; + return getTopicPositions; +}; + +afterEach(() => { + cleanup(); + window.localStorage.clear(); +}); + +const renderTab = async (sessionState: SessionStateLiteral, isVisible = true) => { + await act(async () => { + render( + // A FRESH SWR cache per test: the Table polls through useSWR, and a shared cache would leak + // one test's rows into the next. + new Map(), dedupingInterval: 0, revalidateOnFocus: false }}> + + + ); + }); +}; + +describe('when the tab may not poll', () => { + it('session not started: no RPC at all - nothing exists to ask about', async () => { + // The poll is simply not armed before Play, which is cheaper than polling into a server + // refusal and cannot leak an internal session name. + const rpc = withClient(jest.fn()); + await renderTab('new'); + + expect(screen.getByTestId('topic-positions-not-started')).toBeTruthy(); + expect(rpc).not.toHaveBeenCalled(); + }); + + it('hidden tab: no polling - only the tab on screen pays the broker cost', async () => { + const rpc = withClient(jest.fn()); + await renderTab('running', false); + + expect(rpc).not.toHaveBeenCalled(); + }); +}); + +describe('a polling tab', () => { + it('renders every topic plus the ALL TOPICS aggregate, and asks with a DEADLINE', async () => { + const rpc = withClient( + jest.fn().mockResolvedValue( + answer(Code.OK, '', [ + position('persistent://t/n/a', { first: 1000, last: 2000, cursor: 1500, ordinal: 5, retained: 10 }), + position('persistent://t/n/b', { first: 1200, last: 2400, cursor: 1300, ordinal: 2, retained: 10 }) + ]) + ) + ); + await renderTab('running'); + + // Virtuoso does not lay rows out in a zero-height jsdom viewport, so the ROWS are the e2e + // suite's job (CS-TP-3/6 assert them against a real browser). What jsdom CAN pin is the + // Table's own accounting: two topics PLUS the aggregate = 3, proving the aggregate was + // prepended and the data arrived. + // The count is split across elements, so match on the flattened text. + await waitFor(() => expect(document.body.textContent).toContain('3 of 3 topics')); + + // The deadline is what keeps one hung RPC from wedging the poll forever. + const opts = rpc.mock.calls[0][1]; + expect(opts?.deadline).toBeDefined(); + }); + + it('a session that vanished mid-run empties the table without a crash or an error banner', async () => { + withClient(jest.fn().mockResolvedValue(answer(Code.FAILED_PRECONDITION, 'no such session', []))); + await renderTab('running'); + + await waitFor(() => expect(screen.queryByTestId('topic-positions-error')).toBeNull()); + expect(document.body.textContent).not.toContain('no such session'); + }); + + it('a transport failure surfaces as the stale-data banner, not a throw', async () => { + withClient(jest.fn().mockRejectedValue(new Error('connection refused'))); + await renderTab('running'); + + await waitFor(() => expect(screen.getByTestId('topic-positions-error')).toBeTruthy()); + expect(screen.getByTestId('topic-positions-error').textContent).toContain('connection refused'); + expect(screen.getByTestId('topic-positions-error').textContent).toContain('showing the last data'); + }); +}); diff --git a/ui/components/ui/ConsumerSession/Console/TopicPositions/TopicPositions.tsx b/ui/components/ui/ConsumerSession/Console/TopicPositions/TopicPositions.tsx new file mode 100644 index 000000000..51be58c08 --- /dev/null +++ b/ui/components/ui/ConsumerSession/Console/TopicPositions/TopicPositions.tsx @@ -0,0 +1,252 @@ +import React from 'react'; + +import * as GrpcClient from '../../../../app/contexts/GrpcClient/GrpcClient'; +import * as pb from '../../../../../grpc-web/tools/teal/pulsar/ui/api/v1/consumer_pb'; +import { Code } from '../../../../../grpc-web/google/rpc/code_pb'; +import { createDeadline } from '../../../../../proto-utils/proto-utils'; +import { SessionState } from '../../types'; +import Table, { Columns, ColumnsConfig } from '../../../Table/Table'; +import { + TopicPositionRow, + allTopicsLabel, + behindMsOf, + formatDurationMs, + formatEntryCount, + formatFraction, + formatTimestamp, + makePositionsLoader, + noValue, + topicPositionFromPb +} from './topic-positions'; +import s from './TopicPositions.module.css'; + +export type TopicPositionsProps = { + consumerName: string; + sessionState: SessionState; + /** The tab is mounted while hidden, so it has to be told - polling only runs on screen. */ + isVisible: boolean; +}; + +type ColumnKey = + | 'topic' + | 'firstMessage' + | 'firstPublished' + | 'lastMessage' + | 'lastPublished' + | 'cursorMessage' + | 'behind' + | 'timeFraction' + | 'entryFraction' + | 'entriesRead' + | 'entriesLeft'; + +/** Comparators here are plain and direction-blind; keeping the aggregate row on top under every + * sort is the Table's job (`pinFirst`) - a comparator cannot do it, because 'desc' is the + * reverse of the sorted array and would flip any comparator-based pin to the bottom. */ +const byRow = ( + compare: (a: TopicPositionRow, b: TopicPositionRow) => number +) => (a: { data: TopicPositionRow }, b: { data: TopicPositionRow }): number => compare(a.data, b.data); + +const compareNumbers = (a: number | undefined, b: number | undefined): number => { + if (a === undefined && b === undefined) { + return 0; + } + // Unknown sorts last, so the rows with a real reading cluster at the top. + if (a === undefined) { + return 1; + } + if (b === undefined) { + return -1; + } + return a - b; +}; + +const isAggregateRow = (row: TopicPositionRow): boolean => row.isAggregate === true; + +const mono = (v: string | undefined) => {v ?? noValue}; + +const TopicPositions: React.FC = (props) => { + const { consumerServiceClient } = GrpcClient.useContext(); + // The loader reports here instead of throwing (the Table would toast on every render). When + // rows are still shown, they are the LAST GOOD ones - the banner says so. + const [loadError, setLoadError] = React.useState(undefined); + + // Polling is gated by exactly two things: the TAB being the one on screen, and the session + // existing at all. Opening the tab IS the request - the per-partition broker cost only runs + // while somebody is looking, and the Table's own auto-refresh toggle is the explicit freeze + // for anyone who wants the numbers to hold still. (A "capture" checkbox used to gate this too; + // it predated the shared-Table rework, guarded nothing the tab-gate does not - the session + // records its read position unconditionally either way - and its name wrongly implied the + // HISTORY started when it was ticked.) + const isSessionStarted = props.sessionState !== 'new' && props.sessionState !== 'initializing'; + const isPolling = props.isVisible && isSessionStarted; + + // One loader per consumer name, so its last-good memory dies with the session it belongs to. + const loader = React.useMemo( + () => + makePositionsLoader({ + okCode: Code.OK, + failedPreconditionCode: Code.FAILED_PRECONDITION, + onError: setLoadError, + fetch: async () => { + const req = new pb.GetTopicPositionsRequest(); + req.setConsumerName(props.consumerName); + // The deadline is what keeps ONE hung RPC from wedging the poll forever: without it, + // a request that never settles left `inFlight` latched and every later refresh skipped. + const res = await consumerServiceClient.getTopicPositions(req, { deadline: createDeadline(8) }); + return { + code: res.getStatus()?.getCode(), + message: res.getStatus()?.getMessage(), + rows: res.getPositionsList().map(topicPositionFromPb) + }; + } + }), + [props.consumerName] + ); + + const columns: Columns = React.useMemo(() => { + // Consumption columns first - how far the session got is what this tab is FOR - then the + // topic's own endpoints. The user can drag any non-sticky column into any order; this is + // only where they start. + const defaultConfig: ColumnsConfig = [ + { columnKey: 'topic', visibility: 'visible', width: 390, stickyTo: 'left' }, + { columnKey: 'entriesRead', visibility: 'visible', width: 130 }, + { columnKey: 'entriesLeft', visibility: 'visible', width: 100 }, + { columnKey: 'entryFraction', visibility: 'visible', width: 100 }, + { columnKey: 'behind', visibility: 'visible', width: 90 }, + { columnKey: 'timeFraction', visibility: 'visible', width: 110 }, + { columnKey: 'firstMessage', visibility: 'visible', width: 150 }, + { columnKey: 'firstPublished', visibility: 'visible', width: 150 }, + { columnKey: 'lastMessage', visibility: 'visible', width: 150 }, + { columnKey: 'lastPublished', visibility: 'visible', width: 150 }, + { columnKey: 'cursorMessage', visibility: 'visible', width: 150 } + ]; + + // A refused topic (non-persistent: the broker cannot examine it) says WHY in its first data + // cell and stays blank elsewhere. + const orReason = (row: TopicPositionRow, render: () => React.ReactNode): React.ReactNode => + row.unavailableReason !== undefined + ? {row.unavailableReason} + : render(); + + return { + defaultConfig, + columns: { + topic: { + title: 'Topic', + render: (row) => ( + {row.topicFqn} + ), + sortFn: byRow((a, b) => a.topicFqn.localeCompare(b.topicFqn)) + }, + firstMessage: { + title: 'First message', + render: (row) => orReason(row, () => mono(row.firstMessageId)), + sortFn: byRow((a, b) => (a.firstMessageId ?? '').localeCompare(b.firstMessageId ?? '')) + }, + firstPublished: { + title: 'First published', + render: (row) => (row.unavailableReason !== undefined ? noValue : formatTimestamp(row.firstPublishTime)), + sortFn: byRow((a, b) => compareNumbers(a.firstPublishTime, b.firstPublishTime)) + }, + lastMessage: { + title: 'Last message', + render: (row) => (row.unavailableReason !== undefined ? noValue : mono(row.lastMessageId)), + sortFn: byRow((a, b) => (a.lastMessageId ?? '').localeCompare(b.lastMessageId ?? '')) + }, + lastPublished: { + title: 'Last published', + render: (row) => (row.unavailableReason !== undefined ? noValue : formatTimestamp(row.lastPublishTime)), + sortFn: byRow((a, b) => compareNumbers(a.lastPublishTime, b.lastPublishTime)) + }, + cursorMessage: { + title: 'Message under cursor', + render: (row) => (row.unavailableReason !== undefined ? noValue : mono(row.cursorMessageId)), + sortFn: byRow((a, b) => (a.cursorMessageId ?? '').localeCompare(b.cursorMessageId ?? '')) + }, + behind: { + title: 'Behind', + render: (row) => (row.unavailableReason !== undefined ? noValue : formatDurationMs(behindMsOf(row))), + sortFn: byRow((a, b) => compareNumbers(behindMsOf(a), behindMsOf(b))) + }, + timeFraction: { + title: '% of time range', + render: (row) => (row.unavailableReason !== undefined ? noValue : formatFraction(row.cursorTimeFraction)), + sortFn: byRow((a, b) => compareNumbers(a.cursorTimeFraction, b.cursorTimeFraction)) + }, + entryFraction: { + title: '% of entries', + render: (row) => (row.unavailableReason !== undefined ? noValue : formatFraction(row.cursorEntryFraction)), + sortFn: byRow((a, b) => compareNumbers(a.cursorEntryFraction, b.cursorEntryFraction)) + }, + entriesRead: { + title: 'Entries read', + render: (row) => (row.unavailableReason !== undefined ? noValue : formatEntryCount(row.cursorEntryOrdinal, row.retainedEntries)), + sortFn: byRow((a, b) => compareNumbers(a.cursorEntryOrdinal, b.cursorEntryOrdinal)) + }, + entriesLeft: { + title: 'Entries left', + render: (row) => + row.unavailableReason !== undefined || row.cursorEntryOrdinal === undefined || row.retainedEntries === undefined + ? noValue + : (row.retainedEntries - row.cursorEntryOrdinal).toLocaleString(), + sortFn: byRow((a, b) => + compareNumbers( + a.cursorEntryOrdinal !== undefined && a.retainedEntries !== undefined ? a.retainedEntries - a.cursorEntryOrdinal : undefined, + b.cursorEntryOrdinal !== undefined && b.retainedEntries !== undefined ? b.retainedEntries - b.cursorEntryOrdinal : undefined + ) + ) + } + }, + help: { + behind: ( + + How far the session's read position trails the newest message, in wall-clock time - the consumer-lag + clock. Large means lagging; small on a quiet topic just means no traffic. + + ), + timeFraction: Where the cursor sits between the first and last publish times., + entryFraction: ( + + The share of STORED ENTRIES already consumed. A batched entry holds many messages, so this tracks + message count only as closely as batch sizes stayed uniform. + + ) + } + }; + }, []); + + return ( +
+ {!isSessionStarted && ( +
+ Start the session to see where each topic begins and ends, and how far it has read. +
+ )} + + {isPolling && loadError !== undefined && ( +
+ {loadError} - showing the last data that loaded. +
+ )} + + {isPolling && ( +
+ + tableId="topic-positions" + size="small" + dataLoader={{ cacheKey: [props.consumerName, 'topic-positions'], loader }} + columns={columns} + getId={(row) => row.topicFqn} + autoRefresh={{ intervalMs: 1000 }} + itemNamePlural="topics" + defaultSort={{ type: 'by-single-column', column: 'topic', direction: 'asc' }} + pinFirst={isAggregateRow} + /> +
+ )} +
+ ); +}; + +export default TopicPositions; diff --git a/ui/components/ui/ConsumerSession/Console/TopicPositions/topic-positions.spec.ts b/ui/components/ui/ConsumerSession/Console/TopicPositions/topic-positions.spec.ts new file mode 100644 index 000000000..e4ff827a8 --- /dev/null +++ b/ui/components/ui/ConsumerSession/Console/TopicPositions/topic-positions.spec.ts @@ -0,0 +1,315 @@ +/** + * @jest-environment jsdom + * @jest-environment-options {"customExportConditions": ["node"]} + * + * The Topic Positions row model and its formatters. + * + * The behaviour worth pinning is the one distinction the whole view rests on: ABSENT is not ZERO. A + * cursor on the first retained entry really is 0% through; a cursor whose ledger has aged out knows + * nothing about where it is. Rendering both as "0.0%" would invent the second, and every unset + * protobuf wrapper is a chance to do exactly that. + */ +import * as pb from '../../../../../grpc-web/tools/teal/pulsar/ui/api/v1/consumer_pb'; +import { + aggregateRow, + allTopicsLabel, + behindMsOf, + formatDurationMs, + makePositionsLoader, + TopicPositionRow, + formatEntryCount, + formatFraction, + formatTimestamp, + noValue, + topicPositionFromPb +} from './topic-positions'; + +describe('formatFraction', () => { + it('renders a real fraction as a percentage, keeping one decimal', () => { + // 99.9% and 100% are different answers - nearly done versus done - and whole percent merges them. + expect(formatFraction(0.999)).toBe('99.9%'); + expect(formatFraction(1)).toBe('100.0%'); + }); + + it('renders a genuine ZERO as 0.0%, because a cursor at the first entry IS at the start', () => { + expect(formatFraction(0)).toBe('0.0%'); + }); + + it('renders UNKNOWN as the placeholder, never as zero', () => { + expect(formatFraction(undefined)).toBe(noValue); + }); + + it('refuses a non-finite fraction rather than printing NaN%', () => { + expect(formatFraction(NaN)).toBe(noValue); + expect(formatFraction(Infinity)).toBe(noValue); + }); +}); + +describe('formatTimestamp', () => { + it('renders an epoch millisecond', () => { + expect(formatTimestamp(0)).not.toBe(noValue); + expect(formatTimestamp(1785152235757)).not.toBe(noValue); + }); + + it('renders UNKNOWN as the placeholder - an empty topic has no first message', () => { + expect(formatTimestamp(undefined)).toBe(noValue); + expect(formatTimestamp(NaN)).toBe(noValue); + }); +}); + +describe('formatEntryCount', () => { + it('shows the arithmetic behind the percentage', () => { + expect(formatEntryCount(50, 100)).toBe('50 / 100'); + }); + + it('needs BOTH halves - an ordinal with no denominator says nothing', () => { + expect(formatEntryCount(50, undefined)).toBe(noValue); + expect(formatEntryCount(undefined, 100)).toBe(noValue); + }); +}); + +describe('topicPositionFromPb', () => { + it('leaves every unset wrapper undefined rather than defaulting it to zero', () => { + // What an EMPTY topic answers with: it was asked successfully and holds nothing, so there is no + // first message, no last message, and no cursor - and no reason either, because nothing failed. + const position = new pb.TopicPosition(); + position.setTopicFqn('persistent://t/n/empty'); + + const row = topicPositionFromPb(position); + + expect(row.topicFqn).toBe('persistent://t/n/empty'); + expect(row.firstMessageId).toBeUndefined(); + expect(row.firstPublishTime).toBeUndefined(); + expect(row.lastMessageId).toBeUndefined(); + expect(row.lastPublishTime).toBeUndefined(); + expect(row.cursorMessageId).toBeUndefined(); + expect(row.cursorPublishTime).toBeUndefined(); + expect(row.cursorTimeFraction).toBeUndefined(); + expect(row.cursorEntryFraction).toBeUndefined(); + expect(row.cursorEntryOrdinal).toBeUndefined(); + expect(row.retainedEntries).toBeUndefined(); + expect(row.unavailableReason).toBeUndefined(); + }); + + it('carries a fraction of exactly 0 through as 0, not as absent', () => { + // The mirror of the test above, and the reason the reader cannot use a falsy check anywhere in + // this path: 0 is a real reading. + const position = new pb.TopicPosition(); + position.setTopicFqn('persistent://t/n/topic'); + const fraction = new (require('google-protobuf/google/protobuf/wrappers_pb').DoubleValue)(); + fraction.setValue(0); + position.setCursorTimeFraction(fraction); + + const row = topicPositionFromPb(position); + + expect(row.cursorTimeFraction).toBe(0); + expect(formatFraction(row.cursorTimeFraction)).toBe('0.0%'); + }); + + it('reads the endpoints, the cursor and both progress figures when they are all present', () => { + const { BytesValue, DoubleValue, Int64Value, StringValue } = require('google-protobuf/google/protobuf/wrappers_pb'); + const position = new pb.TopicPosition(); + position.setTopicFqn('persistent://t/n/topic'); + + const firstId = new BytesValue(); + firstId.setValue(new Uint8Array([1, 2])); + position.setFirstMessageId(firstId); + + const firstTime = new Int64Value(); + firstTime.setValue(1000); + position.setFirstPublishTime(firstTime); + + const timeFraction = new DoubleValue(); + timeFraction.setValue(0.5); + position.setCursorTimeFraction(timeFraction); + + const ordinal = new Int64Value(); + ordinal.setValue(50); + position.setCursorEntryOrdinal(ordinal); + + const retained = new Int64Value(); + retained.setValue(100); + position.setRetainedEntries(retained); + + const row = topicPositionFromPb(position); + + expect(row.firstMessageId).toBeDefined(); + expect(row.firstPublishTime).toBe(1000); + expect(row.cursorTimeFraction).toBe(0.5); + expect(formatEntryCount(row.cursorEntryOrdinal, row.retainedEntries)).toBe('50 / 100'); + }); + + it('reads the unavailable reason - a refused topic is not an empty one', () => { + const { StringValue } = require('google-protobuf/google/protobuf/wrappers_pb'); + const position = new pb.TopicPosition(); + position.setTopicFqn('non-persistent://t/n/topic'); + const reason = new StringValue(); + reason.setValue('Examine messages on a non-persistent topic is not allowed'); + position.setUnavailableReason(reason); + + const row = topicPositionFromPb(position); + + expect(row.unavailableReason).toContain('non-persistent'); + }); +}); + + +describe('behindMsOf - the consumer-lag clock', () => { + it('is last published minus cursor published', () => { + expect(behindMsOf({ lastPublishTime: 5000, cursorPublishTime: 2000 })).toBe(3000); + }); + + it('clamps at zero - a cursor past the recorded end is a stale denominator, not time travel', () => { + expect(behindMsOf({ lastPublishTime: 2000, cursorPublishTime: 5000 })).toBe(0); + }); + + it('is unknown when either side is unknown', () => { + expect(behindMsOf({ lastPublishTime: 5000, cursorPublishTime: undefined })).toBeUndefined(); + expect(behindMsOf({ lastPublishTime: undefined, cursorPublishTime: 2000 })).toBeUndefined(); + }); +}); + +describe('formatDurationMs', () => { + it('reads as humans write durations', () => { + expect(formatDurationMs(500)).toBe('<1s'); + expect(formatDurationMs(45_000)).toBe('45s'); + expect(formatDurationMs(2 * 60_000 + 5_000)).toBe('2m 05s'); + expect(formatDurationMs(2 * 3_600_000 + 5 * 60_000)).toBe('2h 05m'); + expect(formatDurationMs(3 * 86_400_000 + 4 * 3_600_000)).toBe('3d 4h'); + }); + + it('unknown and nonsense are the placeholder', () => { + expect(formatDurationMs(undefined)).toBe('-'); + expect(formatDurationMs(-5)).toBe('-'); + expect(formatDurationMs(NaN)).toBe('-'); + }); +}); + +describe('aggregateRow - the "All topics" line', () => { + const row = (over: Partial): TopicPositionRow => ({ topicFqn: 't', ...over }); + + it('takes the global time range, the WORST lag, and the summed entries', () => { + const a = row({ topicFqn: 'a', firstPublishTime: 1000, lastPublishTime: 9000, cursorPublishTime: 8000, cursorEntryOrdinal: 90, retainedEntries: 100 }); + const b = row({ topicFqn: 'b', firstPublishTime: 2000, lastPublishTime: 10_000, cursorPublishTime: 4000, cursorEntryOrdinal: 10, retainedEntries: 100 }); + + const all = aggregateRow([a, b]); + + expect(all?.topicFqn).toBe(allTopicsLabel); + expect(all?.isAggregate).toBe(true); + expect(all?.firstPublishTime).toBe(1000); + expect(all?.lastPublishTime).toBe(10_000); + // b is 6000ms behind (10000-4000), a only 1000ms (9000-8000... vs global last: a is 2000 + // behind the GLOBAL newest) - the aggregate answers with the WORST: 6000. + expect(behindMsOf(all!)).toBe(6000); + expect(all?.cursorEntryOrdinal).toBe(100); + expect(all?.retainedEntries).toBe(200); + expect(all?.cursorEntryFraction).toBe(0.5); + }); + + it('does not exist for a single topic - a summary of one is noise', () => { + expect(aggregateRow([row({ topicFqn: 'only' })])).toBeUndefined(); + }); + + it('unavailable rows contribute NOTHING, not zeros', () => { + const ok1 = row({ topicFqn: 'a', firstPublishTime: 1000, lastPublishTime: 2000 }); + const ok2 = row({ topicFqn: 'b', firstPublishTime: 1500, lastPublishTime: 3000 }); + const refused = row({ topicFqn: 'np', unavailableReason: 'non-persistent' }); + + const all = aggregateRow([ok1, ok2, refused]); + expect(all?.firstPublishTime).toBe(1000); + expect(all?.lastPublishTime).toBe(3000); + }); + + it('a topic with no cursor leaves the aggregate positions unknown rather than guessed', () => { + const read = row({ topicFqn: 'a', firstPublishTime: 1000, lastPublishTime: 2000, cursorPublishTime: 1500 }); + const unread = row({ topicFqn: 'b', firstPublishTime: 1000, lastPublishTime: 2000 }); + + const all = aggregateRow([read, unread]); + expect(all?.cursorTimeFraction).toBeUndefined(); + expect(behindMsOf(all!)).toBeUndefined(); + }); +}); + +describe('makePositionsLoader - what the polling Table is allowed to see', () => { + const okRow = (fqn: string): TopicPositionRow => ({ topicFqn: fqn, firstPublishTime: 1, lastPublishTime: 2 }); + const OK = 0; + const FAILED_PRECONDITION = 9; + + it('success replaces last-good and prepends the aggregate for multi-topic sessions', async () => { + const onError = jest.fn(); + const loader = makePositionsLoader({ + okCode: OK, + failedPreconditionCode: FAILED_PRECONDITION, + onError, + fetch: async () => ({ code: OK, message: '', rows: [okRow('a'), okRow('b')] }) + }); + + const rows = await loader(); + expect(rows.map((r) => r.topicFqn)).toEqual([allTopicsLabel, 'a', 'b']); + expect(onError).toHaveBeenLastCalledWith(undefined); + }); + + it('a transport failure KEEPS the last-good rows and reports the error - never throws', async () => { + const onError = jest.fn(); + let fail = false; + const loader = makePositionsLoader({ + okCode: OK, + failedPreconditionCode: FAILED_PRECONDITION, + onError, + fetch: async () => { + if (fail) { + throw new Error('connection refused'); + } + return { code: OK, message: '', rows: [okRow('a'), okRow('b')] }; + } + }); + + const good = await loader(); + fail = true; + const afterFailure = await loader(); + + expect(afterFailure).toEqual(good); + expect(String(onError.mock.calls[onError.mock.calls.length - 1][0])).toContain('connection refused'); + }); + + it('a non-OK answer keeps last-good too, and recovery clears the error', async () => { + const onError = jest.fn(); + let mode: 'ok' | 'broken' = 'ok'; + const loader = makePositionsLoader({ + okCode: OK, + failedPreconditionCode: FAILED_PRECONDITION, + onError, + fetch: async () => + mode === 'ok' + ? { code: OK, message: '', rows: [okRow('a'), okRow('b')] } + : { code: 2, message: 'the broker fell over', rows: [] } + }); + + const good = await loader(); + mode = 'broken'; + expect(await loader()).toEqual(good); + expect(onError).toHaveBeenLastCalledWith('the broker fell over'); + mode = 'ok'; + await loader(); + expect(onError).toHaveBeenLastCalledWith(undefined); + }); + + it('a vanished session clears the table - stale rows must not pose as current', async () => { + const onError = jest.fn(); + let gone = false; + const loader = makePositionsLoader({ + okCode: OK, + failedPreconditionCode: FAILED_PRECONDITION, + onError, + fetch: async () => + gone + ? { code: FAILED_PRECONDITION, message: 'no such session', rows: [] } + : { code: OK, message: '', rows: [okRow('a'), okRow('b')] } + }); + + await loader(); + gone = true; + expect(await loader()).toEqual([]); + expect(onError).toHaveBeenLastCalledWith(undefined); + }); +}); diff --git a/ui/components/ui/ConsumerSession/Console/TopicPositions/topic-positions.ts b/ui/components/ui/ConsumerSession/Console/TopicPositions/topic-positions.ts new file mode 100644 index 000000000..979dd2764 --- /dev/null +++ b/ui/components/ui/ConsumerSession/Console/TopicPositions/topic-positions.ts @@ -0,0 +1,244 @@ +import * as pb from '../../../../../grpc-web/tools/teal/pulsar/ui/api/v1/consumer_pb'; +import { hexStringFromByteArray } from '../../../../conversions/conversions'; + +/** + * One row of the Topic Positions debug view. + * + * EVERY FIGURE IS OPTIONAL, and `undefined` means "not known" - never zero. The distinction is the + * whole point of the view: a cursor sitting on the first retained entry genuinely IS 0% through, + * while a topic whose cursor has aged out from under retention knows nothing about where it is. A + * table that rendered both as "0%" would quietly invent the second one. + */ +export type TopicPositionRow = { + topicFqn: string; + firstMessageId?: string; + firstPublishTime?: number; + lastMessageId?: string; + lastPublishTime?: number; + cursorMessageId?: string; + cursorPublishTime?: number; + cursorTimeFraction?: number; + cursorEntryFraction?: number; + cursorEntryOrdinal?: number; + retainedEntries?: number; + /** Set when the broker refused the topic - a non-persistent one cannot be examined at all. */ + unavailableReason?: string; + /** The synthetic "All topics" summary row - pinned first, exempt from per-topic semantics. */ + isAggregate?: boolean; +}; + +/** What the table prints where it has nothing to print. */ +export const noValue = '-'; + +const bytesToHex = (v?: { getValue_asU8: () => Uint8Array }): string | undefined => { + const bytes = v?.getValue_asU8?.(); + return bytes === undefined || bytes.length === 0 ? undefined : hexStringFromByteArray(bytes, 'hex-with-space'); +}; + +const num = (v?: { getValue: () => number }): number | undefined => (v === undefined ? undefined : v.getValue()); + +/** + * Read one row off the wire. + * + * An UNSET protobuf wrapper stays `undefined` here rather than becoming 0 - see [[TopicPositionRow]] + * for why that distinction is load-bearing rather than fussy. + */ +export function topicPositionFromPb(position: pb.TopicPosition): TopicPositionRow { + return { + topicFqn: position.getTopicFqn(), + firstMessageId: bytesToHex(position.getFirstMessageId() as never), + firstPublishTime: num(position.getFirstPublishTime() as never), + lastMessageId: bytesToHex(position.getLastMessageId() as never), + lastPublishTime: num(position.getLastPublishTime() as never), + cursorMessageId: bytesToHex(position.getCursorMessageId() as never), + cursorPublishTime: num(position.getCursorPublishTime() as never), + cursorTimeFraction: num(position.getCursorTimeFraction() as never), + cursorEntryFraction: num(position.getCursorEntryFraction() as never), + cursorEntryOrdinal: num(position.getCursorEntryOrdinal() as never), + retainedEntries: num(position.getRetainedEntries() as never), + unavailableReason: position.getUnavailableReason()?.getValue() || undefined + }; +} + +/** + * A fraction as a percentage. + * + * One decimal place because the interesting readings are the ones near the ends - "99.9%" and "100%" + * are different answers on a session that is nearly done versus done, and rounding to whole percent + * would merge them. + */ +export function formatFraction(fraction: number | undefined): string { + if (fraction === undefined || !Number.isFinite(fraction)) { + return noValue; + } + return `${(fraction * 100).toFixed(1)}%`; +} + +/** An epoch millisecond as a local timestamp, or the placeholder when there is none. */ +export function formatTimestamp(epochMs: number | undefined): string { + if (epochMs === undefined || !Number.isFinite(epochMs)) { + return noValue; + } + return new Date(epochMs).toLocaleString(); +} + +/** `ordinal / total`, the arithmetic behind the entry percentage, shown so it can be checked. */ +export function formatEntryCount(ordinal: number | undefined, retained: number | undefined): string { + if (ordinal === undefined || retained === undefined) { + return noValue; + } + return `${ordinal.toLocaleString()} / ${retained.toLocaleString()}`; +} + +/** + * How far the session's read position trails the newest message, in wall-clock milliseconds - the + * consumer-lag clock, and the first thing to check on "why am I seeing old data": a large value is + * lag, a small one on a quiet topic is just no traffic. + * + * Clamped at zero: the endpoints and the cursor are separate lookups, so a message published + * between them can put the cursor "ahead" of the recorded end - stale denominator, not time travel. + */ +export function behindMsOf(row: Pick): number | undefined { + if (row.lastPublishTime === undefined || row.cursorPublishTime === undefined) { + return undefined; + } + return Math.max(0, row.lastPublishTime - row.cursorPublishTime); +} + +/** A duration as humans read one: "3d 4h", "2h 05m", "45s", "<1s". */ +export function formatDurationMs(ms: number | undefined): string { + if (ms === undefined || !Number.isFinite(ms) || ms < 0) { + return noValue; + } + if (ms < 1000) { + return '<1s'; + } + const seconds = Math.floor(ms / 1000); + const days = Math.floor(seconds / 86400); + const hours = Math.floor((seconds % 86400) / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = seconds % 60; + if (days > 0) { + return `${days}d ${hours}h`; + } + if (hours > 0) { + return `${hours}h ${String(minutes).padStart(2, '0')}m`; + } + if (minutes > 0) { + return `${minutes}m ${String(secs).padStart(2, '0')}s`; + } + return `${secs}s`; +} + +/** The label of the synthetic aggregate row. */ +export const allTopicsLabel = 'All topics'; + +/** What the loader needs from the outside world - injectable, so every transition is a unit test. */ +export type PositionsFetch = () => Promise<{ + code: number | undefined; + message: string | undefined; + rows: TopicPositionRow[]; +}>; + +/** + * Build the polling loader the shared Table drives. + * + * THE LOADER NEVER THROWS, by design: the Table toasts its data-loader errors on every render, so + * a 1-second poll that throws is a toast storm. Instead every answer is classified here - success + * replaces the last-good rows (and prepends the aggregate), a missing session means "empty" (the + * session was deleted underneath; not a fault worth shouting about every second), and everything + * else KEEPS the last-good rows on screen and reports through `onError` - stale data with a banner + * beats a blank table with a toast. + */ +export function makePositionsLoader(deps: { + fetch: PositionsFetch; + okCode: number; + failedPreconditionCode: number; + onError: (message: string | undefined) => void; +}): () => Promise { + let lastGood: TopicPositionRow[] = []; + + return async () => { + let answer; + try { + answer = await deps.fetch(); + } catch (err) { + deps.onError(`${(err as Error)?.message ?? err}`); + return lastGood; + } + + if (answer.code === deps.failedPreconditionCode) { + // The session vanished mid-run (deleted elsewhere). Yesterday's rows belong to a session + // that no longer exists - clear rather than display them as current. + deps.onError(undefined); + lastGood = []; + return lastGood; + } + + if (answer.code !== deps.okCode) { + deps.onError(answer.message || 'The consumer session did not answer.'); + return lastGood; + } + + deps.onError(undefined); + const aggregate = aggregateRow(answer.rows); + lastGood = aggregate === undefined ? answer.rows : [aggregate, ...answer.rows]; + return lastGood; + }; +} + +/** + * The "All topics" row: the session's whole watch, one line. + * + * ONLY WHAT AGGREGATES HONESTLY IS AGGREGATED. First/last publish times take the min/max across + * topics - the session's global time range. "% of entries" is the sum of consumed ordinals over + * the sum of retained entries. "Behind" is the WORST topic's lag, because "how far behind is this + * session" is answered by its laggiest member. "% of time range" places that same laggiest cursor + * inside the global range. Message ids do not aggregate - there is no such thing as a global + * message id - so those cells stay blank, and rows a broker refused (non-persistent) contribute + * nothing rather than zeros. + */ +export function aggregateRow(rows: TopicPositionRow[]): TopicPositionRow | undefined { + const usable = rows.filter((r) => r.unavailableReason === undefined && !r.isAggregate); + if (usable.length < 2) { + return undefined; + } + + const defined = (values: (T | undefined)[]): T[] => values.filter((v): v is T => v !== undefined); + + const firsts = defined(usable.map((r) => r.firstPublishTime)); + const lasts = defined(usable.map((r) => r.lastPublishTime)); + const cursors = defined(usable.map((r) => r.cursorPublishTime)); + const behinds = defined(usable.map((r) => behindMsOf(r))); + const withEntries = usable.filter((r) => r.cursorEntryOrdinal !== undefined && r.retainedEntries !== undefined); + + const firstPublishTime = firsts.length > 0 ? Math.min(...firsts) : undefined; + const lastPublishTime = lasts.length > 0 ? Math.max(...lasts) : undefined; + // The LAGGIEST cursor stands for the session: the range up to it is what the whole session has + // certainly covered. + const cursorPublishTime = cursors.length === usable.length && cursors.length > 0 ? Math.min(...cursors) : undefined; + + const ordinalSum = withEntries.reduce((acc, r) => acc + (r.cursorEntryOrdinal as number), 0); + const retainedSum = defined(usable.map((r) => r.retainedEntries)).reduce((acc, v) => acc + v, 0); + + let cursorTimeFraction: number | undefined = undefined; + if (firstPublishTime !== undefined && lastPublishTime !== undefined && cursorPublishTime !== undefined && lastPublishTime > firstPublishTime) { + cursorTimeFraction = Math.min(1, Math.max(0, (cursorPublishTime - firstPublishTime) / (lastPublishTime - firstPublishTime))); + } + + return { + topicFqn: allTopicsLabel, + isAggregate: true, + firstPublishTime, + lastPublishTime, + // behindMsOf(aggregate) must answer the WORST lag, so the aggregate stores the laggiest + // cursor against the global newest message; the two line up by construction. + cursorPublishTime: behinds.length === usable.length && lastPublishTime !== undefined && behinds.length > 0 + ? lastPublishTime - Math.max(...behinds) + : cursorPublishTime, + cursorTimeFraction, + cursorEntryFraction: withEntries.length === usable.length && retainedSum > 0 ? Math.min(1, ordinalSum / retainedSum) : undefined, + cursorEntryOrdinal: withEntries.length === usable.length && withEntries.length > 0 ? ordinalSum : undefined, + retainedEntries: retainedSum > 0 ? retainedSum : undefined + }; +} diff --git a/ui/components/ui/ConsumerSession/ConsumerSession.lifecycle.test.tsx b/ui/components/ui/ConsumerSession/ConsumerSession.lifecycle.test.tsx new file mode 100644 index 000000000..ee948d7af --- /dev/null +++ b/ui/components/ui/ConsumerSession/ConsumerSession.lifecycle.test.tsx @@ -0,0 +1,962 @@ +/** + * @jest-environment jsdom + * @jest-environment-options {"customExportConditions": ["node"]} + * + * Session LIFECYCLE, driven through the real component: pause, the resume stream's failure modes, + * and what Play does with a configuration that cannot be converted. + * + * Everything here is about the session claiming a state the SERVER is not in, which is only + * observable where the state machine, the RPCs and the stream meet - so the component is rendered + * for real and only the gRPC transport is replaced. `data-cs-state` on the session container is the + * state machine's own output, and it is what the Playwright specs assert too. + * + * The transport stub answers with genuine protobuf responses; the resume stream is a hand-rolled + * emitter with the same `on`/`removeListener`/`cancel` surface grpc-web's ClientReadableStream has, + * so listeners the component never installs simply never fire - exactly as in the browser. + * + * Note: with a jest.mock() in the file, esbuild-jest runs babel's hoisting pass over untyped JS, so + * imported bindings must not appear in type annotations here (inference only). + */ +jest.mock('nanoid', () => ({ nanoid: () => 'test-id' })); +jest.mock('mermaid', () => ({ __esModule: true, default: { initialize: () => undefined } })); + +// There is no gRPC endpoint in jsdom, and the generated clients would try to reach one on import. +// The holder is mutable so each test installs its own answers; the name has to start with `mock` +// for jest's out-of-scope check to allow it inside the hoisted factory. +const mockClients = { current: undefined as unknown }; +jest.mock('../../app/contexts/GrpcClient/GrpcClient', () => ({ + useContext: () => mockClients.current, +})); + +import React from 'react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { SWRConfig } from 'swr'; +import ConsumerSession from './ConsumerSession'; +import { getDefaultManagedItem } from '../LibraryBrowser/default-library-items'; +import { defaultNumDisplayItems } from './SessionConfiguration/display-items'; +import { Status } from '../../../grpc-web/google/rpc/status_pb'; +import { Code } from '../../../grpc-web/google/rpc/code_pb'; +import { StringValue } from 'google-protobuf/google/protobuf/wrappers_pb'; +import { + CreateConsumerResponse, + DeleteConsumerResponse, + Message, + PauseResponse, + ResumeResponse, +} from '../../../grpc-web/tools/teal/pulsar/ui/api/v1/consumer_pb'; + +const topicContext = (topicPersistency: 'persistent' | 'non-persistent') => ({ + pulsarResource: { + type: 'topic' as const, + tenant: 'public', + namespace: 'default', + topicPersistency, + topic: 'a-topic', + }, +}); + +const status = (code: number, message: string) => { + const s = new Status(); + s.setCode(code); + s.setMessage(message); + return s; +}; + +/** The `on`/`removeListener`/`cancel` surface of a grpc-web ClientReadableStream, plus an emitter. */ +const fakeResumeStream = () => { + const listeners: Record void)[]> = {}; + let isCancelled = false; + + return { + get isCancelled() { + return isCancelled; + }, + on(event: string, cb: (v: unknown) => void) { + (listeners[event] = listeners[event] || []).push(cb); + return this; + }, + removeListener(event: string, cb: (v: unknown) => void) { + listeners[event] = (listeners[event] || []).filter((it) => it !== cb); + return this; + }, + cancel() { + isCancelled = true; + }, + /** What the server said, delivered to whoever is listening - nobody, if nobody listens. */ + emit(event: string, v?: unknown) { + (listeners[event] || []).slice().forEach((cb) => cb(v)); + }, + }; +}; + +type HarnessOptions = { + pauseWith?: { code: number; message: string }; + pauseRejectsWith?: Error; + createConsumerWith?: { code: number; message: string }; + /** Per-attempt Create statuses; attempts past the end of the list succeed. */ + createConsumerStatuses?: { code: number; message: string }[]; + createRejectsWith?: Error; + /** + * Hold every Create open until `settleCreates()` - the server is still building the consumer. + * That window is where Stop, a hidden tab and an unload all have to behave. + */ + deferCreate?: boolean; +}; + +const makeHarness = (options: HarnessOptions = {}) => { + const stream = fakeResumeStream(); + const createConsumerRequests: any[] = []; + const deleteConsumerRequests: any[] = []; + const pauseRequests: any[] = []; + const resumeRequests: any[] = []; + const resumeOptions: any[] = []; + const notifiedErrors: string[] = []; + const heldCreates: (() => void)[] = []; + + const consumerServiceClient = { + createConsumer: (req: unknown) => { + const attempt = createConsumerRequests.length; + createConsumerRequests.push(req); + if (options.createRejectsWith !== undefined) { + return Promise.reject(options.createRejectsWith); + } + const res = new CreateConsumerResponse(); + const s = options.createConsumerStatuses?.[attempt] + ?? options.createConsumerWith + ?? { code: Code.OK, message: '' }; + res.setStatus(status(s.code, s.message)); + if (!options.deferCreate) { + return Promise.resolve(res); + } + return new Promise((resolve) => heldCreates.push(() => resolve(res))); + }, + resume: (req: unknown, opts: unknown) => { + resumeRequests.push(req); + resumeOptions.push(opts); + return stream; + }, + pause: (req: unknown) => { + pauseRequests.push(req); + if (options.pauseRejectsWith !== undefined) { + return Promise.reject(options.pauseRejectsWith); + } + const res = new PauseResponse(); + const s = options.pauseWith ?? { code: Code.OK, message: '' }; + res.setStatus(status(s.code, s.message)); + return Promise.resolve(res); + }, + deleteConsumer: (req: unknown) => { + deleteConsumerRequests.push(req); + return Promise.resolve(new DeleteConsumerResponse()); + }, + // The target editor asks for the topics a selector resolves to; nothing here depends on it. + resolveTopicSelector: () => Promise.reject(new Error('not used by these tests')), + }; + + mockClients.current = { + consumerServiceClient, + // The Console's Produce tab creates a producer as soon as the session renders. + producerServiceClient: { + createProducer: () => Promise.resolve({ getStatus: () => status(Code.OK, '') }), + deleteProducer: () => Promise.resolve({ getStatus: () => status(Code.OK, '') }), + send: () => Promise.resolve({ getStatus: () => status(Code.OK, '') }), + }, + libraryServiceClient: { + listLibraryItems: () => Promise.reject(new Error('no library in these tests')), + getLibraryItem: () => Promise.reject(new Error('no library in these tests')), + }, + }; + + return { + stream, + createConsumerRequests, + deleteConsumerRequests, + pauseRequests, + resumeRequests, + resumeOptions, + notifiedErrors, + /** The Create the server was still working on finally answers. */ + settleCreates: async () => { + await act(async () => { + heldCreates.splice(0).forEach((resolve) => resolve()); + }); + }, + }; +}; + +const defaultConfig = (context: ReturnType) => ({ + type: 'value' as const, + val: getDefaultManagedItem('consumer-session-config', context), +}); + +/** Renders the session; `rerenderWith` re-renders it in place, as a route change would. */ +const renderSession = async (config: unknown, context: ReturnType) => { + const tree = (ctx: ReturnType) => ( + + + + ); + + let rerender: any; + await act(async () => { + ({ rerender } = render(tree(context))); + }); + + return { + rerenderWith: async (ctx: ReturnType) => { + await act(async () => { + rerender(tree(ctx)); + }); + }, + }; +}; + +const sessionState = () => screen.getByTestId('cs-session').getAttribute('data-cs-state'); +const playButton = () => screen.getByTestId('cs-play') as HTMLButtonElement; +const stopButton = () => screen.getByTestId('cs-stop') as HTMLButtonElement; +const clickPlay = async () => { + await act(async () => { + fireEvent.click(playButton()); + }); +}; + +/** Stop and flush: the session is remounted from scratch under a new key. */ +const clickStop = async () => { + await act(async () => { + fireEvent.click(stopButton()); + }); +}; + +/** Play once from `new`, and wait for the create+resume round trip that lands it in `running`. */ +const startSession = async () => { + await clickPlay(); + expect(sessionState()).toBe('running'); +}; + +/** What `document.visibilityState` reports, plus the event the browser fires when it changes. */ +const setTabHidden = async (isHidden: boolean) => { + Object.defineProperty(document, 'visibilityState', { + configurable: true, + get: () => (isHidden ? 'hidden' : 'visible'), + }); + await act(async () => { + window.dispatchEvent(new Event('visibilitychange')); + }); +}; + +const consumerNames = (requests: any[]) => requests.map((req) => req.getConsumerName()); + +describe('a pause the server refuses', () => { + it('does not present the session as paused', async () => { + // FAILED_PRECONDITION is what ConsumerServiceImpl.pause answers for a session it no longer + // knows - and it comes back as a RESOLVED response, not a rejected call. The server stream is + // still live, so a "paused" label would be a claim about the server that is simply false, and + // messages can still arrive underneath it. + const harness = makeHarness({ pauseWith: { code: Code.FAILED_PRECONDITION, message: 'No such consumer' } }); + await renderSession(defaultConfig(topicContext('persistent')), topicContext('persistent')); + await startSession(); + + await clickPlay(); + + expect(sessionState()).not.toBe('paused'); + // ...and it says so: the session is still consuming, which is what the server is doing. + expect(sessionState()).toBe('running'); + expect(harness.resumeOptions.length).toBeGreaterThanOrEqual(1); + }); + + it('does not present the session as paused when the pause call itself fails', async () => { + makeHarness({ pauseRejectsWith: new Error('transport down') }); + await renderSession(defaultConfig(topicContext('persistent')), topicContext('persistent')); + await startSession(); + + await clickPlay(); + + expect(sessionState()).not.toBe('paused'); + }); + + it('still pauses when the server confirms it', async () => { + // The counterpart: the ordinary path must keep working, or "never claim paused" would be + // trivially satisfiable by never pausing at all. + makeHarness(); + await renderSession(defaultConfig(topicContext('persistent')), topicContext('persistent')); + await startSession(); + + await clickPlay(); + + expect(sessionState()).toBe('paused'); + }); +}); + +describe('the resume stream ending under the session', () => { + it('leaves a recoverable state when the stream errors, instead of a frozen "running"', async () => { + // A transport failure, a cancelled server call or an expired deadline all arrive as `error`. + // With no listener for it the session sits in `running` for ever, counters frozen, waiting for + // messages that can no longer come. + const harness = makeHarness(); + await renderSession(defaultConfig(topicContext('persistent')), topicContext('persistent')); + await startSession(); + + await act(async () => { + harness.stream.emit('error', { code: 14, message: 'transport is closing' }); + }); + + expect(sessionState()).not.toBe('running'); + }); + + it('leaves a recoverable state when the stream ends normally', async () => { + const harness = makeHarness(); + await renderSession(defaultConfig(topicContext('persistent')), topicContext('persistent')); + await startSession(); + + await act(async () => { + harness.stream.emit('end'); + }); + + expect(sessionState()).not.toBe('running'); + }); + + it('does not cap the stream with a deadline a long skip would outlive', async () => { + // Resume is a long-lived server stream: a Skip-N over millions of messages can spend longer + // than any fixed budget resolving before it delivers anything, and the deadline would kill it + // mid-skip. + const harness = makeHarness(); + await renderSession(defaultConfig(topicContext('persistent')), topicContext('persistent')); + await startSession(); + + expect(harness.resumeOptions).toHaveLength(1); + expect(harness.resumeOptions[0]?.deadline).toBeUndefined(); + }); +}); + +describe('a configuration Play cannot execute', () => { + // Shape-check-passing but not convertible: `pauseTriggerChain` is missing, so + // consumerSessionConfigFromValOrRef throws and the session has no runtime config to send. + const unconvertibleConfig = () => { + const config = defaultConfig(topicContext('persistent')); + const spec = { ...(config.val as any).spec }; + delete spec.pauseTriggerChain; + return { type: 'value' as const, val: { ...(config.val as any), spec } }; + }; + + it('disables Play instead of letting the session hang on "initializing"', async () => { + makeHarness(); + await renderSession(unconvertibleConfig(), topicContext('persistent')); + + expect(playButton().disabled).toBe(true); + }); + + it('never asks the server to create a consumer it has no config for', async () => { + const harness = makeHarness(); + await renderSession(unconvertibleConfig(), topicContext('persistent')); + + await clickPlay(); + + expect(harness.createConsumerRequests).toHaveLength(0); + expect(sessionState()).not.toBe('initializing'); + }); + + /** The same default config, with the start-from replaced by a message id of `hexString`. */ + const messageIdConfig = (hexString: string) => { + const config = defaultConfig(topicContext('persistent')); + const startFrom = (config.val as any).spec.startFrom; + startFrom.val.spec.startFrom = { + type: 'messageId', + messageId: { + type: 'value', + val: { + metadata: { id: 'mid-1', name: '', descriptionMarkdown: '', type: 'message-id' }, + spec: { hexString }, + }, + }, + }; + return config; + }; + + it.each([[''], [' ']])('never sends a message-id start-from of %p as zero bytes', async (hexString) => { + // The shared hex parser reads blank text as an EMPTY byte array - correct for a byte payload, + // meaningless as a start position. The server parses the field as a real message id and refuses + // it, so the round trip is spent to end up back where Play started, with nothing on screen + // saying which field was at fault. + const harness = makeHarness(); + await renderSession(messageIdConfig(hexString), topicContext('persistent')); + + await clickPlay(); + + expect(harness.createConsumerRequests).toHaveLength(0); + expect(sessionState()).not.toBe('initializing'); + expect(playButton().disabled).toBe(false); + }); + + it('still sends a message id that is actually filled in', async () => { + const harness = makeHarness(); + await renderSession(messageIdConfig('08 c3 03 10 cd 04 20 00 30 01'), topicContext('persistent')); + + await clickPlay(); + + expect(harness.createConsumerRequests).toHaveLength(1); + expect(sessionState()).toBe('running'); + }); + + it('recovers from a configuration that cannot be serialised into a request', async () => { + // A message id that is not hex. The runtime config converts fine - the id is still just text at + // that point - and the hex parser only runs while the create request is being built, after the + // click and outside any catch. The session was left sitting on "initializing" for ever. + const config = defaultConfig(topicContext('persistent')); + const startFrom = (config.val as any).spec.startFrom; + startFrom.val.spec.startFrom = { + type: 'messageId', + messageId: { + type: 'value', + val: { + metadata: { id: 'mid-1', name: '', descriptionMarkdown: '', type: 'message-id' }, + spec: { hexString: 'zz' }, + }, + }, + }; + + const harness = makeHarness(); + await renderSession(config, topicContext('persistent')); + + await clickPlay(); + + expect(harness.createConsumerRequests).toHaveLength(0); + expect(sessionState()).not.toBe('initializing'); + }); + + it('keeps Play working for a configuration that does convert', async () => { + const harness = makeHarness(); + await renderSession(defaultConfig(topicContext('persistent')), topicContext('persistent')); + + expect(playButton().disabled).toBe(false); + await clickPlay(); + expect(harness.createConsumerRequests).toHaveLength(1); + }); +}); + +describe('the topic the session is mounted on', () => { + /** The topic FQNs the request's first target resolved "the current topic" to. */ + const requestedTopicFqns = (req: any) => + req + .getConsumerSessionConfig() + .getTargetsList()[0] + .getTopicSelector() + .getMultiTopicSelector() + .getTopicFqnsList(); + + it('sends the topic currently being viewed, not the one it was first rendered with', async () => { + // `persistent://t/n/x` and `non-persistent://t/n/x` are two different topics that differ only + // in the scheme. Navigating from one to the other changes nothing else about the page, so a + // session that captured the FQN once keeps consuming the topic the user left. + const harness = makeHarness(); + const { rerenderWith } = await renderSession( + defaultConfig(topicContext('persistent')), + topicContext('persistent') + ); + + await rerenderWith(topicContext('non-persistent')); + await clickPlay(); + + expect(harness.createConsumerRequests).toHaveLength(1); + expect(requestedTopicFqns(harness.createConsumerRequests[0])).toEqual([ + 'non-persistent://public/default/a-topic', + ]); + }); + + it('sends the mounted topic when nothing moved', async () => { + const harness = makeHarness(); + await renderSession(defaultConfig(topicContext('persistent')), topicContext('persistent')); + + await clickPlay(); + + expect(requestedTopicFqns(harness.createConsumerRequests[0])).toEqual([ + 'persistent://public/default/a-topic', + ]); + }); +}); + +/** + * A Create that does not succeed leaves NOTHING running - no consumer on the server, no stream, no + * timer. The only thing left is the session's own claim about itself, and `initializing` is a claim + * that something is still happening. Play is disabled there, so the claim is also a dead end: the + * only way out was Stop, which throws away the loaded messages. + */ +describe('a Create the server does not complete', () => { + it('returns to a state Play can retry after a refused Create, instead of hanging on "initializing"', async () => { + // FAILED_PRECONDITION is what ConsumerServiceImpl answers for a config it cannot act on (an + // empty message id, a topic that vanished) - a RESOLVED response carrying a non-OK status. + const harness = makeHarness({ + createConsumerWith: { code: Code.FAILED_PRECONDITION, message: 'Message ID is empty' }, + }); + await renderSession(defaultConfig(topicContext('persistent')), topicContext('persistent')); + + await clickPlay(); + + expect(harness.createConsumerRequests).toHaveLength(1); + expect(sessionState()).not.toBe('initializing'); + expect(playButton().disabled).toBe(false); + }); + + it('lets Play actually retry after a refused Create', async () => { + // Not merely "the button is enabled": the retry has to reach the server, which it cannot do + // from `initializing` (Play is a no-op there even when it is clickable). + const harness = makeHarness({ + createConsumerWith: { code: Code.FAILED_PRECONDITION, message: 'Message ID is empty' }, + }); + await renderSession(defaultConfig(topicContext('persistent')), topicContext('persistent')); + + await clickPlay(); + await clickPlay(); + + expect(harness.createConsumerRequests).toHaveLength(2); + }); + + it('returns to a state Play can retry when the Create call itself fails', async () => { + // A rejected call - transport down, deadline exceeded - never produces a response at all, and + // the `res === undefined` branch used to return without saying anything about the session. + const harness = makeHarness({ createRejectsWith: new Error('transport down') }); + await renderSession(defaultConfig(topicContext('persistent')), topicContext('persistent')); + + await clickPlay(); + + expect(sessionState()).not.toBe('initializing'); + expect(playButton().disabled).toBe(false); + + await clickPlay(); + expect(harness.createConsumerRequests).toHaveLength(2); + }); + + it('still runs the session when the Create succeeds', async () => { + // The counterpart: "never strand on initializing" is trivially satisfiable by never starting. + makeHarness(); + await renderSession(defaultConfig(topicContext('persistent')), topicContext('persistent')); + + await startSession(); + }); +}); + +/** + * Stop is enabled while a Create is in flight, and it remounts the whole session. The consumer the + * server is still building belongs to a UI that no longer exists by the time it exists itself, and + * its name is generated in the component - so nothing else can ever name it again. + */ +describe('Stop while the session is still being created', () => { + it('deletes the consumer whose Create landed after the session was abandoned', async () => { + const harness = makeHarness({ deferCreate: true }); + await renderSession(defaultConfig(topicContext('persistent')), topicContext('persistent')); + + await clickPlay(); + expect(sessionState()).toBe('initializing'); + expect(harness.createConsumerRequests).toHaveLength(1); + + await clickStop(); + const createdName = harness.createConsumerRequests[0].getConsumerName(); + const deletesBeforeCreateLanded = harness.deleteConsumerRequests.length; + + await harness.settleCreates(); + + // The server now HAS this consumer, subscribed and consuming. Whatever the unmount already + // deleted, the late arrival has to be deleted too, or it stays live for ever. + expect(harness.deleteConsumerRequests.length).toBeGreaterThan(deletesBeforeCreateLanded); + expect(consumerNames(harness.deleteConsumerRequests.slice(deletesBeforeCreateLanded))).toContain(createdName); + }); + + it('deletes only the abandoned consumer, not the session that replaced it', async () => { + // Both Creates are in flight at once, and they are indistinguishable from the outside - the + // deletion has to follow which SESSION asked for each one, not "some create was abandoned". + const harness = makeHarness({ deferCreate: true }); + await renderSession(defaultConfig(topicContext('persistent')), topicContext('persistent')); + + await clickPlay(); + await clickStop(); + await clickPlay(); + expect(harness.createConsumerRequests).toHaveLength(2); + const deletesBeforeCreatesLanded = harness.deleteConsumerRequests.length; + + await harness.settleCreates(); + + expect(harness.deleteConsumerRequests.length - deletesBeforeCreatesLanded).toBe(1); + expect(sessionState()).toBe('running'); + }); + + it('does not delete the consumer a retry created', async () => { + // Returning to `new` after a refused Create is itself a cleanup, so a session that retries has + // been "abandoned" once by the time its second Create lands. Reading that as "this consumer is + // orphaned" deletes the one consumer the user is actually watching. + const harness = makeHarness({ + createConsumerStatuses: [{ code: Code.FAILED_PRECONDITION, message: 'Message ID is empty' }], + }); + await renderSession(defaultConfig(topicContext('persistent')), topicContext('persistent')); + + await clickPlay(); + const deletesBeforeRetry = harness.deleteConsumerRequests.length; + + await clickPlay(); + + expect(harness.createConsumerRequests).toHaveLength(2); + expect(sessionState()).toBe('running'); + expect(harness.deleteConsumerRequests.length).toBe(deletesBeforeRetry); + }); +}); + +/** + * The tab going away pauses a RUNNING stream so the browser does not silently stall it. Every other + * state owns no stream: `new` and `initializing` have no consumer on the server yet, and `paused` + * already stopped. Pausing those asks the server about a session it does not know, and its refusal + * used to be read as "then it must still be running". + */ +describe('the tab being hidden', () => { + afterEach(() => setTabHidden(false)); + + it('does not pause a session that was never started', async () => { + const harness = makeHarness({ + pauseWith: { code: Code.FAILED_PRECONDITION, message: 'No such consumer consumer session' }, + }); + await renderSession(defaultConfig(topicContext('persistent')), topicContext('persistent')); + + await setTabHidden(true); + + expect(harness.pauseRequests).toHaveLength(0); + // ...and above all it is not "running": a refused pause flipped the session into the one state + // that offers Resume, for a consumer that was never created. + expect(sessionState()).toBe('new'); + }); + + it('does not pause a session whose Create is still in flight', async () => { + const harness = makeHarness({ deferCreate: true }); + await renderSession(defaultConfig(topicContext('persistent')), topicContext('persistent')); + + await clickPlay(); + await setTabHidden(true); + + expect(harness.pauseRequests).toHaveLength(0); + expect(sessionState()).toBe('initializing'); + + // ...and the Create that lands while the tab is STILL HIDDEN must not resume into it: a + // stream nobody watches, with nothing armed for the return, used to stall the session + // forever. It parks as a hidden-tab pause instead, and becoming visible resumes it. + await harness.settleCreates(); + expect(sessionState()).toBe('paused'); + expect(harness.resumeRequests).toHaveLength(0); + + await setTabHidden(false); + expect(sessionState()).toBe('running'); + expect(harness.resumeRequests).toHaveLength(1); + }); + + it('does not pause a session that is already paused', async () => { + const harness = makeHarness(); + await renderSession(defaultConfig(topicContext('persistent')), topicContext('persistent')); + await startSession(); + await clickPlay(); + expect(sessionState()).toBe('paused'); + const pausesBefore = harness.pauseRequests.length; + + await setTabHidden(true); + + expect(harness.pauseRequests.length).toBe(pausesBefore); + expect(sessionState()).toBe('paused'); + }); + + it('still pauses a running session, and resumes it when the tab comes back', async () => { + const harness = makeHarness(); + await renderSession(defaultConfig(topicContext('persistent')), topicContext('persistent')); + await startSession(); + + await setTabHidden(true); + expect(harness.pauseRequests).toHaveLength(1); + expect(sessionState()).toBe('paused'); + + await setTabHidden(false); + expect(sessionState()).toBe('running'); + }); + + it('leaves a session the user paused paused when the tab comes back', async () => { + // Coming back must not restart something the USER stopped - only what the hidden tab stopped. + const harness = makeHarness(); + await renderSession(defaultConfig(topicContext('persistent')), topicContext('persistent')); + await startSession(); + await clickPlay(); + expect(sessionState()).toBe('paused'); + + await setTabHidden(true); + await setTabHidden(false); + + expect(sessionState()).toBe('paused'); + expect(harness.resumeOptions).toHaveLength(1); + }); +}); + +/** + * The retention that keeps a long session from growing until the tab dies. It is the only bound on + * the message buffer, it runs on a timer rather than per message, and nothing else in the tree + * exercises it - the count of rendered rows is not the count retained, because the table is + * virtualized. + */ +describe('the number of messages kept on screen', () => { + beforeEach(() => { + jest.useFakeTimers(); + // The flush scrolls the table to the bottom; jsdom has no scrollTo on elements. + (Element.prototype as any).scrollTo = () => undefined; + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + /** The same default config with an explicit display limit. */ + const configWithLimit = (numDisplayItems: number) => { + const config = defaultConfig(topicContext('persistent')); + (config.val as any).spec.numDisplayItems = numDisplayItems; + return config; + }; + + /** A resume response carrying `count` real (valued) messages, numbered from `from`. */ + const messages = (from: number, count: number) => { + const res = new ResumeResponse(); + res.setStatus(status(Code.OK, '')); + res.setMessagesList( + Array.from({ length: count }, (_, i) => { + const m = new Message(); + m.setValue(new StringValue().setValue(`m-${from + i}`)); + m.setNumMessageProcessed(from + i); + m.setNumMessageSent(from + i); + return m; + }) + ); + return res; + }; + + const retained = () => Number(screen.getByTestId('cs-session').getAttribute('data-cs-retained')); + + /** Let the per-second rate tick, then the flush that moves the buffer into the table. */ + const flush = async () => { + await act(async () => { + jest.advanceTimersByTime(1000); + }); + await act(async () => { + jest.advanceTimersByTime(500); + }); + }; + + it('keeps everything that arrives while the limit is not reached', async () => { + const harness = makeHarness(); + await renderSession(configWithLimit(5), topicContext('persistent')); + await startSession(); + + await act(async () => { + harness.stream.emit('data', messages(1, 3)); + }); + await flush(); + + expect(retained()).toBe(3); + }); + + it('drops the oldest once more than the limit has arrived', async () => { + const harness = makeHarness(); + await renderSession(configWithLimit(5), topicContext('persistent')); + await startSession(); + + await act(async () => { + harness.stream.emit('data', messages(1, 12)); + }); + await flush(); + + expect(retained()).toBe(5); + }); + + it('keeps the limit across several deliveries, rather than only within one', async () => { + const harness = makeHarness(); + await renderSession(configWithLimit(5), topicContext('persistent')); + await startSession(); + + await act(async () => { + harness.stream.emit('data', messages(1, 4)); + }); + await flush(); + await act(async () => { + harness.stream.emit('data', messages(5, 4)); + }); + await flush(); + + expect(retained()).toBe(5); + }); + + it('still bounds the buffer when the stored limit is one that keeps everything', async () => { + // A saved session carrying 0 - which is what an emptied field used to commit - turned + // `slice(-limit)` into `slice(0)`, so "limit num. display messages" removed the only bound the + // session has and the buffer grew for as long as the session ran. + // + // The distinction is only visible ABOVE the default limit, so this delivers past it. It is the + // one test here that has to: everything smaller passes either way. + const harness = makeHarness(); + await renderSession(configWithLimit(0), topicContext('persistent')); + await startSession(); + + await act(async () => { + harness.stream.emit('data', messages(1, defaultNumDisplayItems + 5)); + }); + await flush(); + + expect(retained()).toBe(defaultNumDisplayItems); + }); +}); + +/** + * The unload cleanup is a listener on `window`, and `window` outlives every session. Registering it + * from the initialize path meant nothing ever removed it: each Stop remount left one more stale + * closure installed, each holding a dead session's consumer name. + */ +describe('the unload cleanup', () => { + it('deletes only the session that is on screen, however often the session was restarted', async () => { + const harness = makeHarness(); + await renderSession(defaultConfig(topicContext('persistent')), topicContext('persistent')); + + await startSession(); + await clickStop(); + await startSession(); + await clickStop(); + await startSession(); + + const deletesBeforeUnload = harness.deleteConsumerRequests.length; + await act(async () => { + window.dispatchEvent(new Event('beforeunload')); + }); + + expect(harness.deleteConsumerRequests.length - deletesBeforeUnload).toBe(1); + }); + + it('deletes nothing on unload for a session that was never started', async () => { + const harness = makeHarness(); + await renderSession(defaultConfig(topicContext('persistent')), topicContext('persistent')); + + await act(async () => { + window.dispatchEvent(new Event('beforeunload')); + }); + + expect(harness.deleteConsumerRequests).toHaveLength(0); + }); +}); + +describe('the browser-wide delivery controls', () => { + // Both live in localStorage and must be scrubbed, or one test's setting becomes the next + // test's surprise. + afterEach(() => window.localStorage.clear()); + + /** A data frame whose trailing counters put the LOADED count at `sent`. */ + const dataFrame = (sent: number) => { + const res = new ResumeResponse(); + res.setStatus(status(Code.OK, '')); + const msg = new Message(); + const value = new StringValue(); + value.setValue('{"a":1}'); + msg.setValue(value); + msg.setNumMessageProcessed(sent); + msg.setNumMessageSent(sent); + res.setMessagesList([msg]); + return res; + }; + + it('the rate limit rides every resume request, straight from localStorage', async () => { + window.localStorage.setItem('consumerSessionRateLimit', '250'); + const harness = makeHarness(); + await renderSession(defaultConfig(topicContext('persistent')), topicContext('persistent')); + await startSession(); + + expect(harness.resumeRequests).toHaveLength(1); + expect(harness.resumeRequests[0].getMaxMessagesPerSecond()).toBe(250); + }); + + it('the pause-after threshold rides every resume request as the server-side delivery budget', async () => { + window.localStorage.setItem('consumerSessionPauseAfterLoaded', '10'); + const harness = makeHarness(); + await renderSession(defaultConfig(topicContext('persistent')), topicContext('persistent')); + await startSession(); + + // The server enforces "at most 10 loaded on this stream"; the client threshold below is only + // the state-machine driver that turns the quiet stream into a paused session. + expect(harness.resumeRequests[0].getMaxMessagesToDeliver()).toBe(10); + }); + + it('no setting means UNLIMITED on the wire - zero, not a stale number', async () => { + const harness = makeHarness(); + await renderSession(defaultConfig(topicContext('persistent')), topicContext('persistent')); + await startSession(); + + expect(harness.resumeRequests[0].getMaxMessagesPerSecond()).toBe(0); + }); + + it('pauses itself when n more messages have loaded - the same pause the button sends', async () => { + window.localStorage.setItem('consumerSessionPauseAfterLoaded', '3'); + const harness = makeHarness(); + await renderSession(defaultConfig(topicContext('persistent')), topicContext('persistent')); + await startSession(); + + // Two loaded: below the threshold, nothing happens. + await act(async () => { + harness.stream.emit('data', dataFrame(2)); + }); + expect(harness.pauseRequests).toHaveLength(0); + expect(sessionState()).toBe('running'); + + // The third crosses it: the session pauses ITSELF. + await act(async () => { + harness.stream.emit('data', dataFrame(3)); + }); + expect(harness.pauseRequests).toHaveLength(1); + expect(sessionState()).toBe('paused'); + }); + + it('one crossing fires exactly one pause, however many chunks arrive during it', async () => { + window.localStorage.setItem('consumerSessionPauseAfterLoaded', '3'); + const harness = makeHarness(); + await renderSession(defaultConfig(topicContext('persistent')), topicContext('persistent')); + await startSession(); + + // Both frames are past the threshold; the second lands while the pause is in flight and must + // not send a second one. + await act(async () => { + harness.stream.emit('data', dataFrame(3)); + harness.stream.emit('data', dataFrame(4)); + }); + + expect(harness.pauseRequests).toHaveLength(1); + }); + + it('re-arms on resume: Play loads the NEXT n and pauses again', async () => { + window.localStorage.setItem('consumerSessionPauseAfterLoaded', '3'); + const harness = makeHarness(); + await renderSession(defaultConfig(topicContext('persistent')), topicContext('persistent')); + await startSession(); + + await act(async () => { + harness.stream.emit('data', dataFrame(3)); + }); + expect(sessionState()).toBe('paused'); + + // Play again: the threshold is re-armed at loaded + n = 6, so 5 is quiet and 6 pauses. + await clickPlay(); + expect(sessionState()).toBe('running'); + await act(async () => { + harness.stream.emit('data', dataFrame(5)); + }); + expect(sessionState()).toBe('running'); + await act(async () => { + harness.stream.emit('data', dataFrame(6)); + }); + + expect(harness.pauseRequests).toHaveLength(2); + }); + + it('no threshold set means the session NEVER pauses itself', async () => { + const harness = makeHarness(); + await renderSession(defaultConfig(topicContext('persistent')), topicContext('persistent')); + await startSession(); + + await act(async () => { + harness.stream.emit('data', dataFrame(1000)); + }); + + expect(harness.pauseRequests).toHaveLength(0); + expect(sessionState()).toBe('running'); + }); +}); diff --git a/ui/components/ui/ConsumerSession/ConsumerSession.module.css b/ui/components/ui/ConsumerSession/ConsumerSession.module.css index 6b20a47b9..8ac758149 100644 --- a/ui/components/ui/ConsumerSession/ConsumerSession.module.css +++ b/ui/components/ui/ConsumerSession/ConsumerSession.module.css @@ -83,3 +83,17 @@ /* Prevent accidental pages history navigation using touch-pad gestures. */ overscroll-behavior-x: contain; } + +/* The sticky best-effort banner: a degraded start-from stays disclosed for the session's life. */ +.StartFromDegraded { + padding: 6rem 12rem; + background: var(--warning-background-color, #fff7e0); + color: var(--warning-text-color, #7a5b00); + border-bottom: 1rem solid var(--border-color, #e0d5a8); + font-size: 12rem; +} + +/* Drop-position indicator while a header drag hovers this column. */ +.ThDragOver { + box-shadow: inset 3rem 0 0 0 var(--accent-color, #4a72ff); +} diff --git a/ui/components/ui/ConsumerSession/ConsumerSession.test.ts b/ui/components/ui/ConsumerSession/ConsumerSession.test.ts new file mode 100644 index 000000000..4ce9e4480 --- /dev/null +++ b/ui/components/ui/ConsumerSession/ConsumerSession.test.ts @@ -0,0 +1,362 @@ +/** + * @jest-environment jsdom + * @jest-environment-options {"customExportConditions": ["node"]} + * + * BUG-1 regression: a message-less ResumeResponse must surface the server error, not throw. + * BUG-2 regression: a non-OK PauseResponse must be reported, not silently treated as a pause. + * + * Both server paths are real: ConsumerServiceImpl.resume() emits a status-only, message-less + * ResumeResponse when the session is missing or the resume threw, and ConsumerServiceImpl.pause() + * answers FAILED_PRECONDITION (a resolved response, never a transport rejection) in the same + * situations. The responses below are the genuine protobuf messages, not stubs. + * + * Note: with a jest.mock() in the file, esbuild-jest runs babel's hoisting pass over untyped JS, so + * imported bindings must not appear in type annotations here (inference only). + */ +import { Status } from '../../../grpc-web/google/rpc/status_pb'; +import { Code } from '../../../grpc-web/google/rpc/code_pb'; +import { StringValue } from 'google-protobuf/google/protobuf/wrappers_pb'; +import { + ConsumerStats, + Message, + PauseResponse, + ResumeResponse, + StartFromProgress, +} from '../../../grpc-web/tools/teal/pulsar/ui/api/v1/consumer_pb'; +import { startFromProgressDisplayThreshold } from './StartFromProgress/StartFromProgress'; + +// nanoid@4 ships ESM only and jest does not transform node_modules, so importing ConsumerSession +// (which uses it purely to name the consumer/subscription) would fail to parse. Nothing under test +// depends on the generated id. +jest.mock('nanoid', () => ({ nanoid: () => 'test-id' })); +// Same story for mermaid (ESM-only, pulled in far away through the library item editor's markdown +// preview). Neither library is exercised by these tests. +jest.mock('mermaid', () => ({ __esModule: true, default: { initialize: () => undefined } })); + +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { SWRConfig } from 'swr'; +import ConsumerSession, { handleResumeResponse, pauseConsumer } from './ConsumerSession'; +import { getDefaultManagedItem } from '../LibraryBrowser/default-library-items'; + +const makeStatus = (code: number, message: string) => { + const s = new Status(); + s.setCode(code); + s.setMessage(message); + return s; +}; + +const makeMessage = (opts: { value?: string; processed: number; sent: number }) => { + const m = new Message(); + if (opts.value !== undefined) { + const v = new StringValue(); + v.setValue(opts.value); + m.setValue(v); + } + m.setNumMessageProcessed(opts.processed); + m.setNumMessageSent(opts.sent); + return m; +}; + +const makeSinks = () => ({ + messagesBuffer: { current: [] as ReturnType[] }, + messagesProcessed: { current: 7 }, + messagesLoaded: { current: 9 }, + notifyError: jest.fn(), + setStartFromProgress: jest.fn(), + setStartFromDegradation: jest.fn(), +}); + +/** A ResumeResponse carrying the consumer stats the server emits while a big skip is resolving. */ +const makeResumeWithProgress = (progress?: { skipped: number; toSkip: number; complete?: boolean }) => { + const res = new ResumeResponse(); + res.setStatus(makeStatus(Code.OK, '')); + const stats = new ConsumerStats(); + if (progress !== undefined) { + const p = new StartFromProgress(); + p.setMessagesSkipped(progress.skipped); + p.setMessagesToSkip(progress.toSkip); + p.setComplete(progress.complete ?? false); + stats.setStartFromProgress(p); + } + res.setConsumerStats(stats); + return res; +}; + +const lastProgress = (sinks: ReturnType) => { + const calls = sinks.setStartFromProgress.mock.calls; + return calls[calls.length - 1][0]; +}; + +describe('BUG-1: message-less ResumeResponse', () => { + it('surfaces the server error instead of throwing on a status-only response', () => { + const res = new ResumeResponse(); + res.setStatus(makeStatus(Code.FAILED_PRECONDITION, 'No such consumer consumer session: __dekaf_x')); + + const sinks = makeSinks(); + expect(() => handleResumeResponse(res, sinks)).not.toThrow(); + + expect(sinks.notifyError).toHaveBeenCalledTimes(1); + expect(String(sinks.notifyError.mock.calls[0][0])).toContain('No such consumer consumer session'); + // Nothing arrived, so the counters must keep their previous values. + expect(sinks.messagesProcessed.current).toBe(7); + expect(sinks.messagesLoaded.current).toBe(9); + expect(sinks.messagesBuffer.current).toHaveLength(0); + }); + + it('buffers valued messages and advances the counters on an OK response', () => { + const res = new ResumeResponse(); + res.setStatus(makeStatus(Code.OK, '')); + // The runner also emits count-only placeholders (no value) - those advance counters but render + // no row, so they must not reach the buffer. + res.setMessagesList([ + makeMessage({ value: '{"a":1}', processed: 41, sent: 40 }), + makeMessage({ processed: 42, sent: 40 }), + ]); + + const sinks = makeSinks(); + handleResumeResponse(res, sinks); + + expect(sinks.notifyError).not.toHaveBeenCalled(); + expect(sinks.messagesBuffer.current).toHaveLength(1); + expect(sinks.messagesProcessed.current).toBe(42); + expect(sinks.messagesLoaded.current).toBe(40); + }); + + it('reports per-message errors while still processing the messages that came with them', () => { + const res = new ResumeResponse(); + res.setStatus(makeStatus(Code.UNKNOWN, 'filter failed: boom')); + res.setMessagesList([makeMessage({ value: '{"a":1}', processed: 5, sent: 5 })]); + + const sinks = makeSinks(); + handleResumeResponse(res, sinks); + + expect(String(sinks.notifyError.mock.calls[0][0])).toContain('filter failed: boom'); + expect(sinks.messagesBuffer.current).toHaveLength(1); + expect(sinks.messagesProcessed.current).toBe(5); + }); +}); + +describe('start-from skip progress carried on a ResumeResponse', () => { + it('surfaces a skip that is big enough to be worth explaining', () => { + const sinks = makeSinks(); + + handleResumeResponse(makeResumeWithProgress({ skipped: 2_000_000, toSkip: 10_000_000 }), sinks); + + expect(lastProgress(sinks)).toEqual({ messagesSkipped: 2_000_000, messagesToSkip: 10_000_000 }); + }); + + it('stays quiet for a skip that resolves fast enough not to need a UI', () => { + const sinks = makeSinks(); + + // Exactly at the threshold is still "small" - the requirement is EXCEEDS 1,000,000. + handleResumeResponse(makeResumeWithProgress({ skipped: 10, toSkip: startFromProgressDisplayThreshold }), sinks); + expect(lastProgress(sinks)).toBeUndefined(); + + handleResumeResponse(makeResumeWithProgress({ skipped: 10, toSkip: 500 }), sinks); + expect(lastProgress(sinks)).toBeUndefined(); + + // One message over the line, and it is worth showing. + handleResumeResponse( + makeResumeWithProgress({ skipped: 10, toSkip: startFromProgressDisplayThreshold + 1 }), + sinks + ); + expect(lastProgress(sinks)).toEqual({ + messagesSkipped: 10, + messagesToSkip: startFromProgressDisplayThreshold + 1, + }); + }); + + it('clears the indicator once the skip completes', () => { + const sinks = makeSinks(); + + handleResumeResponse(makeResumeWithProgress({ skipped: 2_000_000, toSkip: 10_000_000 }), sinks); + handleResumeResponse(makeResumeWithProgress({ skipped: 10_000_000, toSkip: 10_000_000, complete: true }), sinks); + + expect(lastProgress(sinks)).toBeUndefined(); + }); + + it('clears the indicator when the stats stop carrying progress', () => { + const sinks = makeSinks(); + + handleResumeResponse(makeResumeWithProgress({ skipped: 2_000_000, toSkip: 10_000_000 }), sinks); + // `consumer_stats` is present but `start_from_progress` is not - the documented steady state. + handleResumeResponse(makeResumeWithProgress(), sinks); + + expect(lastProgress(sinks)).toBeUndefined(); + }); + + it('clears the indicator when consumer_stats is absent altogether', () => { + const sinks = makeSinks(); + + handleResumeResponse(makeResumeWithProgress({ skipped: 2_000_000, toSkip: 10_000_000 }), sinks); + + // Every ordinary data response: status + messages, no stats at all. It must not leave the + // "skipping..." panel on screen forever. + const plain = new ResumeResponse(); + plain.setStatus(makeStatus(Code.OK, '')); + plain.setMessagesList([makeMessage({ value: '{"a":1}', processed: 1, sent: 1 })]); + expect(() => handleResumeResponse(plain, sinks)).not.toThrow(); + + expect(lastProgress(sinks)).toBeUndefined(); + }); + + it('handles a stats-only, message-less response without throwing or disturbing the counters', () => { + // While the skip runs the server has nothing to deliver, so these responses carry NO messages - + // the same shape that used to kill the stream by dereferencing a message that was not there. + const sinks = makeSinks(); + + expect(() => + handleResumeResponse(makeResumeWithProgress({ skipped: 3_000_000, toSkip: 9_000_000 }), sinks) + ).not.toThrow(); + + expect(lastProgress(sinks)).toEqual({ messagesSkipped: 3_000_000, messagesToSkip: 9_000_000 }); + expect(sinks.notifyError).not.toHaveBeenCalled(); + expect(sinks.messagesProcessed.current).toBe(7); + expect(sinks.messagesLoaded.current).toBe(9); + expect(sinks.messagesBuffer.current).toHaveLength(0); + }); + + it('still reports progress on a response whose status is an error', () => { + // The status is handled first and then returns nothing; the progress must not be lost with it. + const res = makeResumeWithProgress({ skipped: 4_000_000, toSkip: 8_000_000 }); + res.setStatus(makeStatus(Code.UNKNOWN, 'filter failed: boom')); + + const sinks = makeSinks(); + handleResumeResponse(res, sinks); + + expect(sinks.notifyError).toHaveBeenCalledTimes(1); + expect(lastProgress(sinks)).toEqual({ messagesSkipped: 4_000_000, messagesToSkip: 8_000_000 }); + }); +}); + +describe('BUG-4: a crash inside the session stays local', () => { + it('renders a visible error instead of unmounting the app', () => { + // A config the top-level shape check accepts, whose TARGET is a foreign persisted item - the + // per-target editor then throws mid-render. The route must not end up as an empty document. + const libraryContext = { + pulsarResource: { + type: 'topic' as const, + tenant: 'public', + namespace: 'default', + topicPersistency: 'persistent' as const, + topic: 'a-topic', + }, + }; + const config = getDefaultManagedItem('consumer-session-config', libraryContext); + const foreignTarget = getDefaultManagedItem('message-filter', libraryContext); + const broken = { + ...config, + spec: { ...(config as { spec: Record }).spec, targets: [{ type: 'value', val: foreignTarget }] }, + }; + + render( + React.createElement( + SWRConfig, + { value: { shouldRetryOnError: false, refreshInterval: 0, revalidateOnFocus: false } }, + React.createElement(ConsumerSession, { + initialConfig: { type: 'value', val: broken } as never, + libraryContext, + }) + ) + ); + + expect(screen.getByTestId('cs-crashed')).toBeTruthy(); + expect(document.body.textContent).toContain('could not be rendered'); + }); +}); + +describe('BUG-2: pause failures', () => { + // `status: undefined` builds a PauseResponse with no status at all. + const pauseRespondingWith = (status?: { code: number; message: string }) => { + const res = new PauseResponse(); + if (status !== undefined) { + res.setStatus(makeStatus(status.code, status.message)); + } + return { pause: (_request: unknown, _metadata: unknown) => Promise.resolve(res) }; + }; + + it('reports a non-OK PauseResponse', async () => { + const notifyError = jest.fn(); + const outcome = await pauseConsumer({ + client: pauseRespondingWith({ + code: Code.FAILED_PRECONDITION, + message: 'No such consumer consumer session: __dekaf_x', + }), + consumerName: '__dekaf_x', + notifyError, + }); + + expect(notifyError).toHaveBeenCalledTimes(1); + expect(String(notifyError.mock.calls[0][0])).toContain('No such consumer consumer session'); + // Reporting is not enough: the caller decides whether the session may CALL ITSELF paused, and + // it can only do that if the refusal comes back to it. + expect(outcome).toBe('failed'); + }); + + it('reports a PauseResponse that carries no status at all', async () => { + const notifyError = jest.fn(); + const outcome = await pauseConsumer({ client: pauseRespondingWith(), consumerName: '__dekaf_x', notifyError }); + + expect(notifyError).toHaveBeenCalledTimes(1); + expect(outcome).toBe('failed'); + }); + + it('stays silent on an OK response', async () => { + const notifyError = jest.fn(); + const outcome = await pauseConsumer({ + client: pauseRespondingWith({ code: Code.OK, message: '' }), + consumerName: '__dekaf_x', + notifyError, + }); + + expect(notifyError).not.toHaveBeenCalled(); + expect(outcome).toBe('paused'); + }); + + it('still reports a rejected pause call', async () => { + const notifyError = jest.fn(); + const outcome = await pauseConsumer({ + client: { pause: () => Promise.reject(new Error('transport down')) }, + consumerName: '__dekaf_x', + notifyError, + }); + + expect(notifyError).toHaveBeenCalledTimes(1); + expect(String(notifyError.mock.calls[0][0])).toContain('transport down'); + expect(outcome).toBe('failed'); + }); +}); + +describe('the start-from DEGRADATION record', () => { + const progressFrame = (over: { degraded?: boolean; abandoned?: string[] } = {}) => { + const res = new ResumeResponse(); + res.setStatus(makeStatus(Code.OK, '')); + const stats = new ConsumerStats(); + const progress = new StartFromProgress(); + progress.setMessagesSkipped(10); + progress.setMessagesToSkip(100); + progress.setDegraded(over.degraded ?? false); + progress.setAbandonedStreamsList(over.abandoned ?? []); + stats.setStartFromProgress(progress); + res.setConsumerStats(stats); + return res; + }; + + it('a degraded frame hands the abandoned streams to the sink - size threshold does NOT apply', () => { + // The skip here (100) is far below the progress panel's display threshold; the degradation + // must surface anyway - a best-effort answer on a small skip is still best-effort. + const sinks = makeSinks(); + handleResumeResponse(progressFrame({ degraded: true, abandoned: ['cs-1@persistent://t/ns/a-partition-1'] }), sinks); + + expect(sinks.setStartFromDegradation).toHaveBeenCalledTimes(1); + expect(sinks.setStartFromDegradation.mock.calls[0][0]).toEqual(['cs-1@persistent://t/ns/a-partition-1']); + }); + + it('an ordinary frame never touches the degradation sink - the record is sticky, not cleared per frame', () => { + const sinks = makeSinks(); + handleResumeResponse(progressFrame({ degraded: false }), sinks); + + expect(sinks.setStartFromDegradation).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/components/ui/ConsumerSession/ConsumerSession.tsx b/ui/components/ui/ConsumerSession/ConsumerSession.tsx index 747063b96..39c18bc5b 100644 --- a/ui/components/ui/ConsumerSession/ConsumerSession.tsx +++ b/ui/components/ui/ConsumerSession/ConsumerSession.tsx @@ -9,13 +9,14 @@ import { ResumeResponse, DeleteConsumerRequest, PauseRequest, + PauseResponse, } from '../../../grpc-web/tools/teal/pulsar/ui/api/v1/consumer_pb'; import cts from "../../ui/ChildrenTable/ChildrenTable.module.css"; import MessageComponent from './Message/Message'; import { nanoid } from 'nanoid'; import * as Notifications from '../../app/contexts/Notifications'; import { ItemContent, TableVirtuoso, VirtuosoHandle } from 'react-virtuoso'; -import { ClientReadableStream } from 'grpc-web'; +import { ClientReadableStream, Metadata } from 'grpc-web'; import { createDeadline } from '../../../proto-utils/proto-utils'; import { Code } from '../../../grpc-web/google/rpc/code_pb'; import { useInterval } from '../../app/hooks/use-interval'; @@ -35,14 +36,127 @@ import { getColoring } from './coloring'; import { getValueProjectionThs } from './value-projections/value-projections-utils'; import { Th } from './Th'; import { useColumnWidths } from '../resizable/useColumnWidths'; -import { MessageColumnKey, messageColumnDefaultWidths } from './message-columns'; +import useLocalStorage from 'use-local-storage-state'; +import { localStorageKeys } from '../../local-storage-keys'; +import { MessageColumnKey, messageColumnDefaultWidths, messageThMeta, reorderableMessageColumns } from './message-columns'; +import { useColumnOrder } from '../resizable/useColumnOrder'; import MessageDetails from './Message/MessageDetails/MessageDetails'; import ActionButton from '../ActionButton/ActionButton'; import { handleKeyDown } from './keyboard'; import { useDebounce } from 'use-debounce'; +import { ErrorBoundary } from 'react-error-boundary'; +import NothingToShow from '../NothingToShow/NothingToShow'; +import StartFromProgress, { StartFromSkipProgress, startFromProgressDisplayThreshold } from './StartFromProgress/StartFromProgress'; +import { displayItemLimit } from './SessionConfiguration/display-items'; const consoleCss = "color: #276ff4; font-weight: var(--font-weight-bold);" as const; +export type ResumeResponseSinks = { + messagesBuffer: { current: Message[] }; + messagesProcessed: { current: number }; + messagesLoaded: { current: number }; + notifyError: (message: string) => void; + setStartFromProgress: (progress: StartFromSkipProgress | undefined) => void; + setStartFromDegradation: (abandonedStreams: string[]) => void; +}; + +// `consumer_stats` is absent on an ordinary data response, and `start_from_progress` inside it is +// absent unless the start-from is still being resolved - so EVERY field here is optional and the +// answer for "nothing to report" is `undefined`, which clears whatever is on screen. Returning a +// stale value on an absent field would pin a "skipping..." panel up forever. +function readStartFromProgress(res: ResumeResponse): StartFromSkipProgress | undefined { + const progress = res.getConsumerStats()?.getStartFromProgress(); + + if (progress === undefined || progress.getComplete()) { + return undefined; + } + + const messagesToSkip = progress.getMessagesToSkip(); + if (messagesToSkip <= startFromProgressDisplayThreshold) { + return undefined; + } + + return { messagesSkipped: progress.getMessagesSkipped(), messagesToSkip }; +} + +// The server reports a missing session or a failed resume with a status-only, MESSAGE-LESS +// ResumeResponse (see ConsumerServiceImpl.resume). The status is therefore handled first, and the +// trailing message's counters are read only when the response actually carries a message - +// otherwise the handler threw a TypeError, which killed the stream and swallowed the server error. +export function handleResumeResponse(res: ResumeResponse, sinks: ResumeResponseSinks): void { + if (res.getStatus()?.getCode() !== Code.OK) { + sinks.notifyError(`${res.getStatus()?.getMessage()}`); + } + + // Before any early return below: while a skip is being resolved these responses carry progress and + // NOTHING else, and once it finishes they stop carrying progress at all. + sinks.setStartFromProgress(readStartFromProgress(res)); + + // The DEGRADATION record is separate from the progress panel on purpose: it has no size + // threshold (a degraded small skip is still degraded) and it is STICKY - the sink keeps it for + // the session's life, because a best-effort answer does not become exact when the frame that + // reported it scrolls away. + const degraded = res.getConsumerStats()?.getStartFromProgress(); + if (degraded?.getDegraded()) { + sinks.setStartFromDegradation(degraded.getAbandonedStreamsList()); + } + + const newMessages = res.getMessagesList(); + + for (let i = 0; i < newMessages.length; i++) { + if (newMessages[i]?.hasValue()) { + sinks.messagesBuffer.current.push(newMessages[i]); + } + } + + const lastMessage = newMessages[newMessages.length - 1]; + if (lastMessage === undefined) { + return; + } + + sinks.messagesProcessed.current = lastMessage.getNumMessageProcessed() + sinks.messagesLoaded.current = lastMessage.getNumMessageSent() +} + +export type PauseCapableClient = { + pause: (request: PauseRequest, metadata: Metadata | null) => Promise; +}; + +/** Whether the SERVER confirmed the pause - not whether the UI stopped seeing messages. */ +export type PauseOutcome = 'paused' | 'failed'; + +// A pause that the server refuses (e.g. FAILED_PRECONDITION for a session it doesn't know) comes +// back as a RESOLVED response carrying a non-OK status, not as a rejected call - so the response +// status has to be inspected, or the failure passes unnoticed while the session claims to pause. +// +// The outcome is RETURNED rather than only reported: the session's `paused` state is a claim about +// the server, and a refused pause leaves the server stream running, so the caller has to be able to +// tell the two apart. +export async function pauseConsumer(args: { + client: PauseCapableClient; + consumerName: string; + notifyError: (message: string) => void; +}): Promise { + const pauseReq = new PauseRequest(); + pauseReq.setConsumerName(args.consumerName); + const res = await args.client.pause(pauseReq, { deadline: createDeadline(10) }) + .catch((err) => { + args.notifyError(`Unable to pause consumer ${args.consumerName}. ${err}`); + return undefined; + }); + + if (res === undefined) { + return 'failed'; + } + + if (res.getStatus()?.getCode() !== Code.OK) { + args.notifyError(`Unable to pause consumer ${args.consumerName}. ${res.getStatus()?.getMessage()}`); + return 'failed'; + } + + return 'paused'; +} + export type SessionProps = { sessionKey: number; configValOrRef: ManagedConsumerSessionConfigValOrRef; @@ -81,6 +195,50 @@ const Session: React.FC = (props) => { const [sort, setSort] = useState({ key: 'publishTime', direction: 'asc' }); const { getWidth: getColumnWidth, startResize: startColumnResize, suppressSortClickRef } = useColumnWidths('consumer-session-messages', messageColumnDefaultWidths); + // Draggable column order for the message table, persisted like the widths are. Index and + // publish time stay pinned in front (they are the sticky pair whose offsets depend on each + // other); everything else reorders freely, and the ROWS follow the header via `columnOrder`. + const { order: messageColumnOrder, moveBefore: moveMessageColumn } = useColumnOrder( + 'consumer-session-messages', + reorderableMessageColumns + ); + const [dragOverMessageColumn, setDragOverMessageColumn] = useState(undefined); + const draggingMessageColumnRef = useRef(undefined); + const columnDragProps = (key: MessageColumnKey): React.ThHTMLAttributes => ({ + draggable: true, + onDragStart: (e) => { + if (suppressSortClickRef.current) { + e.preventDefault(); + return; + } + draggingMessageColumnRef.current = key; + e.dataTransfer.setData('text/plain', key); + e.dataTransfer.effectAllowed = 'move'; + }, + onDragEnd: () => { + draggingMessageColumnRef.current = undefined; + setDragOverMessageColumn(undefined); + }, + onDragOver: (e) => { + if (draggingMessageColumnRef.current === undefined) { + return; + } + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + setDragOverMessageColumn(current => (current === key ? current : key)); + }, + onDragLeave: () => setDragOverMessageColumn(current => (current === key ? undefined : current)), + onDrop: (e) => { + e.preventDefault(); + const dragged = draggingMessageColumnRef.current; + draggingMessageColumnRef.current = undefined; + setDragOverMessageColumn(undefined); + if (dragged !== undefined && dragged !== key) { + moveMessageColumn(dragged, key); + } + }, + }); + const resizeProps = (key: MessageColumnKey) => ({ width: getColumnWidth(key), onResizeStart: (x: number) => startColumnResize(key, x), @@ -88,6 +246,90 @@ const Session: React.FC = (props) => { }); const [_searchInResults, setSearchInResults] = useState(''); const [searchInResults] = useDebounce(_searchInResults, 1000); + const [startFromProgress, _setStartFromProgress] = useState(undefined); + const startFromProgressRef = useRef(undefined); + // The streams the start-from resolution gave up waiting for. STICKY for the session: set once + // degraded, cleared only when a new session is created (Play after Stop). The setter tolerates + // the same record arriving on every progress frame without re-rendering. + const [startFromDegradation, _setStartFromDegradation] = useState(undefined); + const setStartFromDegradation = useCallback((abandonedStreams: string[] | undefined) => { + _setStartFromDegradation(prev => + prev !== undefined && abandonedStreams !== undefined && prev.join('\u0000') === abandonedStreams.join('\u0000') + ? prev + : abandonedStreams + ); + }, []); + // Whether the SERVER confirmed the pause currently being attempted. A ref because the transition + // below has to read it in the same commit that armed it, and a bump counter so that a + // confirmation arriving later still re-runs that transition. + const isPauseConfirmed = useRef(false); + const [pauseConfirmations, setPauseConfirmations] = useState(0); + // Streams this session cancelled itself. grpc-web reports a client-side cancel as a stream + // `error`, and that must not be mistaken for the server dropping the session. + const selfCancelledStreams = useRef>(new WeakSet()); + // Bumped by every cleanup. A create that was in flight across one of those built a consumer for a + // session that no longer exists, and the consumer's name is generated HERE - nothing else can + // ever name it again, so nothing else could ever delete it. + const sessionGeneration = useRef(0); + // Whether THIS generation's Create ever succeeded. A failed create installs nothing server-side + // (the build releases everything and stores nothing), so cleanup must not send a Delete for it: + // fire-and-forget, that Delete could arrive after a retry's successful Create - same name - and + // remove the healthy replacement. + const createSucceeded = useRef(false); + // Whether the pause currently in effect is one the HIDDEN TAB asked for - the only kind a + // returning tab may undo. + const isPausedByHiddenTab = useRef(false); + + // The browser-wide delivery controls (see local-storage-keys.ts for why they are NOT session + // config). Mirrored into refs because both are read inside stable callbacks - the rate when a + // resume request is built, the pause threshold on every stream chunk. + const [rateLimitSetting] = useLocalStorage(localStorageKeys.consumerSessionRateLimit, { defaultValue: 0 }); + const [pauseAfterSetting] = useLocalStorage(localStorageKeys.consumerSessionPauseAfterLoaded, { defaultValue: 0 }); + const rateLimitRef = useRef(0); + const pauseAfterRef = useRef(0); + const sessionStateRef = useRef('new'); + // The loaded count at which the session pauses itself, or Infinity when disarmed. Armed on every + // entry into `running` at "loaded + n", so Play behaves as "load the next n"; disarmed the + // moment it fires so one crossing triggers exactly one pause. + const nextPauseAtLoaded = useRef(Infinity); + + useEffect(() => { sessionStateRef.current = sessionState; }, [sessionState]); + useEffect(() => { + rateLimitRef.current = Number.isFinite(rateLimitSetting) && rateLimitSetting > 0 ? Math.floor(rateLimitSetting) : 0; + }, [rateLimitSetting]); + useEffect(() => { + pauseAfterRef.current = Number.isFinite(pauseAfterSetting) && pauseAfterSetting > 0 ? Math.floor(pauseAfterSetting) : 0; + // DELIBERATELY NOT re-armed mid-run. The server half of this setting - the delivery budget - + // is fixed when the Resume request is built, so a mid-run edit can only desync the two: the + // server still stops at the OLD n while the client waits for the new one (or, after a clear, + // for infinity), and the session sits in `running` with a silent stream forever. Both halves + // change together on the next Play, which is what the tooltip promises. + }, [pauseAfterSetting]); + + const cancelStream = useCallback((s: ClientReadableStream | undefined) => { + if (s === undefined) { + return; + } + + selfCancelledStreams.current.add(s); + s.cancel(); + }, []); + + // The stream calls this on every response, most of which report nothing - re-rendering the whole + // session for an unchanged value (usually `undefined`) would be pure waste. + const setStartFromProgress = useCallback((progress: StartFromSkipProgress | undefined) => { + const prev = startFromProgressRef.current; + const isSame = prev === undefined + ? progress === undefined + : progress !== undefined && prev.messagesSkipped === progress.messagesSkipped && prev.messagesToSkip === progress.messagesToSkip; + + if (isSame) { + return; + } + + startFromProgressRef.current = progress; + _setStartFromProgress(progress); + }, []); const currentTopic = useMemo(() => props.libraryContext.pulsarResource.type === 'topic' ? props.libraryContext.pulsarResource : undefined, [props.libraryContext]); const currentTopicFqn: string | undefined = useMemo(() => currentTopic === undefined ? undefined : `${currentTopic.topicPersistency}://${currentTopic.tenant}/${currentTopic.namespace}/${currentTopic.topic}`, [currentTopic]); @@ -99,7 +341,11 @@ const Session: React.FC = (props) => { console.warn(err); return undefined; } - }, [props.configValOrRef]); + // `currentTopicFqn` is part of the result - a target that follows "the current topic" resolves + // to it. Leaving it out kept a session pointed at the topic it was FIRST rendered with, which + // matters most between `persistent://t/n/x` and `non-persistent://t/n/x`: two different topics + // whose pages differ in nothing else. + }, [props.configValOrRef, currentTopicFqn]); const scrollToBottom = () => { const scrollParent = tableRef.current?.children[0]; @@ -123,7 +369,10 @@ const Session: React.FC = (props) => { setMessages((messages) => { const newMessages = messages .concat(messagesBuffer.current.map(msg => messageDescriptorFromPb(msg))) - .slice(-(config?.numDisplayItems || 0)); + // `slice(-limit)` keeps the whole array for ANY non-positive limit - `slice(-0)` is + // `slice(0)` - so the one place that decides what a limit means decides it here too. + // `|| 0` used to be that decision, and it turned "limit the display" into "do not". + .slice(-displayItemLimit(config?.numDisplayItems)); newMessages.forEach((message, i) => { message.displayIndex = (i + 1); @@ -136,51 +385,92 @@ const Session: React.FC = (props) => { }, messagesLoadedPerSecond.now > 0 ? 250 : false); const streamDataHandler = useCallback((res: ResumeResponse) => { - const newMessages = res.getMessagesList(); + handleResumeResponse(res, { messagesBuffer, messagesProcessed, messagesLoaded, notifyError, setStartFromProgress, setStartFromDegradation }); - for (let i = 0; i < newMessages.length; i++) { - if (newMessages[i]?.hasValue()) { - messagesBuffer.current.push(newMessages[i]); - } - } - - messagesProcessed.current = newMessages[newMessages.length - 1].getNumMessageProcessed() - messagesLoaded.current = newMessages[newMessages.length - 1].getNumMessageSent() - - if (res.getStatus()?.getCode() !== Code.OK) { - notifyError(`${res.getStatus()?.getMessage()}`); + // The auto-pause: the same 'pausing' the toolbar button sends, triggered by the loaded + // counter crossing its armed threshold. Disarm FIRST - chunks keep arriving while the pause + // RPC is in flight, and this handler runs for every one of them. + if (messagesLoaded.current >= nextPauseAtLoaded.current && sessionStateRef.current === 'running') { + nextPauseAtLoaded.current = Infinity; + setSessionState('pausing'); } }, []); + // A resume stream can stop delivering for reasons that are NOT "no messages yet": the transport + // drops, the server completes or aborts the call, the session disappears. Without listeners for + // those the session sits in `running` for ever with frozen counters, waiting for messages that + // can no longer arrive. `paused` is the recoverable landing state - Play resumes from there. useEffect(() => { streamRef.current = stream; - (async () => { - if (stream === undefined) { + if (stream === undefined) { + return; + } + + let isDisposed = false; + const isOurOwnCancel = () => selfCancelledStreams.current.has(stream); + + const stopStreaming = () => { + setSessionState((state) => (state === 'running' || state === 'pausing') ? 'paused' : state); + }; + + const errorHandler = (err: unknown) => { + if (isDisposed || isOurOwnCancel()) { return; } - stream.removeListener('data', streamDataHandler); - stream.on('data', streamDataHandler); - })() + notifyError(`Consumer session stream failed. ${(err as { message?: string })?.message ?? err}`); + stopStreaming(); + }; + + const endHandler = () => { + if (isDisposed || isOurOwnCancel()) { + return; + } + + stopStreaming(); + }; + stream.on('data', streamDataHandler); + stream.on('error', errorHandler); + stream.on('end', endHandler); + + return () => { + isDisposed = true; + stream.removeListener('data', streamDataHandler); + stream.removeListener('error', errorHandler); + stream.removeListener('end', endHandler); + }; }, [stream]); + const deleteConsumer = useCallback(async (name: string) => { + const deleteConsumerReq = new DeleteConsumerRequest(); + deleteConsumerReq.setConsumerName(name); + await consumerServiceClient.deleteConsumer(deleteConsumerReq, { deadline: createDeadline(10) }) + .catch((err) => notifyError(`Unable to delete consumer ${name}. ${err}`)); + }, []); + const cleanup = useCallback(async () => { console.info(`%cCleaning up session: ${props.sessionKey}`, consoleCss); - streamRef.current?.cancel(); + // Whatever was still being created belongs to a session that no longer exists. The bump is what + // tells that in-flight create so - see createConsumer below. + sessionGeneration.current += 1; + + cancelStream(streamRef.current); streamRef.current?.removeListener('data', streamDataHandler); setMessages([]); - - async function deleteConsumer() { - const deleteConsumerReq = new DeleteConsumerRequest(); - deleteConsumerReq.setConsumerName(consumerName.current); - await consumerServiceClient.deleteConsumer(deleteConsumerReq, { deadline: createDeadline(10) }) - .catch((err) => notifyError(`Unable to delete consumer ${consumerName.current}. ${err}`)); + // A NEW session gets a clean slate: the degradation record belongs to the session that + // produced it, not to the page. + setStartFromDegradation(undefined); + + // Only a consumer that was actually INSTALLED gets a cleanup Delete. A failed create stored + // nothing server-side, and its fire-and-forget Delete could arrive AFTER a retry's successful + // Create under the same name - deleting the healthy replacement. + if (createSucceeded.current) { + deleteConsumer(consumerName.current); // Don't await this } - - deleteConsumer(); // Don't await this + createSucceeded.current = false; }, [prevSessionState, sessionState]); useEffect(() => { @@ -189,55 +479,129 @@ const Session: React.FC = (props) => { } }, []); + // A closing tab has to delete the consumer too, and `window` outlives every session - so the + // registration must be OWNED by an effect. Registering it from the initialize path left one stale + // closure installed per Stop remount, each still holding a dead session's consumer name, and an + // eventual unload then fired a Delete for every session the tab had ever run. + // + // Nothing exists to delete before the first Play, so `new` registers nothing at all. + useEffect(() => { + if (sessionState === 'new') { + return; + } + + window.addEventListener('beforeunload', cleanup); + return () => window.removeEventListener('beforeunload', cleanup); + }, [cleanup, sessionState]); + const initializeSession = () => { async function createConsumer() { if (config === undefined) { + // Play is disabled in this state, so this is a guard rather than a path; still, staying on + // `initializing` would read as a hang. + notifyError('This session configuration could not be read. Check the configuration, or start a new session.'); + setSessionState('new'); return; } const req = new CreateConsumerRequest(); req.setConsumerName(consumerName.current); - const consumerSessionConfigPb = consumerSessionConfigToPb(config); + + // Building the request is where the parts of the configuration that are still TEXT get + // parsed - a message id, for one - so it can fail on a configuration that converted fine. + // Unhandled, the rejection killed this function silently and left the session on + // "initializing" for ever, looking like a hang instead of a configuration error. + let consumerSessionConfigPb; + try { + consumerSessionConfigPb = consumerSessionConfigToPb(config); + } catch (err) { + notifyError(`Unable to create consumer ${consumerName.current}. ${(err as Error)?.message ?? err}`); + setSessionState('new'); + return; + } + req.setConsumerSessionConfig(consumerSessionConfigPb); + // Which session this create belongs to. Everything after the await has to be checked against + // it: the user can Stop - which remounts the session - while the server is still working. + const generation = sessionGeneration.current; + const res = await consumerServiceClient.createConsumer(req, {}).catch(err => notifyError(`Unable to create consumer ${consumerName.current}. ${err}`)); + + // A create that did not succeed leaves NOTHING running: no consumer, no stream, nothing that + // can ever move the session on. Staying on `initializing` therefore claims a round trip is + // still in progress AND disables Play, so the only way out was Stop - which throws away every + // message loaded so far. `new` is the honest state, and Play can retry from it. if (res === undefined) { + setSessionState('new'); return; } const status = res.getStatus(); const code = status?.getCode(); - if (code === Code.OK) { - setSessionState('running'); - } - if (code !== Code.OK) { const errorMessage = status?.getMessage(); notifyError(`Unable to create consumer. ${errorMessage}`); + setSessionState('new'); return; } + + // The consumer now exists on the server, subscribed and consuming - but the session that + // asked for it is gone, so nothing on screen owns it and nothing knows its name. Delete it + // rather than leaving it running for the lifetime of the process. + if (generation !== sessionGeneration.current) { + deleteConsumer(consumerName.current); + return; + } + + createSucceeded.current = true; + // The tab can have gone HIDDEN while the server was still building the consumer. Resuming + // into a hidden tab starts a stream nobody is watching and - worse - arms nothing that the + // hidden-tab machinery would undo, so returning to the tab found it stalled with no + // recovery. The consumers exist but were never resumed (their gates start closed), so + // `paused` is the honest state; the visibility handler's ordinary return path then resumes + // it the moment the tab is visible again. + if (document.visibilityState === 'hidden') { + isPausedByHiddenTab.current = true; + setSessionStateBeforeWindowBlur('running'); + setSessionState('paused'); + return; + } + setSessionState('running'); } createConsumer(); - - window.addEventListener('beforeunload', cleanup); - return () => { - window.removeEventListener('beforeunload', cleanup); - }; }; // Stream's connection pauses on window blur and we don't receive new messages. // Here we are trying to handle this situation. + // + // Only `running` owns a live server stream, and only a live stream can stall. `new` and + // `initializing` have no consumer on the server at all, and `paused` already stopped one: asking + // the server to pause any of those asks about a session it does not know, and its refusal is then + // read as "still running" - which offers Resume for a consumer that never existed. Sessions the + // USER paused must also stay paused when the tab comes back, so only a pause this handler caused + // is undone here. const handleVisibilityChange = () => { if (document.visibilityState === 'hidden') { + if (sessionState !== 'running') { + return; + } + + isPausedByHiddenTab.current = true; setSessionStateBeforeWindowBlur(sessionState); setSessionState('pausing'); return; } if (document.visibilityState === 'visible') { + if (!isPausedByHiddenTab.current) { + return; + } + + isPausedByHiddenTab.current = false; setSessionState(sessionStateBeforeWindowBlur); return; } @@ -261,12 +625,31 @@ const Session: React.FC = (props) => { if (sessionState === 'pausing') { console.info(`%cPausing session: ${props.sessionKey}`, consoleCss); - const pauseReq = new PauseRequest(); - pauseReq.setConsumerName(consumerName.current); - consumerServiceClient.pause(pauseReq, { deadline: createDeadline(10) }) - .catch((err) => notifyError(`Unable to pause consumer ${consumerName.current}. ${err}`)); + // Armed synchronously, so the transition below cannot read a confirmation left over from an + // earlier pause in this same commit. + isPauseConfirmed.current = false; + let isAbandoned = false; - return; + pauseConsumer({ client: consumerServiceClient, consumerName: consumerName.current, notifyError }) + .then((outcome) => { + if (isAbandoned) { + return; + } + + if (outcome === 'paused') { + isPauseConfirmed.current = true; + setPauseConfirmations(n => n + 1); + return; + } + + // The server did not pause: its stream is still live and messages can still arrive, so + // the honest state is the one it is actually in. + setSessionState('running'); + }); + + return () => { + isAbandoned = true; + }; } if (sessionState === 'paused') { @@ -282,11 +665,28 @@ const Session: React.FC = (props) => { if (sessionState === 'running') { console.info(`%cRunning session: ${props.sessionKey}`, consoleCss); + // Arm the auto-pause for this run: "the next n loaded from here". Every entry into + // `running` re-arms, so resuming a session paused at n loads n more. + nextPauseAtLoaded.current = pauseAfterRef.current > 0 ? messagesLoaded.current + pauseAfterRef.current : Infinity; + const resumeReq = new ResumeRequest(); resumeReq.setConsumerName(consumerName.current); - stream?.cancel(); - stream?.removeListener('data', streamDataHandler); - const newStream = consumerServiceClient.resume(resumeReq, { deadline: createDeadline(60 * 10) }); + // The start-from progress rides along on ConsumerStats; without this the server has no reason + // to compute or send it. + resumeReq.setIncludeConsumerStats(true); + // The browser-wide delivery rate limit rides every resume - see local-storage-keys.ts for + // why it is per-request rather than session config. 0 = unlimited. + resumeReq.setMaxMessagesPerSecond(rateLimitRef.current); + // The server-side half of "pause after n": deliver at most n more on this stream, counted + // at the send. The client threshold below still drives the state machine, but the SERVER + // guarantees the count - a chunk that never leaves the server cannot overshoot a screenful. + resumeReq.setMaxMessagesToDeliver(pauseAfterRef.current); + cancelStream(stream); + // NO DEADLINE: resume is a long-lived server stream that lives until the user stops it or the + // transport drops. Any fixed budget is a wrong guess - a Skip-N over millions of messages can + // spend longer than that resolving before it delivers its first message, and the deadline + // would kill the stream mid-skip. The error/end listeners are what notice a stream that ends. + const newStream = consumerServiceClient.resume(resumeReq, {}); setStream(() => newStream); return; } @@ -304,11 +704,14 @@ const Session: React.FC = (props) => { } }, [sessionState]); + // The local rate reaching zero says the last in-flight messages have landed - it does NOT say the + // server paused. Both are required: the server's confirmation is what makes `paused` true, the + // quiet second is what makes it look finished. useEffect(() => { - if (sessionState === 'pausing' && messagesLoadedPerSecond.now === 0) { + if (sessionState === 'pausing' && isPauseConfirmed.current && messagesLoadedPerSecond.now === 0) { setSessionState('paused'); } - }, [sessionState, messagesLoadedPerSecond]); + }, [sessionState, messagesLoadedPerSecond, pauseConfirmations]); const isShowTooltips = sessionState !== 'running' && sessionState !== 'pausing'; @@ -334,6 +737,7 @@ const Session: React.FC = (props) => { isShowTooltips={isShowTooltips} sessionState={sessionState} selectedMessages={selectedMessages} + columnOrder={messageColumnOrder} coloring={coloring} valueProjectionThs={valueProjectionThs} getColumnWidth={getColumnWidth} @@ -377,6 +781,9 @@ const Session: React.FC = (props) => { className={s.ConsumerSession} data-testid="cs-session" data-cs-state={sessionState} + // How many messages are actually being held, after retention. The count of rendered rows is + // not this number - the table is virtualized - and nothing else on screen reports it. + data-cs-retained={messages.length} style={{ gridTemplateRows: props.isShowConsole ? 'min-content 1fr 400rem' : 'min-content 1fr 0' }} > = (props) => { numFoundInResults={messagesToShow.length} /> + {currentView === 'messages' && startFromDegradation !== undefined && ( +
+ Best effort: {startFromDegradation.length} stream{startFromDegradation.length === 1 ? '' : 's'} stayed silent while + the start position was being resolved and {startFromDegradation.length === 1 ? 'was' : 'were'} skipped past. The + skip COUNT is exact; exactly which messages were dropped may differ. +
+ )} {currentView === 'messages' && messages.length === 0 && (
{sessionState === 'initializing' && 'Initializing session...'} - {sessionState === 'running' && 'Awaiting for new messages...'} + {/* A big skip delivers nothing until it lands, so this is exactly where the session would + otherwise sit on "Awaiting for new messages..." looking hung. */} + {sessionState === 'running' && (startFromProgress === undefined + ? 'Awaiting for new messages...' + : )} {sessionState === 'paused' && 'No messages where loaded.'}
)} @@ -453,10 +875,11 @@ const Session: React.FC = (props) => { help={( <>

- When consuming from multiple topics or a single partitioned topic, the order of messages cannot be assured. + Messages are numbered in the order this session received them.

- The order of numbers in in this column represents the order in which messages were received by the consumer. + Each partition arrives in its own publish order. Messages from different topics + or partitions interleave, so there is no single "right" order across them.

)} @@ -472,159 +895,29 @@ const Session: React.FC = (props) => { help={help.publishTime} {...resizeProps('publishTime')} /> - - - {valueProjectionThs.map(vp => vp.th)} - - - - - - - - - - - - - - - + {messageColumnOrder.flatMap((columnKey) => { + const meta = messageThMeta[columnKey]; + const cells = [( + + )]; + // Value-projection columns stay glued after KEY wherever it sits - the same + // neighbourhood they have always rendered in. + if (columnKey === 'key') { + cells.push(...valueProjectionThs.map(vp => vp.th)); + } + return cells; + })} )} /> @@ -687,17 +980,39 @@ const ConsumerSession: React.FC = (props) => { const [isShowConsole, setIsShowConsole] = useState(false); return ( - setIsShowConsole(!isShowConsole)} - {...props} - onStopSession={() => setSessionKey(n => n + 1)} - configValOrRef={config} - onConfigValOrRefChange={setConfig} - libraryContext={props.libraryContext} - /> + // A saved session can reference any persisted library item, and a stored item can be of a + // foreign type or incomplete. Without a boundary, such a config throws while rendering and React + // unmounts the whole app - the route then shows an EMPTY document with no way back. Keep the + // failure local and visible instead. + ( + + This consumer session could not be rendered. +
+ {String(error?.message || error)} +
+ Check the session configuration, or start a new session. + + )} + /> + )} + > + setIsShowConsole(!isShowConsole)} + {...props} + onStopSession={() => setSessionKey(n => n + 1)} + configValOrRef={config} + onConfigValOrRefChange={setConfig} + libraryContext={props.libraryContext} + /> +
); } diff --git a/ui/components/ui/ConsumerSession/Message/Message.tsx b/ui/components/ui/ConsumerSession/Message/Message.tsx index 394af3fd9..6222567d1 100644 --- a/ui/components/ui/ConsumerSession/Message/Message.tsx +++ b/ui/components/ui/ConsumerSession/Message/Message.tsx @@ -16,6 +16,8 @@ export type MessageProps = { sessionConfig: ConsumerSessionConfig; valueProjectionThs: ValueProjectionTh[], getColumnWidth: (key: MessageColumnKey) => number, + /** The reorderable columns in render order (index and publishTime stay fixed in front). */ + columnOrder: MessageColumnKey[], onClick: React.MouseEventHandler }; @@ -29,6 +31,40 @@ const MessageComponent: React.FC = (props) => { false : props.selectedMessages.includes(props.message.numMessageProcessed); + // One cell per reorderable column, keyed exactly like the header - the row follows whatever + // order the header is dragged into. + const fieldContent: Record = { + publishTime: , + key: , + value: , + sessionTargetIndex: , + topic: , + producerName: , + schemaVersion: , + size: , + properties: , + eventTime: , + brokerPublishTime: , + messageId: , + sequenceId: , + orderingKey: , + redeliveryCount: , + sessionContextState: , + }; + + const fieldTd = (columnKey: MessageColumnKey) => ( + + {fieldContent[columnKey]} + + ); + return ( <> = (props) => { - - - - - {getValueProjectionTds({ - sessionConfig: props.sessionConfig, - valueProjectionThs: props.valueProjectionThs, - coloring: props.coloring, - message: props.message, - isSelected + {props.columnOrder.flatMap((columnKey) => { + const cells = [fieldTd(columnKey)]; + // Value-projection columns are glued after the KEY column wherever it sits - the same + // neighbourhood they have always rendered in, whatever order the rest takes. + if (columnKey === 'key') { + cells.push(...getValueProjectionTds({ + sessionConfig: props.sessionConfig, + valueProjectionThs: props.valueProjectionThs, + coloring: props.coloring, + message: props.message, + isSelected + })); + } + return cells; })} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ); } diff --git a/ui/components/ui/ConsumerSession/Message/fields.test.tsx b/ui/components/ui/ConsumerSession/Message/fields.test.tsx new file mode 100644 index 000000000..91af1d139 --- /dev/null +++ b/ui/components/ui/ConsumerSession/Message/fields.test.tsx @@ -0,0 +1,64 @@ +/** + * @jest-environment jsdom + * + * Regression: ValueField shortens a long message value to 100 chars for display, but it also + * passed that shortened string as `rawValue` - and `rawValue` is exactly what Field puts on the + * clipboard on click (and into the `title` tooltip). So copying a long value handed the user the + * ellipsised display text instead of the payload. + * + * CS-25 (e2e) clicks a value cell but its fixture value is short, so it cannot discriminate. + */ +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { ValueField } from './fields'; +import { genEmptyMessageDescriptor } from '../testing'; + +const clipboardWrites: string[] = []; + +beforeAll(() => { + // jsdom implements neither of these; Field's copy helper needs both to take the modern path. + Object.defineProperty(window, 'isSecureContext', { value: true, configurable: true }); + Object.defineProperty(window.navigator, 'clipboard', { + configurable: true, + value: { + writeText: (text: string) => { + clipboardWrites.push(text); + return Promise.resolve(); + }, + }, + }); +}); + +beforeEach(() => { + clipboardWrites.length = 0; +}); + +function renderValue(value: string) { + render(); + return screen.getByTestId('cs-cell-value'); +} + +describe('ValueField copies the whole value, not the truncated display', () => { + const longValue = `"${'x'.repeat(400)}"`; // 402 chars - well past the 100 char display limit + + it('shortens the displayed text', () => { + expect(renderValue(longValue).textContent).toBe(`${longValue.slice(0, 100)}...`); + }); + + it('copies the complete value on click', async () => { + fireEvent.click(renderValue(longValue)); + await waitFor(() => expect(clipboardWrites).toEqual([longValue])); + }); + + it('exposes the complete value as the cell title', () => { + expect(renderValue(longValue).getAttribute('title')).toBe(longValue); + }); + + it('leaves a short value untouched in both the display and the clipboard', async () => { + const shortValue = '"msg-1"'; + const cell = renderValue(shortValue); + expect(cell.textContent).toBe(shortValue); + fireEvent.click(cell); + await waitFor(() => expect(clipboardWrites).toEqual([shortValue])); + }); +}); diff --git a/ui/components/ui/ConsumerSession/Message/fields.tsx b/ui/components/ui/ConsumerSession/Message/fields.tsx index 423fd92cf..966577fd2 100644 --- a/ui/components/ui/ConsumerSession/Message/fields.tsx +++ b/ui/components/ui/ConsumerSession/Message/fields.tsx @@ -81,8 +81,10 @@ export const KeyField: React.FC = (props) => { } export const ValueField: React.FC = (props) => { - const value = props.message.value === null ? undefined : limitString(props.message.value, 100); - return + // The cell shows a shortened value, but rawValue is what gets copied to the clipboard - so it has + // to stay the complete payload, otherwise a copy hands the user ellipsised text. + const value = props.message.value === null ? undefined : props.message.value; + return } export const SessionTargetIndexField: React.FC = (props) => { diff --git a/ui/components/ui/ConsumerSession/SessionConfiguration/FilterChainEditor/FilterEditor/BasicFilterEditor/BasicMessageFilterOpInput/AnyTestOpInput/TestOpStringMatchesRegexInput/TestOpStringMatchesRegexInput.test.tsx b/ui/components/ui/ConsumerSession/SessionConfiguration/FilterChainEditor/FilterEditor/BasicFilterEditor/BasicMessageFilterOpInput/AnyTestOpInput/TestOpStringMatchesRegexInput/TestOpStringMatchesRegexInput.test.tsx new file mode 100644 index 000000000..e72f817a2 --- /dev/null +++ b/ui/components/ui/ConsumerSession/SessionConfiguration/FilterChainEditor/FilterEditor/BasicFilterEditor/BasicMessageFilterOpInput/AnyTestOpInput/TestOpStringMatchesRegexInput/TestOpStringMatchesRegexInput.test.tsx @@ -0,0 +1,50 @@ +/** + * @jest-environment jsdom + * + * The regex `m` and `i` flags are addons on the shared Input, and an addon is a plain div with an + * `onClick` - so `disabled` on the field does nothing for them. In a read-only (library-owned) + * filter, one click used to rewrite the stored pattern's flags: the same regex, matching a + * different set of messages. + */ +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import TestOpStringMatchesRegexInput from './TestOpStringMatchesRegexInput'; + +const renderOp = (isReadOnly: boolean, flags = '') => { + const onChange = jest.fn(); + render( + + ); + return onChange; +}; + +describe('a read-only regex filter', () => { + it.each([['m'], ['i']])('does not let the %s flag be toggled', (flag) => { + const onChange = renderOp(true); + + fireEvent.click(screen.getByText(flag)); + + expect(onChange).not.toHaveBeenCalled(); + }); + + it.each([['m'], ['i']])('still lets the %s flag be toggled when the filter is editable', (flag) => { + const onChange = renderOp(false); + + fireEvent.click(screen.getByText(flag)); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange.mock.calls[0][0].flags).toContain(flag); + }); + + it('still shows which flags are set', () => { + // Read-only means "cannot change it", not "cannot see it". + renderOp(true, 'mi'); + + expect(screen.getByText('m')).toBeTruthy(); + expect(screen.getByText('i')).toBeTruthy(); + }); +}); diff --git a/ui/components/ui/ConsumerSession/SessionConfiguration/NumDisplayItemsInput.tsx b/ui/components/ui/ConsumerSession/SessionConfiguration/NumDisplayItemsInput.tsx new file mode 100644 index 000000000..367586754 --- /dev/null +++ b/ui/components/ui/ConsumerSession/SessionConfiguration/NumDisplayItemsInput.tsx @@ -0,0 +1,67 @@ +import React, { useEffect, useState } from 'react'; +import s from './SessionConfiguration.module.css'; +import Input from '../../Input/Input'; +import { numDisplayItemsFromText } from './display-items'; + +export type NumDisplayItemsInputProps = { + /** The committed limit - how many messages a session started right now would keep on screen. */ + value: number; + onChange: (value: number) => void; + isReadOnly?: boolean; +}; + +/** + * How many messages the session keeps on screen. + * + * The typed text is kept in local state rather than derived from the committed limit on every + * render, so an in-progress or refused entry stays on screen (and stays correctable) without ever + * becoming the limit. Clearing the field to retype it is the ordinary case, and it used to commit + * `Number('')` - zero - which turns the retention `slice(-limit)` into "keep everything" and leaves + * the buffer growing until the tab dies. + */ +const NumDisplayItemsInput: React.FC = (props) => { + const [draft, setDraft] = useState(() => String(props.value)); + + // Adopt a limit that changed elsewhere (the toggle, a library item that resolved), but leave a + // draft that already means the same number alone. + useEffect(() => { + if (numDisplayItemsFromText(draft) !== props.value) { + setDraft(String(props.value)); + } + }, [props.value]); + + const onDraftChange = (v: string) => { + setDraft(v); + + const value = numDisplayItemsFromText(v); + if (value !== undefined) { + props.onChange(value); + } + }; + + const isInvalid = numDisplayItemsFromText(draft) === undefined; + + return ( +
+ + {isInvalid && ( +
+ {/* A refused entry does not undo the last valid one, so the session still keeps THAT + many - saying which turns a silent difference into a visible one. */} + Enter a whole number of messages, 1 or more. The session still keeps {props.value}. +
+ )} +
+ ); +}; + +export default NumDisplayItemsInput; diff --git a/ui/components/ui/ConsumerSession/SessionConfiguration/SessionConfiguration.module.css b/ui/components/ui/ConsumerSession/SessionConfiguration/SessionConfiguration.module.css index 311b49d5e..05c3db4a6 100644 --- a/ui/components/ui/ConsumerSession/SessionConfiguration/SessionConfiguration.module.css +++ b/ui/components/ui/ConsumerSession/SessionConfiguration/SessionConfiguration.module.css @@ -59,3 +59,9 @@ z-index: 1; display: flex; } + +.FieldError { + color: var(--accent-color-red); + font-size: x-small; + margin-top: 4rem; +} diff --git a/ui/components/ui/ConsumerSession/SessionConfiguration/SessionConfiguration.test.tsx b/ui/components/ui/ConsumerSession/SessionConfiguration/SessionConfiguration.test.tsx new file mode 100644 index 000000000..cdebbb6df --- /dev/null +++ b/ui/components/ui/ConsumerSession/SessionConfiguration/SessionConfiguration.test.tsx @@ -0,0 +1,282 @@ +/** + * @jest-environment jsdom + * @jest-environment-options {"customExportConditions": ["node"]} + * + * BUG-4 regression (e2e RES-2): `/consumer-session?id=` accepts ANY persisted + * library item id. When the stored item is not a consumer-session config, the editor used to + * dereference `spec.targets` (and the other chains) during render and throw + * "Cannot read properties of undefined (reading 'map')" - with no error boundary on the route, + * React unmounted the whole SPA and the document rendered EMPTY. + * + * The fixtures are built with the app's own `getDefaultManagedItem`, so the malformed case is the + * same shape the e2e produces by saving a message-filter through the Library. + * + * Note: with a jest.mock() in the file, esbuild-jest runs babel's hoisting pass over untyped JS, so + * imported bindings must not appear in type annotations here (inference only). + */ +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { SWRConfig } from 'swr'; + +// mermaid/nanoid are ESM-only and jest does not transform node_modules; both are pulled in far away +// from what is under test (markdown preview in the library item editor / session id generation). +jest.mock('mermaid', () => ({ __esModule: true, default: { initialize: () => undefined } })); +jest.mock('nanoid', () => ({ nanoid: () => 'test-id' })); + +import SessionConfiguration from './SessionConfiguration'; +import { getDefaultManagedItem } from '../../LibraryBrowser/default-library-items'; +import { consumerSessionConfigFromValOrRef } from '../../LibraryBrowser/model/resolved-items-conversions'; +import { defaultNumDisplayItems } from './display-items'; + +const contextForTopic = (topicPersistency: 'persistent' | 'non-persistent') => ({ + pulsarResource: { + type: 'topic' as const, + tenant: 'public', + namespace: 'default', + topicPersistency, + topic: 'a-topic', + }, +}); + +const libraryContext = contextForTopic('persistent'); + +// The same SWR settings the app itself installs (components/app/app.tsx) - without them the data +// hooks deep in the editor keep a retry timer alive past the jsdom teardown. +const renderConfig = (val: unknown, context: unknown = libraryContext) => + render( + + undefined} + libraryContext={context as never} + /> + + ); + +describe('BUG-4: a malformed persisted consumer session config', () => { + it('shows an error instead of crashing when the item is a foreign type', () => { + // Exactly what the e2e does: a message-filter saved through the Library, opened as `?id=`. + const foreignItem = getDefaultManagedItem('message-filter', libraryContext); + + expect(() => renderConfig(foreignItem)).not.toThrow(); + expect(screen.getByText(/not a valid Consumer Session configuration/i)).toBeTruthy(); + expect(document.body.textContent).not.toBe(''); + }); + + it('shows an error instead of crashing when the stored spec is incomplete', () => { + // Right item type, but the persisted spec is missing `targets` - the field whose `.map` threw. + const item = getDefaultManagedItem('consumer-session-config', libraryContext); + const truncated = { ...item, spec: { ...item.spec, targets: undefined } }; + + expect(() => renderConfig(truncated)).not.toThrow(); + expect(screen.getByText(/not a valid Consumer Session configuration/i)).toBeTruthy(); + }); + + // Every field the runtime conversion dereferences has to be checked here, not just the ones whose + // absence happened to throw during RENDER. A spec that renders happily but cannot be converted + // leaves the session with no runtime config at all, and the editor claiming it is fine. + it.each([ + ['targets'], + ['startFrom'], + ['messageFilterChain'], + ['coloringRuleChain'], + ['valueProjectionList'], + ['pauseTriggerChain'], + ])('shows an error when the stored spec is missing %s', (field) => { + const item = getDefaultManagedItem('consumer-session-config', libraryContext); + const spec = { ...(item as any).spec }; + delete spec[field]; + + expect(() => renderConfig({ ...item, spec })).not.toThrow(); + expect(screen.getByText(/not a valid Consumer Session configuration/i)).toBeTruthy(); + }); + + it.each([ + ['an empty object', {}], + ['a value wrapper with no val', { type: 'value' }], + ['a reference wrapper with no ref', { type: 'reference' }], + ])('shows an error when startFrom is %s - malformed must say broken, not spin', (_name, startFrom) => { + // `{ startFrom: {} }` used to pass the shallow object check and then sit in + // useManagedItemValue forever: neither a value to render nor a reference to resolve - + // an endless spinner where an error belongs. + const item = getDefaultManagedItem('consumer-session-config', libraryContext); + const spec = { ...(item as any).spec, startFrom }; + + expect(() => renderConfig({ ...item, spec })).not.toThrow(); + expect(screen.getByText(/not a valid Consumer Session configuration/i)).toBeTruthy(); + }); + + it('still renders the editor for a well-formed config', () => { + const item = getDefaultManagedItem('consumer-session-config', libraryContext); + + renderConfig(item); + + expect(screen.queryByText(/not a valid Consumer Session configuration/i)).toBeNull(); + // The default config has exactly one target column. + expect(screen.getAllByTestId('cs-target')).toHaveLength(1); + }); +}); + +/** + * The start-from selector reacts to what the session's TARGETS point at, but the targets live here - + * so the editor has to hand that down. Nothing in the selector's own tests can prove it was passed. + */ +/** + * "Limit num. display messages" is the only thing standing between a long session and a tab that + * runs out of memory: the session keeps `messages.slice(-limit)` and nothing else bounds it. The + * field committed `Number(v)` of whatever was on screen, and every non-positive answer it produced + * turns that slice into "keep everything" - `slice(-0)` IS `slice(0)`. Clearing the field to retype + * the number is the ordinary way to reach it. + */ +describe('the display-message limit', () => { + const configWithLimit = (numDisplayItems: unknown) => { + const item = getDefaultManagedItem('consumer-session-config', libraryContext); + return { ...item, spec: { ...(item as any).spec, numDisplayItems } }; + }; + + /** The editor with a parent that applies what it is handed, as the session does. */ + const renderControlled = (numDisplayItems: unknown) => { + const onChange = jest.fn(); + const Controlled = () => { + const [value, setValue] = React.useState(() => ({ type: 'value', val: configWithLimit(numDisplayItems) })); + return ( + { + setValue(v); + onChange(v); + }} + libraryContext={libraryContext as never} + /> + ); + }; + + render( + + + + ); + return onChange; + }; + + const limitInput = () => screen.getByTestId('cs-num-display-items') as HTMLInputElement; + const error = () => screen.queryByTestId('cs-num-display-items-error'); + const lastLimit = (onChange: jest.Mock) => + onChange.mock.calls[onChange.mock.calls.length - 1][0].val.spec.numDisplayItems; + + it('commits a typed limit', () => { + const onChange = renderControlled(1000); + + fireEvent.change(limitInput(), { target: { value: '250' } }); + + expect(error()).toBeNull(); + expect(lastLimit(onChange)).toBe(250); + }); + + it('refuses an emptied field rather than committing a limit that keeps everything', () => { + const onChange = renderControlled(1000); + + fireEvent.change(limitInput(), { target: { value: '' } }); + + expect(error()).toBeTruthy(); + expect(onChange).not.toHaveBeenCalled(); + }); + + it.each([['0'], ['-5'], ['2.5'], ['1e3'], ['abc']])('refuses a limit of %p', (text) => { + const onChange = renderControlled(1000); + + fireEvent.change(limitInput(), { target: { value: text } }); + + expect(error()).toBeTruthy(); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('keeps the refused text on screen so it can be corrected, and recovers', () => { + const onChange = renderControlled(1000); + + fireEvent.change(limitInput(), { target: { value: '0' } }); + expect(limitInput().value).toBe('0'); + + fireEvent.change(limitInput(), { target: { value: '25' } }); + + expect(error()).toBeNull(); + expect(lastLimit(onChange)).toBe(25); + }); + + it('says which limit is still in effect while the entry is refused', () => { + renderControlled(1000); + + fireEvent.change(limitInput(), { target: { value: '0' } }); + + expect(error()?.textContent).toMatch(/\b1000\b/); + }); + + it('cannot be edited in a read-only (library-owned) configuration', () => { + render( + + undefined} + libraryContext={libraryContext as never} + isReadOnly + /> + + ); + + expect(limitInput().disabled).toBe(true); + }); + + /** + * A persisted spec is JSON on disk - written by an older build, hand-edited, or committed by a + * field that did not validate - so the conversion that turns it into a runtime config is the + * boundary that has to hold, whatever the editor does. + */ + describe('a limit read back from a persisted config', () => { + const limitOf = (numDisplayItems: unknown) => + consumerSessionConfigFromValOrRef({ type: 'value', val: configWithLimit(numDisplayItems) } as never, undefined) + .numDisplayItems; + + it.each([[0], [-5], [2.5], [Number.NaN], [Number.POSITIVE_INFINITY]])( + 'does not let a stored limit of %p disable retention', + (stored) => { + // Every one of these makes `slice(-limit)` keep the whole array, or drop from the wrong end. + const limit = limitOf(stored); + + expect(Number.isSafeInteger(limit)).toBe(true); + expect(limit).toBeGreaterThan(0); + } + ); + + it('falls back to the default when nothing is stored', () => { + expect(limitOf(undefined)).toBe(defaultNumDisplayItems); + }); + + it('keeps a stored limit that is usable', () => { + expect(limitOf(250)).toBe(250); + }); + }); +}); + +describe('start-from is told what the targets retain', () => { + it('disables the history modes when the session sits on a non-persistent topic', () => { + const nonPersistent = contextForTopic('non-persistent'); + const item = getDefaultManagedItem('consumer-session-config', nonPersistent); + + renderConfig(item, nonPersistent); + + const options = Array.from(screen.getByTestId('cs-start-from').querySelectorAll('option')); + expect(options.find((o) => o.value === 'latestMessage')?.disabled).toBe(false); + expect(options.find((o) => o.value === 'earliestMessage')?.disabled).toBe(true); + expect(screen.getByTestId('cs-start-from-non-persistent-note')).toBeTruthy(); + }); + + it('leaves them alone on a persistent topic', () => { + const item = getDefaultManagedItem('consumer-session-config', libraryContext); + + renderConfig(item); + + const options = Array.from(screen.getByTestId('cs-start-from').querySelectorAll('option')); + expect(options.every((o) => !o.disabled)).toBe(true); + expect(screen.queryByTestId('cs-start-from-non-persistent-note')).toBeNull(); + }); +}); diff --git a/ui/components/ui/ConsumerSession/SessionConfiguration/SessionConfiguration.tsx b/ui/components/ui/ConsumerSession/SessionConfiguration/SessionConfiguration.tsx index b81007226..c1a75701f 100644 --- a/ui/components/ui/ConsumerSession/SessionConfiguration/SessionConfiguration.tsx +++ b/ui/components/ui/ConsumerSession/SessionConfiguration/SessionConfiguration.tsx @@ -6,8 +6,10 @@ import LibraryBrowserPanel, { LibraryBrowserPanelProps } from '../../LibraryBrow import { useHover } from '../../../app/hooks/use-hover'; import { ManagedConsumerSessionConfig, ManagedConsumerSessionConfigSpec, ManagedConsumerSessionConfigValOrRef, ManagedConsumerSessionTarget, ManagedConsumerSessionTargetValOrRef } from '../../LibraryBrowser/model/user-managed-items'; import { UseManagedItemValueSpinner, useManagedItemValue } from '../../LibraryBrowser/useManagedItemValue'; +import NothingToShow from '../../NothingToShow/NothingToShow'; import { LibraryContext } from '../../LibraryBrowser/model/library-context'; import StartFromInput from './StartFromInput/StartFromInput'; +import { targetTopicsPersistency } from './StartFromInput/target-topics-persistency'; import SessionTargetInput from './SessionTargetInput/SessionTargetInput'; import AddButton from '../../AddButton/AddButton'; import DeleteButton from '../../DeleteButton/DeleteButton'; @@ -19,10 +21,9 @@ import SmallButton from '../../SmallButton/SmallButton'; import { arrayMove } from './array-move'; import moveLeftIcon from './icons/move-left.svg'; import moveRightIcon from './icons/move-right.svg'; -import Input from '../../Input/Input'; import FormItem from '../../ConfigurationTable/FormItem/FormItem'; - -export const defaultNumDisplayItems = 10_000; +import NumDisplayItemsInput from './NumDisplayItemsInput'; +import { defaultNumDisplayItems } from './display-items'; export type SessionConfigurationProps = { value: ManagedConsumerSessionConfigValOrRef, @@ -33,20 +34,63 @@ export type SessionConfigurationProps = { libraryBrowserPanel?: Partial }; +// `/consumer-session?id=` accepts the id of ANY persisted library item, so the value handed to this +// editor is not guaranteed to be a consumer session config - it can be a foreign item type or an +// incomplete spec. Reaching into such a spec used to throw during render and, with no error boundary +// above the route, take the whole app down with it. Hence the tolerant reads below and the shape +// check before rendering the editor. +function isConsumerSessionConfigSpec(spec: unknown): spec is ManagedConsumerSessionConfigSpec { + const isObject = (v: unknown) => typeof v === 'object' && v !== null; + + if (!isObject(spec)) { + return false; + } + + const s = spec as Partial; + + // A val-or-ref field must be the DISCRIMINATED shape, not merely an object: a persisted + // `{ startFrom: {} }` passed the object check and then sat in useManagedItemValue forever - + // neither a value to render nor a reference to resolve, so the editor showed an endless + // spinner. Malformed means broken, and broken must say so. + const isValOrRef = (v: unknown): boolean => { + if (!isObject(v)) { + return false; + } + const candidate = v as { type?: unknown; val?: unknown; ref?: unknown }; + if (candidate.type === 'value') { + return isObject(candidate.val); + } + if (candidate.type === 'reference') { + return typeof candidate.ref === 'string' && candidate.ref !== ''; + } + return false; + }; + + // Every field the RUNTIME conversion reads has to be here, not only the ones whose absence threw + // during render: a spec that renders but cannot be converted leaves the session with no runtime + // config, which used to show as a Play button that started nothing. + return Array.isArray(s.targets) + && isValOrRef(s.startFrom) + && isObject(s.messageFilterChain) + && isObject(s.coloringRuleChain) + && isObject(s.valueProjectionList) + && isObject(s.pauseTriggerChain); +} + function detectAdvancedConfig(value: ManagedConsumerSessionConfigValOrRef): boolean { - if (value.val?.spec.coloringRuleChain.val?.spec.coloringRules.length) { + if (value.val?.spec?.coloringRuleChain?.val?.spec?.coloringRules?.length) { return true; } - if (value.val?.spec.messageFilterChain.val?.spec.filters.length) { + if (value.val?.spec?.messageFilterChain?.val?.spec?.filters?.length) { return true; } - if (value.val?.spec.valueProjectionList.val?.spec.projections.length) { + if (value.val?.spec?.valueProjectionList?.val?.spec?.projections?.length) { return true; } - if (value.val?.spec.numDisplayItems !== undefined) { + if (value.val?.spec?.numDisplayItems !== undefined) { return true; } @@ -82,7 +126,26 @@ const SessionConfiguration: React.FC = (props) => { } const item = resolveResult.value; - const itemSpec = item.spec; + const itemSpec = item?.spec; + + if (!isConsumerSessionConfigSpec(itemSpec)) { + return ( +
+ + The library item with id: {item?.metadata?.id ?? (props.value.type === 'reference' ? props.value.ref : 'unknown')} + {item?.metadata?.type === undefined ? '' : ` (type: ${item.metadata.type})`} +  is not a valid Consumer Session configuration. +
+ Open a Consumer Session configuration item, or start a new session instead. +
+ )} + /> + + ); + } const onSpecChange = (spec: ManagedConsumerSessionConfigSpec) => { const newValue: ManagedConsumerSessionConfigValOrRef = { ...props.value, val: { ...item, spec } }; @@ -141,6 +204,9 @@ const SessionConfiguration: React.FC = (props) => { onChange={(v) => onSpecChange({ ...itemSpec, startFrom: v })} libraryContext={props.libraryContext} isReadOnly={props.isReadOnly} + // The start-from modes depend on what the selected topics retain, and the targets that + // decide that live here. + targetTopicsPersistency={targetTopicsPersistency(itemSpec.targets, props.libraryContext)} /> {!isAdvancedConfig && = (props) => { isReadOnly={props.isReadOnly} />
- onSpecChange({ ...itemSpec, numDisplayItems: Number(v) })} + onSpecChange({ ...itemSpec, numDisplayItems })} + isReadOnly={props.isReadOnly} />
diff --git a/ui/components/ui/ConsumerSession/SessionConfiguration/SessionTargetInput/SessionTargetInput.tsx b/ui/components/ui/ConsumerSession/SessionConfiguration/SessionTargetInput/SessionTargetInput.tsx index 8411c5121..2d48bffa5 100644 --- a/ui/components/ui/ConsumerSession/SessionConfiguration/SessionTargetInput/SessionTargetInput.tsx +++ b/ui/components/ui/ConsumerSession/SessionConfiguration/SessionTargetInput/SessionTargetInput.tsx @@ -108,6 +108,7 @@ const SessionTargetInput: React.FC = (props) => {
+ testId="cs-target-compacted" items={[ { type: 'item', value: true, help: readCompactedHelp, foregroundColor: '#fff', backgroundColor: 'var(--accent-color-blue)', label: 'Compacted' }, { type: 'item', value: false, help: readCompactedHelp, foregroundColor: 'var(--background-color)', backgroundColor: '#aaa', label: 'Compacted' } diff --git a/ui/components/ui/ConsumerSession/SessionConfiguration/StartFromInput/ApproximateFractionInput.tsx b/ui/components/ui/ConsumerSession/SessionConfiguration/StartFromInput/ApproximateFractionInput.tsx new file mode 100644 index 000000000..0ab4c9e89 --- /dev/null +++ b/ui/components/ui/ConsumerSession/SessionConfiguration/StartFromInput/ApproximateFractionInput.tsx @@ -0,0 +1,91 @@ +import React, { useEffect, useState } from 'react'; +import s from './StartFromInput.module.css'; +import Input from '../../../Input/Input'; +import { fractionFromPercent, percentFromFraction } from './approximate-fraction'; + +export type ApproximateFractionInputProps = { + /** The stored proportion, in [0, 1]. What it is a proportion OF is the mode's business, not this + * control's - it edits a percentage either way. */ + fraction: number; + onChange: (fraction: number) => void; + /** + * Test-id prefix identifying WHICH mode this instance is editing, e.g. `cs-start-from-data` -> + * `cs-start-from-data-fraction`. Both modes render the same control, so one shared id would let a + * test drive one and assert the other. + */ + testIdPrefix: string; + disabled?: boolean; + isReadOnly?: boolean; +}; + +/** + * Percentage editor shared by the two approximate start-from modes: a slider for the coarse move + * and a number field for an exact value. + * + * The typed text is kept in local state rather than derived from `fraction` on every render, so an + * in-progress or invalid entry stays on screen (and stays correctable) without ever being committed + * to the session config. + */ +const ApproximateFractionInput: React.FC = (props) => { + const [draft, setDraft] = useState(() => percentFromFraction(props.fraction)); + + // Adopt a fraction that changed elsewhere (the slider, or a library item that resolved), but leave + // a draft that already means the same value alone - re-deriving it would eat a trailing '.' and + // make "60.5" untypeable. + useEffect(() => { + if (fractionFromPercent(draft) !== props.fraction) { + setDraft(percentFromFraction(props.fraction)); + } + }, [props.fraction]); + + const onDraftChange = (v: string) => { + setDraft(v); + + const fraction = fractionFromPercent(v); + if (fraction !== undefined) { + props.onChange(fraction); + } + }; + + const isInvalid = fractionFromPercent(draft) === undefined; + const sliderPercent = Number(percentFromFraction(props.fraction)) || 0; + + return ( +
+ props.onChange(fractionFromPercent(e.target.value) ?? props.fraction)} + /> +
+ +
%
+
+ {isInvalid && ( +
+ {/* A refused entry does not undo the last valid one, so Play would start from THAT while + the box shows something else. Saying which percentage that is turns a silent + difference into a visible one. */} + Enter a percentage between 0 and 100. The session still uses {percentFromFraction(props.fraction)}%. +
+ )} +
+ ); +}; + +export default ApproximateFractionInput; diff --git a/ui/components/ui/ConsumerSession/SessionConfiguration/StartFromInput/MessageCountInput.tsx b/ui/components/ui/ConsumerSession/SessionConfiguration/StartFromInput/MessageCountInput.tsx new file mode 100644 index 000000000..139e25c94 --- /dev/null +++ b/ui/components/ui/ConsumerSession/SessionConfiguration/StartFromInput/MessageCountInput.tsx @@ -0,0 +1,72 @@ +import React, { useEffect, useState } from 'react'; +import s from './StartFromInput.module.css'; +import Input from '../../../Input/Input'; +import { messageCountFromText } from './message-count'; + +export type MessageCountInputProps = { + /** The committed count - what a session started right now would use. */ + n: number; + onChange: (n: number) => void; + /** The largest count THIS mode's server side can answer, where it has one. */ + max?: number; + disabled?: boolean; + isReadOnly?: boolean; +}; + +/** + * The message count shared by "Skip first n messages" and "Latest n messages". + * + * The typed text is kept in local state rather than derived from `n` on every render, so an + * in-progress or invalid entry stays on screen (and stays correctable) without ever being committed + * to the session config - clearing the field to retype it is the ordinary case, and it used to + * commit NaN on the way through. + */ +const MessageCountInput: React.FC = (props) => { + const [draft, setDraft] = useState(() => String(props.n)); + + // Adopt a count that changed elsewhere (a library item that resolved, a mode switch), but leave a + // draft that already means the same number alone. + useEffect(() => { + if (messageCountFromText(draft, props.max) !== props.n) { + setDraft(String(props.n)); + } + }, [props.n]); + + const onDraftChange = (v: string) => { + setDraft(v); + + const count = messageCountFromText(v, props.max); + if (count !== undefined) { + props.onChange(count); + } + }; + + const isInvalid = messageCountFromText(draft, props.max) === undefined; + + return ( +
+ + {isInvalid && ( +
+ {/* A refused entry does not undo the last valid one, so Play would start from THAT. Saying + which number that is turns a silent difference into a visible one - and where the mode + has a ceiling, saying what it is turns "wrong" into something correctable. */} + {props.max === undefined + ? <>Enter a whole number of messages, 0 or more. The session still uses {props.n}. + : <>Enter a whole number of messages from 0 to {props.max}. The session still uses {props.n}.} +
+ )} +
+ ); +}; + +export default MessageCountInput; diff --git a/ui/components/ui/ConsumerSession/SessionConfiguration/StartFromInput/StartFromInput.module.css b/ui/components/ui/ConsumerSession/SessionConfiguration/StartFromInput/StartFromInput.module.css index e7d2edc5d..bd10ce777 100644 --- a/ui/components/ui/ConsumerSession/SessionConfiguration/StartFromInput/StartFromInput.module.css +++ b/ui/components/ui/ConsumerSession/SessionConfiguration/StartFromInput/StartFromInput.module.css @@ -10,3 +10,52 @@ .AdditionalControls { margin-top: 8rem; } + +.PersistencyNote { + padding: 12rem; + border-radius: 8rem; + margin-top: 8rem; + background: var(--surface-color); +} + +.ApproximateFractionNote { + display: flex; + flex-direction: column; + gap: 8rem; + padding: 12rem; + border-radius: 8rem; + margin-top: 8rem; + background: var(--surface-color); +} + +.ApproximateFraction { + display: flex; + flex-direction: column; + gap: 8rem; +} + +.MessageCount { + display: flex; + flex-direction: column; + gap: 8rem; +} + +.ApproximateFractionSlider { + width: 100%; + margin: 0; +} + +.ApproximateFractionValue { + display: flex; + align-items: center; + gap: 6rem; +} + +.ApproximateFractionUnit { + color: grey; +} + +.ApproximateFractionError { + color: var(--accent-color-red); + font-size: x-small; +} diff --git a/ui/components/ui/ConsumerSession/SessionConfiguration/StartFromInput/StartFromInput.test.tsx b/ui/components/ui/ConsumerSession/SessionConfiguration/StartFromInput/StartFromInput.test.tsx new file mode 100644 index 000000000..f18084f86 --- /dev/null +++ b/ui/components/ui/ConsumerSession/SessionConfiguration/StartFromInput/StartFromInput.test.tsx @@ -0,0 +1,881 @@ +/** + * @jest-environment jsdom + * @jest-environment-options {"customExportConditions": ["node"]} + * + * The two approximate start-from modes - "About % through the data" and "About % through the time + * range" - their selector branches, and the percent control each one edits. + * + * The percent control is the piece Playwright can drive but cannot judge: the model stores a + * FRACTION in [0, 1] while the user edits a PERCENT in [0, 100], so every keystroke crosses a + * conversion, and an out-of-range percent must be refused BEFORE it reaches the model (the server + * rejects a fraction outside [0.0, 1.0] outright). + * + * Both modes render the SAME control with different test ids, so the per-mode suite below runs + * twice. The ids are what keep them apart: a shared one would let a test drive the data control and + * assert the time one without noticing. + * + * mermaid/nanoid are ESM-only and jest does not transform node_modules; both are pulled in far away + * through the library browser panel that every managed-item editor renders. + */ +jest.mock('mermaid', () => ({ __esModule: true, default: { initialize: () => undefined } })); +jest.mock('nanoid', () => ({ nanoid: () => 'test-id' })); + +import React from 'react'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { SWRConfig } from 'swr'; +import StartFromInput from './StartFromInput'; +import { fractionFromPercent, percentFromFraction } from './approximate-fraction'; +import { latestMessageCountMax } from './message-count'; + +const libraryContext = { + pulsarResource: { + type: 'topic' as const, + tenant: 'public', + namespace: 'default', + topicPersistency: 'persistent' as const, + topic: 'a-topic', + }, +}; + +const startFromItem = (startFrom: unknown) => ({ + type: 'value' as const, + val: { + metadata: { id: 'sf-1', name: '', descriptionMarkdown: '', type: 'consumer-session-start-from' as const }, + spec: { startFrom }, + }, +}); + +// The same SWR settings the app installs - without them the data hooks in the library panel keep a +// retry timer alive past the jsdom teardown. +const renderInput = (startFrom: unknown, targetTopicsPersistency?: { hasPersistent: boolean; hasNonPersistent: boolean }) => { + const onChange = jest.fn(); + render( + + + + ); + return onChange; +}; + +/** The same editor as it renders a REFERENCED library item: shown, never edited in place. */ +const renderReadOnly = (startFrom: unknown) => { + render( + + undefined} + libraryContext={libraryContext} + isReadOnly + /> + + ); +}; + +/** + * Same component, but with a parent that actually applies what it is handed - which is what the app + * does. Anything about how the control reacts to its own committed value needs this: with an inert + * `onChange` the props never move, so half the behaviour never runs. + */ +const renderControlled = (startFrom: unknown) => { + const onChange = jest.fn(); + const Controlled = () => { + const [value, setValue] = React.useState(() => startFromItem(startFrom)); + return ( + { + setValue(v); + onChange(v); + }} + libraryContext={libraryContext} + /> + ); + }; + + render( + + + + ); + return onChange; +}; + +const nonPersistentOnly = { hasPersistent: false, hasNonPersistent: true }; +const mixed = { hasPersistent: true, hasNonPersistent: true }; + +/** The start-from modes offered by the selector, mapped to whether the option is selectable. */ +const modeOptions = () => + Array.from(screen.getByTestId('cs-start-from').querySelectorAll('option')).reduce>( + (acc, o) => ({ ...acc, [o.value]: !o.disabled }), + {} + ); + +/** The start-from the component handed back to its parent on the last onChange. */ +const lastStartFrom = (onChange: jest.Mock) => onChange.mock.calls[onChange.mock.calls.length - 1][0].val.spec.startFrom; + +describe('percent <-> fraction', () => { + it('renders a fraction as a clean percent, without binary-float debris', () => { + // In IEEE-754 `0.07 * 100` is 7.000000000000001 and `0.29 * 100` is 28.999999999999996. + // Putting either of those in a form field would be absurd. + expect(percentFromFraction(0.07)).toBe('7'); + expect(percentFromFraction(0.29)).toBe('29'); + expect(percentFromFraction(0)).toBe('0'); + expect(percentFromFraction(1)).toBe('100'); + expect(percentFromFraction(0.605)).toBe('60.5'); + }); + + it('accepts a valid percent and converts it back to a fraction', () => { + expect(fractionFromPercent('0')).toBe(0); + expect(fractionFromPercent('60')).toBe(0.6); + expect(fractionFromPercent('100')).toBe(1); + expect(fractionFromPercent('12.5')).toBe(0.125); + }); + + it('rejects out-of-range percents - the server refuses a fraction outside [0.0, 1.0]', () => { + expect(fractionFromPercent('-1')).toBeUndefined(); + expect(fractionFromPercent('101')).toBeUndefined(); + expect(fractionFromPercent('1000')).toBeUndefined(); + }); + + it('rejects non-numeric input rather than turning it into NaN', () => { + expect(fractionFromPercent('')).toBeUndefined(); + expect(fractionFromPercent(' ')).toBeUndefined(); + expect(fractionFromPercent('abc')).toBeUndefined(); + expect(fractionFromPercent('50%')).toBeUndefined(); + expect(fractionFromPercent('1e2')).toBeUndefined(); + expect(fractionFromPercent('0x10')).toBeUndefined(); + }); +}); + +describe('the start-from mode selector', () => { + it('offers both approximate modes alongside the pre-existing ones', () => { + renderInput({ type: 'latestMessage' }); + + const values = Array.from(screen.getByTestId('cs-start-from').querySelectorAll('option')).map((o) => o.value); + expect(values).toContain('approximateDataPosition'); + expect(values).toContain('approximateTimePosition'); + // The seven pre-existing modes must survive the addition. + expect(values).toEqual( + expect.arrayContaining([ + 'earliestMessage', + 'latestMessage', + 'messageId', + 'dateTime', + 'relativeDateTime', + 'nthMessageAfterEarliest', + 'nthMessageBeforeLatest', + ]) + ); + }); + + it('labels the two approximate modes by what the percentage is OF', () => { + // The labels are the whole point of the split: "Approximate position" meant either of these and + // could not say which. They are also what the e2e specs select by. + renderInput({ type: 'latestMessage' }); + + const byValue = Object.fromEntries( + Array.from(screen.getByTestId('cs-start-from').querySelectorAll('option')).map((o) => [o.value, o.textContent]) + ); + expect(byValue.approximateDataPosition).toBe('About % through the data'); + expect(byValue.approximateTimePosition).toBe('About % through the time range'); + }); + + it.each([ + ['approximateDataPosition'], + ['approximateTimePosition'], + ])('builds a %s spec when the mode is picked', (mode) => { + const onChange = renderInput({ type: 'latestMessage' }); + + fireEvent.change(screen.getByTestId('cs-start-from'), { target: { value: mode } }); + + // A silently-ignored branch would leave the spec untouched; a missing branch would leave + // onChange uncalled entirely. The two share one switch, so each needs its own case. + expect(onChange).toHaveBeenCalled(); + expect(lastStartFrom(onChange)).toEqual({ type: mode, fraction: 0.5 }); + }); + + it('still builds the skip-n spec, which shares the same switch', () => { + const onChange = renderInput({ type: 'latestMessage' }); + + fireEvent.change(screen.getByTestId('cs-start-from'), { target: { value: 'nthMessageAfterEarliest' } }); + + expect(lastStartFrom(onChange)).toEqual({ type: 'nthMessageAfterEarliest', n: 5 }); + }); +}); + +/** The two modes and the test-id prefix each one's controls carry. */ +const approximateModes = [ + ['approximateDataPosition', 'cs-start-from-data'], + ['approximateTimePosition', 'cs-start-from-time'], +] as const; + +describe.each(approximateModes)('the percent control for %s', (mode, prefix) => { + const fractionInput = () => screen.getByTestId(`${prefix}-fraction`); + const slider = () => screen.getByTestId(`${prefix}-fraction-slider`); + const error = () => screen.queryByTestId(`${prefix}-fraction-error`); + + it('shows the stored fraction as a percent in both the number input and the slider', () => { + renderInput({ type: mode, fraction: 0.6 }); + + expect((fractionInput() as HTMLInputElement).value).toBe('60'); + expect((slider() as HTMLInputElement).value).toBe('60'); + expect(error()).toBeNull(); + }); + + it('commits a typed percent as a fraction', () => { + const onChange = renderInput({ type: mode, fraction: 0.6 }); + + fireEvent.change(fractionInput(), { target: { value: '40' } }); + + expect(lastStartFrom(onChange)).toEqual({ type: mode, fraction: 0.4 }); + }); + + it('commits a slider move as a fraction', () => { + const onChange = renderInput({ type: mode, fraction: 0.6 }); + + fireEvent.change(slider(), { target: { value: '25' } }); + + expect(lastStartFrom(onChange)).toEqual({ type: mode, fraction: 0.25 }); + }); + + it('refuses a percent above 100 and says so, instead of sending a fraction the server rejects', () => { + const onChange = renderInput({ type: mode, fraction: 0.6 }); + + fireEvent.change(fractionInput(), { target: { value: '150' } }); + + expect(error()).toBeTruthy(); + expect(onChange).not.toHaveBeenCalled(); + // The refused text stays visible so the user can correct it. + expect((fractionInput() as HTMLInputElement).value).toBe('150'); + }); + + it('refuses a negative percent', () => { + const onChange = renderInput({ type: mode, fraction: 0.6 }); + + fireEvent.change(fractionInput(), { target: { value: '-5' } }); + + expect(error()).toBeTruthy(); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('refuses an emptied field rather than committing NaN', () => { + const onChange = renderInput({ type: mode, fraction: 0.6 }); + + fireEvent.change(fractionInput(), { target: { value: '' } }); + + expect(error()).toBeTruthy(); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('recovers once the value is corrected', () => { + const onChange = renderInput({ type: mode, fraction: 0.6 }); + + fireEvent.change(fractionInput(), { target: { value: '150' } }); + fireEvent.change(fractionInput(), { target: { value: '15' } }); + + expect(error()).toBeNull(); + expect(lastStartFrom(onChange)).toEqual({ type: mode, fraction: 0.15 }); + }); + + it('is not rendered for the other modes', () => { + renderInput({ type: 'nthMessageAfterEarliest', n: 5 }); + + expect(screen.queryByTestId(`${prefix}-fraction`)).toBeNull(); + expect(screen.queryByTestId(`${prefix}-note`)).toBeNull(); + expect(screen.getByTestId('cs-start-from-n')).toBeTruthy(); + }); + + it('does not rewrite what the user typed just because the model rounded it', () => { + // The stored fraction keeps 6 decimals, so "12.34567" commits 0.123457 - which renders back as + // "12.3457". Re-deriving the field from the model would edit the text from under the user + // mid-entry; the field is only re-derived when the model means something ELSE. + const onChange = renderControlled({ type: mode, fraction: 0.6 }); + + fireEvent.change(fractionInput(), { target: { value: '12.34567' } }); + + expect((fractionInput() as HTMLInputElement).value).toBe('12.34567'); + expect(lastStartFrom(onChange)).toEqual({ type: mode, fraction: 0.123457 }); + }); + + it('says which percentage is still in effect while the entry is refused', () => { + // The refused entry does not undo the last valid one, so Play starts from THAT - 60%, while the + // box shows 150 and an error. Either the control blocks Play or it says what will happen; it + // must not do neither. + renderControlled({ type: mode, fraction: 0.6 }); + + fireEvent.change(fractionInput(), { target: { value: '150' } }); + + expect(error()?.textContent).toMatch(/60\s*%/); + }); + + it('adopts a value changed elsewhere, discarding a rejected entry', () => { + const onChange = renderControlled({ type: mode, fraction: 0.6 }); + + fireEvent.change(fractionInput(), { target: { value: '150' } }); + expect(error()).toBeTruthy(); + + fireEvent.change(slider(), { target: { value: '30' } }); + + expect((fractionInput() as HTMLInputElement).value).toBe('30'); + expect(error()).toBeNull(); + expect(lastStartFrom(onChange)).toEqual({ type: mode, fraction: 0.3 }); + }); +}); + +describe('the two percent controls are not the same control', () => { + // They render identically and store the same shape, so the ONLY thing keeping them apart in the + // DOM is the test-id prefix. If both instances shared one id, every per-mode test above would + // still pass while a Playwright spec silently drove the wrong mode. + it('renders only the data control for the data mode', () => { + renderInput({ type: 'approximateDataPosition', fraction: 0.6 }); + + expect(screen.getByTestId('cs-start-from-data-fraction')).toBeTruthy(); + expect(screen.getByTestId('cs-start-from-data-note')).toBeTruthy(); + expect(screen.queryByTestId('cs-start-from-time-fraction')).toBeNull(); + expect(screen.queryByTestId('cs-start-from-time-note')).toBeNull(); + }); + + it('renders only the time control for the time mode', () => { + renderInput({ type: 'approximateTimePosition', fraction: 0.6 }); + + expect(screen.getByTestId('cs-start-from-time-fraction')).toBeTruthy(); + expect(screen.getByTestId('cs-start-from-time-note')).toBeTruthy(); + expect(screen.queryByTestId('cs-start-from-data-fraction')).toBeNull(); + expect(screen.queryByTestId('cs-start-from-data-note')).toBeNull(); + }); + + it('keeps the mode when the percentage is edited, rather than falling back to the other one', () => { + // The onChange handler rebuilds the whole start-from, so it has to re-state its own `type`. + const onChange = renderInput({ type: 'approximateTimePosition', fraction: 0.6 }); + + fireEvent.change(screen.getByTestId('cs-start-from-time-fraction'), { target: { value: '20' } }); + + expect(lastStartFrom(onChange)).toEqual({ type: 'approximateTimePosition', fraction: 0.2 }); + }); +}); + +/** + * The data mode resolves PER PHYSICAL TOPIC and lands on an entry rather than on a message. Both are + * deliberate and both are invisible from the control itself - a percentage box gives no hint that + * "50%" means 50% of EACH log, nor that the jump is measured in batched entries. The explanation + * under the control is the only place the user is told, so it is pinned here. + */ +describe('what the "% through the data" note tells the user', () => { + const noteText = () => screen.getByTestId('cs-start-from-data-note').textContent ?? ''; + + it('says each topic and each partition starts at its own position', () => { + renderInput({ type: 'approximateDataPosition', fraction: 0.6 }); + + const note = noteText(); + expect(note).toMatch(/partition/i); + expect(note).toMatch(/each|every/i); + expect(note).toMatch(/its own position|own position/i); + }); + + it('says the percentage is not of the topics combined', () => { + renderInput({ type: 'approximateDataPosition', fraction: 0.6 }); + + // Without this the reader can reasonably assume "50%" means 50% of one merged stream, which is + // the position the session does NOT take. + expect(noteText()).toMatch(/not of all of them|not of the (topics )?combined|rather than of all/i); + }); + + it('says the position is approximate, and why - entries are batches, so a % of entries is not that % of messages', () => { + renderInput({ type: 'approximateDataPosition', fraction: 0.6 }); + + const note = noteText(); + expect(note).toMatch(/approximate/i); + expect(note).toMatch(/entries/i); + expect(note).toMatch(/batch/i); + expect(note).toMatch(/not exactly/i); + }); + + it('describes 100% as the server actually resolves it: past everything retained', () => { + // 1.0 in this mode is MessageId.latest - the same position "Latest message" takes, showing + // nothing that is retained and only what arrives from now on. Calling it "the newest message" + // promises the last message in the topic, which is what the TIME mode's 100% gives; here it + // delivers an apparently empty session, and the note was the only place to find out. + renderInput({ type: 'approximateDataPosition', fraction: 0.6 }); + + const note = noteText(); + expect(note).toMatch(/100%/); + expect(note).toMatch(/from now on|only new messages|nothing that is still kept|past (the )?(newest|everything)/i); + expect(note).not.toMatch(/100% is the\s+newest\b(?!.*from now)/i); + }); + + it('points at the time mode as the other reading of "half way in"', () => { + // The two labels differ by three words, so the note is where a reader who picked the wrong one + // finds out. Without it the modes are only distinguishable by guessing. + renderInput({ type: 'approximateDataPosition', fraction: 0.6 }); + + expect(noteText()).toMatch(/About % through the time range/); + }); + + it('stays in the reader\'s terms, not Pulsar internals', () => { + renderInput({ type: 'approximateDataPosition', fraction: 0.6 }); + + expect(noteText()).not.toMatch(/examineMessage|ordinal|brokerEntryMetadata|managed ?ledger/i); + }); +}); + +/** + * The time mode's two surprises are the mirror image: the percentage is of ELAPSED TIME rather than + * of messages, and a partitioned topic is pooled into ONE range instead of being positioned + * partition by partition. Neither is visible from a percentage box either. + */ +describe('what the "% through the time range" note tells the user', () => { + const noteText = () => screen.getByTestId('cs-start-from-time-note').textContent ?? ''; + + it('says the percentage is of time, not of messages', () => { + renderInput({ type: 'approximateTimePosition', fraction: 0.6 }); + + const note = noteText(); + expect(note).toMatch(/time/i); + expect(note).toMatch(/not messages|not of messages|rather than messages|measures time, not messages/i); + }); + + it('points at the data mode as the other reading of "half way in"', () => { + renderInput({ type: 'approximateTimePosition', fraction: 0.6 }); + + expect(noteText()).toMatch(/About % through the data/); + }); + + it('says a partitioned topic is treated as ONE range, unlike the data mode', () => { + // This is the substantive difference between the two modes' scopes, and getting it wrong costs + // the user a session pointed somewhere they did not ask for. + renderInput({ type: 'approximateTimePosition', fraction: 0.6 }); + + const note = noteText(); + expect(note).toMatch(/partition/i); + expect(note).toMatch(/one topic|same moment|across every partition/i); + }); + + it('says the timestamp comes from the sending machine', () => { + // publishTime is the producer's clock, so the position is only as good as that clock. Saying so + // is the difference between "approximate" and "wrong". + renderInput({ type: 'approximateTimePosition', fraction: 0.6 }); + + const note = noteText(); + expect(note).toMatch(/approximate/i); + expect(note).toMatch(/machine that sent|producer|sending machine/i); + }); + + it('stays in the reader\'s terms, not Pulsar internals', () => { + renderInput({ type: 'approximateTimePosition', fraction: 0.6 }); + + expect(noteText()).not.toMatch(/examineMessage|publishTime|ledger|epoch|millisecond/i); + }); +}); + +/** + * "Skip first n messages" and "Latest n messages" both edit a COUNT OF MESSAGES: a whole number, at + * least zero, that the server has to be able to act on. The field used to commit `parseInt` of + * whatever was on screen after every keystroke, and `parseInt` answers something for nearly + * anything - which is how a blank field became NaN, `min=0` became decoration, and `2.7`, `1e3` and + * a number past 2^53 all became a different count than the one that was typed. + */ +const countModes = [ + ['nthMessageAfterEarliest'], + ['nthMessageBeforeLatest'], +] as const; + +describe.each(countModes)('the message-count field for %s', (mode) => { + const countInput = () => screen.getByTestId('cs-start-from-n') as HTMLInputElement; + const error = () => screen.queryByTestId('cs-start-from-n-error'); + + it('commits a valid count', () => { + const onChange = renderInput({ type: mode, n: 5 }); + + fireEvent.change(countInput(), { target: { value: '12' } }); + + expect(lastStartFrom(onChange)).toEqual({ type: mode, n: 12 }); + expect(error()).toBeNull(); + }); + + it('accepts zero - "skip nothing" and "the latest 0" are legal counts', () => { + const onChange = renderInput({ type: mode, n: 5 }); + + fireEvent.change(countInput(), { target: { value: '0' } }); + + expect(lastStartFrom(onChange)).toEqual({ type: mode, n: 0 }); + }); + + it('refuses an emptied field rather than committing NaN', () => { + // Clearing the field to retype it is the most ordinary thing a user does here, and `parseInt('')` + // is NaN - which then serializes into the request as a count nobody asked for. + const onChange = renderInput({ type: mode, n: 5 }); + + fireEvent.change(countInput(), { target: { value: '' } }); + + expect(error()).toBeTruthy(); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('refuses a negative count - `min=0` is advice to the browser, not a guard', () => { + // Defence in depth: the UI refuses a negative here AND the server now refuses it at the trust + // boundary (server startFromCountValidationTest). `min=0` is only advice to the browser, so this + // client-side check still earns its place even though the server guards the same thing. + const onChange = renderInput({ type: mode, n: 5 }); + + fireEvent.change(countInput(), { target: { value: '-1' } }); + + expect(error()).toBeTruthy(); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('refuses a fractional count instead of silently truncating it', () => { + const onChange = renderInput({ type: mode, n: 5 }); + + fireEvent.change(countInput(), { target: { value: '2.7' } }); + + expect(error()).toBeTruthy(); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('refuses an exponent instead of reading only its mantissa', () => { + // `parseInt('1e3')` is 1: a thousand becomes one, with nothing on screen to say so. + const onChange = renderInput({ type: mode, n: 5 }); + + fireEvent.change(countInput(), { target: { value: '1e3' } }); + + expect(error()).toBeTruthy(); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('refuses a count past the safe integer range instead of rounding it', () => { + // 2^53 + 1 is not representable: the committed value would differ from the typed one. + const onChange = renderInput({ type: mode, n: 5 }); + + fireEvent.change(countInput(), { target: { value: '9007199254740993' } }); + + expect(error()).toBeTruthy(); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('keeps the refused text on screen so it can be corrected, and recovers', () => { + const onChange = renderControlled({ type: mode, n: 5 }); + + fireEvent.change(countInput(), { target: { value: '-1' } }); + expect(countInput().value).toBe('-1'); + + fireEvent.change(countInput(), { target: { value: '7' } }); + + expect(error()).toBeNull(); + expect(lastStartFrom(onChange)).toEqual({ type: mode, n: 7 }); + }); + + it('says which count is still in effect while the entry is refused', () => { + // The refused entry does not undo the last valid one, so Play would start from THAT. Saying so + // is the difference between a rejected keystroke and a session that silently starts somewhere + // the screen does not show. + renderControlled({ type: mode, n: 5 }); + + fireEvent.change(countInput(), { target: { value: '-1' } }); + + expect(error()?.textContent).toMatch(/\b5\b/); + }); +}); + +/** + * The two count modes look identical but are NOT bounded the same way. "Latest n messages" is + * resolved by a synchronous backward metadata walk - roughly one broker lookup per entry - so the + * server caps it (`latestNMaxAccepted`, ten million); "Skip first n messages" streams past its + * messages and has no such ceiling, deliberately. A UI that accepts both alike shows a count as + * valid and then fails only after Play, with the field it came from long out of sight - which is + * exactly what happened when this file pinned the stale `Int.MaxValue` after the server tightened. + */ +describe('the mode-specific maximum count', () => { + const countInput = () => screen.getByTestId('cs-start-from-n') as HTMLInputElement; + const error = () => screen.queryByTestId('cs-start-from-n-error'); + + it('mirrors the server ceiling exactly - the parity pin', () => { + // The server side of this pin is startFromCountValidationTest, which fixes + // `latestNMaxAccepted` at the same number. If either side moves alone, one of the two suites + // goes red - that asymmetric window (UI accepts, server refuses after Play) is the bug. + expect(latestMessageCountMax).toBe(10_000_000); + }); + + it('accepts exactly the ceiling for "Latest n messages"', () => { + const onChange = renderInput({ type: 'nthMessageBeforeLatest', n: 5 }); + + fireEvent.change(countInput(), { target: { value: String(latestMessageCountMax) } }); + + expect(error()).toBeNull(); + expect(lastStartFrom(onChange)).toEqual({ type: 'nthMessageBeforeLatest', n: latestMessageCountMax }); + }); + + it('refuses one more than the ceiling for "Latest n messages"', () => { + const onChange = renderInput({ type: 'nthMessageBeforeLatest', n: 5 }); + + fireEvent.change(countInput(), { target: { value: String(latestMessageCountMax + 1) } }); + + expect(error()).toBeTruthy(); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('says what the ceiling is, not merely that the number is wrong', () => { + renderControlled({ type: 'nthMessageBeforeLatest', n: 5 }); + + fireEvent.change(countInput(), { target: { value: String(latestMessageCountMax + 1) } }); + + expect(error()?.textContent).toContain(String(latestMessageCountMax)); + }); + + it('does not impose that ceiling on "Skip first n messages"', () => { + // Skip-N has no server-side maximum, so borrowing Latest-N's would refuse a count the server + // would happily serve. + const onChange = renderInput({ type: 'nthMessageAfterEarliest', n: 5 }); + + fireEvent.change(countInput(), { target: { value: String(latestMessageCountMax + 1) } }); + + expect(error()).toBeNull(); + expect(lastStartFrom(onChange)).toEqual({ type: 'nthMessageAfterEarliest', n: latestMessageCountMax + 1 }); + }); + + it('tells the browser about the ceiling too, for the spinner and the native step', () => { + renderInput({ type: 'nthMessageBeforeLatest', n: 5 }); + expect(countInput().getAttribute('max')).toBe(String(latestMessageCountMax)); + + cleanup(); + + renderInput({ type: 'nthMessageAfterEarliest', n: 5 }); + expect(countInput().getAttribute('max')).toBeNull(); + }); +}); + +describe('the message-id field', () => { + const idInput = () => screen.getByTestId('cs-start-from-message-id') as HTMLInputElement; + const error = () => screen.queryByTestId('cs-start-from-message-id-error'); + + const messageIdStartFrom = (hexString: string) => ({ + type: 'messageId', + messageId: { + type: 'value', + val: { + metadata: { id: 'mid-1', name: '', descriptionMarkdown: '', type: 'message-id' }, + spec: { hexString }, + }, + }, + }); + + it('accepts a message id as the placeholder renders one', () => { + renderControlled(messageIdStartFrom('')); + + fireEvent.change(idInput(), { target: { value: '08 c3 03 10 cd 04 20 00 30 01' } }); + + expect(error()).toBeNull(); + }); + + it('says so when the text is not hex, instead of failing later inside Play', () => { + // The parser throws on malformed hex, and it is called while the create request is being built - + // after the click, outside any catch. The user sees a session that never starts. + renderControlled(messageIdStartFrom('')); + + fireEvent.change(idInput(), { target: { value: 'zz' } }); + + expect(error()).toBeTruthy(); + expect(idInput().value).toBe('zz'); + }); + + it('says so when a byte is left half-written', () => { + renderControlled(messageIdStartFrom('')); + + fireEvent.change(idInput(), { target: { value: '08 c' } }); + + expect(error()).toBeTruthy(); + }); + + // The shared hex parser deliberately reads "" as an empty byte array, because a byte payload + // really can be empty - a message id cannot. Sending zero bytes as a start position is refused by + // the server (`MessageId.messageId` is parsed as a real id), and the refusal arrives long after + // the click, as a create failure with nothing on screen pointing back at this field. + it.each([[''], [' '], ['\t']])('says an id of %p is missing, rather than sending zero bytes', (text) => { + renderControlled(messageIdStartFrom('08 c3')); + + fireEvent.change(idInput(), { target: { value: text } }); + + expect(error()).toBeTruthy(); + expect(error()?.textContent).toMatch(/message id/i); + }); + + it('flags a message-id mode that starts out empty, before anything is typed', () => { + // Picking the mode creates an empty id, so this is the state the user is dropped into. + renderControlled(messageIdStartFrom('')); + + expect(error()).toBeTruthy(); + }); +}); + +/** + * A referenced (library-owned) start-from is shown, never edited in place - the value belongs to the + * stored item. Both number fields forward `inputProps` alongside that, which is exactly the + * combination that used to hand them back to the user. + */ +describe('a read-only start-from', () => { + it('does not let the message count be edited', () => { + renderReadOnly({ type: 'nthMessageAfterEarliest', n: 5 }); + + expect((screen.getByTestId('cs-start-from-n') as HTMLInputElement).disabled).toBe(true); + }); + + it.each([ + ['approximateDataPosition', 'cs-start-from-data'], + ['approximateTimePosition', 'cs-start-from-time'], + ])('does not let the %s percentage be edited', (mode, prefix) => { + renderReadOnly({ type: mode, fraction: 0.6 }); + + expect((screen.getByTestId(`${prefix}-fraction`) as HTMLInputElement).disabled).toBe(true); + expect((screen.getByTestId(`${prefix}-fraction-slider`) as HTMLInputElement).disabled).toBe(true); + }); + + it('does not let the message id be edited', () => { + renderReadOnly({ + type: 'messageId', + messageId: { + type: 'value', + val: { + metadata: { id: 'mid-1', name: '', descriptionMarkdown: '', type: 'message-id' }, + spec: { hexString: '08 c3' }, + }, + }, + }); + + expect((screen.getByTestId('cs-start-from-message-id') as HTMLInputElement).disabled).toBe(true); + }); +}); + +describe('non-persistent targets', () => { + it('offers only Latest when nothing in the selection retains history', () => { + renderInput({ type: 'latestMessage' }, nonPersistentOnly); + + expect(modeOptions()).toEqual({ + earliestMessage: false, + latestMessage: true, + messageId: false, + dateTime: false, + relativeDateTime: false, + nthMessageAfterEarliest: false, + nthMessageBeforeLatest: false, + // Both approximate modes are history-dependent: one needs the topic's entry count, the other + // its first and last publish times, and a non-persistent topic answers neither. + approximateDataPosition: false, + approximateTimePosition: false, + }); + }); + + it('disables Earliest too, because on a non-persistent topic it would quietly mean "from now"', () => { + renderInput({ type: 'latestMessage' }, nonPersistentOnly); + + expect(modeOptions().earliestMessage).toBe(false); + }); + + it('explains why, in the reader\'s terms', () => { + renderInput({ type: 'latestMessage' }, nonPersistentOnly); + + const note = screen.getByTestId('cs-start-from-non-persistent-note'); + expect(note.textContent).toMatch(/non-persistent/i); + expect(note.textContent).toMatch(/no history|retain nothing|keep no history/i); + // Plain language, not Pulsar internals. + expect(note.textContent).not.toMatch(/examineMessage|405|broker/i); + }); + + it('falls back to Latest when the selected mode became unusable', () => { + // The user had picked a history mode, then pointed the session at a non-persistent topic. + const onChange = renderInput({ type: 'nthMessageAfterEarliest', n: 5 }, nonPersistentOnly); + + expect(lastStartFrom(onChange)).toEqual({ type: 'latestMessage' }); + }); + + it('leaves an already-valid selection alone', () => { + const onChange = renderInput({ type: 'latestMessage' }, nonPersistentOnly); + + expect(onChange).not.toHaveBeenCalled(); + }); + + it('keeps every mode when the selection is mixed, and says the non-persistent parts start from now', () => { + const onChange = renderInput({ type: 'nthMessageAfterEarliest', n: 5 }, mixed); + + expect(Object.values(modeOptions()).every((enabled) => enabled)).toBe(true); + expect(screen.getByTestId('cs-start-from-mixed-persistency-note').textContent).toMatch(/non-persistent/i); + expect(screen.queryByTestId('cs-start-from-non-persistent-note')).toBeNull(); + // A mixed selection is legal - nothing may be rewritten under the user. + expect(onChange).not.toHaveBeenCalled(); + }); + + it('says nothing at all when every selected topic retains history', () => { + renderInput({ type: 'nthMessageAfterEarliest', n: 5 }, { hasPersistent: true, hasNonPersistent: false }); + + expect(Object.values(modeOptions()).every((enabled) => enabled)).toBe(true); + expect(screen.queryByTestId('cs-start-from-non-persistent-note')).toBeNull(); + expect(screen.queryByTestId('cs-start-from-mixed-persistency-note')).toBeNull(); + }); + + it('says nothing when there is no target context at all, as in the library item editor', () => { + renderInput({ type: 'nthMessageAfterEarliest', n: 5 }); + + expect(Object.values(modeOptions()).every((enabled) => enabled)).toBe(true); + expect(screen.queryByTestId('cs-start-from-non-persistent-note')).toBeNull(); + expect(screen.queryByTestId('cs-start-from-mixed-persistency-note')).toBeNull(); + }); +}); + +describe('counted modes disclose their ordering basis', () => { + // The labels "Skip first n messages" / "Latest n messages" used to promise exact global semantics + // the server cannot provide - Pulsar keeps append order per partition, not a global publish-time + // sequence. The server-side contract lives in MessageOrderKey's scaladoc; this note is the + // user-facing half. The COUNT is always exactly n; WHICH n can differ when producer clocks + // disagree, and hiding that in a doc comment nobody reads was the review complaint. + it.each([ + ['nthMessageAfterEarliest', { type: 'nthMessageAfterEarliest', n: 5 }], + ['nthMessageBeforeLatest', { type: 'nthMessageBeforeLatest', n: 5 }], + ])('%s shows the ordering note', (_name, startFrom) => { + renderInput(startFrom); + const note = screen.getByTestId('cs-start-from-counted-note'); + expect(note.textContent).toMatch(/exactly n/i); + expect(note.textContent).toMatch(/publish time/i); + expect(note.textContent).toMatch(/clocks/i); + }); + + it('modes that seek exactly do not carry the note', () => { + renderInput({ type: 'earliestMessage' }); + expect(screen.queryByTestId('cs-start-from-counted-note')).toBeNull(); + }); +}); + +describe('the "single non-partitioned topic" hint sits only where it is true', () => { + // A message id names one entry in one topic's log, so it genuinely wants a single non-partitioned + // topic. The counted modes are exact-count ACROSS partitions (the counted-note says as much), so + // the same hint there undersells them - it used to appear on all three, pre-rework. + const hint = () => screen.queryByText(/works best with a single non-partitioned topic/i); + + const messageIdStartFrom = { + type: 'messageId', + messageId: { + type: 'value', + val: { + metadata: { id: 'mid-1', name: '', descriptionMarkdown: '', type: 'message-id' }, + spec: { hexString: '08 c3' }, + }, + }, + }; + + it('shows it for a message id, where a single-topic position is the accurate advice', () => { + renderInput(messageIdStartFrom); + expect(hint()).toBeTruthy(); + }); + + it.each([ + ['nthMessageAfterEarliest', { type: 'nthMessageAfterEarliest', n: 5 }], + ['nthMessageBeforeLatest', { type: 'nthMessageBeforeLatest', n: 5 }], + ])('does not show it for %s, whose count is exact across partitions', (_name, startFrom) => { + renderInput(startFrom); + expect(hint()).toBeNull(); + // The counted-note is what speaks for these modes now, and it does not undersell them. + expect(screen.getByTestId('cs-start-from-counted-note')).toBeTruthy(); + }); +}); diff --git a/ui/components/ui/ConsumerSession/SessionConfiguration/StartFromInput/StartFromInput.tsx b/ui/components/ui/ConsumerSession/SessionConfiguration/StartFromInput/StartFromInput.tsx index cbf5722ee..f44db5495 100644 --- a/ui/components/ui/ConsumerSession/SessionConfiguration/StartFromInput/StartFromInput.tsx +++ b/ui/components/ui/ConsumerSession/SessionConfiguration/StartFromInput/StartFromInput.tsx @@ -12,6 +12,12 @@ import { UseManagedItemValueSpinner, useManagedItemValue } from '../../../Librar import LibraryBrowserPanel, { LibraryBrowserPanelProps } from '../../../LibraryBrowser/LibraryBrowserPanel/LibraryBrowserPanel'; import { LibraryContext } from '../../../LibraryBrowser/model/library-context'; import { cloneDeep } from 'lodash'; +import ApproximateFractionInput from './ApproximateFractionInput'; +import MessageCountInput from './MessageCountInput'; +import { defaultApproximateFraction } from './approximate-fraction'; +import { TargetTopicsPersistency, startFromPersistencyAdvice } from './target-topics-persistency'; +import { messageIdError } from './message-id'; +import { latestMessageCountMax } from './message-count'; export type StartFromInputProps = { value: ManagedConsumerSessionStartFromValOrRef; @@ -20,6 +26,11 @@ export type StartFromInputProps = { disabled?: boolean; isReadOnly?: boolean; libraryBrowserPanel?: Partial + /** + * What the session's selected topics can retain. Omitted where there are no targets to speak of + * (the standalone library item editor), which leaves every mode available. + */ + targetTopicsPersistency?: TargetTopicsPersistency; }; type StartFromType = ConsumerSessionStartFrom['type']; @@ -35,20 +46,78 @@ const list: List = [ { type: 'item', title: 'Specific time', value: 'dateTime' }, { type: 'item', title: 'Relative time ago', value: 'relativeDateTime' }, { type: 'item', title: 'Skip first n messages', value: 'nthMessageAfterEarliest' }, - { type: 'item', title: 'Latest n messages', value: 'nthMessageBeforeLatest' } + { type: 'item', title: 'Latest n messages', value: 'nthMessageBeforeLatest' }, + // Two modes, not one, because "about half way in" is two different questions: half the MESSAGES + // behind you, or half the TIME behind you. On a topic where almost everything arrived in the last + // hour of a month's retention those land hours and weeks apart. + { type: 'item', title: 'About % through the data', value: 'approximateDataPosition' }, + { type: 'item', title: 'About % through the time range', value: 'approximateTimePosition' } ]; +// Every mode except the live tail needs the topic to have kept something. On a non-persistent topic +// they are all unusable - including "Earliest message", which is the misleading one: nothing is +// retained, so it quietly behaves as "from now" instead of failing. +const historyDependentStartFromTypes: StartFromType[] = [ + 'earliestMessage', + 'messageId', + 'dateTime', + 'relativeDateTime', + 'nthMessageAfterEarliest', + 'nthMessageBeforeLatest', + // Both approximate modes are proportions OF a history, so neither can be computed where there is + // none: one needs the topic's entry count, the other its first and last publish times. + 'approximateDataPosition', + 'approximateTimePosition' +]; + +function startFromList(isHistoryUnavailable: boolean): List { + if (!isHistoryUnavailable) { + return list; + } + + return list.map((item) => { + if (item.type !== 'item' || !historyDependentStartFromTypes.includes(item.value)) { + return item; + } + + return { ...item, disabled: true }; + }); +} + const StartFromInput: React.FC = (props) => { const [hoverRef, isHovered] = useHover(); const resolveResult = useManagedItemValue(props.value); + const persistencyAdvice = props.targetTopicsPersistency === undefined + ? 'none' + : startFromPersistencyAdvice(props.targetTopicsPersistency); + const isHistoryUnavailable = persistencyAdvice === 'history-unavailable'; + const resolvedStartFromType = resolveResult.type === 'success' ? resolveResult.value?.spec?.startFrom?.type : undefined; + useEffect(() => { if (props.value.val === undefined && resolveResult.type === 'success') { props.onChange({ ...props.value, val: resolveResult.value }); } }, [resolveResult]); + // A mode the selected topics can no longer honour would just sit there being unusable, so drop + // back to the live tail - the only thing a non-persistent topic can actually do. + useEffect(() => { + if (!isHistoryUnavailable || props.isReadOnly || resolveResult.type !== 'success') { + return; + } + + if (resolvedStartFromType === undefined || resolvedStartFromType === 'latestMessage') { + return; + } + + props.onChange({ + ...props.value, + val: { ...resolveResult.value, spec: { startFrom: { type: 'latestMessage' } } } + }); + }, [isHistoryUnavailable, resolvedStartFromType, props.isReadOnly]); + if (resolveResult.type !== 'success') { return } @@ -66,7 +135,18 @@ const StartFromInput: React.FC = (props) => { props.onChange(newValue); }; - const worksBestWithNonPartitionedTopic =
Works best with a single non-partitioned topic.
; + // messageId only: a message id names one entry in one topic's log, so it has no meaning across a + // partitioned topic or several topics. The counted modes deliberately do NOT carry this - they are + // exact-count across partitions (see the counted-note above), and this line would undersell them. + const messageIdWorksBestNote =
Works best with a single non-partitioned topic.
; + + const messageIdHexString = itemSpec.startFrom.type === 'messageId' + ? (itemSpec.startFrom.messageId.val?.spec.hexString || '') + : undefined; + // The same check the request conversion makes, run while the user can still see the field: the + // conversion happens after the Play click, so without this the only symptom is a create that the + // server refuses for a reason that names no field. + const startFromMessageIdError = messageIdHexString === undefined ? undefined : messageIdError(messageIdHexString); return (
@@ -96,7 +176,7 @@ const StartFromInput: React.FC = (props) => {
testId="cs-start-from" - list={list} + list={startFromList(isHistoryUnavailable)} value={itemSpec.startFrom.type} onChange={(v) => { switch (v as StartFromType) { @@ -116,6 +196,14 @@ const StartFromInput: React.FC = (props) => { onSpecChange({ startFrom: { type: 'nthMessageBeforeLatest', n: 5 } }); return; } + case 'approximateDataPosition': { + onSpecChange({ startFrom: { type: 'approximateDataPosition', fraction: defaultApproximateFraction } }); + return; + } + case 'approximateTimePosition': { + onSpecChange({ startFrom: { type: 'approximateTimePosition', fraction: defaultApproximateFraction } }); + return; + } case 'messageId': { const messageId: ManagedMessageIdValOrRef = { type: 'value', @@ -159,32 +247,114 @@ const StartFromInput: React.FC = (props) => { isReadOnly={props.isReadOnly} />
+ {persistencyAdvice === 'history-unavailable' && ( +
+ These topics are non-persistent: they keep no history, so a session can only show messages published from + now on. +
+ )} + {persistencyAdvice === 'history-partial' && ( +
+ Some of the selected topics are non-persistent and keep no history. Those start from now, whatever is chosen + here. +
+ )} + {(itemSpec.startFrom.type === 'nthMessageAfterEarliest' || itemSpec.startFrom.type === 'nthMessageBeforeLatest') && ( +
+ Across a partitioned topic or several topics, "first" and "last" are decided by each message's publish time, + taken partition by partition in the order they were written. Pulsar keeps no global sequence, so if producer + clocks disagree - or one steps backwards - the count is still exactly n, but which n can differ from a strict + publish-time answer. +
+ )} {itemSpec.startFrom.type === 'nthMessageAfterEarliest' && (
- onSpecChange({ startFrom: { type: 'nthMessageAfterEarliest', n: parseInt(v) } })} - inputProps={{ disabled: props.disabled, min: 0 }} - placeholder='n' + onSpecChange({ startFrom: { type: 'nthMessageAfterEarliest', n } })} + disabled={props.disabled} isReadOnly={props.isReadOnly} /> - {worksBestWithNonPartitionedTopic}
)} {itemSpec.startFrom.type === 'nthMessageBeforeLatest' && (
- onSpecChange({ startFrom: { type: 'nthMessageBeforeLatest', n: parseInt(v) } })} - inputProps={{ disabled: props.disabled, min: 0 }} - placeholder='n' + onSpecChange({ startFrom: { type: 'nthMessageBeforeLatest', n } })} + // Only this mode has a ceiling: the last n are resolved by a backward walk over entry + // metadata, one broker lookup per entry with no progress reporting, so a huge n is a + // request the server would grind on for hours. Skip-N streams past its messages + // instead, reports progress while it does, and deliberately has no maximum. + max={latestMessageCountMax} + disabled={props.disabled} + isReadOnly={props.isReadOnly} + /> +
+ )} + {itemSpec.startFrom.type === 'approximateDataPosition' && ( +
+ onSpecChange({ startFrom: { type: 'approximateDataPosition', fraction } })} + disabled={props.disabled} + isReadOnly={props.isReadOnly} + /> +
+
+ Starts partway into the messages a topic still keeps: 0% is the oldest message still kept. 100% goes + past all of them and shows only messages published from now on, exactly like "Latest message" - it is + the one percentage that shows nothing that is already there. +
+
+ This counts messages, not time. If most of the messages arrived in the last hour, then 50% lands inside + that last hour - use "About % through the time range" to go half way back in time instead. +
+
+ Every topic starts at its own position. If this session covers more than one topic, or a partitioned + topic, each topic and each partition jumps to that percentage of its own messages - not of all of them + put together. +
+
+ The position is approximate. Pulsar stores messages in batched entries, and the jump is measured in + entries, so 75% of the entries is not exactly 75% of the messages - the two drift apart as far as batch + sizes varied. That is the trade for staying instant on a huge topic, where "Skip first n messages" has to + count every message it skips. +
+
+
+ )} + {itemSpec.startFrom.type === 'approximateTimePosition' && ( +
+ onSpecChange({ startFrom: { type: 'approximateTimePosition', fraction } })} + disabled={props.disabled} isReadOnly={props.isReadOnly} /> - {worksBestWithNonPartitionedTopic} +
+
+ Starts partway through the time a topic still covers: 0% is the oldest message still kept, 100% is the + newest one. 50% is the middle of that stretch of time, whether one message was published in it or a + billion. +
+
+ This measures time, not messages. If most of the messages arrived in the last hour of a month's worth of + history, 50% here is about two weeks ago - use "About % through the data" to land half way through the + messages instead. +
+
+ A partitioned topic is treated as one topic: the stretch runs from its oldest message to its newest, + across every partition, and all of them jump to the same moment. A partition that went quiet early + simply has nothing left to show from there. Separate topics still each start at their own moment. +
+
+ The moment is approximate: it is worked out from when messages were published, and that timestamp comes + from the machine that sent them. +
+
)} {itemSpec.startFrom.type === 'messageId' && ( @@ -207,9 +377,15 @@ const StartFromInput: React.FC = (props) => { newItemSpec.startFrom.messageId.val.spec.hexString = v; onSpecChange(newItemSpec); }} + isError={startFromMessageIdError !== undefined} isReadOnly={props.isReadOnly} /> - {worksBestWithNonPartitionedTopic} + {startFromMessageIdError !== undefined && ( +
+ {startFromMessageIdError} +
+ )} + {messageIdWorksBestNote}
)} {itemSpec.startFrom.type === 'dateTime' && ( diff --git a/ui/components/ui/ConsumerSession/SessionConfiguration/StartFromInput/approximate-fraction.ts b/ui/components/ui/ConsumerSession/SessionConfiguration/StartFromInput/approximate-fraction.ts new file mode 100644 index 000000000..a298fb9bf --- /dev/null +++ b/ui/components/ui/ConsumerSession/SessionConfiguration/StartFromInput/approximate-fraction.ts @@ -0,0 +1,52 @@ +/** + * Both "About % through the data" and "About % through the time range" store a FRACTION in [0, 1] + * (the protobuf contract), but a percentage is the natural thing to put in front of a user. These + * two functions are the whole conversion, and the only place the 0-100 range is enforced on the way + * in - the server rejects a fraction outside [0.0, 1.0] outright, so an invalid percent must never + * reach the model. + * + * Shared deliberately: the two modes differ in what the percentage is OF, never in how it is typed, + * so a divergence here would be a defect in one of them rather than a feature. + */ + +/** Default for a freshly-picked approximate mode: the middle of whatever it is a proportion of. */ +export const defaultApproximateFraction = 0.5; + +/** Digits kept when converting back and forth - 4 decimal places of a percent, i.e. 1 part in 10^6. */ +const percentDecimals = 4; + +/** + * A stored fraction rendered as the percent shown in the input. + * + * `Number(...)` after `toFixed` strips both the trailing zeros and the binary-float debris: + * `0.33 * 100` is `33.000000000000004`, which nobody wants to see in a form field. + */ +export function percentFromFraction(fraction: number): string { + if (!Number.isFinite(fraction)) { + return ''; + } + + return String(Number((fraction * 100).toFixed(percentDecimals))); +} + +/** + * A percent as typed, converted to the fraction the model stores - or `undefined` when the text is + * not a percentage in [0, 100]. `undefined` means "refuse this", never "use zero". + * + * The regexp is deliberately stricter than `Number()`, which happily accepts `''` (0), `'0x10'` (16) + * and `'1e2'` (100) - none of which a user meant to type into a percentage field. + */ +export function fractionFromPercent(raw: string): number | undefined { + const trimmed = raw.trim(); + + if (!/^[+-]?(\d+(\.\d*)?|\.\d+)$/.test(trimmed)) { + return undefined; + } + + const percent = Number(trimmed); + if (!Number.isFinite(percent) || percent < 0 || percent > 100) { + return undefined; + } + + return Number((percent / 100).toFixed(percentDecimals + 2)); +} diff --git a/ui/components/ui/ConsumerSession/SessionConfiguration/StartFromInput/message-count.ts b/ui/components/ui/ConsumerSession/SessionConfiguration/StartFromInput/message-count.ts new file mode 100644 index 000000000..50462fa46 --- /dev/null +++ b/ui/components/ui/ConsumerSession/SessionConfiguration/StartFromInput/message-count.ts @@ -0,0 +1,55 @@ +/** + * The count of messages behind "Skip first n messages" and "Latest n messages". + * + * Both are a whole number of messages, at least zero, that the server has to be able to act on. The + * field used to commit `parseInt` of whatever was on screen after every keystroke, and `parseInt` + * answers something for nearly anything: `''` is NaN, `'2.7'` is 2, `'1e3'` is 1, and anything past + * 2^53 comes back as a different number than was typed. None of those are refusals, so each one + * became a start position the user never asked for. The server now refuses negatives too + * (startFromCountRejectionReason), so this field is the first line of defence, not the only one. + */ + +/** + * The largest "Latest n messages" the server accepts - the mirror of `latestNMaxAccepted` in + * `server/.../handleStartFrom.scala`, enforced there by `startFromCountRejectionReason`. + * + * Not a memory bound: the last n are resolved by a backward walk over entry METADATA (nothing is + * retained), but that walk costs one broker lookup per entry, runs while session creation is + * blocked, and reports no progress - so a larger n is a request the server refuses outright. This + * constant MUST track the server's: when it lagged behind (the server tightened to ten million + * while this stayed at Int.MaxValue), every value in between validated here, was committed, and + * failed only after Play - with the field it came from long out of sight. The parity is pinned by + * test on both sides. + * + * "Skip first n messages" has no such ceiling on purpose - it streams past its messages rather + * than holding them, and it reports progress while it does - so this is a per-MODE limit, not a + * limit on counts. + */ +export const latestMessageCountMax = 10_000_000; + +/** + * The count a text field means, or `undefined` when it does not mean one. `undefined` is "refuse + * this", never "use zero". + * + * Deliberately stricter than `Number()`: no sign, no decimal point, no exponent, nothing outside the + * range where an integer survives the round trip through a double, and nothing above the mode's own + * maximum where it has one. + */ +export function messageCountFromText(raw: string, max?: number): number | undefined { + const trimmed = raw.trim(); + + if (!/^\d+$/.test(trimmed)) { + return undefined; + } + + const count = Number(trimmed); + if (!Number.isSafeInteger(count)) { + return undefined; + } + + if (max !== undefined && count > max) { + return undefined; + } + + return count; +} diff --git a/ui/components/ui/ConsumerSession/SessionConfiguration/StartFromInput/message-id.ts b/ui/components/ui/ConsumerSession/SessionConfiguration/StartFromInput/message-id.ts new file mode 100644 index 000000000..f41e217ba --- /dev/null +++ b/ui/components/ui/ConsumerSession/SessionConfiguration/StartFromInput/message-id.ts @@ -0,0 +1,27 @@ +/** + * The message id behind "Message with specific ID", and why some text is not one. + * + * The shared hex parser reads blank text as an EMPTY byte array, and that is right for what it is + * for: a byte payload really can be empty. A start POSITION cannot. The server parses this field as + * a real message id and refuses zero bytes, so an empty field bought a full create round trip whose + * only outcome was an error naming no field at all. + * + * Kept out of the component so every serialization path can refuse the same text with one check. + * There is more than one such path and no single "last" place: the Play request conversion runs + * after the click, and the library-save conversion runs on save - an unguarded parse in either + * throws where nothing catches it, so the check belongs at each sink. + */ +import { hexStringToByteArray } from '../../../../conversions/conversions'; + +export function messageIdError(hexString: string): string | undefined { + if (hexString.trim() === '') { + return 'Enter the message id to start from, as hex bytes - for example 08 c3 03 10 cd 04 20 00 30 01.'; + } + + try { + hexStringToByteArray(hexString); + return undefined; + } catch (err) { + return (err as Error).message; + } +} diff --git a/ui/components/ui/ConsumerSession/SessionConfiguration/StartFromInput/target-topics-persistency.spec.ts b/ui/components/ui/ConsumerSession/SessionConfiguration/StartFromInput/target-topics-persistency.spec.ts new file mode 100644 index 000000000..44defd362 --- /dev/null +++ b/ui/components/ui/ConsumerSession/SessionConfiguration/StartFromInput/target-topics-persistency.spec.ts @@ -0,0 +1,170 @@ +/** + * Which of a session's selected topics can retain history at all. + * + * A non-persistent topic stores NOTHING: a consumer only ever sees messages published after it + * subscribed, and the admin `examineMessage` call the history-based start-from modes are built on + * refuses non-persistent topics with a 405. So the start-from selector has to know, and the only + * signal it needs is client-side: `non-persistent://` versus `persistent://`. + * + * The bias throughout is "never disable on a guess": anything this cannot resolve counts as + * possibly-persistent, so an unresolved reference or an open-ended regex leaves every mode enabled. + */ +import { + isNonPersistentTopicFqn, + startFromPersistencyAdvice, + targetTopicsPersistency, +} from './target-topics-persistency'; + +const topicContext = (topicPersistency: 'persistent' | 'non-persistent') => ({ + pulsarResource: { + type: 'topic' as const, + tenant: 'public', + namespace: 'default', + topicPersistency, + topic: 'a-topic', + }, +}); + +const namespaceContext = { pulsarResource: { type: 'namespace' as const, tenant: 'public', namespace: 'default' } }; + +/** A target whose topic selector is `selector`; `isEnabled` defaults to true. */ +const target = (selector: unknown, isEnabled = true) => ({ + type: 'value' as const, + val: { + metadata: { id: 'tg', name: '', descriptionMarkdown: '', type: 'consumer-session-target' as const }, + spec: { + isEnabled, + topicSelector: { + type: 'value' as const, + val: { + metadata: { id: 'ts', name: '', descriptionMarkdown: '', type: 'topic-selector' as const }, + spec: { topicSelector: selector }, + }, + }, + }, + }, +}); + +const multi = (...topicFqns: string[]) => ({ type: 'multi-topic-selector', topicFqns }); +const regex = (regexSubscriptionMode: string) => ({ + type: 'namespaced-regex-topic-selector', + namespaceFqn: 'public/default', + pattern: '.*', + regexSubscriptionMode, +}); + +const persistency = (targets: unknown[], context: unknown = topicContext('persistent')) => + targetTopicsPersistency(targets as never, context as never); + +describe('isNonPersistentTopicFqn', () => { + it('splits on the FQN scheme', () => { + expect(isNonPersistentTopicFqn('non-persistent://public/default/t')).toBe(true); + expect(isNonPersistentTopicFqn('persistent://public/default/t')).toBe(false); + }); + + it('does not mistake a persistent topic merely NAMED like one', () => { + // The scheme is a prefix, not a substring - a topic called "non-persistent-audit" is persistent. + expect(isNonPersistentTopicFqn('persistent://public/default/non-persistent-audit')).toBe(false); + }); +}); + +describe('targetTopicsPersistency', () => { + it('reports a lone non-persistent target', () => { + expect(persistency([target(multi('non-persistent://public/default/t'))])).toEqual({ + hasPersistent: false, + hasNonPersistent: true, + }); + }); + + it('reports a lone persistent target', () => { + expect(persistency([target(multi('persistent://public/default/t'))])).toEqual({ + hasPersistent: true, + hasNonPersistent: false, + }); + }); + + it('reports a mix, whether it comes from two targets or one multi-topic target', () => { + const acrossTargets = persistency([ + target(multi('persistent://public/default/a')), + target(multi('non-persistent://public/default/b')), + ]); + expect(acrossTargets).toEqual({ hasPersistent: true, hasNonPersistent: true }); + + const withinOneTarget = persistency([ + target(multi('persistent://public/default/a', 'non-persistent://public/default/b')), + ]); + expect(withinOneTarget).toEqual({ hasPersistent: true, hasNonPersistent: true }); + }); + + it('ignores a disabled target - it is not consumed from', () => { + expect( + persistency([ + target(multi('persistent://public/default/a')), + target(multi('non-persistent://public/default/b'), false), + ]) + ).toEqual({ hasPersistent: true, hasNonPersistent: false }); + }); + + it('reads the current topic from the page the session is mounted on', () => { + const current = [target({ type: 'current-topic' })]; + + expect(persistency(current, topicContext('non-persistent'))).toEqual({ + hasPersistent: false, + hasNonPersistent: true, + }); + expect(persistency(current, topicContext('persistent'))).toEqual({ + hasPersistent: true, + hasNonPersistent: false, + }); + // Mounted on a namespace: "current topic" pins nothing down. + expect(persistency(current, namespaceContext)).toEqual({ hasPersistent: true, hasNonPersistent: false }); + }); + + it('only treats a regex selector as non-persistent when it excludes persistent topics', () => { + expect(persistency([target(regex('non-persistent-only'))])).toEqual({ + hasPersistent: false, + hasNonPersistent: true, + }); + expect(persistency([target(regex('persistent-only'))])).toEqual({ + hasPersistent: true, + hasNonPersistent: false, + }); + }); + + it('treats an "all topics" regex as reaching BOTH domains, because the server makes it', () => { + // NamespacedRegexTopicSelector lists the persistent and the non-persistent topics of the + // namespace and concatenates them before matching the pattern, so a matching non-persistent + // topic IS consumed. Claiming persistent-only here suppressed the one warning that says those + // topics start from now whatever the start-from asks for - and nothing gets disabled by this, + // since a mixed selection keeps every mode available. + expect(persistency([target(regex('all-topics'))])).toEqual({ hasPersistent: true, hasNonPersistent: true }); + expect(startFromPersistencyAdvice(persistency([target(regex('all-topics'))]))).toBe('history-partial'); + }); + + it('never concludes "non-persistent" from something it cannot resolve', () => { + // No targets at all, an unresolved target reference, and an empty topic list: each is unknown, + // and unknown must leave every mode available. + expect(persistency([])).toEqual({ hasPersistent: true, hasNonPersistent: false }); + expect(persistency([{ type: 'reference', ref: 'some-id' }])).toEqual({ + hasPersistent: true, + hasNonPersistent: false, + }); + expect(persistency([target(multi())])).toEqual({ hasPersistent: true, hasNonPersistent: false }); + }); + + it('lets an empty selector alongside a non-persistent one stay non-persistent', () => { + // An empty selector picks no topics, so it must not dilute what the other target proved. + expect(persistency([target(multi()), target(multi('non-persistent://public/default/b'))])).toEqual({ + hasPersistent: false, + hasNonPersistent: true, + }); + }); +}); + +describe('startFromPersistencyAdvice', () => { + it('classifies the three cases the selector reacts to', () => { + expect(startFromPersistencyAdvice({ hasPersistent: false, hasNonPersistent: true })).toBe('history-unavailable'); + expect(startFromPersistencyAdvice({ hasPersistent: true, hasNonPersistent: true })).toBe('history-partial'); + expect(startFromPersistencyAdvice({ hasPersistent: true, hasNonPersistent: false })).toBe('none'); + }); +}); diff --git a/ui/components/ui/ConsumerSession/SessionConfiguration/StartFromInput/target-topics-persistency.ts b/ui/components/ui/ConsumerSession/SessionConfiguration/StartFromInput/target-topics-persistency.ts new file mode 100644 index 000000000..6dd0b0e04 --- /dev/null +++ b/ui/components/ui/ConsumerSession/SessionConfiguration/StartFromInput/target-topics-persistency.ts @@ -0,0 +1,136 @@ +import { LibraryContext } from '../../../LibraryBrowser/model/library-context'; +import { ManagedConsumerSessionTargetValOrRef } from '../../../LibraryBrowser/model/user-managed-items'; + +/** + * What the session's selected topics can offer the start-from selector. + * + * A non-persistent topic retains nothing: a consumer only ever receives messages published after it + * subscribed, and every history-based start-from mode is built on an admin call that refuses + * non-persistent topics outright. So the selector needs to know whether ANY selected topic can + * retain history - not whether all of them can. + */ +export type TargetTopicsPersistency = { + /** At least one selected topic can retain history. Anything unresolvable counts here. */ + hasPersistent: boolean; + /** At least one selected topic is non-persistent and therefore retains nothing. */ + hasNonPersistent: boolean; +}; + +/** Pulsar FQNs carry the persistency in the scheme: `non-persistent://tenant/namespace/topic`. */ +export function isNonPersistentTopicFqn(topicFqn: string): boolean { + return topicFqn.trim().startsWith('non-persistent://'); +} + +/** + * Classify the topics the session's enabled targets point at. + * + * Every branch that cannot resolve a topic reports `hasPersistent` instead of staying silent: this + * result disables controls, and disabling a control on a guess is worse than leaving a useless one + * enabled. An empty selector is the one exception - it selects no topics, so it contributes nothing + * either way and must not dilute what a sibling target proved. + */ +export function targetTopicsPersistency( + targets: ManagedConsumerSessionTargetValOrRef[], + libraryContext: LibraryContext +): TargetTopicsPersistency { + let hasPersistent = false; + let hasNonPersistent = false; + + const observeTopicFqn = (topicFqn: string) => { + if (isNonPersistentTopicFqn(topicFqn)) { + hasNonPersistent = true; + return; + } + + hasPersistent = true; + }; + + targets.forEach((target) => { + const targetSpec = target.val?.spec; + + // An unresolved target reference - what it points at is unknown. + if (targetSpec === undefined) { + hasPersistent = true; + return; + } + + if (targetSpec.isEnabled === false) { + return; + } + + const topicSelector = targetSpec.topicSelector?.val?.spec?.topicSelector; + if (topicSelector === undefined) { + hasPersistent = true; + return; + } + + switch (topicSelector.type) { + case 'current-topic': { + const pulsarResource = libraryContext.pulsarResource; + if (pulsarResource.type !== 'topic') { + // Mounted on a namespace: "the current topic" pins nothing down yet. + hasPersistent = true; + return; + } + + if (pulsarResource.topicPersistency === 'non-persistent') { + hasNonPersistent = true; + return; + } + + hasPersistent = true; + return; + } + case 'multi-topic-selector': { + topicSelector.topicFqns.forEach(observeTopicFqn); + return; + } + case 'namespaced-regex-topic-selector': { + if (topicSelector.regexSubscriptionMode === 'non-persistent-only') { + hasNonPersistent = true; + return; + } + + // "all-topics" really does mean both domains: the server lists the namespace's persistent + // AND non-persistent topics and concatenates them before matching the pattern + // (NamespacedRegexTopicSelector), so a matching non-persistent topic is consumed from. + // Reporting it as possibly-mixed costs nothing - a mixed selection disables no mode - and + // it restores the note saying those topics start from now whatever is chosen here. + if (topicSelector.regexSubscriptionMode === 'all-topics') { + hasPersistent = true; + hasNonPersistent = true; + return; + } + + hasPersistent = true; + return; + } + } + }); + + // Nothing resolvable at all (no targets, or only empty selectors). + if (!hasPersistent && !hasNonPersistent) { + return { hasPersistent: true, hasNonPersistent: false }; + } + + return { hasPersistent, hasNonPersistent }; +} + +export type StartFromPersistencyAdvice = + /** Nothing selected retains history: only a live tail is possible. */ + | 'history-unavailable' + /** Some selected topics retain history and some do not. */ + | 'history-partial' + | 'none'; + +export function startFromPersistencyAdvice(persistency: TargetTopicsPersistency): StartFromPersistencyAdvice { + if (!persistency.hasPersistent && persistency.hasNonPersistent) { + return 'history-unavailable'; + } + + if (persistency.hasPersistent && persistency.hasNonPersistent) { + return 'history-partial'; + } + + return 'none'; +} diff --git a/ui/components/ui/ConsumerSession/SessionConfiguration/display-items.ts b/ui/components/ui/ConsumerSession/SessionConfiguration/display-items.ts new file mode 100644 index 000000000..1f0ea19ca --- /dev/null +++ b/ui/components/ui/ConsumerSession/SessionConfiguration/display-items.ts @@ -0,0 +1,52 @@ +/** + * How many messages a session keeps on screen. + * + * The limit is what stops a long-running session from growing until the tab dies, and it is applied + * as `messages.slice(-limit)`. That expression turns EVERY non-positive limit into "keep + * everything": `slice(-0)` is `slice(0)`, i.e. the whole array, and so is `slice(-NaN)`. So a limit + * of zero - which is what an emptied number field commits, and what a saved session can carry - + * silently removes the very limit it configures, and the browser buffer grows without bound. + * + * A fractional or negative limit is no better: `slice(-2.5)` drops a different number of messages + * than either 2 or 3 would, and `slice(5)` (from a limit of -5) drops the OLDEST messages from the + * front while keeping everything after them. + * + * Hence one definition of the domain, used by the field that edits it, by the conversion that reads + * a persisted config, and by the retention itself. + */ + +export const defaultNumDisplayItems = 10_000; + +/** + * The limit a text field means, or `undefined` when it does not mean one. `undefined` is "refuse + * this" - there is no sensible "0 messages on screen", so zero is refused rather than reinterpreted. + */ +export function numDisplayItemsFromText(raw: string): number | undefined { + const trimmed = raw.trim(); + + if (!/^\d+$/.test(trimmed)) { + return undefined; + } + + const count = Number(trimmed); + if (!Number.isSafeInteger(count) || count < 1) { + return undefined; + } + + return count; +} + +/** + * The limit to actually apply, given whatever a persisted config carries. + * + * A stored spec is a TRUST BOUNDARY: it is JSON on disk, written by an older build, hand-edited, or + * committed by a field that did not validate. Anything that is not a usable limit falls back to the + * default rather than disabling retention. + */ +export function displayItemLimit(stored: number | undefined): number { + if (stored === undefined || !Number.isSafeInteger(stored) || stored < 1) { + return defaultNumDisplayItems; + } + + return stored; +} diff --git a/ui/components/ui/ConsumerSession/StartFromProgress/StartFromProgress.module.css b/ui/components/ui/ConsumerSession/StartFromProgress/StartFromProgress.module.css new file mode 100644 index 000000000..40a5447db --- /dev/null +++ b/ui/components/ui/ConsumerSession/StartFromProgress/StartFromProgress.module.css @@ -0,0 +1,20 @@ +.StartFromProgress { + display: flex; + flex-direction: column; + gap: 8rem; + min-width: 320rem; +} + +.Bar { + width: 100%; + height: 8rem; +} + +.Counts { + font-variant-numeric: tabular-nums; +} + +.Hint { + font-size: x-small; + color: grey; +} diff --git a/ui/components/ui/ConsumerSession/StartFromProgress/StartFromProgress.test.tsx b/ui/components/ui/ConsumerSession/StartFromProgress/StartFromProgress.test.tsx new file mode 100644 index 000000000..cea59498a --- /dev/null +++ b/ui/components/ui/ConsumerSession/StartFromProgress/StartFromProgress.test.tsx @@ -0,0 +1,55 @@ +/** + * @jest-environment jsdom + * + * The panel shown while a large "skip first n messages" is being resolved. Skipping n messages + * exactly costs O(n) - Pulsar keeps no message-ordinal index - so a very large n genuinely takes + * time, and without this the session looks frozen on "Awaiting for new messages...". + */ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import StartFromProgress from './StartFromProgress'; + +describe('StartFromProgress', () => { + it('says what is happening and how far along it is', () => { + render(); + + const panel = screen.getByTestId('cs-start-from-progress'); + expect(panel.textContent).toMatch(/skipping/i); + // Both counts must be legible, and the reader must be able to see it is moving. + expect(panel.textContent).toContain('2,500,000'); + expect(panel.textContent).toContain('10,000,000'); + expect(panel.textContent).toContain('25%'); + }); + + it('drives a real progress indicator, not just text', () => { + render(); + + const bar = screen.getByTestId('cs-start-from-progress-bar'); + expect(bar.getAttribute('value')).toBe('2500000'); + expect(bar.getAttribute('max')).toBe('10000000'); + }); + + it('exposes the raw counts for e2e, so a test does not have to parse prose', () => { + render(); + + const panel = screen.getByTestId('cs-start-from-progress'); + expect(panel.getAttribute('data-cs-skipped')).toBe('3000000'); + expect(panel.getAttribute('data-cs-to-skip')).toBe('4000000'); + expect(panel.getAttribute('data-cs-percent')).toBe('75'); + }); + + it('survives a zero total instead of rendering NaN%', () => { + // The server should never send this, but a divide-by-zero here would put "NaN%" on screen. + render(); + + const panel = screen.getByTestId('cs-start-from-progress'); + expect(panel.getAttribute('data-cs-percent')).toBe('0'); + expect(panel.textContent).not.toContain('NaN'); + }); + + it('never claims more than 100%', () => { + render(); + + expect(screen.getByTestId('cs-start-from-progress').getAttribute('data-cs-percent')).toBe('100'); + }); +}); diff --git a/ui/components/ui/ConsumerSession/StartFromProgress/StartFromProgress.tsx b/ui/components/ui/ConsumerSession/StartFromProgress/StartFromProgress.tsx new file mode 100644 index 000000000..a5390b668 --- /dev/null +++ b/ui/components/ui/ConsumerSession/StartFromProgress/StartFromProgress.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import s from './StartFromProgress.module.css'; + +/** How far a "skip first n messages" start-from has got, as the UI needs it. */ +export type StartFromSkipProgress = { + messagesSkipped: number; + messagesToSkip: number; +}; + +/** + * Below this many messages a skip resolves quickly enough that a progress panel would be pure noise, + * so nothing is shown. Above it the skip takes real time and the session would otherwise look frozen. + */ +export const startFromProgressDisplayThreshold = 1000000; + +const formatCount = (n: number) => n.toLocaleString('en-US'); + +export type StartFromProgressProps = { + progress: StartFromSkipProgress; +}; + +/** + * Shown while a large "skip first n messages" start-from is still being resolved. + * + * Skipping n messages exactly costs O(n): Pulsar keeps no message-ordinal index, so the only way to + * land on message n is to count n messages. This panel exists so a big skip reads as "working" rather + * than "hung". + */ +const StartFromProgress: React.FC = ({ progress }) => { + const { messagesSkipped, messagesToSkip } = progress; + const percent = messagesToSkip > 0 + ? Math.min(100, Math.floor((messagesSkipped / messagesToSkip) * 100)) + : 0; + + return ( +
+
Skipping {formatCount(messagesToSkip)} messages before the first one is shown...
+ +
+ {formatCount(messagesSkipped)} of {formatCount(messagesToSkip)} skipped ({percent}%) +
+
+ Skipping an exact number of messages means counting them one by one, so a large skip takes a while. + "About % through the data" reaches roughly the same place immediately. +
+
+ ); +}; + +export default StartFromProgress; diff --git a/ui/components/ui/ConsumerSession/Th.tsx b/ui/components/ui/ConsumerSession/Th.tsx index 4c862d06a..320b4bada 100644 --- a/ui/components/ui/ConsumerSession/Th.tsx +++ b/ui/components/ui/ConsumerSession/Th.tsx @@ -4,7 +4,7 @@ import { Sort, SortKey } from "./sort"; import arrowDownIcon from '../../ui/ChildrenTable/arrow-down.svg'; import arrowUpIcon from '../../ui/ChildrenTable/arrow-up.svg'; import SvgIcon from '../SvgIcon/SvgIcon'; -import { FC, MutableRefObject } from "react"; +import React, { FC, MutableRefObject } from "react"; import s from './ConsumerSession.module.css' import cts from "../../ui/ChildrenTable/ChildrenTable.module.css"; import { isEqual } from "lodash"; @@ -20,7 +20,10 @@ export type ThProps = { width?: number, onResizeStart?: (startClientX: number) => void, suppressSortClickRef?: MutableRefObject, - testId?: string + testId?: string, + /** Native-drag column reorder, provided by the table that owns the order. */ + dragProps?: React.ThHTMLAttributes, + isDragOver?: boolean }; export const Th: FC = (props: ThProps) => { @@ -43,7 +46,13 @@ export const Th: FC = (props: ThProps) => { } return ( - +
{ + let consoleError: jest.SpyInstance; + + beforeEach(() => { + window.localStorage.clear(); + // React logs the caught render error; keep the suite output readable. + consoleError = jest.spyOn(console, 'error').mockImplementation(() => undefined); + }); + + afterEach(() => { + consoleError.mockRestore(); + window.localStorage.clear(); + }); + + it('brings the controls back and persists a usable default config', async () => { + window.localStorage.setItem(key, JSON.stringify(poisonedConfig)); + + render(); + + // The modal is usable again: format picker, Export and Reset buttons are all back. + await waitFor(() => expect(screen.getByTestId('cs-export-run')).toBeTruthy()); + expect(screen.getByTestId('cs-export-format')).toBeTruthy(); + expect(screen.getByTestId('cs-export-reset')).toBeTruthy(); + + // The very section that threw renders again, from the recovered config. + const activeFields = defaultExportConfig.fields.fields.filter((f) => f.isActive).length; + expect(screen.getByText(`Message fields ${activeFields} of ${defaultExportConfig.fields.fields.length}`)).toBeTruthy(); + + // A subsequent export would run against the default config, not the poisoned one. + expect(readStoredConfig()).toEqual(defaultExportConfig); + }); + + it('renders the controls normally when the stored config is valid', () => { + window.localStorage.setItem(key, JSON.stringify(defaultExportConfig)); + + render(); + + expect(screen.getByTestId('cs-export-run')).toBeTruthy(); + expect(consoleError).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/components/ui/ConsumerSession/Toolbar/ExportMessagesButton/MessagesExporter/MessagesExporter.tsx b/ui/components/ui/ConsumerSession/Toolbar/ExportMessagesButton/MessagesExporter/MessagesExporter.tsx index edc87e6e5..73a04df75 100644 --- a/ui/components/ui/ConsumerSession/Toolbar/ExportMessagesButton/MessagesExporter/MessagesExporter.tsx +++ b/ui/components/ui/ConsumerSession/Toolbar/ExportMessagesButton/MessagesExporter/MessagesExporter.tsx @@ -169,6 +169,10 @@ const MessagesExporter = (props: MessagesExporterProps) => { setErrorKey(errorKey + 1); notifyInfo("Invalid export config. Resetting to default. Try to reload the page if the problem persists."); }} + // ErrorBoundary keeps its own error state (and keeps rendering the empty fallback) until it is + // reset. Re-keying the child alone left the user with a blank modal, so hand the bumped key to + // resetKeys - that clears the error and re-renders the controls with the default config. + resetKeys={[errorKey]} fallback={<>} > <_MessagesExporter key={errorKey} {...props} config={config} onConfigChange={setConfig} /> diff --git a/ui/components/ui/ConsumerSession/Toolbar/Toolbar.module.css b/ui/components/ui/ConsumerSession/Toolbar/Toolbar.module.css index 8c17dc03e..0294ebea0 100644 --- a/ui/components/ui/ConsumerSession/Toolbar/Toolbar.module.css +++ b/ui/components/ui/ConsumerSession/Toolbar/Toolbar.module.css @@ -19,6 +19,32 @@ justify-content: flex-end; } +/* Sits in the ToolbarLeft button row, between Play and Stop: spaced like a .Control, captions + left-aligned under the inputs so they read as labels rather than as right-aligned stats. */ +.DeliveryControl { + display: flex; + flex-direction: column; + align-items: flex-start; + margin-right: 8rem; +} + +.DeliveryControlInput { + width: 72rem; + font-size: 12rem; + padding: 1rem 6rem; + text-align: right; + border: 1rem solid var(--border-color, #d0d0d0); + border-radius: 4rem; + background: var(--surface-color, transparent); + color: inherit; +} + +.DeliveryControlCaption { + font-size: 12rem; + color: var(--text-color-secondary, #666); + white-space: nowrap; +} + .MessagesLoadedStats { display: flex; flex-direction: column; diff --git a/ui/components/ui/ConsumerSession/Toolbar/Toolbar.test.tsx b/ui/components/ui/ConsumerSession/Toolbar/Toolbar.test.tsx new file mode 100644 index 000000000..c4685b3de --- /dev/null +++ b/ui/components/ui/ConsumerSession/Toolbar/Toolbar.test.tsx @@ -0,0 +1,92 @@ +/** + * @jest-environment jsdom + * @jest-environment-options {"customExportConditions": ["node"]} + * + * The play/pause/resume button's disabled logic. An unconvertible stored config (no runtime config) + * must block only the transition that BUILDS a session from it - Play from `new`. Pause (`running` -> + * `pausing`) and Resume (`paused` -> `running`) are name-only RPCs that never touch the runtime + * config, so an unusable config must NOT strand a live session with Stop (which throws away the + * loaded messages) as its only move. + */ +import React from 'react'; +import { cleanup, render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import * as Modals from '../../../app/contexts/Modals/Modals'; +import Toolbar from './Toolbar'; +import { SessionState, ConsumerSessionConfig } from '../types'; + +const renderToolbar = (sessionState: SessionState, config: ConsumerSessionConfig | undefined) => { + render( + + + + + + ); +}; + +const playButton = () => screen.getByTestId('cs-play') as HTMLButtonElement; + +// A defined config is "usable"; Toolbar only reads whether it is undefined. +const usableConfig = {} as ConsumerSessionConfig; + +describe('an unusable (undefined) config disables only the build-from-config transition', () => { + afterEach(cleanup); + + it('disables Play from `new`, which is the only state that builds a session from config', () => { + renderToolbar('new', undefined); + expect(playButton().disabled).toBe(true); + }); + + it('keeps Pause enabled while running - pausing does not consume the config', () => { + renderToolbar('running', undefined); + expect(playButton().disabled).toBe(false); + }); + + it('keeps Resume enabled while paused - resuming does not consume the config', () => { + renderToolbar('paused', undefined); + expect(playButton().disabled).toBe(false); + }); +}); + +describe('a usable config leaves the ordinary transitions enabled', () => { + afterEach(cleanup); + + it('enables Play from `new`', () => { + renderToolbar('new', usableConfig); + expect(playButton().disabled).toBe(false); + }); + + it('enables Pause while running', () => { + renderToolbar('running', usableConfig); + expect(playButton().disabled).toBe(false); + }); + + it('enables Resume while paused', () => { + renderToolbar('paused', usableConfig); + expect(playButton().disabled).toBe(false); + }); +}); + +describe('the transient states have no action, config aside', () => { + afterEach(cleanup); + + it.each([['initializing'], ['pausing']] as const)('disables the button in %s', (state) => { + renderToolbar(state, usableConfig); + expect(playButton().disabled).toBe(true); + }); +}); diff --git a/ui/components/ui/ConsumerSession/Toolbar/Toolbar.throttle.test.tsx b/ui/components/ui/ConsumerSession/Toolbar/Toolbar.throttle.test.tsx new file mode 100644 index 000000000..1b3743037 --- /dev/null +++ b/ui/components/ui/ConsumerSession/Toolbar/Toolbar.throttle.test.tsx @@ -0,0 +1,102 @@ +/** + * @jest-environment jsdom + * @jest-environment-options {"customExportConditions": ["node"]} + * + * The two browser-wide delivery controls in the toolbar: "msg/s limit" and "pause after". Both are + * DRAFT-COMMITTED localStorage values - keystrokes edit a draft, only blur/Enter commits - so what + * these tests pin is the commit boundary: what reaches storage, and what can never reach it. + */ +import React from 'react'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import * as Modals from '../../../app/contexts/Modals/Modals'; +import Toolbar from './Toolbar'; +import { ConsumerSessionConfig, SessionState } from '../types'; +import { localStorageKeys } from '../../../local-storage-keys'; + +const renderToolbar = (sessionState: SessionState = 'new') => { + render( + + + + + + ); +}; + +const rateInput = () => screen.getByTestId('cs-rate-limit') as HTMLInputElement; +const pauseInput = () => screen.getByTestId('cs-pause-after') as HTMLInputElement; +const stored = (key: string) => window.localStorage.getItem(key); + +afterEach(() => { + cleanup(); + window.localStorage.clear(); +}); + +describe('the delivery controls commit to localStorage', () => { + it('typing a number and leaving the field commits it', () => { + renderToolbar(); + fireEvent.change(rateInput(), { target: { value: '250' } }); + // Storage still holds the mounted default mid-edit - a half-typed "2" must never become a + // live 2 msg/s limit. (The hook persists its default on mount, so "unset" reads as '0'.) + expect(stored(localStorageKeys.consumerSessionRateLimit)).toBe('0'); + fireEvent.blur(rateInput()); + expect(stored(localStorageKeys.consumerSessionRateLimit)).toBe('250'); + }); + + it('Enter commits too', () => { + renderToolbar(); + fireEvent.change(pauseInput(), { target: { value: '100' } }); + fireEvent.keyDown(pauseInput(), { key: 'Enter' }); + fireEvent.blur(pauseInput()); + expect(stored(localStorageKeys.consumerSessionPauseAfterLoaded)).toBe('100'); + }); + + it('invalid text is REJECTED wholesale, never repaired into a different number', () => { + // Stripping used to turn a pasted "1.5" into 15 and "1e3" into 13 - a number the user never + // typed. Rejection keeps whatever was there before. + renderToolbar(); + fireEvent.change(rateInput(), { target: { value: '250' } }); + fireEvent.change(rateInput(), { target: { value: '1e5-2.7' } }); + expect(rateInput().value).toBe('250'); + fireEvent.change(rateInput(), { target: { value: '1.5' } }); + expect(rateInput().value).toBe('250'); + }); + + it('a committed value is capped at the operational ceiling', () => { + renderToolbar(); + fireEvent.change(rateInput(), { target: { value: '9999999999' } }); + fireEvent.blur(rateInput()); + expect(stored(localStorageKeys.consumerSessionRateLimit)).toBe('1000000000'); + }); + + it('clearing the field commits 0 - the explicit OFF', () => { + window.localStorage.setItem(localStorageKeys.consumerSessionRateLimit, '250'); + renderToolbar(); + expect(rateInput().value).toBe('250'); + fireEvent.change(rateInput(), { target: { value: '' } }); + fireEvent.blur(rateInput()); + expect(stored(localStorageKeys.consumerSessionRateLimit)).toBe('0'); + expect(rateInput().placeholder).toBe('off'); + }); + + it('initializes from what an earlier session stored', () => { + window.localStorage.setItem(localStorageKeys.consumerSessionPauseAfterLoaded, '42'); + renderToolbar(); + expect(pauseInput().value).toBe('42'); + }); +}); diff --git a/ui/components/ui/ConsumerSession/Toolbar/Toolbar.tsx b/ui/components/ui/ConsumerSession/Toolbar/Toolbar.tsx index e8ca6fb1c..7e819cd2a 100644 --- a/ui/components/ui/ConsumerSession/Toolbar/Toolbar.tsx +++ b/ui/components/ui/ConsumerSession/Toolbar/Toolbar.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import useLocalStorage from 'use-local-storage-state'; import s from './Toolbar.module.css' import pauseIcon from './icons/pause.svg'; import resumeIcon from './icons/resume.svg'; @@ -10,6 +11,70 @@ import SmallButton from '../../SmallButton/SmallButton'; import Input from '../../Input/Input'; import ExportMessagesButton from './ExportMessagesButton/ExportMessagesButton'; import { tooltipId } from '../../Tooltip/Tooltip'; +import { localStorageKeys } from '../../../local-storage-keys'; + +/** + * A small non-negative integer input committed on blur or Enter, with 0 meaning "off". + * + * DRAFT-COMMITTED, like the other numeric inputs in the session config: keystrokes edit a local + * draft, and only a valid commit reaches storage - so a half-typed value can never become the + * live setting, and an invalid one reverts to what was there before. + */ +export const DeliveryControlInput: React.FC<{ + testId: string; + caption: string; + title: string; + value: number; + onCommit: (n: number) => void; +}> = (props) => { + const [draft, setDraft] = React.useState(props.value > 0 ? String(props.value) : ''); + + // An external change (another tab via the storage event, or a reset) replaces the draft - the + // input shows the live value whenever the user is not mid-edit. + React.useEffect(() => { + setDraft(props.value > 0 ? String(props.value) : ''); + }, [props.value]); + + // Above this, "messages per second" and "messages to load" stop meaning anything - and the + // wire carries an int64, so the ceiling also keeps the value inside every representation. + const maxCommittable = 1_000_000_000; + + const commit = () => { + // Digits only ever enter the draft (invalid text is REJECTED wholesale below, not stripped + // into a different number), so the only invalid draft is the empty string - the explicit off. + const parsed = draft === '' ? 0 : Number.parseInt(draft, 10); + const next = Number.isFinite(parsed) && parsed > 0 ? Math.min(parsed, maxCommittable) : 0; + setDraft(next > 0 ? String(next) : ''); + props.onCommit(next); + }; + + return ( +
+ { + // REJECT invalid text, never repair it: stripping turned a pasted "1.5" into 15 and + // "1e3" into 13 - a different number than the user gave, silently. + const next = e.target.value; + if (/^[0-9]{0,10}$/.test(next)) { + setDraft(next); + } + }} + onBlur={commit} + onKeyDown={(e) => { + if (e.key === 'Enter') { + (e.target as HTMLInputElement).blur(); + } + }} + /> + {props.caption} +
+ ); +}; export type ToolbarProps = { sessionState: SessionState; @@ -30,8 +95,21 @@ export type ToolbarProps = { const Toolbar: React.FC = (props) => { const i18n = I18n.useContext(); + // Browser-wide delivery controls, NOT session config: both live in localStorage and ride the + // session's requests (the rate on each Resume, the auto-pause purely client-side), so neither + // can travel with a saved session into a library item. + const [rateLimit, setRateLimit] = useLocalStorage(localStorageKeys.consumerSessionRateLimit, { defaultValue: 0 }); + const [pauseAfter, setPauseAfter] = useLocalStorage(localStorageKeys.consumerSessionPauseAfterLoaded, { defaultValue: 0 }); + const playButtonState = (props.sessionState === 'new' || props.sessionState === 'paused') ? 'play' : 'pause'; + // No runtime config means the stored configuration could not be converted into one - there is + // nothing to send. Play used to stay enabled and start a session that could never leave + // "initializing", which reads as a hang rather than as the configuration error it is. This only + // matters for the transition that BUILDS a session from the config - Play from `new`; pause and + // resume are name-only RPCs that never touch it (see the disabled scoping below). + const isConfigUnusable = props.config === undefined; + let playButtonOnClick: () => void; switch (props.sessionState) { case 'new': playButtonOnClick = () => props.onSessionStateChange('initializing'); break; @@ -55,10 +133,25 @@ const Toolbar: React.FC = (props) => { svgIcon={playButtonState === 'play' ? resumeIcon : pauseIcon} onClick={playButtonOnClick} type={'primary'} - disabled={props.sessionState !== 'new' && props.sessionState !== 'paused' && props.sessionState !== 'running'} + disabled={(isConfigUnusable && props.sessionState === 'new') || (props.sessionState !== 'new' && props.sessionState !== 'paused' && props.sessionState !== 'running')} />
+ Takes effect when you press Play. Your browser remembers it - it is not saved with the session.'} + /> + Your browser remembers it - it is not saved with the session.'} + /> +
= (props) => { />
+
+ +
= (props) => {
)}
- - -
{i18n.formatLongNumber(props.messagesProcessed)} diff --git a/ui/components/ui/ConsumerSession/conversions/conversions.spec.ts b/ui/components/ui/ConsumerSession/conversions/conversions.spec.ts index 8ec326443..7fd065d1e 100644 --- a/ui/components/ui/ConsumerSession/conversions/conversions.spec.ts +++ b/ui/components/ui/ConsumerSession/conversions/conversions.spec.ts @@ -1,5 +1,5 @@ -import { partialMessageDescriptorToSerializable } from "./conversions"; -import { PartialMessageDescriptor } from "../types"; +import { partialMessageDescriptorToSerializable, startFromFromPb, startFromToPb } from "./conversions"; +import { ConsumerSessionStartFrom, PartialMessageDescriptor } from "../types"; describe("partialMessageDescriptorToSerializable", () => { const testData: { @@ -108,3 +108,72 @@ describe("partialMessageDescriptorToSerializable", () => { } ); }); + +describe("startFrom wire mapping", () => { + // startFromToPb wrote nthMessageAfterEarliest/nthMessageBeforeLatest while startFromFromPb had no + // case for them, so the mapping was one-way: saving a "skip first n" session worked and loading it + // back threw "Unknown StartFrom value case". Per-mode tests are what let that survive - a mode with + // no test simply has no failing test. So this is keyed by the union's own `type`, which makes + // TypeScript refuse to compile the file if a new mode is added without a fixture here. + const fixtures: Record = { + earliestMessage: { type: "earliestMessage" }, + latestMessage: { type: "latestMessage" }, + nthMessageAfterEarliest: { type: "nthMessageAfterEarliest", n: 42 }, + nthMessageBeforeLatest: { type: "nthMessageBeforeLatest", n: 7 }, + // The two approximate modes carry the SAME payload, so the round trip CANNOT tell a SYMMETRIC + // oneof swap - both directions consistently reaching for the wrong case - from a correct + // mapping: fromPb(toPb(x)) still equals x while a stored item would flip modes on load. The + // asymmetric pin below ("write their own oneof field") is what actually catches that, by + // checking the raw pb one direction only. + approximateDataPosition: { type: "approximateDataPosition", fraction: 0.6 }, + approximateTimePosition: { type: "approximateTimePosition", fraction: 0.6 }, + messageId: { type: "messageId", hexString: "a1 b2 c3" }, + // Whole seconds only: startFromToPb floors to epoch seconds, so sub-second input cannot survive. + dateTime: { type: "dateTime", dateTime: new Date(1_700_000_000_000) }, + relativeDateTime: { + type: "relativeDateTime", + relativeDateTime: { unit: "hour", value: 3, isRoundedToUnitStart: true }, + }, + }; + + it.each(Object.entries(fixtures))("round-trips %s through protobuf", (_type, startFrom) => { + expect(startFromFromPb(startFromToPb(startFrom))).toEqual(startFrom); + }); + + it("preserves n rather than defaulting it to zero", () => { + // n rides a wrapper message, so a branch that returned the right `type` with a dropped payload + // would still satisfy a test that only checked the discriminant. + const skip = startFromFromPb(startFromToPb({ type: "nthMessageAfterEarliest", n: 1234 })); + const latest = startFromFromPb(startFromToPb({ type: "nthMessageBeforeLatest", n: 5678 })); + expect(skip).toEqual({ type: "nthMessageAfterEarliest", n: 1234 }); + expect(latest).toEqual({ type: "nthMessageBeforeLatest", n: 5678 }); + }); + + it("round-trips n = 0, which is also the protobuf int default", () => { + expect(startFromFromPb(startFromToPb({ type: "nthMessageAfterEarliest", n: 0 }))) + .toEqual({ type: "nthMessageAfterEarliest", n: 0 }); + expect(startFromFromPb(startFromToPb({ type: "nthMessageBeforeLatest", n: 0 }))) + .toEqual({ type: "nthMessageBeforeLatest", n: 0 }); + }); + + // The round trip above is blind to a symmetric oneof swap between the two approximate modes: they + // carry the same one-double payload, so a startFromToPb that wrote the wrong case AND a + // startFromFromPb that read the same wrong case would round-trip cleanly while silently flipping + // the mode of every stored item on load. Pin the WRITE side against the raw protobuf, one + // direction, so such a swap in startFromToPb cannot hide behind a matching read. + describe("the approximate modes write their own oneof field", () => { + it("sets the data-position case for approximateDataPosition, and only it", () => { + const pbValue = startFromToPb({ type: "approximateDataPosition", fraction: 0.6 }); + expect(pbValue.hasStartFromApproximateDataPosition()).toBe(true); + expect(pbValue.hasStartFromApproximateTimePosition()).toBe(false); + expect(pbValue.getStartFromApproximateDataPosition()!.getFraction()).toBe(0.6); + }); + + it("sets the time-position case for approximateTimePosition, and only it", () => { + const pbValue = startFromToPb({ type: "approximateTimePosition", fraction: 0.6 }); + expect(pbValue.hasStartFromApproximateTimePosition()).toBe(true); + expect(pbValue.hasStartFromApproximateDataPosition()).toBe(false); + expect(pbValue.getStartFromApproximateTimePosition()!.getFraction()).toBe(0.6); + }); + }); +}); diff --git a/ui/components/ui/ConsumerSession/conversions/conversions.ts b/ui/components/ui/ConsumerSession/conversions/conversions.ts index 5c45a287c..5aa1285fb 100644 --- a/ui/components/ui/ConsumerSession/conversions/conversions.ts +++ b/ui/components/ui/ConsumerSession/conversions/conversions.ts @@ -1,6 +1,7 @@ import { Timestamp } from "google-protobuf/google/protobuf/timestamp_pb"; import * as pb from "../../../../grpc-web/tools/teal/pulsar/ui/api/v1/consumer_pb"; import { hexStringFromByteArray, hexStringToByteArray } from "../../../conversions/conversions"; +import { messageIdError } from "../SessionConfiguration/StartFromInput/message-id"; import { MessageDescriptor, PartialMessageDescriptor, @@ -288,6 +289,48 @@ export function startFromFromPb(startFrom: pb.ConsumerSessionStartFrom): Consume case pb.ConsumerSessionStartFrom.StartFromCase.START_FROM_LATEST_MESSAGE: return { type: 'latestMessage' }; + // These two were missing while startFromToPb wrote them, so the mapping was one-way: a saved + // "skip first n" / "latest n" config serialized fine and then threw "Unknown StartFrom value + // case" on the way back in. + case pb.ConsumerSessionStartFrom.StartFromCase.START_FROM_NTH_MESSAGE_AFTER_EARLIEST: { + const nthMessageAfterEarliestPb = startFrom.getStartFromNthMessageAfterEarliest(); + if (nthMessageAfterEarliestPb === undefined) { + throw new Error('NthMessageAfterEarliest should be defined.'); + } + + return { type: 'nthMessageAfterEarliest', n: nthMessageAfterEarliestPb.getN() }; + } + + case pb.ConsumerSessionStartFrom.StartFromCase.START_FROM_NTH_MESSAGE_BEFORE_LATEST: { + const nthMessageBeforeLatestPb = startFrom.getStartFromNthMessageBeforeLatest(); + if (nthMessageBeforeLatestPb === undefined) { + throw new Error('NthMessageBeforeLatest should be defined.'); + } + + return { type: 'nthMessageBeforeLatest', n: nthMessageBeforeLatestPb.getN() }; + } + + // The two approximate modes carry the SAME payload - one double - so a branch that reached for + // the other one's oneof case would still produce a valid-looking fraction; the only symptom + // would be a session positioned by the wrong rule. + case pb.ConsumerSessionStartFrom.StartFromCase.START_FROM_APPROXIMATE_DATA_POSITION: { + const approximateDataPositionPb = startFrom.getStartFromApproximateDataPosition(); + if (approximateDataPositionPb === undefined) { + throw new Error('ApproximateDataPosition should be defined.'); + } + + return { type: 'approximateDataPosition', fraction: approximateDataPositionPb.getFraction() }; + } + + case pb.ConsumerSessionStartFrom.StartFromCase.START_FROM_APPROXIMATE_TIME_POSITION: { + const approximateTimePositionPb = startFrom.getStartFromApproximateTimePosition(); + if (approximateTimePositionPb === undefined) { + throw new Error('ApproximateTimePosition should be defined.'); + } + + return { type: 'approximateTimePosition', fraction: approximateTimePositionPb.getFraction() }; + } + case pb.ConsumerSessionStartFrom.StartFromCase.START_FROM_MESSAGE_ID: { const byteArray = startFrom.getStartFromMessageId()?.getMessageId_asU8(); if (byteArray === undefined) { @@ -761,7 +804,7 @@ export function consumerSessionTargetToPb(v: ConsumerSessionTarget): pb.Consumer return targetPb; } -function startFromToPb(startFrom: ConsumerSessionStartFrom): pb.ConsumerSessionStartFrom { +export function startFromToPb(startFrom: ConsumerSessionStartFrom): pb.ConsumerSessionStartFrom { const startFromPb = new pb.ConsumerSessionStartFrom(); switch (startFrom.type) { @@ -777,9 +820,24 @@ function startFromToPb(startFrom: ConsumerSessionStartFrom): pb.ConsumerSessionS case 'nthMessageBeforeLatest': startFromPb.setStartFromNthMessageBeforeLatest(new pb.NthMessageBeforeLatest().setN(startFrom.n)); break; - case 'messageId': + case 'approximateDataPosition': + startFromPb.setStartFromApproximateDataPosition(new pb.ApproximateDataPosition().setFraction(startFrom.fraction)); + break; + case 'approximateTimePosition': + startFromPb.setStartFromApproximateTimePosition(new pb.ApproximateTimePosition().setFraction(startFrom.fraction)); + break; + case 'messageId': { + // The last point where a start position of zero bytes can still be stopped. The shared hex + // parser accepts blank text - an empty byte payload is a real thing - but an empty start + // position is not, and the server refuses it after a full create round trip. + const idError = messageIdError(startFrom.hexString); + if (idError !== undefined) { + throw new Error(idError); + } + startFromPb.setStartFromMessageId(new pb.MessageId().setMessageId(hexStringToByteArray(startFrom.hexString))); break; + } case 'dateTime': const epochSeconds = Math.floor(startFrom.dateTime.getTime() / 1000); const timestampPb = new Timestamp(); @@ -869,4 +927,3 @@ export function valueProjectionResultToPb(v: ValueProjectionResult): pb.ValuePro } return resultPb; } - diff --git a/ui/components/ui/ConsumerSession/keyboard.spec.ts b/ui/components/ui/ConsumerSession/keyboard.spec.ts new file mode 100644 index 000000000..0aab72468 --- /dev/null +++ b/ui/components/ui/ConsumerSession/keyboard.spec.ts @@ -0,0 +1,148 @@ +import { KeyboardEvent } from "react"; +import { VirtuosoHandle } from "react-virtuoso"; +import { handleKeyDown } from "./keyboard"; +import { MessageDescriptor } from "./types"; +import { genEmptyMessageDescriptor } from "./testing"; + +/** + * Regression: the message table is focusable (tabIndex={0}) so keyboard users can reach the + * ArrowUp/ArrowDown/j/k navigation - but handleKeyDown called event.preventDefault() for EVERY key + * before looking at which key it was, so Tab / Shift-Tab could never move focus back out of the + * table. Only the keys the handler actually acts on may be swallowed. + * + * CS-26 (e2e) covers the navigation keys; nothing covered the keys that must pass through. + */ +const messages: MessageDescriptor[] = [0, 1, 2].map((i) => + genEmptyMessageDescriptor({ numMessageProcessed: i, displayIndex: i }), +); + +type Pressed = { + preventDefaultCalls: number; + selectionUpdates: number[][]; + scrolledTo: { index: number }[]; +}; + +function press( + key: string, + opts?: { shiftKey?: boolean; selected?: number[]; messages?: MessageDescriptor[] }, +): Pressed { + // handleKeyDown debounces itself against a module-level timestamp (64ms) - step the fake clock + // well past it so every press is handled on its own merits. + jest.advanceTimersByTime(1000); + + const result: Pressed = { preventDefaultCalls: 0, selectionUpdates: [], scrolledTo: [] }; + const event = { + key, + shiftKey: opts?.shiftKey ?? false, + preventDefault: () => { + result.preventDefaultCalls += 1; + }, + } as unknown as KeyboardEvent; + + handleKeyDown({ + event, + messages: opts?.messages ?? messages, + selectedMessages: opts?.selected ?? [0], + setSelectedMessages: (selected) => result.selectionUpdates.push(selected), + virtuoso: { + scrollIntoView: (location: { index: number }) => result.scrolledTo.push(location), + } as unknown as VirtuosoHandle, + }); + + return result; +} + +// File-level, NOT per describe: `handleKeyDown`'s debounce timestamp is module state shared by every +// test here, and it only ever moves forwards. Re-installing fake timers between describes resets the +// clock to the real "now", which is BEHIND the timestamp the previous describe left behind - every +// press after that looks like it arrived within 64ms of the last one and is silently dropped. +beforeAll(() => jest.useFakeTimers()); +afterAll(() => jest.useRealTimers()); + +describe("handleKeyDown only swallows the keys it handles", () => { + it("lets Tab through so focus can leave the message table", () => { + const got = press("Tab"); + expect(got.preventDefaultCalls).toBe(0); + expect(got.selectionUpdates).toEqual([]); + }); + + it("lets Shift-Tab through so focus can leave the message table backwards", () => { + const got = press("Tab", { shiftKey: true }); + expect(got.preventDefaultCalls).toBe(0); + expect(got.selectionUpdates).toEqual([]); + }); + + it.each(["a", "Escape", "Enter", "PageDown", "/"])("lets the unhandled key %s through", (key) => { + expect(press(key).preventDefaultCalls).toBe(0); + }); + + it.each(["ArrowUp", "k", "ArrowDown", "j"])("still swallows the navigation key %s", (key) => { + expect(press(key).preventDefaultCalls).toBe(1); + }); + + it("still moves the selection with the navigation keys", () => { + expect(press("ArrowDown", { selected: [0] }).selectionUpdates).toEqual([[1]]); + expect(press("j", { selected: [1] }).selectionUpdates).toEqual([[2]]); + expect(press("ArrowDown", { selected: [2] }).selectionUpdates).toEqual([[0]]); // wraps + expect(press("ArrowUp", { selected: [0] }).selectionUpdates).toEqual([[2]]); // wraps + expect(press("k", { selected: [2] }).selectionUpdates).toEqual([[1]]); + }); +}); + +/** + * ...and how navigation STARTS. + * + * The table is focusable precisely so a keyboard user can reach the navigation above - but every + * key it acted on required a message to be selected ALREADY, and the only thing that selected one + * was a mouse click. So a keyboard-only user could focus the table, press every key on it and never + * select a row: the message details panel was unreachable without a pointer. Neither the tests above + * nor e2e CS-26 noticed, because both seed the selection with a click first. + */ +describe("handleKeyDown starts navigating from no selection", () => { + it.each(["ArrowDown", "j"])("selects the first message on %s", (key) => { + expect(press(key, { selected: [] }).selectionUpdates).toEqual([[0]]); + }); + + it.each(["ArrowUp", "k"])("selects the last message on %s", (key) => { + // Up from nothing means the end of the list, which is where a session that has been running + // leaves the messages worth looking at. + expect(press(key, { selected: [] }).selectionUpdates).toEqual([[2]]); + }); + + it("selects the first message on Enter", () => { + expect(press("Enter", { selected: [] }).selectionUpdates).toEqual([[0]]); + }); + + it("swallows the Enter it acts on, and only that one", () => { + // Swallow exactly what was acted on: an Enter pressed with a selection already in place is not + // this handler's, and taking it would deny it to anything else on the page. + expect(press("Enter", { selected: [] }).preventDefaultCalls).toBe(1); + expect(press("Enter", { selected: [0] }).preventDefaultCalls).toBe(0); + }); + + it("scrolls the row it just selected into view", () => { + // Selecting a row nobody can see is not selecting it: the table is virtualized, and the last + // row of a long session is far off screen. + expect(press("ArrowUp", { selected: [] }).scrolledTo).toEqual([{ index: 2, align: "end" }]); + expect(press("ArrowDown", { selected: [] }).scrolledTo).toEqual([{ index: 0, align: "start" }]); + }); + + it("starts from a selection of several messages too", () => { + // The same dead end: `!== 1` covered "none" and "many" alike. + expect(press("ArrowDown", { selected: [1, 2] }).selectionUpdates).toEqual([[0]]); + }); + + it("does nothing at all when the table has no messages", () => { + const got = press("ArrowDown", { selected: [], messages: [] }); + + expect(got.selectionUpdates).toEqual([]); + expect(got.scrolledTo).toEqual([]); + }); + + it("still lets Tab out of a table with nothing selected", () => { + const got = press("Tab", { selected: [] }); + + expect(got.preventDefaultCalls).toBe(0); + expect(got.selectionUpdates).toEqual([]); + }); +}); diff --git a/ui/components/ui/ConsumerSession/keyboard.ts b/ui/components/ui/ConsumerSession/keyboard.ts index 2a5274ac7..846bd5e5b 100644 --- a/ui/components/ui/ConsumerSession/keyboard.ts +++ b/ui/components/ui/ConsumerSession/keyboard.ts @@ -12,12 +12,21 @@ export type HandleKeyDownProps = { const arrowUpKeys = ['ArrowUp', 'k']; const arrowDownKeys = ['ArrowDown', 'j']; +/** Keys that start navigating from nothing, without moving an existing selection. */ +const enterKeys = ['Enter']; let lastKeyDownTime = new Date().getTime(); export function handleKeyDown(props: HandleKeyDownProps) { const { event, messages, selectedMessages, setSelectedMessages, virtuoso } = props; - event.preventDefault(); + + // Only swallow the keys this handler acts on. The table is focusable, so preventing the default + // for every key trapped focus inside it - Tab / Shift-Tab could no longer move focus out. + // Enter is deliberately NOT here: it is acted on only when there is no selection to move, and it + // is swallowed there rather than for every press. + if (arrowUpKeys.includes(event.key) || arrowDownKeys.includes(event.key)) { + event.preventDefault(); + } // Debounce frequent events for better performance const keyDownTime = new Date().getTime(); @@ -26,7 +35,32 @@ export function handleKeyDown(props: HandleKeyDownProps) { } lastKeyDownTime = keyDownTime; + const select = (index: number) => { + setSelectedMessages([messages[index].numMessageProcessed!]); + virtuoso.scrollIntoView({ index, align: index === 0 ? 'start' : 'end' }); + }; + + // Nothing (or a whole group) is selected: this press has to CREATE the first selection, or the + // table is unreachable without a pointer - the table is focusable precisely so that it is not. + // Down/Enter start at the top, Up starts at the bottom, which is where a session that has been + // running leaves the messages worth looking at. if (selectedMessages.length !== 1) { + if (messages.length === 0) { + return; + } + + if (arrowDownKeys.includes(event.key) || enterKeys.includes(event.key)) { + // Acted on, so swallowed - unlike an Enter pressed with a selection already in place, which + // this handler leaves entirely alone. + event.preventDefault(); + select(0); + return; + } + + if (arrowUpKeys.includes(event.key)) { + select(messages.length - 1); + } + return; } diff --git a/ui/components/ui/ConsumerSession/message-columns.ts b/ui/components/ui/ConsumerSession/message-columns.ts index 90bdb5833..31b2a16ab 100644 --- a/ui/components/ui/ConsumerSession/message-columns.ts +++ b/ui/components/ui/ConsumerSession/message-columns.ts @@ -38,3 +38,45 @@ export const messageColumnDefaultWidths: Record = { redeliveryCount: 130, sessionContextState: 380, }; + +/** The message columns a user may DRAG into any order - everything except the sticky index / + * publish-time pair. This is also the default order. */ +export const reorderableMessageColumns: MessageColumnKey[] = [ + 'key', + 'value', + 'sessionTargetIndex', + 'topic', + 'producerName', + 'schemaVersion', + 'size', + 'properties', + 'eventTime', + 'brokerPublishTime', + 'messageId', + 'sequenceId', + 'orderingKey', + 'redeliveryCount', + 'sessionContextState', +]; + +/** Everything the header needs to render one reorderable column, keyed like the row cells. */ +import type { SortKey } from './sort'; + +export const messageThMeta: Record = { + publishTime: { testId: 'cs-th-publishTime', title: 'Publish time', sortKey: 'publishTime', helpKey: 'publishTime' }, + key: { testId: 'cs-th-key', title: 'Key', sortKey: 'key', helpKey: 'key' }, + value: { testId: 'cs-th-value', title: 'Value', sortKey: 'value', helpKey: 'value' }, + sessionTargetIndex: { testId: 'cs-th-target', title: 'Target', sortKey: 'sessionTargetIndex', helpKey: 'sessionTargetIndex' }, + topic: { testId: 'cs-th-topic', title: 'Topic', sortKey: 'topic', helpKey: 'topic' }, + producerName: { testId: 'cs-th-producer', title: 'Producer', sortKey: 'producerName', helpKey: 'producerName' }, + schemaVersion: { testId: 'cs-th-schemaVersion', title: 'Schema version', sortKey: 'schemaVersion', helpKey: 'schemaVersion' }, + size: { testId: 'cs-th-size', title: 'Size', sortKey: 'size', helpKey: 'size' }, + properties: { testId: 'cs-th-properties', title: 'Properties', sortKey: 'properties', helpKey: 'propertiesMap' }, + eventTime: { testId: 'cs-th-eventTime', title: 'Event time', sortKey: 'eventTime', helpKey: 'eventTime' }, + brokerPublishTime: { testId: 'cs-th-brokerPublishTime', title: 'Broker pub. time', sortKey: 'brokerPublishTime', helpKey: 'brokerPublishTime' }, + messageId: { testId: 'cs-th-messageId', title: 'Message Id', sortKey: 'messageId', helpKey: 'messageId' }, + sequenceId: { testId: 'cs-th-sequenceId', title: 'Sequence Id', sortKey: 'sequenceId', helpKey: 'sequenceId' }, + orderingKey: { testId: 'cs-th-orderingKey', title: 'Ordering key', sortKey: 'orderingKey', helpKey: 'orderingKey' }, + redeliveryCount: { testId: 'cs-th-redeliveryCount', title: 'Redelivery count', sortKey: 'redeliveryCount', helpKey: 'redeliveryCount' }, + sessionContextState: { testId: 'cs-th-sessionContextState', title: 'Session Context State', sortKey: 'sessionContextStateJson', helpKey: 'sessionContextStateJson' }, +}; diff --git a/ui/components/ui/ConsumerSession/sort.test.ts b/ui/components/ui/ConsumerSession/sort.test.ts new file mode 100644 index 000000000..b98fa8b84 --- /dev/null +++ b/ui/components/ui/ConsumerSession/sort.test.ts @@ -0,0 +1,60 @@ +/** + * BUG-3 regression: sortMessages must not reorder the array it is given. + * + * ConsumerSession passes the `messages` React state array straight into sortMessages whenever the + * session is paused and the search box is empty, so an in-place `sort()`/`reverse()` rewrites state + * into VISUAL order. The retention logic (`.slice(-numDisplayItems)`) then drops messages by the + * last visual sort instead of by arrival order. + */ +import { genEmptyMessageDescriptor } from './testing'; +import { sortMessages } from './sort'; + +// Arrival order 3, 1, 2 - deliberately unsorted for every key under test. +const arrivalOrder = [3, 1, 2]; +const makeMessages = () => + arrivalOrder.map((n) => + genEmptyMessageDescriptor({ + displayIndex: n, + key: `k-${n}`, + value: `v-${n}`, + publishTime: n, + numMessageProcessed: n, + }) + ); + +describe('BUG-3: sortMessages is immutable', () => { + it('sorts by index without touching the input array', () => { + const input = makeMessages(); + const sorted = sortMessages(input, { key: 'index', direction: 'desc' }); + + expect(sorted.map((m) => m.displayIndex)).toEqual([3, 2, 1]); + expect(input.map((m) => m.displayIndex)).toEqual(arrivalOrder); + expect(sorted).not.toBe(input); + }); + + it('sorts by key without touching the input array', () => { + const input = makeMessages(); + const sorted = sortMessages(input, { key: 'key', direction: 'asc' }); + + expect(sorted.map((m) => m.key)).toEqual(['k-1', 'k-2', 'k-3']); + expect(input.map((m) => m.displayIndex)).toEqual(arrivalOrder); + }); + + it('sorts by value without touching the input array', () => { + const input = makeMessages(); + const sorted = sortMessages(input, { key: 'value', direction: 'desc' }); + + expect(sorted.map((m) => m.value)).toEqual(['v-3', 'v-2', 'v-1']); + expect(input.map((m) => m.displayIndex)).toEqual(arrivalOrder); + }); + + it('keeps arrival order intact across repeated sorts of the same array', () => { + // The paused session re-sorts the SAME state array on every sort click / re-render. + const input = makeMessages(); + sortMessages(input, { key: 'index', direction: 'desc' }); + sortMessages(input, { key: 'key', direction: 'asc' }); + sortMessages(input, { key: 'publishTime', direction: 'desc' }); + + expect(input.map((m) => m.displayIndex)).toEqual(arrivalOrder); + }); +}); diff --git a/ui/components/ui/ConsumerSession/sort.ts b/ui/components/ui/ConsumerSession/sort.ts index 62d4be2bb..5bb7ae37d 100644 --- a/ui/components/ui/ConsumerSession/sort.ts +++ b/ui/components/ui/ConsumerSession/sort.ts @@ -55,7 +55,9 @@ export const sortMessages = ( undefs: MessageDescriptor[], sortFn: SortFn ): MessageDescriptor[] { - let result = defs.sort(sortFn); + // Copy first: `defs` is often the caller's array (the ConsumerSession `messages` state when the + // session is paused), and an in-place sort would rewrite it into visual order. + let result = defs.slice().sort(sortFn); result = sort.direction === "asc" ? result : result.reverse(); return result.concat(undefs); } diff --git a/ui/components/ui/ConsumerSession/types.ts b/ui/components/ui/ConsumerSession/types.ts index e3049284d..f3ebb2134 100644 --- a/ui/components/ui/ConsumerSession/types.ts +++ b/ui/components/ui/ConsumerSession/types.ts @@ -77,6 +77,18 @@ export type ConsumerSessionStartFrom = { type: "latestMessage" } | { type: "nthMessageAfterEarliest", n: number } | { type: "nthMessageBeforeLatest", n: number } | + // "About % through the data": approximately this far through the messages a topic still holds. + // 0 = earliest retained, 1 = past the latest. Resolved by entry ordinal, which Pulsar can address + // instantly at any topic size - and which is why the position is only approximate, since one entry + // holds a whole batch of messages. PER PHYSICAL TOPIC. + { type: "approximateDataPosition", fraction: number } | + // "About % through the time range": approximately this far through the time a topic still covers. + // 0 = earliest retained, 1 = the last message. The range runs from the earliest first-message + // publish time to the latest last-message publish time across a topic's partitions, so this one is + // PER LOGICAL TOPIC. The two are separate modes because they answer different questions: on a topic + // where almost everything arrived in the last hour of a month's retention, half the MESSAGES are + // inside that last hour while half the TIME is fifteen days back. + { type: "approximateTimePosition", fraction: number } | { type: "messageId"; hexString: string } | { type: "dateTime"; dateTime: Date } | { diff --git a/ui/components/ui/Input/Input.module.css b/ui/components/ui/Input/Input.module.css index c92382fea..36a9a9f65 100644 --- a/ui/components/ui/Input/Input.module.css +++ b/ui/components/ui/Input/Input.module.css @@ -145,6 +145,12 @@ opacity: 0.5; } +/* Shown, because the setting is part of what is being read - but not offered as a control. */ +.AddonReadOnly, +.AddonReadOnly:hover { + cursor: default; +} + .AddonLabel { width: 16rem; height: 20rem; diff --git a/ui/components/ui/Input/Input.test.tsx b/ui/components/ui/Input/Input.test.tsx new file mode 100644 index 000000000..069a7bb6f --- /dev/null +++ b/ui/components/ui/Input/Input.test.tsx @@ -0,0 +1,163 @@ +/** + * @jest-environment jsdom + * + * The shared text/number field, and specifically WHO WINS when two different things have an opinion + * about whether it is editable. + * + * `isReadOnly` is how the whole app renders a referenced (library-owned) configuration: the value on + * screen belongs to a stored item and must not be edited in place. Callers independently pass + * `inputProps` to forward native attributes such as `min`/`max`/`step`, and a caller that also + * mentions `disabled` there - even as `undefined`, which is what `inputProps={{ disabled: + * props.disabled }}` produces on a component with no `disabled` prop - must not be able to hand the + * field back to the user. + */ +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import Input from './Input'; + +const renderInput = (props: Record) => { + const onChange = jest.fn(); + render()} />); + return { onChange, input: () => screen.getByTestId('the-input') as HTMLInputElement }; +}; + +describe('a read-only Input', () => { + it('stays disabled when a caller passes inputProps without a disabled of its own', () => { + // `{ disabled: props.disabled, min: 0 }` with no `disabled` prop set - the exact shape the + // start-from number fields pass. + const { input } = renderInput({ isReadOnly: true, inputProps: { disabled: undefined, min: 0 } }); + + expect(input().disabled).toBe(true); + }); + + it('stays disabled when a caller explicitly passes disabled: false', () => { + const { input } = renderInput({ isReadOnly: true, inputProps: { disabled: false } }); + + expect(input().disabled).toBe(true); + }); + + it('refuses real typing, not merely styling', async () => { + // userEvent, not fireEvent: fireEvent dispatches the change event straight at the element and + // would "type" into a disabled field that no browser would accept. + const { onChange, input } = renderInput({ isReadOnly: true, inputProps: { disabled: undefined } }); + + await userEvent.type(input(), '9'); + + expect(onChange).not.toHaveBeenCalled(); + }); + + it('still honours a caller that disables the field on its own', () => { + const { input } = renderInput({ inputProps: { disabled: true } }); + + expect(input().disabled).toBe(true); + }); + + it('leaves an ordinary field editable', () => { + const { onChange, input } = renderInput({ inputProps: { disabled: undefined, min: 0 } }); + + expect(input().disabled).toBe(false); + fireEvent.change(input(), { target: { value: '9' } }); + expect(onChange).toHaveBeenCalledWith('9'); + }); + + it('still forwards the other native attributes it was given', () => { + // The spread has to keep working - `min`/`max`/`step` are what the number fields rely on. + const { input } = renderInput({ inputProps: { min: 0, max: 100, step: 'any' } }); + + expect(input().getAttribute('min')).toBe('0'); + expect(input().getAttribute('max')).toBe('100'); + expect(input().getAttribute('step')).toBe('any'); + }); +}); + +/** + * `disabled` on the native `` stops TYPING and nothing else. This component ships two other + * controls of its own, and both mutate: + * + * - the addons, which are how the regex `m`/`i` flags and the match-case switch are toggled - they + * change the value the filter is evaluated with, not merely how it is displayed; + * - the clear button, which sets the value to `''`. + * + * Neither is a ` + +
+
+ ); +}; + +export default ReplayCaughtUpBanner; diff --git a/ui/components/ui/ConsumerSession/SessionConfiguration/SessionConfiguration.test.tsx b/ui/components/ui/ConsumerSession/SessionConfiguration/SessionConfiguration.test.tsx index cdebbb6df..2a3dd019f 100644 --- a/ui/components/ui/ConsumerSession/SessionConfiguration/SessionConfiguration.test.tsx +++ b/ui/components/ui/ConsumerSession/SessionConfiguration/SessionConfiguration.test.tsx @@ -27,6 +27,7 @@ import SessionConfiguration from './SessionConfiguration'; import { getDefaultManagedItem } from '../../LibraryBrowser/default-library-items'; import { consumerSessionConfigFromValOrRef } from '../../LibraryBrowser/model/resolved-items-conversions'; import { defaultNumDisplayItems } from './display-items'; +import { decodeConsumerSessionConfig, describeProblem } from './decode-session-config'; const contextForTopic = (topicPersistency: 'persistent' | 'non-persistent') => ({ pulsarResource: { @@ -118,9 +119,270 @@ describe('BUG-4: a malformed persisted consumer session config', () => { }); /** - * The start-from selector reacts to what the session's TARGETS point at, but the targets live here - - * so the editor has to hand that down. Nothing in the selector's own tests can prove it was passed. + * P2.5 - the shape check has to be RECURSIVE. + * + * `/consumer-session?id=` accepts any persisted library item, and a persisted item is JSON on disk: + * written by an older build, hand-edited, truncated, or saved by a build with a different model. A + * check that asks only "is targets an array" and "are the four chains objects" lets a whole family + * of corrupt items through the front door, where they crash a few frames deeper (`topic.val.metadata.id`) + * or sit in `useManagedItemValue` forever - neither a value to render nor a reference to resolve. + * + * So this is a TABLE, walked at every level of the document: the item, its spec, each val-or-ref + * wrapper, each target, and the chains inside a target. Each row asserts the same two things - the + * decoder REFUSES and names the offending path, and the editor degrades through the one existing + * `cs-invalid-config` path instead of throwing. */ +describe('malformed saved items, level by level', () => { + /** The default item is Date-free, so a JSON round trip is a faithful deep clone here. */ + const clone = (value: T): T => JSON.parse(JSON.stringify(value)) as T; + + const base = () => clone(getDefaultManagedItem('consumer-session-config', libraryContext)) as any; + + const corrupt = (mutate: (item: any) => void) => { + const item = base(); + mutate(item); + return item; + }; + + /** A second target, so a row can corrupt one target and leave a sound one beside it. */ + const withTwoTargets = (mutate: (item: any) => void) => { + const item = base(); + const second = clone(item.spec.targets[0]); + second.val.metadata.id = 'second-target'; + item.spec.targets.push(second); + mutate(item); + return item; + }; + + const targetSpec = (item: any, index: number) => item.spec.targets[index].val.spec; + + const cases: [string, any, string][] = [ + // The item itself. + [ + 'a foreign library item type', + clone(getDefaultManagedItem('markdown-document', libraryContext)), + 'metadata.type' + ], + ['no spec at all', corrupt((item) => delete item.spec), 'spec'], + ['a spec that is an array, not an object', corrupt((item) => (item.spec = [])), 'spec'], + + // The config spec's own fields. + ['targets missing', corrupt((item) => delete item.spec.targets), 'spec.targets'], + ['targets not an array', corrupt((item) => (item.spec.targets = {})), 'spec.targets'], + ['targets EMPTY - a session with nothing to consume', corrupt((item) => (item.spec.targets = [])), 'spec.targets'], + ['startFrom missing', corrupt((item) => delete item.spec.startFrom), 'spec.startFrom'], + ['numDisplayItems stored as text', corrupt((item) => (item.spec.numDisplayItems = '500')), 'spec.numDisplayItems'], + [ + 'a delivery order this build does not have', + corrupt((item) => (item.spec.messageDeliveryOrder = 'whatever-comes-next')), + 'spec.messageDeliveryOrder' + ], + + // The val-or-ref wrappers. + ['a wrapper with no discriminant', corrupt((item) => (item.spec.startFrom = {})), 'spec.startFrom.type'], + ['a value wrapper with no val', corrupt((item) => (item.spec.startFrom = { type: 'value' })), 'spec.startFrom.val'], + [ + 'a reference wrapper with an empty ref', + corrupt((item) => (item.spec.startFrom = { type: 'reference', ref: '' })), + 'spec.startFrom.ref' + ], + ['a NULL nested wrapper', corrupt((item) => (item.spec.coloringRuleChain = null)), 'spec.coloringRuleChain'], + [ + 'a val-PLUS-reference hybrid', + corrupt((item) => (item.spec.valueProjectionList = { ...item.spec.valueProjectionList, ref: 'some-other-item' })), + 'spec.valueProjectionList' + ], + [ + 'a chain whose item is of the wrong managed type', + corrupt((item) => (item.spec.messageFilterChain.val.metadata.type = 'coloring-rule-chain')), + 'spec.messageFilterChain.val.metadata.type' + ], + [ + 'a chain spec missing its own list', + corrupt((item) => delete item.spec.messageFilterChain.val.spec.filters), + 'spec.messageFilterChain.val.spec.filters' + ], + + // Into the targets. + ['a NULL target', withTwoTargets((item) => (item.spec.targets[1] = null)), 'spec.targets[1]'], + ['an empty target object', withTwoTargets((item) => (item.spec.targets[1] = {})), 'spec.targets[1].type'], + [ + 'a target wrapper whose val has no metadata', + withTwoTargets((item) => delete item.spec.targets[1].val.metadata), + 'spec.targets[1].val.metadata' + ], + [ + 'a target holding some other managed item', + withTwoTargets((item) => (item.spec.targets[1].val.metadata.type = 'message-filter')), + 'spec.targets[1].val.metadata.type' + ], + [ + 'a target flag of the wrong type', + withTwoTargets((item) => (targetSpec(item, 1).isEnabled = 'yes')), + 'spec.targets[1].val.spec.isEnabled' + ], + + // Inside one target: consumption mode, deserializer, topic selector, and the three chains. + [ + 'a target with no consumption mode', + withTwoTargets((item) => delete targetSpec(item, 1).consumptionMode), + 'spec.targets[1].val.spec.consumptionMode' + ], + [ + 'a consumption mode this build cannot run', + withTwoTargets((item) => (targetSpec(item, 1).consumptionMode.mode = { type: 'read-backwards' })), + 'spec.targets[1].val.spec.consumptionMode.mode.type' + ], + [ + 'a deserializer this build cannot run', + withTwoTargets((item) => (targetSpec(item, 1).messageValueDeserializer.val.spec.deserializer.deserializer = { type: 'avro' })), + 'spec.targets[1].val.spec.messageValueDeserializer.val.spec.deserializer.deserializer.type' + ], + [ + 'a topic selector that is a bare object', + withTwoTargets((item) => (targetSpec(item, 1).topicSelector = {})), + 'spec.targets[1].val.spec.topicSelector.type' + ], + [ + 'a topic selector kind this build does not have', + withTwoTargets((item) => (targetSpec(item, 1).topicSelector.val.spec.topicSelector = { type: 'all-topics' })), + 'spec.targets[1].val.spec.topicSelector.val.spec.topicSelector.type' + ], + [ + 'a topic list holding something that is not a topic name', + withTwoTargets( + (item) => + (targetSpec(item, 1).topicSelector.val.spec.topicSelector = { + type: 'multi-topic-selector', + topicFqns: ['persistent://t/n/a', 7] + }) + ), + 'spec.targets[1].val.spec.topicSelector.val.spec.topicSelector.topicFqns[1]' + ], + [ + "a target's coloring rule list holding a null", + withTwoTargets((item) => (targetSpec(item, 1).coloringRuleChain.val.spec.coloringRules = [null])), + 'spec.targets[1].val.spec.coloringRuleChain.val.spec.coloringRules[0]' + ], + [ + "a target's filter chain mode this build does not have", + withTwoTargets((item) => (targetSpec(item, 1).messageFilterChain.val.spec.mode = 'most')), + 'spec.targets[1].val.spec.messageFilterChain.val.spec.mode' + ], + [ + "a target's value projection missing its short name", + withTwoTargets((item) => { + const projection = clone(getDefaultManagedItem('value-projection', libraryContext)) as any; + delete projection.spec.shortName; + targetSpec(item, 1).valueProjectionList.val.spec.projections = [{ type: 'value', val: projection }]; + }), + 'spec.targets[1].val.spec.valueProjectionList.val.spec.projections[0].val.spec.shortName' + ] + ]; + + it.each(cases)('refuses %s and degrades instead of crashing', (_name, item, path) => { + // The decoder refuses it, and names the level it refused at. + const decoded = decodeConsumerSessionConfig(item); + expect(decoded.ok).toBe(false); + expect((decoded as any).problem.path).toBe(path); + + // And the editor degrades through the SAME `cs-invalid-config` path a malformed config + // already used - no crash, no endless spinner, no third mechanism. + expect(() => renderConfig(item)).not.toThrow(); + + const shown = screen.getByTestId('cs-invalid-config'); + expect(shown.textContent).toContain('not a valid Consumer Session configuration'); + // The path is the point: "something is wrong somewhere" is not actionable on a config with + // several targets and a chain inside each of them. + expect(shown.textContent).toContain(path); + }); + + /** + * The other half of a decoder: what it must NOT refuse. A shape check that rejects legitimate + * saved items is a worse bug than the one it fixes, because it locks people out of their own + * library instead of showing them one broken screen. + */ + const accepted: [string, () => any][] = [ + ['the item a new session starts from', () => base()], + [ + 'a saved config with several targets', + () => + withTwoTargets(() => { + /* two sound targets, nothing corrupted */ + }) + ], + [ + 'chains stored as library REFERENCES, not resolved yet', + () => + corrupt((item) => { + item.spec.messageFilterChain = { type: 'reference', ref: 'shared-filter-chain' }; + item.spec.coloringRuleChain = { type: 'reference', ref: 'shared-coloring' }; + item.spec.targets[0] = { type: 'reference', ref: 'shared-target' }; + }) + ], + [ + 'a reference carrying an unsaved in-browser edit', + () => + corrupt((item) => { + item.spec.valueProjectionList = { type: 'reference', ref: 'shared-projections', val: item.spec.valueProjectionList.val }; + }) + ], + [ + 'a start position that names a nested library item', + () => + corrupt((item) => { + item.spec.startFrom.val.spec.startFrom = { + type: 'relativeDateTime', + relativeDateTime: { + type: 'value', + val: { + metadata: { id: 'rel-1', name: '', descriptionMarkdown: '', type: 'relative-date-time' }, + spec: { value: 15, unit: 'minute', isRoundedToUnitStart: false } + } + } + }; + }) + ], + [ + 'an older spec with no delivery order and no display limit', + () => + corrupt((item) => { + delete item.spec.messageDeliveryOrder; + delete item.spec.numDisplayItems; + }) + ], + [ + 'a regex target with a populated topic list beside it', + () => + withTwoTargets((item) => { + targetSpec(item, 0).topicSelector.val.spec.topicSelector = { + type: 'multi-topic-selector', + topicFqns: ['persistent://t/n/a', 'persistent://t/n/b'] + }; + targetSpec(item, 1).topicSelector.val.spec.topicSelector = { + type: 'namespaced-regex-topic-selector', + namespaceFqn: 't/n', + pattern: '.*', + regexSubscriptionMode: 'persistent-only' + }; + }) + ] + ]; + + it.each(accepted)('accepts %s', (_name, build) => { + const decoded = decodeConsumerSessionConfig(build()); + + expect(decoded.ok ? undefined : describeProblem((decoded as any).problem)).toBeUndefined(); + }); + + it('will not let the editor delete its way into the shape it just refused', () => { + // The decoder refuses an empty target list, so the editor must not be able to produce one - + // otherwise removing the last target replaces the editor with its own error page, for good. + renderConfig(base()); + + expect((screen.getByTestId('cs-target-remove') as HTMLButtonElement).disabled).toBe(true); + }); +}); /** * "Limit num. display messages" is the only thing standing between a long session and a tab that * runs out of memory: the session keeps `messages.slice(-limit)` and nothing else bounds it. The @@ -280,3 +542,221 @@ describe('start-from is told what the targets retain', () => { expect(screen.queryByTestId('cs-start-from-non-persistent-note')).toBeNull(); }); }); + +// Owner decision (2026-08-09, direct instruction): the default is Best effort - supersedes the +// review-findings P1.1 note that said Guaranteed. +describe('delivery order', () => { + const pbModule = require('../../../../grpc-web/tools/teal/pulsar/ui/api/v1/consumer_pb'); + const { consumerSessionConfigToPb, messageDeliveryOrderToPb } = require('../conversions/conversions'); + + const renderControlledOrder = (initial = getDefaultManagedItem('consumer-session-config', libraryContext)) => { + const Controlled = () => { + const [value, setValue] = React.useState(() => ({ type: 'value', val: initial })); + return ( + + ); + }; + + render( + + + + ); + }; + + const orderSelect = () => screen.getByTestId('cs-delivery-order') as HTMLSelectElement; + const orderHelp = () => document.getElementById('cs-delivery-order-help')?.textContent ?? ''; + const timeSelect = () => screen.getByTestId('cs-delivery-order-key') as HTMLSelectElement; + const timeHelp = () => document.getElementById('cs-delivery-order-key-help')?.textContent ?? ''; + + it('a new spec resolves to the Best effort default and serializes it explicitly', () => { + // The product default, by owner decision: a new session merges with bounded lateness and + // never stalls on a silent stream. Guaranteed remains an explicit choice with its stall + // disclosure and one-click switch untouched. + const item = getDefaultManagedItem('consumer-session-config', libraryContext); + const resolved = consumerSessionConfigFromValOrRef({ type: 'value', val: item } as never, undefined); + + expect(resolved.messageDeliveryOrder).toBe('best-effort'); + expect(consumerSessionConfigToPb(resolved).getMessageDeliveryOrder()) + .toBe(pbModule.MessageDeliveryOrder.MESSAGE_DELIVERY_ORDER_BEST_EFFORT_BY_PUBLISH_TIME); + }); + + it('an older spec without the field resolves and serializes as Best effort, like every other absence', () => { + // Pre-branch saved sessions carry no field. They inherit the same product default as a new + // one - a session that never named an order runs the default, not something it chose. + const item = getDefaultManagedItem('consumer-session-config', libraryContext); + const legacy = { ...item, spec: { ...item.spec, messageDeliveryOrder: undefined } }; + const resolved = consumerSessionConfigFromValOrRef({ type: 'value', val: legacy } as never, undefined); + + expect(resolved.messageDeliveryOrder).toBe('best-effort'); + expect(consumerSessionConfigToPb(resolved).getMessageDeliveryOrder()) + .toBe(pbModule.MessageDeliveryOrder.MESSAGE_DELIVERY_ORDER_BEST_EFFORT_BY_PUBLISH_TIME); + }); + + it('an absent value at the wire boundary itself serializes as Best effort, the default', () => { + // The one mapping Play and the live switch share. Its catch-all branch is what an + // unresolved absent value falls into, so the branch has to name the default - and explicit + // Guaranteed has to stay untouched next to it. + expect(messageDeliveryOrderToPb(undefined)) + .toBe(pbModule.MessageDeliveryOrder.MESSAGE_DELIVERY_ORDER_BEST_EFFORT_BY_PUBLISH_TIME); + expect(messageDeliveryOrderToPb('guaranteed')) + .toBe(pbModule.MessageDeliveryOrder.MESSAGE_DELIVERY_ORDER_GUARANTEED); + }); + + it('an explicit Guaranteed choice survives spec -> resolved config -> protobuf, undisturbed by the default', () => { + const item = getDefaultManagedItem('consumer-session-config', libraryContext); + const merged = { ...item, spec: { ...item.spec, messageDeliveryOrder: 'guaranteed' } }; + const resolved = consumerSessionConfigFromValOrRef({ type: 'value', val: merged } as never, undefined); + + expect(resolved.messageDeliveryOrder).toBe('guaranteed'); + expect(consumerSessionConfigToPb(resolved).getMessageDeliveryOrder()) + .toBe(pbModule.MessageDeliveryOrder.MESSAGE_DELIVERY_ORDER_GUARANTEED); + }); + + it('shows all session settings immediately with Best effort order selected by default', () => { + renderConfig(getDefaultManagedItem('consumer-session-config', libraryContext)); + + expect(screen.queryByTestId('cs-advanced-toggle')).toBeNull(); + expect(orderSelect().value).toBe('best-effort'); + expect(screen.getByText('Limit num. display messages')).toBeTruthy(); + expect(screen.getByTestId('cs-session-filters')).toBeTruthy(); + expect(screen.getByTestId('cs-session-projections')).toBeTruthy(); + expect(screen.getByTestId('cs-session-coloring')).toBeTruthy(); + }); + + it('orders and names the modes as Guaranteed, Best effort, Fastest', () => { + renderConfig(getDefaultManagedItem('consumer-session-config', libraryContext)); + + expect(Array.from(orderSelect().options).map((option) => option.textContent)).toEqual([ + 'Guaranteed', + 'Best effort', + 'Fastest', + ]); + }); + + it('shows distinct help for every mode and hides Order by only for Fastest', () => { + renderControlledOrder(); + + // The default is Best effort, so the walk starts there. + expect(orderHelp()).toContain('within ~0.75 s'); + expect(orderHelp()).toContain('none are dropped'); + expect(timeSelect()).toBeTruthy(); + + // Re-aimed with the 2026-08-09 replay redesign: Guaranteed's help describes the replay + // contract (deliver recorded history exactly, pause at the boundary, Resume extends), not the + // old hold-forever wait. + fireEvent.change(orderSelect(), { target: { value: 'guaranteed' } }); + expect(orderHelp()).toContain('Replays everything recorded up to Play'); + expect(orderHelp()).toContain('pauses when caught up'); + expect(orderHelp()).toContain('Resume replays what was recorded since'); + expect(orderHelp()).not.toContain('Waits indefinitely'); + expect(timeSelect()).toBeTruthy(); + + fireEvent.change(orderSelect(), { target: { value: 'as-received' } }); + expect(orderHelp()).toContain('independently'); + expect(orderHelp()).toContain('no reordering delay'); + expect(screen.queryByTestId('cs-delivery-order-key')).toBeNull(); + }); + + it('uses Pulsar timestamp names and explains their fallbacks', () => { + renderControlledOrder(); + + expect(Array.from(timeSelect().options).map((option) => option.textContent)).toEqual([ + 'Publish time', + 'Broker publish time', + 'Event time', + ]); + expect(timeHelp()).toBe('Added automatically by the producer; always present.'); + + fireEvent.change(timeSelect(), { target: { value: 'broker-publish-time' } }); + expect(timeHelp()).toContain('when the message reaches the broker'); + expect(timeHelp()).toContain('broker timestamp entry metadata'); + + fireEvent.change(timeSelect(), { target: { value: 'event-time' } }); + expect(timeHelp()).toContain('set by the application'); + expect(timeHelp()).toContain('use publish time'); + }); + + it('shows a spec without the field as the Best effort default', () => { + const item = getDefaultManagedItem('consumer-session-config', libraryContext); + renderConfig({ ...item, spec: { ...item.spec, messageDeliveryOrder: undefined } }); + + expect(orderSelect().value).toBe('best-effort'); + expect(orderHelp()).toContain('within ~0.75 s'); + expect(screen.getByTestId('cs-delivery-order-key')).toBeTruthy(); + }); + + it('shows a saved Guaranteed choice as itself, not as the Best effort default', () => { + const item = getDefaultManagedItem('consumer-session-config', libraryContext); + const merged = { ...item, spec: { ...item.spec, messageDeliveryOrder: 'guaranteed' } }; + renderConfig(merged); + + const select = screen.getByTestId('cs-delivery-order') as HTMLSelectElement; + expect(select.value).toBe('guaranteed'); + }); +}); + +/** + * Latest x Guaranteed, approached from the ORDER side: the start-from already says Latest and the + * user picks Guaranteed in the delivery-order select. The combination gets the same one-line note + * the start-from side shows - and neither field is rewritten from under the user (the M4 lesson): + * the order becomes what was just chosen, the start-from stays Latest, and Play on the combo + * yields the server's instant caught-up answer. + */ +describe('choosing Guaranteed while the start-from is Latest', () => { + const renderControlledOrder = (initial: unknown) => { + const Controlled = () => { + const [value, setValue] = React.useState(() => ({ type: 'value', val: initial })); + return ( + + ); + }; + + render( + + + + ); + }; + + const withLatestStartFrom = () => { + const item = getDefaultManagedItem('consumer-session-config', libraryContext) as { + spec: { startFrom: { val: { spec: { startFrom: unknown } } } }; + }; + item.spec.startFrom.val.spec.startFrom = { type: 'latestMessage' }; + return item; + }; + + it('shows the note and rewrites neither field', () => { + renderControlledOrder(withLatestStartFrom()); + expect(screen.queryByTestId('cs-start-from-latest-guaranteed-note')).toBeNull(); + + fireEvent.change(screen.getByTestId('cs-delivery-order'), { target: { value: 'guaranteed' } }); + + // The order is what the user just chose; the start-from is untouched; the note explains the + // combination instead of any silent rewrite. + expect((screen.getByTestId('cs-delivery-order') as HTMLSelectElement).value).toBe('guaranteed'); + expect((screen.getByTestId('cs-start-from') as HTMLSelectElement).value).toBe('latestMessage'); + expect(screen.getByTestId('cs-start-from-latest-guaranteed-note')).toBeTruthy(); + }); + + it('retires the note when the order goes back to Best effort', () => { + renderControlledOrder(withLatestStartFrom()); + + fireEvent.change(screen.getByTestId('cs-delivery-order'), { target: { value: 'guaranteed' } }); + expect(screen.getByTestId('cs-start-from-latest-guaranteed-note')).toBeTruthy(); + + fireEvent.change(screen.getByTestId('cs-delivery-order'), { target: { value: 'best-effort' } }); + + expect(screen.queryByTestId('cs-start-from-latest-guaranteed-note')).toBeNull(); + expect((screen.getByTestId('cs-start-from') as HTMLSelectElement).value).toBe('latestMessage'); + }); +}); diff --git a/ui/components/ui/ConsumerSession/SessionConfiguration/SessionConfiguration.tsx b/ui/components/ui/ConsumerSession/SessionConfiguration/SessionConfiguration.tsx index c1a75701f..3e1cd0a1a 100644 --- a/ui/components/ui/ConsumerSession/SessionConfiguration/SessionConfiguration.tsx +++ b/ui/components/ui/ConsumerSession/SessionConfiguration/SessionConfiguration.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect } from 'react'; import FilterChainEditor from './FilterChainEditor/FilterChainEditor'; import s from './SessionConfiguration.module.css' @@ -24,6 +24,9 @@ import moveRightIcon from './icons/move-right.svg'; import FormItem from '../../ConfigurationTable/FormItem/FormItem'; import NumDisplayItemsInput from './NumDisplayItemsInput'; import { defaultNumDisplayItems } from './display-items'; +import Select from '../../Select/Select'; +import { DeliveryOrderKey, MessageDeliveryOrder } from '../types'; +import { decodeConsumerSessionConfig, describeProblem } from './decode-session-config'; export type SessionConfigurationProps = { value: ManagedConsumerSessionConfigValOrRef, @@ -34,80 +37,9 @@ export type SessionConfigurationProps = { libraryBrowserPanel?: Partial }; -// `/consumer-session?id=` accepts the id of ANY persisted library item, so the value handed to this -// editor is not guaranteed to be a consumer session config - it can be a foreign item type or an -// incomplete spec. Reaching into such a spec used to throw during render and, with no error boundary -// above the route, take the whole app down with it. Hence the tolerant reads below and the shape -// check before rendering the editor. -function isConsumerSessionConfigSpec(spec: unknown): spec is ManagedConsumerSessionConfigSpec { - const isObject = (v: unknown) => typeof v === 'object' && v !== null; - - if (!isObject(spec)) { - return false; - } - - const s = spec as Partial; - - // A val-or-ref field must be the DISCRIMINATED shape, not merely an object: a persisted - // `{ startFrom: {} }` passed the object check and then sat in useManagedItemValue forever - - // neither a value to render nor a reference to resolve, so the editor showed an endless - // spinner. Malformed means broken, and broken must say so. - const isValOrRef = (v: unknown): boolean => { - if (!isObject(v)) { - return false; - } - const candidate = v as { type?: unknown; val?: unknown; ref?: unknown }; - if (candidate.type === 'value') { - return isObject(candidate.val); - } - if (candidate.type === 'reference') { - return typeof candidate.ref === 'string' && candidate.ref !== ''; - } - return false; - }; - - // Every field the RUNTIME conversion reads has to be here, not only the ones whose absence threw - // during render: a spec that renders but cannot be converted leaves the session with no runtime - // config, which used to show as a Play button that started nothing. - return Array.isArray(s.targets) - && isValOrRef(s.startFrom) - && isObject(s.messageFilterChain) - && isObject(s.coloringRuleChain) - && isObject(s.valueProjectionList) - && isObject(s.pauseTriggerChain); -} - -function detectAdvancedConfig(value: ManagedConsumerSessionConfigValOrRef): boolean { - if (value.val?.spec?.coloringRuleChain?.val?.spec?.coloringRules?.length) { - return true; - } - - if (value.val?.spec?.messageFilterChain?.val?.spec?.filters?.length) { - return true; - } - - if (value.val?.spec?.valueProjectionList?.val?.spec?.projections?.length) { - return true; - } - - if (value.val?.spec?.numDisplayItems !== undefined) { - return true; - } - - return false; -} - const SessionConfiguration: React.FC = (props) => { const [hoverRef, isHovered] = useHover(); const ref = React.useRef(null); - const [isShowAdvanced, setIsShowAdvanced] = useState(detectAdvancedConfig(props.value)); - const isAdvancedConfig = detectAdvancedConfig(props.value); - - useEffect(() => { - if (isAdvancedConfig && !isShowAdvanced) { - setIsShowAdvanced(true); - } - }, [isAdvancedConfig, isShowAdvanced]); const resolveResult = useManagedItemValue(props.value); @@ -126,9 +58,15 @@ const SessionConfiguration: React.FC = (props) => { } const item = resolveResult.value; - const itemSpec = item?.spec; - if (!isConsumerSessionConfigSpec(itemSpec)) { + // `/consumer-session?id=` accepts the id of ANY persisted library item, so what arrives here is + // not guaranteed to be a consumer session config - it can be a foreign item type, a spec written + // by another build, or one that was hand-edited. Reaching into such a spec used to throw during + // render and, with no error boundary above the route, take the whole app down with it. So the + // whole document is decoded before anything is dereferenced, and a failure says WHERE. + const decoded = decodeConsumerSessionConfig(item); + + if (!decoded.ok) { return (
= (props) => { {item?.metadata?.type === undefined ? '' : ` (type: ${item.metadata.type})`}  is not a valid Consumer Session configuration.
+ {describeProblem(decoded.problem)} +
Open a Consumer Session configuration item, or start a new session instead.
)} @@ -147,6 +87,28 @@ const SessionConfiguration: React.FC = (props) => { ); } + const itemSpec = decoded.item.spec; + + // A spec saved before the field existed names no order, and an absent value means the product + // default - Best effort - here exactly as it does on the wire and on the server. + const deliveryOrder = itemSpec.messageDeliveryOrder ?? 'best-effort'; + const deliveryOrderTime = itemSpec.deliveryOrderKey ?? 'publish-time'; + + const deliveryOrderHelp = deliveryOrder === 'guaranteed' + ? 'Replays everything recorded up to Play (of what retention still holds) in exact selected-timestamp ' + + 'order, then pauses when caught up; Resume replays what was recorded since. Multi-stream sessions ' + + 'require persistent topics.' + : deliveryOrder === 'best-effort' + ? 'Merges by the selected timestamp within ~0.75 s. Late messages may appear out of order; none are dropped.' + : 'Delivers each topic or partition independently, with no reordering delay.'; + + const deliveryOrderTimeHelp = deliveryOrderTime === 'broker-publish-time' + ? 'Recorded when the message reaches the broker. Requires broker timestamp entry metadata. ' + + 'If the cluster does not expose it, the session cannot start; missing values use publish time.' + : deliveryOrderTime === 'event-time' + ? 'Optional timestamp set by the application. Missing values use publish time and are reported.' + : 'Added automatically by the producer; always present.'; + const onSpecChange = (spec: ManagedConsumerSessionConfigSpec) => { const newValue: ManagedConsumerSessionConfigValOrRef = { ...props.value, val: { ...item, spec } }; props.onChange(newValue); @@ -175,10 +137,6 @@ const SessionConfiguration: React.FC = (props) => { type: 'value', val: item as ManagedConsumerSessionConfig }; - - const isAdvancedConfig = detectAdvancedConfig(newValue); - setIsShowAdvanced(isAdvancedConfig); - props.onChange(newValue); }} onSave={(item) => props.onChange({ @@ -207,61 +165,109 @@ const SessionConfiguration: React.FC = (props) => { // The start-from modes depend on what the selected topics retain, and the targets that // decide that live here. targetTopicsPersistency={targetTopicsPersistency(itemSpec.targets, props.libraryContext)} + // ...and on the delivery order: Guaranteed is an exact replay of recorded history, so + // "Latest message" (a replay of nothing) is gated in the selector, with the note. + deliveryOrder={deliveryOrder} /> - {!isAdvancedConfig && setIsShowAdvanced(v)} - label='Show advanced settings' - isReadOnly={props.isReadOnly} - />} - {isShowAdvanced && (<> - -
- + + {/* Visible, not hover-only, and ASSOCIATED - the caveat is the contract. */} +
+ {deliveryOrderHelp} +
+ {deliveryOrder !== 'as-received' && (<> + +
+ {deliveryOrderTimeHelp} +
+ )} + + + +
+ onSpecChange({ + ...itemSpec, + numDisplayItems: v ? defaultNumDisplayItems : undefined + })} + label='Limit num. display messages' + isReadOnly={props.isReadOnly} + /> +
+ onSpecChange({ ...itemSpec, numDisplayItems })} isReadOnly={props.isReadOnly} /> -
- onSpecChange({ ...itemSpec, numDisplayItems })} - isReadOnly={props.isReadOnly} - /> -
- +
+
- onSpecChange({ ...itemSpec, messageFilterChain: v })} - libraryContext={props.libraryContext} - isReadOnly={props.isReadOnly} - /> + onSpecChange({ ...itemSpec, messageFilterChain: v })} + libraryContext={props.libraryContext} + isReadOnly={props.isReadOnly} + /> - onSpecChange({ ...itemSpec, valueProjectionList: v })} - libraryContext={props.libraryContext} - isReadOnly={props.isReadOnly} - /> + onSpecChange({ ...itemSpec, valueProjectionList: v })} + libraryContext={props.libraryContext} + isReadOnly={props.isReadOnly} + /> - onSpecChange({ ...itemSpec, coloringRuleChain: v })} - libraryContext={props.libraryContext} - isReadOnly={props.isReadOnly} - /> - )} + onSpecChange({ ...itemSpec, coloringRuleChain: v })} + libraryContext={props.libraryContext} + isReadOnly={props.isReadOnly} + /> {/* = (props) => { )} 1 + ? 'Remove this Consumer Session Target' + : 'A session needs at least one target'} + // Removing the last one leaves a session with nothing to consume from. The + // server refuses that config and so does the decoder above, which would swap + // this editor for the invalid-config error with no way back. + disabled={itemSpec.targets.length === 1} onClick={() => { const newTargets = [...itemSpec.targets]; newTargets.splice(i, 1); diff --git a/ui/components/ui/ConsumerSession/SessionConfiguration/StartFromInput/ApproximateFractionInput.tsx b/ui/components/ui/ConsumerSession/SessionConfiguration/StartFromInput/ApproximateFractionInput.tsx index 0ab4c9e89..727667a98 100644 --- a/ui/components/ui/ConsumerSession/SessionConfiguration/StartFromInput/ApproximateFractionInput.tsx +++ b/ui/components/ui/ConsumerSession/SessionConfiguration/StartFromInput/ApproximateFractionInput.tsx @@ -9,11 +9,15 @@ export type ApproximateFractionInputProps = { fraction: number; onChange: (fraction: number) => void; /** - * Test-id prefix identifying WHICH mode this instance is editing, e.g. `cs-start-from-data` -> - * `cs-start-from-data-fraction`. Both modes render the same control, so one shared id would let a - * test drive one and assert the other. + * Test-id prefix identifying which mode this instance is editing. Both modes render the same + * control, so one shared id would let a test drive one and assert the other. */ testIdPrefix: string; + /** Plain-language name shared by the slider and exact percentage field. */ + accessibleName: string; + /** Labels shown below the two ends of the slider. */ + startLabel: string; + endLabel: string; disabled?: boolean; isReadOnly?: boolean; }; @@ -49,6 +53,11 @@ const ApproximateFractionInput: React.FC = (props const isInvalid = fractionFromPercent(draft) === undefined; const sliderPercent = Number(percentFromFraction(props.fraction)) || 0; + const helpId = `${props.testIdPrefix}-note`; + const endpointsId = `${props.testIdPrefix}-endpoints`; + const errorId = `${props.testIdPrefix}-fraction-error`; + const descriptionIds = `${helpId} ${endpointsId}`; + const describedBy = isInvalid ? `${descriptionIds} ${errorId}` : descriptionIds; return (
@@ -60,9 +69,16 @@ const ApproximateFractionInput: React.FC = (props max={100} step={1} value={sliderPercent} + aria-label={`${props.accessibleName} slider`} + aria-describedby={describedBy} + aria-invalid={isInvalid} disabled={props.disabled || props.isReadOnly} onChange={(e) => props.onChange(fractionFromPercent(e.target.value) ?? props.fraction)} /> +
+ 0% · {props.startLabel} + 100% · {props.endLabel} +
= (props type="number" onChange={onDraftChange} isError={isInvalid} - inputProps={{ disabled: props.disabled, min: 0, max: 100, step: 'any' }} + inputProps={{ + disabled: props.disabled, + min: 0, + max: 100, + step: 'any', + 'aria-label': `${props.accessibleName} percentage`, + 'aria-describedby': describedBy, + 'aria-invalid': isInvalid + }} placeholder="0-100" isReadOnly={props.isReadOnly} />
%
{isInvalid && ( -
+
+ {props.config !== undefined && (messageDeliveryOrder === 'best-effort' + || messageDeliveryOrder === 'guaranteed') && props.orderingActive && ( +
+
+ + {messageDeliveryOrder === 'guaranteed' ? 'Replaying history - exact order' : 'Best effort'} · {deliveryOrderTime} + + {props.orderingWaitingStreams > 0 && ( + +  · waiting for {i18n.formatLongNumber(props.orderingWaitingStreams)} topics/partitions + + )} + {/* The escape from that wait, RIGHT NEXT TO IT. The replay waits only on streams + still inside their recorded range - but a range that can no longer be delivered + (trimmed by retention, or the start position seeked past the end) holds that + wait forever and looks exactly like an empty topic; the disclosure above says + so, and this ends it in one click, without going back through the configuration + screen. Offered only for a Guaranteed session that is actually stalled, and + only when a caller can apply the change. */} + {messageDeliveryOrder === 'guaranteed' + && props.orderingWaitingStreams > 0 + && props.onDeliveryOrderChange !== undefined && ( + + )} + {props.orderKeyFallbacks > 0 && ( + +  · {i18n.formatLongNumber(props.orderKeyFallbacks)} used publish time + + )} + {/* Under guaranteed the seam chip below owns inversions - the server ticks both + counters on the same commit-phase events, so rendering both here would show + each violation twice with two different causal labels. */} + {messageDeliveryOrder !== 'guaranteed' && props.orderingLateDeliveries > 0 && ( + +  · {i18n.formatLongNumber(props.orderingLateDeliveries)} late + + )} + {/* Replay ordering violations, loudly: the replay delivered these flagged (each + row carries its own marker); this is the session-level count. The tooltip names + both causes the proto does - the chip stays short. */} + {props.replaySeamViolations > 0 && ( + +  · {i18n.formatLongNumber(props.replaySeamViolations)} out of order + + )} +
+
+ )} +
{ const testData: { @@ -125,8 +132,8 @@ describe("startFrom wire mapping", () => { // mapping: fromPb(toPb(x)) still equals x while a stored item would flip modes on load. The // asymmetric pin below ("write their own oneof field") is what actually catches that, by // checking the raw pb one direction only. - approximateDataPosition: { type: "approximateDataPosition", fraction: 0.6 }, - approximateTimePosition: { type: "approximateTimePosition", fraction: 0.6 }, + approximateEntryPosition: { type: "approximateEntryPosition", fraction: 0.6 }, + approximatePublishTimePosition: { type: "approximatePublishTimePosition", fraction: 0.6 }, messageId: { type: "messageId", hexString: "a1 b2 c3" }, // Whole seconds only: startFromToPb floors to epoch seconds, so sub-second input cannot survive. dateTime: { type: "dateTime", dateTime: new Date(1_700_000_000_000) }, @@ -162,18 +169,101 @@ describe("startFrom wire mapping", () => { // the mode of every stored item on load. Pin the WRITE side against the raw protobuf, one // direction, so such a swap in startFromToPb cannot hide behind a matching read. describe("the approximate modes write their own oneof field", () => { - it("sets the data-position case for approximateDataPosition, and only it", () => { - const pbValue = startFromToPb({ type: "approximateDataPosition", fraction: 0.6 }); - expect(pbValue.hasStartFromApproximateDataPosition()).toBe(true); - expect(pbValue.hasStartFromApproximateTimePosition()).toBe(false); - expect(pbValue.getStartFromApproximateDataPosition()!.getFraction()).toBe(0.6); + it("sets the entry-position case for approximateEntryPosition, and only it", () => { + const pbValue = startFromToPb({ type: "approximateEntryPosition", fraction: 0.6 }); + expect(pbValue.hasStartFromApproximateEntryPosition()).toBe(true); + expect(pbValue.hasStartFromApproximatePublishTimePosition()).toBe(false); + expect(pbValue.getStartFromApproximateEntryPosition()!.getFraction()).toBe(0.6); }); - it("sets the time-position case for approximateTimePosition, and only it", () => { - const pbValue = startFromToPb({ type: "approximateTimePosition", fraction: 0.6 }); - expect(pbValue.hasStartFromApproximateTimePosition()).toBe(true); - expect(pbValue.hasStartFromApproximateDataPosition()).toBe(false); - expect(pbValue.getStartFromApproximateTimePosition()!.getFraction()).toBe(0.6); + it("sets the publish-time-position case for approximatePublishTimePosition, and only it", () => { + const pbValue = startFromToPb({ type: "approximatePublishTimePosition", fraction: 0.6 }); + expect(pbValue.hasStartFromApproximatePublishTimePosition()).toBe(true); + expect(pbValue.hasStartFromApproximateEntryPosition()).toBe(false); + expect(pbValue.getStartFromApproximatePublishTimePosition()!.getFraction()).toBe(0.6); }); }); }); + +/** + * A relative start position that was SAVED, not typed. + * + * The picker refuses anything the model cannot carry, so no in-memory fixture built through the UI + * can hold a bad value - but the library can. Its `ManagedRelativeDateTimeSpec.value` is an int64 + * while the request's `RelativeDateTime.value` is an int32, and older builds wrote whatever they + * were given. So these cases are built as bytes and loaded back the way a stored item actually + * arrives, which is the only shape that can represent the defect at all. + */ +describe("a relative start position loaded from the library", () => { + const savedRelativeStartFrom = (value: number): ConsumerSessionStartFrom => { + const savedPb = managedConsumerSessionStartFromToPb({ + metadata: { type: "consumer-session-start-from", id: "sf-1", name: "Saved", descriptionMarkdown: "" }, + spec: { + startFrom: { + type: "relativeDateTime", + relativeDateTime: { + type: "value", + val: { + metadata: { type: "relative-date-time", id: "rel-1", name: "Saved", descriptionMarkdown: "" }, + spec: { unit: "hour", value: 1, isRoundedToUnitStart: false }, + }, + }, + }, + }, + }); + + // The number as stored. The field is an int64, so every one of these genuinely survives a save + // and comes back on load. + savedPb.getSpec()!.getStartFromRelativeDateTime()!.getVal()!.getSpec()!.setValue(value); + + // Bytes in, model out - exactly how a library item reaches the session. + const restored = managedPb.ManagedConsumerSessionStartFrom.deserializeBinary(savedPb.serializeBinary()); + return consumerSessionStartFromFromValOrRef({ + type: "value", + val: managedConsumerSessionStartFromFromPb(restored), + }); + }; + + it.each([ + // Subtracting a negative is an instant in the FUTURE, under a label that reads "ago". It + // serializes cleanly, so nothing downstream ever questions it. + ["a negative value", -1], + // Past int32 the generated serializer fails an internal assertion while the request is being + // written - Play dies with a message about protobuf internals, on a session the user cannot + // see anything wrong with. + ["a value the request field cannot carry", relativeDateTimeValueMax + 1], + ])("is refused with an actionable message when it is %s", (_case, value) => { + const startFrom = savedRelativeStartFrom(value); + + expect(() => startFromToPb(startFrom)).toThrow(/relative start position/i); + expect(() => startFromToPb(startFrom)).toThrow(String(value)); + }); + + it.each([ + ["zero - a position the user can ask for on purpose", 0], + ["the largest value the model carries", relativeDateTimeValueMax], + ])("still starts a session from %s", (_case, value) => { + const startFrom = savedRelativeStartFrom(value); + + const request = startFromToPb(startFrom); + expect(request.getStartFromRelativeDateTime()!.getValue()).toBe(value); + // ...and the request survives the write. A value the field cannot hold fails HERE, deep inside + // generated code, which is why it has to be refused before it gets this far. + expect(() => request.serializeBinary()).not.toThrow(); + }); + + // Fractions and NaN cannot ride an int64 field, so they cannot come back from the library - but + // the same boundary is what stands between any other producer of this value and a request that + // dies in the serializer, so it refuses them too. + it.each([ + ["a fraction", 1.5], + ["not a number", Number.NaN], + ])("refuses %s at the same boundary", (_case, value) => { + const startFrom: ConsumerSessionStartFrom = { + type: "relativeDateTime", + relativeDateTime: { unit: "hour", value, isRoundedToUnitStart: false }, + }; + + expect(() => startFromToPb(startFrom)).toThrow(/relative start position/i); + }); +}); diff --git a/ui/components/ui/ConsumerSession/conversions/conversions.ts b/ui/components/ui/ConsumerSession/conversions/conversions.ts index 5aa1285fb..777344e7a 100644 --- a/ui/components/ui/ConsumerSession/conversions/conversions.ts +++ b/ui/components/ui/ConsumerSession/conversions/conversions.ts @@ -2,6 +2,7 @@ import { Timestamp } from "google-protobuf/google/protobuf/timestamp_pb"; import * as pb from "../../../../grpc-web/tools/teal/pulsar/ui/api/v1/consumer_pb"; import { hexStringFromByteArray, hexStringToByteArray } from "../../../conversions/conversions"; import { messageIdError } from "../SessionConfiguration/StartFromInput/message-id"; +import { relativeDateTimeValueMax } from "../../RelativeDateTimePicker/relative-date-time"; import { MessageDescriptor, PartialMessageDescriptor, @@ -30,7 +31,8 @@ import { TestResult, ChainTestResult, JsMessageFilter, - ValueProjectionResult + ValueProjectionResult, + MessageDeliveryOrder } from "../types"; import { @@ -73,6 +75,7 @@ export function messageDescriptorFromPb(message: pb.Message): MessageDescriptor topic: message.getTopic()?.getValue() ?? null, sessionContextStateJson: message.getSessionContextStateJson()?.getValue() ?? null, debugStdout: message.getDebugStdout()?.getValue() ?? null, + replaySeamViolation: message.getReplaySeamViolation(), sessionTargetIndex: message.getSessionTargetIndex()?.getValue() ?? null, @@ -313,22 +316,22 @@ export function startFromFromPb(startFrom: pb.ConsumerSessionStartFrom): Consume // The two approximate modes carry the SAME payload - one double - so a branch that reached for // the other one's oneof case would still produce a valid-looking fraction; the only symptom // would be a session positioned by the wrong rule. - case pb.ConsumerSessionStartFrom.StartFromCase.START_FROM_APPROXIMATE_DATA_POSITION: { - const approximateDataPositionPb = startFrom.getStartFromApproximateDataPosition(); - if (approximateDataPositionPb === undefined) { - throw new Error('ApproximateDataPosition should be defined.'); + case pb.ConsumerSessionStartFrom.StartFromCase.START_FROM_APPROXIMATE_ENTRY_POSITION: { + const approximateEntryPositionPb = startFrom.getStartFromApproximateEntryPosition(); + if (approximateEntryPositionPb === undefined) { + throw new Error('ApproximateEntryPosition should be defined.'); } - return { type: 'approximateDataPosition', fraction: approximateDataPositionPb.getFraction() }; + return { type: 'approximateEntryPosition', fraction: approximateEntryPositionPb.getFraction() }; } - case pb.ConsumerSessionStartFrom.StartFromCase.START_FROM_APPROXIMATE_TIME_POSITION: { - const approximateTimePositionPb = startFrom.getStartFromApproximateTimePosition(); - if (approximateTimePositionPb === undefined) { - throw new Error('ApproximateTimePosition should be defined.'); + case pb.ConsumerSessionStartFrom.StartFromCase.START_FROM_APPROXIMATE_PUBLISH_TIME_POSITION: { + const approximatePublishTimePositionPb = startFrom.getStartFromApproximatePublishTimePosition(); + if (approximatePublishTimePositionPb === undefined) { + throw new Error('ApproximatePublishTimePosition should be defined.'); } - return { type: 'approximateTimePosition', fraction: approximateTimePositionPb.getFraction() }; + return { type: 'approximatePublishTimePosition', fraction: approximatePublishTimePositionPb.getFraction() }; } case pb.ConsumerSessionStartFrom.StartFromCase.START_FROM_MESSAGE_ID: { @@ -820,11 +823,11 @@ export function startFromToPb(startFrom: ConsumerSessionStartFrom): pb.ConsumerS case 'nthMessageBeforeLatest': startFromPb.setStartFromNthMessageBeforeLatest(new pb.NthMessageBeforeLatest().setN(startFrom.n)); break; - case 'approximateDataPosition': - startFromPb.setStartFromApproximateDataPosition(new pb.ApproximateDataPosition().setFraction(startFrom.fraction)); + case 'approximateEntryPosition': + startFromPb.setStartFromApproximateEntryPosition(new pb.ApproximateEntryPosition().setFraction(startFrom.fraction)); break; - case 'approximateTimePosition': - startFromPb.setStartFromApproximateTimePosition(new pb.ApproximateTimePosition().setFraction(startFrom.fraction)); + case 'approximatePublishTimePosition': + startFromPb.setStartFromApproximatePublishTimePosition(new pb.ApproximatePublishTimePosition().setFraction(startFrom.fraction)); break; case 'messageId': { // The last point where a start position of zero bytes can still be stopped. The shared hex @@ -844,14 +847,34 @@ export function startFromToPb(startFrom: ConsumerSessionStartFrom): pb.ConsumerS timestampPb.setSeconds(epochSeconds); startFromPb.setStartFromDateTime(new pb.DateTime().setDateTime(timestampPb)); break; - case 'relativeDateTime': + case 'relativeDateTime': { + // The last point where a number this start position cannot mean can still be stopped. The + // picker refuses these on the way in, but a value can also arrive from the LIBRARY, whose + // stored field is an int64 while this request field is an int32 - and older builds saved + // whatever they were handed. Left alone: + // + // - a negative is subtracted as a negative, so "n hours ago" seeks into the FUTURE, and it + // serializes cleanly, so nothing downstream ever questions it; + // - a fraction, NaN or anything past int32 fails an assertion inside the generated + // serializer, which kills Play with a message about protobuf internals. + // + // Refused here instead, where the create path turns it into a notification the user can act + // on, and where the configuration is still exactly as they saved it. + const { unit, value, isRoundedToUnitStart } = startFrom.relativeDateTime; + if (!Number.isInteger(value) || value < 0 || value > relativeDateTimeValueMax) { + throw new Error( + `Relative start position must be a whole number of ${unit}s from 0 to ${relativeDateTimeValueMax}, but it is ${value}.` + ); + } + const relativeDateTimePb = new pb.RelativeDateTime(); - relativeDateTimePb.setUnit(dateTimeUnitToPb(startFrom.relativeDateTime.unit)) - relativeDateTimePb.setValue(startFrom.relativeDateTime.value) - relativeDateTimePb.setIsRoundedToUnitStart(startFrom.relativeDateTime.isRoundedToUnitStart) + relativeDateTimePb.setUnit(dateTimeUnitToPb(unit)) + relativeDateTimePb.setValue(value) + relativeDateTimePb.setIsRoundedToUnitStart(isRoundedToUnitStart) startFromPb.setStartFromRelativeDateTime(relativeDateTimePb); break; + } default: throw new Error(`Unknown StartFrom type. ${startFrom}`); } @@ -859,6 +882,21 @@ export function startFromToPb(startFrom: ConsumerSessionStartFrom): pb.ConsumerS return startFromPb; } +/** + * The wire symbol for a delivery order. ONE mapping, deliberately: the session config sent by Play + * and the live SetDeliveryOrder switch must ask for the same thing, and two copies of this ternary + * could drift into a running session that no longer matches its own configuration. + * + * An unrecognized value maps to Best effort, which is what proto3 absence means on the server too. + */ +export function messageDeliveryOrderToPb(order: MessageDeliveryOrder | undefined): pb.MessageDeliveryOrder { + return order === 'as-received' + ? pb.MessageDeliveryOrder.MESSAGE_DELIVERY_ORDER_AS_RECEIVED + : order === 'guaranteed' + ? pb.MessageDeliveryOrder.MESSAGE_DELIVERY_ORDER_GUARANTEED + : pb.MessageDeliveryOrder.MESSAGE_DELIVERY_ORDER_BEST_EFFORT_BY_PUBLISH_TIME; +} + export function consumerSessionConfigToPb(config: ConsumerSessionConfig): pb.ConsumerSessionConfig { const startFromPb = startFromToPb(config.startFrom); const targetsPb = config.targets.map(consumerSessionTargetToPb); @@ -874,6 +912,14 @@ export function consumerSessionConfigToPb(config: ConsumerSessionConfig): pb.Con configPb.setPauseTriggerChain(pauseTriggerChainPb); configPb.setColoringRuleChain(coloringRuleChainPb); configPb.setValueProjectionList(valueProjectionListPb); + configPb.setMessageDeliveryOrder(messageDeliveryOrderToPb(config.messageDeliveryOrder)); + configPb.setDeliveryOrderKey( + config.deliveryOrderKey === 'broker-publish-time' + ? pb.DeliveryOrderKey.DELIVERY_ORDER_KEY_BROKER_PUBLISH_TIME + : config.deliveryOrderKey === 'event-time' + ? pb.DeliveryOrderKey.DELIVERY_ORDER_KEY_EVENT_TIME + : pb.DeliveryOrderKey.DELIVERY_ORDER_KEY_PUBLISH_TIME + ); return configPb; } diff --git a/ui/components/ui/ConsumerSession/testing.ts b/ui/components/ui/ConsumerSession/testing.ts index 0c0970026..0191110d8 100644 --- a/ui/components/ui/ConsumerSession/testing.ts +++ b/ui/components/ui/ConsumerSession/testing.ts @@ -36,6 +36,7 @@ export function genMessageDescriptor( sessionValueProjectionListResult: [], numMessageProcessed: 0, numMessageSent: 0, + replaySeamViolation: false, ...override, }; } @@ -73,6 +74,7 @@ export function genEmptyMessageDescriptor( sessionValueProjectionListResult: [], numMessageProcessed: 0, numMessageSent: 0, + replaySeamViolation: false, ...override, }; } diff --git a/ui/components/ui/ConsumerSession/types.ts b/ui/components/ui/ConsumerSession/types.ts index f3ebb2134..0aaed0de2 100644 --- a/ui/components/ui/ConsumerSession/types.ts +++ b/ui/components/ui/ConsumerSession/types.ts @@ -77,18 +77,12 @@ export type ConsumerSessionStartFrom = { type: "latestMessage" } | { type: "nthMessageAfterEarliest", n: number } | { type: "nthMessageBeforeLatest", n: number } | - // "About % through the data": approximately this far through the messages a topic still holds. - // 0 = earliest retained, 1 = past the latest. Resolved by entry ordinal, which Pulsar can address - // instantly at any topic size - and which is why the position is only approximate, since one entry - // holds a whole batch of messages. PER PHYSICAL TOPIC. - { type: "approximateDataPosition", fraction: number } | - // "About % through the time range": approximately this far through the time a topic still covers. - // 0 = earliest retained, 1 = the last message. The range runs from the earliest first-message - // publish time to the latest last-message publish time across a topic's partitions, so this one is - // PER LOGICAL TOPIC. The two are separate modes because they answer different questions: on a topic - // where almost everything arrived in the last hour of a month's retention, half the MESSAGES are - // inside that last hour while half the TIME is fifteen days back. - { type: "approximateTimePosition", fraction: number } | + // Approximate data position: proportional to retained entries, per physical topic. One entry can + // hold a batch, so this is deliberately not presented as an exact message percentile. + { type: "approximateEntryPosition", fraction: number } | + // Approximate publish-time position: interpolated between observed first- and final-entry publish + // times, per logical topic. All partitions of the topic use the same cutoff. + { type: "approximatePublishTimePosition", fraction: number } | { type: "messageId"; hexString: string } | { type: "dateTime"; dateTime: Date } | { @@ -96,6 +90,41 @@ export type ConsumerSessionStartFrom = relativeDateTime: RelativeDateTime }; +/** How a session reading more than one topic or partition interleaves messages. Guaranteed waits + * for every source without a timeout; Best effort uses a ~0.75 s reorder window; Fastest does not + * reorder across sources. A single source needs no ordering layer. A missing value means + * Guaranteed. + * + * `'best-effort'` is deliberately NOT named after a timestamp: which timestamp the merge compares + * is a separate choice, `DeliveryOrderKey`. The wire constant it maps to still reads + * `MESSAGE_DELIVERY_ORDER_BEST_EFFORT_BY_PUBLISH_TIME` because it predates that choice, and its + * name and value 2 are frozen for compatibility - the mapping lives in + * `conversions/conversions.ts` and `LibraryBrowser/model/user-managed-items-conversions-pb.ts`. + * These three strings are also the `